فهرست منبع

fix(client): bound contextual diff comparison work

Turtle 4 روز پیش
والد
کامیت
f50309b5d9

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-14-web-diff-context.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 .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
+2026-09-14-web-diff-context.md: 072af0966689d475d7fdd1df83861fa847f3745f
+2026-09-14-web-diff-context.zh.md: cd165a443451fc5ba580078b1b431a2788671dbe

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

@@ -10,12 +10,24 @@ Filesystem result metadata carries before/after fragments that include unchanged
 
 ## 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.
+The Web primitive derives line patches with the maintained `diff` library, using `maxEditLength: 256`. Each exact change includes up to three neutral context lines on either side; distant changes use separate hunks and shared context contributes to neither total. Beyond 256 additions/deletions per fragment, search stops and the complete old/new fragments render as a coarse replacement, including shared lines in the display, copy, and counts. The card and `diffTotals` use this same deterministic 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.
+Unbounded comparison stalls collapsed summaries on heavily changed fragments. A deterministic edit-distance limit preserves exact sparse edits regardless of file length; a wall-clock timeout could make the summary and body choose different results under load. A coarse replacement sacrifices alignment above the limit while retaining every input line. Caching or asynchronous rendering adds ownership and invalidation work that the bounded comparison does not require. Extending durable metadata or maintaining a custom diff algorithm is unnecessary for this presentation behavior.
+
+## Measurement
+
+A local CPU diagnostic bundled the production `DiffBlock.tsx` entry with esbuild (`--bundle --platform=node --format=esm`) and timed `diffTotals` under Node 26.5.0 on macOS ARM64. Each fragment has 10,000 lines: unique indexed lines replaced completely, 100 evenly spaced replacements, or alternating repeated `old`/`shared` versus `new`/`shared` lines. Input construction and module loading are excluded; returned totals remain reachable. These are function timings, not browser paint or input latency, and carry no CI timing threshold.
+
+| Input | Unbounded milliseconds | Bounded search milliseconds |
+| --- | --- | --- |
+| Complete replacement | 6492.16, 6777.79, 6786.79 | 5.25, 4.66, 4.19 |
+| 100 sparse replacements | 8.19, 4.58, 4.01 | 7.58, 4.26, 4.13 |
+| Alternating repeated lines | 3383.13 | 10.26, 7.46, 5.52 |
+
+The bound admits all 100 sparse replacements unchanged. The 129-replacement regression fails without it because the unbounded implementation returns exact counts instead of the required complete-fragment fallback.
 
 ## 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.
+The browser build includes `diff`. The bound limits edit-graph search, not wall-clock duration: normalization, fallback rows, and copied output still scale with input length, and expanded cards also derive their rows separately. The content-line rule treats a final newline as a terminator. Regressions cover exact output at 256 edits, complete coarse output above the limit, a sparse edit in 10,000 lines, shared and distant context, repeated lines, copied prefixes, and summary/footer parity. Authored and borrowed Session snapshots cover coarse and exact browser cards respectively.

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

@@ -10,12 +10,24 @@ Status: implemented
 
 ## Decision
 
-Web 原语通过维护中的 `diff` 库生成行补丁。每处改动两侧最多保留三行上下文;远距离改动分成独立 hunk。上下文以中性色显示一次,且不计入增删统计。卡片和 `diffTotals` 使用同一补丁推导。这遵循[工具呈现归属决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md),仍属于 Client 呈现,不改变持久化元数据或公开 props。
+Web 原语通过维护中的 `diff` 库生成行补丁,使用 `maxEditLength: 256`。每处精确改动两侧最多保留三行中性上下文;远距离改动分成独立 hunk,共享上下文不计入增删统计。每个片段的新增与删除行数超过 256 时,搜索停止,完整新旧片段按粗粒度替换呈现,共享行也计入显示、复制和统计。卡片与 `diffTotals` 使用同一确定性推导。这遵循[工具呈现归属决策](../architecture/2026-08-23-client-derived-tool-presentation.zh.md),仍属于 Client 呈现,不改变持久化元数据或公开 props。
 
 ## Alternatives considered
 
-只裁剪共同前后缀无法识别替换之间的未改动行。给持久化元数据扩展行类型会让纯显示修正要求生产方和历史读取方一同改变。自定义 diff 算法没有独立需求,却增加维护负担。
+无上限比较会让大量改动片段的折叠摘要停顿。确定性的编辑距离上限能让稀疏编辑保持精确,不受文件长度影响;墙钟超时可能使摘要和正文在负载下选择不同结果。粗粒度替换在超过上限后放弃对齐,但保留全部输入行。缓存或异步渲染会增加归属和失效处理,而有界比较不需要这些机制。该呈现行为无需扩展持久化元数据或维护自定义 diff 算法。
+
+## Measurement
+
+本地 CPU 诊断用 esbuild(`--bundle --platform=node --format=esm`)打包生产 `DiffBlock.tsx` 入口,并在 macOS ARM64 的 Node 26.5.0 下计时 `diffTotals`。每个片段有一万行:全部替换带唯一索引的行、均匀分布的 100 处替换,或交替重复的 `old`/`shared` 与 `new`/`shared` 行。不计输入构造和模块加载;返回的统计值保持可达。这些是函数耗时,不是浏览器绘制或输入延迟,也没有作为 CI 时间阈值。
+
+| 输入 | 无上限毫秒数 | 有界搜索毫秒数 |
+| --- | --- | --- |
+| 全部替换 | 6492.16, 6777.79, 6786.79 | 5.25, 4.66, 4.19 |
+| 100 处稀疏替换 | 8.19, 4.58, 4.01 | 7.58, 4.26, 4.13 |
+| 交替重复行 | 3383.13 | 10.26, 7.46, 5.52 |
+
+上限允许全部 100 处稀疏替换保持精确。移除上限时,129 处替换回归会失败,因为无上限实现返回精确统计,而非要求的完整片段回退。
 
 ## Consequences
 
-浏览器构建包含 `diff`。非常大的替换采用同步比较,即使折叠摘要也可能延迟渲染;行高限制不会约束这项工作。不采用近似的完整替换回退,因为它可能错误标记未改动行。既有内容行规则把末尾换行视为终止符,因此仅末尾换行有无不同仍不展示。组件回归覆盖共享和远距离上下文、重复行、插入与删除、复制前缀及摘要统计。
+浏览器构建包含 `diff`。上限约束编辑图搜索,不约束墙钟时长:规范化、回退行及复制内容仍随输入长度增长,展开卡片也会单独推导正文行。内容行规则把末尾换行视为终止符。回归覆盖 256 次编辑时的精确输出、超过上限后的完整粗粒度输出、一万行中的稀疏编辑、共享和远距离上下文、重复行、复制前缀及摘要与底部统计一致性。编写和借用的 Session 快照分别覆盖粗粒度和精确的浏览器卡片

+ 17 - 12
apps/web/tests/diff-context.e2e.ts

@@ -1,4 +1,4 @@
-/** Cold rendering of the shared filesystem edit Session preserves neutral context. */
+/** Cold Session rendering covers exact context and bounded whole-fragment replacements. */
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { chromium, type Browser, type Page } from 'playwright'
@@ -9,11 +9,15 @@ import {
 } from './scaffold.ts'
 import { expandTurnProcesses, newEnglishPage, saveFailureShot } 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 ROOT = fileURLToPath(new URL('../../../snapshots', import.meta.url))
 const MODE = webSnapshotMode()
 
-describe.skipIf(MODE === 'record')('web e2e: contextual edit diff', () => {
+const CASES = [
+  { name: 'diff-context', source: 'session/fs-edit/session.v3.jsonl', totals: '+1 -1', shared: 'level=info', inventory: ['ui.expected.md'] },
+  { name: 'diff-bounded', source: 'web/diff-bounded/session.v3.jsonl', totals: '+130 -130', shared: 'shared heading', inventory: ['session.v3.jsonl', 'ui.expected.md'] },
+]
+
+describe.skipIf(MODE === 'record').each(CASES)('web e2e: $name', (scenario) => {
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
@@ -21,7 +25,7 @@ describe.skipIf(MODE === 'record')('web e2e: contextual edit diff', () => {
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
-    await seedSession(scaffold, await readFile(SOURCE, 'utf8'), 'diff-context')
+    await seedSession(scaffold, await readFile(`${ROOT}/${scenario.source}`, 'utf8'), scenario.name)
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
     tripwire = watchConsole(page)
@@ -36,8 +40,8 @@ describe.skipIf(MODE === 'record')('web e2e: contextual edit diff', () => {
     }
   })
 
-  it('shows true totals before expansion and neutral shared context after expansion', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-diff-context'))
+  it('keeps collapsed and expanded counts consistent with the displayed diff', async () => {
+    onTestFailed(() => saveFailureShot(page, `web-e2e-${scenario.name}`))
     const group = page.locator('[role="treeitem"]').first()
     await group.waitFor({ timeout: 15_000 })
     await group.click()
@@ -45,16 +49,17 @@ describe.skipIf(MODE === 'record')('web e2e: contextual edit diff', () => {
     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.textContent()).toContain(scenario.totals)
     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`,
+    expect(await card.getByText(scenario.shared, { exact: true }).count()).toBe(1)
+    expect(await card.textContent()).toContain(`${scenario.totals} · 1 file`)
+    const snapshotDir = `${ROOT}/web/${scenario.name}`
+    await compareOrRefreshGolden(`${snapshotDir}/ui.expected.md`,
       await captureStableAria(page, '[data-variant="edit"]', scaffold.workspaceCwd), MODE)
-    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+    await assertFixtureInventory(snapshotDir, scenario.inventory)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   })

+ 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: fb2765de406da11db87f9651999d12030c9297a7
-README.zh.md: 9eb69c6a46d972b3fee4a42d77d534b4dae88920
+README.md: 631b19de44660c21813bc4f87b7a59dc8dfa6ee3
+README.zh.md: b23c87f7861555625f28a9fd182aa6acb159b21c

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

@@ -73,7 +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.
+`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. Search stops beyond 256 line additions/deletions per fragment; those fragments display and count the complete old and new contents as a coarse replacement, including shared lines. Copy includes the full displayed diff with its 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.
 
@@ -144,7 +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.
+- **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations.
 - **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 - 2
packages/client/ui-primitives/README.zh.md

@@ -73,7 +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。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。
+`DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,摘要和底部统计都不计入共享上下文。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。
 
 `JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。
 
@@ -144,7 +144,7 @@ kind: "package-library"
 
 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
 
-- **大规模替换需要同步行比较**:工具行折叠时也会计算 diff 统计;包含大量改动行的输入可能延迟渲染。高度限制只约束显示行数,不限制比较工作量
+- **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配
 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
 - **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。
 - **字形级图标是重新绘制的近似版本**:鱼形标志与闪光标记来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。

+ 12 - 5
packages/client/ui-primitives/src/DiffBlock.tsx

@@ -64,15 +64,22 @@ const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
   gap: css.gap,
 }
 
-/** Derive local patches with three context lines, using the card's terminator rule. */
+/** Bound synchronous edit-graph search; one replacement consumes two edits. */
+const MAX_DIFF_EDIT_LENGTH = 256
+
+/** Derive exact local patches or a whole-fragment replacement when search exceeds the limit. */
 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
+  const oldLines = contentLines(diff.oldText ?? '')
+  const newLines = contentLines(diff.newText)
+  const normalize = (lines: string[]): string => lines.map(line => `${line}\n`).join('')
+  return structuredPatch('', '', normalize(oldLines), normalize(newLines),
+    undefined, undefined, { context: 3, maxEditLength: MAX_DIFF_EDIT_LENGTH })?.hunks
+    ?? [{ lines: [...oldLines.map(line => `-${line}`), ...newLines.map(line => `+${line}`)] }]
 }
 
 /**
- * Count actual added and removed lines; shared context contributes to neither total.
+ * Count displayed additions and deletions. Exact patches exclude shared context;
+ * comparisons exceeding the edit limit count both complete fragments as replaced.
  * Text follows {@link contentLines}'s terminator rule.
  * @param diffs - the hunks to count.
  * @returns the +/- totals for summaries and the card footer.

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

@@ -96,6 +96,35 @@ describe('DiffBlock structure', () => {
 })
 
 describe('DiffBlock local changes', () => {
+  it.each([128, 129])('renders and copies %i replacements with bounded comparison', async (count) => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+    const oldLines = ['shared context', ...Array.from({ length: count }, (_, i) => `old ${i}`)]
+    const newLines = ['shared context', ...Array.from({ length: count }, (_, i) => `new ${i}`)]
+    const diffs = [{ path: 'large.txt', oldText: oldLines.join('\n'), newText: newLines.join('\n') }]
+    const total = count === 128 ? count : count + 1
+    render(<DiffBlock diffs={diffs} maxLines={1000} />)
+    expect(diffTotals(diffs)).toEqual({ added: total, removed: total })
+    expect(screen.getByText(`└ +${total} -${total} · 1 file`)).toBeTruthy()
+    await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
+    expect(writeText).toHaveBeenCalledWith(count === 128
+      ? ['large.txt', '  shared context', ...oldLines.slice(1).map(line => `- ${line}`), ...newLines.slice(1).map(line => `+ ${line}`)].join('\n')
+      : ['large.txt', ...oldLines.map(line => `- ${line}`), ...newLines.map(line => `+ ${line}`)].join('\n'))
+  })
+
+  it('keeps a sparse edit exact in a ten-thousand-line fragment', () => {
+    const before = Array.from({ length: 10000 }, (_, i) => `line ${i}`)
+    const after = [...before]
+    after[5000] = 'changed'
+    const diffs = [{ path: 'sparse.txt', oldText: before.join('\n'), newText: after.join('\n') }]
+    const { container } = render(<DiffBlock diffs={diffs} />)
+    expect(diffTotals(diffs)).toEqual({ added: 1, removed: 1 })
+    expect(bodyRows(container)).toEqual([
+      'sparse.txt', 'line 4997', 'line 4998', 'line 4999', 'line 5000', 'changed',
+      'line 5001', 'line 5002', 'line 5003',
+    ])
+  })
+
   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 } })

+ 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: 2887da69937cea89aad68e9d01a5fa507ef248ec
-README.zh.md: 8df7cca46966f6afcab630dc2e5d77a546b7304d
+README.md: 15dffa634660a5cf94081452481399821d09b25b
+README.zh.md: 6f01658e5fea4fefca9eded114971d3e88a13148

+ 1 - 1
packages/client/ui-tool/README.md

@@ -64,7 +64,7 @@ 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.
+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 and expanded footer share the primitive's exact or coarse-replacement counts.
 
 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.
 

+ 1 - 1
packages/client/ui-tool/README.zh.md

@@ -64,7 +64,7 @@ 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 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。折叠工具行只统计实际新增和删除的行
+Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。折叠工具行与展开卡片底部采用原语一致的精确或粗粒度替换统计
 
 Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。
 

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 16 - 0
snapshots/web/diff-bounded/session.v3.jsonl


+ 7 - 0
snapshots/web/diff-bounded/snapshot.yml

@@ -0,0 +1,7 @@
+version: 1
+scenario: diff-bounded
+profile: web
+composition: default
+recording: authored
+header:
+  class: default

+ 10 - 0
snapshots/web/diff-bounded/ui.expected.md

@@ -0,0 +1,10 @@
+- button "Edit large.txt +130 -130" [expanded]:
+  - img
+  - text: Edit
+  - button "large.txt"
+  - text: +130 -130
+- button "Copy"
+- text: large.txt - shared heading - old setting 0 - old setting 1 - old setting 2
+- button "Expand 252 more diff lines": … 252 more lines
+- text: + new setting 125 + new setting 126 + new setting 127 + new setting 128 └ +130 -130 · 1 file
+- button "Inspect"

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است