From b121adcf1a95bd0557bedda861e23d1a2b1ffcba Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 30 Jul 2026 19:30:58 +0800 Subject: [PATCH 1/3] Polish web chat presentation --- apps/web/tests/live-interactions.e2e.ts | 14 ++++-- .../live-interactions/cancel.expected.md | 1 + .../live-interactions/error-auth.expected.md | 1 + .../live-interactions/loading.expected.md | 25 ++++++++++ .../live-interactions/retry.expected.md | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/ChatView.module.css | 50 ++++++++++++------- .../src/client/chat/ChatView.tsx | 35 ++----------- .../src/client/chat/MessageItem.tsx | 16 ++++-- .../src/client/queue/QueueDock.module.css | 4 ++ .../tests/chat-branch-tails.spec.tsx | 6 ++- .../ui-conversation/tests/chat-view.spec.tsx | 1 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 3 +- .../client/ui-primitives/src/icons/index.tsx | 12 +++++ packages/client/ui-primitives/src/index.ts | 1 + .../src/markdown/JsonBlock.module.css | 46 ++++++++++++++--- .../ui-primitives/src/markdown/JsonBlock.tsx | 41 ++++++++++++--- .../client/ui-primitives/tests/icons.spec.tsx | 4 +- .../ui-primitives/tests/markdown.spec.tsx | 13 +++-- 23 files changed, 201 insertions(+), 87 deletions(-) create mode 100644 apps/web/tests/snapshots/live-interactions/loading.expected.md diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index c563638e80..1f1b5301ae 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -27,11 +27,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -// One golden per interactive end-state: what the user is left looking at -// after cancel, after a non-retryable failure (pins the FIXME(web-error-surface) -// gap as a reviewable artifact: NO error copy in the tree), and after retry -// recovery — three genuinely different terminal surfaces of one fixture. +// One golden pins the stable mid-turn loading state; the other three capture +// what the user is left looking at after cancel, after a non-retryable failure +// (pins the FIXME(web-error-surface) gap as a reviewable artifact: NO error +// copy in the tree), and after retry recovery. const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md') +const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md') const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md') const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md') const MODE = webSnapshotMode() @@ -133,6 +134,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // The marker IS the synchronization: the stream is provably parked in the // hang (prefix chunks delivered to the loop) before the stop click. await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true) + await expect(page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible()).resolves.toBe(true) + const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE) await page.getByRole('button', { name: 'Stop generating' }).click() await settled expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted') @@ -231,7 +235,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'cancel.expected.md', 'error-auth.expected.md', 'retry.expected.md', + 'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4323c94285..6d6631081b 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -11,6 +11,7 @@ - img - button "编辑": - img +- button "上下文注入" - paragraph: partial - text: 已停止 - button "复制": diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 1d78e91c73..45152f64a1 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -11,6 +11,7 @@ - img - button "编辑": - img +- button "上下文注入" - textbox "Message the agent" - button "Add attachment": - img diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md new file mode 100644 index 0000000000..6787919794 --- /dev/null +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -0,0 +1,25 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- button "上下文注入" +- paragraph: partial +- status: Deep diving... +- textbox "Message the agent" +- button "Add attachment": + - img +- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- button "Plan mode off, press to turn on": Plan off +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6a9c808342..a0da177ef2 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -11,6 +11,7 @@ - img - button "编辑": - img +- button "上下文注入" - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": - img - img diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 6ca5e5fa30..789fe222af 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 855c42b3377e80b0d8f21a418da0a591782439e1 -README.zh.md: 31cf2c7b5a9a0740c2be9079ce55d897d175a6d0 +README.md: bf24994e6d6103755e776cf5b694d16d8d6cc2fa +README.zh.md: b43c108b973b5d29f78210e33323bacc80c6148f diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 855c42b337..bf24994e6d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). +Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, text-icon context disclosures, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 31cf2c7b5a..b43c108b97 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 +会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、文本图标式上下文展开区、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 42b5384dfc..55270946ba 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -66,33 +66,45 @@ border-left: 1px solid var(--dsw-alias-border-l2); } -/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to - right with a stepped trail — flat keyframe holds, no tweening. Phase - offsets come from per-rect animation-delay (index * -250ms) set inline - by the component. */ -.turnDots { +/* Turn activity keeps the former loader's one-line footprint. A pale + brand-blue band sweeps from left to right; reduced-motion keeps it static. */ +.turnStatus { align-self: flex-start; flex: none; - display: flex; + display: inline-flex; align-items: center; - /* One message line box: the dots center inside the text line height. */ height: 26px; - /* Same pin as StateDot: ongoing blue has no alias token (business-primary - is the 500 step, not this 450). */ - color: var(--dsw-static-deepseek-450); + font: var(--dsw-font-s-strong-14); + white-space: nowrap; + background: linear-gradient( + 90deg, + var(--dsw-static-deepseek-500) 0%, + var(--dsw-static-deepseek-500) 40%, + var(--dsw-static-deepseek-200) 50%, + var(--dsw-static-deepseek-500) 60%, + var(--dsw-static-deepseek-500) 100% + ); + background-position: 100% 0; + background-size: 250% 100%; + background-clip: text; + color: transparent; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: dsh-turn-status-shimmer 1.8s linear infinite; } -.turnDotCell { - fill: currentColor; - opacity: 0.15; - animation: dsh-turn-dots-chase 1s infinite; +@keyframes dsh-turn-status-shimmer { + to { + background-position: 0 0; + } } -@keyframes dsh-turn-dots-chase { - 0%, 24.9% { opacity: 1; } - 25%, 49.9% { opacity: 0.6; } - 50%, 74.9% { opacity: 0.35; } - 75%, 100% { opacity: 0.15; } +@media (prefers-reduced-motion: reduce) { + .turnStatus { + background-position: 0 0; + background-size: 100% 100%; + animation: none; + } } .hint { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index b9e1e3351f..5747d195fe 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -178,36 +178,11 @@ const CommandRow = memo(function CommandRow({ renderSlot, node }: { ) }) -/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot - * 2px cell, same blue) chasing left to right with a stepped trail — flat - * keyframe holds, no tweening, no rotation. Phase offsets come from - * per-rect animation-delay. */ -const LOADER_CELLS = [0, 5, 10, 15] as const - -function TurnDots() { +/** Turn-level model activity label retained across first-token, tool, and streaming phases. */ +function TurnStatus() { return ( - /* The wrapper is a 26px line box (message line height) so the loader - occupies one text line and centers the dots inside it. */ - {!atBottom && (
diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a149d37337..9449791cbc 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,15 +1,17 @@ // MessageItem: the four simple node kinds — user bubble (right-aligned, with // clock + copy / branch / edit IconActions), steering (badged bubble), context -// injection and unknown-surface JSON rows. Props are frozen node slices off -// the snapshot cache; memo holds across streaming because unchanged nodes -// keep their references. +// injection as a text-icon disclosure, and unknown-surface JSON rows. Props +// are frozen node slices off the snapshot cache; memo holds across streaming +// because unchanged nodes keep their references. import { memo } from 'react' import type { ReactNode } from 'react' import type { ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconTextOutline14, JsonBlock, MessageText, +} from '@deepseek-ai/dsh-client-ui-primitives' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' @@ -95,7 +97,11 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) case 'context': return (
- + } + />
) default: diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 51d0737ee7..d224841bc5 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -49,6 +49,10 @@ border-radius: 8px; } +.row + .row { + box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1); +} + .preview, .editor { flex: 1 1 auto; diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index fc5a94af61..5b1e8a6108 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -111,7 +111,11 @@ describe('MessageItem arms', () => { const ctxView = render( , ) - expect(ctxView.getByText(/上下文注入/)).toBeTruthy() + const contextToggle = ctxView.getByRole('button', { name: '上下文注入' }) + expect(contextToggle.getAttribute('aria-expanded')).toBe('false') + expect(contextToggle.querySelector('svg')).not.toBeNull() + fireEvent.click(contextToggle) + expect(contextToggle.getAttribute('aria-expanded')).toBe('true') const unknownView = render( , ) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index c0cb4dcb78..20d6318ba0 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -318,6 +318,7 @@ describe('ChatView', () => { const view = render() expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() expect(view.getByText('cmd-r1')).toBeTruthy() + expect(view.getByRole('status').textContent).toBe('Deep diving...') }) it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b5e4b5c078..ff55730e10 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 0ef3c20f848b3d331c007911d0837f11cd72c024 -README.zh.md: af94551bfb9e12dbadcef6a96a54f9bf7ea71299 +README.md: ba315a02563596a680bc1849c07b8dec3cfdab21 +README.zh.md: c0ecfb42765a3a539fb2aa470133f86b36ca2f57 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 0ef3c20f84..ba315a0256 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ ## Markdown rendering -`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. `JsonBlock` renders a bounded JSON disclosure with design-system chevrons, `aria-expanded`, and an optional semantic collapsed-state icon. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index af94551bfb..c0ecfb4276 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -6,7 +6,8 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。`JsonBlock` 会渲染一个有界的 JSON 展开区,其中带有设计系统的 V 形箭头、`aria-expanded`,并可选配折叠态语义图标。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 + ## 终端输出 `TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。 diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 830ff4f642..b91587b904 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -685,6 +685,18 @@ export const IconChecklistOutline14 = ({ size = 14, className }: IconProps) => ( ) +/** Text document glyph for context-disclosure rows. */ +export const IconTextOutline14 = ({ size = 14, className }: IconProps) => ( + + + +) + /** ic_ds_List_Pen_outline_16 */ export const IconListPenOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index aa674f7a1a..2f9e3169dc 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -24,6 +24,7 @@ export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps } from './TerminalBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' +export type { JsonBlockProps } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' export { MessageText } from './markdown/MessageText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' diff --git a/packages/client/ui-primitives/src/markdown/JsonBlock.module.css b/packages/client/ui-primitives/src/markdown/JsonBlock.module.css index 7a967146e6..c1894c3402 100644 --- a/packages/client/ui-primitives/src/markdown/JsonBlock.module.css +++ b/packages/client/ui-primitives/src/markdown/JsonBlock.module.css @@ -3,22 +3,54 @@ } .toggle { - font-size: 12px; - line-height: 18px; + display: inline-flex; + align-items: center; + min-height: 24px; + font-size: 14px; + line-height: 24px; color: var(--dsw-alias-label-secondary); - padding: 2px 6px; + padding: 0; border: none; background: transparent; cursor: pointer; - border-radius: 6px; } -.toggle:hover { - background: var(--dsw-alias-interactive-bg-hover); +.leading { + position: relative; + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.iconIdle { + display: inline-flex; + opacity: 1; + transition: opacity 100ms ease; +} + +.chevronHover { + position: absolute; + inset: 0; + margin: auto; + opacity: 0; + transition: opacity 100ms ease; +} + +.toggle:hover .iconIdle { + opacity: 0; +} + +.toggle:hover .chevronHover { + opacity: 1; } .body { - margin: 4px 0 0; + margin: 4px 0 0 22px; padding: 8px; max-height: 200px; overflow: auto; diff --git a/packages/client/ui-primitives/src/markdown/JsonBlock.tsx b/packages/client/ui-primitives/src/markdown/JsonBlock.tsx index 4697feb247..fb0aaa17f0 100644 --- a/packages/client/ui-primitives/src/markdown/JsonBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/JsonBlock.tsx @@ -1,15 +1,28 @@ -// JsonBlock: collapsible JSON block (conversation side; independent from the RPC panel's PayloadJson to avoid cross-panel coupling). +// JsonBlock: accessible JSON disclosure row (conversation side; independent +// from the RPC panel's PayloadJson to avoid cross-panel coupling). -import { useMemo, useState } from 'react' +import { useMemo, useState, type ReactNode } from 'react' +import { IconChevronDownOutline14, IconChevronRightOutline14 } from '../icons/index.tsx' import css from './JsonBlock.module.css' const MAX_CHARS = 20_000 -export function JsonBlock({ label, payload, defaultOpen = false }: { +/** Props for the compact JSON disclosure used in conversation content. */ +export interface JsonBlockProps { label: string payload: unknown defaultOpen?: boolean -}) { + /** Semantic glyph shown while collapsed; hover previews the disclosure chevron. */ + collapsedIcon?: ReactNode +} + +/** Render a bounded, pretty-printed JSON disclosure. */ +export function JsonBlock({ + label, + payload, + defaultOpen = false, + collapsedIcon, +}: JsonBlockProps) { const [open, setOpen] = useState(defaultOpen) const body = useMemo(() => { if (!open) return '' @@ -23,10 +36,26 @@ export function JsonBlock({ label, payload, defaultOpen = false }: { } return s.length > MAX_CHARS ? `${s.slice(0, MAX_CHARS)}\n… 已截断,共 ${s.length} 字符` : s }, [open, payload]) + const leading = open + ? + : collapsedIcon === undefined + ? + : ( + <> + {collapsedIcon} + + + ) return (
- {open &&
{body}
}
diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index 536d1b774f..520d1e4eb2 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -14,8 +14,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (45 deepsuite + 14 figma extracts + the hand-authored sparkle)', () => { - expect(iconNames.length).toBe(60) + it('exports the full P-I set (45 deepsuite + 14 figma extracts + 2 hand-authored glyphs)', () => { + expect(iconNames.length).toBe(61) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index b7f665c78a..3c06400b7b 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -124,12 +124,17 @@ describe('MarkdownText', () => { }) describe('JsonBlock', () => { - it('collapsed by default; toggle reveals pretty-printed payload', () => { - render() + it('collapsed by default; accessible toggle reveals pretty-printed payload', () => { + render(T} />) + const toggle = screen.getByRole('button', { name: 'args' }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(screen.getByTestId('json-icon')).toBeDefined() expect(screen.queryByText(/"a": 1/)).toBeNull() - fireEvent.click(screen.getByRole('button', { name: /args/ })) + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(screen.getByText(/"a": 1/)).toBeDefined() - fireEvent.click(screen.getByRole('button', { name: /args/ })) + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(screen.queryByText(/"a": 1/)).toBeNull() }) From a2a79ada82ee038ed287dd1c6ef277f70559bd53 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 14:18:47 +0800 Subject: [PATCH 2/3] test(web): scope retry status assertion --- .../ui-conversation/tests/chat-view.spec.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 98d38bd185..b90a2647ab 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Profiler } from 'react' -import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render, within } from '@testing-library/react' import type { AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, @@ -254,16 +254,16 @@ describe('ChatView', () => { } as const satisfies ConversationNode const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true }) const view = render() - const disclosure = view.container.querySelector('details') - expect(disclosure?.dataset.active).toBe('true') - expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') + const disclosure = view.container.querySelector('details') as HTMLDetailsElement + expect(disclosure.dataset.active).toBe('true') + expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') act(() => { h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] }) }) - expect(view.getAllByRole('status')).toHaveLength(1) + expect(within(disclosure).getAllByRole('status')).toHaveLength(1) expect(view.container.querySelector('details')).toBe(disclosure) - expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s') + expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s') act(() => { h.set({ @@ -277,14 +277,15 @@ describe('ChatView', () => { running: false, }) }) - expect(disclosure?.dataset.active).toBeUndefined() - expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s') + expect(disclosure.dataset.active).toBeUndefined() + expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s') act(() => { h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true }) }) - expect(disclosure?.dataset.active).toBeUndefined() - expect(view.getByRole('status').textContent).toContain('重试已取消') + const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement + expect(cancelledDisclosure.dataset.active).toBeUndefined() + expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消') }) it('the expanded row Inspect pill hands the call id to inspectCall', () => { From aa121e409fcd20cfc9be29a663d75b45e39fe891 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 14:38:27 +0800 Subject: [PATCH 3/3] test(web): refresh running-turn snapshots --- apps/web/tests/live-interactions.e2e.ts | 5 ++++- .../web/tests/snapshots/live-interactions/cancel.expected.md | 1 - .../tests/snapshots/live-interactions/error-auth.expected.md | 1 - .../tests/snapshots/live-interactions/loading.expected.md | 1 - apps/web/tests/snapshots/live-interactions/retry.expected.md | 1 - apps/web/tests/snapshots/queue-actions/collapsed.expected.md | 1 + apps/web/tests/snapshots/queue-actions/editing.expected.md | 1 + apps/web/tests/snapshots/queue-actions/ui.expected.md | 1 + apps/web/tests/snapshots/steering/mid-steer.expected.md | 1 + 9 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index e235aa952f..1707a6478f 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -134,7 +134,10 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // The marker IS the synchronization: the stream is provably parked in the // hang (prefix chunks delivered to the loop) before the stop click. await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true) - await expect(page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible()).resolves.toBe(true) + await expect.poll( + () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), + { timeout: 10_000 }, + ).toBe(true) const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE) await page.getByRole('button', { name: 'Stop generating' }).click() diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 1967842743..eb3742eb34 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -11,7 +11,6 @@ - img - button "Edit": - img -- button "Context injection" - paragraph: partial - text: Stopped - button "Copy": diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 5f3cd90e63..9dcca575de 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -11,7 +11,6 @@ - img - button "Edit": - img -- button "Context injection" - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index 6c90369438..ae44167759 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -11,7 +11,6 @@ - img - button "Edit": - img -- button "Context injection" - paragraph: partial - status: Deep diving... - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index a5994d4876..4ed6a10c6f 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -11,7 +11,6 @@ - img - button "Edit": - img -- button "Context injection" - group: - status: Retried model request (1/2) · {{duration}} - button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.": diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index f09469c98c..1780e53e48 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -12,6 +12,7 @@ - button "Edit": - img - paragraph: partial +- status: Deep diving... - button "2 queued messages" - textbox "Message the agent" - button "Commands": diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index cd211013c8..1811d4d213 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -12,6 +12,7 @@ - button "Edit": - img - paragraph: partial +- status: Deep diving... - button "2 queued messages" [disabled] [expanded] - list: - listitem: diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 197e1cd622..5786d9d88f 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -12,6 +12,7 @@ - button "Edit": - img - paragraph: partial +- status: Deep diving... - list: - listitem: - text: Edited queue item diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 31c5b2b1dc..a8816f93d7 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -19,6 +19,7 @@ - img - img - text: Ask question waiting +- status: Deep diving... - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2]