fix(web): address diff card review — split terminator, error arm, wire narrowing
- 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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<DiffBlockProps, 'diffs'>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>
|
||||
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 } }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className={css.card}>
|
||||
<div className={css.root} data-variant={model.variant} data-state={model.state}>
|
||||
@@ -72,6 +96,7 @@ export function FileMutationRow({ toolName, block, cwd, openFile }: ToolRowProps
|
||||
{diff !== null && (
|
||||
<DiffBlock {...diff.card} maxLines={CHAT_DIFF_MAX_LINES} className={css.diff} />
|
||||
)}
|
||||
{failure !== null && <div className={css.failure}>{failure}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(<FileMutationRow {...rowProps(settled({ callView: null, resultView: null }))} />)
|
||||
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(<FileMutationRow {...rowProps(settled({
|
||||
isError: true, callView: null, resultView: null,
|
||||
content: [{ type: 'text', text: 'old_string not found in notes/demo.txt' }],
|
||||
}))} />)
|
||||
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(<FileMutationRow {...rowProps(settled({
|
||||
isError: true, callView: null, resultView: null, content: [],
|
||||
error: { name: 'ToolError', code: 'sandbox_denied' },
|
||||
}))} />)
|
||||
expect(view.getByText('ToolError: sandbox_denied')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows no failure text for a successful diff or a running call', () => {
|
||||
const ok = render(<FileMutationRow {...rowProps(settled())} />)
|
||||
expect(ok.container.querySelector('[class*="_failure_"]')).toBeNull()
|
||||
cleanup()
|
||||
const run = render(<FileMutationRow {...rowProps(running())} />)
|
||||
expect(run.container.querySelector('[class*="_failure_"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the stopped state when the call was interrupted', () => {
|
||||
const view = render(<FileMutationRow {...rowProps(settled({
|
||||
callView: null, resultView: null, isError: true,
|
||||
error: { name: 'ToolError', code: 'interrupted' },
|
||||
}))} />)
|
||||
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(<FileMutationRow {...rowProps(settled({
|
||||
call: { name: 'edit', argsRaw: '' }, callView: null, resultView: null,
|
||||
}))} />)
|
||||
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', () => {
|
||||
|
||||
@@ -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<DiffRow['kind'], string | undefined> = {
|
||||
path: css.path,
|
||||
@@ -61,8 +67,10 @@ const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
|
||||
* 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')
|
||||
}
|
||||
|
||||
@@ -76,6 +76,26 @@ describe('DiffBlock structure', () => {
|
||||
const { container } = render(<DiffBlock diffs={[]} />)
|
||||
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(<DiffBlock diffs={[{ path: 'n.txt', oldText: null, newText: 'hello\n' }]} />)
|
||||
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(<DiffBlock diffs={[{ path: 'gone.ts', oldText: 'a\nb', newText: '' }]} />)
|
||||
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(<DiffBlock diffs={[{ path: 'a.ts', oldText: null, newText: 'x\n\ny' }]} />)
|
||||
expect(container.querySelectorAll('[class*="_add_"]').length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DiffBlock footer', () => {
|
||||
|
||||
Reference in New Issue
Block a user