fix(web): diff card review — TUI parity, path-header overlap, double-resolve

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.
This commit is contained in:
Chinesezjc
2026-07-30 21:36:42 +08:00
parent 7166ad88ca
commit 1fd6b5a107
13 changed files with 116 additions and 40 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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只有页脚。

View File

@@ -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'))

View File

@@ -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;
}

View File

@@ -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({
<div className={css.terminalDescription}>{terminalBody.description}</div>
)}
{terminalBody !== null
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.cardBody} />
: diffBody !== null
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.terminalBody} />
? <DiffBlock {...diffBody.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.cardBody} />
: variant === 'code'
? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
: <div className={css.body}>{text}</div>}

View File

@@ -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;
}

View File

@@ -146,12 +146,12 @@ function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | u
{terminal.description !== undefined && (
<div className={css.terminalDescription}>{terminal.description}</div>
)}
<TerminalBlock {...terminal.card} className={css.terminal} />
<TerminalBlock {...terminal.card} className={css.cardBody} />
</>
)
}
const diff = diffCardModel(material.block)
if (diff !== null) return <DiffBlock {...diff.card} className={css.terminal} />
if (diff !== null) return <DiffBlock {...diff.card} className={css.cardBody} />
// 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>

View File

@@ -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
<button
type="button"
className={css.fileLink}
onClick={() => { openFile(resolveToolPath(cwd, filePath)) }}
onClick={() => { openFile(filePath) }}
>
{model.summary}
</button>

View File

@@ -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. */

View File

@@ -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<DiffRow['kind'], string | undefined> = {
* 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.
*/

View File

@@ -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 / <name>` 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<string>()
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.

View File

@@ -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<string, ToolDefinition> = {
trailing: {