Переглянути джерело

fix(client): render actual changes in contextual diff cards

Turtle 1 місяць тому
батько
коміт
242410cf4b

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-14-web-diff-context.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/bug-fix/2026-09-14-web-diff-context.md
+2026-09-14-web-diff-context.md: 6fdf61563d78c7a302dcdb8255d9c8c50236a202
+2026-09-14-web-diff-context.zh.md: 1c0a99408489ef54f20a6deb7ead481967f0b04d

+ 21 - 0
.agents/notes/implemented/bug-fix/2026-09-14-web-diff-context.md

@@ -0,0 +1,21 @@
+# Agent Note: Web diff cards compare contextual content
+
+Status: implemented
+
+English | [中文](2026-09-14-web-diff-context.zh.md)
+
+## Problem
+
+Filesystem result metadata carries before/after fragments that include unchanged context. Treating each complete fragment as removed or added mislabels shared lines and inflates both card and collapsed-row totals.
+
+## Decision
+
+The Web primitive derives line patches with the maintained `diff` library. Each change includes up to three context lines on either side; distant changes use separate hunks. Context appears once in a neutral tone and contributes to neither total. The card and `diffTotals` use the same patch derivation. This remains Client presentation under the [tool presentation ownership decision](../architecture/2026-08-23-client-derived-tool-presentation.md), without changing persisted metadata or public props.
+
+## Alternatives considered
+
+Trimming only a common prefix and suffix cannot recognize unchanged lines between replacements. Extending durable metadata with row kinds would require producers and historical readers to change for a display-only correction. A custom diff algorithm adds maintenance without a distinct requirement.
+
+## Consequences
+
+The browser build includes `diff`. Comparing very large replacements is synchronous and can delay rendering even for collapsed summaries; the row height cap does not bound this work. An approximate replacement fallback is excluded because it can mislabel unchanged lines. The existing content-line rule treats a trailing newline as a terminator, so newline-only differences remain unrepresented. Component regressions cover shared and distant context, repeated lines, insertion/deletion, copied prefixes, and summary totals.

+ 21 - 0
.agents/notes/implemented/bug-fix/2026-09-14-web-diff-context.zh.md

@@ -0,0 +1,21 @@
+# Agent Note: Web diff 卡片比较含上下文的内容
+
+Status: implemented
+
+[English](2026-09-14-web-diff-context.md) | 中文
+
+## Problem
+
+文件系统结果元数据携带的前后文本片段包含未改动的上下文。把完整片段分别当作删除和新增会错误标记共享行,并夸大卡片和折叠工具行的统计。
+
+## Decision
+
+Web 原语通过维护中的 `diff` 库生成行补丁。每处改动两侧最多保留三行上下文;远距离改动分成独立 hunk。上下文以中性色显示一次,且不计入增删统计。卡片和 `diffTotals` 使用同一补丁推导。这遵循[工具呈现归属决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md),仍属于 Client 呈现,不改变持久化元数据或公开 props。
+
+## Alternatives considered
+
+只裁剪共同前后缀无法识别替换之间的未改动行。给持久化元数据扩展行类型会让纯显示修正要求生产方和历史读取方一同改变。自定义 diff 算法没有独立需求,却增加维护负担。
+
+## Consequences
+
+浏览器构建包含 `diff`。非常大的替换采用同步比较,即使折叠摘要也可能延迟渲染;行高限制不会约束这项工作。不采用近似的完整替换回退,因为它可能错误标记未改动行。既有内容行规则把末尾换行视为终止符,因此仅末尾换行有无不同仍不展示。组件回归覆盖共享和远距离上下文、重复行、插入与删除、复制前缀及摘要统计。

+ 56 - 0
apps/web/tests/diff-context.e2e.ts

@@ -0,0 +1,56 @@
+/** Cold rendering of the shared filesystem edit Session preserves neutral context. */
+import { readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, seedSession, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { expandTurnProcesses, newEnglishPage } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/diff-context', import.meta.url))
+const SOURCE = fileURLToPath(new URL('../../../snapshots/session/fs-edit/session.v3.jsonl', import.meta.url))
+const MODE = webSnapshotMode()
+
+describe.skipIf(MODE === 'record')('web e2e: contextual edit diff', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    await seedSession(scaffold, await readFile(SOURCE, 'utf8'), 'diff-context')
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+  })
+
+  afterAll(async () => {
+    try {
+      await browser?.close()
+    } finally {
+      await scaffold?.close()
+    }
+  })
+
+  it('shows true totals before expansion and neutral shared context after expansion', async () => {
+    const group = page.locator('[role="treeitem"]').first()
+    await group.waitFor({ timeout: 15_000 })
+    await group.click()
+    await page.locator('[role="treeitem"]').nth(1).click()
+    await page.getByText('DONE', { exact: true }).waitFor({ timeout: 15_000 })
+    await expandTurnProcesses(page)
+    const edit = page.locator('[data-variant="edit"]')
+    expect(await edit.textContent()).toContain('+1 -1')
+    expect(await edit.locator('[data-diff]').count()).toBe(0)
+    await edit.locator('[data-expandable]').click()
+    const card = edit.locator('[data-diff]')
+    await card.waitFor()
+    expect(await card.getByText('level=info', { exact: true }).count()).toBe(1)
+    expect(await card.textContent()).toContain('+1 -1 · 1 file')
+    await compareOrRefreshGolden(`${SNAPSHOT_DIR}/ui.expected.md`,
+      await captureStableAria(page, '[data-variant="edit"]', scaffold.workspaceCwd), MODE)
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+  })
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -23,6 +23,7 @@
   // cannot see both sides of the cordis Context merges).
   "exclude": [
     "tests/default-product-isolation.e2e.ts",
+    "tests/diff-context.e2e.ts",
     "tests/scaffold.ts",
     "tests/auto-review-fixture.ts",
     "tests/scaffold-generation.spec.ts",

+ 2 - 2
packages/client/ui-primitives/README.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 packages/client/ui-primitives/README.md
-README.md: 07e61be1bb993716c85273aac8ee6e650d221365
-README.zh.md: 194d8e994d82ae167ce2c7af06d9b199ef066b27
+README.md: fb2765de406da11db87f9651999d12030c9297a7
+README.zh.md: 9eb69c6a46d972b3fee4a42d77d534b4dae88920

+ 2 - 0
packages/client/ui-primitives/README.md

@@ -73,6 +73,7 @@ The catalog above lists what each export is for; this section covers the behavio
 
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
 
+`DiffBlock` compares the old and new content by line. It shows actual additions and deletions with up to three neutral context lines on each side, separates distant changes with `⋯`, and excludes shared context from both summary and footer totals. Copy includes the local diff with context prefixes. A final newline is treated as a terminator; differences only in the presence of a final newline are not displayed.
 
 `JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree.
 
@@ -143,6 +144,7 @@ None; this package neither assembles nor sends a provider request.
 
 These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
 
+- **Large replacements require synchronous line comparison** — diff totals are computed even while a tool row is collapsed; inputs with many changed lines can delay rendering. The height cap limits displayed rows, not comparison work.
 - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it.
 - **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path.
 - **Glyph-level icons are redrawn approximations** — the fish logo and the sparkle mark come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.

+ 2 - 0
packages/client/ui-primitives/README.zh.md

@@ -73,6 +73,7 @@ kind: "package-library"
 
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 
+`DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,摘要和底部统计都不计入共享上下文。复制包含带上下文前缀的局部 diff。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。
 
 `JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
 
@@ -143,6 +144,7 @@ kind: "package-library"
 
 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
 
+- **大规模替换需要同步行比较**:工具行折叠时也会计算 diff 统计;包含大量改动行的输入可能延迟渲染。高度限制只约束显示行数,不限制比较工作量。
 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。
 - **字形级图标是重新绘制的近似版本**:鱼形标志与闪光标记来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。

+ 1 - 0
packages/client/ui-primitives/package.json

@@ -28,6 +28,7 @@
     "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0",
     "clsx": "^2.0.0",
+    "diff": "^9.0.0",
     "react-dom": "^18.2.0",
     "anser": "^2.3.5",
     "katex": "^0.16.47",

+ 8 - 0
packages/client/ui-primitives/src/DiffBlock.module.css

@@ -76,6 +76,14 @@
   color: var(--dsw-alias-state-success-primary);
 }
 
+.context {
+  color: var(--dsw-alias-label-secondary);
+}
+
+.context::before {
+  content: '  ';
+}
+
 .expand {
   display: block;
   width: 100%;

+ 33 - 17
packages/client/ui-primitives/src/DiffBlock.tsx

@@ -1,5 +1,6 @@
 import { useCallback, useMemo, useState } from 'react'
 import clsx from 'clsx'
+import { structuredPatch } from 'diff'
 import { FoldToggle } from './FoldToggle.tsx'
 import { writeClipboard } from './clipboard.ts'
 import css from './DiffBlock.module.css'
@@ -14,9 +15,9 @@ export const DEFAULT_DIFF_MAX_LINES = 16
 export interface DiffHunk {
   /** 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). */
+  /** Prior content including context, or `null` when no prior content is available. */
   oldText: string | null
-  /** Content after the change (the added side). */
+  /** Content after the change, including any shared context. */
   newText: string
 }
 
@@ -44,7 +45,7 @@ export interface DiffBlockLabels {
 
 /** A single rendered body line and its role, so the height cap slices a flat list. */
 interface DiffRow {
-  kind: 'path' | 'del' | 'add' | 'gap'
+  kind: 'path' | 'del' | 'add' | 'context' | 'gap'
   text: string
 }
 
@@ -59,23 +60,33 @@ const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
   path: css.path,
   del: css.del,
   add: css.add,
+  context: css.context,
   gap: css.gap,
 }
 
+/** Derive local patches with three context lines, using the card's terminator rule. */
+function localHunks(diff: DiffHunk) {
+  const normalize = (text: string): string => contentLines(text).map(line => `${line}\n`).join('')
+  return structuredPatch('', '', normalize(diff.oldText ?? ''), normalize(diff.newText),
+    undefined, undefined, { context: 3 }).hunks
+}
+
 /**
- * Total added/removed line counts across hunks — the same numbers the footer
- * prints, exported so a summary row can show them without rebuilding the body.
- * Every old-side line counts toward `removed` and every new-side line toward
- * `added`, under {@link contentLines}'s terminator rule.
+ * Count actual added and removed lines; shared context contributes to neither total.
+ * Text follows {@link contentLines}'s terminator rule.
  * @param diffs - the hunks to count.
- * @returns the +/- totals.
+ * @returns the +/- totals for summaries and the card footer.
  */
 export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number } {
   let added = 0
   let removed = 0
   for (const diff of diffs) {
-    if (diff.oldText !== null) removed += contentLines(diff.oldText).length
-    added += contentLines(diff.newText).length
+    for (const hunk of localHunks(diff)) {
+      for (const line of hunk.lines) {
+        if (line.startsWith('+')) added++
+        if (line.startsWith('-')) removed++
+      }
+    }
   }
   return { added, removed }
 }
@@ -99,16 +110,20 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed
     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 contentLines(diff.oldText)) {
-        rows.push({ kind: 'del', text: line })
+    for (const [index, hunk] of localHunks(diff).entries()) {
+      if (index > 0) rows.push({ kind: 'gap', text: '⋯' })
+      for (const line of hunk.lines) {
+        const kind = line.startsWith('-') ? 'del' : line.startsWith('+') ? 'add' : 'context'
+        rows.push({ kind, text: line.slice(1) })
       }
     }
-    for (const line of contentLines(diff.newText)) {
-      rows.push({ kind: 'add', text: line })
-    }
   }
-  return { rows, ...diffTotals(diffs), files: paths.size }
+  return {
+    rows,
+    added: rows.filter(row => row.kind === 'add').length,
+    removed: rows.filter(row => row.kind === 'del').length,
+    files: paths.size,
+  }
 }
 
 /**
@@ -138,6 +153,7 @@ function copyText(rows: DiffRow[]): string {
     switch (row.kind) {
       case 'del': return `- ${row.text}`
       case 'add': return `+ ${row.text}`
+      case 'context': return `  ${row.text}`
       case 'path': return row.text
       case 'gap': return row.text
       /* v8 ignore next -- closed-union backstop; only reached if a row kind is forged */

+ 46 - 0
packages/client/ui-primitives/tests/diff-block.client.spec.tsx

@@ -5,6 +5,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { ComponentProps } from 'react'
 import { DEFAULT_DIFF_MAX_LINES, DiffBlock as LocalizedDiffBlock, type DiffHunk } from '../src/index.ts'
 import { diffBlockLabels } from './labels.client.ts'
+import { diffTotals } from '../src/DiffBlock.tsx'
 
 function DiffBlock(props: Omit<ComponentProps<typeof LocalizedDiffBlock>, 'labels'>) {
   return <LocalizedDiffBlock {...props} labels={diffBlockLabels} />
@@ -94,6 +95,51 @@ describe('DiffBlock structure', () => {
   })
 })
 
+describe('DiffBlock local changes', () => {
+  it('copies shared context once and counts only a changed line', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+    const diffs = [{ path: 'settings.ts', oldText: 'start\nmode = 1\nend', newText: 'start\nmode = 2\nend' }]
+    render(<DiffBlock diffs={diffs} />)
+    expect(screen.getAllByText('start')).toHaveLength(1)
+    expect(screen.getAllByText('end')).toHaveLength(1)
+    expect(screen.getByText('└ +1 -1 · 1 file')).toBeTruthy()
+    expect(diffTotals(diffs)).toEqual({ added: 1, removed: 1 })
+    await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
+    expect(writeText).toHaveBeenCalledWith('settings.ts\n  start\n- mode = 1\n+ mode = 2\n  end')
+  })
+
+  it('keeps three neutral context lines around distant changes and separates hunks', () => {
+    const before = Array.from({ length: 50 }, (_, i) => `item ${i}`)
+    const after = before.map((line, i) => i === 10 || i === 40 ? `changed ${i}` : line)
+    const { container } = render(<DiffBlock diffs={[{
+      path: 'items.txt', oldText: before.join('\n'), newText: after.join('\n'),
+    }]} maxLines={100} />)
+    expect(bodyRows(container)).toEqual([
+      'items.txt', 'item 7', 'item 8', 'item 9', 'item 10', 'changed 10',
+      'item 11', 'item 12', 'item 13', '⋯', 'item 37', 'item 38', 'item 39',
+      'item 40', 'changed 40', 'item 41', 'item 42', 'item 43',
+    ])
+    expect(changeRows(container)).toEqual(['item 10', 'changed 10', 'item 40', 'changed 40'])
+    expect(screen.getByText('└ +2 -2 · 1 file')).toBeTruthy()
+  })
+
+  it.each([
+    ['a\nx\na\ny\na', 'a\nx\na\nz\na', 1, 1],
+    ['a\nb', 'a\nx\nb', 1, 0],
+    ['a\nx\nb', 'a\nb', 0, 1],
+    ['same\n', 'same', 0, 0],
+    ['same', 'same', 0, 0],
+    ['', '', 0, 0],
+    ['a\n\nb', 'a\nb', 0, 1],
+  ])('counts ordered changes in %j → %j', (oldText, newText, added, removed) => {
+    const diffs = [{ path: 'a.txt', oldText, newText }]
+    render(<DiffBlock diffs={diffs} />)
+    expect(diffTotals(diffs)).toEqual({ added, removed })
+    expect(screen.getByText(`└ +${added} -${removed} · 1 file`)).toBeTruthy()
+  })
+})
+
 describe('DiffBlock footer', () => {
   it('counts added and removed lines and one file', () => {
     const diffs: DiffHunk[] = [{ path: 'a.ts', oldText: 'a\nb', newText: 'c' }]

+ 2 - 2
packages/client/ui-tool/README.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 packages/client/ui-tool/README.md
-README.md: b7e71ac68090e19b1a87428ed930021d8decd209
-README.zh.md: 33dc7b38f8539de10a45edbe4b8f45c903188c2a
+README.md: 2887da69937cea89aad68e9d01a5fa507ef248ec
+README.zh.md: 8df7cca46966f6afcab630dc2e5d77a546b7304d

+ 2 - 0
packages/client/ui-tool/README.md

@@ -64,6 +64,8 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool
 
 Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules.
 
+Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The collapsed row reports only actual additions and deletions.
+
 An Auto denial takes precedence over keyed specialized views. Its generic row preserves the call identity, omits raw arguments, and normalizes the stored reason only for display: trim surrounding whitespace and collapse line separators to spaces, with localized fallback for an empty result. Session and SDK error details keep the original reason.
 
 The terminal model uses `hasSpillNotice` from the browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry, not an independent UI pattern. The [spill-policy README](../../spill/spill-policy/README.md#shared-notice-ownership) owns notice formatting and recognition. This check conservatively selects generic output; matching text cannot authenticate its source, and replay leaves recorded result bytes untouched.

+ 2 - 0
packages/client/ui-tool/README.zh.md

@@ -64,6 +64,8 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
 
 每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。
 
+Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。折叠工具行只统计实际新增和删除的行。
+
 Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。
 
 terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 的 `hasSpillNotice`,而非独立的 UI 匹配规则。[spill-policy README](../../spill/spill-policy/README.zh.md#shared-notice-ownership) 负责提示文本的格式化与识别。该检查保守地选择通用输出;匹配的文本无法证明其来源,回放也不改变已记录的结果字节。

+ 2 - 10
packages/client/ui-tool/src/client/tool/models/diff-card-model.ts

@@ -3,16 +3,8 @@ import type { DiffBlockProps, DiffHunk } from '@deepseek-ai/dsh-client-ui-primit
 import type { ToolCallBlock } from './tool-call-model.ts'
 import { parsedToolCall, validEscalationFields } from './raw-tool-call.ts'
 
-/**
- * Diff-body lines the chat row shows before collapsing the middle — half the
- * primitive's own default, which the details panel keeps. A chat row is a
- * summary surface inside the message flow: the flow must stay scannable across
- * many calls, while the details panel is the single-call reading surface. The
- * same split {@link CHAT_TERMINAL_MAX_LINES} draws for a terminal card, so the
- * two card kinds cap a long body at the same place in the flow. A design
- * constant of this UI's row geometry, not a deployment choice.
- */
-export const CHAT_DIFF_MAX_LINES = 8
+/** Room for a path, one removed/added pair, and three context lines on each side. */
+export const CHAT_DIFF_MAX_LINES = 9
 
 /**
  * The {@link DiffBlock} props this derivation owns. Picked off the primitive's

+ 19 - 0
packages/client/ui-tool/tests/tool-row.client.spec.tsx

@@ -299,6 +299,25 @@ describe('ToolRow', () => {
     expect(view.getByText('List files')).toBeTruthy()
   })
 
+  it('excludes shared context from collapsed edit totals and the expanded card', () => {
+    const view = render(<ToolRow {...rowProps} variant="edit" title="Edit" summary="settings.ts" diff={{
+      card: { diffs: [{
+        path: 'settings.ts',
+        oldText: 'start\nsecond\nthird\nold\nfourth\nfifth\nend',
+        newText: 'start\nsecond\nthird\nnew\nfourth\nfifth\nend',
+      }] },
+    }} />)
+    expect(view.getByText('+1 -1')).toBeTruthy()
+    expect(view.container.querySelector('[data-diff]')).toBeNull()
+    fireEvent.click(view.getByRole('button'))
+    expect(view.getByText(/└ \+1 -1/)).toBeTruthy()
+    expect(view.getAllByText('start')).toHaveLength(1)
+    expect(view.getAllByText('end')).toHaveLength(1)
+    expect(view.getByText('old', { exact: true })).toBeTruthy()
+    expect(view.getByText('new', { exact: true })).toBeTruthy()
+    expect(view.queryByRole('button', { name: /展开其余/ })).toBeNull()
+  })
+
   it('formats the argument body only while expanding it', () => {
     const stringify = vi.spyOn(JSON, 'stringify')
     const bodyFormatCalls = () => stringify.mock.calls.filter(

+ 3 - 0
pnpm-lock.yaml

@@ -3488,6 +3488,9 @@ importers:
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
+      diff:
+        specifier: ^9.0.0
+        version: 9.0.0
       katex:
         specifier: ^0.16.47
         version: 0.16.47

+ 9 - 0
snapshots/web/diff-context/snapshot.yml

@@ -0,0 +1,9 @@
+version: 1
+scenario: diff-context
+profile: web
+composition: default
+recording: authored
+header:
+  class: default
+session:
+  source: ../../session/fs-edit/session.v3.jsonl

+ 8 - 0
snapshots/web/diff-context/ui.expected.md

@@ -0,0 +1,8 @@
+- button "Edit config.txt +1 -1" [expanded]:
+  - img
+  - text: Edit
+  - button "config.txt"
+  - text: +1 -1
+- button "Copy"
+- text: config.txt - mode=DEBUG + mode=RELEASE level=info └ +1 -1 · 1 file
+- button "Inspect"

+ 1 - 0
tsconfig.host.json

@@ -10,6 +10,7 @@
   "include": [
     "apps/web/product-isolation.ts",
     "apps/web/tests/default-product-isolation.e2e.ts",
+    "apps/web/tests/diff-context.e2e.ts",
     "apps/web/tests/scaffold.ts",
     "apps/web/tests/auto-review-fixture.ts",
     "apps/web/tests/auto-review-denial.e2e.ts",