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:
Chinesezjc
2026-07-30 18:32:55 +08:00
parent d7e46bea35
commit 8c5c4b46c8
10 changed files with 221 additions and 19 deletions
@@ -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', () => {