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
@@ -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
@@ -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.
@@ -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 viewwrite/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: '<tool>', 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 <preset>` 命令行。
@@ -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)
@@ -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<ToolRowVariant, ReactNode> = {
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 (
<ToolRow
@@ -39,9 +41,12 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
// A terminal presenter's description is the contract's above-card text, so
// it outranks the args-derived summary here exactly as it does in BashRow.
summary={terminal?.description ?? model.summary}
// Single-file tools never expose an args body — the path link is the only action.
// Single-file tools never expose an args body — the path link is the only
// args interaction. A diff card is not an args body: a write/edit row is
// single-file AND carries a diff, so the card expands under the path link.
body={singleFile ? null : model.body}
terminal={terminal}
diff={diff}
state={model.state}
filePath={model.filePath}
onOpenFile={singleFile ? openFile : undefined}
@@ -10,8 +10,9 @@
import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { CodeBlock, DiffBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../contract/diff-card-model.ts'
import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
import css from './ToolRow.module.css'
@@ -33,6 +34,13 @@ export interface ToolRowProps {
* expandable (its leading slot never toggles).
*/
terminal?: TerminalCardModel | null | undefined
/**
* Diff-card material for a call whose render intent is a diff card (derived by
* `diffCardModel`); it replaces the text body when present, the same way
* `terminal` does. A call carries at most one card intent, so the two are
* never both set.
*/
diff?: DiffCardModel | null | undefined
state: ToolRowState
/** Makes the row itself the expand control instead of only its leading icon. */
expandOnRowClick?: boolean | undefined
@@ -64,6 +72,7 @@ export function ToolRow({
summary,
body,
terminal,
diff,
state,
expandOnRowClick = false,
filePath,
@@ -71,13 +80,17 @@ export function ToolRow({
}: ToolRowProps) {
const [expanded, setExpanded] = useState(false)
const terminalBody = terminal ?? null
const diffBody = diff ?? null
// 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. Terminal
// material still expands: only the file variants carry a path, so a terminal
// card and a file link never land on the same row.
// 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.
const singleFile = filePath !== undefined
const fileLink = singleFile && onOpenFile !== undefined
const expandable = (body !== null && !singleFile) || terminalBody !== null
const cardBody = terminalBody !== null || diffBody !== null
const expandable = (body !== null && !singleFile) || cardBody
// The text arms take the empty string for a null body: a row expandable
// only through its terminal material renders the terminal body instead, so
// this substitution never shows.
@@ -164,9 +177,11 @@ export function ToolRow({
)}
{open && (terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>)}
: diffBody !== null
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.terminalBody} />
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>)}
</div>
)
}
@@ -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<DiffBlockProps, 'diffs'>
}
/**
* 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 } }
}
@@ -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 <DiffBlock {...diff.card} className={css.terminal} />
// 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 <div className={css.empty}></div>
@@ -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;
}
@@ -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 <StateDot state="error" />
case 'stopped': return <StateDot state="warning" />
// Running keeps the icon — the row sweep carries the in-flight signal.
default: return <IconEditOutline16 size={14} />
}
}
/** 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 (
<div className={css.card}>
<div className={css.root} data-variant={model.variant} data-state={model.state}>
<span className={css.leading}>{leadingFor(model.state)}</span>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.title}>{model.title}</span>
<span className={css.sep} aria-hidden />
{filePath !== undefined ? (
<button
type="button"
className={css.fileLink}
onClick={() => { openFile(resolveToolPath(cwd, filePath)) }}
>
{model.summary}
</button>
) : (
<span className={css.summary}>{model.summary}</span>
)}
</div>
{diff !== null && (
<DiffBlock {...diff.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diff} />
)}
</div>
)
}
/**
* 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)
},
}
@@ -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<Extract<ToolCallView, { card: 'diff' }>>): 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<Extract<ToolResultView, { card: 'diff' }>>): ToolResultView => ({
card: 'diff', title: 'Edit notes/demo.txt',
diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }], ...over,
})
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'edit', argsRaw: ARGS,
turn: 1, step: 1, time: 1_000, callView: callDiff(), ...over,
})
const settled = (over?: Partial<ToolResultNode>): 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(<GenericToolCard {...ownerProps(settled())} />)
// 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(<GenericToolCard {...ownerProps(running())} />)
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(<GenericToolCard {...{
callId: 'c1', toolName: 'some_tool', openFile: vi.fn(),
block: settled({
call: { name: 'some_tool', argsRaw: '{"foo":"bar"}' },
callView: null, resultView: null,
}),
}} />)
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<SessionListState>({
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(<FileMutationRow {...rowProps(settled())} />)
// 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(<FileMutationRow {...{ ...rowProps(settled()), openFile }} />)
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(<FileMutationRow {...rowProps(settled({
call: { name: 'write', argsRaw: writeArgs },
callView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
resultView: { card: 'diff', title: 'Write notes/new.txt', diffs: [{ path: 'notes/new.txt', oldText: null, newText: 'hello fixture' }] },
}), 'write')} />)
expect(view.getByText('└ +1 -0 · 1 file')).toBeTruthy()
})
it('reflects the run state on its leading slot', () => {
const runningView = render(<FileMutationRow {...rowProps(running())} />)
expect(runningView.container.querySelector('[data-state="running"]')).not.toBeNull()
cleanup()
const errorView = render(<FileMutationRow {...rowProps(settled({ isError: true, resultView: null, callView: null }))} />)
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(<FileMutationRow {...rowProps(settled({ callView: null, resultView: null }))} />)
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<SessionListState>(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<WorkspaceListState>({
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: true, recentWorkspaceId: undefined,
})
return render(
<DetailsPanel
sessionId={SID}
useSession={bindSnapshotSelector({ getSnapshot: () => 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> = {}): 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')
})
})