DSH / Atlas
2026-07-30implementedfeature

Web diff card — the write/edit render intent reaches the browser

Web diff 卡片 —— write/edit 渲染意图抵达浏览器

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). T

English

Problem

The write and edit tools declare card: 'diff' for both their call and their result (render-intent union): 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 <pre>. 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 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-tool/src/client/tool/models/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 shares the TUI's single-column framing, line-terminator rule, and distinct-path file count. Line classification differs: Web renders the complete old and new sides, while the TUI derives neutral context and exact changed rows when its bounded comparison completes and labels its whole-side fallback approximate.

  • Path grouping. 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 TUI keeps a path header on every hunk, but both front ends count distinct paths in the N file(s) footer, so two hunks in one file read as 1 file.
  • Whole-side change colors. Every old-side line is - on the error token and every new-side 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 and the TUI use: 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.
  • Footer and copy. A dim └ +A -R · N file(s) footer reports the Web card's complete new- and old-side line counts. The TUI footer instead reports exact changed rows when available and marks a bounded whole-side fallback approximate; both use the same distinct-path file count. The copy control copies the prefixed Web 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. 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 change 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.client.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-tool/tests/diff-card.client.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 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

中文

Problem

writeedit 工具为其 call 和 result 都声明了 card: 'diff'render-intent union):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 摊平进一个 <pre>diffs 载荷 —— result 的全部意义 —— 被丢弃,于是一次文件改动读起来只是一行确认、看不到任何改动。

这是把 terminal 卡片diff 这一支重做一遍:那次改动让 Web 客户端成为 terminal 渲染意图的消费者;这次让它成为 diff 渲染意图的消费者,复用同一套四层结构。

Decision

DiffBlock 是一个 ui-primitives 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。ui-tool/src/client/tool/models/diff-card-model.ts 是唯一把快照的 callView/resultView 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 card: 'diff' 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 card 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。

该组件与 TUI 共用单栏框架、行终止符规则和去重路径计数。两者的行分类不同:Web 渲染完整的变更前后两侧,而 TUI 会在有界比较完成时派生中性上下文和精确变更行,并把整侧回退标记为近似结果。

  • 路径分组。 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 replace_all)以一个 gap 开启,而非重复路径。TUI 在每个 hunk 上都保留路径头,但两个前端的 N file(s) 页脚都按去重路径计数,因此同文件两个 hunk 在两端都读作 1 file
  • 整侧改动配色。 旧侧每一行都以 error token 上的 - 显示,新侧每一行都以 success token 上的 + 显示,并在横向滚动的盒子里以 white-space: pre 逐字绘制:源码行靠缩进阅读,因此滚动而不折行。新建(oldText: null)没有删除侧。
  • 高度上限带展开控件。 长于 DEFAULT_DIFF_MAX_LINES(16)的 diff 显示 ceil(max/2) 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 TerminalBlock 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。
  • 行终止符。 每一侧的内容按 TerminalBlock 与 TUI 共用的终止符规则在 \n 上切分:空文本是零行(整文件删除的 newText、新建缺失的 oldText 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。
  • 页脚与复制。 暗色 └ +A -R · N file(s) 页脚报告 Web 卡片完整新侧与旧侧的行数。TUI 页脚则在可用时报告精确变更行数,并把有界整侧回退标记为近似结果;两者使用相同的去重路径计数。复制控件复制带前缀的 Web diff 文本(路径头、- /+ 行、 gap),使多文件复制保持可辨别归属。

几何、圆角、字体镜像 CodeBlock/TerminalBlock,使 diff 卡片、terminal 卡片、代码块读起来是一家;white-space: pre 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。

chat 行把 diff 常驻渲染在路径链接摘要之下,上限 CHAT_DIFF_MAX_LINES(8),对应面板的 16 —— 与 terminal 卡片记录的内联输出决策、以及流内表面与阅读表面的同一划分一致。write/edit 行是单文件的,所以它的摘要既是可打开的路径链接,其 diff 卡片又展开;两者共存,因为卡片不是路径的参数体。

Alternatives considered

并排(双栏)diff。 owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。

git 式行号槽。 FileDiff 约定只携带 { path, oldText, newText } —— structuredPatch 的 hunk 起始行在 diff.ts 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端约定改动(携带 oldStart/newStart)并同步升级 TUI 以保持一致;推迟,使本变更保持为对既有约定的纯 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.client.spec.tsx 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 aria-expanded 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。

packages/client/ui-tool/tests/diff-card.client.spec.tsx 钉住每个渲染点的接线:diffCardModel 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、FileMutationRow 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 writeedit 下的注册、以及面板的 Output 区。

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