From 2752eeb4e6cb2887660188f6cfaccfe29564c3cf Mon Sep 17 00:00:00 2001 From: "Carrie.H" Date: Sat, 10 Oct 2026 19:15:51 +0800 Subject: [PATCH] Refine compact artifact cards and semantic conversation feedback --- .codex/skills/ipollowork-plugin-ui/SKILL.md | 2 +- .../references/plugin-contract.md | 2 +- apps/app/src/app/index.css | 22 ++- apps/app/src/components/chat/artifact.tsx | 62 ++++---- apps/app/src/components/chat/message-list.tsx | 62 +++++--- .../conversation-streaming-result.flow.mjs | 137 +++++++++++++++++- evals/flows/shared-ui-core.flow.mjs | 3 +- evals/support/shared-ui-core-fixture.mjs | 4 +- .../conversation-streaming-result.md | 20 +++ evals/voiceovers/shared-ui-core.md | 2 + packages/ui/README.md | 2 +- packages/ui/src/plugin/runtime.ts | 2 +- packages/ui/src/react/badge.tsx | 5 +- 13 files changed, 255 insertions(+), 70 deletions(-) diff --git a/.codex/skills/ipollowork-plugin-ui/SKILL.md b/.codex/skills/ipollowork-plugin-ui/SKILL.md index 1aed07730..0f0df8924 100644 --- a/.codex/skills/ipollowork-plugin-ui/SKILL.md +++ b/.codex/skills/ipollowork-plugin-ui/SKILL.md @@ -20,7 +20,7 @@ description: 为 iPolloWork 新插件或插件工作区复用 UI 组件、颜色 - 宿主 React 使用原有 `@/components/ui/*` 入口;共享包控件入口为 `@ipollowork/ui/controls`。 - iframe 插件只从 `@ipollowork/ui/runtime-contract` 导入 `requireRuntime(1)`;实际 React/控件从返回的运行时对象取得。不要从 `plugin-runtime` 或宿主私有路径导入实现,也不要给生产插件再打包 React。 - 开发构建显式选择 `buildPluginRuntime('bundled')`,将运行时放在业务脚本之前;生产包不包含运行时,保留契约中的固定 meta 标记,由宿主注入。不能仅凭 `NODE_ENV` 推断加载方式。 -- 当前运行时版本为 `1.4.0`。使用本轮颜色与字体规范时调用 `requireRuntime(1, required, 3)`,第三参数检查最低 minor;使用公共 Icon 时调用 `requireRuntime(1, ['Button', 'Icon'], 4)`,尺寸 S/M/L 为14/16/20px,Button 前后图标直接作为 children 组合,纯图标按钮必须有 aria-label。required 列出实际使用的全部函数导出(包括子组件)。旧版本或缺组件时停止初始化并提示更新客户端。不支持完整 semver 范围、最低 patch 或 manifest UI 版本协商,也不自动下载另一份组件库。 +- 当前运行时版本为 `1.5.0`。使用本轮颜色与字体规范时调用 `requireRuntime(1, required, 3)`,第三参数检查最低 minor;使用公共 Icon 时调用 `requireRuntime(1, ['Button', 'Icon'], 4)`,尺寸 S/M/L 为14/16/20px,Button 前后图标直接作为 children 组合,纯图标按钮必须有 aria-label。required 列出实际使用的全部函数导出(包括子组件)。共享 Badge 的 info/success/warning 语义变体和前置图标组合要求 `requireRuntime(1, ['Badge', 'Icon'], 5)`。旧版本或缺组件时停止初始化并提示更新客户端。不支持完整 semver 范围、最低 patch 或 manifest UI 版本协商,也不自动下载另一份组件库。 1.2 核心扩展及 Alert/Toast 浅背景已本地整合到 Carrie,未提交、推送或发布;隔离分支仍保留。核心样板以浏览器沙箱验证,真实客户端安装验证另记在接入契约,不把两者等同。签名包上传、打包客户端和跨客户端版本升级未验收。推荐目录的 62 个条目不等于运行时已提供 62 个独立组件。 diff --git a/.codex/skills/ipollowork-plugin-ui/references/plugin-contract.md b/.codex/skills/ipollowork-plugin-ui/references/plugin-contract.md index 552bde5dc..6e2c27615 100644 --- a/.codex/skills/ipollowork-plugin-ui/references/plugin-contract.md +++ b/.codex/skills/ipollowork-plugin-ui/references/plugin-contract.md @@ -193,7 +193,7 @@ Select 保存中禁用并关联字段错误;风格提示失败恢复持久化 ## 版本兼容规则 -当前 `UI_RUNTIME_VERSION = '1.4.0'`(1.4新增公共Icon;1.3规范保持兼容);独立于客户端与插件版本。1.3增加公共颜色/字体变量、保证七档字号类生成并增加最低minor检查;保留原v1组件入口与Props。短片包0.1.3采用新规范,插件ID和数据路径不变。生产业务脚本不包含组件实现;旧体积统计属于1.2快照,不当作本轮产物实测。 +当前 `UI_RUNTIME_VERSION = '1.5.0'`(1.5新增Badge的info/success/warning语义变体,使用时调用 `requireRuntime(1, ['Badge', 'Icon'], 5)`;1.4新增公共Icon;1.3规范保持兼容);独立于客户端与插件版本。1.3增加公共颜色/字体变量、保证七档字号类生成并增加最低minor检查;保留原v1组件入口与Props。短片包0.1.3采用新规范,插件ID和数据路径不变。生产业务脚本不包含组件实现;旧体积统计属于1.2快照,不当作本轮产物实测。 | 运行时情况 | 当前代码行为 | 插件要求 | | --- | --- | --- | diff --git a/apps/app/src/app/index.css b/apps/app/src/app/index.css index c478503f0..7b4377be5 100644 --- a/apps/app/src/app/index.css +++ b/apps/app/src/app/index.css @@ -789,13 +789,27 @@ html:lang(zh).ipollowork-electron.ipollowork-platform-mac .media-composer-contro .chat-output-title code { font:inherit; background:transparent; padding:0; } /* File list actions stay inside the same card at every panel width. */ -[data-testid="artifact-file-shell"] > .chat-output-card { width:100%; } +.artifact-file-shell { --ring:var(--primary); display:flex; align-items:center; gap:8px; min-width:0; width:min(100%,360px); min-height:56px; padding:8px 10px; border:1px solid var(--border); border-radius:10px; background:var(--background); box-shadow:none; } +.artifact-file-shell:has(> .chat-output-card:not(:disabled):hover) { background:var(--background); border-color:var(--primary); } +.artifact-file-shell:has(> .chat-output-card:not(:disabled):active) { background:color-mix(in srgb,var(--muted) 65%,var(--background)); } +.artifact-file-shell:has(> .chat-output-card:focus-visible) { outline:2px solid var(--ring); outline-offset:2px; } +.artifact-file-shell > .chat-output-card { flex:1; width:auto; min-width:0; min-height:36px; padding:0; border:0; border-radius:0; background:transparent; } +.artifact-file-shell > .chat-output-card:hover, .artifact-file-shell > .chat-output-card:active { background:transparent; } +.artifact-file-shell > .chat-output-card:focus-visible { box-shadow:none; } +.artifact-file-shell .chat-output-title { display:block; width:100%; white-space:nowrap; font-size:var(--ui-control-size); line-height:var(--ui-control-line); } +.artifact-file-shell .chat-output-icon:not([data-artifact-thumbnail]) { width:36px; height:36px; overflow:visible; border-radius:0; background:transparent; } +.artifact-file-shell .artifact-file-document { width:26px; height:30px; stroke-width:1.3; } +.artifact-file-shell .chat-output-icon[data-artifact-thumbnail] { width:52px; height:36px; } +.artifact-file-extension { position:absolute; right:0; bottom:2px; max-width:44px; overflow:hidden; padding:1px 3px; border-radius:3px; background:var(--muted); color:var(--muted-foreground); font-size:9px; font-weight:600; line-height:12px; } +.artifact-file-meta { display:flex; min-width:0; white-space:nowrap; font-size:var(--ui-meta-size); line-height:var(--ui-meta-line); color:var(--muted-foreground); } +.artifact-file-details { min-width:0; overflow:hidden; text-overflow:ellipsis; } +.artifact-file-saved { flex-shrink:0; white-space:pre; } +.artifact-file-meta .chat-output-description { display:inline; font:inherit; } .chat-output-icon svg { color:var(--muted-foreground); stroke-width:1.8; } -[data-testid="artifact-file-actions"] { right:10px; } -[data-testid="artifact-file-actions"] button { box-shadow:none; } +[data-testid="artifact-file-actions"] button:not(:disabled):active { background:var(--muted); color:var(--foreground); } /* File cards use the same responsive rows in Markdown and output lists. */ -.chat-output-grid { display:grid; min-width:0; grid-template-columns:repeat(auto-fill,minmax(min(100%,16rem),1fr)); gap:12px; } +.chat-output-grid { display:grid; min-width:0; grid-template-columns:repeat(auto-fit,minmax(min(100%,16rem),1fr)); gap:12px; } .chat-output-grid .chat-output-card { display:flex; width:100%; min-width:0; } .chat-output-card { padding-top:7px; padding-bottom:7px; } diff --git a/apps/app/src/components/chat/artifact.tsx b/apps/app/src/components/chat/artifact.tsx index c50e71d8a..31cd355f7 100644 --- a/apps/app/src/components/chat/artifact.tsx +++ b/apps/app/src/components/chat/artifact.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource react */ import type { UIMessage } from "ai"; -import { ChevronRight, Copy, Download, FileOutput, Folder, FolderOpen, Loader2, MessageSquarePlusIcon, MoreHorizontalIcon, RefreshCw, Search, X } from "lucide-react"; +import { ChevronRight, Copy, Download, FileOutput, FileText, Folder, FolderOpen, Loader2, MessageSquarePlusIcon, MoreHorizontalIcon, RefreshCw, Search, X } from "lucide-react"; import { useCallback, useMemo, useRef, useState } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog"; @@ -44,7 +44,7 @@ import { toast } from "@/components/ui/sonner"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { cn } from "@/lib/utils"; +import { cn, formatFileSize } from "@/lib/utils"; import { type ArtifactInteractionContext, type ArtifactItem, @@ -295,9 +295,10 @@ function ArtifactButton({ artifact, displayName, client, workspaceId, sessionId, ? artifact : { ...artifact, name: presentedName, target: { ...artifact.target, name: presentedName } }; const title = presentedName; - const typeLabel = getArtifactTypeLabel(studioTarget?.surface === "video" ? "video" : artifact.type); + const typeLabel = getArtifactTypeLabel(artifact.type); const extension = artifact.name.includes(".") ? artifact.name.slice(artifact.name.lastIndexOf(".") + 1).toUpperCase() : typeLabel; const canDownload = Boolean(client && workspaceId && artifact.target.kind === "file"); + const hasThumbnail = /\.(png|jpe?g|webp|gif|avif|svg|mp4|mov|webm)$/i.test(artifact.path); const download = async () => { if (!client || !workspaceId || artifact.target.kind !== "file" || downloading) return; @@ -325,33 +326,33 @@ function ArtifactButton({ artifact, displayName, client, workspaceId, sessionId, const content = ( <> - - + + {hasThumbnail ? : ( + <> +
{title}
{( - - {extension} + + + {typeLabel} + {artifact.target.size !== undefined ? · {formatFileSize(artifact.target.size)} : null} + + {artifact.target.exists === true ? · {t("artifact.status_saved")} : null} )}
); - if (!canActivate && !(client && workspaceId && sessionId && artifact.target.kind === "file")) { - return ( -
- {content} -
- ); - } - return ( -
+
{( -
- {canDownload ? ( - - ) : null} +
+ {tone === "assistant" ? ( - {statusLabel} + + ) : null} {title} {tone === "assistant" && artifact ? ( @@ -1144,27 +1160,23 @@ export function RunIssueNotice({ detail, kind, onDismiss, children }: { : t("session.run_failed_hint") return ( -
-
- {interrupted ? - : } -
-

{title}

-

{description}

- {children ?
{children}
: null} - {detail ?
- {t("session.error_details")} -
-
{detail}
- -
-
: null} -
- {onDismiss ? : null} -
-
+ + {interrupted ? : } + {title} + +

{description}

+ {children ?
{children}
: null} + {detail ?
+ {t("session.error_details")} +
+
{detail}
+ +
+
: null} +
+
) } @@ -1673,6 +1685,10 @@ export function MessageList({ messages, status, retryStatus, templateEntryPath, const error = deliveryError ?? activityError; const latestUserIndex = messages.findLastIndex(message => message.role === "user" && !isInternalContinuationMessage(message)) const latestSessionErrorMessage = messages.slice(latestUserIndex + 1).findLast(isSessionErrorMessage) + const currentTurnHasStoppedImage = messages.slice(latestUserIndex + 1).some(message => + message.role === "assistant" && stoppedImageMessageIds.has(message.id) + && message.parts.some(part => isFileUIPart(part) && getFileMediaType(part).startsWith("image/") && getFileUrl(part).length > 0), + ) const latestErrorTargetId = latestTurnAssistantMessageId ?? latestSessionErrorMessage?.id const liveActionLabel = isStreaming ? getActiveToolLabel(collectToolParts(messages)) @@ -1756,7 +1772,7 @@ export function MessageList({ messages, status, retryStatus, templateEntryPath, : null} {retryStatus ? : null} {error && !latestErrorTargetId ? : null} - {stopAcknowledged && !error && !latestSessionErrorMessage ? : null} + {stopAcknowledged && !error && !latestSessionErrorMessage && !currentTurnHasStoppedImage ? : null}
) diff --git a/evals/flows/conversation-streaming-result.flow.mjs b/evals/flows/conversation-streaming-result.flow.mjs index dc005b958..51461bc3d 100644 --- a/evals/flows/conversation-streaming-result.flow.mjs +++ b/evals/flows/conversation-streaming-result.flow.mjs @@ -2,6 +2,11 @@ import { loadVoiceoverParagraphs } from "../runner/voiceover.mjs"; const vo = await loadVoiceoverParagraphs("conversation-streaming-result"); +async function assertImageBadge(ctx, variant, icon, processing = false) { + const state = await ctx.eval(`(() => {const e=document.querySelector('[data-testid=assistant-image-status]'),i=e?.querySelector('svg');return {variant:e?.dataset.variant,icon:i?.classList.contains(${JSON.stringify(icon)}),animated:i?.classList.contains('animate-spin'),slot:e?.dataset.slot,font:e?getComputedStyle(e).fontSize:null,line:e?getComputedStyle(e).lineHeight:null};})()`); + ctx.assert(state.slot === 'badge' && state.variant === variant && state.icon && state.animated === processing && state.font === '10px' && state.line === '14px', JSON.stringify(state)); +} + export async function mountFixture() { window.__streamingAnswerProof?.cleanup?.(); document.querySelectorAll("#streaming-answer-proof").forEach((node) => node.remove()); @@ -37,7 +42,7 @@ export async function mountFixture() { const root = ReactDOM.createRoot(host); const queryClient = new QueryClient(); const imageUrl = `data:image/svg+xml,${encodeURIComponent('')}`; - const openTargets = ["design/proof/report.pdf", "design/proof/cover.png"].map((path) => createWorkspaceFileOpenTarget({ path })); + const openTargets = ["design/proof/report.pdf", "design/proof/cover.png", "design/proof/正反馈与负反馈为什么正不一定好完整说明与参考资料内容说明和图示示例与应用场景以及复盘检查清单和附加说明报告.pdf"].map((path) => createWorkspaceFileOpenTarget({ path, size: 122880 })); const thumbnailImage = new Image(); thumbnailImage.src = imageUrl; await thumbnailImage.decode(); @@ -51,6 +56,8 @@ export async function mountFixture() { downloadWorkspaceThumbnail: async () => ({ data: new Uint8Array(await thumbnailBlob.arrayBuffer()), detail: "420 × 960" }), baseUrl: "http://127.0.0.1:52999", downloadWorkspaceFile: async (_workspaceId, path) => { window.__streamingAnswerProof.downloadedPath = path; + window.__streamingAnswerProof.downloadRequests=(window.__streamingAnswerProof.downloadRequests||0)+1; + await window.__streamingAnswerProof.downloadGate; return { data: new Uint8Array([137, 80, 78, 71]), contentType: "image/png" }; } }; const commentary = { @@ -185,6 +192,14 @@ export async function mountFixture() { ]); setStatus("ready"); }; + window.__streamingAnswerProof.showReferenceCard = () => { + setArtifactFiles(["design/proof/正反馈与负反馈为什么正不一定好完整说明与参考资料内容说明和图示示例与应用场景以及复盘检查清单和附加说明报告.pdf"]); + setMessages([ + { id: "reference-file-user", role: "user", parts: [{ type: "text", text: "请生成报告" }] }, + { id: "reference-file-answer", role: "assistant", parts: [{ type: "text", text: "报告已保存。", state: "done" }] }, + ]); + setStatus("ready"); setRunOutcome("completed"); setFinalizing(false); + }; window.__streamingAnswerProof.showImage = () => { setRunOutcome("running"); setRunEndedAt(null); @@ -233,6 +248,14 @@ export async function mountFixture() { setStopAcknowledged(true); setStatus("ready"); }; + window.__streamingAnswerProof.showStoppedText = () => { + window.__streamingAnswerProof.showImage(); + window.__streamingAnswerProof.stopImage(); + setMessages(previous => [...previous, + { id: "proof-text-stop-request", role: "user", parts: [{ type: "text", text: "请继续写报告" }] }, + { id: "proof-text-stop-answer", role: "assistant", parts: [{ type: "text", text: "已完成部分报告。", state: "done" }] }, + ]); + }; window.__streamingAnswerProof.failImage = () => { setRunOutcome("failed"); setRunEndedAt(Date.now()); @@ -495,6 +518,7 @@ export default { return { preview: host.querySelector('[data-testid=assistant-image-status]')?.textContent, userHeight: host.querySelector('img[alt="reference.svg"]')?.parentElement?.style.maxHeight }; })()`); + await assertImageBadge(ctx, "info", "lucide-loader-circle", true); ctx.assert(state.preview?.includes("仍在生成") && state.userHeight === "160px", JSON.stringify(state)); }, screenshot: { name: "streaming-image-preview", requireText: ["仍在生成"] }, @@ -516,6 +540,7 @@ export default { open: [...host.querySelectorAll('button')].some((button) => button.textContent === '打开图片'), download: [...host.querySelectorAll('button')].some((button) => button.textContent === '下载图片') }; })()`); + await assertImageBadge(ctx, "info", "lucide-loader-circle", true); ctx.assert(state.status?.includes("图片已保存") && state.open && state.download, JSON.stringify(state)); }, screenshot: { name: "saved-image-still-streaming", requireText: ["图片已保存", "正在整理结果", "打开图片"] }, @@ -621,14 +646,15 @@ export default { const state = await ctx.eval(`(() => { const host = document.querySelector('#streaming-answer-proof'); return { status: host.querySelector('[data-testid=assistant-image-status]')?.textContent, - notice: host.querySelector('[data-testid=run-issue-notice]')?.textContent, + noticeCount: host.querySelectorAll('[data-testid=run-issue-notice]').length, duration: host.querySelector('[data-testid=assistant-process-column]')?.textContent, image: Boolean(host.querySelector('img[alt="cover.png"]')) }; })()`); - ctx.assert(state.status?.includes("图片可能未完成") && state.notice?.includes("任务已中断") + await assertImageBadge(ctx, "warning", "lucide-circle-pause"); + ctx.assert(state.status?.includes("图片可能未完成") && state.noticeCount === 0 && state.duration?.includes("已停止 · 用时") && state.image, JSON.stringify(state)); }, - screenshot: { name: "stopped-image", requireText: ["已停止", "图片可能未完成", "任务已中断"] }, + screenshot: { name: "stopped-image", requireText: ["已停止", "图片可能未完成"] }, }), }, { @@ -649,6 +675,7 @@ export default { duration: host.querySelector('[data-testid=assistant-process-column]')?.textContent, image: Boolean(host.querySelector('img[alt="cover.png"]')) }; })()`); + await assertImageBadge(ctx, "destructive", "lucide-circle-alert"); ctx.assert(state.status?.includes("图片可能未完成") && state.duration?.includes("未完成 · 用时") && state.image, JSON.stringify(state)); }, @@ -671,6 +698,7 @@ export default { preview: Boolean(host.querySelector('img[alt="preview.svg"]')), card: host.querySelector('[data-testid=artifact-file-card]')?.textContent }; })()`); + await assertImageBadge(ctx, "info", "lucide-info"); ctx.assert(state.status?.includes("预览已就绪") && state.preview && state.card?.includes("cover.png"), JSON.stringify(state)); }, screenshot: { name: "unmatched-image-file", requireText: ["预览已就绪", "cover.png"] }, @@ -741,6 +769,7 @@ export default { open: host.textContent.includes('打开图片'), run: host.textContent.includes('本次任务未完成'), errorInReply: Boolean(host.querySelector('[data-assistant-result] [data-assistant-run-error]')), cards: host.querySelectorAll('[data-testid=run-issue-notice]').length }; })()`); + await assertImageBadge(ctx, "success", "lucide-circle-check"); ctx.assert(state.saved?.includes('图片已生成') && state.open && state.run && state.errorInReply && state.cards === 0, JSON.stringify(state)); }, screenshot: { name: "saved-image-after-failure", requireText: ["图片已生成", "打开图片", "本次任务未完成"] }, @@ -909,6 +938,106 @@ export default { screenshot: { name: "completed-engine-post-processing", requireText: ["正在收尾", "iPolloWork 应用正在导出 MP4"] }, }), }, + { + name: "Reference document card", + run: ctx => ctx.prove("Compact document card separates filename and saved state", { + voiceover: vo[24], + action: async () => { + await ctx.eval("window.__streamingAnswerProof.showReferenceCard()"); + await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=artifact-file-shell]')?.textContent.includes('已保存')"); + }, + assert: async () => { + const g = await ctx.eval(`(() => {const c=document.querySelector('#streaming-answer-proof [data-testid=artifact-file-shell]');const t=c.querySelector('[data-testid=artifact-file-title]');const d=c.querySelector('[data-testid=artifact-file-description]');return {height:c.getBoundingClientRect().height,width:c.getBoundingClientRect().width,titleSize:getComputedStyle(t).fontSize,titleLine:getComputedStyle(t).lineHeight,metaSize:getComputedStyle(d).fontSize,shadow:getComputedStyle(c).boxShadow,savedInside:c.querySelector('.artifact-file-saved')?.getBoundingClientRect().right<=d.getBoundingClientRect().right,text:c.textContent}})()`); + ctx.assert(g.height===56 && g.width===360 && g.titleSize==='13px' && g.titleLine==='18px' && g.metaSize==='12px' && g.shadow==='none' && g.savedInside && g.text.includes('PDF') && g.text.includes('已保存'),JSON.stringify(g)); + }, screenshot:{name:"reference-document-card",requireText:["PDF","已保存"]}, + }), + }, + { + name: "Reference media card", + run: ctx => ctx.prove("Image file keeps its compact thumbnail and saved state", { + voiceover: vo[25], + action:async()=>{await ctx.eval("window.__streamingAnswerProof.showUnmatchedImage()");await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=artifact-file-shell] .artifact-thumbnail')?.naturalWidth>0");}, + assert:async()=>{const g=await ctx.eval(`(()=>{const c=document.querySelector('#streaming-answer-proof [data-testid=artifact-file-shell]');const i=c.querySelector('.artifact-thumbnail').getBoundingClientRect();return {w:i.width,h:i.height,text:c.textContent}})()`);ctx.assert(g.w===52&&g.h===36&&g.text.includes('已保存'),JSON.stringify(g));}, + screenshot:{name:"reference-image-thumbnail",requireText:["已保存"]}, + }), + }, + { + name: "Reference narrow long filename", + run: ctx => ctx.prove("Long filenames stay on one line with separate actions in narrow layouts", { + voiceover:vo[26], + action:async()=>{await ctx.client.send('Page.bringToFront');await ctx.client.send('Emulation.setDeviceMetricsOverride',{width:620,height:900,deviceScaleFactor:1,mobile:false});await ctx.eval("window.__streamingAnswerProof.showReferenceCard()");await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=artifact-file-title]')?.textContent.includes('正反馈')");}, + assert:async()=>{const g=await ctx.eval(`(()=>{const c=document.querySelector('#streaming-answer-proof [data-testid=artifact-file-shell]');const t=c.querySelector('[data-testid=artifact-file-title]');const a=c.querySelector('[data-testid=artifact-file-actions]');const tr=t.getBoundingClientRect(),ar=a.getBoundingClientRect(),cr=c.getBoundingClientRect(),s=getComputedStyle(t);return {line:s.whiteSpace,ellipsis:s.textOverflow,clipped:t.scrollWidth>t.clientWidth,gap:ar.left-tr.right,inside:ar.right<=cr.right,visible:getComputedStyle(a).opacity,title:t.title,text:t.textContent}})()`);ctx.assert(g.line==='nowrap'&&g.ellipsis==='ellipsis'&&g.clipped&&g.gap>=0&&g.inside&&g.visible==='1'&&g.title===g.text,JSON.stringify(g));}, + screenshot:{name:"reference-narrow-filename",requireText:["PDF","已保存"]}, + }), + }, + { + name: "Reference lightweight actions", + run:ctx=>ctx.prove("Lightweight file actions remain accessible without covering the title",{ + voiceover:vo[27], + action:async()=>{await ctx.trustedClick('#streaming-answer-proof [data-testid=artifact-file-more]');await ctx.waitFor("document.querySelector('[role=menu]')!==null");}, + assert:async()=>{const g=await ctx.eval(`(()=>{const c=document.querySelector('#streaming-answer-proof [data-testid=artifact-file-shell]');return {buttons:Array.from(c.querySelectorAll('[data-testid=artifact-file-actions] button')).map(b=>({w:b.getBoundingClientRect().width,h:b.getBoundingClientRect().height,label:b.getAttribute('aria-label')})),menu:!!document.querySelector('[role=menu]')}})()`);ctx.assert(g.menu&&g.buttons.length===2&&g.buttons.every(b=>b.w===28&&b.h===28&&b.label),JSON.stringify(g));}, + screenshot:{name:"reference-file-actions",requireText:["复制文件路径"]}, + }).then(async()=>{await ctx.client.send('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape'});await ctx.client.send('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape'});await ctx.client.send('Emulation.setDeviceMetricsOverride',{width:1104,height:900,deviceScaleFactor:1,mobile:false});}), + }, + { + name:"File card hover feedback", + run:ctx=>ctx.prove("Card hover changes background and border without moving content",{ + voiceover:vo[28], + action:async()=>{ + await ctx.eval("window.__streamingAnswerProof.showReferenceCard()");await ctx.waitFor("!!document.querySelector('#streaming-answer-proof [data-testid=artifact-file-shell]')"); + await ctx.client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:1,y:1}); + await ctx.eval(`window.__cardMeasure=()=>{const e=document.querySelector('#streaming-answer-proof [data-testid=artifact-file-shell]');const r=e.getBoundingClientRect(),s=getComputedStyle(e);return {bg:s.backgroundColor,border:s.borderColor,outline:s.outlineWidth,outlineColor:s.outlineColor,primary:(()=>{const p=document.createElement('span');p.style.color='var(--primary)';e.append(p);const color=getComputedStyle(p).color;p.remove();return color})(),w:r.width,h:r.height,x:r.x,y:r.y}};window.__cardIdle=window.__cardMeasure()`); + const point=await ctx.eval(`(()=>{const e=document.querySelector('#streaming-answer-proof [data-testid=artifact-file-card]');e.scrollIntoView({block:'center'});const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`); + await ctx.client.send('Input.dispatchMouseEvent',{type:'mouseMoved',...point}); + await ctx.waitFor("window.__cardMeasure().border!==window.__cardIdle.border"); + }, + assert:async()=>{const g=await ctx.eval("({idle:window.__cardIdle,hover:window.__cardMeasure()})");ctx.assert(g.idle.bg===g.hover.bg&&g.hover.border===g.hover.primary&&g.idle.w===g.hover.w&&g.idle.h===g.hover.h,JSON.stringify(g));await ctx.eval('window.__cardHover=window.__cardMeasure()');}, + screenshot:{name:'card-hover-feedback',requireText:['已保存']}, + }), + }, + { + name:"File card press feedback", + run:ctx=>ctx.prove("Pressed card uses a stronger background and stays in place",{ + voiceover:vo[29], + action:async()=>{const point=await ctx.eval(`(()=>{const r=document.querySelector('#streaming-answer-proof [data-testid=artifact-file-card]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);await ctx.client.send('Input.dispatchMouseEvent',{type:'mousePressed',button:'left',clickCount:1,...point});await ctx.waitFor('window.__cardMeasure().bg!==window.__cardHover.bg');}, + assert:async()=>{const g=await ctx.eval('({hover:window.__cardHover,press:window.__cardMeasure()})');ctx.assert(g.hover.bg!==g.press.bg&&g.hover.w===g.press.w&&g.hover.h===g.press.h&&g.hover.x===g.press.x&&g.hover.y===g.press.y,JSON.stringify(g));}, + screenshot:{name:'card-press-feedback',requireText:['已保存']}, + }).finally(async()=>{const point=await ctx.eval(`(()=>{const r=document.querySelector('#streaming-answer-proof [data-testid=artifact-file-card]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);await ctx.client.send('Input.dispatchMouseEvent',{type:'mouseReleased',button:'left',clickCount:1,...point});}), + }, + { + name:"File card keyboard focus", + run:ctx=>ctx.prove("Keyboard focus gives the file card a visible focus ring",{ + voiceover:vo[30], + action:async()=>{await ctx.client.send('Input.dispatchMouseEvent',{type:'mouseMoved',x:1,y:1});await ctx.client.send('Input.dispatchKeyEvent',{type:'keyDown',key:'Tab',code:'Tab',windowsVirtualKeyCode:9});await ctx.client.send('Input.dispatchKeyEvent',{type:'keyUp',key:'Tab',code:'Tab',windowsVirtualKeyCode:9});await ctx.eval("document.querySelector('#streaming-answer-proof [data-testid=artifact-file-card]').focus()");await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=artifact-file-card]').matches(':focus-visible')");}, + assert:async()=>{const g=await ctx.eval('window.__cardMeasure()');ctx.assert(parseFloat(g.outline)>=2&&g.outlineColor===g.primary,JSON.stringify(g));}, + screenshot:{name:'card-keyboard-focus',requireText:['已保存']}, + }), + }, + { + name:"File card download busy", + run:ctx=>ctx.prove("Downloading disables only download and does not open the file",{ + voiceover:vo[31], + action:async()=>{await ctx.eval("window.__streamingAnswerProof.downloadBaseline=window.__streamingAnswerProof.downloadRequests||0;window.__streamingAnswerProof.openedPath=null;window.__streamingAnswerProof.downloadGate=new Promise(resolve=>window.__streamingAnswerProof.releaseDownload=resolve)");await ctx.trustedClick('#streaming-answer-proof [data-testid=artifact-file-actions] button:first-child');await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=artifact-file-actions] button:first-child')?.disabled");}, + assert:async()=>{const g=await ctx.eval(`(()=>{const c=document.querySelector('#streaming-answer-proof [data-testid=artifact-file-shell]'),b=c.querySelector('[data-testid=artifact-file-actions] button');return {busy:b.getAttribute('aria-busy'),requests:window.__streamingAnswerProof.downloadRequests-window.__streamingAnswerProof.downloadBaseline,spinner:!!b.querySelector('.animate-spin'),cardDisabled:c.querySelector('[data-testid=artifact-file-card]').disabled,opened:window.__streamingAnswerProof.openedPath,downloaded:window.__streamingAnswerProof.downloadedPath}})()`);ctx.assert(g.busy==='true'&&g.requests===1&&g.spinner&&!g.cardDisabled&&!g.opened&&g.downloaded.endsWith('.pdf'),JSON.stringify(g));}, + screenshot:{name:'card-download-busy',requireText:['已保存']}, + }).finally(async()=>{await ctx.eval('window.__streamingAnswerProof.releaseDownload();window.__streamingAnswerProof.downloadGate=undefined');await ctx.waitFor("!document.querySelector('#streaming-answer-proof [data-testid=artifact-file-actions] button:first-child')?.disabled");}), + }, + { + name: "Text interruption keeps one shared Alert despite historical image", + run: ctx => ctx.prove("An interrupted text turn retains its recovery hint in the shared warning Alert, independent of previous image status", { + voiceover: vo[32], + action: async () => { + await ctx.eval("window.__streamingAnswerProof.showStoppedText()"); + await ctx.waitFor("document.querySelector('#streaming-answer-proof [data-testid=run-issue-notice]')?.dataset.slot==='alert'"); + await ctx.eval("document.querySelector('#streaming-answer-proof [data-testid=run-issue-notice]').scrollIntoView({block:'center'})"); + }, + assert: async () => { + const state = await ctx.eval(`(()=>{const h=document.querySelector('#streaming-answer-proof'),e=h.querySelector('[data-testid=run-issue-notice]'),s=getComputedStyle(e),p=document.createElement('span');p.style.backgroundColor='var(--feedback-warning-background)';h.append(p);const bg=getComputedStyle(p).backgroundColor;p.remove();return {count:h.querySelectorAll('[data-testid=run-issue-notice]').length,shared:e.dataset.slot==='alert',icon:!!e.querySelector('svg'),semantic:s.backgroundColor===bg,border:s.borderTopWidth,text:e.textContent}})()`); + ctx.assert(state.count===1&&state.shared&&state.icon&&state.semantic&&state.border==='0px'&&state.text.includes('可以继续发送消息'),JSON.stringify(state)); + }, + screenshot:{name:'text-stop-shared-alert',requireText:['任务已中断','可以继续发送消息']}, + }), + }, { name: "Composer text matches conversation typography", run: async (ctx) => { diff --git a/evals/flows/shared-ui-core.flow.mjs b/evals/flows/shared-ui-core.flow.mjs index 58fcccdee..0d5c8cc07 100644 --- a/evals/flows/shared-ui-core.flow.mjs +++ b/evals/flows/shared-ui-core.flow.mjs @@ -21,7 +21,7 @@ async function click(parent,frame,selector) { } async function findFrame(ctx,mode) { for(let attempt=0;attempt<80;attempt++) { - for(const target of (await listTargets(ctx.cdpBaseUrl)).filter(t=>t.type==='iframe')) { + for(const target of (await listTargets(ctx.cdpBaseUrl)).filter(t=>t.type==='iframe' && t.parentId===ctx.client.targetId)) { const c=await connect(target.webSocketDebuggerUrl); if(await evaluate(c,`window.ipolloworkUi?.mode===${JSON.stringify(mode)} && Boolean(document.querySelector('#receipt')) && Boolean(document.querySelector('#name'))`).catch(()=>false))return c; c.close(); @@ -164,6 +164,7 @@ export default { const contrast=await ctx.eval(`(()=>{const e=document.querySelector('[data-feedback=${variant}]'),canvas=document.createElement('canvas'),c=canvas.getContext('2d');function luminance(color){c.fillStyle=color;c.fillRect(0,0,1,1);const s=[...c.getImageData(0,0,1,1).data].slice(0,3).map(v=>{v/=255;return v<=0.04045?v/12.92:((v+0.055)/1.055)**2.4});return s[0]*0.2126+s[1]*0.7152+s[2]*0.0722}const a=luminance(getComputedStyle(e).backgroundColor),b=luminance(getComputedStyle(e.querySelector('[data-slot=alert-description]')).color);return (Math.max(a,b)+0.05)/(Math.min(a,b)+0.05)})()`); ctx.assert(contrast>=4.5,`${mode}/${theme}/${width}/${variant}: description contrast ${contrast.toFixed(2)} >= 4.5`); } + await ctx.waitFor(`(()=>{return ['info','success','warning','destructive'].map(variant=>{const e=document.querySelector('[data-semantic-badge="'+variant+'"]'),s=getComputedStyle(e),p=document.createElement('span');p.style.backgroundColor=variant==='destructive'?'color-mix(in oklab, var(--destructive) '+(document.documentElement.dataset.theme==='dark'?20:10)+'%, transparent)':'var(--feedback-'+variant+'-background)';document.body.append(p);const expected=getComputedStyle(p).backgroundColor;p.remove();return {variant,pass:e.dataset.slot==='badge'&&s.backgroundColor===expected&&Boolean(e.querySelector('svg'))&&e.scrollWidth<=e.clientWidth&&s.fontSize==='10px'&&s.lineHeight==='14px'}}).every(b=>b.pass)})()`, {label:`${mode}/${theme}/${width}: semantic Badge backgrounds, typography and leading icons`}); for(const kind of ['default','info','success','warning','error']) { await ctx.eval(`window.ipolloworkUi.toast${kind==='default'?'':'.'+kind}('语义背景:${kind}',{duration:Infinity})`); await ctx.waitFor('Boolean(document.querySelector("[data-slot=toast-card]"))'); diff --git a/evals/support/shared-ui-core-fixture.mjs b/evals/support/shared-ui-core-fixture.mjs index fff4809eb..88221eef4 100644 --- a/evals/support/shared-ui-core-fixture.mjs +++ b/evals/support/shared-ui-core-fixture.mjs @@ -3,7 +3,7 @@ function mountLibrary() { const ui = window.ipolloworkUi; const h = ui.React.createElement; const required = ['Table','Card','Avatar','Image','Field','InputGroup','Autocomplete','Checkbox','RadioGroup','Switch','Toggle','ToggleGroup','DropdownMenu','ContextMenu','Command','AlertDialog','Sheet','Popover','HoverCard','Tooltip','Alert','Badge','Progress','Skeleton','Empty','Tabs','Accordion','Collapsible','ScrollArea','Separator','DescriptiveButton','Button','Icon']; - if (Number(ui.version.split('.')[1]) < 4) throw Error('请更新客户端 UI 运行时'); + if (Number(ui.version.split('.')[1]) < 5) throw Error('请更新客户端 UI 运行时'); if (required.some(name => typeof ui[name] !== 'function')) throw Error('缺少核心组件'); window.__libraryRequired = required; function Demo() { @@ -60,7 +60,7 @@ function mountLibrary() { h(ui.TableBody,null,h(ui.TableRow,null,h(ui.TableCell,null,'演示项目'),h(ui.TableCell,null,h(ui.Badge,null,'待处理')))))))), sample('Avatar / Image',h(ui.Avatar,null,h(ui.AvatarImage,{src:'data:image/svg+xml,'+encodeURIComponent(''),alt:'头像'}),h(ui.AvatarFallback,null,'IP')),h(ui.Image,{alt:'图片占位',style:{height:40,width:80}})), sample('Alert / Toast',...['default','success','warning','destructive'].filter(variant=>!dismissed.includes(variant)).map(variant=>h(ui.Alert,{key:variant,variant,'data-feedback':variant,onDismiss:()=>setDismissed([...dismissed,variant]),closeLabel:'关闭'+variant},h(ui.AlertTitle,null,({default:'信息提醒',success:'连接成功',warning:'操作提醒',destructive:'连接失败'})[variant]),h(ui.AlertDescription,null,'错误留在原位置'),variant==='destructive'?h(ui.AlertAction,null,action('alert-retry','重试连接',()=>setReceipt('提醒重试已执行'))):null)),action('notify','成功提示',()=>ui.toast.success('操作成功'))), - sample('Badge / Progress / Skeleton / Empty',h(ui.Badge,null,'进行中'),h(ui.Progress,{value:40,'aria-label':'实际进度'}),h(ui.Skeleton,{style:{height:16,width:100}}),h(ui.Empty,null,h(ui.EmptyHeader,null,h(ui.EmptyTitle,null,'暂无结果'),h(ui.EmptyDescription,null,'请选择条件')))), + sample('Badge / Progress / Skeleton / Empty',...['info','success','warning','destructive'].map((variant,index)=>h(ui.Badge,{key:variant,variant,'data-semantic-badge':variant},h(ui.Icon,{name:['Info','CircleCheck','TriangleAlert','CircleX'][index],'data-icon':'inline-start'}),['处理中','已保存','已中断','失败'][index])),h(ui.Progress,{value:40,'aria-label':'实际进度'}),h(ui.Skeleton,{style:{height:16,width:100}}),h(ui.Empty,null,h(ui.EmptyHeader,null,h(ui.EmptyTitle,null,'暂无结果'),h(ui.EmptyDescription,null,'请选择条件')))), sample('Tabs / Accordion / Collapsible',h(ui.Tabs,{defaultValue:'one'},h(ui.TabsList,null,h(ui.TabsTrigger,{value:'one'},'概览'),h(ui.TabsTrigger,{value:'two',id:'tab-two'},'详情')),h(ui.TabsContent,{value:'one'},'概览内容'),h(ui.TabsContent,{value:'two'},'详情内容')),h(ui.Accordion,null,h(ui.AccordionItem,{value:'first'},h(ui.AccordionTrigger,{id:'accordion'},'展开说明'),h(ui.AccordionContent,null,'说明已展开'))),h(ui.Collapsible,null,h(ui.CollapsibleTrigger,{id:'collapse',render:h(ui.Button)},'展开详情'),h(ui.CollapsibleContent,null,'详情已展开'))), sample('ScrollArea / Separator / DescriptiveButton',h(ui.ScrollArea,{style:{height:80}},h(ui.ScrollAreaViewport,{'aria-label':'可滚动内容'},h('div',null,Array.from({length:10},(_,i)=>h('p',{key:i},'滚动内容 '+i))))),h(ui.Separator),h(ui.DescriptiveButton,{id:'descriptive',onClick:()=>setReceipt('描述按钮已执行')},h(ui.DescriptiveButtonContent,null,h(ui.DescriptiveButtonTitle,null,'继续编辑'),h(ui.DescriptiveButtonDescription,null,'标题和说明共用按钮')))), ], diff --git a/evals/voiceovers/conversation-streaming-result.md b/evals/voiceovers/conversation-streaming-result.md index b47bce393..ad9316429 100644 --- a/evals/voiceovers/conversation-streaming-result.md +++ b/evals/voiceovers/conversation-streaming-result.md @@ -47,3 +47,23 @@ 23. After the model finishes generating the video source, the process still says it is in progress while the iPolloWork app exports the MP4, instead of reporting the task as complete too early. 24. Returning to the conversation input, two typed lines and the placeholder use shared 13-pixel text with 20-pixel line spacing as ordinary conversation text. + +25. 文件卡片左对齐,宽度最多360px,使用56px紧凑横排,纸张图标配合文件类型,文件名与已保存状态分成两行。 + +26. 图片文件保留52×36px小缩略图,下面的类型与保存状态清晰可见。 + +27. 缩窄窗口后,长文件名保持单行省略,悬停查看完整名称,右侧操作始终独立占位。 + +28. 下载与更多采用轻量按钮,菜单仍能打开,操作不会遮挡文件名。 + +29. 悬停文件卡片,背景保持白色,仅边框变为 primary 青色,内容保持原位。 + +30. 按下卡片,背景进一步加深,尺寸与位置不变。 + +31. 使用键盘聚焦文件卡片,可以看到清晰的焦点环。 + +32. 下载时按钮转圈并避免重复点击,文件卡片仍然可以打开。 + +文件卡片的键盘焦点使用 primary 青色。图片状态复用共享 Badge 的语义色与前置图标,只有仍在处理时显示动画;已保存文件与整个任务的执行结果分开表达。 + +33. 中断图片任务时,图片的共享 Badge 已表达停止状态,不再额外出现相同任务提示;中断文字任务仍显示一条共享 Warning Alert,保留继续发送消息的说明,历史图片状态不影响当前文字任务。 diff --git a/evals/voiceovers/shared-ui-core.md b/evals/voiceovers/shared-ui-core.md index c35f1922d..57bf7d95d 100644 --- a/evals/voiceovers/shared-ui-core.md +++ b/evals/voiceovers/shared-ui-core.md @@ -15,3 +15,5 @@ 6. 信息、成功、警告和错误提醒使用对应的语义浅背景,没有边框。切换亮暗主题后仍可区分;关闭和重试可操作,开发打包与宿主注入显示一致。 7. 插件直接使用公共图标,S、M、L保持14、16、20像素。前置、后置和纯图标按钮共用Button,28、32、36像素下仍对齐;点击、键盘、禁用和加载反馈在亮暗窄窗口及两种接入方式中一致。 + +共享 Badge 按语义色显示处理中、已保存、中断和失败,并组合前置 Lucide 图标;开发打包与生产宿主注入均验证亮暗主题及窄容器。 diff --git a/packages/ui/README.md b/packages/ui/README.md index ab83435c8..484b9e9aa 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -92,7 +92,7 @@ See `.codex/skills/ipollowork-plugin-ui/references/plugin-contract.md` for the canonical v1 public-entry, build/injection and major-version contract, including current limitations, and `evals/flows/shared-ui-runtime.flow.mjs` for proof. `plugin-runtime` is an implementation entrypoint, not the production -plugin business-script API. Runtime `1.4.0` is distinct from client/plugin +plugin business-script API. Runtime `1.5.0` is distinct from client/plugin package versions; `requireRuntime(1, required, 3)` enforces minor >= 3 in major 1. Older two-argument callers remain compatible. There is no semver range negotiation. The built-in production plugin was installed in a dedicated Electron dev diff --git a/packages/ui/src/plugin/runtime.ts b/packages/ui/src/plugin/runtime.ts index ef768d25a..033b7673c 100644 --- a/packages/ui/src/plugin/runtime.ts +++ b/packages/ui/src/plugin/runtime.ts @@ -5,7 +5,7 @@ import { buttonVariants, inputClassName, textareaClassName, cn } from '../common import { requireRuntime } from './runtime-contract'; export { requireRuntime } from './runtime-contract'; -export const UI_RUNTIME_VERSION = '1.4.0'; +export const UI_RUNTIME_VERSION = '1.5.0'; export type UiRuntime = ReturnType; declare global { interface Window { ipolloworkUi?: UiRuntime } } diff --git a/packages/ui/src/react/badge.tsx b/packages/ui/src/react/badge.tsx index ca9c5cba5..559a0ab19 100644 --- a/packages/ui/src/react/badge.tsx +++ b/packages/ui/src/react/badge.tsx @@ -5,13 +5,16 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "../common/control-styles" const badgeVariants = cva( - "group/badge inline-flex min-h-5 w-fit shrink-0 items-center justify-center gap-1 rounded-[6px] border border-transparent px-1.5 py-0.5 text-ui-micro font-semibold whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + "group/badge inline-flex min-h-5 w-fit shrink-0 items-center justify-center gap-1 rounded-[6px] border border-transparent px-1.5 py-0.5 text-[length:var(--ui-micro-size)] leading-[var(--ui-micro-line)] font-semibold whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", { variants: { variant: { default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + success: "bg-feedback-success text-green-11", + warning: "bg-feedback-warning text-amber-11", + info: "bg-feedback-info text-sky-11", destructive: "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", outline: