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 <pre>. 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.
This commit is contained in:
Chinesezjc
2026-07-30 16:11:59 +08:00
parent 007659b78f
commit d2582b8dc1
22 changed files with 1173 additions and 20 deletions
@@ -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: '<tool>', 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 (`<done>/<total> 已完成 · <active item>` 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 `"<done>/<total> tasks · <n> 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.