From d2582b8dc13ac8229a1ee46da8a862f1da2c201b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 16:11:59 +0800 Subject: [PATCH 01/19] 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 02/19] 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 03/19] =?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 04/19] 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 05/19] 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 b76a551e10777aeab38ab177141d60d5192c507d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 20:25:05 +0800 Subject: [PATCH 06/19] 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 1fd6b5a107124470219687b9a761f40640257db8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 21:36:42 +0800 Subject: [PATCH 07/19] =?UTF-8?q?fix(web):=20diff=20card=20review=20?= =?UTF-8?q?=E2=80=94=20TUI=20parity,=20path-header=20overlap,=20double-res?= =?UTF-8?q?olve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the TUI diff footer onto the same terminator rule and distinct-path count the Web DiffBlock uses (a trailing newline terminates its line; two hunks in one file read as 1 file), so the two front ends' `+A -R · N file(s)` footers agree. Reserve space in the diff path header for the floating copy button so a long path no longer scrolls under it. Pass the tool's raw path to the injected openFile (which already resolves against cwd) instead of resolving twice. Rename the shared block-body CSS class to a card-neutral cardBody so a terminal-spacing tweak cannot silently move the diff card. Add a same-file two-hunk TUI unit test and an assembled built-boot assertion that the write turn renders +1 -0 · 1 file end to end. --- .../2026-07-30-web-diff-card.i18n.yaml | 4 +- .../feature/2026-07-30-web-diff-card.md | 5 ++- .../feature/2026-07-30-web-diff-card.zh.md | 5 ++- apps/web/tests/built-boot.snapshot.ts | 11 ++++++ .../src/client/chat/ToolRow.module.css | 15 ++++---- .../src/client/chat/ToolRow.tsx | 11 +++--- .../client/skeleton/DetailsPanel.module.css | 7 ++-- .../src/client/skeleton/DetailsPanel.tsx | 4 +- .../client/toolviews/file-mutation-row.tsx | 9 +++-- .../ui-primitives/src/DiffBlock.module.css | 6 ++- .../client/ui-primitives/src/DiffBlock.tsx | 12 +++--- packages/ui/tui/src/components/transcript.ts | 29 +++++++++++--- packages/ui/tui/tests/tui.spec.ts | 38 +++++++++++++++++++ 13 files changed, 116 insertions(+), 40 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 7ed620736c..f9b2cfa908 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: 8087ce698e65f78c7c6f51211ef00e3b0ab58ed9 -2026-07-30-web-diff-card.zh.md: d85ac1f2e13c7fb3732b327b40122076337ac538 +2026-07-30-web-diff-card.md: 396bdbc2843c1bbed5c6a913be436d8b9e96a81c +2026-07-30-web-diff-card.zh.md: afdeafa6e94b46b4f0fbd4a065afdac8a93ac57d 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 8087ce698e..396bdbc284 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,11 +16,12 @@ 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 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): +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: -- **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`. +- **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 on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both. - **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. +- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry. - **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. 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 d85ac1f2e1..afdeafa6e9 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,11 +16,12 @@ 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)` 页脚统计**去重后的路径数** —— 这是与 TUI 的分歧:TUI 页脚用 `diffs.length`,同文件两个 hunk 在那里读作 `2 files`,此处读作 `1 file`。 +- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚在两个前端都统计**去重后的路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。 - **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。 - **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。 +- **行终止符。** 每一侧的内容按 `TerminalBlock` 的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。本 PR 把同一规则应用到了 TUI diff 分支,因此对于真实 write/edit 调用携带的以换行结尾的内容,两个前端的 `+A -R` 页脚计数一致。 - **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。 几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。 diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 69d5d5cfae..0e1d58f965 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -108,6 +108,17 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull() }, { timeout: 10_000 }) + // The write/edit turns render a real diff card through the assembled graph + // (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text. + // The write turn's `hello fixture\n` proves the terminator rule end to end: a + // trailing newline terminates its line, so the footer reads `+1` (not a + // phantom `+2`) and one distinct file. + const diffCards = document.querySelectorAll('[data-diff]') + expect(diffCards.length).toBeGreaterThan(0) + const footers = [...document.querySelectorAll('[data-diff]')] + .map(card => card.textContent ?? '') + expect(footers.some(text => text.includes('+ hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true) + // Every bundle injected its plugin-owned style tag (the loader's CSS path). const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')] .map(style => style.getAttribute('data-plugin')) diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index e53b472c50..f1d3e7dd7e 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -113,14 +113,15 @@ color: var(--dsw-alias-label-tertiary); } -/* The two block-shaped expanded bodies: the code variant's run_code program - through CodeBlock (shiki-highlighted TypeScript) and a terminal card's - command output through TerminalBlock. Both are drawn by the shared - primitive, so only the row's indentation is this file's concern — the margin - also replaces each primitive's own standalone vertical spacing with the - flow's row rhythm. */ +/* The block-shaped expanded bodies: the code variant's run_code program through + CodeBlock (shiki-highlighted TypeScript), a terminal card's command output + through TerminalBlock, and a write/edit diff through DiffBlock. All are drawn + by a shared primitive, so only the row's indentation is this file's concern — + the margin also replaces each primitive's own standalone vertical spacing with + the flow's row rhythm. Card-neutral: it carries no terminal- or diff-specific + value, so it fits every block body. */ .codeBody, -.terminalBody { +.cardBody { margin: 4px 0 4px 22px; } diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index 52238788d1..b89f537230 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -83,9 +83,10 @@ export function ToolRow({ // A row that names a single file keeps one interaction (open that path); // args expand is off whether or not the open callback is wired yet. A card // body (terminal or diff) still expands: only the file variants carry a - // path. A write/edit row carries both a file path and a diff card, so its - // path link and its expandable card coexist — the card expands, the summary - // stays a link. + // path. A write/edit row carries both a file path and a diff card, so both + // the path link and the expandable card are offered — the collapsed row shows + // the path link, and expanding swaps it for the card body (DisclosureRow + // renders collapsedContent only while closed). const singleFile = filePath !== undefined const fileLink = singleFile && onOpenFile !== undefined const cardBody = terminalBody !== null || diffBody !== null @@ -138,9 +139,9 @@ export function ToolRow({
{terminalBody.description}
)} {terminalBody !== null - ? + ? : diffBody !== null - ? + ? : variant === 'code' ? :
{text}
} diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index 143174fe42..994ae718cb 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -101,8 +101,9 @@ font: var(--dsw-font-xs-13); } -/* The terminal card sits directly under its section label, so it drops the - primitive's standalone vertical margin; the section owns the spacing. */ -.terminal { +/* A card body (terminal or diff) sits directly under its section label, so it + drops the primitive's standalone vertical margin; the section owns the + spacing. Card-neutral: no terminal- or diff-specific value. */ +.cardBody { margin: 0; } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index d09d3ea256..5a5e19179c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -146,12 +146,12 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u {terminal.description !== undefined && (
{terminal.description}
)} - + ) } const diff = diffCardModel(material.block) - if (diff !== null) return + 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.tsx b/packages/client/ui-conversation/src/client/toolviews/file-mutation-row.tsx index e777c78932..323a73e77c 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 @@ -17,7 +17,7 @@ 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 { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' import css from './file-mutation-row.module.css' function leadingFor(state: ToolRowState) { @@ -63,8 +63,9 @@ function errorText(block: ToolRowProps['block']): string | 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. + * tool's interaction); the host's `openFile` resolves it against the session + * cwd, so this passes the tool's own path verbatim. 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) @@ -85,7 +86,7 @@ export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps diff --git a/packages/client/ui-primitives/src/DiffBlock.module.css b/packages/client/ui-primitives/src/DiffBlock.module.css index 8794dbb87e..c5b79006a3 100644 --- a/packages/client/ui-primitives/src/DiffBlock.module.css +++ b/packages/client/ui-primitives/src/DiffBlock.module.css @@ -46,10 +46,14 @@ white-space: pre; } -/* A file header: the path in the primary tone, set apart by weight. */ +/* A file header: the path in the primary tone, set apart by weight. The copy + button floats over this first row's top-right corner, so reserve space at the + line's end for it — a long path scrolls under the button otherwise, and the + button's hit area would eat clicks on the path's tail. */ .path { color: var(--dsw-alias-label-primary); font-weight: 600; + padding-right: 56px; } /* A same-file second hunk's separator (a scattered edit), in the dim tone. */ diff --git a/packages/client/ui-primitives/src/DiffBlock.tsx b/packages/client/ui-primitives/src/DiffBlock.tsx index 5ae28bc9b2..23c498b1d1 100644 --- a/packages/client/ui-primitives/src/DiffBlock.tsx +++ b/packages/client/ui-primitives/src/DiffBlock.tsx @@ -4,9 +4,10 @@ // 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. +// added side the new text in full, both split on the same terminator rule, and +// the footer counts distinct paths on both ends. 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' @@ -68,9 +69,8 @@ const ROW_CLASS: Record = { * 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 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. + * DISTINCT paths, matching the TUI diff card's footer, so two hunks in one file + * read as `1 file` on both front ends. * @param diffs - the hunks to render. * @returns the body rows, the +/- totals, and the distinct-file count. */ diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index 58d3d6a178..c9a505bbb2 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -52,15 +52,28 @@ function pretty(value: unknown): string { return displayText(serialized ?? String(value)) } +/** + * A side's content lines under the terminator rule the Web DiffBlock also + * applies: empty text is zero lines (a full deletion's `newText`, a create's + * absent `oldText`), and a single trailing newline terminates the last line + * rather than adding an empty one. An interior blank line survives. Keeping the + * two front ends on the same rule holds their `+A -R` footers in step. + */ +function diffContentLines(text: string): string[] { + if (text === '') return [] + const body = text.endsWith('\n') ? text.slice(0, -1) : text + return body.split('\n') +} + /** A file diff as colored `+`/`-` lines, optionally prefixed with its path. */ function diffLines(diff: FileDiff, palette: Palette): string[] { // The card header is a fixed `Tool / ` frame that never names a file, so // each hunk always carries its own path header (no redundancy to suppress). const lines = [palette.bold(displayText(diff.path))] if (diff.oldText !== null) { - for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.error(`- ${line}`)) + for (const line of diffContentLines(displayText(diff.oldText))) lines.push(palette.error(`- ${line}`)) } - for (const line of displayText(diff.newText).split('\n')) lines.push(palette.success(`+ ${line}`)) + for (const line of diffContentLines(displayText(diff.newText))) lines.push(palette.success(`+ ${line}`)) return lines } @@ -488,15 +501,19 @@ export class ToolCardComponent implements Component { } if (view.card === 'diff') { // The header no longer names the file, so each diff keeps its own path - // header. A trailing footer summarizes the change (`+A -R · N file(s)`). + // header. A trailing footer summarizes the change (`+A -R · N file(s)`), + // on the same terminator rule and distinct-path count the Web DiffBlock + // uses, so the two front ends' footers agree. let added = 0 let removed = 0 + const paths = new Set() const hunks = view.diffs.flatMap((diff, index) => { - if (diff.oldText !== null) removed += displayText(diff.oldText).split('\n').length - added += displayText(diff.newText).split('\n').length + paths.add(diff.path) + if (diff.oldText !== null) removed += diffContentLines(displayText(diff.oldText)).length + added += diffContentLines(displayText(diff.newText)).length return [...index > 0 ? [''] : [], ...diffLines(diff, this.palette)] }) - const files = view.diffs.length + const files = paths.size const footer = this.palette.dim(`└ +${added} -${removed} · ${files} file${files === 1 ? '' : 's'}`) // A diff's own `+`/`-` colors carry its meaning, so it renders verbatim // rather than under the dim result-output color. diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index d99780dedb..c03d40f098 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4317,6 +4317,21 @@ describe('tool cards and surface replay', () => { diffs: [{ path: 'src/only.ts', oldText: 'old', newText: 'new' }], }), }, + scatteredDiff: { + name: 'scatteredDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + // Two hunks in ONE file, each side ending in the terminator newline real + // write/edit content carries. The footer must read `+2 -0 · 1 file`: the + // trailing newline terminates its line rather than adding a phantom empty + // one, and the two hunks count as the single distinct path they touch. + presentCall: () => ({ + card: 'diff', + title: 'Edit src/scatter.ts', + diffs: [ + { path: 'src/scatter.ts', oldText: null, newText: 'first\n' }, + { path: 'src/scatter.ts', oldText: null, newText: 'second\n' }, + ], + }), + }, generic: { name: 'generic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }), @@ -4621,6 +4636,29 @@ describe('tool cards and surface replay', () => { await dispose(result) }) + it('counts a same-file diff once and terminates its trailing newline', async () => { + const result = await setup({ tools }) + appendUser(result.session, 'scatter edits in one file') + appendAssistant(result.session, [ + { type: 'text', text: 'Editing' }, + { type: 'tool-call', id: 'scatter' as never, name: 'scatteredDiff', arguments: '{}' }, + ]) + result.session.append('tool/call', { + turn: 1, step: 1, callId: 'scatter' as never, name: 'scatteredDiff', arguments: '{}', + }) + await tick() + const output = result.terminal.output + // Two hunks, one path: distinct-path count, same as the Web DiffBlock. + expect(output).toContain('· 1 file') + expect(output).not.toContain('· 2 files') + // The `first\n`/`second\n` sides each contribute exactly one added line — + // the trailing newline terminates rather than adding a phantom empty `+ `. + expect(output).toContain('+ first') + expect(output).toContain('+ second') + expect(output).toContain('+2 -0') + await dispose(result) + }) + it('drops blank rows from a terminal card result that the dim styling wraps', async () => { const blankRowTools: Record = { trailing: { From 568c564db47982980691c451e40f986e3e663b8e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 21:51:27 +0800 Subject: [PATCH 08/19] fix(web): update diff-card tests for the openFile no-double-resolve contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage lane caught a regression in the prior commit: FileMutationRow now passes the tool's raw path to the injected openFile (which resolves against cwd in apply.ts), but diff-card.spec still asserted the row pre-resolved. Assert on the raw path instead. Also fix the built-boot diff assertion to match on the line body and footer text — the `+ ` prefix is a CSS ::before, absent from textContent. --- apps/web/tests/built-boot.snapshot.ts | 10 +++++----- .../client/ui-conversation/tests/diff-card.spec.tsx | 6 ++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts index 0e1d58f965..6500305cd8 100644 --- a/apps/web/tests/built-boot.snapshot.ts +++ b/apps/web/tests/built-boot.snapshot.ts @@ -112,12 +112,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn // (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text. // The write turn's `hello fixture\n` proves the terminator rule end to end: a // trailing newline terminates its line, so the footer reads `+1` (not a - // phantom `+2`) and one distinct file. - const diffCards = document.querySelectorAll('[data-diff]') + // phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so + // it is absent from textContent — assert on the line body and the footer. + const diffCards = [...document.querySelectorAll('[data-diff]')] expect(diffCards.length).toBeGreaterThan(0) - const footers = [...document.querySelectorAll('[data-diff]')] - .map(card => card.textContent ?? '') - expect(footers.some(text => text.includes('+ hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true) + const footers = diffCards.map(card => card.textContent ?? '') + expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true) // Every bundle injected its plugin-owned style tag (the loader's CSS path). const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')] diff --git a/packages/client/ui-conversation/tests/diff-card.spec.tsx b/packages/client/ui-conversation/tests/diff-card.spec.tsx index 031216b9f7..bf29f889ae 100644 --- a/packages/client/ui-conversation/tests/diff-card.spec.tsx +++ b/packages/client/ui-conversation/tests/diff-card.spec.tsx @@ -163,11 +163,13 @@ describe('FileMutationRow diff card', () => { expect(view.getByText('复制')).toBeTruthy() }) - it('the summary is a path link that opens through the host, cwd-resolved', () => { + it('the summary is a path link that opens the tool path through the host', () => { const openFile = vi.fn() const view = render() fireEvent.click(view.getByRole('button', { name: 'notes/demo.txt' })) - expect(openFile).toHaveBeenCalledWith('/w/app/notes/demo.txt') + // The row passes the tool's own path; the injected openFile resolves it + // against the session cwd (apply.ts), so the row must not resolve twice. + expect(openFile).toHaveBeenCalledWith('notes/demo.txt') }) it('registers under write too, rendering a create as an added-only diff', () => { From 1ea5f0b124c51f17471278acf96bebbbe608d2a4 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 22:24:29 +0800 Subject: [PATCH 09/19] test(tui): cover diffContentLines empty-side arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage lane flagged transcript.ts line 63 (diffContentLines' empty-text return) uncovered: the same-file diff test only fed newline-terminated sides. Add a third hunk removing a line with an empty added side (a full deletion), so the empty arm runs and the footer proves the empty side draws no `+ ` row (+2 -1 · 1 file). Raise the test's line budget so every hunk row stays visible. --- packages/ui/tui/tests/tui.spec.ts | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 457b2c25ac..2354a6d51b 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -4320,16 +4320,20 @@ describe('tool cards and surface replay', () => { }, scatteredDiff: { name: 'scatteredDiff', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], - // Two hunks in ONE file, each side ending in the terminator newline real - // write/edit content carries. The footer must read `+2 -0 · 1 file`: the - // trailing newline terminates its line rather than adding a phantom empty - // one, and the two hunks count as the single distinct path they touch. + // Three hunks in ONE file. The first two sides end in the terminator + // newline real write/edit content carries; the third removes a line and + // leaves an EMPTY added side (a full deletion), so `diffContentLines('')` + // returns zero lines. The footer must read `+2 -1 · 1 file`: each trailing + // newline terminates its line rather than adding a phantom empty one, the + // empty side contributes no `+ ` row, and the three hunks count as the + // single distinct path they touch. presentCall: () => ({ card: 'diff', title: 'Edit src/scatter.ts', diffs: [ { path: 'src/scatter.ts', oldText: null, newText: 'first\n' }, { path: 'src/scatter.ts', oldText: null, newText: 'second\n' }, + { path: 'src/scatter.ts', oldText: 'gone\n', newText: '' }, ], }), }, @@ -4638,7 +4642,10 @@ describe('tool cards and surface replay', () => { }) it('counts a same-file diff once and terminates its trailing newline', async () => { - const result = await setup({ tools }) + // A budget past the card's row count so every hunk row stays visible (the + // collapse arithmetic is covered elsewhere); this test is about the + // terminator rule and the distinct-path footer count. + const result = await setup({ tools, config: { maxToolOutputLines: 20 } }) appendUser(result.session, 'scatter edits in one file') appendAssistant(result.session, [ { type: 'text', text: 'Editing' }, @@ -4649,14 +4656,17 @@ describe('tool cards and surface replay', () => { }) await tick() const output = result.terminal.output - // Two hunks, one path: distinct-path count, same as the Web DiffBlock. + // Three hunks, one path: distinct-path count, same as the Web DiffBlock. expect(output).toContain('· 1 file') - expect(output).not.toContain('· 2 files') + expect(output).not.toContain('· 3 files') // The `first\n`/`second\n` sides each contribute exactly one added line — // the trailing newline terminates rather than adding a phantom empty `+ `. expect(output).toContain('+ first') expect(output).toContain('+ second') - expect(output).toContain('+2 -0') + // The third hunk removes `gone` and leaves an empty added side, which + // contributes no `+ ` row (diffContentLines('') is zero lines). + expect(output).toContain('- gone') + expect(output).toContain('+2 -1') await dispose(result) }) From f9a40d555f2667b35b165a639bf2621a3524560b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 22:59:34 +0800 Subject: [PATCH 10/19] docs: record ui-conversation README pairing hash 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 0f0529e337..96db5f7c1a 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: e3625f40a1f6b0d4097cbef51818c250c88cd0e8 -README.zh.md: a1c10c085d30dd5241823492d666f3c0b58d7942 +README.md: fa4a19c6e97e423dc01bc037c7784b30c5d4c0e6 +README.zh.md: e09e1c85d7f8ac48b5d86ee9b81d3ba3f6da268e From 6991650fce41ffe0c74fb0c1b45a8e0f7492c65e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 23:53:11 +0800 Subject: [PATCH 11/19] fix(config): map dsh-host-directory-picker-auto to workspace source web.cordis.yml references @deepseek-ai/dsh-host-directory-picker-auto but tsconfig.base.json had no paths entry for it, so the tsx source launch fell back to built lib/ and verify-cordis-config failed. Add the mapping alongside its -browse/-native siblings. --- tsconfig.base.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tsconfig.base.json b/tsconfig.base.json index f5f5c0a3ba..9bdaa91652 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -125,6 +125,8 @@ "@deepseek-ai/dsh-host-directory-picker-browse/*": ["./packages/host/directory-picker-browse/src/*"], "@deepseek-ai/dsh-host-directory-picker-native": ["./packages/host/directory-picker-native/src"], "@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"], + "@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"], + "@deepseek-ai/dsh-host-directory-picker-auto/*": ["./packages/host/directory-picker-auto/src/*"], "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], From 311aca3663e9dd38c6dc3b1410cf09935c95b2e6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 11:14:07 +0800 Subject: [PATCH 12/19] fix(web): improve models settings safety and contrast --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 4 +- .../2026-07-30-web-config-plane.zh.md | 4 +- apps/web/tests/models-settings.e2e.ts | 37 +++++++++- .../models-settings/delete.expected.md | 7 ++ packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/ModelsSection.module.css | 74 +++++++++++-------- .../ui-models/src/client/ModelsSection.tsx | 57 ++++++++++++-- .../client/ui-models/src/client/locales.ts | 10 +++ packages/client/ui-models/tests/apply.spec.ts | 4 + .../ui-models/tests/components.spec.tsx | 46 +++++++++++- .../client/ui-models/tests/styles.spec.ts | 13 ++++ 14 files changed, 217 insertions(+), 51 deletions(-) create mode 100644 apps/web/tests/snapshots/models-settings/delete.expected.md create mode 100644 packages/client/ui-models/tests/styles.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index ac37214ebf..d7f3ce8a17 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: 95ede6264026f7b32e95749d00fe841f57dbf867 -2026-07-30-web-config-plane.zh.md: 6e06b69218a405055621cbd40781f9fbda9f9e6b +2026-07-30-web-config-plane.md: e4c72d1ad555e1d542593b8eb4b7fafc1afb8e0a +2026-07-30-web-config-plane.zh.md: 73dc2b7450c940e893b795cb29c4d2a7752a9bb0 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 95ede62640..e4c72d1ad5 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -20,7 +20,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently. -**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value and the wholesale `settings.replace` a removal needs can never drop a sibling's secret. An edit without removals lands as a minimal `settings.update` merge patch; clearing a fold field back to inherited or deleting a row replaces the whole user section, because merge semantics cannot express removal. +**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. ## Alternatives considered @@ -33,4 +33,4 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer ## Consequences -The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card and configured states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and the documented reset edge — a `settings.replace` cannot re-supply a stored *literal* secret in the replaced subtree, which the reference-based default makes unreachable. +The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 6e06b69218..73dc2b7450 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -20,7 +20,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值,删除所需的整体 `settings.replace` 也绝不可能丢掉兄弟条目的机密。不含删除的编辑以一次最小的 `settings.update` 合并 patch 落地;把折叠区字段清回继承值或删除整行则经 `settings.replace` 替换整个用户分节,因为合并语义表达不了删除。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。 ## 曾考虑的替代方案 @@ -33,4 +33,4 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 ## 后果 -整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态与已配置态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及已记录在案的重置边界情形——`settings.replace` 无法在被替换的子树里重新补上已存储的*字面量*机密,而基于引用的默认形态让这种情况根本无从出现。 +整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 28c423b0a0..a175191b33 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -8,7 +8,8 @@ // settings/credentials/llm-domain traffic, so there is no fixture and a // stray stream would fail loud on the open seam. The provider under test is // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can -// never shadow the derived reference. +// never shadow the derived reference. Removing that row is guarded by the +// localized provider-confirmation dialog before the unset reaches the wire. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -24,6 +25,7 @@ import { saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url)) const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') +const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md') const MODE = webSnapshotMode() describe('web e2e: Models settings page configures a dormant provider', () => { @@ -109,11 +111,42 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('confirms provider deletion before removing its settings profile', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete')) + const settingsDialog = page.getByRole('dialog', { name: '设置' }) + await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() + const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' }) + await deleteDialog.waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria( + page, + '[role="dialog"][aria-label="删除模型提供方?"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE) + + await deleteDialog.getByRole('button', { name: '取消', exact: true }).click() + expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:') + await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() + await page.getByRole('dialog', { name: '删除模型提供方?' }) + .getByRole('button', { name: '删除提供方', exact: true }).click() + await expect.poll( + async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), + { timeout: 10_000 }, + ).not.toContain('minimax-cn:') + expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8')) + .toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + await expect.poll( + async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(), + { timeout: 10_000 }, + ).toBe(0) await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'empty.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/models-settings/delete.expected.md b/apps/web/tests/snapshots/models-settings/delete.expected.md new file mode 100644 index 0000000000..afb0cb5fd2 --- /dev/null +++ b/apps/web/tests/snapshots/models-settings/delete.expected.md @@ -0,0 +1,7 @@ +- dialog "删除模型提供方?": + - heading "删除模型提供方?" [level=2] + - button "关闭": + - img + - paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。 + - button "取消" + - button "删除提供方" diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 951b1d04fe..fcb12d5cd5 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: adfbc084e1b0e227d50032cb6c924401b81c6a79 -README.zh.md: 4ee7d4efa729fdccee392ab8e55078b5a4a239ef +README.md: 9dd09faeb515bb8e8336c52418ab5cab5ede1b34 +README.zh.md: 753ee24f6dda647afef42b78cfc97f36dde5d739 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index adfbc084e1..9dd09faeb5 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base). +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 4ee7d4efa7..753ee24f6d 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,7 +4,7 @@ 模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base)。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。 diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index a2be484a63..11e51b66ec 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -3,6 +3,7 @@ flex-direction: column; gap: 12px; max-width: 720px; + color: var(--dsw-alias-label-primary); } .title { @@ -14,13 +15,13 @@ .intro { margin: 0; font-size: 13px; - color: var(--text-tertiary, #888); + color: var(--dsw-alias-label-tertiary); } .notice { margin: 0; font-size: 12px; - color: var(--text-warning, #a15c00); + color: var(--dsw-alias-state-warn-label); } .rows { @@ -33,13 +34,13 @@ } .rowCard { - border: 1px solid var(--border, #e2e2e2); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 12px; - background: var(--surface, #fff); + background: var(--dsw-alias-bg-layer-3); } .rowHead { @@ -63,7 +64,7 @@ display: inline-flex; align-items: center; gap: 5px; - color: var(--text-success, #0a7d33); + color: var(--dsw-alias-state-success-primary); font-size: 12px; } @@ -76,12 +77,12 @@ } .badgeMuted { - color: var(--text-tertiary, #999); + color: var(--dsw-alias-label-tertiary); font-size: 12px; } .badgeWarn { - color: var(--text-warning, #a15c00); + color: var(--dsw-alias-state-warn-label); font-size: 12px; } @@ -94,17 +95,17 @@ border: none; border-radius: 999px; padding: 8px 18px; - background: var(--accent-strong, #111); - color: var(--text-inverse, #fff); + background: var(--dsw-alias-button-primary-fill); + color: var(--dsw-alias-label-primary-foreground); font: inherit; cursor: pointer; } .secondaryButton { - border: 1px solid var(--border, #d9d9d9); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 6px 14px; - background: var(--surface, #fff); + background: var(--dsw-alias-bg-layer-3); color: inherit; font: inherit; cursor: pointer; @@ -113,7 +114,7 @@ .dangerButton { border: none; background: none; - color: var(--text-danger, #c0392b); + color: var(--dsw-alias-state-error-primary); font: inherit; cursor: pointer; } @@ -126,9 +127,9 @@ } .editor { - border: 1px solid var(--border, #e6e6e6); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; - background: var(--surface-secondary, #f7f7f8); + background: var(--dsw-alias-bg-layer-2); padding: 14px 16px; display: flex; flex-direction: column; @@ -148,7 +149,7 @@ .editorRoute { font-size: 12px; - color: var(--text-tertiary, #999); + color: var(--dsw-alias-label-tertiary); } .field { @@ -163,14 +164,14 @@ gap: 10px; font-size: 12px; font-weight: 500; - color: var(--text-secondary, #555); + color: var(--dsw-alias-label-secondary); } .linkButton { border: none; background: none; padding: 0; - color: var(--text-tertiary, #888); + color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 12px; text-decoration: underline; @@ -185,7 +186,7 @@ .advancedHint { margin: 0; font-size: 12px; - color: var(--text-tertiary, #999); + color: var(--dsw-alias-label-tertiary); } .editorActions { @@ -202,12 +203,12 @@ .addButton { align-self: flex-start; - border: 1px solid var(--border, #d9d9d9); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 8px 16px; font: inherit; font-size: 13px; - background: var(--surface, #fff); + background: var(--dsw-alias-bg-layer-3); color: inherit; cursor: pointer; } @@ -219,9 +220,9 @@ .addCard, .setupCard { - border: 1px solid var(--border, #e6e6e6); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; - background: var(--surface-secondary, #f7f7f8); + background: var(--dsw-alias-bg-layer-3); padding: 14px 16px; display: flex; flex-direction: column; @@ -237,7 +238,7 @@ } .customized { - border-top: 1px solid var(--border, #ececec); + border-top: 1px solid var(--dsw-alias-border-l2); padding-top: 10px; } @@ -245,7 +246,7 @@ cursor: pointer; font-size: 12px; font-weight: 500; - color: var(--text-secondary, #555); + color: var(--dsw-alias-label-secondary); list-style: revert; } @@ -259,25 +260,38 @@ .input { box-sizing: border-box; padding: 9px 12px; - border: 1px solid var(--border, #d9d9d9); + border: 1px solid var(--dsw-alias-border-l2); border-radius: 10px; font: inherit; font-size: 13px; - background: var(--surface, #fff); - color: inherit; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); } .input:focus { outline: none; - border-color: var(--accent-strong, #111); + border-color: var(--dsw-alias-brand-primary); } .input::placeholder { - color: var(--text-tertiary, #aaa); + color: var(--dsw-alias-label-dimmed); } .error { margin: 0; font-size: 12px; - color: var(--text-danger, #c0392b); + color: var(--dsw-alias-state-error-primary); +} + +.deleteDialog { + width: min(480px, 100%); +} + +.deleteConfirm:not(:disabled) { + border-color: var(--dsw-alias-state-error-primary); + color: var(--dsw-alias-state-error-primary); +} + +.deleteConfirm:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); } diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index d095acb86c..24f1ab2e4c 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -4,13 +4,15 @@ * card at a time. A whole-section provider without a configured key (the * unconfigured DeepSeek posture) renders as its open setup card instead of a * row; the add flow is a card carrying the dormant-provider select. Every - * mutation writes through the wire; the page re-renders from the pushed - * invalidations or the post-apply reload. + * mutation writes through the wire, while a provider removal first requires + * confirmation; the page re-renders from pushed invalidations or the + * post-apply reload. */ import { useState } from 'react' import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { messageOf } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' @@ -114,6 +116,8 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const state = injected.useSnapshot(snapshot => snapshot) const [editing, setEditing] = useState(undefined) const [adding, setAdding] = useState(false) + const [deleteTarget, setDeleteTarget] = useState(undefined) + const [deleting, setDeleting] = useState(false) const closeEditor = (changed: boolean): void => { setEditing(undefined) @@ -121,6 +125,26 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { if (changed) void controller.load() } + const closeDelete = (): void => { + if (deleting) return + setDeleteTarget(undefined) + } + + const confirmDelete = (): void => { + /* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */ + if (deleteTarget === undefined || deleting) return + setDeleting(true) + void removeProviderProfile(api, controller, deleteTarget) + .then((failure) => { + if (failure !== undefined) { + controller.fail(failure) + return + } + setDeleteTarget(undefined) + }) + .finally(() => { setDeleting(false) }) + } + if (state.status === 'idle') void controller.load() if (state.status === 'error') { /* v8 ignore next -- an error status always carries text; the fallback satisfies the nullable type */ @@ -193,11 +217,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { type="button" className={styles['dangerButton']} disabled={!state.writable} - onClick={() => { - void removeProviderProfile(api, controller, target).then((failure) => { - if (failure !== undefined) controller.fail(failure) - }) - }} + onClick={() => { setDeleteTarget(target) }} > {t('remove')} @@ -276,6 +296,29 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { )}
+ + + + + )} + /> ) } diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 48431ddacf..5cc6782dca 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -9,8 +9,13 @@ export const en = { dormant: 'Inactive', edit: 'Edit', remove: 'Delete', + deleteTitle: 'Delete model provider?', + deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.', + deleteConfirm: 'Delete provider', + deleting: 'Deleting provider…', add: 'Add provider', provider: 'Provider', + close: 'Close', cancel: 'Cancel', apply: 'Apply', applying: 'Applying…', @@ -46,8 +51,13 @@ export const zh: typeof en = { dormant: '未启用', edit: '编辑', remove: '删除', + deleteTitle: '删除模型提供方?', + deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。', + deleteConfirm: '删除提供方', + deleting: '正在删除提供方…', add: '添加提供方', provider: '提供方', + close: '关闭', cancel: '取消', apply: '保存', applying: '保存中…', diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index eb34b162a0..6c64a93058 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -48,6 +48,7 @@ describe('ui-models apply', () => { expect(resolveSlotLabel(entry.options.label)).toBe('模型') const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)() expect(injected.t('nav')).toBe('模型') + expect(injected.t('deleteTitle')).toBe('删除模型提供方?') expect(typeof injected.controller.load).toBe('function') expect(typeof injected.useSnapshot).toBe('function') expect(injected.api).toBeDefined() @@ -73,8 +74,11 @@ describe('ui-models apply', () => { await b.ctx.plugin({ inject: [...inject], apply }).await() b.locale.setLocale('en') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models') + const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected + expect(injected().t('deleteTitle')).toBe('Delete model provider?') b.locale.setLocale('zh') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型') + expect(injected().t('deleteTitle')).toBe('删除模型提供方?') }) it('locale change while the slot is undeclared stays a no-op', async () => { diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index a4f3734fbd..47aec0dd5e 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom /** Section, setup-card, and hand-written editor behavior over a scripted wire face. */ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from 'schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' @@ -471,10 +471,28 @@ describe('ModelsSection', () => { await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) }) }) - it('removes a user-added provider by unsetting its path', async () => { + it('requires confirmation before removing a user-added provider', async () => { const { replace, mutate } = await mountSection() fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) + expect(dialog.textContent).toContain(en.deleteDescription) + expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel })) + expect(mutate).not.toHaveBeenCalled() + fireEvent.click(within(dialog).getByRole('button', { name: en.cancel })) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(mutate).not.toHaveBeenCalled() + + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.close })) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(mutate).not.toHaveBeenCalled() + + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.deleteConfirm })) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) + expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() expect(replace).not.toHaveBeenCalled() expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -482,6 +500,28 @@ describe('ModelsSection', () => { }) }) + it('blocks duplicate deletion while the confirmed removal is pending', async () => { + let resolveRemoval!: (response: RpcResponse) => void + const mutate = vi.fn(() => new Promise>((resolve) => { + resolveRemoval = resolve + })) + await mountSection({ mutate }) + fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) + const confirm = within(dialog).getByRole('button', { name: en.deleteConfirm }) + fireEvent.click(confirm) + fireEvent.click(confirm) + expect(mutate).toHaveBeenCalledOnce() + expect(confirm.disabled).toBe(true) + expect(within(dialog).getByRole('button', { name: en.cancel }).disabled).toBe(true) + expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm) + fireEvent.click(within(dialog).getByRole('button', { name: en.close })) + expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog) + expect(mutate).toHaveBeenCalledOnce() + await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) }) + await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() }) + }) + it('renders the load failure with a retry control', async () => { const face = scriptedFace() face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never @@ -589,6 +629,8 @@ describe('ModelsSection', () => { // would appear — rather than the row silently staying put. await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) }) fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) + fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + .getByRole('button', { name: en.deleteConfirm })) await screen.findByText(`${en.loadFailed}: the host refused`) }) diff --git a/packages/client/ui-models/tests/styles.spec.ts b/packages/client/ui-models/tests/styles.spec.ts new file mode 100644 index 0000000000..478046454b --- /dev/null +++ b/packages/client/ui-models/tests/styles.spec.ts @@ -0,0 +1,13 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8') + +describe('ModelsSection theme styles', () => { + it('uses the shared theme tokens without light-only fallbacks', () => { + expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/) + expect(css).toContain('background: var(--dsw-alias-bg-layer-3)') + expect(css).toContain('color: var(--dsw-alias-label-primary)') + }) +}) From 788b9eb9866f714e2144ed1c4d6ff3748943dbe5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 11:32:10 +0800 Subject: [PATCH 13/19] fix(web): hide provider liveness badges --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +-- .../2026-07-30-web-config-plane.md | 2 +- .../2026-07-30-web-config-plane.zh.md | 2 +- apps/web/tests/models-settings.e2e.ts | 9 +++-- .../models-settings/configured.expected.md | 2 +- packages/client/ui-models/README.i18n.yaml | 4 +-- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/ModelsSection.module.css | 33 +------------------ .../ui-models/src/client/ModelsSection.tsx | 5 --- .../client/ui-models/src/client/locales.ts | 4 --- .../ui-models/tests/components.spec.tsx | 5 ++- 12 files changed, 16 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index d7f3ce8a17..fedfc7e489 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: e4c72d1ad555e1d542593b8eb4b7fafc1afb8e0a -2026-07-30-web-config-plane.zh.md: 73dc2b7450c940e893b795cb29c4d2a7752a9bb0 +2026-07-30-web-config-plane.md: 6d1a8c242c1888ee4fca9e21ebc814f7a345d633 +2026-07-30-web-config-plane.zh.md: c3255cacfdd1f06d12f7bb2631f95273536b7ef9 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index e4c72d1ad5..6d1a8c242c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -20,7 +20,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, plus `reasoningEffort` for deepseek / `reasoning` for pi-ai), with every other field owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, so a hand-coded field that drifts from its schema fails loud on save rather than silently. -**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder; badges come from route liveness. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. +**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 73dc2b7450..c3255cacfd 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -20,7 +20,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,另加 deepseek 的 `reasoningEffort`/pi-ai 的 `reasoning`),其余每个字段都归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,因此偏离其 schema 的手写字段会在保存时大声失败,而非静默失败。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目;徽标来自路由存活状态。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index a175191b33..9c2215ed7f 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -1,10 +1,10 @@ // Web e2e scenario: the Models settings page end to end through the real // wire — the add card offers the dormant pi-ai catalog, typing an API key // stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`) -// while the settings document records only that reference, and the saved -// route registers live (the row's 已启用 badge is the topology invalidation -// landing). The customized-settings fold writes the curated reasoning field -// as a merge patch. Zero model calls: configuration is pure +// while the settings document records only that reference; the saved row +// appears after the route topology invalidation without presenting liveness +// as provider status. The customized-settings fold writes the curated +// reasoning field as a merge patch. Zero model calls: configuration is pure // settings/credentials/llm-domain traffic, so there is no fixture and a // stray stream would fail loud on the open seam. The provider under test is // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can @@ -84,7 +84,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // registers, and the topology frame invalidates the page into the row. const row = dialog.getByText('minimax-cn', { exact: true }).first() await row.waitFor({ timeout: 10_000 }) - await dialog.getByText('已启用').waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 8b9c4ad6e1..251352ee00 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -14,7 +14,7 @@ - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: - listitem: - - text: minimax-cn 已启用 + - text: minimax-cn - button "编辑" - button "删除" - button "+ 添加提供方" diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index fcb12d5cd5..0cd3fa6269 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: 9dd09faeb515bb8e8336c52418ab5cab5ede1b34 -README.zh.md: 753ee24f6dda647afef42b78cfc97f36dde5d739 +README.md: 4edb34ccbe8f628c04e410a6dd2f002e247623f3 +README.zh.md: 68a1e0ee205d3ba764620bcfeba7c11a88ee8736 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 9dd09faeb5..4edb34ccbe 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time. +Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 753ee24f6d..68a1e0ee20 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片。 +模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index 11e51b66ec..a8b28db46c 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -54,41 +54,10 @@ font-weight: 600; } -.badges { - display: inline-flex; - gap: 6px; - flex: 1; -} - -.badgeOk { - display: inline-flex; - align-items: center; - gap: 5px; - color: var(--dsw-alias-state-success-primary); - font-size: 12px; -} - -.badgeOk::before { - content: ''; - width: 6px; - height: 6px; - border-radius: 999px; - background: currentcolor; -} - -.badgeMuted { - color: var(--dsw-alias-label-tertiary); - font-size: 12px; -} - -.badgeWarn { - color: var(--dsw-alias-state-warn-label); - font-size: 12px; -} - .rowActions { display: inline-flex; gap: 8px; + margin-left: auto; } .primaryButton { diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 24f1ab2e4c..c206dbd864 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -198,11 +198,6 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
  • {row.entry.displayName} - - {row.entry.active - ? {t('active')} - : {t('dormant')}} - + )} + /> +
    + ) +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Permission row copy. */ + 'settings.permission': PermissionSettingsKey + } +} diff --git a/packages/client/ui-permission/src/client/index.ts b/packages/client/ui-permission/src/client/index.ts index 30fc6d2dd5..65d66c4104 100644 --- a/packages/client/ui-permission/src/client/index.ts +++ b/packages/client/ui-permission/src/client/index.ts @@ -1,46 +1,46 @@ /** - * Permission preset plugin, browser half — a popupSelect DECORATION hung on - * the host `/permission` command: one flat list of presets, current value - * marked active, a pick executes the switch. The decoration owns only the - * bare invocation; the host command keeps its catalog row, the argued path - * (`/permission ` still switches directly), and the lifecycle - * logging. Options and the active mark read the session's `permissions` - * 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. + * Permission plugin, browser half. The General-settings row writes the + * default preset for subsequently created sessions through Settings; the + * `/permission` popup decoration switches the current session through the + * host command and its `permissions` projection. */ 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' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +// Type-only: pulls the General item slot and locale service contracts. +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { PermissionRow } from './PermissionRow.tsx' +import type { PermissionRowInjected } from './PermissionRow.tsx' +import { en, zh } from './locales.ts' +import { displayPresetName } from './presentation.ts' +import { + PERMISSION_SETTINGS_NS, PermissionSettingsController, refreshPermissionIfLoaded, +} from './settings-store.ts' + +export type { PermissionRowInjected, PermissionRowProps } from './PermissionRow.tsx' +export type { + PermissionDefaultOption, PermissionSettingsState, +} from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['command', 'sessions'] +export const inject = ['command', 'sessions', 'slots', 'locale', 'connection'] /** Read one session's current permissions projection value (undefined = capability absent). */ function selectOf(session: SessionFace | undefined): PermissionSelect | undefined { return session?.projections.faceOf('permissions').getSnapshot() as PermissionSelect | undefined } -/** - * 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. - */ -function displayName(name: string): string { - if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name - return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') -} - /** Flatten the projection select into popup rows; `custom` is display state, never a target. */ function optionsOf(value: PermissionSelect): SelectOption[] { return value.options .filter(option => option.value !== 'custom') .map(option => ({ id: option.value, - label: displayName(option.name), + label: displayPresetName(option.name), ...(option.description !== undefined ? { detail: option.description } : {}), ...(option.value === value.currentValue ? { active: true } : {}), })) @@ -56,6 +56,41 @@ export function apply(ctx: ClientContext): void { const sessions = ctx.sessions const sessionFor = (session: ClientSessionContext): SessionFace | undefined => sessions.binding(session.sessionId)?.session + + ctx.effect(() => ctx.locale.register('settings.permission', { zh, en }), 'ui-permission: settings row dictionaries') + + const connection = ctx.get('connection') as ConnectionHandle + const controller = new PermissionSettingsController(connection.api) + const useSnapshot = bindSnapshotSelector(controller.store) + const injected = (): PermissionRowInjected => ({ controller, useSnapshot }) + + ctx.effect(() => { + const refresh = (ns?: string): void => { + if (ns !== undefined && ns !== PERMISSION_SETTINGS_NS) return + refreshPermissionIfLoaded(controller) + } + const disposers = [ + ctx.on('settings/changed', refresh), + ctx.on('connection/reset', () => { refresh() }), + ] + return () => { + controller.dispose() + for (const dispose of disposers) dispose() + } + }, 'ui-permission: settings invalidations') + + ctx.effect(() => { + const row = deferRegistration(ctx.slots, 'settings.general.item', PermissionRow, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'permission', + order: -20, + locale: 'settings.permission', + inject: injected, + }, PermissionRow)) + return () => { row.dispose() } + }, 'ui-permission: General settings row') + ctx.effect(() => command.decorate({ name: 'permission', // The picker exists exactly while the projection does: a permission-less diff --git a/packages/client/ui-permission/src/client/locales.ts b/packages/client/ui-permission/src/client/locales.ts new file mode 100644 index 0000000000..748235c1ee --- /dev/null +++ b/packages/client/ui-permission/src/client/locales.ts @@ -0,0 +1,20 @@ +/** `settings.permission` namespace dictionaries (the Permission row's copy). */ + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'title': '权限', + 'description': '选择新会话的默认权限模式', + 'loading': '加载中', + 'unavailable': '不可用', +} satisfies Record + +/** The settings.permission namespace key union. */ +export type PermissionSettingsKey = keyof typeof zh + +/** English dictionary, checked complete against the zh key set. */ +export const en = { + 'title': 'Permission', + 'description': 'Choose the default permission mode for new sessions', + 'loading': 'Loading', + 'unavailable': 'Unavailable', +} satisfies Record diff --git a/packages/client/ui-permission/src/client/presentation.ts b/packages/client/ui-permission/src/client/presentation.ts new file mode 100644 index 0000000000..752daedf11 --- /dev/null +++ b/packages/client/ui-permission/src/client/presentation.ts @@ -0,0 +1,9 @@ +/** + * Convert conventional kebab-case preset names into user-facing title case. + * @param name - host-supplied preset label or key. + * @returns the title-cased conventional key, or a non-kebab label unchanged. + */ +export function displayPresetName(name: string): string { + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name + return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') +} diff --git a/packages/client/ui-permission/src/client/settings-store.ts b/packages/client/ui-permission/src/client/settings-store.ts new file mode 100644 index 0000000000..830347aef7 --- /dev/null +++ b/packages/client/ui-permission/src/client/settings-store.ts @@ -0,0 +1,191 @@ +/** + * Permission default-settings controller. The host descriptor supplies the + * current value and the dynamic preset enum; writes target only + * `defaultPreset` and carry the descriptor revision. + */ + +import type { + IApiClient, SettingsNamespaceView, +} from '@deepseek-ai/dsh-client-connection/client' +import { + createSnapshotStore, type SnapshotStore, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + nodeAtPath, rehydrateSchema, type SchemaNode, +} from '@deepseek-ai/dsh-client-schema-form' +import { displayPresetName } from './presentation.ts' + +/** Permission's settings namespace on the host wire. */ +export const PERMISSION_SETTINGS_NS = 'permission' + +/** One selectable new-session default. */ +export interface PermissionDefaultOption { + /** Preset key written to Settings. */ + id: string + /** Host-supplied label or a title-cased preset key. */ + label: string +} + +/** Permission settings-row snapshot. */ +export interface PermissionSettingsState { + status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error' + error: string | null + writable: boolean + currentValue: string + options: readonly PermissionDefaultOption[] + revision: number +} + +interface ConstChoice { + type: string + value?: unknown + meta?: { description?: unknown } +} + +/** + * Read the dynamic preset enum encoded by the host's `defaultPreset` schema. + * @param view - permission namespace descriptor. + * @returns current value and selectable options. + */ +export function permissionDefaultOf(view: SettingsNamespaceView): { + currentValue: string + options: PermissionDefaultOption[] +} { + const value = (view.value as { defaultPreset?: unknown } | null)?.defaultPreset + if (typeof value !== 'string') throw new Error('permission settings has no defaultPreset value') + const node = nodeAtPath(rehydrateSchema(view.schema), ['defaultPreset']) + if (node === undefined) throw new Error('permission settings schema has no defaultPreset field') + const rawChoices = node.type === 'union' + ? (node.list as SchemaNode[] | undefined) ?? [] + : [node] + const options = rawChoices.flatMap((candidate) => { + const choice = candidate as unknown as ConstChoice + if (choice.type !== 'const' || typeof choice.value !== 'string') return [] + const described = choice.meta?.description + return [{ + id: choice.value, + label: typeof described === 'string' && described.length > 0 + ? displayPresetName(described) + : displayPresetName(choice.value), + }] + }) + if (options.length === 0 || !options.some(option => option.id === value)) { + throw new Error('permission settings schema does not advertise its current preset') + } + return { currentValue: value, options } +} + +/** Controller joining Settings reads, writes, and pushed invalidations. */ +export class PermissionSettingsController { + /** Row snapshot consumed through a bound selector hook. */ + readonly store: SnapshotStore = createSnapshotStore({ + status: 'idle', + error: null, + writable: false, + currentValue: '', + options: [], + revision: 0, + }) + + private generation = 0 + private view: SettingsNamespaceView | undefined + + /** @param api - Settings wire face. */ + constructor(private readonly api: Pick) {} + + /** + * Refresh the permission descriptor. Latest request wins. + * @returns nothing; {@link store} carries success or failure. + */ + async load(): Promise { + const generation = ++this.generation + this.store.update((state) => { + state.status = 'loading' + state.error = null + }) + try { + const response = await this.api.settings.describe({}) + if (!response.result.ok) throw new Error(response.result.error.message) + if (generation !== this.generation) return + const view = response.result.value.namespaces.find(entry => entry.ns === PERMISSION_SETTINGS_NS) + if (view === undefined) { + this.view = undefined + this.store.update((state) => { + state.status = 'unavailable' + state.writable = false + state.currentValue = '' + state.options = [] + }) + return + } + this.accept(view, response.result.value.writable) + } catch (error) { + if (generation !== this.generation) return + this.fail(error) + } + } + + /** + * Persist one preset as the default for subsequently created sessions. + * @param preset - advertised preset key. + * @returns nothing; {@link store} carries success or failure. + */ + async select(preset: string): Promise { + const view = this.view + const state = this.store.getSnapshot() + if (view === undefined || !state.writable) return + const generation = ++this.generation + this.store.update((draft) => { + draft.status = 'saving' + draft.error = null + }) + try { + const response = await this.api.settings.mutate({ + ns: PERMISSION_SETTINGS_NS, + ops: [{ op: 'set', path: ['defaultPreset'], value: preset }], + expectedRevision: view.revision, + }) + if (generation !== this.generation) return + if (!response.result.ok) throw new Error(response.result.error.message) + this.accept(response.result.value, true) + } catch (error) { + if (generation !== this.generation) return + this.fail(error) + } + } + + /** Stop in-flight responses from publishing after plugin disposal. */ + dispose(): void { + this.generation += 1 + this.view = undefined + } + + private accept(view: SettingsNamespaceView, writable: boolean): void { + const resolved = permissionDefaultOf(view) + this.view = view + this.store.update((state) => { + state.status = 'ready' + state.error = null + state.writable = writable + state.currentValue = resolved.currentValue + state.options = resolved.options + state.revision = view.revision + }) + } + + private fail(error: unknown): void { + this.store.update((state) => { + state.status = 'error' + state.error = error instanceof Error ? error.message : String(error) + }) + } +} + +/** + * Refetch only after the row has opened once. + * @param controller - permission settings controller. + */ +export function refreshPermissionIfLoaded(controller: PermissionSettingsController): void { + if (controller.store.getSnapshot().status === 'idle') return + void controller.load() +} diff --git a/packages/client/ui-permission/src/css-modules.d.ts b/packages/client/ui-permission/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-permission/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-permission/src/index.ts b/packages/client/ui-permission/src/index.ts index 5359562972..5c28cd69b2 100644 --- a/packages/client/ui-permission/src/index.ts +++ b/packages/client/ui-permission/src/index.ts @@ -1,8 +1,8 @@ /** - * Permission preset selection plugin, node half. Pure UI plugin: the empty - * apply exists so the plugin appears in the host cordis.yml / Loader; the - * browser half ships via exports["./client"], discovered through the - * package.json dshClient declaration. + * Permission surfaces plugin, node half. The empty apply exists so the plugin + * appears in the host cordis.yml / Loader; the browser half ships the + * new-session Settings row and current-session command picker through + * exports["./client"], discovered from the package.json dshClient declaration. */ /** Host plugin body — no host-side behavior for this surface plugin. */ diff --git a/packages/client/ui-permission/src/invariant.ts b/packages/client/ui-permission/src/invariant.ts index c0fd33a80b..1c3f7d6500 100644 --- a/packages/client/ui-permission/src/invariant.ts +++ b/packages/client/ui-permission/src/invariant.ts @@ -15,9 +15,9 @@ export const name = 'client-ui-permission-invariant' export const inject = ['invariants'] /** - * No runtime invariant: a single command contribution registration whose disposal is - * proven by the HMR-safety spec — it emits no cordis events and owns no - * cross-plugin mutable state. + * No runtime invariant: the command and slot contribution lifecycles are + * proven by the HMR-safety spec, while the browser-only Settings controller + * owns no host events or cross-plugin mutable state. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 5f9125db53..cbdb30a5fd 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -5,13 +5,16 @@ * the current value active and `custom` excluded; availability follows the * projection key's presence; a pick submits the /permission line through * Session.command and surfaces rejection/unmatched as thrown errors; fiber - * disposal removes the contribution (HMR safety). + * disposal removes the contribution (HMR safety). The same plugin registers + * its Settings row and invalidates that row on host settings changes. */ import { Context } from 'cordis' import { describe, expect, it } from 'vitest' -import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client' import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client' +import { PermissionRow } from '../src/client/PermissionRow.tsx' import { apply, inject } from '../src/client/index.ts' const sid = (k: string): SessionId => k as SessionId @@ -27,6 +30,26 @@ const SELECT: PermissionSelect = { async function bench() { const ctx = new Context() + await ctx.plugin(SlotsService) + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + ctx.slots.register({ + name: 'root', + children: { + 'settings.general.item': { kind: 'list', scope: 'root' }, + }, + } as never, () => null) + ctx.provide('connection', { + api: { + settings: { + describe: () => Promise.resolve({ + rpcId: 'describe', + result: { ok: true as const, value: { writable: true, namespaces: [] } }, + }), + mutate: () => Promise.reject(new Error('settings mutation is not exercised')), + }, + }, + } as never) let decoration: CommandDecoration | undefined ctx.provide('command', { decorate(c: CommandDecoration) { @@ -60,6 +83,8 @@ async function bench() { ctx, fiber, values, commands, setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r }, decoration: () => decoration, + permissionRow: () => ctx.slots.entries('settings.general.item') + .find(entry => entry.component === PermissionRow), } } @@ -69,6 +94,11 @@ describe('ui-permission browser plugin', () => { const c = b.decoration()! expect(c.name).toBe('permission') expect(c.ui.kind).toBe('popupSelect') + const row = b.permissionRow()! + expect(row.options).toEqual({ id: 'permission', order: -20 }) + const injected = row.inject?.() + expect(injected?.controller).toBeDefined() + expect(typeof injected?.useSnapshot).toBe('function') }) it('availability follows the projection key; options mark the current value active and exclude custom', async () => { @@ -114,7 +144,11 @@ describe('ui-permission browser plugin', () => { it('disposal removes the decoration (HMR safety)', async () => { const b = await bench() expect(b.decoration()).toBeDefined() + b.ctx.emit('settings/changed', 'another') + b.ctx.emit('settings/changed', 'permission') + b.ctx.emit('connection/reset') await b.fiber.dispose() expect(b.decoration()).toBeUndefined() + expect(b.permissionRow()).toBeUndefined() }) }) diff --git a/packages/client/ui-permission/tests/permission-row.spec.tsx b/packages/client/ui-permission/tests/permission-row.spec.tsx new file mode 100644 index 0000000000..685df69749 --- /dev/null +++ b/packages/client/ui-permission/tests/permission-row.spec.tsx @@ -0,0 +1,127 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' +import { en } from '../src/client/locales.ts' +import { PermissionSettingsController } from '../src/client/settings-store.ts' + +afterEach(cleanup) + +const SCHEMA = { + uid: 4, + refs: { + 1: { type: 'const', value: 'read-only' }, + 2: { type: 'const', value: 'workspace-write' }, + 3: { type: 'union', list: [1, 2] }, + 4: { type: 'object', dict: { defaultPreset: 3 } }, + }, +} + +function view(defaultPreset: string, revision = 0): SettingsNamespaceView { + return { + ns: 'permission', + schema: SCHEMA, + value: { defaultPreset }, + base: { defaultPreset: 'read-only' }, + applies: 'live', + secrets: [], + revision, + } +} + +function ok(value: T) { + return { rpcId: 'test', result: { ok: true as const, value } } +} + +const dictionary: Record = en +const t: PermissionRowProps['t'] = key => dictionary[key] ?? key +const runtime = { + useSessions: (() => { throw new Error('unused') }) as never, + useWorkspaces: (() => { throw new Error('unused') }) as never, +} + +function mount(controller: PermissionSettingsController) { + return render( + , + ) +} + +describe('PermissionRow', () => { + it('loads the descriptor, opens the menu, and selects a new default', async () => { + const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 1)))) + const controller = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate, + } as never, + }) + mount(controller) + const button = await screen.findByRole('button', { name: 'Read Only' }) + expect(button.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(button) + expect(button.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(document, { key: 'Escape' }) + await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) + fireEvent.click(button) + fireEvent.click(button) + expect(button.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) + await screen.findByRole('button', { name: 'Workspace Write' }) + expect(mutate).toHaveBeenCalledOnce() + }) + + it('hides an unavailable namespace and disables a read-only provider', async () => { + const absent = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [] })), + mutate: vi.fn(), + } as never, + }) + const rendered = mount(absent) + await waitFor(() => { expect(rendered.container.textContent).toBe('') }) + rendered.unmount() + + const readonly = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: false, namespaces: [view('read-only')] })), + mutate: vi.fn(), + } as never, + }) + mount(readonly) + expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true) + }) + + it('shows loading and a contained write error', async () => { + const describe = Promise.withResolvers>>() + const controller = new PermissionSettingsController({ + settings: { + describe: () => describe.promise, + mutate: () => Promise.resolve({ + rpcId: 'test', + result: { + ok: false as const, + error: { code: 'settings-conflict', message: 'changed elsewhere', details: {} }, + }, + }), + } as never, + }) + mount(controller) + expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true) + describe.resolve(ok({ writable: true, namespaces: [view('read-only')] })) + const button = await screen.findByRole('button', { name: 'Read Only' }) + fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) + expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere') + }) +}) diff --git a/packages/client/ui-permission/tests/settings-store.spec.ts b/packages/client/ui-permission/tests/settings-store.spec.ts new file mode 100644 index 0000000000..74edb838b0 --- /dev/null +++ b/packages/client/ui-permission/tests/settings-store.spec.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' +import { + PermissionSettingsController, permissionDefaultOf, refreshPermissionIfLoaded, +} from '../src/client/settings-store.ts' + +const SCHEMA = { + uid: 6, + refs: { + 1: { type: 'const', value: 'read-only' }, + 2: { type: 'const', meta: { description: 'Workspace' }, value: 'workspace-write' }, + 3: { type: 'union', list: [1, 2] }, + 6: { type: 'object', dict: { defaultPreset: 3 } }, + }, +} + +function view(defaultPreset: string, revision = 0, schema: SettingsNamespaceView['schema'] = SCHEMA): SettingsNamespaceView { + return { + ns: 'permission', + schema, + value: { defaultPreset }, + base: { defaultPreset: 'read-only' }, + applies: 'live', + secrets: [], + revision, + } +} + +function ok(value: T) { + return { rpcId: 'test', result: { ok: true as const, value } } +} + +describe('permission settings store', () => { + it('derives dynamic options and host labels from the descriptor schema', () => { + expect(permissionDefaultOf(view('read-only'))).toEqual({ + currentValue: 'read-only', + options: [ + { id: 'read-only', label: 'Read Only' }, + { id: 'workspace-write', label: 'Workspace' }, + ], + }) + const single = { + uid: 2, + refs: { + 1: { type: 'const', meta: { description: '' }, value: 'read-only' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + } + expect(permissionDefaultOf(view('read-only', 0, single))).toEqual({ + currentValue: 'read-only', + options: [{ id: 'read-only', label: 'Read Only' }], + }) + const undescribed = { + uid: 2, + refs: { + 1: { type: 'const', meta: { description: 7 }, value: 'read-only' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + } + expect(permissionDefaultOf(view('read-only', 0, undescribed)).options) + .toEqual([{ id: 'read-only', label: 'Read Only' }]) + }) + + it('rejects malformed values and dynamic enums at the wire boundary', () => { + expect(() => permissionDefaultOf({ ...view('read-only'), value: {} })).toThrow(/no defaultPreset value/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 1, refs: { 1: { type: 'object', dict: {} } }, + }))).toThrow(/no defaultPreset field/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 2, + refs: { + 1: { type: 'union' }, + 2: { type: 'object', dict: { defaultPreset: 1 } }, + }, + }))).toThrow(/does not advertise/) + expect(() => permissionDefaultOf(view('read-only', 0, { + uid: 4, + refs: { + 1: { type: 'string' }, + 2: { type: 'const', value: 1 }, + 3: { type: 'union', list: [1, 2] }, + 4: { type: 'object', dict: { defaultPreset: 3 } }, + }, + }))).toThrow(/does not advertise/) + expect(() => permissionDefaultOf(view('missing'))).toThrow(/does not advertise/) + }) + + it('loads and writes defaultPreset with optimistic concurrency', async () => { + const describe = vi.fn(() => Promise.resolve(ok({ + writable: true, + namespaces: [view('read-only', 4)], + }))) + const mutate = vi.fn(() => Promise.resolve(ok(view('workspace-write', 5)))) + const controller = new PermissionSettingsController({ + settings: { describe, mutate } as never, + }) + await controller.load() + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', + writable: true, + currentValue: 'read-only', + revision: 4, + }) + await controller.select('workspace-write') + expect(mutate).toHaveBeenCalledWith({ + ns: 'permission', + ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], + expectedRevision: 4, + }) + expect(controller.store.getSnapshot()).toMatchObject({ + status: 'ready', + currentValue: 'workspace-write', + revision: 5, + }) + }) + + it('hides the row when the namespace is absent and contains write failures', async () => { + const describe = vi.fn(() => Promise.resolve(ok({ writable: true, namespaces: [] }))) + const controller = new PermissionSettingsController({ + settings: { describe, mutate: vi.fn() } as never, + }) + await controller.load() + expect(controller.store.getSnapshot().status).toBe('unavailable') + + const failing = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate: () => Promise.resolve({ + rpcId: 'test', + result: { + ok: false as const, + error: { code: 'settings-conflict', message: 'stale', details: {} }, + }, + }), + } as never, + }) + await failing.load() + await failing.select('workspace-write') + expect(failing.store.getSnapshot()).toMatchObject({ status: 'error', error: 'stale' }) + }) + + it('contains read failures, no-ops without a writable view, and ignores stale responses', async () => { + const first = Promise.withResolvers>>() + const describe = vi.fn() + .mockImplementationOnce(() => first.promise) + .mockResolvedValueOnce(ok({ writable: false, namespaces: [view('read-only', 2)] })) + const mutate = vi.fn() + const controller = new PermissionSettingsController({ + settings: { describe, mutate } as never, + }) + const stale = controller.load() + await controller.load() + first.resolve(ok({ writable: true, namespaces: [view('workspace-write', 1)] })) + await stale + expect(controller.store.getSnapshot()).toMatchObject({ + currentValue: 'read-only', + writable: false, + revision: 2, + }) + await controller.select('workspace-write') + expect(mutate).not.toHaveBeenCalled() + + const rejected = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve({ + rpcId: 'test', + result: { ok: false as const, error: { code: 'internal', message: 'offline', details: {} } }, + }), + mutate, + } as never, + }) + await rejected.select('workspace-write') + await rejected.load() + expect(rejected.store.getSnapshot()).toMatchObject({ status: 'error', error: 'offline' }) + + const thrown = new PermissionSettingsController({ + settings: { + // Promise consumers must contain unknown rejection values from a + // transport implementation, including non-Error legacy clients. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + describe: () => Promise.reject('disconnected'), + mutate, + } as never, + }) + await thrown.load() + expect(thrown.store.getSnapshot()).toMatchObject({ status: 'error', error: 'disconnected' }) + }) + + it('disposal suppresses in-flight reads and writes, and loaded invalidations refetch', async () => { + const read = Promise.withResolvers>>() + const describe = vi.fn(() => read.promise) + const idle = new PermissionSettingsController({ settings: { describe, mutate: vi.fn() } as never }) + refreshPermissionIfLoaded(idle) + expect(describe).not.toHaveBeenCalled() + const loading = idle.load() + idle.dispose() + read.resolve(ok({ writable: true, namespaces: [view('read-only')] })) + await loading + expect(idle.store.getSnapshot().status).toBe('loading') + + const rejectedRead = Promise.withResolvers>>() + const disposedRead = new PermissionSettingsController({ + settings: { describe: () => rejectedRead.promise, mutate: vi.fn() } as never, + }) + const reading = disposedRead.load() + disposedRead.dispose() + rejectedRead.reject(new Error('late read')) + await reading + expect(disposedRead.store.getSnapshot().status).toBe('loading') + + const mutation = Promise.withResolvers>>() + const activeDescribe = vi.fn(() => Promise.resolve(ok({ + writable: true, + namespaces: [view('read-only')], + }))) + const active = new PermissionSettingsController({ + settings: { + describe: activeDescribe, + mutate: () => mutation.promise, + } as never, + }) + await active.load() + refreshPermissionIfLoaded(active) + await vi.waitFor(() => { expect(activeDescribe).toHaveBeenCalledTimes(2) }) + const saving = active.select('workspace-write') + active.dispose() + mutation.resolve(ok(view('workspace-write', 1))) + await saving + expect(active.store.getSnapshot().status).toBe('saving') + + const rejectedMutation = Promise.withResolvers>>() + const disposedWrite = new PermissionSettingsController({ + settings: { + describe: () => Promise.resolve(ok({ writable: true, namespaces: [view('read-only')] })), + mutate: () => rejectedMutation.promise, + } as never, + }) + await disposedWrite.load() + const writing = disposedWrite.select('workspace-write') + disposedWrite.dispose() + rejectedMutation.reject(new Error('late write')) + await writing + expect(disposedWrite.store.getSnapshot().status).toBe('saving') + }) +}) diff --git a/packages/client/ui-permission/tsconfig.json b/packages/client/ui-permission/tsconfig.json index b66ce746b2..32f84d6a2d 100644 --- a/packages/client/ui-permission/tsconfig.json +++ b/packages/client/ui-permission/tsconfig.json @@ -8,18 +8,36 @@ "src" ], "references": [ + { + "path": "../connection" + }, + { + "path": "../locale" + }, { "path": "../../../vendor/cordis" }, { "path": "../runtime" }, + { + "path": "../schema-form" + }, { "path": "../ui-command" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slash" }, + { + "path": "../ui-slots" + }, + { + "path": "../web-react" + }, { "path": "../../ui/permission" }, diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 9377fc73b8..8beb4c9578 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -1,6 +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 -README.md: c392d745021c0fc6a752cf71dd0506a435106c50 -README.zh.md: 83ab81e01eae435a74b50fa363a4de203c483002 +# pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md +README.md: 9e12f02fc1e767fb807be4fdd3f506c189662bc7 +README.zh.md: 225e27f5705f33bc6199615b0fe96e04eaa6a04c diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index c392d74502..9e12f02fc1 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (Permission/Tool Call skeleton rows + the `settings.general.item` slot declaration), and the `settings` dictionaries. Feature-owned rows (Language, Appearance) and sections (Models) stay with their feature packages. +Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (`settings.general.item` slot plus the Tool Call skeleton), and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages. ## Model Experience @@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Permission and Tool Call are display skeletons** — the backing host services and RPC methods do not exist yet; the controls are disabled and write nothing. When they gain real backing, each moves to its owning feature plugin per the self-registration doctrine. +- **Tool Call is a display skeleton** — its backing host setting does not exist yet, so the cubes write nothing. When it gains real backing, the row moves to its owning feature plugin per the self-registration doctrine. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 83ab81e01e..225e27f570 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置界面文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(「权限」/「工具调用」骨架行和 `settings.general.item` slot 声明),以及 `settings` 字典。归具体功能所有的行(「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 +设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(`settings.general.item` slot 加上「工具调用」骨架行),以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 ## 模型体验 @@ -14,4 +14,4 @@ ## 已知限制与暂缓事项 -- **「权限」与「工具调用」只是展示骨架**:对应的宿主服务和 RPC 方法尚不存在;这些控件已禁用,不会写入任何内容。一旦获得实际支撑,按照自注册原则,每一项都会移至拥有它的功能插件。 +- **「工具调用」只是展示骨架**:其宿主设置尚不存在,因此控件不会写入任何内容。一旦获得实际支撑,按照自注册原则,该行会移至拥有它的功能插件。 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 798a7710f0..6d0ee01404 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", - "description": "Settings ownerless-copy plugin: the General section (skeleton rows + item slot), the shell trigger/header chrome content, and the settings dictionaries", + "description": "Settings ownerless-copy plugin: the General section and Tool Call skeleton, shell trigger/header chrome content, and settings dictionaries", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index aced3b2962..cb1d137ec8 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -13,15 +13,6 @@ border-bottom: none; } -/* Title + trailing control row (figma 'Setting-Cell': gap 8, pad 16/0). */ -.row { - display: flex; - align-items: center; - gap: 8px; - padding: 16px 0; - border-bottom: 1px solid var(--dsw-alias-border-l2); -} - /* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */ .group { display: flex; @@ -31,16 +22,6 @@ border-bottom: 1px solid var(--dsw-alias-border-l2); } -/* Leading text column (figma 'Frame 2036083120': gap 4, pad-right 48). */ -.rowText { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; - padding-right: 48px; -} - .title { font-size: 14px; font-weight: 400; @@ -55,35 +36,6 @@ color: var(--dsw-alias-label-tertiary); } -/* Selector pill (figma 'Selector': h36 r18, fill #F5F6F7, pad 0/14, gap 12). */ -.selector { - display: inline-flex; - align-items: center; - gap: 12px; - height: 36px; - padding: 0 14px; - border: none; - border-radius: 18px; - background: var(--dsw-alias-bg-module-platform); - font: inherit; - font-size: 14px; - line-height: 22px; - color: var(--dsw-alias-label-primary); - cursor: pointer; -} - -.selector:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.selector:disabled { - cursor: default; -} - -.chevron { - flex: none; -} - /* Tool Call mode cubes share an 8px gap and wrap to one per row when the panel is too narrow. */ .cubeRow { diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx index 62c3a0b134..861f4e23d4 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.tsx +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -1,55 +1,51 @@ /** - * The General section (figma 501:29983 'Options'): Permission and Tool Call - * skeleton rows, then the feature-contributed preference rows from the - * `settings.general.item` slot (locale → Language, ui-theme → Appearance). - * The section column stacks rows; each row draws its own internals and - * separator. + * The General section (figma 501:29983 'Options'): one column rendering the + * `settings.general.item` contributions. Features own their rows; this + * package contributes only the ownerless Tool Call skeleton. */ -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import css from './GeneralSection.module.css' -/** Full component props: section owner share + item render share + the standard locale seat. */ +/** Full component props: section owner share plus item render share. */ export type GeneralSectionComponentProps = - PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> & PropsLocale<'settings'> + PropsRuntime<'settings.section'> & PropsRenderSlots<'settings.general.item'> /** * Render the General section content column. * @param props - composed slot props (contract/slots.ts). * @returns the section element tree. */ -export function GeneralSection({ t, renderSlot }: GeneralSectionComponentProps) { +export function GeneralSection({ renderSlot }: GeneralSectionComponentProps) { return (
    - {/* Permission (skeleton): disabled selector pill. */} -
    -
    -
    {t('permission.title')}
    -
    {t('permission.desc')}
    -
    - -
    - - {/* Tool Call (skeleton): schema cube pinned selected, code cube unselected. */} -
    -
    {t('toolcall.title')}
    -
    -
    -
    {t('toolcall.schema.title')}
    -
    {t('toolcall.schema.desc')}
    -
    -
    -
    {t('toolcall.code.title')}
    -
    {t('toolcall.code.desc')}
    -
    -
    -
    - - {/* Feature-owned preference rows (Language, Appearance, …). */} {renderSlot('settings.general.item', {})}
    ) } + +/** Props of the ownerless Tool Call item contribution. */ +export type ToolCallSkeletonProps = + PropsRuntime<'settings.general.item'> & PropsLocale<'settings'> + +/** + * Render the static Tool Call mode choice until its host setting exists. + * @param props - item runtime and translated copy. + * @returns the skeleton row. + */ +export function ToolCallSkeleton({ t }: ToolCallSkeletonProps) { + return ( +
    +
    {t('toolcall.title')}
    +
    +
    +
    {t('toolcall.schema.title')}
    +
    {t('toolcall.schema.desc')}
    +
    +
    +
    {t('toolcall.code.title')}
    +
    {t('toolcall.code.desc')}
    +
    +
    +
    + ) +} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 2683c20608..894b3a50e9 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,9 +1,9 @@ /** * Settings ownerless-copy plugin, browser half: registers everything on the * Settings surface that belongs to no single feature — the trigger/header - * chrome content, the General section (skeleton rows + the - * `settings.general.item` slot declaration), and the `settings` - * dictionaries. Feature-owned rows and sections stay with their features. + * chrome content, the General section (`settings.general.item` slot plus the + * ownerless Tool Call skeleton), and the `settings` dictionaries. + * Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' @@ -13,13 +13,15 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge. import type {} from '@deepseek-ai/dsh-client-locale/client' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' -import { GeneralSection } from './GeneralSection.tsx' +import { GeneralSection, ToolCallSkeleton } from './GeneralSection.tsx' import { en, zh, type SettingsKey } from './locales.ts' export type { CloseLabelProps, HeaderContentProps, TriggerContentProps, } from './chrome.tsx' -export type { GeneralSectionComponentProps } from './GeneralSection.tsx' +export type { + GeneralSectionComponentProps, ToolCallSkeletonProps, +} from './GeneralSection.tsx' export type { SettingsKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -67,11 +69,19 @@ export function apply(ctx: ClientContext): void { locale: NS, children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, }, GeneralSection)) + const toolCall = deferRegistration(ctx.slots, 'settings.general.item', ToolCallSkeleton, () => + ctx.slots.register({ + name: 'settings.general.item', + id: 'tool-call', + order: -10, + locale: NS, + }, ToolCallSkeleton)) return () => { trigger.dispose() header.dispose() close.dispose() general.dispose() + toolCall.dispose() } }, 'ui-settings-general: chrome and section registrations') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index d49dfecf96..1e3ff3b883 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -1,12 +1,10 @@ /** * `settings` namespace dictionaries: shell chrome plus the shell-owned - * General section (nav label, skeleton rows). Skeleton-row technical copy - * (Read only / Schema mode / Code mode and their descriptions) is shared - * verbatim across locales per the Figma design. Feature-owned rows - * (Language, Appearance) ship their copy in their own packages. + * General section (nav label and ownerless Tool Call skeleton). Technical + * mode copy is shared verbatim across locales per the Figma design. + * Feature-owned rows ship their copy in their own packages. */ const SHARED = { - 'permission.value': 'Read only', 'toolcall.schema.title': 'Schema mode', 'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time', 'toolcall.code.title': 'Code mode', @@ -20,8 +18,6 @@ export const zh = { 'title': '设置', 'close': '关闭', 'general.nav': '通用设置', - 'permission.title': '权限', - 'permission.desc': '选择默认权限模式', 'toolcall.title': '工具调用', } satisfies Record @@ -35,7 +31,5 @@ export const en = { 'title': 'Settings', 'close': 'Close', 'general.nav': 'General', - 'permission.title': 'Permission', - 'permission.desc': 'Choose default permission mode', 'toolcall.title': 'Tool Call', } satisfies Record diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index 726efe0be8..dfbaa49c6e 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,12 +1,12 @@ /** Ownerless-copy registrations: the four seats, the dictionaries, thunked labels, and HMR recovery. */ import { Context } from 'cordis' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' -import { GeneralSection } from '../src/client/GeneralSection.tsx' +import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' /** The four seats this plugin fills (slot name → expected component). */ const SEATS = [ @@ -61,10 +61,16 @@ describe('ui-settings-general apply', () => { // The nav label is a locale-following thunk; owners resolve at read time. expect(resolveSlotLabel(entry.options.label)).toBe('通用设置') expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) + const toolEntry = before.slots.entries('settings.general.item')[0]! + expect(toolEntry).toMatchObject({ + component: ToolCallSkeleton, + options: { id: 'tool-call', order: -10 }, + }) // Copy rides the standard locale seat: every seat declares the namespace. for (const [name] of SEATS) { expect(before.slots.entries(name)[0]!.locale).toBe('settings') } + expect(toolEntry.locale).toBe('settings') const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() @@ -76,6 +82,9 @@ describe('ui-settings-general apply', () => { // The self-inflicted ledger notifications hit the duplicate guard. expect(after.slots.entries(name)).toHaveLength(1) } + await vi.waitFor(() => { + expect(after.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) + }) }) it('registers the zh/en settings dictionaries and frees the seats on teardown', async () => { @@ -124,6 +133,7 @@ describe('ui-settings-general apply', () => { for (const [name, component] of SEATS) { expect(b.slots.entries(name)[0]!.component).toBe(component) } + expect(b.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) // The recovered registrations still ride the locale path. b.locale.setLocale('en') diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 9af2fb825b..2538335581 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -1,8 +1,10 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' -import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' -import { GeneralSection } from '../src/client/GeneralSection.tsx' +import type { + GeneralSectionComponentProps, ToolCallSkeletonProps, +} from '../src/client/GeneralSection.tsx' +import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' import { en } from '../src/client/locales.ts' @@ -10,7 +12,7 @@ afterEach(cleanup) // The seat's key domain is settings ∪ common; the stub answers from the // package dictionary and falls back to the key like the real chain. -const t: GeneralSectionComponentProps['t'] = key => (en as Record)[key] ?? key +const t: ToolCallSkeletonProps['t'] = key => (en as Record)[key] ?? key // Global standard kit stubs: none of these components consume the hooks. const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never @@ -42,21 +44,21 @@ describe('GeneralSection', () => { const renderSlot = vi.fn( ((key: string) =>
    ) as GeneralSectionComponentProps['renderSlot'], ) - const props: GeneralSectionComponentProps = { ...kit, t, renderSlot } + const props: GeneralSectionComponentProps = { ...kit, renderSlot } const view = render() return { view, renderSlot } } - it('renders the Permission skeleton row with the disabled selector', () => { - mount() - expect(screen.getByText('Permission')).toBeTruthy() - expect(screen.getByText('Choose default permission mode')).toBeTruthy() - const selector = screen.getByRole('button', { name: /Read only/ }) - expect(selector.disabled).toBe(true) + it('renders the item slot as the section body', () => { + const { renderSlot } = mount() + expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {}) + expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() }) +}) - it('renders the Tool Call skeleton cubes with schema pinned selected', () => { - mount() +describe('ToolCallSkeleton', () => { + it('renders the mode cubes with schema pinned selected', () => { + render() expect(screen.getByText('Tool Call')).toBeTruthy() const schema = screen.getByText('Schema mode') const code = screen.getByText('Code mode') @@ -65,10 +67,4 @@ describe('GeneralSection', () => { expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy() expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy() }) - - it('renders the feature-contributed item slot after the skeleton rows', () => { - const { renderSlot } = mount() - expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {}) - expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() - }) }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 02cf787a27..3d9399cc5c 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 73d8afb32f868ca82dfa2d350df089a5d0b9b358 -README.zh.md: 47af18f76302e261e18f682e0d3cf0ee903933db +README.md: eba4ad8406a4b3426c04d8da37f68a1306b5382d +README.zh.md: 4fdbc889d297d810053836566010bac35839b8da diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 73d8afb32f..eba4ad8406 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -34,7 +34,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream; the carrier's request signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves exactly the namespaces a registered configurable provider addresses (`ctx.llm.listConfigurableProviders()`): the seam is general, but this plane is the model-provider surface, so a namespace nothing in the directory names is neither described nor writable here and answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired both by `llm/adapters-updated` and by a change to an exposed provider namespace, whose settings carry that provider's catalog and endpoint. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit Web-preference allowlist, currently only `permission`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 47af18f763..4fdbc889d2 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -34,7 +34,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载;载体的请求信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域只服务于已注册可配置提供方所指向的那些 namespace(`ctx.llm.listConfigurableProviders()`):seam 本身是通用的,但这个面是模型提供方表层,因此目录中无人点名的 namespace 在这里既不会被描述也不可写入,只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它既由 `llm/adapters-updated` 触发,也由某个已暴露提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 Web 偏好 allowlist,目前仅包含 `permission`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index e224608c72..205c9912f5 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -72,6 +72,9 @@ import { openNativePath } from './native-path-opener.ts' /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 +/** Non-model settings namespaces intentionally served to the Web client. */ +const WEB_SETTINGS_NAMESPACES = ['permission'] as const + /** Provider work budget: at most 100 calls and 2,000 inspected hits. */ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 @@ -1098,31 +1101,35 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } } - /** - * The settings namespaces this proxy serves: exactly those a registered - * configurable provider addresses. The settings seam itself is general — - * any plugin may register a namespace for its own configuration — but the - * Web configuration plane is scoped to model providers, and that boundary - * has to be enforced here rather than assumed from the current plugin set. - * Without it, every future `settings.register()` would silently become - * remotely readable and writable configuration. - */ - function exposedNamespaces(): Set { + /** Settings namespaces whose changes can invalidate the model catalog. */ + function modelProviderNamespaces(): Set { return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs)) } - /** Refuse a namespace outside the model-provider boundary, naming why. */ + /** + * The settings namespaces this proxy serves: configurable model providers + * plus the small explicit Web preference allowlist. The settings seam + * remains general; a future registration does not become remotely readable + * or writable by default. + */ + function exposedNamespaces(): Set { + const exposed = modelProviderNamespaces() + for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns) + return exposed + } + + /** Refuse a namespace outside the explicit configuration-client boundary. */ function notExposed(request: RpcRequest, ns: string): RpcResponse { return err(request, { code: 'settings-not-exposed', - message: `settings namespace "${ns}" is not exposed to configuration clients; only a namespace a registered model provider addresses is`, + message: `settings namespace "${ns}" is not exposed to configuration clients`, details: { ns }, }) } /** * Run one settings write (merge or wholesale replace) and acknowledge with - * the namespace's new redacted view. A namespace outside the model-provider + * the namespace's new redacted view. A namespace outside the configuration * boundary is refused before the seam is touched; every seam refusal — * unknown or invalid namespace, read-only provider, schema validation, * storage — becomes one `settings-rejected` carrying the seam's own message. @@ -2165,11 +2172,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro // inherited to overridden leaves the resolved value equal, and a // configuration client still has to re-read (its held revision is // stale, and the field's meaning changed). - queue.push(frame({ type: 'host/settings-changed', ns: String(ns) })) + const name = String(ns) + queue.push(frame({ type: 'host/settings-changed', ns: name })) // A provider's own settings carry its model catalog and endpoint, // so a change there invalidates the model list even when the route // set is untouched — `llm/adapters-updated` alone misses it. - if (exposedNamespaces().has(String(ns))) queue.push(frame({ type: 'host/models-changed' })) + if (modelProviderNamespaces().has(name)) queue.push(frame({ type: 'host/models-changed' })) }), ctx.on('credentials/updated', (ref) => { queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) })) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index a505f72018..08d2dee2f3 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -160,8 +160,8 @@ async function harness(options?: { await ctx.plugin(LlmService) if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings) if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials) - // The proxy serves only namespaces a configurable provider addresses, which - // is what the real LLM plugins declare at load; the tests mirror that. + // Model-provider namespaces and the explicit Web preference allowlist are + // the proxy's complete settings surface. if (options?.configurableProviders !== false) { ctx.llm.registerConfigurableProviders([ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, @@ -222,19 +222,29 @@ describe('settings domain', () => { expect(JSON.stringify(value)).not.toContain('user-secret') }) - it('serves only namespaces a registered model provider addresses', async () => { + it('serves model-provider and explicitly allowlisted Web namespaces only', async () => { // The settings seam is general: any plugin may register a namespace for - // its own configuration. The Web configuration plane is not — it is the - // model-provider surface, and a namespace nothing in the provider - // directory addresses must be invisible and unwritable here, so a future - // plugin cannot become remotely configurable just by registering. + // its own configuration. The Web configuration plane remains opt-in, so a + // future internal plugin cannot become remotely configurable just by + // registering; permission is the one non-model namespace intentionally + // admitted by this surface. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) + ctx.settings.register(settingsNamespace('permission'), z.object({ + defaultPreset: z.union(['read-only', 'workspace-write']).required(), + }), { + base: { defaultPreset: 'read-only' }, + }) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.settings.describe(request({}))) - expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek']) + expect(value.namespaces.map(view => view.ns)).toEqual(['llm-deepseek', 'permission']) + const permission = expectOk(await api.settings.mutate(request({ + ns: 'permission', + ops: [{ op: 'set', path: ['defaultPreset'], value: 'workspace-write' }], + }))) + expect(permission.value).toEqual({ defaultPreset: 'workspace-write' }) for (const response of [ await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })), @@ -277,6 +287,20 @@ describe('settings domain', () => { .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) }) + it('broadcasts a permission change without invalidating the model catalog', async () => { + const ctx = await harness() + const permission = ctx.settings.register(settingsNamespace('permission'), z.object({ + defaultPreset: z.union(['read-only', 'workspace-write']).required(), + }), { + base: { defaultPreset: 'read-only' }, + }) + const api = createApiProxy(ctx, DEFAULTS) + const frames = await collectHost(api, ['host/settings-changed', 'host/models-changed'], 1, async () => { + await permission.update({ defaultPreset: 'workspace-write' }) + }) + expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'permission' }]) + }) + it('maps a stale expectedRevision to settings-conflict carrying both revisions', async () => { const ctx = await harness() ctx.settings.register(NS, AdapterConfig) diff --git a/packages/ui/permission/README.i18n.yaml b/packages/ui/permission/README.i18n.yaml index f6f5f49a14..e23eed90a4 100644 --- a/packages/ui/permission/README.i18n.yaml +++ b/packages/ui/permission/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/ui/permission/README.md -README.md: 814085ed6f2c9650854f377e1c97e442fc4211a4 -README.zh.md: 36880d6b8c3f0b39b88db1abb02534f30e3355fa +README.md: 576555b56c82e4041f2862bcb41ce137cb2d4f77 +README.zh.md: 894289314a4fb2fc22216462d3f3e0544d7d17bb diff --git a/packages/ui/permission/README.md b/packages/ui/permission/README.md index 814085ed6f..576555b56c 100644 --- a/packages/ui/permission/README.md +++ b/packages/ui/permission/README.md @@ -6,7 +6,9 @@ User-facing permission presets through `ctx.permission` ([`PermissionService`](s `set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. -The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load; composition defaults outside the table instead make a zero-event session derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). +The service owns the `permission` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permission/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed preserves its effective permission and receives only missing durable facts rather than the latest user default. + +The service requires a confining `ctx.bash` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permission` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. diff --git a/packages/ui/permission/README.zh.md b/packages/ui/permission/README.zh.md index 36880d6b8c..894289314a 100644 --- a/packages/ui/permission/README.zh.md +++ b/packages/ui/permission/README.zh.md @@ -6,7 +6,9 @@ `set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个 preset 共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 -该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常;如果组合在表外指定默认值,则零事件会话会推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +该服务拥有 `permission` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permission/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed 会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。 + +该服务要求存在具有约束能力的 `ctx.bash` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 两个可选子件在同一服务之上交付产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元折叠三个全量值旋钮事件,在组合默认值之上视图出 select——表内选项加仅作当前值的 `custom`)与 `/permission` 命令(裸调用报告当前预设与表;预设参数经 `set` 切换)。每个子件仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 diff --git a/packages/ui/permission/package.json b/packages/ui/permission/package.json index c5021af37b..c3554e3c4d 100644 --- a/packages/ui/permission/package.json +++ b/packages/ui/permission/package.json @@ -43,6 +43,7 @@ "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-projection": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-user-approval": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/ui/permission/src/index.ts b/packages/ui/permission/src/index.ts index 5c18a2b560..3919db66f6 100644 --- a/packages/ui/permission/src/index.ts +++ b/packages/ui/permission/src/index.ts @@ -21,6 +21,7 @@ import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-a import type {} from '@deepseek-ai/dsh-bash' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' // Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children. import type {} from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-commands' @@ -68,6 +69,9 @@ export interface PresetSpec { */ export const CUSTOM_PRESET = 'custom' +/** Settings namespace carrying the default for future sessions. */ +export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission') + /** * Fold the last selected preset from the durable log; replay needs no catch-up * state. @@ -126,7 +130,13 @@ function foldKnobs(events: readonly SessionEvent[]): KnobState { return state } -/** The {@link PermissionService} config: the deployment's preset table. */ +/** User setting resolved when a new session receives its initial permission. */ +export interface PermissionSettings { + /** Preset pinned into a newly created session. */ + defaultPreset: string +} + +/** The {@link PermissionService} config: preset table and composition default. */ export interface Config { /** * The preset table: name → knob bundle. Defaults to `workspace-write` @@ -134,6 +144,11 @@ export interface Config { * never). The name `custom` is reserved for the derived not-a-preset state. */ presets?: Record + /** + * Default for new sessions. When omitted, the preset matching the composed + * sandbox and approval defaults is used. + */ + defaultPreset?: string } /** @@ -159,11 +174,13 @@ export class PermissionService extends Service { name: 'danger-full-access', description: 'Full file access without approval prompts.', }, }), + defaultPreset: z.string(), }) static inject = ['bash', 'approval'] private readonly presets: Record + private defaultSettings: () => PermissionSettings constructor(ctx: Context, config: Config) { super(ctx, 'permission') @@ -175,6 +192,34 @@ export class PermissionService extends Service { if (ctx.bash.sandboxMode === undefined) { throw new Error('permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration') } + const inferredDefault = this.derive(EMPTY_KNOBS) + const defaultPreset = config.defaultPreset ?? inferredDefault + if (defaultPreset === CUSTOM_PRESET) { + throw new Error('permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly') + } + this.resolve(defaultPreset) + const baseSettings: PermissionSettings = { defaultPreset } + this.defaultSettings = () => baseSettings + const presetChoices = this.names.map((name) => { + const choice = z.const(name) + const label = this.presets[name]?.name + return label === undefined ? choice : choice.description(label) + }) + const settingsSchema: z = z.object({ + defaultPreset: z.union(presetChoices).required(), + }) + installSettingsSection(ctx, PERMISSION_SETTINGS_NAMESPACE, settingsSchema, baseSettings, { + setSource: (current) => { + this.defaultSettings = current + }, + // The source thunk reads the latest scope snapshot at session creation; + // no process-level registration needs replacement on change. + onChange: () => {}, + }) + + ctx.on('session/created', (session) => { + this.pinInitialPermission(session) + }) // The permissions projection unit: fold the three whole-value knob // events; view derives the select over the composition defaults this @@ -237,6 +282,15 @@ export class PermissionService extends Service { return Object.keys(this.presets) } + /** + * The preset currently selected as the default for future sessions. + * @returns the resolved settings value, or the composition default without + * a mounted settings provider. + */ + get defaultPreset(): string { + return this.defaultSettings().defaultPreset + } + /** * Resolve the preset matching the effective knob values. A still-matching * last selection wins shared-bundle ties; otherwise the first table match @@ -328,6 +382,44 @@ export class PermissionService extends Service { setApprovalPolicy(session, spec.approval) } } + + /** + * Fill every missing permission fact before a session is published. A + * genuinely fresh session uses the current user default; seeded or partially + * initialized sessions preserve their effective knob values and only gain + * the missing durable facts. + */ + private pinInitialPermission(session: Session): void { + const events = session.events + const selected = effectivePermissionPreset(events) + const sandbox = effectiveSandboxMode(events) + const approval = effectiveApprovalPolicy(events) + const seeded = events.some(event => event.type === 'session/end-seed') + if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) { + const name = this.defaultPreset + const spec = this.resolve(name) + session.append('permission/preset', { preset: name }) + setSandboxMode(session, spec.sandbox) + setApprovalPolicy(session, spec.approval) + return + } + + const state: KnobState = { + preset: selected ?? null, + sandbox: sandbox ?? null, + approval: approval ?? null, + } + const effective = this.derive(state) + if (selected === undefined && effective !== CUSTOM_PRESET) { + session.append('permission/preset', { preset: effective }) + } + if (sandbox === undefined) { + setSandboxMode(session, this.ctx.bash.sandboxMode as SandboxMode) + } + if (approval === undefined) { + setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask') + } + } } export default PermissionService diff --git a/packages/ui/permission/tests/permission.spec.ts b/packages/ui/permission/tests/permission.spec.ts index 05a747de8d..b7a4203745 100644 --- a/packages/ui/permission/tests/permission.spec.ts +++ b/packages/ui/permission/tests/permission.spec.ts @@ -1,10 +1,29 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' -import PermissionService, { CUSTOM_PRESET, effectivePermissionPreset } from '@deepseek-ai/dsh-permission' +import PermissionService, { + CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE, +} from '@deepseek-ai/dsh-permission' import type { Config } from '@deepseek-ai/dsh-permission' +import { Settings } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' + +/** Writable memory provider for the permission/settings lifecycle specs. */ +class MemorySettings extends Settings { + readonly doc: Record = {} + readonly writable = true + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc[ns] = structuredClone(section) + return Promise.resolve() + } +} async function mounted(options: { config?: Config @@ -27,6 +46,23 @@ function freshSession(id: string): Session { return new Session(SessionId(id)) } +async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemorySettings) + ctx.provide('bash', { + sandboxMode: 'workspace-write', + resolve() { throw new Error('permission tests do not execute bash') }, + run() { throw new Error('permission tests do not execute bash') }, + start() { throw new Error('permission tests do not execute bash') }, + }) + ctx.provide('approval', { + config: { policy: 'approvalDefault' in options ? options.approvalDefault : 'ask' }, + }) + await ctx.plugin(PermissionService, {}) + return ctx +} + describe('effectivePermissionPreset', () => { it('folds to the last event, or undefined without one', () => { const session = freshSession('sess-fold') @@ -66,8 +102,11 @@ describe('PermissionService', () => { expect(() => ctx.permission.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/) }) - it('composition defaults outside the table derive custom at zero events', async () => { - const ctx = await mounted({ approvalDefault: 'never' }) + it('composition defaults outside the table still derive custom when an explicit new-session default is configured', async () => { + const ctx = await mounted({ + approvalDefault: 'never', + config: { defaultPreset: 'workspace-write' }, + }) const session = freshSession('sess-defaults-custom') expect(ctx.permission.current(session.events)).toBe(CUSTOM_PRESET) }) @@ -138,6 +177,11 @@ describe('PermissionService', () => { .rejects.toThrow(/reserved for the derived not-a-preset state/) }) + it('requires an explicit default when composition defaults match no preset', async () => { + await expect(mounted({ approvalDefault: 'never' })) + .rejects.toThrow(/configure defaultPreset explicitly/) + }) + it('reads a schema-less approval stand-in as the ask default', async () => { const ctx = await mounted({ approvalDefault: undefined }) const session = freshSession('sess-standin') @@ -146,3 +190,79 @@ describe('PermissionService', () => { expect(ctx.permission.current(session.events)).toBe('workspace-write') }) }) + +describe('new-session default', () => { + it('pins the current setting into each new session without changing earlier sessions', async () => { + const ctx = await mountedStore() + const first = ctx.sessions.create(SessionId('first')) + expect(first.events.map(event => [event.type, event.data])).toEqual([ + ['permission/preset', { preset: 'workspace-write' }], + ['sandbox/mode', { mode: 'workspace-write' }], + ['approval/policy', { policy: 'ask' }], + ]) + + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + expect(ctx.permission.defaultPreset).toBe('danger-full-access') + const second = ctx.sessions.create(SessionId('second')) + expect(ctx.permission.current(first.events)).toBe('workspace-write') + expect(ctx.permission.current(second.events)).toBe('danger-full-access') + expect(second.events.map(event => event.type)).toEqual([ + 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + }) + + it('preserves a seeded legacy session instead of applying the latest user default', async () => { + const ctx = await mountedStore() + await ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'danger-full-access', + }) + const legacy = freshSession('legacy-source') + legacy.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events }) + expect(ctx.permission.current(resumed.events)).toBe('workspace-write') + expect(resumed.events.slice(-3).map(event => event.type)).toEqual([ + 'permission/preset', 'sandbox/mode', 'approval/policy', + ]) + }) + + it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => { + const ctx = await mountedStore() + const partial = freshSession('partial-source') + partial.append('sandbox/mode', { mode: 'workspace-write' }) + partial.append('approval/policy', { policy: 'ask' }) + const resumed = ctx.sessions.create(SessionId('partial-resumed'), { seed: partial.events }) + expect(resumed.events.at(-1)).toMatchObject({ + type: 'permission/preset', + data: { preset: 'workspace-write' }, + }) + + const custom = freshSession('custom-source') + custom.append('sandbox/mode', { mode: 'read-only' }) + custom.append('approval/policy', { policy: 'never' }) + const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events }) + expect(ctx.permission.current(unmatched.events)).toBe(CUSTOM_PRESET) + expect(unmatched.events.at(-1)?.type).toBe('session/end-seed') + }) + + it('materializes ask when a legacy seed and approval stand-in omit the policy', async () => { + const ctx = await mountedStore({ approvalDefault: undefined }) + const partial = freshSession('approval-fallback-source') + partial.append('sandbox/mode', { mode: 'workspace-write' }) + const resumed = ctx.sessions.create(SessionId('approval-fallback-resumed'), { seed: partial.events }) + expect(resumed.events.at(-1)).toMatchObject({ + type: 'approval/policy', + data: { policy: 'ask' }, + }) + }) + + it('rejects a stored default outside the configured preset table', async () => { + const ctx = await mountedStore() + await expect(ctx.settings.update(PERMISSION_SETTINGS_NAMESPACE, { + defaultPreset: 'missing', + })).rejects.toThrow() + expect(ctx.permission.defaultPreset).toBe('workspace-write') + }) +}) diff --git a/packages/ui/permission/tests/projection.spec.ts b/packages/ui/permission/tests/projection.spec.ts index 1649fe7077..a50c17a399 100644 --- a/packages/ui/permission/tests/projection.spec.ts +++ b/packages/ui/permission/tests/projection.spec.ts @@ -44,7 +44,7 @@ async function agentFor(ctx: Context, session: Session): Promise { } describe('permissions projection unit', () => { - it('serves the composition-default select at zero events', async () => { + it('serves the pinned new-session default select', async () => { const { ctx, session } = await harness() const value = ctx.sessionProjections.snapshot(session).values.permissions expect(value).toMatchObject({ currentValue: 'workspace-write' }) @@ -103,12 +103,14 @@ describe('/permission command', () => { kind: 'success', text: 'current preset workspace-write (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(0) + expect(session.events.filter(event => event.type === 'permission/preset')).toHaveLength(1) }) it('rejects an unknown preset without touching the log', async () => { const { ctx, session } = await harness() const agent = await agentFor(ctx, session) + const before = session.events.filter(event => + event.type !== 'command/run' && event.type !== 'command/done') const execution = await ctx.commands.execute(agent, '/permission yolo', new AbortController().signal) // The error text carries the same no-self-labelling rule as the success // texts: `permission · unknown preset "yolo" (…)`, not `unknown permission @@ -117,6 +119,7 @@ describe('/permission command', () => { kind: 'error', text: 'unknown preset "yolo" (available: workspace-write, danger-full-access)', }) - expect(session.events.filter(event => event.type !== 'command/run' && event.type !== 'command/done')).toHaveLength(0) + expect(session.events.filter(event => + event.type !== 'command/run' && event.type !== 'command/done')).toEqual(before) }) }) diff --git a/packages/ui/permission/tsconfig.json b/packages/ui/permission/tsconfig.json index 493fbf358e..9dc4afcd9a 100644 --- a/packages/ui/permission/tsconfig.json +++ b/packages/ui/permission/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../session-projection/session-projection" }, + { + "path": "../../settings/settings" + }, { "path": "../commands" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 545d5c7eae..96a8df6f8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1413,24 +1413,48 @@ importers: packages/client/ui-permission: devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime + '@deepseek-ai/dsh-client-schema-form': + specifier: workspace:^ + version: link:../schema-form '@deepseek-ai/dsh-client-ui-command': specifier: workspace:^ version: link:../ui-command + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slash': specifier: workspace:^ version: link:../ui-slash + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-permission': specifier: workspace:^ version: link:../../ui/permission + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 packages/client/ui-plan: devDependencies: @@ -5431,6 +5455,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session-projection/session-projection + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval From f45b6f76a5fcd4198f49c572f4dd57a6fda9d1cf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 12:48:39 +0800 Subject: [PATCH 16/19] fix(web): remove tool-call settings placeholder --- ...mission-default-for-new-sessions.i18n.yaml | 4 +- ...-31-permission-default-for-new-sessions.md | 2 +- ...-permission-default-for-new-sessions.zh.md | 2 +- apps/web/tests/settings-chrome.e2e.ts | 5 +- .../settings-chrome/dialog.expected.md | 2 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 4 +- .../client/ui-settings-general/README.zh.md | 4 +- .../client/ui-settings-general/package.json | 2 +- .../src/client/GeneralSection.module.css | 67 +------------------ .../src/client/GeneralSection.tsx | 35 +--------- .../ui-settings-general/src/client/index.ts | 15 +---- .../ui-settings-general/src/client/locales.ts | 17 +---- .../ui-settings-general/tests/apply.spec.ts | 14 ++-- .../tests/components.spec.tsx | 22 ++---- 15 files changed, 30 insertions(+), 169 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml index a76d46c1ce..b29ee4ec4b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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-31-permission-default-for-new-sessions.md -2026-07-31-permission-default-for-new-sessions.md: 236e0eedd2b3a6ba64a837fa1838d63545f13fb1 -2026-07-31-permission-default-for-new-sessions.zh.md: 8cdb5e6a0b6ca8a9a878351474728b325fb92528 +2026-07-31-permission-default-for-new-sessions.md: 78ec7a9b7c690c7b29fa10c1518fb7466971f0ac +2026-07-31-permission-default-for-new-sessions.zh.md: f6b29b879112de8b8f1c1f7ab466b7d90f9a142c diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md index 236e0eedd2..78ec7a9b7c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -14,7 +14,7 @@ The Web General-settings page displayed Permission as a disabled skeleton even t The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. -The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The ownerless General-settings package retains only the Tool Call skeleton. +The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The ownerless General-settings package contributes no placeholder rows. ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes emit `host/settings-changed` but not `host/models-changed`. diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md index 8cdb5e6a0b..f6b29b8791 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -14,7 +14,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 ` 服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。 -现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。无归属的「通用」设置包只保留「工具调用」骨架。 +现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。无归属的「通用」设置包不贡献任何占位行。 ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更会发出 `host/settings-changed`,但不会发出 `host/models-changed`。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index e81b8ab6fd..e3a016dd68 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -53,8 +53,7 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.waitFor({ timeout: 10_000 }) expect(await trigger.getAttribute('aria-expanded')).toBe('true') - // General is active by default; Permission, Language and Appearance are - // functional, while Tool Call remains a skeleton. + // General is active by default; Permission, Language and Appearance are functional. expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true') await dialog.getByRole('button', { name: 'Danger Full Access' }).waitFor({ timeout: 10_000 }) await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1) @@ -88,7 +87,7 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.waitFor({ timeout: 10_000 }) const selector = dialog.getByRole('button', { name: 'Danger Full Access' }) await selector.waitFor({ timeout: 10_000 }) - expect(await selector.isEnabled()).toBe(true) + await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true) await selector.click() await page.getByRole('menuitem', { name: 'Read Only' }).click() await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 }) diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index ff65e630f0..9234aa4948 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -14,7 +14,7 @@ - button "Danger Full Access": - text: Danger Full Access - img - - text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言 + - text: 语言 - button "中文": - text: 中文 - img diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 8beb4c9578..b1fd862af4 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: 9e12f02fc1e767fb807be4fdd3f506c189662bc7 -README.zh.md: 225e27f5705f33bc6199615b0fe96e04eaa6a04c +README.md: 241678567c4dbc7411ab9e76f595f2f696cc02d6 +README.zh.md: da4568d109c20bf1860fb8841942d42078b9443a diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 9e12f02fc1..241678567c 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section (`settings.general.item` slot plus the Tool Call skeleton), and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages. +Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages. ## Model Experience @@ -14,4 +14,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Tool Call is a display skeleton** — its backing host setting does not exist yet, so the cubes write nothing. When it gains real backing, the row moves to its owning feature plugin per the self-registration doctrine. +- The General section has no built-in rows; each row appears only when its owning feature plugin is mounted. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 225e27f570..da4568d109 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区(`settings.general.item` slot 加上「工具调用」骨架行),以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 +设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。 ## 模型体验 @@ -14,4 +14,4 @@ ## 已知限制与暂缓事项 -- **「工具调用」只是展示骨架**:其宿主设置尚不存在,因此控件不会写入任何内容。一旦获得实际支撑,按照自注册原则,该行会移至拥有它的功能插件。 +- 「通用」分区没有内置行;每一行仅在其所属功能插件挂载时出现。 diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 6d0ee01404..af85a9954f 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", - "description": "Settings ownerless-copy plugin: the General section and Tool Call skeleton, shell trigger/header chrome content, and settings dictionaries", + "description": "Settings ownerless-copy plugin: the General section, shell trigger/header chrome content, and settings dictionaries", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.module.css b/packages/client/ui-settings-general/src/client/GeneralSection.module.css index cb1d137ec8..efa367bc52 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.module.css +++ b/packages/client/ui-settings-general/src/client/GeneralSection.module.css @@ -1,7 +1,5 @@ -/* General section rows (figma 501:29983 'Options'): stacked groups, 16px - * vertical padding each, hairline separator under all but the last child - * (feature-contributed rows carry their own row chrome and separators; the - * :last-child rule strips the trailing one wherever the column ends). */ +/* Feature-contributed rows own their chrome and separators; the section + * strips the trailing separator wherever the column ends. */ .section { display: flex; @@ -12,64 +10,3 @@ .section > :last-child { border-bottom: none; } - -/* Title + full-width body group (figma 'Frame 2117131229': column, gap 8). */ -.group { - display: flex; - flex-direction: column; - gap: 8px; - padding: 16px 0; - border-bottom: 1px solid var(--dsw-alias-border-l2); -} - -.title { - font-size: 14px; - font-weight: 400; - line-height: 22px; - color: var(--dsw-alias-label-primary); -} - -.desc { - font-size: 12px; - font-weight: 400; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - -/* Tool Call mode cubes share an 8px gap and wrap to one per row when the - panel is too narrow. */ -.cubeRow { - display: flex; - align-items: stretch; - gap: 8px; - flex-wrap: wrap; -} - -/* Tool Call mode cube (figma '.Selector Cube' 418w r16, flexed to fit the - * 800 panel; horizontal inset = outer pad 4 + inner .Menu_cell pad 10, - * vertical = inner pad 8). */ -.modeCube { - box-sizing: border-box; - flex: 1 1 276px; - display: flex; - flex-direction: column; - justify-content: center; - gap: 2px; - padding: 8px 14px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 16px; - background: transparent; - text-align: left; - cursor: pointer; -} - -.modeCube:hover:not(.selected) { - background: var(--dsw-alias-interactive-bg-hover); -} - -/* Selected cube: #F5F6F7 fill + #ADB2B8 border (static token — the bluish-400 - * step has no alias-layer name). */ -.selected { - background: var(--dsw-alias-bg-module-platform); - border-color: var(--dsw-static-neutral-bluish-400); -} diff --git a/packages/client/ui-settings-general/src/client/GeneralSection.tsx b/packages/client/ui-settings-general/src/client/GeneralSection.tsx index 861f4e23d4..1217b36f96 100644 --- a/packages/client/ui-settings-general/src/client/GeneralSection.tsx +++ b/packages/client/ui-settings-general/src/client/GeneralSection.tsx @@ -1,9 +1,5 @@ -/** - * The General section (figma 501:29983 'Options'): one column rendering the - * `settings.general.item` contributions. Features own their rows; this - * package contributes only the ownerless Tool Call skeleton. - */ -import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +/** The General section: one column rendering feature-owned item contributions. */ +import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import css from './GeneralSection.module.css' /** Full component props: section owner share plus item render share. */ @@ -22,30 +18,3 @@ export function GeneralSection({ renderSlot }: GeneralSectionComponentProps) {
    ) } - -/** Props of the ownerless Tool Call item contribution. */ -export type ToolCallSkeletonProps = - PropsRuntime<'settings.general.item'> & PropsLocale<'settings'> - -/** - * Render the static Tool Call mode choice until its host setting exists. - * @param props - item runtime and translated copy. - * @returns the skeleton row. - */ -export function ToolCallSkeleton({ t }: ToolCallSkeletonProps) { - return ( -
    -
    {t('toolcall.title')}
    -
    -
    -
    {t('toolcall.schema.title')}
    -
    {t('toolcall.schema.desc')}
    -
    -
    -
    {t('toolcall.code.title')}
    -
    {t('toolcall.code.desc')}
    -
    -
    -
    - ) -} diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 894b3a50e9..0a0d84ed80 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -1,8 +1,7 @@ /** * Settings ownerless-copy plugin, browser half: registers everything on the * Settings surface that belongs to no single feature — the trigger/header - * chrome content, the General section (`settings.general.item` slot plus the - * ownerless Tool Call skeleton), and the `settings` dictionaries. + * chrome content, the General section, and the `settings` dictionaries. * Feature-owned rows and sections stay with their features. * Export discipline: packages/client/AGENTS.md. */ @@ -13,14 +12,14 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge. import type {} from '@deepseek-ai/dsh-client-locale/client' import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx' -import { GeneralSection, ToolCallSkeleton } from './GeneralSection.tsx' +import { GeneralSection } from './GeneralSection.tsx' import { en, zh, type SettingsKey } from './locales.ts' export type { CloseLabelProps, HeaderContentProps, TriggerContentProps, } from './chrome.tsx' export type { - GeneralSectionComponentProps, ToolCallSkeletonProps, + GeneralSectionComponentProps, } from './GeneralSection.tsx' export type { SettingsKey } from './locales.ts' @@ -69,19 +68,11 @@ export function apply(ctx: ClientContext): void { locale: NS, children: { 'settings.general.item': { kind: 'list', scope: 'root' } }, }, GeneralSection)) - const toolCall = deferRegistration(ctx.slots, 'settings.general.item', ToolCallSkeleton, () => - ctx.slots.register({ - name: 'settings.general.item', - id: 'tool-call', - order: -10, - locale: NS, - }, ToolCallSkeleton)) return () => { trigger.dispose() header.dispose() close.dispose() general.dispose() - toolCall.dispose() } }, 'ui-settings-general: chrome and section registrations') } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index 1e3ff3b883..b71fc683b9 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -1,24 +1,11 @@ -/** - * `settings` namespace dictionaries: shell chrome plus the shell-owned - * General section (nav label and ownerless Tool Call skeleton). Technical - * mode copy is shared verbatim across locales per the Figma design. - * Feature-owned rows ship their copy in their own packages. - */ -const SHARED = { - 'toolcall.schema.title': 'Schema mode', - 'toolcall.schema.desc': 'Traditional function calling — invoke tools one at a time', - 'toolcall.code.title': 'Code mode', - 'toolcall.code.desc': 'Chain multiple tools with code — multi-step orchestration', -} satisfies Record +/** Shell chrome and General-nav dictionaries; feature rows own their copy. */ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - ...SHARED, 'trigger': '设置', 'title': '设置', 'close': '关闭', 'general.nav': '通用设置', - 'toolcall.title': '工具调用', } satisfies Record /** The settings namespace key union. */ @@ -26,10 +13,8 @@ export type SettingsKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { - ...SHARED, 'trigger': 'Settings', 'title': 'Settings', 'close': 'Close', 'general.nav': 'General', - 'toolcall.title': 'Tool Call', } satisfies Record diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index dfbaa49c6e..506a69699a 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -6,7 +6,7 @@ import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' -import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' /** The four seats this plugin fills (slot name → expected component). */ const SEATS = [ @@ -61,17 +61,11 @@ describe('ui-settings-general apply', () => { // The nav label is a locale-following thunk; owners resolve at read time. expect(resolveSlotLabel(entry.options.label)).toBe('通用设置') expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) - const toolEntry = before.slots.entries('settings.general.item')[0]! - expect(toolEntry).toMatchObject({ - component: ToolCallSkeleton, - options: { id: 'tool-call', order: -10 }, - }) + expect(before.slots.entries('settings.general.item')).toEqual([]) // Copy rides the standard locale seat: every seat declares the namespace. for (const [name] of SEATS) { expect(before.slots.entries(name)[0]!.locale).toBe('settings') } - expect(toolEntry.locale).toBe('settings') - const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() for (const [name] of SEATS) expect(after.slots.entries(name)).toHaveLength(0) @@ -83,7 +77,7 @@ describe('ui-settings-general apply', () => { expect(after.slots.entries(name)).toHaveLength(1) } await vi.waitFor(() => { - expect(after.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) + expect(after.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) }) }) @@ -133,7 +127,7 @@ describe('ui-settings-general apply', () => { for (const [name, component] of SEATS) { expect(b.slots.entries(name)[0]!.component).toBe(component) } - expect(b.slots.entries('settings.general.item')[0]!.component).toBe(ToolCallSkeleton) + expect(b.slots.entries('settings.general.item')).toEqual([]) expect(b.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' }) // The recovered registrations still ride the locale path. b.locale.setLocale('en') diff --git a/packages/client/ui-settings-general/tests/components.spec.tsx b/packages/client/ui-settings-general/tests/components.spec.tsx index 2538335581..db6be78ccd 100644 --- a/packages/client/ui-settings-general/tests/components.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.spec.tsx @@ -1,18 +1,17 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' -import type { - GeneralSectionComponentProps, ToolCallSkeletonProps, -} from '../src/client/GeneralSection.tsx' -import { GeneralSection, ToolCallSkeleton } from '../src/client/GeneralSection.tsx' +import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' +import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' +import type { TriggerContentProps } from '../src/client/chrome.tsx' import { en } from '../src/client/locales.ts' afterEach(cleanup) // The seat's key domain is settings ∪ common; the stub answers from the // package dictionary and falls back to the key like the real chain. -const t: ToolCallSkeletonProps['t'] = key => (en as Record)[key] ?? key +const t: TriggerContentProps['t'] = key => (en as Record)[key] ?? key // Global standard kit stubs: none of these components consume the hooks. const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never @@ -55,16 +54,3 @@ describe('GeneralSection', () => { expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy() }) }) - -describe('ToolCallSkeleton', () => { - it('renders the mode cubes with schema pinned selected', () => { - render() - expect(screen.getByText('Tool Call')).toBeTruthy() - const schema = screen.getByText('Schema mode') - const code = screen.getByText('Code mode') - expect(schema.parentElement!.className).toContain('selected') - expect(code.parentElement!.className).not.toContain('selected') - expect(screen.getByText('Traditional function calling — invoke tools one at a time')).toBeTruthy() - expect(screen.getByText('Chain multiple tools with code — multi-step orchestration')).toBeTruthy() - }) -}) From 62ad4880206cbbad9c11bfbbc71f9e0bc244b1a5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 12:51:23 +0800 Subject: [PATCH 17/19] ci: refresh pull request mergeability From 7aa126e0d57062764e100e0cd64c5754388d5a76 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 13:36:40 +0800 Subject: [PATCH 18/19] test(session): expect empty fork seed marker --- packages/core/session/tests/fork.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index b04d08e9ab..b0381f3c3f 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -67,7 +67,7 @@ describe('SessionStore.fork', () => { const child = sessions.fork(source, undefined, SessionId('empty-child')) - expect(child.events).toEqual([]) + expect(inherited(child)).toEqual([]) expect(child.header).toMatchObject({ id: SessionId('empty-child'), cwd: '/workspace', From 8ab647366087b6228b39342e24f1ae8f8cc6b8f4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 31 Jul 2026 13:45:22 +0800 Subject: [PATCH 19/19] test(permission): cover settings confirmation paths --- .../ui-permission/src/client/PermissionRow.tsx | 18 ++++++++---------- .../ui-permission/tests/browser-plugin.spec.ts | 2 ++ .../tests/permission-row.spec.tsx | 7 +++++++ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/client/ui-permission/src/client/PermissionRow.tsx b/packages/client/ui-permission/src/client/PermissionRow.tsx index b27c5fc584..ae8c8bafe7 100644 --- a/packages/client/ui-permission/src/client/PermissionRow.tsx +++ b/packages/client/ui-permission/src/client/PermissionRow.tsx @@ -41,7 +41,7 @@ export type PermissionRowProps = export function PermissionRow({ load, select, usePermission, t }: PermissionRowProps) { const state = usePermission(snapshot => snapshot) const [open, setOpen] = useState(false) - const [confirmation, setConfirmation] = useState(null) + const [confirmingFullAccess, setConfirmingFullAccess] = useState(false) const [acknowledged, setAcknowledged] = useState(false) useEffect(() => { @@ -52,12 +52,12 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP if (state.writable && state.status !== 'unavailable') return setOpen(false) setAcknowledged(false) - setConfirmation(null) + setConfirmingFullAccess(false) }, [state.status, state.writable]) if (state.status === 'unavailable') return null const selected = state.options.find(option => option.id === state.currentValue) - const busy = state.status === 'loading' || state.status === 'saving' || confirmation !== null + const busy = state.status === 'loading' || state.status === 'saving' || confirmingFullAccess const label = selected?.label ?? (busy ? t('loading') : t('unavailable')) const description: string = state.error ?? t('description') @@ -79,7 +79,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP if (id === state.currentValue) return if (id === FULL_ACCESS_PRESET) { setAcknowledged(false) - setConfirmation(id) + setConfirmingFullAccess(true) return } void select(id) @@ -102,7 +102,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP /> { setAcknowledged(false) - setConfirmation(null) + setConfirmingFullAccess(false) }} onConfirm={() => { - if (!acknowledged || confirmation === null) return - const preset = confirmation setAcknowledged(false) - setConfirmation(null) - void select(preset) + setConfirmingFullAccess(false) + void select(FULL_ACCESS_PRESET) }} /> diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 399f5e8306..fea56a413a 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -104,6 +104,8 @@ describe('ui-permission browser plugin', () => { expect(injected?.hooks.permission).toBeDefined() expect(typeof injected?.load).toBe('function') expect(typeof injected?.select).toBe('function') + await injected!.load() + await injected!.select('read-only') }) it('availability follows the projection key; options mark the current value active and exclude custom', async () => { diff --git a/packages/client/ui-permission/tests/permission-row.spec.tsx b/packages/client/ui-permission/tests/permission-row.spec.tsx index 81a4dc4b70..f74e6ae2ad 100644 --- a/packages/client/ui-permission/tests/permission-row.spec.tsx +++ b/packages/client/ui-permission/tests/permission-row.spec.tsx @@ -75,6 +75,9 @@ describe('PermissionRow', () => { fireEvent.click(button) expect(button.getAttribute('aria-expanded')).toBe('false') fireEvent.click(button) + fireEvent.click(screen.getByRole('menuitem', { name: 'Read Only' })) + expect(mutate).not.toHaveBeenCalled() + fireEvent.click(button) fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' })) await screen.findByRole('button', { name: 'Workspace Write' }) expect(mutate).toHaveBeenCalledOnce() @@ -92,6 +95,10 @@ describe('PermissionRow', () => { fireEvent.click(await screen.findByRole('button', { name: 'Read Only' })) fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' })) expect(mutate).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog', { name: 'Enable Full access?' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Read Only' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' })) const dialog = screen.getByRole('dialog', { name: 'Enable Full access?' }) const enable = screen.getByRole('button', { name: 'Enable Full access' }) expect((enable as HTMLButtonElement).disabled).toBe(true)