From 8c5c4b46c83562611eb4bf3fe9adf60fdc35c81b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 30 Jul 2026 18:32:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20address=20diff=20card=20review=20?= =?UTF-8?q?=E2=80=94=20split=20terminator,=20error=20arm,=20wire=20narrowi?= =?UTF-8?q?ng?= 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', () => {