From 751b970997fe340340ae9a2176db2c46da159cc2 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 30 Jul 2026 12:25:48 +0800 Subject: [PATCH 01/26] fix(web): align queue panel with responsive composer --- apps/web/tests/queue-actions.e2e.ts | 20 +++++++++++++++++++ .../src/client/queue/QueueDock.module.css | 17 +++++++++++++--- .../src/client/queue/QueueDock.tsx | 2 +- .../skeleton/ConversationRoot.module.css | 5 +++++ .../src/client/skeleton/HeroShell.module.css | 4 ++-- .../src/client/skeleton/InputBar.module.css | 9 +++++---- .../src/client/skeleton/TodoPanel.module.css | 18 +++++++++++++---- 7 files changed, 61 insertions(+), 14 deletions(-) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index be5e73f9a6..19a5005e50 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -85,6 +85,26 @@ describe('web e2e: queue row actions', () => { { timeout: 10_000 }, ).toBe(2) + await page.setViewportSize({ width: 640, height: 1000 }) + const queueBox = await page.locator('[data-queue-dock]').boundingBox() + const composerBox = await page.locator('[data-composer-card]').boundingBox() + expect(queueBox).not.toBeNull() + expect(composerBox).not.toBeNull() + expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x) + expect(queueBox!.x + queueBox!.width) + .toBeLessThanOrEqual(composerBox!.x + composerBox!.width) + const queueLeftInset = queueBox!.x - composerBox!.x + const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width + const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => { + const style = getComputedStyle(element) + return { + dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')), + } + }) + expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1) + expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1) + await page.setViewportSize({ width: 1680, height: 1000 }) + const editRow = page.getByText(EDIT, { exact: true }).locator('..') await editRow.getByRole('button', { name: '编辑排队消息' }).click() const editor = page.getByRole('textbox', { name: '编辑排队消息' }) 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 4c05c2cbca..5a918a3c6d 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -1,10 +1,21 @@ -/* Figma .FileContainerText 1:791: 776px wrapper around the inset 752px panel. */ +/* Figma .FileContainerText 1:791: the wrapper uses the shared dock inset + inside the composer card around the inset panel. */ .dock { box-sizing: border-box; flex: none; - width: 100%; - max-width: 776px; + width: calc( + 100% - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); + max-width: calc( + var(--dsh-composer-card-max-width) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); /* Eat InputBar's 6px top padding and tuck the panel 2px under the card; the later composer sibling paints its surface and shadow over this edge. */ margin: 0 auto -10px; diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 99b91f301d..b55ef8b007 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -61,7 +61,7 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { } return ( -
+
    {queue.map(row => ( diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 272bbae873..a3ba4b15da 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -131,6 +131,11 @@ .composerStack { display: flex; flex-direction: column; + /* InputBar and dock registrants derive their horizontal geometry from the + same card width, outer clearance, and dock inset. */ + --dsh-composer-card-max-width: 800px; + --dsh-composer-side-clearance: 32px; + --dsh-composer-dock-inset: 12px; } /* Common seat for the composer chain (fallback + elected overlay siblings). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 71b90bfdfa..523f640c0b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -11,7 +11,7 @@ padding: 0 24px; } -/* Cap matches InputBar card width (800). Glow may paint past the sides. */ +/* Cap matches the InputBar card. Glow may paint past the sides. */ .stack { display: flex; flex-direction: column; @@ -19,7 +19,7 @@ /* figma 75:8208: 12 between title block / workspace / card. */ gap: 12px; width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); overflow: visible; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 8837752830..401bbb73d8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -23,7 +23,7 @@ /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by the chat scroller. Top 6 is the gap under the dock todo strip (12px todo margin + 6px here); error/status strips still carry their own margin. */ - padding: 6px 32px 12px; + padding: 6px var(--dsh-composer-side-clearance) 12px; } .hero { @@ -33,7 +33,7 @@ .error, .status { width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); margin-bottom: 6px; padding: 4px 8px; border-radius: 8px; @@ -48,7 +48,7 @@ .notice { width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); margin-bottom: 6px; padding: 4px 8px; border-radius: 8px; @@ -69,6 +69,7 @@ } .card { + box-sizing: border-box; position: relative; /* overlay anchor positioning context */ display: flex; flex-direction: column; @@ -76,7 +77,7 @@ top pad on the card before .InputText. */ gap: 12px; width: 100%; - max-width: 800px; + max-width: var(--dsh-composer-card-max-width); padding-top: 10px; /* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says the input border is one notch weaker than buttons) — exactly the diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 7b506b5553..94abf3871a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,13 +1,23 @@ /* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419): - tip surface, 14px radius, status icons + secondary item labels. Column is - calc(100% - 88px) / max 776, centered; InputBar top pad supplies the gap. */ + tip surface, 14px radius, status icons + secondary item labels. It shares + the composer card geometry and adds the dock inset on both sides. */ .root { flex: none; overflow: hidden; margin: 0 auto; - width: calc(100% - 88px); - max-width: 776px; + width: calc( + 100% - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); + max-width: calc( + var(--dsh-composer-card-max-width) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; background: var(--dsw-specific-tip); From d2582b8dc13ac8229a1ee46da8a862f1da2c201b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 16:11:59 +0800 Subject: [PATCH 02/26] feat(web): render write/edit tool output as a diff card The write/edit tools already declare card:'diff' with applied hunks on callView/resultView, but the Web client discarded it: a mutation landed on GenericToolCard and the details panel flattened the result to a
    . Add
    DiffBlock (ui-primitives), diff-card-model (the single callView/resultView
    derivation), and FileMutationRow (keyed under write and edit), and make the
    generic fallback row and the details panel diff-aware. The +/- block form,
    per-file path header, same-file gap, and footer mirror the TUI diff card;
    the chat row caps at CHAT_DIFF_MAX_LINES against the panel's full height.
    ---
     .../2026-07-30-web-diff-card.i18n.yaml        |   6 +
     .../feature/2026-07-30-web-diff-card.md       |  56 ++++
     .../feature/2026-07-30-web-diff-card.zh.md    |  56 ++++
     .../client/connection/src/client/fixture.ts   |  28 +-
     .../client/ui-conversation/README.i18n.yaml   |   4 +-
     packages/client/ui-conversation/README.md     |   2 +
     packages/client/ui-conversation/README.zh.md  |   2 +
     .../ui-conversation/src/client/apply.ts       |   6 +
     .../src/client/chat/GenericToolCard.tsx       |   7 +-
     .../src/client/chat/ToolRow.tsx               |  31 ++-
     .../src/client/contract/diff-card-model.ts    |  66 +++++
     .../src/client/skeleton/DetailsPanel.tsx      |  11 +-
     .../toolviews/file-mutation-row.module.css    | 119 +++++++++
     .../client/toolviews/file-mutation-row.tsx    |  97 +++++++
     .../ui-conversation/tests/diff-card.spec.tsx  | 248 ++++++++++++++++++
     .../client/ui-primitives/README.i18n.yaml     |   4 +-
     packages/client/ui-primitives/README.md       |   6 +-
     packages/client/ui-primitives/README.zh.md    |   6 +-
     .../ui-primitives/src/DiffBlock.module.css    | 103 ++++++++
     .../client/ui-primitives/src/DiffBlock.tsx    | 171 ++++++++++++
     packages/client/ui-primitives/src/index.ts    |   2 +
     .../ui-primitives/tests/diff-block.spec.tsx   | 162 ++++++++++++
     22 files changed, 1173 insertions(+), 20 deletions(-)
     create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
     create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-diff-card.md
     create mode 100644 .agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
     create mode 100644 packages/client/ui-conversation/src/client/contract/diff-card-model.ts
     create mode 100644 packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css
     create mode 100644 packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx
     create mode 100644 packages/client/ui-conversation/tests/diff-card.spec.tsx
     create mode 100644 packages/client/ui-primitives/src/DiffBlock.module.css
     create mode 100644 packages/client/ui-primitives/src/DiffBlock.tsx
     create mode 100644 packages/client/ui-primitives/tests/diff-block.spec.tsx
    
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
    new file mode 100644
    index 0000000000..18f2d5178b
    --- /dev/null
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml
    @@ -0,0 +1,6 @@
    +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
    +# 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 .agents/notes/implemented/feature/2026-07-30-web-diff-card.md
    +2026-07-30-web-diff-card.md: 5e43d5d29f7f4000efebc166724ec9d921d2b441
    +2026-07-30-web-diff-card.zh.md: aac577cfa8dd9e0bf5f17a729d049d207a64d374
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
    new file mode 100644
    index 0000000000..5e43d5d29f
    --- /dev/null
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md
    @@ -0,0 +1,56 @@
    +# Agent Note: Web diff card — the write/edit render intent reaches the browser
    +
    +Status: implemented
    +
    +English | [中文](2026-07-30-web-diff-card.zh.md)
    +
    +## Problem
    +
    +The `write` and `edit` tools declare `card: 'diff'` for both their call and their result ([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)): the call view carries the intended change derived from the arguments, and the result view carries the applied contextual hunks (`FileDiff[]`, computed by `packages/fs/tool-fs/src/diff.ts` and persisted in the result `meta` so replay reproduces it). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `callView`/`resultView` — and the TUI already renders it as per-file `+`/`-` blocks with a `+A -R · N file(s)` footer.
    +
    +The Web client ignored it. A write/edit call landed on `GenericToolCard`, whose row is derived from raw tool args, and the details panel flattened the result's content blocks into one `
    `. The `diffs` payload — the whole point of the result — was discarded, so a file mutation read as a one-line confirmation with no visible change.
    +
    +This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` arm: that change made the Web client a consumer of the `terminal` render intent; this one makes it a consumer of the `diff` render intent, reusing the same four-layer shape.
    +
    +## Decision
    +
    +`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
    +
    +The component's contract mirrors the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same across front ends:
    +
    +- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts distinct paths.
    +- **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side.
    +- **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends.
    +- **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable.
    +
    +Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer.
    +
    +The chat row renders the diff resident under its path-link summary, capped at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 — the same inline-output decision and the same in-flow-vs-reading-surface split recorded for the [terminal card](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention). A write/edit row is single-file, so its summary stays an openable path link AND its diff card expands; the two coexist because the card is not the path's args body.
    +
    +## Alternatives considered
    +
    +**A side-by-side (two-column) diff.** Rejected for now by the owner: it is denser but does not fit the narrow chat row, and the goal was parity with the TUI's single-column unified form. A two-column mode in the details panel is a later props change, not a redesign.
    +
    +**Git-style line-number gutters.** The `FileDiff` contract carries only `{ path, oldText, newText }` — `structuredPatch`'s hunk start lines are dropped in `diff.ts`, so no line number reaches the client. Rendering a numbered gutter needs a backend contract change (carry `oldStart`/`newStart`) and a matching TUI upgrade to stay consistent; deferred so this PR stays a pure Web consumer of the existing contract.
    +
    +**Reuse `CodeBlock`.** Rejected for the same reason the terminal card was: `CodeBlock` soft-wraps and has no per-line `+`/`-` role, no path headers, and no footer. The two share geometry and font tokens, which is the only part where one implementation is correct for both.
    +
    +## Consequences
    +
    +`DiffBlock` reads only the diff view's fields, so it stays a pure function of what the render intent carries — replay-safe like the presenters that produce the view. A UI without the diff capability still gets the bridge's generic fallback; nothing about the tool's result shape changed. No new runtime dependency: unlike the terminal card's `anser`, a diff needs no parser.
    +
    +The multi-file arm of `DiffBlock` (one card, several path headers) has no producer today: `write`/`edit` each mutate one file per call, so a real card shows one file with one or more hunks. The arm is built and tested for a future multi-file mutation tool, not for a current consumer.
    +
    +## Testing
    +
    +`packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%.
    +
    +`packages/client/ui-conversation/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section.
    +
    +The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so the built-boot snapshot pins all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file).
    +
    +## Related
    +
    +- [Web terminal card](2026-07-28-web-terminal-card.md) — the same four-layer shape for the `terminal` arm; this note reuses its inline-output decision and its head/tail cap arithmetic.
    +- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this consumes; the Web client is now a consumer of the `diff` arm too.
    +- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md) — the slot and snapshot layering the two render sites sit in.
    diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
    new file mode 100644
    index 0000000000..aac577cfa8
    --- /dev/null
    +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
    @@ -0,0 +1,56 @@
    +# Agent Note: Web diff 卡片 —— write/edit 渲染意图抵达浏览器
    +
    +Status: implemented
    +
    +[English](2026-07-30-web-diff-card.md) | 中文
    +
    +## Problem
    +
    +`write` 和 `edit` 工具为其 call 和 result 都声明了 `card: 'diff'`([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)):call view 携带从参数推导的预期改动,result view 携带已应用的上下文 hunk(`FileDiff[]`,由 `packages/fs/tool-fs/src/diff.ts` 计算,并持久化在 result `meta` 中以便回放重建)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `callView`/`resultView` 投递到 `ConversationSnapshot` —— TUI 也已将其渲染为按文件分组的 `+`/`-` 块加 `+A -R · N file(s)` 页脚。
    +
    +Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行从原始工具参数推导,详情面板把 result 的 content block 摊平进一个 `
    `。`diffs` 载荷 —— result 的全部意义 —— 被丢弃,于是一次文件改动读起来只是一行确认、看不到任何改动。
    +
    +这是把 [terminal 卡片](2026-07-28-web-terminal-card.md) 对 `diff` 这一支重做一遍:那次改动让 Web 客户端成为 `terminal` 渲染意图的消费者;这次让它成为 `diff` 渲染意图的消费者,复用同一套四层结构。
    +
    +## Decision
    +
    +`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
    +
    +组件的契约镜像 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来一致:
    +
    +- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚统计去重后的路径数。
    +- **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。
    +- **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。
    +- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。
    +
    +几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。
    +
    +chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX_LINES`(8),对应面板的 16 —— 与 [terminal 卡片](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention)记录的内联输出决策、以及流内表面对单调阅读表面的同一划分一致。write/edit 行是单文件的,所以它的摘要既是可打开的路径链接,其 diff 卡片又展开;两者共存,因为卡片不是路径的参数体。
    +
    +## Alternatives considered
    +
    +**并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。
    +
    +**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR 保持为对既有契约的纯 Web 消费。
    +
    +**复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token,那是唯一一处一个实现对两者都正确的部分。
    +
    +## Consequences
    +
    +`DiffBlock` 只读 diff view 的字段,因此它是渲染意图所携带内容的纯函数 —— 与产出该视图的 presenter 一样回放安全。没有 diff 能力的 UI 仍得到 bridge 的通用回退;工具的 result 形状没有任何改变。无新增运行时依赖:不同于 terminal 卡片的 `anser`,diff 不需要解析器。
    +
    +`DiffBlock` 的多文件支路(一张卡、多个路径头)今天没有生产者:`write`/`edit` 每次调用各改一个文件,所以真实卡片显示一个文件带一个或多个 hunk。该支路为将来的多文件改动工具而构建并测试,不是为当前消费者。
    +
    +## Testing
    +
    +`packages/client/ui-primitives/tests/diff-block.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。
    +
    +`packages/client/ui-conversation/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。
    +
    +fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 built-boot snapshot 在两个渲染点钉住全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。
    +
    +## Related
    +
    +- [Web terminal 卡片](2026-07-28-web-terminal-card.md) —— `terminal` 支路的同一套四层结构;本 note 复用其内联输出决策与头尾上限算术。
    +- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— 本改动消费的 `card` 标签词汇;Web 客户端现在也是 `diff` 支路的消费者。
    +- [Web 客户端架构](../architecture/2026-07-19-gui-web-client-architecture.md) —— 两个渲染点所处的 slot 与快照分层。
    diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
    index cb79a6b9a2..4fddf6e9c0 100644
    --- a/packages/client/connection/src/client/fixture.ts
    +++ b/packages/client/connection/src/client/fixture.ts
    @@ -232,6 +232,13 @@ function buildAlphaLog(): SessionEvent[] {
       toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
       toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
       toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
    +  // Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
    +  // `edit` so it lands on the keyed FileMutationRow (the resident diff card the
    +  // single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
    +  // the presenter reads to emit the two-hunk sample: the card draws one path
    +  // header, the first hunk, a `⋯` gap, then the second (the same-file
    +  // second-hunk arm turns 62/63 cannot reach).
    +  toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"multi","new_string":"multi"}', '已编辑')
       // Turn 64: one run_code turn with three logged sub-dispatches — the Code
       // Mode acceptance surface (parent code row + nested native-identical rows,
       // including an isError sub-call and a bash sub-call that must hit the same
    @@ -333,9 +340,26 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
             diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
           }
         case 'edit':
    -      return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
    +      // The multi-hunk sample (turn 67) is keyed on its file_path, so the two
    +      // scattered hunks share one path header and the card draws the `⋯` gap.
    +      if (str(args.file_path) === 'src/config.ts') {
    +        return {
    +          card: 'diff', title: `Edit ${str(args.file_path)}`,
    +          diffs: [
    +            { path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' },
    +            { path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' },
    +          ],
    +        }
    +      }
    +      return {
    +        card: 'diff', title: `Edit ${str(args.file_path)}`,
    +        diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }],
    +      }
         case 'write':
    -      return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
    +      return {
    +        card: 'diff', title: `Write ${str(args.file_path)}`,
    +        diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }],
    +      }
         default:
           return undefined // echo et al: the documented no-view fallback path
       }
    diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
    index 654722b589..a8ec59bb6a 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: 3973c14f2b8fe746549bb74af85a7a60a7d66aea
    -README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b
    +README.md: d3cd5cc268b60b58bb4dbb6c3b6c118084c0def8
    +README.zh.md: f3a835ed82ecbd266b9f0829acc6182209940cb5
    diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
    index 3973c14f2b..d3cd5cc268 100644
    --- a/packages/client/ui-conversation/README.md
    +++ b/packages/client/ui-conversation/README.md
    @@ -14,6 +14,8 @@ Generic tool rows classify the built-in bash, read, search, write, edit, and run
     
     A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
     
    +A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
    +
     Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
     
     The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks ·  in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
    diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
    index a6bb15c4cd..f3a835ed82 100644
    --- a/packages/client/ui-conversation/README.zh.md
    +++ b/packages/client/ui-conversation/README.zh.md
    @@ -12,6 +12,8 @@
     
     声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
     
    +声明 `diff` 渲染意图的工具调用(`write`/`edit` 工具),通过 ui-primitives 的 `DiffBlock` 内联渲染其已应用的改动,采用同一套四层结构。`contract/diff-card-model.ts` 是从 `callView`/`resultView` 对推导的唯一位置;已结算 result 的 hunk 替换 call 时 diff,对任何其他 card 标签或 generic result view(write/edit 的执行错误)它返回 null,落回通用路径。键控的 `FileMutationRow`(在 `write` 与 `edit` 下都注册)把卡片常驻在摘要之下,其路径链接仍经 host 打开文件;渲染点兜底行与详情面板同样感知 diff。行的上限是 `CHAT_DIFF_MAX_LINES`(8),面板为 16([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md))。
    +
     工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
     
     审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。
    diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
    index 7f3aeb38cc..75f8ade379 100644
    --- a/packages/client/ui-conversation/src/client/apply.ts
    +++ b/packages/client/ui-conversation/src/client/apply.ts
    @@ -19,6 +19,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
     import { ChatView } from './chat/ChatView.tsx'
     import { StatsLine } from './chat/StatsLine.tsx'
     import { bashToolviewSample } from './toolviews/bash-sample.tsx'
    +import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
     import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
     import { todoToolview } from './toolviews/todo-row.tsx'
     import { todoDockEntry } from './skeleton/TodoPanel.tsx'
    @@ -254,6 +255,11 @@ export function apply(ctx: Context): void {
       // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
       ctx.plugin(bashToolviewSample)
     
    +  // The write/edit rows ride the same seam: a file-mutation call declares the
    +  // diff render intent, so these rows stack the applied diff card under their
    +  // path-link summary (the terminal card's posture, applied to diffs).
    +  ctx.plugin(fileMutationToolview)
    +
       // The todo_write row rides the same seam (a product registration, not a sample).
       ctx.plugin(todoToolview)
     
    diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
    index ce55d84f57..8a707045e8 100644
    --- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
    +++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
    @@ -10,6 +10,7 @@ import {
       IconThinkOutline14,
     } from '@deepseek-ai/dsh-client-ui-primitives'
     import type { ToolRowOwnerProps } from '../contract/slots.ts'
    +import { diffCardModel } from '../contract/diff-card-model.ts'
     import { terminalCardModel } from '../contract/terminal-card-model.ts'
     import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
     import { ToolRow } from './ToolRow.tsx'
    @@ -29,6 +30,7 @@ const VARIANT_ICONS: Record = {
     export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
       const model = toolRowModel(toolName, block, cwd)
       const terminal = terminalCardModel(block, cwd)
    +  const diff = diffCardModel(block)
       const singleFile = model.filePath !== undefined
       return (
         
    -        : variant === 'code'
    -          ? 
    -          : 
    {text}
    )} + : diffBody !== null + ? + : variant === 'code' + ? + :
    {text}
    )}
) } diff --git a/packages/client/ui-conversation/src/client/contract/diff-card-model.ts b/packages/client/ui-conversation/src/client/contract/diff-card-model.ts new file mode 100644 index 0000000000..f02ccd5930 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/diff-card-model.ts @@ -0,0 +1,66 @@ +/** + * Pure derivation of the diff-card props from a frozen call slice: the + * `card:'diff'` render intent the write/edit tools declare arrives on the + * snapshot as `callView`/`resultView`, and this is the one place that turns + * that pair into what {@link DiffBlock} draws. Both conversation render sites + * (the chat tool row's expanded body and the details panel's Output section) + * call this, so the hunks they show are derived once. + * @module + */ +import type { DiffBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolCallBlock } from './tool-call-model.ts' + +/** + * Diff-body lines the chat row shows before collapsing the middle — half the + * primitive's own default, which the details panel keeps. A chat row is a + * summary surface inside the message flow: the flow must stay scannable across + * many calls, while the details panel is the single-call reading surface. The + * same split {@link CHAT_TERMINAL_MAX_LINES} draws for a terminal card, so the + * two card kinds cap a long body at the same place in the flow. A design + * constant of this UI's row geometry, not a deployment choice. + */ +export const CHAT_DIFF_MAX_LINES = 8 + +/** + * The {@link DiffBlock} props this derivation owns. Picked off the primitive's + * props so the two stay in step; `maxLines`/`className` belong to each render + * site. + */ +export interface DiffCardModel { + /** + * The props {@link DiffBlock} draws. Held as a nested object so a render site + * spreads exactly the primitive's own surface and can never leak a + * neighbouring field into it. + */ + card: Pick +} + +/** + * Derive the diff-card props for a tool call, or null when this call is not a + * diff card and belongs on the generic path. + * + * The result side is authoritative once the call settles: the write/edit tools + * return the applied contextual hunks there (an edit's real before/after, a + * create's whole-file diff), which replace the call-time diff derived from the + * arguments alone. While the call is still running only the call side exists, + * so a running write/edit shows its intended change. Null is the documented + * generic-card default and covers every non-diff card — including a `card` + * value this UI version does not know, which arrives over the wire and cannot + * be trusted to be one of the compiled variants — and a settled call whose + * result view is generic (how write/edit keep their execution errors on the + * generic path). + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @returns the diff-card props, or null for the generic path. + */ +export function diffCardModel(block: ToolCallBlock): DiffCardModel | null { + if (!('kind' in block)) { + // Running: the call view may carry the intended diff; the result is absent. + const call = block.callView?.card === 'diff' ? block.callView : null + return call === null ? null : { card: { diffs: call.diffs } } + } + // Settled: the result view's applied hunks replace the call-time diff. A + // window that dropped the call head leaves only the result, which still + // renders — the result view carries the whole change. + const result = block.resultView?.card === 'diff' ? block.resultView : null + return result === null ? null : { card: { diffs: result.diffs } } +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 9fc5a04ff6..d09d3ea256 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -7,10 +7,11 @@ // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. -import { CodeBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { CodeBlock, DiffBlock, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' +import { diffCardModel } from '../contract/diff-card-model.ts' import { terminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolCallBlock } from '../contract/tool-call-model.ts' import css from './DetailsPanel.module.css' @@ -127,8 +128,10 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo * The Output section's body for the selected call. A terminal-card call — a * shell command's call/result views — renders through the shared TerminalBlock * at the primitive's own full height allowance, so column-aligned output keeps - * its alignment and scrolls sideways instead of folding. Every other call, and - * a running call with no terminal card yet, keeps the flattened text form. + * its alignment and scrolls sideways instead of folding. A diff-card call — a + * write/edit's applied change — renders through the shared DiffBlock at the same + * full height. Every other call, and a running call with neither card yet, keeps + * the flattened text form. * @param props.material - the selected call's material from {@link materialFor}. * @param props.cwd - the session workspace root, resolving the terminal view's cwd. * @returns the Output section's body element. @@ -147,6 +150,8 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u ) } + const diff = diffCardModel(material.block) + if (diff !== null) return // A settled call always carries the result node the flattened form needs; // the running shape has no result to flatten. if (!('kind' in material.block)) return
运行中…
diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css new file mode 100644 index 0000000000..b87103aa3a --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css @@ -0,0 +1,119 @@ +/* File-mutation toolview: same geometry/tokens as ToolRow (figma + {Edit,Write} · path), plus the diff card the row stacks under its summary + line. Mirrors bash-sample.module.css, whose terminal card this replaces with + a diff card. */ + +/* Summary line over the diff card; the summary row keeps its own 24px height, + so the card is a column around it rather than a change to it. */ +.card { + display: flex; + flex-direction: column; +} + +/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap), + and replaces the primitive's standalone vertical margin with the flow's. */ +.diff { + margin: 4px 0 4px 22px; +} + +.root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +/* Running sweep glare — same deepsuite ShimmerText pattern as ToolRow. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-file-mutation-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-file-mutation-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +.leading { + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +/* File-tool path: same geometry as .summary; hover underline + pointer. */ +.fileLink { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + text-align: left; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.fileLink:hover { + text-decoration: underline; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx new file mode 100644 index 0000000000..0862eb4fd5 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx @@ -0,0 +1,97 @@ +// File-mutation toolview registrant: third-party posture over the keyed +// toolview hole (ctx.slots.register + ToolRowProps only — never imports the +// chat domain), registered under both `edit` and `write`. Product chrome +// matches ToolRow (figma: {Edit,Write} · {path}). +// +// A write/edit call declares the diff render intent, so this row renders the +// applied change through DiffBlock resident below its summary line — the same +// posture BashRow gives a terminal card. The row has no expand control and is +// not a details-panel target (tool rows stopped being one), so the diff body +// is resident rather than expand-gated, and the card's own copy and expand +// controls are the row's only interactions. CHAT_DIFF_MAX_LINES caps the body +// against the message flow; the details panel keeps the block's full default. +// The summary stays a path link (the file-tool interaction) that opens through +// the host. + +import type { Context } from 'cordis' +import { DiffBlock, IconEditOutline16, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../contract/diff-card-model.ts' +import { resolveToolPath, toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './file-mutation-row.module.css' + +function leadingFor(state: ToolRowState) { + switch (state) { + case 'error': return + case 'stopped': return + // Running keeps the icon — the row sweep carries the in-flight signal. + default: return + } +} + +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome, + * with the applied diff resident below it. The summary is a path link (a file + * tool's interaction) resolved against the session cwd and opened through the + * host; the card's copy and expand controls are the row's only other actions. + */ +export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps) { + const model = toolRowModel(toolName, block, cwd) + const diff = diffCardModel(block) + const status = stateStatus(model.state) + const filePath = model.filePath + return ( +
+
+ {leadingFor(model.state)} + {status !== null && {status}} + {model.title} + + {filePath !== undefined ? ( + + ) : ( + {model.summary} + )} +
+ {diff !== null && ( + + )} +
+ ) +} + +/** + * The file-mutation rows as a plain registrant plugin. `inject` carries the + * load-order seam: requiring the conversation service guarantees the chat entry + * (and with it the 'conversation.chat.toolview' declaration) is registered — + * ui-conversation's apply mounts the service after the chat entry. + */ +export const fileMutationToolview = { + name: 'file-mutation-toolview', + inject: ['slots', 'conversation'], + /** + * Register the file-mutation row into the chat view's keyed toolview hole + * under both mutation tool names. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'edit' }, FileMutationRow) + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'write' }, FileMutationRow) + }, +} diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx new file mode 100644 index 0000000000..77b762c38b --- /dev/null +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -0,0 +1,248 @@ +// @vitest-environment jsdom +// The diff render intent on the web side: the pure diffCardModel derivation +// over callView/resultView, and both conversation render sites that consume it +// — the chat tool row's expanded body (GenericToolCard / FileMutationRow) and +// the details panel's Output section. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { FileMutationRow } from '../src/client/toolviews/file-mutation-row.tsx' + +afterEach(cleanup) + +const SID = 's1' as SessionId + +const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}' + +/** The edit tool's own call view (a call-time diff derived from the arguments). */ +const callDiff = (over?: Partial>): ToolCallView => ({ + card: 'diff', title: 'Edit notes/demo.txt', + diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over, +}) + +/** The edit tool's own result view (the applied hunk diff). */ +const resultDiff = (over?: Partial>): ToolResultView => ({ + card: 'diff', title: 'Edit notes/demo.txt', + diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over, +}) + +const running = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'edit', argsRaw: ARGS, + turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over, +}) + +const settled = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'edit', argsRaw: ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: 'The file notes/demo.txt has been updated successfully.' }], isError: false, + callView: callDiff(), resultView: resultDiff(), ...over, +}) + +describe('diffCardModel', () => { + it('derives a running card from the call view alone', () => { + expect(diffCardModel(running())).toEqual({ + card: { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] }, + }) + }) + + it('derives a settled card from the result view, which replaces the call-time diff', () => { + // The applied hunks (result) win over the args-derived call diff. + expect(diffCardModel(settled({ + resultView: resultDiff({ diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }), + }))).toEqual({ + card: { diffs: [{ path: 'notes/demo.txt', oldText: 'a', newText: 'b' }] }, + }) + }) + + it('renders a settled diff even when the window dropped the call head', () => { + // A truncated call carries only the result view, which holds the whole change. + expect(diffCardModel(settled({ call: null, callView: null }))?.card.diffs).toHaveLength(1) + }) + + it('returns null for every non-diff call: no views, generic views, unknown cards', () => { + expect(diffCardModel(running({ callView: null }))).toBeNull() + expect(diffCardModel(settled({ callView: null, resultView: null }))).toBeNull() + expect(diffCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull() + // A generic result settles a diff call on the generic path (write/edit's + // own execution-error arm). + expect(diffCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView + expect(diffCardModel(running({ callView: future }))).toBeNull() + expect(diffCardModel(settled({ + callView: future, resultView: { card: 'chart' } as unknown as ToolResultView, + }))).toBeNull() + }) +}) + +describe('chat row diff body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({ + callId: 'c1', toolName: 'edit', block, openFile: vi.fn(), + }) + + it('the expanded body is the applied diff, capped tighter than the panel', () => { + expect(CHAT_DIFF_MAX_LINES).toBeLessThan(16) + const view = render() + // Collapsed: the summary row (path) only, no diff body. + expect(view.queryByText('hello fixture')).toBeNull() + // The path link is not the expand control; the leading toggle is. + fireEvent.click(view.container.querySelector('button[aria-expanded]')!) + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.getByText('hello fixture')).toBeTruthy() + }) + + it('a running diff call expands to its intended change', () => { + const view = render() + fireEvent.click(view.container.querySelector('button[aria-expanded]')!) + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + }) + + it('a non-diff call keeps the args-JSON text body', () => { + // A non-file tool name so the row is not single-file (no path link), and its + // args body is the fallback the diff card must not have replaced. + const view = render() + fireEvent.click(view.container.querySelector('button[aria-expanded]')!) + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.getByText(/"foo"/)).toBeTruthy() + }) +}) + +describe('FileMutationRow diff card', () => { + const list = () => createSnapshotStore({ + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + current: SID, + phase: 'ready', + }) + + const rowProps = (block: RunningToolCall | ToolResultNode, toolName = 'edit'): ToolRowProps => ({ + callId: 'c1', toolName, block, openFile: vi.fn(), cwd: '/w/app', + sessionId: SID, useSessions: bindSnapshotSelector(list()), + } as unknown as ToolRowProps) + + it('renders the applied diff under the summary row, without an expand gesture', () => { + const view = render() + // The diff card is resident (no expand toggle needed). + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.getByText('hello fixture')).toBeTruthy() + expect(view.getByText('复制')).toBeTruthy() + }) + + it('the summary is a path link that opens through the host, cwd-resolved', () => { + const openFile = vi.fn() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' })) + expect(openFile).toHaveBeenCalledWith('/w/app/notes/demo.txt') + }) + + it('registers under write too, rendering a create as an added-only diff', () => { + const writeArgs = '{"file_path":"notes/new.txt","content":"hello fixture\\n"}' + const view = render() + expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy() + }) + + it('reflects the run state on its leading slot', () => { + const runningView = render() + expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull() + cleanup() + const errorView = render() + expect(errorView.container.querySelector('[data-state="error"]')).not.toBeNull() + }) + + it('a mutation call with no diff view renders the summary row alone', () => { + const view = render() + expect(view.container.querySelector('[data-diff]')).toBeNull() + }) +}) + +describe('DetailsPanel diff Output section', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore(cwd === undefined + ? { ids: [], byId: {}, current: undefined, phase: 'ready' } + : { + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + current: SID, + phase: 'ready', + }) + const workspaces = createSnapshotStore({ + items: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'edit' } + + it('renders the applied diff at full height, keeping the JSON Input section', () => { + const view = mount(snapshot({ nodes: [settled()] }), target) + expect(view.getByText(/"file_path"/)).toBeTruthy() + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.getByText('hello fixture')).toBeTruthy() + }) + + it('a running diff call renders its intended change, not the 运行中… placeholder', () => { + const view = mount(snapshot({ runningCalls: [running()] }), target) + expect(view.container.querySelector('[data-diff]')).not.toBeNull() + expect(view.queryByText('运行中…')).toBeNull() + }) + + it('a non-diff result keeps the flattened pre', () => { + const view = mount(snapshot({ + nodes: [settled({ + callView: null, resultView: null, + content: [{ type: 'text', text: 'permission denied' }], + })], + }), target) + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.getByText('Output').closest('section')?.querySelector('pre')?.textContent).toBe('permission denied') + }) +}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index b5e4b5c078..ba6010f6c7 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: 58c8ddcf0343216979ffdae7749c5368e26c45e5 +README.zh.md: 2d775f6591d3e2f5305cd517a38effb2cf25becf diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 0ef3c20f84..58c8ddcf03 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), and TerminalBlock. Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, and DiffBlock. Contract: api-contracts v3 §8. ## Markdown rendering @@ -12,6 +12,10 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/ `TerminalBlock` renders a shell command as a terminal surface: one prompt row per line of the command (the shortened `cwd` label on the first row only, since the view knows one working directory and a `cd` moves later lines elsewhere, then that line), the command's output, a status pill for a non-zero exit code or a terminating signal, and a copy control that writes the raw `output` prop. A run-state `StateDot` marks the call once, on the first row, out of flow in a gutter the card reserves as its own left padding, so the dot sits inside the card box yet left of the prompt text. It reaches three of `StateDot`'s states — the chase while `running`, red for the same exit status that renders the pill, green otherwise — so a card states whether its command is still running rather than leaving that to be inferred from the presence of output; it carries one visually hidden text label because `StateDot` is `aria-hidden`. One dot regardless of line count is deliberate: the exit status is the whole call's, so a dot per line would claim a per-line outcome the view does not carry. Command text is `white-space: pre`, so repeated spaces, tabs, and an indented continuation render verbatim while the row stays single-line and ellipsizes. ANSI escape sequences are parsed with the `anser` runtime dependency into React spans; cursor movements replay into a per-line column buffer before inert controls are stripped, since carriage return and backspace only MOVE the cursor: `100%` + CR + `OK` alone shows `OK0%`, while the `\x1b[K` a spinner writes with its redraw erases the tail so `100%\r\x1b[KOK` shows `OK`. Erase-in-line is honored in all three parameter forms, the cursor advances by terminal columns (8-column tab stops, two for emoji and CJK, none for a combining mark), and SGR state is normalized per cell as a terminal stores it, threading across lines and closing at the state the line ended in; basic-16 foreground colors map onto `--dsw-*` tokens, while 256-palette and truecolor values pass through as literal rgb. Output keeps `white-space: pre` with horizontal scrolling, so column-aligned output holds its alignment instead of soft-wrapping, and collapses to a head slice plus a tail slice past `maxLines` (default 16, the TUI transcript's split arithmetic) behind an expand button. Rationale: [the web terminal card note](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md). +## Diff rendering + +`DiffBlock` renders a file mutation as an inline diff surface: one bold path header per file, the removed lines (`- `, error token) above the added lines (`+ `, success token), a `⋯` gap before a same-file second hunk, and a dim `└ +A -R · N file(s)` footer. Lines are `white-space: pre` with horizontal scrolling, so a source line holds its indentation instead of soft-wrapping, and the body collapses to a head slice plus a tail slice past `maxLines` (default 16, `TerminalBlock`'s split arithmetic) behind an expand button. A create (`oldText: null`) has no removed side. The copy control writes the prefixed diff text (path headers, `- `/`+ ` lines, the gap) so a multi-file copy stays attributable, and floats in the top-right corner rather than on a banner row of its own. Geometry mirrors `CodeBlock`/`TerminalBlock`. The `+`/`-` block form mirrors the TUI transcript's diff card so a diff reads the same across front ends. Rationale: [the web diff card note](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md). + ## Model Experience None, as the package renders pure React atoms in the browser; nothing here reaches a model request. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index af94551bfb..2d775f6591 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量),以及 TerminalBlock。契约:api-contracts v3 §8。 +纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock,以及 DiffBlock。契约:api-contracts v3 §8。 ## Markdown 渲染 @@ -11,6 +11,10 @@ `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 渲染 + +`DiffBlock` 将一次文件改动渲染为内联 diff 表层:每个文件一个粗体路径头、删除行(`- `,error token)在新增行(`+ `,success token)之上、同文件第二个 hunk 前一个 `⋯` gap,以及暗色 `└ +A -R · N file(s)` 页脚。各行使用 `white-space: pre` 并横向滚动,因此源码行保留其缩进而不软换行;超过 `maxLines`(默认 16,与 `TerminalBlock` 相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。新建(`oldText: null`)没有删除侧。复制控件写入带前缀的 diff 文本(路径头、`- `/`+ ` 行、gap),使多文件复制保持可归属,并浮在右上角而非占据自己的 banner 行。几何镜像 `CodeBlock`/`TerminalBlock`。`+`/`-` 块形式镜像 TUI 转录的 diff 卡片,使 diff 在两个前端读起来一致。原理:[Web diff 卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)。 + ## 模型体验 无。该包(package)在浏览器中渲染纯 React 原子组件;这里没有任何内容进入模型请求。 diff --git a/packages/client/ui-primitives/src/DiffBlock.module.css b/packages/client/ui-primitives/src/DiffBlock.module.css new file mode 100644 index 0000000000..8794dbb87e --- /dev/null +++ b/packages/client/ui-primitives/src/DiffBlock.module.css @@ -0,0 +1,103 @@ +/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface + + banner row, markdown code-block font) so a diff card reads as one family with + a fenced block and a terminal card. The deliberate divergence, shared with + TerminalBlock: the body keeps `white-space: pre` and scrolls horizontally, + because folding a source line destroys the indentation a diff is read by. */ + +.block { + --dsl-diff-radius: 12px; + --dsl-diff-line-height: 22px; + + position: relative; + margin: 16px 0; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-markdown-code-block); + border-radius: var(--dsl-diff-radius); +} + +/* The copy control floats in the top-right corner over the body, so the card + has no empty banner row above its first diff line (the TUI diff card has no + banner either — only the footer). The block is position: relative, so this + anchors to the card. */ +.copyButton { + position: absolute; + top: 8px; + right: 12px; + z-index: 1; + background-color: transparent; + border: none; + padding: 0; + margin: 0; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + font: var(--dsw-font-xs-13); +} + +.body { + padding: 12px 14px; + font: var(--dsw-font-markdown-code-block); + overflow-x: auto; + overflow-y: hidden; +} + +/* No wrapping, no word-break: a diff is read by its indentation. */ +.line { + min-height: var(--dsl-diff-line-height); + white-space: pre; +} + +/* A file header: the path in the primary tone, set apart by weight. */ +.path { + color: var(--dsw-alias-label-primary); + font-weight: 600; +} + +/* A same-file second hunk's separator (a scattered edit), in the dim tone. */ +.gap { + color: var(--dsw-alias-label-tertiary); +} + +/* The diff's own meaning-carrying colors: removed on the error token, added on + the success token. A `- `/`+ ` prefix is drawn here so a copied line and the + shown line agree, and so the sign reads without relying on color alone. */ +.del::before { + content: '- '; + color: var(--dsw-alias-state-error-primary); +} + +.del { + color: var(--dsw-alias-state-error-primary); +} + +.add::before { + content: '+ '; + color: var(--dsw-alias-state-success-primary); +} + +.add { + color: var(--dsw-alias-state-success-primary); +} + +.expand { + display: block; + width: 100%; + padding: 0; + border: none; + background-color: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + font: inherit; + text-align: left; +} + +.expand:hover { + color: var(--dsw-alias-label-secondary); +} + +/* The change summary, dim under the body: `└ +A -R · N file(s)`, the same + footer the TUI transcript's diff card draws. */ +.footer { + padding: 0 14px 12px; + font: var(--dsw-font-markdown-code-block); + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx new file mode 100644 index 0000000000..ab1700b7b5 --- /dev/null +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -0,0 +1,171 @@ +// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy +// control over one or more per-file hunks, each a bold path header followed by +// the removed block (`-`, error color) and the added block (`+`, success +// color), with a dim `└ +A -R · N file(s)` footer. The +/- block form mirrors +// the TUI transcript's diff card (packages/ui/tui: diffLines) so a diff reads +// the same across front ends: the removed side is the old text in full, the +// added side the new text in full. Output never soft-wraps — an aligned source +// line keeps its indentation and scrolls horizontally instead of folding. +// Colors resolve through --dsw-* tokens; geometry mirrors CodeBlock. + +import { useCallback, useMemo, useState } from 'react' +import clsx from 'clsx' +import { writeClipboard } from './clipboard.ts' +import css from './DiffBlock.module.css' + +/** + * Output lines shown before the height cap collapses the middle. Matches + * {@link DEFAULT_TERMINAL_MAX_LINES} so a diff card and a terminal card cut a + * long body at the same place. + */ +export const DEFAULT_DIFF_MAX_LINES = 16 + +/** + * One file's change, in the shape {@link DiffBlock} draws. Structurally the + * render-intent contract's `FileDiff`, redeclared here so this primitive stays + * free of the tool contract (the terminal card's decoupling, applied to diffs). + */ +export interface DiffHunk { + /** The changed file's path (as the tool operated on it; the bridge relativizes it). */ + path: string + /** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */ + oldText: string | null + /** Content after the change (the added side). */ + newText: string +} + +export interface DiffBlockProps { + /** One entry per applied hunk, in file order; empty renders nothing. */ + diffs: DiffHunk[] + /** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */ + maxLines?: number | undefined + /** Extra class merged onto the wrapper (callers position; this component draws). */ + className?: string | undefined +} + +/** A single rendered body line and its role, so the height cap slices a flat list. */ +interface DiffRow { + kind: 'path' | 'del' | 'add' | 'gap' + text: string +} + +/** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */ +const ROW_CLASS: Record = { + path: css.path, + del: css.del, + add: css.add, + gap: css.gap, +} + +/** + * Flatten the hunks into the body's rows plus the footer counts. A path header + * opens each new file; a same-file second hunk (a scattered edit) opens with a + * `⋯` gap instead of repeating the path. Every old-side line counts toward + * `removed` and every new-side line toward `added`, the same per-side line count + * the TUI footer draws, so the two front ends agree on a change's size. + * @param diffs - the hunks to render. + * @returns the body rows, the +/- totals, and the distinct-file count. + */ +function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } { + const rows: DiffRow[] = [] + const paths = new Set() + let added = 0 + let removed = 0 + let prevPath: string | undefined + for (const diff of diffs) { + paths.add(diff.path) + if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path }) + else rows.push({ kind: 'gap', text: '⋯' }) + prevPath = diff.path + if (diff.oldText !== null) { + for (const line of diff.oldText.split('\n')) { + rows.push({ kind: 'del', text: line }) + removed++ + } + } + for (const line of diff.newText.split('\n')) { + rows.push({ kind: 'add', text: line }) + added++ + } + } + return { rows, added, removed, files: paths.size } +} + +/** + * The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its + * content, exactly what the card shows. The removed and added blocks are the + * change; the path headers keep a multi-file copy attributable. + * @param rows - the flattened body rows. + * @returns the diff as plain text. + */ +function copyText(rows: DiffRow[]): string { + return rows.map((row) => { + switch (row.kind) { + case 'del': return `- ${row.text}` + case 'add': return `+ ${row.text}` + case 'gap': return row.text + default: return row.text + } + }).join('\n') +} + +/** + * Render a file mutation as an inline diff surface. + * @param props - see {@link DiffBlockProps}. + * @returns the diff block element. + */ +export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) { + const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs]) + const [expanded, setExpanded] = useState(false) + const [copied, setCopied] = useState(false) + + const onCopy = useCallback(() => { + if (copied) return + void writeClipboard(copyText(rows)).then((ok) => { + if (!ok) return + setCopied(true) + window.setTimeout(() => { setCopied(false) }, 1000) + }) + }, [copied, rows]) + + const onToggle = useCallback(() => { setExpanded(value => !value) }, []) + + if (rows.length === 0) return null + + const hidden = rows.length - maxLines + const capped = hidden > 0 && !expanded + // Same split arithmetic as TerminalBlock and the TUI transcript's collapsed + // card, so a body's head and tail slices agree across the front ends. + const headLines = Math.ceil(maxLines / 2) + const tailLines = maxLines - headLines + const head = capped ? rows.slice(0, headLines) : rows + const tail = capped ? rows.slice(rows.length - tailLines) : [] + + return ( +
+ +
+ {head.map((row, index) => ( +
{row.text}
+ ))} + {hidden > 0 && ( + + )} + {tail.map((row, index) => ( +
{row.text}
+ ))} +
+
└ +{added} -{removed} · {files} file{files === 1 ? '' : 's'}
+
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index aa674f7a1a..fc0d08b76e 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps } from './TerminalBlock.tsx' +export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx' +export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx' export { CodeBlock } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' diff --git a/packages/client/ui-primitives/tests/diff-block.spec.tsx b/packages/client/ui-primitives/tests/diff-block.spec.tsx new file mode 100644 index 0000000000..d732a13315 --- /dev/null +++ b/packages/client/ui-primitives/tests/diff-block.spec.tsx @@ -0,0 +1,162 @@ +// @vitest-environment jsdom +// DiffBlock: the per-file hunk rows (path header, removed block, added block), +// the same-file second-hunk gap separator, the `+A -R · N file(s)` footer and +// its singular/plural, the head/tail height cap and its expand control, the +// empty-diffs null render, and the copy control writing the prefixed diff text +// on both the accepted and the refused clipboard paths. writeClipboard's own +// return contract is pinned in terminal-block.spec.tsx (the shared seam), so +// only its DOM consequence is asserted here. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { DEFAULT_DIFF_MAX_LINES, DiffBlock, type DiffHunk } from '../src/index.ts' + +afterEach(cleanup) + +beforeEach(() => { + vi.useRealTimers() +}) + +/** The rendered body rows, one string per visible line (CSS-module class prefix). */ +function bodyRows(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class*="_line_"]')].map(row => row.textContent ?? '') +} + +/** Only the changed rows (add/del), excluding the path header and gap chrome. */ +function changeRows(container: HTMLElement): string[] { + return [...container.querySelectorAll('[class*="_del_"], [class*="_add_"]')].map(row => row.textContent ?? '') +} + +/** `count` numbered added lines as one hunk's newText. */ +function added(count: number): string { + return Array.from({ length: count }, (_v, i) => `line ${i + 1}`).join('\n') +} + +describe('DiffBlock structure', () => { + it('renders a create as a path header and an added block (no removed side)', () => { + const diffs: DiffHunk[] = [{ path: 'notes/new.txt', oldText: null, newText: 'hello\nworld' }] + const { container } = render() + expect(screen.getByText('notes/new.txt')).toBeTruthy() + // No removed rows: both change lines are added. + expect(changeRows(container)).toEqual(['hello', 'world']) + expect(container.querySelectorAll('[class*="_del_"]').length).toBe(0) + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(2) + }) + + it('renders an edit as a removed block above an added block', () => { + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'old', newText: 'new' }] + const { container } = render() + expect(container.querySelectorAll('[class*="_del_"]').length).toBe(1) + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(1) + expect(changeRows(container)).toEqual(['old', 'new']) + }) + + it('opens a same-file second hunk with a gap instead of repeating the path', () => { + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: 'x', newText: 'y' }, + { path: 'a.ts', oldText: 'p', newText: 'q' }, + ] + const { container } = render() + // One path header, one gap row. + expect(container.querySelectorAll('[class*="_path_"]').length).toBe(1) + expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(1) + }) + + it('opens a new file with its own path header', () => { + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: 'x', newText: 'y' }, + { path: 'b.ts', oldText: 'p', newText: 'q' }, + ] + const { container } = render() + expect(container.querySelectorAll('[class*="_path_"]').length).toBe(2) + expect(container.querySelectorAll('[class*="_gap_"]').length).toBe(0) + }) + + it('renders nothing for empty diffs', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) +}) + +describe('DiffBlock footer', () => { + it('counts added and removed lines and one file', () => { + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }] + render() + expect(screen.getByText('└ +1 -2 · 1 file')).toBeTruthy() + }) + + it('pluralizes the distinct-file count', () => { + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: null, newText: 'x' }, + { path: 'b.ts', oldText: null, newText: 'y' }, + ] + render() + expect(screen.getByText('└ +2 -0 · 2 files')).toBeTruthy() + }) +}) + +describe('DiffBlock height cap', () => { + it('shows head and tail with an expand control past the cap, then all lines expanded', () => { + // One added line over the default cap forces the collapse. + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(DEFAULT_DIFF_MAX_LINES) }] + // The path header counts as a row, so a body of maxLines added lines plus + // the header is one over the cap. + const { container } = render() + const toggle = screen.getByRole('button', { name: /展开其余/ }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + // Collapsed shows fewer rows than the full body. + const collapsedCount = bodyRows(container).length + expect(collapsedCount).toBeLessThan(DEFAULT_DIFF_MAX_LINES + 1) + fireEvent.click(toggle) + expect(screen.getByRole('button', { name: '收起差异' }).getAttribute('aria-expanded')).toBe('true') + expect(bodyRows(container).length).toBeGreaterThan(collapsedCount) + }) + + it('shows no expand control at or under the cap', () => { + const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: null, newText: added(4) }] + render() + expect(screen.queryByRole('button', { name: /展开其余|收起差异/ })).toBeNull() + }) +}) + +describe('DiffBlock copy', () => { + it('copies the prefixed diff text and flips the label on success', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const diffs: DiffHunk[] = [ + { path: 'a.ts', oldText: 'old', newText: 'new' }, + { path: 'a.ts', oldText: 'p', newText: 'q' }, + ] + render() + const copy = screen.getByRole('button', { name: '复制' }) + await act(async () => { fireEvent.click(copy) }) + // Path header, del/add prefixes, and the same-file gap all reach the clipboard. + expect(writeText).toHaveBeenCalledWith('a.ts\n- old\n+ new\n⋯\n- p\n+ q') + expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy() + await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('keeps the label on a refused clipboard write', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + }) + render() + const copy = screen.getByRole('button', { name: '复制' }) + await act(async () => { fireEvent.click(copy) }) + expect(screen.getByRole('button', { name: '复制' })).toBeTruthy() + }) + + it('ignores a second click while the copied label is showing', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + render() + const copy = screen.getByRole('button', { name: '复制' }) + await act(async () => { fireEvent.click(copy) }) + await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制成功' })) }) + expect(writeText).toHaveBeenCalledTimes(1) + }) +}) From d7e46bea355d6246cfc9e0c3bd2c8696b7464da8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 16:45:00 +0800 Subject: [PATCH 03/26] test(web): update chat-apply keyed-entry assertion for the file-mutation rows The diff card registers edit and write into the keyed toolview hole, so the mounted-entry set is now ['bash', 'edit', 'write', 'todo_write']. --- .../client/ui-conversation/tests/chat-apply.spec.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index d7b9125b34..33f8af938d 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -80,12 +80,13 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the file-mutation rows, and the todo row as keyed entries through the load-order seam', async () => { const b = await bench() - // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the - // service being present implies the chat entry declared the hole first. + // Each registrant plugin's inject: ['slots', 'conversation'] resolved — the + // service being present implies the chat entry declared the hole first. The + // file-mutation registrant claims both write and edit for the diff card. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'todo_write']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'todo_write']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() From 8c5c4b46c83562611eb4bf3fe9adf60fdc35c81b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 18:32:55 +0800 Subject: [PATCH 04/26] =?UTF-8?q?fix(web):=20address=20diff=20card=20revie?= =?UTF-8?q?w=20=E2=80=94=20split=20terminator,=20error=20arm,=20wire=20nar?= =?UTF-8?q?rowing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DiffBlock: an empty side contributes zero lines and a trailing newline is a terminator, so a create ending in a newline draws one added line (not a phantom empty one) and a full deletion draws no phantom + line. - diffCardModel: narrow the wire diffs payload (card is the only validated field) so a malformed diff card falls back to the generic path instead of throwing inside DiffBlock. - FileMutationRow: surface the result text when an errored mutation has no diff card, so a failed edit/write is more than a red dot. - copyText ends its closed union on assertNever. - Docs: drop the "bridge relativizes" claim, record the file-count divergence from the TUI footer, correct the built-boot overclaim, note why the row title outranks the view title, and make fixture turn 67 args self-consistent. - Tests: terminator/empty-side/interior-blank rows, wire-narrowing null arms, the error-text arm and its name/code fallback, stopped state, no-path summary, and the registration/disposal shape. --- .../2026-07-30-web-diff-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-diff-card.md | 6 +- .../feature/2026-07-30-web-diff-card.zh.md | 6 +- .../client/connection/src/client/fixture.ts | 2 +- .../src/client/contract/diff-card-model.ts | 40 ++++++++- .../toolviews/file-mutation-row.module.css | 11 +++ .../client/toolviews/file-mutation-row.tsx | 25 ++++++ .../ui-conversation/tests/diff-card.spec.tsx | 90 ++++++++++++++++++- .../client/ui-primitives/src/DiffBlock.tsx | 36 ++++++-- .../ui-primitives/tests/diff-block.spec.tsx | 20 +++++ 10 files changed, 221 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml index 18f2d5178b..7ed620736c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.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 .agents/notes/implemented/feature/2026-07-30-web-diff-card.md -2026-07-30-web-diff-card.md: 5e43d5d29f7f4000efebc166724ec9d921d2b441 -2026-07-30-web-diff-card.zh.md: aac577cfa8dd9e0bf5f17a729d049d207a64d374 +2026-07-30-web-diff-card.md: 8087ce698e65f78c7c6f51211ef00e3b0ab58ed9 +2026-07-30-web-diff-card.zh.md: d85ac1f2e13c7fb3732b327b40122076337ac538 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md index 5e43d5d29f..8087ce698e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.md @@ -16,9 +16,9 @@ This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` `DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change. -The component's contract mirrors the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same across front ends: +The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends, with one deliberate divergence noted below (the file count): -- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts distinct paths. +- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths — the divergence from the TUI, whose footer uses `diffs.length` and so reads two hunks in one file as `2 files` where this reads `1 file`. - **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side. - **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends. - **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable. @@ -47,7 +47,7 @@ The multi-file arm of `DiffBlock` (one card, several path headers) has no produc `packages/client/ui-conversation/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section. -The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so the built-boot snapshot pins all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). +The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md index aac577cfa8..d85ac1f2e1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md @@ -16,9 +16,9 @@ Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行 `DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。 -组件的契约镜像 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来一致: +组件的契约遵循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态,仅文件计数一处刻意分歧(见下): -- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚统计去重后的路径数。 +- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚统计**去重后的路径数** —— 这是与 TUI 的分歧:TUI 页脚用 `diffs.length`,同文件两个 hunk 在那里读作 `2 files`,此处读作 `1 file`。 - **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。 - **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。 - **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。 @@ -47,7 +47,7 @@ chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX `packages/client/ui-conversation/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。 -fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 built-boot snapshot 在两个渲染点钉住全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。 +fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身契约它不带 diff 行为断言,那由接线套件负责。 ## Related diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 4fddf6e9c0..4082d469e8 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -238,7 +238,7 @@ function buildAlphaLog(): SessionEvent[] { // the presenter reads to emit the two-hunk sample: the card draws one path // header, the first hunk, a `⋯` gap, then the second (the same-file // second-hunk arm turns 62/63 cannot reach). - toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"multi","new_string":"multi"}', '已编辑') + toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑') // Turn 64: one run_code turn with three logged sub-dispatches — the Code // Mode acceptance surface (parent code row + nested native-identical rows, // including an isError sub-call and a bash sub-call that must hit the same diff --git a/packages/client/ui-conversation/src/client/contract/diff-card-model.ts b/packages/client/ui-conversation/src/client/contract/diff-card-model.ts index f02ccd5930..bc914e4820 100644 --- a/packages/client/ui-conversation/src/client/contract/diff-card-model.ts +++ b/packages/client/ui-conversation/src/client/contract/diff-card-model.ts @@ -7,7 +7,7 @@ * call this, so the hunks they show are derived once. * @module */ -import type { DiffBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallBlock } from './tool-call-model.ts' /** @@ -35,6 +35,30 @@ export interface DiffCardModel { card: Pick } +/** + * Narrow a wire `card:'diff'` view's `diffs` to well-formed hunks. The event + * view crosses the wire and `toolEventViewSchema` validates only the `card` + * string, so a version mismatch or an anomalous plugin can deliver a `diff` card + * whose `diffs` is absent, not an array, or carries malformed hunks. Returning + * null for any of those routes the block to the generic path instead of letting + * DiffBlock's `for...of`/`split` throw and crash the row or the details panel. + * @param diffs - the view's `diffs` field, unverified. + * @returns the validated hunks, or null when the payload is not usable. + */ +function narrowDiffs(diffs: unknown): DiffHunk[] | null { + if (!Array.isArray(diffs) || diffs.length === 0) return null + const out: DiffHunk[] = [] + for (const hunk of diffs) { + if (typeof hunk !== 'object' || hunk === null) return null + const { path, oldText, newText } = hunk as Record + if (typeof path !== 'string') return null + if (oldText !== null && typeof oldText !== 'string') return null + if (typeof newText !== 'string') return null + out.push({ path, oldText, newText }) + } + return out +} + /** * Derive the diff-card props for a tool call, or null when this call is not a * diff card and belongs on the generic path. @@ -49,6 +73,14 @@ export interface DiffCardModel { * be trusted to be one of the compiled variants — and a settled call whose * result view is generic (how write/edit keep their execution errors on the * generic path). + * + * This derivation consumes only `diffs`; the render intent's `title` field is + * deliberately dropped. The row supplies its own title (`Edit`/`Write · path` + * from the args) and that outranks the view's `title`, matching the TUI diff + * branch, which likewise draws no view title. A tool that names its own diff + * header therefore does not surface that text on the Web row — an accepted + * product choice, recorded here as the one asymmetry with the terminal card, + * whose derivation does consume the view's title. * @param block - RunningToolCall or ToolResultNode off the snapshot caches. * @returns the diff-card props, or null for the generic path. */ @@ -56,11 +88,13 @@ export function diffCardModel(block: ToolCallBlock): DiffCardModel | null { if (!('kind' in block)) { // Running: the call view may carry the intended diff; the result is absent. const call = block.callView?.card === 'diff' ? block.callView : null - return call === null ? null : { card: { diffs: call.diffs } } + const diffs = call === null ? null : narrowDiffs(call.diffs) + return diffs === null ? null : { card: { diffs } } } // Settled: the result view's applied hunks replace the call-time diff. A // window that dropped the call head leaves only the result, which still // renders — the result view carries the whole change. const result = block.resultView?.card === 'diff' ? block.resultView : null - return result === null ? null : { card: { diffs: result.diffs } } + const diffs = result === null ? null : narrowDiffs(result.diffs) + return diffs === null ? null : { card: { diffs } } } diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css index b87103aa3a..3ecf480adf 100644 --- a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.module.css @@ -117,3 +117,14 @@ clip: rect(0 0 0 0); white-space: nowrap; } + +/* The result text for an errored mutation, indented to the card's own column + (the diff card's inset) and in the error tone, since it stands in for the diff + card the failure path does not produce. */ +.failure { + margin: 4px 0 4px 22px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx index 0862eb4fd5..e777c78932 100644 --- a/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx @@ -39,6 +39,27 @@ function stateStatus(state: ToolRowState): string | null { } } +/** + * A settled result's text, flattened from its content blocks, for the arm that + * shows a failure the diff card cannot: write/edit return `undefined` from + * `presentResult` on `result.isError`, so an errored mutation has no diff card, + * and the keyed row is not a details-panel target. Without this the failure — + * an `old_string` that did not match, a permission denial — would read as a bare + * red dot with the model-facing error text nowhere on screen. + * @param block - the frozen call slice. + * @returns the result text, or null for a running call or an empty result. + */ +function errorText(block: ToolRowProps['block']): string | null { + if (!('kind' in block)) return null + const parts: string[] = [] + for (const item of block.content) { + if (item.type === 'text') parts.push(item.text) + } + if (parts.length === 0 && block.error !== undefined) parts.push(`${block.error.name}: ${block.error.code}`) + const text = parts.join('\n') + return text === '' ? null : text +} + /** * File-mutation row: icon + {Edit,Write} · {path} in the shared ToolRow chrome, * with the applied diff resident below it. The summary is a path link (a file @@ -50,6 +71,9 @@ export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps const diff = diffCardModel(block) const status = stateStatus(model.state) const filePath = model.filePath + // An errored mutation has no diff card (presentResult returns undefined on + // isError); surface its result text so the failure is more than a red dot. + const failure = diff === null && model.state === 'error' ? errorText(block) : null return (
@@ -72,6 +96,7 @@ export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps {diff !== null && ( )} + {failure !== null &&
{failure}
}
) } diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 77b762c38b..031216b9f7 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -17,7 +17,7 @@ import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/contract/diff- import { createChatStore } from '../src/client/stores.ts' import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx' import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' -import { FileMutationRow } from '../src/client/toolviews/file-mutation-row.tsx' +import { FileMutationRow, fileMutationToolview } from '../src/client/toolviews/file-mutation-row.tsx' afterEach(cleanup) @@ -86,6 +86,22 @@ describe('diffCardModel', () => { callView: future, resultView: { card: 'chart' } as unknown as ToolResultView, }))).toBeNull() }) + + it('falls back to null for a malformed diff payload off the wire', () => { + // toolEventViewSchema validates only the `card` string, so a version + // mismatch can deliver a diff card with an unusable diffs field. Each shape + // routes to the generic path instead of throwing inside DiffBlock. + const bad = (diffs: unknown): ToolResultView => ({ card: 'diff', diffs } as unknown as ToolResultView) + expect(diffCardModel(settled({ resultView: bad(undefined) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad('nope') }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([null]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 1, oldText: null, newText: 'x' }]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: 5, newText: 'x' }]) }))).toBeNull() + expect(diffCardModel(settled({ resultView: bad([{ path: 'a', oldText: null, newText: 9 }]) }))).toBeNull() + // The running side narrows identically. + expect(diffCardModel(running({ callView: { card: 'diff', diffs: 'nope' } as unknown as ToolCallView }))).toBeNull() + }) }) describe('chat row diff body', () => { @@ -176,6 +192,78 @@ describe('FileMutationRow diff card', () => { const view = render() expect(view.container.querySelector('[data-diff]')).toBeNull() }) + + it('surfaces the result text when an errored mutation has no diff card', () => { + // write/edit return undefined from presentResult on isError, so the failure + // has no diff — the row shows the model-facing error text instead of a bare + // red dot. + const view = render() + expect(view.container.querySelector('[data-diff]')).toBeNull() + expect(view.getByText('old_string not found in notes/demo.txt')).toBeTruthy() + }) + + it('falls back to the error name/code when an errored result has no text block', () => { + const view = render() + expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy() + }) + + it('shows no failure text for a successful diff or a running call', () => { + const ok = render() + expect(ok.container.querySelector('[class*="_failure_"]')).toBeNull() + cleanup() + const run = render() + expect(run.container.querySelector('[class*="_failure_"]')).toBeNull() + }) + + it('shows the stopped state when the call was interrupted', () => { + const view = render() + expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull() + // The visually-hidden status label carries the stopped semantic for AT. + expect(view.getByText('已停止')).toBeTruthy() + }) + + it('renders a plain summary span when the call carries no file path', () => { + // Empty args leave deriveFilePath undefined, so the summary is not a link. + const view = render() + expect(view.container.querySelector('[class*="_fileLink_"]')).toBeNull() + expect(view.container.querySelector('[class*="_summary_"]')).not.toBeNull() + }) +}) + +describe('fileMutationToolview registration', () => { + it('registers one component under both edit and write, and each disposes', () => { + const registered: { key: string; disposed: boolean }[] = [] + const disposers: (() => void)[] = [] + const ctx = { + slots: { + register: ({ key }: { name: string; key: string }) => { + const entry = { key, disposed: false } + registered.push(entry) + const dispose = () => { entry.disposed = true } + disposers.push(dispose) + return dispose + }, + }, + } + fileMutationToolview.apply(ctx as never) + expect(registered.map(r => r.key).sort()).toEqual(['edit', 'write']) + // The registrant's inject seam is the load-order contract the row relies on. + expect(fileMutationToolview.inject).toEqual(['slots', 'conversation']) + // Disposal removes each contribution (packages/AGENTS.md registry contract). + for (const dispose of disposers) dispose() + expect(registered.every(r => r.disposed)).toBe(true) + }) }) describe('DetailsPanel diff Output section', () => { diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index ab1700b7b5..12c389c72b 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -26,7 +26,7 @@ export const DEFAULT_DIFF_MAX_LINES = 16 * free of the tool contract (the terminal card's decoupling, applied to diffs). */ export interface DiffHunk { - /** The changed file's path (as the tool operated on it; the bridge relativizes it). */ + /** The changed file's path, drawn verbatim as the hunk's header (the tool's model-facing path). */ path: string /** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */ oldText: string | null @@ -49,6 +49,12 @@ interface DiffRow { text: string } +/** Local exhaustiveness helper — this package does not depend on `dsh-llm`. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if a row kind is forged */ +function assertNever(value: never): never { + throw new Error(`unreachable diff row kind: ${String(value)}`) +} + /** The dim class per row kind (path/gap chrome vs the diff's own +/- colors). */ const ROW_CLASS: Record = { path: css.path, @@ -61,8 +67,10 @@ const ROW_CLASS: Record = { * Flatten the hunks into the body's rows plus the footer counts. A path header * opens each new file; a same-file second hunk (a scattered edit) opens with a * `⋯` gap instead of repeating the path. Every old-side line counts toward - * `removed` and every new-side line toward `added`, the same per-side line count - * the TUI footer draws, so the two front ends agree on a change's size. + * `removed` and every new-side line toward `added`. The file count is of + * DISTINCT paths, which is the one deliberate divergence from the TUI diff card: + * the TUI footer uses `diffs.length`, so two hunks in one file read there as + * `2 files`, whereas this counts the one file they belong to. * @param diffs - the hunks to render. * @returns the body rows, the +/- totals, and the distinct-file count. */ @@ -78,12 +86,12 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed else rows.push({ kind: 'gap', text: '⋯' }) prevPath = diff.path if (diff.oldText !== null) { - for (const line of diff.oldText.split('\n')) { + for (const line of contentLines(diff.oldText)) { rows.push({ kind: 'del', text: line }) removed++ } } - for (const line of diff.newText.split('\n')) { + for (const line of contentLines(diff.newText)) { rows.push({ kind: 'add', text: line }) added++ } @@ -91,6 +99,21 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed return { rows, added, removed, files: paths.size } } +/** + * Split a side's text into its content lines. Empty text is zero lines (a full + * deletion's `newText` or a create's absent `oldText` side draws nothing), and a + * single trailing newline is a line terminator rather than an extra empty line — + * the same terminator rule TerminalBlock applies to command output. An interior + * blank line (a genuine `\n\n`) survives. + * @param text - the removed or added side's text. + * @returns the content lines, without the terminating newline. + */ +function contentLines(text: string): string[] { + if (text === '') return [] + const body = text.endsWith('\n') ? text.slice(0, -1) : text + return body.split('\n') +} + /** * The diff text a reader copies: each row's `-`/`+`/path/gap prefix and its * content, exactly what the card shows. The removed and added blocks are the @@ -103,8 +126,9 @@ function copyText(rows: DiffRow[]): string { switch (row.kind) { case 'del': return `- ${row.text}` case 'add': return `+ ${row.text}` + case 'path': return row.text case 'gap': return row.text - default: return row.text + default: return assertNever(row.kind) } }).join('\n') } diff --git a/packages/client/ui-primitives/tests/diff-block.spec.tsx b/packages/client/ui-primitives/tests/diff-block.spec.tsx index d732a13315..bb4a2fdac3 100644 --- a/packages/client/ui-primitives/tests/diff-block.spec.tsx +++ b/packages/client/ui-primitives/tests/diff-block.spec.tsx @@ -76,6 +76,26 @@ describe('DiffBlock structure', () => { const { container } = render() expect(container.firstChild).toBeNull() }) + + it('treats a trailing newline as a terminator, not an extra blank line', () => { + // A create whose newText ends in a newline is one added line, not two, and + // the footer counts one — the phantom `+ ` empty line the naive split drew. + const { container } = render() + expect(changeRows(container)).toEqual(['hello']) + expect(screen.getByText('└ +1 -0 · 1 file')).toBeTruthy() + }) + + it('renders a full deletion as removed-only with no phantom added line', () => { + // newText '' is zero added lines: an empty string must contribute nothing. + const { container } = render() + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(0) + expect(screen.getByText('└ +0 -2 · 1 file')).toBeTruthy() + }) + + it('keeps a genuine interior blank line', () => { + const { container } = render() + expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3) + }) }) describe('DiffBlock footer', () => { From a0a9e9733a7af0500046d24213cb44eb9bbba845 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 19:04:56 +0800 Subject: [PATCH 05/26] docs: re-record ui-conversation README pairing after master merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index a8ec59bb6a..bbde233bbc 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: d3cd5cc268b60b58bb4dbb6c3b6c118084c0def8 -README.zh.md: f3a835ed82ecbd266b9f0829acc6182209940cb5 +README.md: 14b754a1a55c5455a068e45077f58411a380d955 +README.zh.md: 554a33779000d773010054766edfa0cb2e9461e0 From 452f11907e0618610bc83ef66d43f14628d0816f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 19:42:11 +0800 Subject: [PATCH 06/26] test(ui-primitives): exclude DiffBlock's assertNever default arm from coverage The copyText switch's default arm calls assertNever, the closed-union backstop that the per-file 100% coverage gate cannot reach without a forged row kind. The assertNever function itself already carries the v8 ignore; mark the switch arm that reaches it the same way, matching TodoPanel's StatusGlyph default arm. --- packages/client/ui-primitives/src/DiffBlock.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index 12c389c72b..5ae28bc9b2 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -128,6 +128,7 @@ function copyText(rows: DiffRow[]): string { case 'add': return `+ ${row.text}` case 'path': return row.text case 'gap': return row.text + /* v8 ignore next -- closed-union backstop; only reached if a row kind is forged */ default: return assertNever(row.kind) } }).join('\n') From 9eb0ef4c3e40bf7d71ae907729770ddf9b2f8adb Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 04:50:18 -0700 Subject: [PATCH 07/26] feat(client): confirm before enabling full access --- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 24 ++++ .../src/client/contract/slots.ts | 2 + .../src/client/skeleton/InputBar.tsx | 4 +- .../skeleton/PermissionSelect.module.css | 70 ++++++++++ .../src/client/skeleton/PermissionSelect.tsx | 128 ++++++++++++++---- .../ui-conversation/tests/input-bar.spec.tsx | 97 +++++++++++-- .../tests/input-matrix.spec.tsx | 1 + .../tests/input-scenarios.spec.tsx | 1 + .../ui-conversation/tests/skeleton.spec.tsx | 1 + packages/client/ui-primitives/src/Modal.tsx | 6 +- .../client/ui-primitives/tests/atoms.spec.tsx | 3 +- 13 files changed, 289 insertions(+), 52 deletions(-) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index fc466190a7..c0e04e0d12 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -8,7 +8,7 @@ The resident conversation shell survives no-session and session transitions. Wit The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves. -Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels (the `/permission` popup's display transform twin), and a pick submits the `/permission ` command line through the bar's injected `command` callback. +Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f6fbff9c1e..193cfbe92e 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。 -审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 +审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission `,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。 todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c6b597ae79..8511c2c0ba 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -95,6 +95,28 @@ export function apply(ctx: Context): void { }, 'ui-conversation: command hint dictionaries') const translateHint = ctx.locale.bind(HINT_NS) + const ACCESS_NS = 'conversation.access' + ctx.effect(() => { + const disposers = [ + ctx.locale.register(ACCESS_NS, 'zh', { + 'confirm.title': '确认启用 Full access?', + 'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', + 'confirm.acknowledge': '我已了解风险,并愿意继续', + 'confirm.cancel': '取消', + 'confirm.enable': '启用 Full access', + }), + ctx.locale.register(ACCESS_NS, 'en', { + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', + }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-conversation: Access control dictionaries') + const translateAccess = ctx.locale.bind(ACCESS_NS) + // Apply-time construction keeps store identity bound to this fiber. const chatStore = createChatStore() @@ -199,6 +221,7 @@ export function apply(ctx: Context): void { stop: undefined, command: undefined, translateHint, + translateAccess, hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON }, } } @@ -217,6 +240,7 @@ export function apply(ctx: Context): void { return result.ok && result.value.matched }, translateHint, + translateAccess, hooks: { notices: shell.notices, lexicon: shell.lexicon }, } }, diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1684e616b2..093f10edc5 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -284,6 +284,8 @@ export interface ComposerBarInjected { command: ((line: string) => Promise) | undefined /** Locale-aware hint translator for claimed command placeholders (session-independent — always present). */ translateHint: (key: string) => string + /** Locale-aware copy for the Access control's Full access risk confirmation. */ + translateAccess: (key: string) => string /** * Registrant hooks compartment: the renderer binds these to * useNotices/useLexicon (static absent sources without a session — hook diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 77747e0ae2..4e2b366f7f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -33,7 +33,7 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps export function InputBar({ - useSession, useInput, inputActions, keyboard, stop, command, translateHint, renderSlot, useNotices, useLexicon, + useSession, useInput, inputActions, keyboard, stop, command, translateHint, translateAccess, renderSlot, useNotices, useLexicon, useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer, onAdd, addLabel = 'Add attachment', }: InputBarProps) { @@ -272,7 +272,7 @@ export function InputBar({ // or while the command face is absent with the session). const accessSelect: ReactNode = command === undefined ? null - : + : // Mirror-layer decorations: a visible backdrop with transparent text. The // claim token highlights through behind the textarea glyphs; each U+FFFC diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index 50dce3913f..bbc51b10e9 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -41,3 +41,73 @@ flex: 0 0 auto; color: var(--dsw-alias-label-caption); } + +.confirmation { + width: min(440px, 100%); + max-height: calc(100vh - 48px); + overflow: hidden; +} + +.confirmationContent { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +@supports (height: 100dvh) { + .confirmation { + max-height: calc(100dvh - 48px); + } +} + +.warning { + display: flex; + align-items: flex-start; + gap: 10px; + color: var(--dsw-alias-label-secondary); + font-size: 14px; + line-height: 22px; +} + +.warning p { + margin: 0; +} + +.warningIcon { + flex: none; + margin-top: 2px; + color: var(--dsw-alias-state-error-primary); +} + +.acknowledgement { + display: flex; + align-items: flex-start; + gap: 10px; + margin-top: 20px; + color: var(--dsw-alias-label-primary); + font-size: 14px; + line-height: 22px; + cursor: pointer; +} + +.acknowledgement input { + flex: none; + width: 16px; + height: 16px; + margin: 3px 0 0; + accent-color: var(--dsw-alias-button-primary-fill); + cursor: pointer; +} + +.acknowledgement input:focus-visible { + outline: 2px solid var(--dsw-alias-border-l4); + outline-offset: 2px; +} + +.modalAction { + min-width: 72px; +} + +.confirmAction { + min-width: 136px; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 873c8c11c4..567abaa27e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -1,9 +1,11 @@ import { useState } from 'react' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' -import { Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, IconWarningOutline16, Menu, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PermissionSelect.module.css' +const FULL_ACCESS = 'danger-full-access' + /** * Display transform: kebab-case machine names render as title-case labels * (`workspace-write` → `Workspace Write`); non-kebab host-configured names @@ -15,58 +17,124 @@ function displayName(name: string): string { return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') } +function optionLabel(option: PermissionSelectValue['options'][number]): string { + return option.value === FULL_ACCESS ? 'Full access' : displayName(option.name) +} + export interface PermissionSelectProps { value: PermissionSelectValue | undefined locked: boolean command: (line: string) => Promise + t: (key: string) => string } -export function PermissionSelect({ value, locked, command }: PermissionSelectProps) { +export function PermissionSelect({ value, locked, command, t }: PermissionSelectProps) { const [pick, setPick] = useState(null) const [open, setOpen] = useState(false) + const [confirmation, setConfirmation] = useState(null) + const [acknowledged, setAcknowledged] = useState(false) if (value === undefined) return null const currentValue = pick ?? value.currentValue const current = value.options.find(option => option.value === currentValue) - const busy = pick !== null + const busy = pick !== null || confirmation !== null const items: MenuEntry[] = value.options .filter(o => o.value !== 'custom') - .map(option => ({ id: option.value, label: displayName(option.name) })) + .map(option => ({ id: option.value, label: optionLabel(option) })) - const choose = (id: string): void => { - setOpen(false) - if (id === value.currentValue) return + const submit = (id: string): void => { setPick(id) void command(`/permission ${id}`) .catch(() => false) .then(() => { setPick(null) }) } + const choose = (id: string): void => { + setOpen(false) + if (id === value.currentValue) return + if (id === FULL_ACCESS) { + setAcknowledged(false) + setConfirmation(id) + return + } + submit(id) + } + + const closeConfirmation = (): void => { + setAcknowledged(false) + setConfirmation(null) + } + + const confirmFullAccess = (): void => { + if (!acknowledged || confirmation === null) return + const id = confirmation + closeConfirmation() + submit(id) + } + return ( - { setOpen(false) }} - side="top" - anchor={ - - } - /> + <> + { setOpen(false) }} + side="top" + anchor={ + + } + /> + + + + + )} + > +
+ +

{t('confirm.description')}

+
+ +
+ ) } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index c0bfda7f4a..2fa04b6af4 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -43,6 +43,8 @@ interface BenchOptions { variant?: 'hero' | 'composer' placeholder?: string translateHint?: (key: string) => string + translateAccess?: (key: string) => string + command?: (line: string) => Promise accessory?: React.ReactNode overlay?: React.ReactNode leftItems?: React.ReactNode @@ -100,12 +102,19 @@ function bench(over?: BenchOptions) { useNotices: bindSnapshotSelector(shell.notices), useLexicon: bindSnapshotSelector(shell.lexicon), stop, - command: () => Promise.resolve(true), + command: over?.command ?? (() => Promise.resolve(true)), // Mirrors the en 'command.hint' locale entries the production apply wires in. translateHint: over?.translateHint ?? ((key: string) => ({ 'placeholder.default': 'Message the agent', 'placeholder.plan': 'describe your task to generate plan', } as Record)[key] ?? key), + translateAccess: over?.translateAccess ?? ((key: string) => ({ + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access can perform sensitive operations.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', + } as Record)[key] ?? key), renderSlot, variant: over?.variant ?? 'composer', ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), @@ -450,7 +459,35 @@ describe('placeholder chrome and control seats', () => { expect(view.queryByLabelText('Model')).toBeNull() }) - it('the Access chip renders the projection value and submits /permission on pick', async () => { + it('the Access chip renders the projection value and submits a non-Full-access pick directly', async () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'read-only', name: 'read-only' }, + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'read-only', + } + const { view } = bench({ permissions, command }) + const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement + // Title-case display is presentation only; the menu ids stay machine names. + expect(trigger.textContent).toBe('Read Only') + fireEvent.click(trigger) + const items = view.getAllByRole('menuitem') + expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access']) + fireEvent.click(items[1]!) + // Optimistic pick + disable until admission resolves (command stub resolves true). + const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement + expect(busy.textContent).toBe('Workspace Write') + expect(busy.disabled).toBe(true) + expect(command).toHaveBeenCalledWith('/permission workspace-write') + await act(async () => {}) + expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false) + }) + + it('requires explicit risk acknowledgement before submitting Full access', async () => { + const command = vi.fn(() => Promise.resolve(true)) const permissions = { options: [ { value: 'workspace-write', name: 'workspace-write' }, @@ -458,20 +495,50 @@ describe('placeholder chrome and control seats', () => { ], currentValue: 'workspace-write', } - const { view } = bench({ permissions }) - const trigger = view.getByLabelText(/^Access mode/) as HTMLButtonElement - // Title-case display is presentation only; the menu ids stay machine names. - expect(trigger.textContent).toBe('Workspace Write') - fireEvent.click(trigger) - const items = view.getAllByRole('menuitem') - expect(items.map(o => o.textContent)).toEqual(['Workspace Write', 'Danger Full Access']) - fireEvent.click(items[1]!) - // Optimistic pick + disable until admission resolves (command stub resolves true). - const busy = view.getByLabelText(/^Access mode/) as HTMLButtonElement - expect(busy.textContent).toBe('Danger Full Access') - expect(busy.disabled).toBe(true) + const { view } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + + expect(command).not.toHaveBeenCalled() + expect(view.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy() + const enable = view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement + expect(enable.disabled).toBe(true) + + fireEvent.click(view.getByRole('checkbox', { name: 'I understand the risks and want to continue' })) + expect(enable.disabled).toBe(false) + fireEvent.click(enable) + + expect(command).toHaveBeenCalledOnce() + expect(command).toHaveBeenCalledWith('/permission danger-full-access') + expect(view.queryByRole('dialog')).toBeNull() + expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).textContent).toBe('Full access') await act(async () => {}) - expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).disabled).toBe(false) + }) + + it('cancels a Full access selection without changing permission and resets acknowledgement', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view } = bench({ permissions, command }) + const openConfirmation = () => { + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + } + + openConfirmation() + fireEvent.click(view.getByRole('checkbox')) + fireEvent.click(view.getByRole('button', { name: 'Cancel' })) + expect(command).not.toHaveBeenCalled() + expect((view.getByLabelText(/^Access mode/) as HTMLButtonElement).textContent).toBe('Workspace Write') + + openConfirmation() + expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false) + expect((view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement).disabled).toBe(true) }) it('a registered entry fills its seat and receives the locked owner prop', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index a9c00b0748..7df065bb8f 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -49,6 +49,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled stop: vi.fn(), command: () => Promise.resolve(true), translateHint: (key: string) => key, + translateAccess: (key: string) => key, variant: 'composer', } return render() diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 1c7bbe50ec..5915703b06 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -135,6 +135,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { stop: vi.fn(), command: () => Promise.resolve(true), translateHint: (key: string) => key, + translateAccess: (key: string) => key, variant: 'composer', } const view = render() diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 3ed459b5a9..0d60462d9b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -150,6 +150,7 @@ function mount( stop={stop} command={() => Promise.resolve(true)} translateHint={(key: string) => key} + translateAccess={(key: string) => key} renderSlot={(() => null) as InputBarProps['renderSlot']} {...bar} /> diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index ef790c8b6a..f25135d1da 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -16,12 +16,13 @@ import css from './Modal.module.css' * @param props.description - optional supporting sentence under the title. * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). + * @param props.contentClassName - optional class for a scrollable content region. * @param props.headless - render children directly in the card (no default * header/close/body chrome) for dialogs whose figma frame owns its own * header structure; mask, card, Escape, and aria-label remain. * @returns null when closed; otherwise the overlay tree. */ -export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: { +export function Modal({ open, onClose, title, description, children, footer, className, contentClassName, headless = false }: { open: boolean onClose: () => void title: string @@ -29,6 +30,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla children?: ReactNode footer?: ReactNode className?: string + contentClassName?: string headless?: boolean }) { useEffect(() => { @@ -55,7 +57,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla ? children : ( <> -
+

{title}

}> + Create}> ) expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined() expect(screen.getByText('Name it.')).toBeDefined() + expect(screen.getByText('Name it.').parentElement?.className).toContain('scrolling-content') fireEvent.keyDown(document, { key: 'a' }) expect(onClose).not.toHaveBeenCalled() fireEvent.keyDown(document, { key: 'Escape' }) From b76a551e10777aeab38ab177141d60d5192c507d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 20:25:05 +0800 Subject: [PATCH 08/26] docs: re-record ui-conversation README pairing after master merge --- packages/client/ui-conversation/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 1588367646..78e7d2f143 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: fc466190a744a1c13094ca6ebf62755d5bf49c98 -README.zh.md: f6fbff9c1e5d005b64e928680bbf401d94e4ce79 +README.md: 186ae70d16e9e1f1ffeadac441140ae2e5dfd2b5 +README.zh.md: 42604fde855b9e0fadabae9e871362b488957be2 From 6d416b06c59ca7435f47bfb45439f89d3ddda01b Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 30 Jul 2026 05:48:21 -0700 Subject: [PATCH 09/26] fix(client): gate every full access picker --- apps/web/tests/access-confirmation.e2e.ts | 87 +++++++++++++ .../access-confirmation/ui.expected.md | 10 ++ .../snapshots/code-mode-round/ui.expected.md | 2 +- .../cordis-tool-round/ui.expected.md | 2 +- .../snapshots/fresh-round-trip/ui.expected.md | 2 +- .../lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/reloaded.expected.md | 2 +- .../live-interactions/cancel.expected.md | 2 +- .../live-interactions/error-auth.expected.md | 2 +- .../live-interactions/retry.expected.md | 2 +- .../snapshots/message-actions/ui.expected.md | 2 +- .../question-composer/answered.expected.md | 2 +- .../queue-actions/editing.expected.md | 2 +- .../snapshots/queue-actions/ui.expected.md | 2 +- .../snapshots/seeded-history/ui.expected.md | 2 +- .../snapshots/steering/settled.expected.md | 2 +- apps/web/tsconfig.json | 3 +- .../ui-command/src/client/PopupSelectView.tsx | 121 ++++++++++-------- .../client/ui-command/src/client/contract.ts | 11 ++ .../client/ui-command/src/client/index.ts | 2 +- .../client/ui-command/src/client/popup.ts | 53 +++++++- .../ui-command/tests/popup-view.spec.tsx | 42 ++++++ .../client/ui-command/tests/popup.spec.ts | 43 +++++++ .../skeleton/PermissionSelect.module.css | 70 ---------- .../src/client/skeleton/PermissionSelect.tsx | 63 ++++----- .../ui-conversation/tests/input-bar.spec.tsx | 36 ++++++ packages/client/ui-permission/package.json | 3 + .../client/ui-permission/src/client/index.ts | 52 +++++++- .../tests/browser-plugin.spec.ts | 20 ++- packages/client/ui-primitives/src/Modal.tsx | 10 +- .../src/RiskConfirmation.module.css | 73 +++++++++++ .../ui-primitives/src/RiskConfirmation.tsx | 80 ++++++++++++ packages/client/ui-primitives/src/index.ts | 2 + .../client/ui-primitives/tests/atoms.spec.tsx | 6 +- pnpm-lock.yaml | 3 + tsconfig.host.json | 1 + 36 files changed, 624 insertions(+), 195 deletions(-) create mode 100644 apps/web/tests/access-confirmation.e2e.ts create mode 100644 apps/web/tests/snapshots/access-confirmation/ui.expected.md create mode 100644 packages/client/ui-primitives/src/RiskConfirmation.module.css create mode 100644 packages/client/ui-primitives/src/RiskConfirmation.tsx diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts new file mode 100644 index 0000000000..e8a0875c29 --- /dev/null +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -0,0 +1,87 @@ +// Web e2e scenario: every visible permission picker gates Full access behind +// the same locale-aware, in-page risk confirmation. Zero model calls: the +// scenario boots the shipped Web composition and exercises the real +// permission projection, client command path, HTTP RPC, and pushed update. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() + +describe('web e2e: Full access confirmation', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // CI uses Playwright's pinned browser. A developer may point this one + // scenario at an installed Chromium when the matching browser download + // is temporarily unavailable. + const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH + browser = await chromium.launch(executablePath === undefined ? {} : { executablePath }) + // Keep the product default Chinese locale: the golden pins the actual + // registered dictionary rather than a test-local translation callback. + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('requires acknowledgement before the composer picker can enable Full access', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation')) + const access = page.locator('button[aria-label^="Access mode"]').first() + await access.waitFor({ timeout: 10_000 }) + + // Normalize the starting preset through the real command path. The + // shipped web config may already start at Full access. + if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) { + await access.click() + await page.getByRole('menuitem', { name: 'Workspace Write' }).click() + await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) + .toBe('Access mode, current: Workspace Write') + } + + await access.click() + await page.getByRole('menuitem', { name: 'Full access' }).click() + const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' }) + await dialog.waitFor({ timeout: 10_000 }) + const enable = dialog.getByRole('button', { name: '启用 Full access' }) + expect(await enable.isDisabled()).toBe(true) + + // The modal is in this page's body (not a native/new window) and escapes + // the sticky composer's stacking context. + expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true) + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + + await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check() + expect(await enable.isEnabled()).toBe(true) + await enable.click() + await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 }) + .toBe('Access mode, current: Full access') + expect(await dialog.count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/access-confirmation/ui.expected.md b/apps/web/tests/snapshots/access-confirmation/ui.expected.md new file mode 100644 index 0000000000..1287e6e565 --- /dev/null +++ b/apps/web/tests/snapshots/access-confirmation/ui.expected.md @@ -0,0 +1,10 @@ +- dialog "确认启用 Full access?": + - heading "确认启用 Full access?" [level=2] + - button "Close": + - img + - img + - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。 + - checkbox "我已了解风险,并愿意继续" + - text: 我已了解风险,并愿意继续 + - button "取消" + - button "启用 Full access" [disabled] diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index 0282a16f80..a57dae2a6a 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -35,7 +35,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 5b51e47cf4..313ee2c08e 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -49,7 +49,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 49c7958292..9552c89d80 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -32,7 +32,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 65abda0dba..a76f170451 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -28,7 +28,7 @@ - textbox "Describe what you want to build" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 45e3514fa4..194efaa7c4 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -24,7 +24,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4323c94285..bbe2387634 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -21,7 +21,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash 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..6812119684 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -14,7 +14,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 6a9c808342..293ed5bc37 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -24,7 +24,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 19ba02d99d..7546b7b124 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -35,7 +35,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index 36752c783a..583878f2be 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -32,7 +32,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 2594f18294..30cc7f4a2c 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -28,7 +28,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 919617bdab..cd8427dff2 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -22,7 +22,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index efc43a272e..3d691ea25d 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -38,7 +38,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current deepseek-v4-flash": - text: deepseek-v4-flash diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 5efbcf385d..979f16e72b 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -32,7 +32,7 @@ - textbox "Message the agent" - button "Add attachment": - img -- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- 'button "Access mode, current: Full access"': Full access - button "Plan mode off, press to turn on": Plan off - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7a7f228fb0..9f8c9c371c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -38,7 +38,8 @@ "tests/cordis-tool-round.e2e.ts", "tests/message-actions.e2e.ts", "tests/queue-actions.e2e.ts", - "tests/skill-invocation-policy.e2e.ts" + "tests/skill-invocation-policy.e2e.ts", + "tests/access-confirmation.e2e.ts" ], "references": [ { diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx index ec0bbdd2bb..7e7ad91c34 100644 --- a/packages/client/ui-command/src/client/PopupSelectView.tsx +++ b/packages/client/ui-command/src/client/PopupSelectView.tsx @@ -12,7 +12,7 @@ import { useEffect, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import { filterOptions } from './popup.ts' import type { PopupSelectController } from './popup.ts' import css from './PopupSelectView.module.css' @@ -56,23 +56,24 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { // closes the shell before its own handlers run; that click's target then // takes focus naturally, so no focusComposer here. useEffect(() => { - if (!state.open) return + if (!state.open || state.confirming !== null) return const onPointerDown = (ev: PointerEvent): void => { if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return popup.dismiss() } document.addEventListener('pointerdown', onPointerDown, true) return () => { document.removeEventListener('pointerdown', onPointerDown, true) } - }, [state.open, popup]) + }, [state.open, state.confirming, popup]) // Focus the search input after it mounts (separate effect so the ref is populated). useEffect(() => { - if (state.open) searchRef.current?.focus() - }, [state.open]) + if (state.open && state.confirming === null) searchRef.current?.focus() + }, [state.open, state.confirming]) if (!state.open) return null const rows = filterOptions(state.options, state.search) + const confirmation = state.confirming?.confirmation const onKeyDown = (ev: React.KeyboardEvent): void => { // ArrowLeft/ArrowRight fall through on purpose: the search input keeps @@ -99,55 +100,73 @@ export function PopupSelectView({ popup }: PopupSelectInjected) { } return ( -
- { popup.setSearch(ev.currentTarget.value) }} - /> - {state.error !== null && ( -
- {state.error} - {state.status === 'failed' && ( - + <> + {state.confirming === null && ( +
+ { popup.setSearch(ev.currentTarget.value) }} + /> + {state.error !== null && ( +
+ {state.error} + {state.status === 'failed' && ( + + )} +
+ )} + {state.status === 'pending' &&
Loading options…
} + {state.submitting &&
Applying…
} + {state.status === 'ready' && rows.length === 0 &&
No options
} + {state.status === 'ready' && ( +
+ {rows.map((option, index) => ( +
{ void popup.select(index) }} + onMouseEnter={() => { popup.highlight(index) }} + > + {option.label} + {option.detail !== undefined && {option.detail}} + {option.active === true && } +
+ ))} +
)}
)} - {state.status === 'pending' &&
Loading options…
} - {state.submitting &&
Applying…
} - {state.status === 'ready' && rows.length === 0 &&
No options
} - {state.status === 'ready' && ( -
- {rows.map((option, index) => ( -
{ void popup.select(index) }} - onMouseEnter={() => { popup.highlight(index) }} - > - {option.label} - {option.detail !== undefined && {option.detail}} - {option.active === true && } -
- ))} -
+ {confirmation !== undefined && ( + { popup.acknowledge(value) }} + onCancel={() => { popup.cancelConfirmation() }} + onConfirm={() => { void popup.confirm() }} + /> )} -
+ ) } diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts index 61ab4de2e2..8498b5def8 100644 --- a/packages/client/ui-command/src/client/contract.ts +++ b/packages/client/ui-command/src/client/contract.ts @@ -6,12 +6,23 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' +/** Copy for an option that must be acknowledged before onSelect can run. */ +export interface SelectConfirmation { + readonly title: string + readonly description: string + readonly acknowledgeLabel: string + readonly cancelLabel: string + readonly confirmLabel: string +} + /** One option row of a popupSelect shell. */ export interface SelectOption { readonly id: string readonly label: string readonly detail?: string readonly active?: boolean + /** Optional in-page risk gate owned by the shared popup shell. */ + readonly confirmation?: SelectConfirmation } /** diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index f40078212e..95b3824805 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -21,7 +21,7 @@ export { filterOptions, PopupSelectController } from './popup.ts' export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts' export type { PopupSelectInjected } from './PopupSelectView.tsx' export type { - CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption, + CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectConfirmation, SelectOption, } from './contract.ts' declare module 'cordis' { diff --git a/packages/client/ui-command/src/client/popup.ts b/packages/client/ui-command/src/client/popup.ts index c2d30f3213..5e20911820 100644 --- a/packages/client/ui-command/src/client/popup.ts +++ b/packages/client/ui-command/src/client/popup.ts @@ -67,12 +67,17 @@ export interface PopupState { readonly active: number /** A select() settlement is in flight: further select/search/highlight no-op until it settles. */ readonly submitting: boolean + /** Option waiting for explicit risk acknowledgement; null during normal selection. */ + readonly confirming: SelectOption | null + /** Caller-controlled checkbox state for the pending confirmation. */ + readonly acknowledged: boolean /** Surfaced settlement failure (options load or onSelect); null when none. */ readonly error: string | null } const CLOSED: PopupState = { - open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null, + open: false, command: null, status: 'pending', options: [], search: '', active: 0, + submitting: false, confirming: null, acknowledged: false, error: null, } /** @@ -166,7 +171,7 @@ export class PopupSelectController { */ setSearch(search: string): void { const s = this.state.getSnapshot() - if (!s.open || s.submitting || search === s.search) return + if (!s.open || s.submitting || s.confirming !== null || search === s.search) return this.state.set({ ...s, search, active: 0 }) } @@ -177,7 +182,7 @@ export class PopupSelectController { */ move(dir: 1 | -1): void { const s = this.state.getSnapshot() - if (!s.open || s.status !== 'ready' || s.submitting) return + if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return const rows = filterOptions(s.options, s.search) if (rows.length === 0) return const active = (s.active + dir + rows.length) % rows.length @@ -191,7 +196,7 @@ export class PopupSelectController { */ highlight(index: number): void { const s = this.state.getSnapshot() - if (!s.open || s.status !== 'ready' || s.submitting) return + if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return this.state.set({ ...s, active: index }) } @@ -209,10 +214,46 @@ export class PopupSelectController { async select(index: number): Promise { const binding = this.binding const s = this.state.getSnapshot() - if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return + if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return const option = filterOptions(s.options, s.search)[index] if (option === undefined) return - this.state.set({ ...s, submitting: true, error: null }) + if (option.confirmation !== undefined) { + this.state.set({ ...s, confirming: option, acknowledged: false, error: null }) + return + } + await this.settle(binding, option) + } + + /** + * Update the explicit checkbox for the currently pending risk gate. + * @param acknowledged - whether the user has acknowledged the displayed risk. + */ + acknowledge(acknowledged: boolean): void { + const s = this.state.getSnapshot() + if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return + this.state.set({ ...s, acknowledged }) + } + + /** Cancel only the risk gate and return to the still-open option picker. */ + cancelConfirmation(): void { + const s = this.state.getSnapshot() + if (!s.open || s.submitting || s.confirming === null) return + this.state.set({ ...s, confirming: null, acknowledged: false }) + } + + /** Settle the gated option only after the checkbox is acknowledged. */ + async confirm(): Promise { + const binding = this.binding + const s = this.state.getSnapshot() + if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return + await this.settle(binding, s.confirming) + } + + /** Run the business settlement for an already admitted option. */ + private async settle(binding: OpenBinding, option: SelectOption): Promise { + const s = this.state.getSnapshot() + if (this.binding !== binding || !s.open || s.submitting) return + this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null }) try { await binding.spec.onSelect(option, binding.context) } catch (error) { diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx index 2cd9891478..db2491fef0 100644 --- a/packages/client/ui-command/tests/popup-view.spec.tsx +++ b/packages/client/ui-command/tests/popup-view.spec.tsx @@ -32,6 +32,17 @@ const OPTIONS: SelectOption[] = [ { id: 'light', label: 'Light', active: true }, { id: 'sepia', label: 'Sepia', detail: 'warm' }, ] +const GATED: SelectOption = { + id: 'full', + label: 'Full access', + confirmation: { + title: 'Enable Full access?', + description: 'Sensitive operations.', + acknowledgeLabel: 'I understand the risks', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }, +} const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } @@ -143,6 +154,37 @@ describe('PopupSelectView', () => { expect(view.container.childElementCount).toBe(0) }) + it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => { + const onSelect = vi.fn() + const { popup, consume } = await mountOpen({ + options: () => Promise.resolve([GATED]), + onSelect, + }) + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + expect(screen.queryByLabelText('/theme options')).toBeNull() + expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy() + const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement + expect(enable.disabled).toBe(true) + expect(onSelect).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' })) + expect(enable.disabled).toBe(false) + await act(async () => { fireEvent.click(enable) }) + expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A') + expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('canceling a gated option returns to the picker with acknowledgement reset', async () => { + await mountOpen({ options: () => Promise.resolve([GATED]) }) + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByLabelText('/theme options')).toBeTruthy() + await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) }) + expect(screen.getByRole('checkbox').checked).toBe(false) + }) + it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => { let release!: () => void const onSelect = vi.fn(() => new Promise((resolve) => { release = resolve })) diff --git a/packages/client/ui-command/tests/popup.spec.ts b/packages/client/ui-command/tests/popup.spec.ts index 87a1070a40..68e8084b80 100644 --- a/packages/client/ui-command/tests/popup.spec.ts +++ b/packages/client/ui-command/tests/popup.spec.ts @@ -19,6 +19,17 @@ const OPTIONS: SelectOption[] = [ { id: 'light', label: 'Light', active: true }, { id: 'sepia', label: 'Sepia', detail: 'warm' }, ] +const GATED: SelectOption = { + id: 'full', + label: 'Full access', + confirmation: { + title: 'Enable Full access?', + description: 'Sensitive operations.', + acknowledgeLabel: 'I understand', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }, +} const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' } @@ -200,6 +211,38 @@ describe('search / move / highlight over the filtered list', () => { }) describe('select', () => { + it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => { + const onSelect = vi.fn() + const deps = makeDeps() + const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps) + await popup.select(0) + expect(popup.state.getSnapshot()).toMatchObject({ + open: true, confirming: GATED, acknowledged: false, submitting: false, + }) + expect(onSelect).not.toHaveBeenCalled() + await popup.confirm() + expect(onSelect).not.toHaveBeenCalled() + popup.acknowledge(true) + await popup.confirm() + expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A) + expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT) + expect(popup.state.getSnapshot().open).toBe(false) + }) + + it('cancels a confirmation back to the picker without selecting or consuming', async () => { + const onSelect = vi.fn() + const deps = makeDeps() + const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps) + await popup.select(0) + popup.acknowledge(true) + popup.cancelConfirmation() + expect(popup.state.getSnapshot()).toMatchObject({ + open: true, confirming: null, acknowledged: false, submitting: false, + }) + expect(onSelect).not.toHaveBeenCalled() + expect(deps.consume).not.toHaveBeenCalled() + }) + it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => { const seen: Array<{ option: SelectOption; context: Ctx }> = [] const deps = makeDeps() diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index bbc51b10e9..50dce3913f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -41,73 +41,3 @@ flex: 0 0 auto; color: var(--dsw-alias-label-caption); } - -.confirmation { - width: min(440px, 100%); - max-height: calc(100vh - 48px); - overflow: hidden; -} - -.confirmationContent { - min-height: 0; - overflow-y: auto; - overscroll-behavior: contain; -} - -@supports (height: 100dvh) { - .confirmation { - max-height: calc(100dvh - 48px); - } -} - -.warning { - display: flex; - align-items: flex-start; - gap: 10px; - color: var(--dsw-alias-label-secondary); - font-size: 14px; - line-height: 22px; -} - -.warning p { - margin: 0; -} - -.warningIcon { - flex: none; - margin-top: 2px; - color: var(--dsw-alias-state-error-primary); -} - -.acknowledgement { - display: flex; - align-items: flex-start; - gap: 10px; - margin-top: 20px; - color: var(--dsw-alias-label-primary); - font-size: 14px; - line-height: 22px; - cursor: pointer; -} - -.acknowledgement input { - flex: none; - width: 16px; - height: 16px; - margin: 3px 0 0; - accent-color: var(--dsw-alias-button-primary-fill); - cursor: pointer; -} - -.acknowledgement input:focus-visible { - outline: 2px solid var(--dsw-alias-border-l4); - outline-offset: 2px; -} - -.modalAction { - min-width: 72px; -} - -.confirmAction { - min-width: 136px; -} diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 567abaa27e..d2f8cf8ec0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh-permission/client' -import { Button, IconWarningOutline16, Menu, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import css from './PermissionSelect.module.css' @@ -9,8 +9,9 @@ const FULL_ACCESS = 'danger-full-access' /** * Display transform: kebab-case machine names render as title-case labels * (`workspace-write` → `Workspace Write`); non-kebab host-configured names - * pass through. Twin of the /permission popup's (client ui-permission) — the - * two permission surfaces must show the same text. + * pass through. Full access intentionally overrides the machine-name + * transform so both permission surfaces use the product label `Full access`; + * the warning body remains locale-aware. */ function displayName(name: string): string { if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name @@ -34,6 +35,13 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect const [confirmation, setConfirmation] = useState(null) const [acknowledged, setAcknowledged] = useState(false) + useEffect(() => { + if (!locked && value !== undefined) return + setOpen(false) + setAcknowledged(false) + setConfirmation(null) + }, [locked, value]) + if (value === undefined) return null const currentValue = pick ?? value.currentValue @@ -68,7 +76,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect } const confirmFullAccess = (): void => { - if (!acknowledged || confirmation === null) return + if (locked || !acknowledged || confirmation === null) return const id = confirmation closeConfirmation() submit(id) @@ -99,42 +107,19 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect } /> - - - - - )} - > -
- -

{t('confirm.description')}

-
- -
+ description={t('confirm.description')} + acknowledgeLabel={t('confirm.acknowledge')} + cancelLabel={t('confirm.cancel')} + confirmLabel={t('confirm.enable')} + acknowledged={acknowledged} + disabled={locked} + onAcknowledgedChange={setAcknowledged} + onCancel={closeConfirmation} + onConfirm={confirmFullAccess} + /> ) } diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 2fa04b6af4..48bfd48c53 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -541,6 +541,42 @@ describe('placeholder chrome and control seats', () => { expect((view.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement).disabled).toBe(true) }) + it('revokes an open Full access confirmation when the task locks', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view, session } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + fireEvent.click(view.getByRole('checkbox')) + act(() => { session.set(snapshotOf({ removed: true })) }) + expect(view.queryByRole('dialog')).toBeNull() + expect(command).not.toHaveBeenCalled() + }) + + it('resets an open Full access confirmation when switching tasks', () => { + const command = vi.fn(() => Promise.resolve(true)) + const permissions = { + options: [ + { value: 'workspace-write', name: 'workspace-write' }, + { value: 'danger-full-access', name: 'danger-full-access' }, + ], + currentValue: 'workspace-write', + } + const { view, props } = bench({ permissions, command }) + fireEvent.click(view.getByLabelText(/^Access mode/)) + fireEvent.click(view.getByRole('menuitem', { name: 'Full access' })) + fireEvent.click(view.getByRole('checkbox')) + view.rerender() + expect(view.queryByRole('dialog')).toBeNull() + expect(command).not.toHaveBeenCalled() + }) + it('a registered entry fills its seat and receives the locked owner prop', () => { const { view, slotCalls } = bench({ disabled: true, diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index cee54f104c..ee2d789500 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -24,6 +24,7 @@ }, "dshClient": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-command" ], @@ -35,6 +36,7 @@ }, "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-command": "^0.0.1", "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", @@ -43,6 +45,7 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index 30fc6d2dd5..2914e96f64 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -8,15 +8,21 @@ * projection (the same host-computed select the composer chip renders); a * pick submits the `/permission ` command line, so both surfaces * write through one path and the pushed projection frame is the one - * confirmation. + * confirmation. The Full access row carries the same explicit risk gate as + * the composer chip; the shared popup shell owns the modal mechanics. */ import type { ClientContext, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import type { CommandServiceContract, SelectOption } from '@deepseek-ai/dsh-client-ui-command/client' import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' /** Required services (cordis fiber inject). */ -export const inject = ['command', 'sessions'] +export const inject = ['command', 'sessions', 'locale'] + +const FULL_ACCESS = 'danger-full-access' +const ACCESS_NS = 'permission.access' /** Read one session's current permissions projection value (undefined = capability absent). */ function selectOf(session: SessionFace | undefined): PermissionSelect | undefined { @@ -26,8 +32,9 @@ function selectOf(session: SessionFace | undefined): PermissionSelect | undefine /** * Display transform twin of the composer chip's (ui-conversation * PermissionSelect): kebab-case machine names render as title-case labels - * (`workspace-write` → `Workspace Write`) so both permission surfaces show - * the same text; non-kebab host-configured names pass through. + * (`workspace-write` → `Workspace Write`); non-kebab host-configured names + * pass through. Full access intentionally uses the product label rather than + * a title-cased machine value; its warning body remains locale-aware. */ function displayName(name: string): string { if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name @@ -35,14 +42,25 @@ function displayName(name: string): string { } /** Flatten the projection select into popup rows; `custom` is display state, never a target. */ -function optionsOf(value: PermissionSelect): SelectOption[] { +function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectOption[] { return value.options .filter(option => option.value !== 'custom') .map(option => ({ id: option.value, - label: displayName(option.name), + label: option.value === FULL_ACCESS ? 'Full access' : displayName(option.name), ...(option.description !== undefined ? { detail: option.description } : {}), ...(option.value === value.currentValue ? { active: true } : {}), + ...(option.value === FULL_ACCESS + ? { + confirmation: { + title: t('confirm.title'), + description: t('confirm.description'), + acknowledgeLabel: t('confirm.acknowledge'), + cancelLabel: t('confirm.cancel'), + confirmLabel: t('confirm.enable'), + }, + } + : {}), })) } @@ -54,6 +72,26 @@ function optionsOf(value: PermissionSelect): SelectOption[] { export function apply(ctx: ClientContext): void { const command = ctx.get('command') as CommandServiceContract const sessions = ctx.sessions + ctx.effect(() => { + const disposers = [ + ctx.locale.register(ACCESS_NS, 'zh', { + 'confirm.title': '确认启用 Full access?', + 'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。', + 'confirm.acknowledge': '我已了解风险,并愿意继续', + 'confirm.cancel': '取消', + 'confirm.enable': '启用 Full access', + }), + ctx.locale.register(ACCESS_NS, 'en', { + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', + }), + ] + return () => { for (const dispose of disposers) dispose() } + }, 'ui-permission: Full access confirmation dictionaries') + const t = ctx.locale.bind(ACCESS_NS) const sessionFor = (session: ClientSessionContext): SessionFace | undefined => sessions.binding(session.sessionId)?.session ctx.effect(() => command.decorate({ @@ -67,7 +105,7 @@ export function apply(ctx: ClientContext): void { options: (session) => { const value = selectOf(sessionFor(session)) if (value === undefined) throw new Error('permission presets are not available on this host') - return Promise.resolve(optionsOf(value)) + return Promise.resolve(optionsOf(value, t)) }, onSelect: async (option, session) => { const live = sessionFor(session) diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 5f9125db53..f8fde6d10d 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -54,6 +54,17 @@ async function bench() { ctx.provide('sessions', { binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined), }) + const en = { + 'confirm.title': 'Enable Full access?', + 'confirm.description': 'Full access can perform sensitive operations.', + 'confirm.acknowledge': 'I understand the risks and want to continue', + 'confirm.cancel': 'Cancel', + 'confirm.enable': 'Enable Full access', + } as Record + ctx.provide('locale', { + register: () => () => {}, + bind: () => (key: string) => en[key] ?? key, + }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { @@ -86,7 +97,14 @@ describe('ui-permission browser plugin', () => { expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true) expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.') // Kebab-case names title-case; non-kebab host-configured names pass through. - expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Danger Full Access']) + expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access']) + expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({ + title: 'Enable Full access?', + description: 'Full access can perform sensitive operations.', + acknowledgeLabel: 'I understand the risks and want to continue', + cancelLabel: 'Cancel', + confirmLabel: 'Enable Full access', + }) b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] }) const passthrough = await c.ui.options(proj, new AbortController().signal) expect(passthrough[0]?.label).toBe('Ask Every Time') diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index f25135d1da..d4199d9581 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,9 +1,11 @@ // Modal: controlled full-viewport dialog (create-workspace and similar). -// Fixed overlay in the React tree (no react-dom portal) so ui-primitives -// stays free of a react-dom dependency; mask tokens match figma 451:18655. +// The overlay portals to this document's body so ancestor stacking contexts +// cannot leave sticky page controls above the mask. This is still an in-page +// WebUI dialog; it never creates or targets another browser/native window. import { useEffect } from 'react' import type { ReactNode } from 'react' +import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutline16 } from './icons/index.tsx' import css from './Modal.module.css' @@ -44,7 +46,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla if (!open) return null - return ( + return createPortal((