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