feat(web): render write/edit tool output as a diff card

The write/edit tools already declare card:'diff' with applied hunks on
callView/resultView, but the Web client discarded it: a mutation landed on
GenericToolCard and the details panel flattened the result to a <pre>. Add
DiffBlock (ui-primitives), diff-card-model (the single callView/resultView
derivation), and FileMutationRow (keyed under write and edit), and make the
generic fallback row and the details panel diff-aware. The +/- block form,
per-file path header, same-file gap, and footer mirror the TUI diff card;
the chat row caps at CHAT_DIFF_MAX_LINES against the panel's full height.
This commit is contained in:
Chinesezjc
2026-07-30 16:11:59 +08:00
parent 007659b78f
commit d2582b8dc1
22 changed files with 1173 additions and 20 deletions

View File

@@ -0,0 +1,103 @@
/* Geometry mirrors CodeBlock/TerminalBlock (12px radius, code-block surface +
banner row, markdown code-block font) so a diff card reads as one family with
a fenced block and a terminal card. The deliberate divergence, shared with
TerminalBlock: the body keeps `white-space: pre` and scrolls horizontally,
because folding a source line destroys the indentation a diff is read by. */
.block {
--dsl-diff-radius: 12px;
--dsl-diff-line-height: 22px;
position: relative;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-diff-radius);
}
/* The copy control floats in the top-right corner over the body, so the card
has no empty banner row above its first diff line (the TUI diff card has no
banner either — only the footer). The block is position: relative, so this
anchors to the card. */
.copyButton {
position: absolute;
top: 8px;
right: 12px;
z-index: 1;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
}
.body {
padding: 12px 14px;
font: var(--dsw-font-markdown-code-block);
overflow-x: auto;
overflow-y: hidden;
}
/* No wrapping, no word-break: a diff is read by its indentation. */
.line {
min-height: var(--dsl-diff-line-height);
white-space: pre;
}
/* A file header: the path in the primary tone, set apart by weight. */
.path {
color: var(--dsw-alias-label-primary);
font-weight: 600;
}
/* A same-file second hunk's separator (a scattered edit), in the dim tone. */
.gap {
color: var(--dsw-alias-label-tertiary);
}
/* The diff's own meaning-carrying colors: removed on the error token, added on
the success token. A `- `/`+ ` prefix is drawn here so a copied line and the
shown line agree, and so the sign reads without relying on color alone. */
.del::before {
content: '- ';
color: var(--dsw-alias-state-error-primary);
}
.del {
color: var(--dsw-alias-state-error-primary);
}
.add::before {
content: '+ ';
color: var(--dsw-alias-state-success-primary);
}
.add {
color: var(--dsw-alias-state-success-primary);
}
.expand {
display: block;
width: 100%;
padding: 0;
border: none;
background-color: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
font: inherit;
text-align: left;
}
.expand:hover {
color: var(--dsw-alias-label-secondary);
}
/* The change summary, dim under the body: `└ +A -R · N file(s)`, the same
footer the TUI transcript's diff card draws. */
.footer {
padding: 0 14px 12px;
font: var(--dsw-font-markdown-code-block);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -0,0 +1,171 @@
// DiffBlock: the inline-diff surface for a file mutation (write/edit) — a copy
// control over one or more per-file hunks, each a bold path header followed by
// the removed block (`-`, error color) and the added block (`+`, success
// 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.
import { useCallback, useMemo, useState } from 'react'
import clsx from 'clsx'
import { writeClipboard } from './clipboard.ts'
import css from './DiffBlock.module.css'
/**
* Output lines shown before the height cap collapses the middle. Matches
* {@link DEFAULT_TERMINAL_MAX_LINES} so a diff card and a terminal card cut a
* long body at the same place.
*/
export const DEFAULT_DIFF_MAX_LINES = 16
/**
* One file's change, in the shape {@link DiffBlock} draws. Structurally the
* render-intent contract's `FileDiff`, redeclared here so this primitive stays
* 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). */
path: string
/** Prior content, or `null` for a new file / an overwrite (nothing on the removed side). */
oldText: string | null
/** Content after the change (the added side). */
newText: string
}
export interface DiffBlockProps {
/** One entry per applied hunk, in file order; empty renders nothing. */
diffs: DiffHunk[]
/** Height cap in body lines before the middle collapses (default {@link DEFAULT_DIFF_MAX_LINES}). */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
}
/** A single rendered body line and its role, so the height cap slices a flat list. */
interface DiffRow {
kind: 'path' | 'del' | 'add' | 'gap'
text: string
}
/** 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,
del: css.del,
add: css.add,
gap: css.gap,
}
/**
* 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.
* @param diffs - the hunks to render.
* @returns the body rows, the +/- totals, and the distinct-file count.
*/
function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
const rows: DiffRow[] = []
const paths = new Set<string>()
let added = 0
let removed = 0
let prevPath: string | undefined
for (const diff of diffs) {
paths.add(diff.path)
if (diff.path !== prevPath) rows.push({ kind: 'path', text: diff.path })
else rows.push({ kind: 'gap', text: '⋯' })
prevPath = diff.path
if (diff.oldText !== null) {
for (const line of diff.oldText.split('\n')) {
rows.push({ kind: 'del', text: line })
removed++
}
}
for (const line of diff.newText.split('\n')) {
rows.push({ kind: 'add', text: line })
added++
}
}
return { rows, added, removed, files: paths.size }
}
/**
* 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
* change; the path headers keep a multi-file copy attributable.
* @param rows - the flattened body rows.
* @returns the diff as plain text.
*/
function copyText(rows: DiffRow[]): string {
return rows.map((row) => {
switch (row.kind) {
case 'del': return `- ${row.text}`
case 'add': return `+ ${row.text}`
case 'gap': return row.text
default: return row.text
}
}).join('\n')
}
/**
* Render a file mutation as an inline diff surface.
* @param props - see {@link DiffBlockProps}.
* @returns the diff block element.
*/
export function DiffBlock({ diffs, maxLines = DEFAULT_DIFF_MAX_LINES, className }: DiffBlockProps) {
const { rows, added, removed, files } = useMemo(() => buildRows(diffs), [diffs])
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
void writeClipboard(copyText(rows)).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
})
}, [copied, rows])
const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
if (rows.length === 0) return null
const hidden = rows.length - maxLines
const capped = hidden > 0 && !expanded
// Same split arithmetic as TerminalBlock and the TUI transcript's collapsed
// card, so a body's head and tail slices agree across the front ends.
const headLines = Math.ceil(maxLines / 2)
const tailLines = maxLines - headLines
const head = capped ? rows.slice(0, headLines) : rows
const tail = capped ? rows.slice(rows.length - tailLines) : []
return (
<div className={clsx(css.block, className)} data-diff="">
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? '复制成功' : '复制'}
</button>
<div className={css.body}>
{head.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
))}
{hidden > 0 && (
<button
type="button"
className={css.expand}
aria-expanded={expanded}
aria-label={expanded ? '收起差异' : `展开其余 ${hidden} 行差异`}
onClick={onToggle}
>
{expanded ? '收起' : `… 其余 ${hidden}`}
</button>
)}
{tail.map((row, index) => (
<div key={index} className={clsx(css.line, ROW_CLASS[row.kind])}>{row.text}</div>
))}
</div>
<div className={css.footer}> +{added} -{removed} · {files} file{files === 1 ? '' : 's'}</div>
</div>
)
}

View File

@@ -22,6 +22,8 @@ export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps } from './JsonTree.tsx'
export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
export type { TerminalBlockProps } from './TerminalBlock.tsx'
export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'