소스 검색

feat(deliverables): compare each changed file from the card

Rows of the changed-files card open a `changes-diff` right-Sidebar tab that compares the file at turn start and turn end. The workspace-changes recorder copies every file a file tool edits into the Session's temporary directory before the first edit and again at turn end, so files git does not cover are compared without git and counted first-to-last; `maxFileBytes` caps copies and snapshot reads, and `diffTimeoutMs` degrades a slow line comparison to whole-file replacement.
creatixchu 2 일 전
부모
커밋
a79b97ecca
62개의 변경된 파일1930개의 추가작업 그리고 423개의 파일을 삭제
  1. 2 2
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml
  2. 4 4
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md
  3. 4 4
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml
  5. 43 0
      .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md
  6. 43 0
      .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md
  7. 25 3
      apps/web/tests/changed-files-turn.e2e.ts
  8. 2 2
      docs/capability-seams.i18n.yaml
  9. 1 1
      docs/capability-seams.md
  10. 1 1
      docs/capability-seams.zh.md
  11. 2 2
      docs/config-catalog.i18n.yaml
  12. 9 2
      docs/config-catalog.md
  13. 9 2
      docs/config-catalog.zh.md
  14. 2 2
      docs/subsystems/deliverables.i18n.yaml
  15. 76 9
      docs/subsystems/deliverables.md
  16. 76 9
      docs/subsystems/deliverables.zh.md
  17. 2 2
      packages/client/README.i18n.yaml
  18. 1 1
      packages/client/README.md
  19. 1 1
      packages/client/README.zh.md
  20. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  21. 11 6
      packages/client/ui-deliverables/README.md
  22. 11 6
      packages/client/ui-deliverables/README.zh.md
  23. 3 1
      packages/client/ui-deliverables/package.json
  24. 88 4
      packages/client/ui-deliverables/src/changes.ts
  25. 19 23
      packages/client/ui-deliverables/src/client/ChangedFiles.tsx
  26. 7 4
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  27. 20 0
      packages/client/ui-deliverables/src/client/DiffPreview.module.css
  28. 136 0
      packages/client/ui-deliverables/src/client/DiffPreview.tsx
  29. 70 0
      packages/client/ui-deliverables/src/client/changes-diff.ts
  30. 34 0
      packages/client/ui-deliverables/src/client/diff-definition.ts
  31. 33 11
      packages/client/ui-deliverables/src/client/index.ts
  32. 27 1
      packages/client/ui-deliverables/src/client/locales.ts
  33. 26 6
      packages/client/ui-deliverables/src/present-open.ts
  34. 53 4
      packages/client/ui-deliverables/tests/changes-open.host.spec.ts
  35. 53 26
      packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
  36. 218 0
      packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx
  37. 3 0
      packages/client/ui-deliverables/tsconfig.client.json
  38. 2 2
      packages/deliverables/README.i18n.yaml
  39. 2 2
      packages/deliverables/README.md
  40. 2 2
      packages/deliverables/README.zh.md
  41. 2 2
      packages/deliverables/workspace-changes/README.i18n.yaml
  42. 6 5
      packages/deliverables/workspace-changes/README.md
  43. 11 8
      packages/deliverables/workspace-changes/README.zh.md
  44. 88 0
      packages/deliverables/workspace-changes/src/capture.ts
  45. 66 0
      packages/deliverables/workspace-changes/src/compare.ts
  46. 46 1
      packages/deliverables/workspace-changes/src/git.ts
  47. 35 14
      packages/deliverables/workspace-changes/src/index.ts
  48. 15 114
      packages/deliverables/workspace-changes/src/numstat.ts
  49. 184 72
      packages/deliverables/workspace-changes/src/recorder.ts
  50. 55 7
      packages/deliverables/workspace-changes/src/types.ts
  51. 63 0
      packages/deliverables/workspace-changes/tests/capture.spec.ts
  52. 39 0
      packages/deliverables/workspace-changes/tests/compare.spec.ts
  53. 9 3
      packages/deliverables/workspace-changes/tests/git.spec.ts
  54. BIN
      packages/deliverables/workspace-changes/tests/numstat.spec.ts
  55. 132 43
      packages/deliverables/workspace-changes/tests/plugin.spec.ts
  56. 14 0
      packages/deliverables/workspace-changes/tests/support.ts
  57. 18 3
      packages/extensions/tool-cordis/src/api-catalog.ts
  58. 3 0
      pnpm-lock.yaml
  59. 1 0
      scripts/gen-cordis-catalog.ts
  60. 1 1
      scripts/gen-doc-graphs.ts
  61. 10 0
      scripts/type-equiv.manifest.json
  62. 3 3
      snapshots/web/changed-files-turn/ui.expected.md

+ 2 - 2
.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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/feature/2026-09-11-turn-changed-files-card.md
-2026-09-11-turn-changed-files-card.md: 37efc24b5eaa0a20af677a0e15977e3653e2fb66
-2026-09-11-turn-changed-files-card.zh.md: ae45644f90ab536d254ae83f68f4450edcd6b01e
+2026-09-11-turn-changed-files-card.md: 3afae2c21ebba63c83f5cba5e2d1d01960f25684
+2026-09-11-turn-changed-files-card.zh.md: 782e8857834763ad4efbc6d79fb2832577b510ff

+ 4 - 4
.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md

@@ -16,13 +16,13 @@ The recorder snapshots the working tree with git at turn start and turn end: `ad
 
 Git is the default executable on `PATH`; no environment plugin is consulted. Outside any repository, or without git, no snapshot is taken and the summary lists the file-tool edits alone, with the working directory as the workspace, so the card still appears but misses shell edits. Nested repositories and submodules are gitlinks and are not descended into.
 
-Changes outside snapshot coverage are handled by source. File-tool edits to ignored files and to files outside the work tree join the same list with counts summed from the hunks the tools persist with their results, or from the call's arguments when a result persists none, as a `write` that creates a file and every `str_replace_editor` mutation do; no extra baseline is captured. Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation.
+Changes outside snapshot coverage come from whole-file captures: before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder copies the named file into the Session's temporary directory, once per path per turn, and copies it again at turn end; ignored files, files outside the work tree, and every file-tool edit without a snapshot are listed from a line comparison of the two copies ([comparison decision](2026-09-15-changed-file-diff-preview.md)). Files under the temporary directories are omitted unless they lie inside the working directory; a file left in `/tmp` needs `present` to reach the user. Shell edits outside coverage are a known limitation.
 
 The list sorts by a display path in code-unit order: the path relative to the working directory, `../` for repository files above it, `~` under the home directory, otherwise absolute; parent and absolute paths therefore lead without a separate group. The card shows the total count with summed added and deleted lines in its header, three rows before a fold, and a collapse control at the bottom once expanded. With a Host desktop the header opens the deepest workspace folder containing the listed files — computed over workspace-relative paths and falling back to the workspace root — and each row opens its file in the default application; without one, rows preview in the right Sidebar.
 
 The recorder appends inside the turn on `agent/turn-stopping` and again after `turn/end` only when tool results settled after the last record, so aborted, failed, and steered turns are covered. The Client keeps the latest announcement per turn and reads its summary once through the authenticated summary route.
 
-The log deliberately carries nothing but the turn number. Summaries, snapshot trees, and the tree ids a later content comparison needs live only as long as the Session in the Host process: the summaries in the recorder, the objects in a temporary directory removed on disposal. A conversation reopened after a Host restart has no card for its earlier turns. The product decision is that a card whose content the Host can no longer open should not appear at all, so the card's lifetime equals the content's lifetime rather than the log's. Whether to keep content across restarts, and for how many turns, is left to #3822.
+The log deliberately carries nothing but the turn number. Summaries, snapshot trees, and captured copies live only as long as the Session in the Host process: the summaries in the recorder, the objects and copies in a temporary directory removed on disposal. A conversation reopened after a Host restart has no card for its earlier turns. The product decision is that a card whose content the Host can no longer open should not appear at all, so the card's lifetime equals the content's lifetime rather than the log's. Content is not kept across restarts ([comparison decision](2026-09-15-changed-file-diff-preview.md)).
 
 ## Alternatives considered
 
@@ -34,7 +34,7 @@ The log deliberately carries nothing but the turn number. Summaries, snapshot tr
 
 **A pure-JavaScript git or a bundled binary for hosts without git** adds megabytes and a platform matrix for users who mostly run without the card; until git exists the card lists file-tool edits only.
 
-**Recording the pre-edit file content at first touch** would make hunk counts exact and enable full-file diffs for uncovered files, but requires the file tools to hand their pre-read content to the recorder; the persisted hunks already carry the before and after text of each edit for that purpose.
+**Summing the hunks the file tools persist** for uncovered files was the first implementation; it counted a repeatedly edited line more than once and could not show a whole-file comparison, so the recorder now copies the whole file at first touch instead ([comparison decision](2026-09-15-changed-file-diff-preview.md)).
 
 **Recording the file list and counts in the event** was the first implementation: the card would then render from the log forever, while the content it opens would not survive. It was replaced by the announcement-only event so that the card and its content share one lifetime.
 
@@ -46,7 +46,7 @@ The log deliberately carries nothing but the turn number. Summaries, snapshot tr
 
 ## Consequences
 
-Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the Session's temporary directory, which disposal removes. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff.
+Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the Session's temporary directory, which disposal removes. Edits the user makes during a turn are attributed to it. Every file-tool edit also copies its whole file into that directory once per turn.
 
 The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card whenever a turn changes a file; one dedicated scenario seeds a git repository so the card also carries a shell edit, while the others list their file-tool writes alone. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries.
 

+ 4 - 4
.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md

@@ -16,13 +16,13 @@ Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-chan
 
 git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。不在任何仓库内或没有 git 时,不做快照,摘要只列文件工具的编辑,并以工作目录作为工作区,因此卡片仍会出现,但缺少 shell 的改动。嵌套仓库和 submodule 是 gitlink,不会深入。
 
-快照覆盖之外的改动按来源处理。文件工具对被忽略文件和工作树之外文件的编辑进入同一个列表,行数由工具随结果持久化的 hunk 累加得出,结果没有持久化 hunk 时取调用参数,新建文件的 `write` 和 `str_replace_editor` 的每种修改都是这种情况;不需要另外捕获基线。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。
+快照覆盖之外的改动来自整文件捕获:在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把所指文件复制到 Session 的临时目录,每轮每个路径一次,轮次结束时再复制一次;被忽略的文件、工作树之外的文件,以及没有快照时的每一次文件工具编辑,都由两份副本的逐行对比列出([对比决定](2026-09-15-changed-file-diff-preview.zh.md))。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。
 
 列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——每一行用默认应用打开该文件;没有桌面时行改为在右侧 Sidebar 中预览。
 
 记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖。Client 保留每轮最新的宣告,并通过经过认证的摘要路由读取一次摘要。
 
-日志有意只携带轮号。摘要、快照树以及将来做内容对比要用的 tree id 只在 Host 进程内随 Session 存活:摘要在记录器里,对象在释放时删除的临时目录里。Host 重启后重新打开的对话,先前轮次没有卡片。产品上的决定是,Host 已经打不开内容的卡片根本不该出现,所以卡片的寿命等于内容的寿命,而不是日志的寿命。是否跨重启保留内容、保留多少轮,留给 #3822
+日志有意只携带轮号。摘要、快照树与捕获的副本只在 Host 进程内随 Session 存活:摘要在记录器里,对象和副本在释放时删除的临时目录里。Host 重启后重新打开的对话,先前轮次没有卡片。产品上的决定是,Host 已经打不开内容的卡片根本不该出现,所以卡片的寿命等于内容的寿命,而不是日志的寿命。内容不跨重启保留([对比决定](2026-09-15-changed-file-diff-preview.zh.md))
 
 ## Alternatives considered
 
@@ -34,7 +34,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。
 
 **为没有 git 的主机引入纯 JavaScript 的 git 或捆绑二进制**会为大多数不使用卡片的用户增加数 MB 体积和一套平台矩阵;在 git 出现之前卡片只列文件工具的编辑。
 
-**在首次触碰时记录编辑前的文件内容**能让 hunk 计数精确并为未覆盖文件提供整文件 diff,但要求文件工具把预先读取的内容交给记录器;持久化的 hunk 已经为此带上了每次编辑的前后文本
+**累加文件工具持久化的 hunk** 是未覆盖文件的第一版实现;它会把反复编辑的行计算多次,也给不出整文件对比,因此记录器改为在首次触碰时复制整个文件([对比决定](2026-09-15-changed-file-diff-preview.zh.md))
 
 **把文件列表和行数记进事件**是第一版实现:卡片从此可以永远从日志渲染,而它打开的内容却活不了那么久。它被只做宣告的事件取代,让卡片和内容共用一个寿命。
 
@@ -46,7 +46,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。
 
 ## Consequences
 
-每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入 Session 的临时目录,释放时一并删除。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比
+每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入 Session 的临时目录,释放时一并删除。用户在轮次进行中的编辑会被算到该轮。每次文件工具编辑还会把整个文件复制进该目录,每轮一次
 
 只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只要某一轮改动了文件就会新增该事件与卡片;一个专门的场景种入 git 仓库,让卡片也带上 shell 的改动,其余场景只列它们的文件工具写入。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.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/feature/2026-09-15-changed-file-diff-preview.md
+2026-09-15-changed-file-diff-preview.md: df01bc2a7f3b8def92d5da0cee8bc3110a073abd
+2026-09-15-changed-file-diff-preview.zh.md: cc5d9379121de15f37b72823e37d243b6a6a8bfb

+ 43 - 0
.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md

@@ -0,0 +1,43 @@
+# Agent Note: Changed-file comparison tab
+
+Status: implemented
+
+English | [中文](2026-09-15-changed-file-diff-preview.zh.md)
+
+## Problem
+
+The [changed-files card](2026-09-11-turn-changed-files-card.md) tells the user which files a turn changed and by how many lines, but not what changed. A row opened the file's current content in the Sidebar, or in a desktop application, which shows neither the turn's edit nor the state before it. Files git does not cover — ignored files, files outside the repository, and every file-tool edit in a working directory without a repository — had only the hunks the file tools persist with their results: partial context, counts summed over repeated edits, and no whole-file comparison at all.
+
+## Decision
+
+Each card row opens a `changes-diff` tab in the right Sidebar that compares the file at turn start and turn end. The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) recorder serves the comparison through `workspaceChanges.diff(sessionId, seq, index, signal)`; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) registers the tab type, addresses it by the viewed Session, the announcing event's sequence, and the file's index, and reads the comparison through an authenticated route. Rows open the comparison with and without a Host desktop; the tab's header offers the default-application open when a desktop exists, so the earlier row-level native open moves there and the card keeps one behavior.
+
+Files git does not cover are compared the way Codex's turn diff tracker compares its `apply_patch` edits: from whole-file copies, not from hunks. Before a `write`, `edit`, or mutating `str_replace_editor` call runs, the recorder's `tools/pre-execute` step, which already waits for the baseline snapshot, copies the named file into the Session's temporary directory beside the snapshot objects, once per path per turn; at turn end it copies the path again. The copies are named by the SHA-1 of their bytes, so identical content is stored once, and they need no git. Paths the snapshots cover keep their git counts; every other captured path is listed from a line comparison of its two copies, which counts a repeatedly edited line once and includes a shell edit made after the file-tool edit. The persisted hunks and the argument-derived hunks are no longer read by the recorder.
+
+Two bounds keep the copies and the comparisons small. `maxFileBytes` caps a copy and a snapshot blob read for a comparison; a larger file is listed with `oversized`, without counts, and its comparison is refused, which is what makes whole-file copies affordable. `diffTimeoutMs` bounds the line comparison, the same 100 ms Codex uses; past it the comparison degrades to one hunk that replaces every line, marked `coarse`, so a pathological file never stalls the turn's record or the tab. Both are Config fields.
+
+The comparison is computed when asked for, on the Host, from the two content sources kept beside the served summary: a path in a snapshot tree, read with `ls-tree -l` and `cat-file blob` under the byte cap, or a copy read from disk. Snapshot sides git reported as binary and copies holding a NUL byte serve no lines. The tab renders the hunks with old and new line numbers and no syntax highlighting; a comparison the Host no longer serves, a failed read, a binary file, and an oversized file each show one line.
+
+Content still lives only as long as the Session in this Host process, as the card decision settled; the comparison shares the card's lifetime, so a conversation reopened after a Host restart has neither.
+
+## Alternatives considered
+
+**Whole-file before and after text in the file tools' result metadata**, as Codex's `apply_patch` returns, would put every edited file twice into the Session log. The recorder captures the file itself instead, so the log keeps carrying only the turn number.
+
+**Storing the copies in memory**, as Codex does for its per-turn tracker, grows with every turn because the records live for the Session; the copies go to the Session's temporary directory beside the snapshot objects and are read only when a comparison is asked for.
+
+**Storing the copies as git blobs in the private object store** would have shared one read path with the snapshots but made the file-tool path depend on git, which the working directories without a repository or without git cannot provide; plain files under the same directory keep that path git-free.
+
+**A shadow repository for working directories outside any repository** remains deferred; the copies cover the file-tool edits there, which is the part a user can act on.
+
+**Syntax highlighting and a side-by-side view** in the tab are deferred until the plain unified view proves insufficient.
+
+**Keeping rows opening the current file** would have left the comparison one click further away; the current file still opens from the prose links and the Files tab.
+
+## Consequences
+
+Every file-tool edit reads and writes its whole file once per turn on the Host, bounded by `maxFileBytes`, even for paths the snapshots also cover; a Session's temporary directory holds the copies until disposal. Comparison line counts for uncovered files are first-to-last, not sums. A comparison costs at most two `ls-tree` and two `cat-file` commands plus one bounded line comparison.
+
+The `WorkspaceChangedFile` type gains `oversized`, `WorkspaceChanges` gains `diff`, and two served types, `WorkspaceDiffHunk` and `WorkspaceFileDiff`, join the subsystem page. The Session log is unchanged. The changed-files card's rows are labeled as opening the file's changes in both languages, so the recorded Web scenario's golden changed; the same scenario now opens a shell-appended file's comparison from the snapshots and an ignored file's from its copies.
+
+Focused tests cover whole-file capture classification, the timeout degradation, comparisons served from snapshot trees, renames, deleted files, oversized blobs, and copies, the comparison route, the tab's addresses, store, and states, and the card's row wiring.

+ 43 - 0
.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: 改动文件对比 tab
+
+Status: implemented
+
+[English](2026-09-15-changed-file-diff-preview.md) | 中文
+
+## Problem
+
+[改动文件卡片](2026-09-11-turn-changed-files-card.zh.md)告诉用户一轮改了哪些文件、改了多少行,但没说改了什么。点一行打开的是文件当前内容,在 Sidebar 里或在桌面应用里,既看不到本轮的编辑,也看不到编辑前的状态。git 覆盖不到的文件——被忽略的文件、仓库之外的文件,以及没有仓库的工作目录里的每一次文件工具编辑——只有文件工具随结果持久化的 hunk:上下文不完整,行数按重复编辑累加,完全没有整文件对比。
+
+## Decision
+
+卡片的每一行在右侧 Sidebar 打开一个 `changes-diff` tab,对比该文件在轮次开始与结束时的内容。Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 记录器通过 `workspaceChanges.diff(sessionId, seq, index, signal)` 提供对比;[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)注册该 tab 类型,用当前查看的 Session、宣告事件的序号和文件下标给它定址,并通过经过认证的路由读取对比。有没有 Host 桌面,行都打开对比;有桌面时 tab 头部提供用默认应用打开,先前行级别的原生打开移到那里,卡片只保留一种行为。
+
+git 覆盖不到的文件按 Codex 的 turn diff tracker 对比其 `apply_patch` 编辑的方式对比:用整文件副本,不用 hunk。在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器本来就要等待基线快照的 `tools/pre-execute` 步骤把所指文件复制到 Session 临时目录里快照对象旁边,每轮每个路径一次;轮次结束时再复制一次该路径。副本按其字节的 SHA-1 命名,相同内容只存一份,且不需要 git。快照覆盖到的路径保留 git 的行数;其余每个被捕获的路径由两份副本的逐行对比列出,反复编辑的行只计一次,文件工具编辑之后的 shell 改动也包含在内。记录器不再读取持久化的 hunk 和由参数推出的 hunk。
+
+两个上限让副本和对比保持小。`maxFileBytes` 限制副本和为对比而读出的快照 blob;更大的文件列出时带 `oversized`,没有行数,其对比被拒绝,整文件副本因此负担得起。`diffTimeoutMs` 限制逐行对比,与 Codex 一样是 100 毫秒;超时后退化为一个替换全部行的 hunk,标记 `coarse`,因此病态的文件从不会拖住本轮记录或 tab。两者都是 Config 字段。
+
+对比在被请求时在 Host 上计算,来源是保存在所提供摘要旁边的两侧内容来源:快照树中的路径,用 `ls-tree -l` 和 `cat-file blob` 在字节上限之内读出;或者从磁盘读取的副本。git 报告为二进制的快照一侧和含 NUL 字节的副本不提供行。tab 渲染带旧新行号的 hunk,没有语法高亮;Host 已不再提供的对比、读取失败、二进制文件和过大的文件各显示一行。
+
+内容仍然只在本 Host 进程内随 Session 存活,这是卡片决定已经定下的;对比与卡片同寿命,因此 Host 重启后重新打开的对话两者都没有。
+
+## Alternatives considered
+
+**把整文件的前后文本放进文件工具的结果元数据**,像 Codex 的 `apply_patch` 返回的那样,会把每个被编辑的文件两次写进 Session 日志。记录器改为自己捕获文件,日志继续只携带轮号。
+
+**把副本存在内存里**,像 Codex 的每轮 tracker 那样,会随每一轮增长,因为记录要活到 Session 结束;副本放到 Session 临时目录里快照对象旁边,只在请求对比时读取。
+
+**把副本存为私有对象库里的 git blob** 能和快照共用一条读取路径,但会让文件工具这条路径依赖 git,而没有仓库或没有 git 的工作目录给不了它;同一目录下的普通文件让这条路径与 git 无关。
+
+**给仓库之外的工作目录建影子仓库**仍然推迟;副本已经覆盖那里的文件工具编辑,这是用户能采取行动的部分。
+
+**tab 里的语法高亮和左右对照视图**推迟到纯 unified 视图被证明不够用时再做。
+
+**保留行打开当前文件**会让对比多一次点击才能到达;当前文件仍可从正文链接和文件 tab 打开。
+
+## Consequences
+
+每次文件工具编辑在 Host 上每轮读写一次整个文件,受 `maxFileBytes` 限制,即使快照也覆盖该路径;Session 的临时目录保存副本直到释放。未覆盖文件的对比行数是首尾对比,不是累加。一次对比最多花费两次 `ls-tree`、两次 `cat-file` 和一次有界的逐行对比。
+
+`WorkspaceChangedFile` 类型新增 `oversized`,`WorkspaceChanges` 新增 `diff`,两个提供给客户端的类型 `WorkspaceDiffHunk` 和 `WorkspaceFileDiff` 加入子系统页面。Session 日志不变。改动文件卡片的行在两种语言里都标为打开该文件的改动,因此录制的 Web 场景的黄金文件变了;同一场景现在会从快照打开一个 shell 追加过的文件的对比,从副本打开一个被忽略文件的对比。
+
+聚焦测试覆盖整文件捕获的分类、超时退化、从快照树提供的对比、改名、删除的文件、过大的 blob 和副本、对比路由、tab 的地址、store 与各状态,以及卡片行的接线。

+ 25 - 3
apps/web/tests/changed-files-turn.e2e.ts

@@ -1,4 +1,4 @@
-/** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card. */
+/** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card; its rows open comparisons. */
 import { execFileSync } from 'node:child_process'
 import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
@@ -110,9 +110,31 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
     expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1)
     expect(await card.getByRole('listitem').count()).toBe(3)
     expect(await card.getByRole('button', { name: '展开全部 4 个改动文件' }).count()).toBe(1)
-    // Without a Host desktop the header is a label and rows preview in the Sidebar.
+    // Without a Host desktop the header is a label; every row opens its comparison in the Sidebar.
     expect(await card.getByRole('button', { name: '打开改动文件所在的文件夹' }).count()).toBe(0)
-    expect(await card.getByRole('button', { name: '在侧边栏打开 notes.txt' }).count()).toBe(1)
+    expect(await card.getByRole('button', { name: '查看 notes.txt 的改动' }).count()).toBe(1)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
+  it('opens a shell-appended file’s comparison from the snapshots and an ignored file’s from its captured copies', async () => {
+    const card = page.locator('[data-changed-files]')
+    const column = page.locator('[data-rightbar-col]')
+    await card.getByRole('button', { name: '查看 notes.txt 的改动' }).click()
+    const notes = column.locator('[data-changes-diff][data-diff-state="text"]')
+    await notes.waitFor({ state: 'visible' })
+    expect(await column.locator('[data-dockkit-tab]').filter({ hasText: 'notes.txt' }).count()).toBe(1)
+    expect(await notes.locator('[data-diff-line]').evaluateAll(lines => lines.map(line => `${line.getAttribute('data-diff-line')}:${line.textContent}`))).toEqual([
+      'context:11 start', 'add:2+done',
+    ])
+    // The ignored file has no snapshot; its comparison comes from the copies captured around the write call.
+    await card.getByRole('button', { name: '查看 app.local 的改动' }).click()
+    const local = column.locator('[data-changes-diff][data-diff-state="text"]').filter({ hasText: 'mode=demo' })
+    await local.waitFor({ state: 'visible' })
+    expect(await local.locator('[data-diff-line]').evaluateAll(lines => lines.map(line => `${line.getAttribute('data-diff-line')}:${line.textContent}`))).toEqual(['add:1+mode=demo'])
+    expect(await local.getByText('本轮新建的文件').count()).toBe(1)
+    // No desktop, so the header offers no native open.
+    expect(await local.getByRole('button').count()).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   })

+ 2 - 2
docs/capability-seams.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 docs/capability-seams.md
-capability-seams.md: 9dad96da1399441f96355a13d3848fcf01a5c177
-capability-seams.zh.md: 470694f746e500330ee3acf56ed1fa912c9c0088
+capability-seams.md: cf5a3211cffd2cd066fb25a7aecb3f2275f3f9b4
+capability-seams.zh.md: 87a80c9824e1978a6e1b40f509b1736a2c0a131a

+ 1 - 1
docs/capability-seams.md

@@ -544,7 +544,7 @@ flowchart LR
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. |
-| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn. |
+| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced and each listed file's turn-start and turn-end comparison, by Session and event sequence, until that Session is disposed; the log carries only the turn. |
 | `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | Owns user terminal processes, default shell resolution and bounded screen recovery through the subprocess provider and typed Remote transport. |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. |

+ 1 - 1
docs/capability-seams.zh.md

@@ -546,7 +546,7 @@ flowchart LR
 | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 |
 | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 |
 | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 |
-| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn. |
+| `ctx.workspaceChanges` | `core` | [`workspace-changes`](../packages/deliverables/workspace-changes) | - | - | - | Serves the summary each workspace/changes event announced and each listed file's turn-start and turn-end comparison, by Session and event sequence, until that Session is disposed; the log carries only the turn. |
 | `ctx.terminalController` | `core` | [`api-terminal-controller`](../packages/api/terminal-controller) | - | - | - | 通过子进程提供方与类型化 Remote 传输管理用户终端进程、解析默认 shell,并恢复有界终端屏幕。 |
 | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 |
 | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 |

+ 2 - 2
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: 54fc0bd7e93f2b613d4c90c1bdffc3457f9db05c
-config-catalog.zh.md: c037ad8908bea443234639c1ce2e4484844e24f3
+config-catalog.md: ce41d91277040f8c51eb27d63379368695bae8fd
+config-catalog.zh.md: 64dd37959e2304f395a1d6e8c22345453d71c66c

+ 9 - 2
docs/config-catalog.md

@@ -3638,7 +3638,7 @@ Source: [`packages/workflow/workflow-ptc/src/index.ts:32`](../packages/workflow/
 Requires: `subprocess`
 
 ```ts config-catalog
-/** Snapshot bounds. Invalid values fail plugin load. */
+/** Snapshot, capture, and comparison bounds. Invalid values fail plugin load. */
 export interface Config {
   /** Milliseconds one git command may run before the turn's record is abandoned. */
   timeoutMs: number
@@ -3646,10 +3646,17 @@ export interface Config {
   outputMaxBytes: number
   /** Maximum files carried by one summary; `total` still reports the complete count. */
   maxFiles: number
+  /**
+   * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison;
+   * a larger file is listed without counts or comparison.
+   */
+  maxFileBytes: number
+  /** Milliseconds a line comparison may run before it degrades to whole-file replacement. */
+  diffTimeoutMs: number
 }
 ```
 
-Source: [`packages/deliverables/workspace-changes/src/index.ts:29`](../packages/deliverables/workspace-changes/src/index.ts)
+Source: [`packages/deliverables/workspace-changes/src/index.ts:33`](../packages/deliverables/workspace-changes/src/index.ts)
 
 ## Loadable plugins with no config
 

+ 9 - 2
docs/config-catalog.zh.md

@@ -3640,7 +3640,7 @@ export interface Config {
 依赖: `subprocess`
 
 ```ts config-catalog
-/** Snapshot bounds. Invalid values fail plugin load. */
+/** Snapshot, capture, and comparison bounds. Invalid values fail plugin load. */
 export interface Config {
   /** Milliseconds one git command may run before the turn's record is abandoned. */
   timeoutMs: number
@@ -3648,10 +3648,17 @@ export interface Config {
   outputMaxBytes: number
   /** Maximum files carried by one summary; `total` still reports the complete count. */
   maxFiles: number
+  /**
+   * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison;
+   * a larger file is listed without counts or comparison.
+   */
+  maxFileBytes: number
+  /** Milliseconds a line comparison may run before it degrades to whole-file replacement. */
+  diffTimeoutMs: number
 }
 ```
 
-来源: [`packages/deliverables/workspace-changes/src/index.ts:29`](../packages/deliverables/workspace-changes/src/index.ts)
+来源: [`packages/deliverables/workspace-changes/src/index.ts:36`](../packages/deliverables/workspace-changes/src/index.ts)
 
 ## 无配置的可加载插件
 

+ 2 - 2
docs/subsystems/deliverables.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 docs/subsystems/deliverables.md
-deliverables.md: cf795d747576451aafb104aad930efc352000fd3
-deliverables.zh.md: e4bb8bb2e590982d714701f9ab0b1851732831d2
+deliverables.md: 3347481f3e69d9a3921bb8784335b62bc9d869c2
+deliverables.zh.md: d3d10565d58664d749cb0c9dbacf1fa2ab741066

+ 76 - 9
docs/subsystems/deliverables.md

@@ -2,7 +2,7 @@
 
 English | [中文](deliverables.zh.md)
 
-What a turn hands to the user, owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, recorded in a log-only Session event, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end (or, outside a repository, from the file tools' persisted hunks alone), announced by a log-only event and served by a Host service while the Session lives. Only clients read them; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md).
+What a turn hands to the user, owned by the [deliverables package group](../../packages/deliverables/README.md): the files the model declared through the `present` tool, recorded in a log-only Session event, and the files the turn changed, summarized from git working-tree snapshots taken at turn start and turn end plus whole-file captures around each file-tool edit for the paths git does not cover, announced by a log-only event and served by a Host service while the Session lives together with each listed file's turn-start and turn-end comparison. Only clients read them; the Web [deliverables plugin](../../packages/client/ui-deliverables/README.md) renders both at the end of the turn. Tool behavior, snapshot mechanics, and configuration are on the package READMEs for [`tool-present`](../../packages/deliverables/tool-present/README.md) and [`workspace-changes`](../../packages/deliverables/workspace-changes/README.md).
 
 Sources: [`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts), [`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts)
 
@@ -21,7 +21,7 @@ interface PresentedFile {
 ## `WorkspaceChangedFile` — one changed file
 
 ```ts type-equiv
-/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */
+/** One file changed during a turn, with line counts from git or from the whole-file captures around its file-tool edits. */
 interface WorkspaceChangedFile {
   /** Path relative to the Session working directory, or an absolute Host path outside it. */
   path: string
@@ -31,12 +31,14 @@ interface WorkspaceChangedFile {
    * directory, otherwise the absolute path. Always slash-separated.
    */
   display: string
-  /** Lines added; zero for a binary file. */
+  /** Lines added; zero for a binary or oversized file. */
   added: number
-  /** Lines deleted; zero for a binary file. */
+  /** Lines deleted; zero for a binary or oversized file. */
   deleted: number
-  /** Present when git reported the file as binary. */
+  /** Present when git reported the file as binary, or when a captured side holds a NUL byte. */
   binary?: true
+  /** Present when a captured side exceeded the plugin's `maxFileBytes`; the file is listed without counts or comparison. */
+  oversized?: true
 }
 ```
 
@@ -62,10 +64,54 @@ interface WorkspaceChangesSummary {
 }
 ```
 
-## `WorkspaceChanges` — the Host service serving summaries
+## `WorkspaceDiffHunk` — one unified-diff hunk
 
 ```ts type-equiv
-/** Serves the summaries the recorder keeps for live Sessions. */
+/** One unified-diff hunk with three context lines; every line keeps its `+`, `-`, or space prefix. */
+interface WorkspaceDiffHunk {
+  /** First line of the hunk in the turn-start content, 1-based; a side without lines starts at 1 with zero lines. */
+  oldStart: number
+  /** Lines of the hunk taken from the turn-start content. */
+  oldLines: number
+  /** First line of the hunk in the turn-end content, 1-based; a side without lines starts at 1 with zero lines. */
+  newStart: number
+  /** Lines of the hunk taken from the turn-end content. */
+  newLines: number
+  /** Hunk body in order, each line prefixed with `+`, `-`, or a space. */
+  lines: string[]
+}
+```
+
+## `WorkspaceFileDiff` — one file's comparison
+
+```ts type-equiv
+/** The comparison of one listed file's turn-start and turn-end contents, computed when asked for. */
+type WorkspaceFileDiff =
+  | {
+    kind: 'text'
+    /** The listed file's `path`. */
+    path: string
+    /** The listed file's `display`. */
+    display: string
+    /** Whether the file existed at turn start. */
+    before: boolean
+    /** Whether the file existed at turn end. */
+    after: boolean
+    /** Hunks in file order; empty when both sides hold the same lines. */
+    hunks: WorkspaceDiffHunk[]
+    /** True when the line comparison exceeded the plugin's `diffTimeoutMs` and every line is shown as replaced. */
+    coarse: boolean
+  }
+  /** A side git reported as binary or that holds a NUL byte; no lines are served. */
+  | { kind: 'binary'; path: string; display: string }
+  /** A side larger than the plugin's `maxFileBytes`; no lines are served. */
+  | { kind: 'oversized'; path: string; display: string }
+```
+
+## `WorkspaceChanges` — the Host service serving summaries and comparisons
+
+```ts type-equiv
+/** Serves the summaries and file comparisons the recorder keeps for live Sessions. */
 interface WorkspaceChanges {
   /**
    * The summary announced by one `workspace/changes` event.
@@ -74,12 +120,22 @@ interface WorkspaceChanges {
    * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it.
    */
   summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined
+  /**
+   * Compare one listed file's contents at turn start and turn end.
+   * @param sessionId - the Session that appended the event.
+   * @param seq - the event's sequence number.
+   * @param index - the file's index in the summary's `files`.
+   * @param signal - cancels the reads.
+   * @returns the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index.
+   * @throws when a snapshot read fails for a live Session.
+   */
+  diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise<WorkspaceFileDiff | undefined>
 }
 ```
 
 ## Durable events and the served summary
 
-`tool-present` declaration-merges `deliverables/presented: { turn; callId; files: PresentedFile[] }` into `SessionEventMap`, appended once per successful final `present` result. `workspace-changes` merges `workspace/changes: { turn }`, appended when a top-level turn stops; the summary that event announced is not in the log but is returned by `workspaceChanges.summary(sessionId, seq)` for the event's sequence until the Session is disposed, so a conversation reopened after a Host restart has no changed-files card for its earlier turns. A later event for the same turn replaces the earlier one, so a client keeps only the latest. The generated [persistence catalog](../persistence-catalog.md#deliverablespresented--log-only) records both declaration sites. Neither event reaches the model.
+`tool-present` declaration-merges `deliverables/presented: { turn; callId; files: PresentedFile[] }` into `SessionEventMap`, appended once per successful final `present` result. `workspace-changes` merges `workspace/changes: { turn }`, appended when a top-level turn stops; the summary that event announced is not in the log but is returned by `workspaceChanges.summary(sessionId, seq)` for the event's sequence until the Session is disposed, so a conversation reopened after a Host restart has no changed-files card for its earlier turns. `workspaceChanges.diff(sessionId, seq, index, signal)` compares one listed file on the same terms. A later event for the same turn replaces the earlier one, so a client keeps only the latest. The generated [persistence catalog](../persistence-catalog.md#deliverablespresented--log-only) records both declaration sites. Neither event reaches the model.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -93,7 +149,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.workspaceChanges` — `WorkspaceChanges`
 
-Serves the summaries the recorder keeps for live Sessions.
+Serves the summaries and file comparisons the recorder keeps for live Sessions.
 
 ```ts cordis-catalog
 /**
@@ -103,6 +159,17 @@ Serves the summaries the recorder keeps for live Sessions.
  * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it.
  */
 summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined
+
+/**
+ * Compare one listed file's contents at turn start and turn end.
+ * @param sessionId - the Session that appended the event.
+ * @param seq - the event's sequence number.
+ * @param index - the file's index in the summary's `files`.
+ * @param signal - cancels the reads.
+ * @returns the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index.
+ * @throws when a snapshot read fails for a live Session.
+ */
+diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise<WorkspaceFileDiff | undefined>
 ```
 
 Types: [SessionId](core.md)

+ 76 - 9
docs/subsystems/deliverables.zh.md

@@ -2,7 +2,7 @@
 
 [English](deliverables.md) | 中文
 
-记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有:模型通过 `present` 工具声明的文件,记在一个只写日志的 Session 事件里;这一轮改动的文件,由轮开始和轮结束时的 git 工作树快照对比得出(不在仓库内时只由文件工具持久化的 hunk 得出),用一个只写日志的事件宣告,并在 Session 存活期间由 Host 服务提供。它们只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。
+记录一轮交给用户的东西,由 [deliverables 包组](../../packages/deliverables/README.zh.md)拥有:模型通过 `present` 工具声明的文件,记在一个只写日志的 Session 事件里;这一轮改动的文件,由轮开始和轮结束时的 git 工作树快照对比得出,git 覆盖不到的路径则由文件工具每次编辑前后的整文件捕获得出,用一个只写日志的事件宣告,并在 Session 存活期间由 Host 服务连同每个所列文件在轮开始与轮结束时的对比一起提供。它们只由客户端读取,Web [产出物插件](../../packages/client/ui-deliverables/README.zh.md)在轮末渲染两者。工具行为、快照机制和配置见 [`tool-present`](../../packages/deliverables/tool-present/README.zh.md) 与 [`workspace-changes`](../../packages/deliverables/workspace-changes/README.zh.md) 的包 README。
 
 源码:[`packages/deliverables/tool-present/src/types.ts`](../../packages/deliverables/tool-present/src/types.ts)、[`packages/deliverables/workspace-changes/src/types.ts`](../../packages/deliverables/workspace-changes/src/types.ts)
 
@@ -21,7 +21,7 @@ interface PresentedFile {
 ## `WorkspaceChangedFile`:一个改动的文件
 
 ```ts type-equiv
-/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */
+/** One file changed during a turn, with line counts from git or from the whole-file captures around its file-tool edits. */
 interface WorkspaceChangedFile {
   /** Path relative to the Session working directory, or an absolute Host path outside it. */
   path: string
@@ -31,12 +31,14 @@ interface WorkspaceChangedFile {
    * directory, otherwise the absolute path. Always slash-separated.
    */
   display: string
-  /** Lines added; zero for a binary file. */
+  /** Lines added; zero for a binary or oversized file. */
   added: number
-  /** Lines deleted; zero for a binary file. */
+  /** Lines deleted; zero for a binary or oversized file. */
   deleted: number
-  /** Present when git reported the file as binary. */
+  /** Present when git reported the file as binary, or when a captured side holds a NUL byte. */
   binary?: true
+  /** Present when a captured side exceeded the plugin's `maxFileBytes`; the file is listed without counts or comparison. */
+  oversized?: true
 }
 ```
 
@@ -62,10 +64,54 @@ interface WorkspaceChangesSummary {
 }
 ```
 
-## `WorkspaceChanges`:提供摘要的 Host 服务
+## `WorkspaceDiffHunk`:一个 unified diff hunk
 
 ```ts type-equiv
-/** Serves the summaries the recorder keeps for live Sessions. */
+/** One unified-diff hunk with three context lines; every line keeps its `+`, `-`, or space prefix. */
+interface WorkspaceDiffHunk {
+  /** First line of the hunk in the turn-start content, 1-based; a side without lines starts at 1 with zero lines. */
+  oldStart: number
+  /** Lines of the hunk taken from the turn-start content. */
+  oldLines: number
+  /** First line of the hunk in the turn-end content, 1-based; a side without lines starts at 1 with zero lines. */
+  newStart: number
+  /** Lines of the hunk taken from the turn-end content. */
+  newLines: number
+  /** Hunk body in order, each line prefixed with `+`, `-`, or a space. */
+  lines: string[]
+}
+```
+
+## `WorkspaceFileDiff`:一个文件的对比
+
+```ts type-equiv
+/** The comparison of one listed file's turn-start and turn-end contents, computed when asked for. */
+type WorkspaceFileDiff =
+  | {
+    kind: 'text'
+    /** The listed file's `path`. */
+    path: string
+    /** The listed file's `display`. */
+    display: string
+    /** Whether the file existed at turn start. */
+    before: boolean
+    /** Whether the file existed at turn end. */
+    after: boolean
+    /** Hunks in file order; empty when both sides hold the same lines. */
+    hunks: WorkspaceDiffHunk[]
+    /** True when the line comparison exceeded the plugin's `diffTimeoutMs` and every line is shown as replaced. */
+    coarse: boolean
+  }
+  /** A side git reported as binary or that holds a NUL byte; no lines are served. */
+  | { kind: 'binary'; path: string; display: string }
+  /** A side larger than the plugin's `maxFileBytes`; no lines are served. */
+  | { kind: 'oversized'; path: string; display: string }
+```
+
+## `WorkspaceChanges`:提供摘要与对比的 Host 服务
+
+```ts type-equiv
+/** Serves the summaries and file comparisons the recorder keeps for live Sessions. */
 interface WorkspaceChanges {
   /**
    * The summary announced by one `workspace/changes` event.
@@ -74,12 +120,22 @@ interface WorkspaceChanges {
    * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it.
    */
   summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined
+  /**
+   * Compare one listed file's contents at turn start and turn end.
+   * @param sessionId - the Session that appended the event.
+   * @param seq - the event's sequence number.
+   * @param index - the file's index in the summary's `files`.
+   * @param signal - cancels the reads.
+   * @returns the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index.
+   * @throws when a snapshot read fails for a live Session.
+   */
+  diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise<WorkspaceFileDiff | undefined>
 }
 ```
 
 ## 持久事件与提供的摘要
 
-`tool-present` 通过声明合并把 `deliverables/presented: { turn; callId; files: PresentedFile[] }` 加入 `SessionEventMap`,每次 `present` 的最终结果成功时追加一条。`workspace-changes` 合并 `workspace/changes: { turn }`,在顶层轮停止时追加;该事件宣告的摘要不在日志里,而是由 `workspaceChanges.summary(sessionId, seq)` 按事件序号返回,直到 Session 释放,因此 Host 重启后重新打开的对话,先前轮次没有改动文件卡片。同一轮后来的事件替代先前的,客户端只保留最新一条。生成的[持久化目录](../persistence-catalog.zh.md#deliverablespresented--log-only)记录了两处声明位置。两个事件都不会进入模型请求。
+`tool-present` 通过声明合并把 `deliverables/presented: { turn; callId; files: PresentedFile[] }` 加入 `SessionEventMap`,每次 `present` 的最终结果成功时追加一条。`workspace-changes` 合并 `workspace/changes: { turn }`,在顶层轮停止时追加;该事件宣告的摘要不在日志里,而是由 `workspaceChanges.summary(sessionId, seq)` 按事件序号返回,直到 Session 释放,因此 Host 重启后重新打开的对话,先前轮次没有改动文件卡片。`workspaceChanges.diff(sessionId, seq, index, signal)` 按同样的条件对比一个所列文件。同一轮后来的事件替代先前的,客户端只保留最新一条。生成的[持久化目录](../persistence-catalog.zh.md#deliverablespresented--log-only)记录了两处声明位置。两个事件都不会进入模型请求。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -93,7 +149,7 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.workspaceChanges` — `WorkspaceChanges`
 
-Serves the summaries the recorder keeps for live Sessions.
+Serves the summaries and file comparisons the recorder keeps for live Sessions.
 
 ```ts cordis-catalog
 /**
@@ -103,6 +159,17 @@ Serves the summaries the recorder keeps for live Sessions.
  * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it.
  */
 summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined
+
+/**
+ * Compare one listed file's contents at turn start and turn end.
+ * @param sessionId - the Session that appended the event.
+ * @param seq - the event's sequence number.
+ * @param index - the file's index in the summary's `files`.
+ * @param signal - cancels the reads.
+ * @returns the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index.
+ * @throws when a snapshot read fails for a live Session.
+ */
+diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise<WorkspaceFileDiff | undefined>
 ```
 
 Types: [SessionId](core.zh.md)

+ 2 - 2
packages/client/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/README.md
-README.md: b1e01080de00410e39db83d13664fb707f0a7863
-README.zh.md: 1b605a6b87ada47f5adc99525e66892a4d583314
+README.md: c2dd351b3d15d55484456779aa069d4759f9ff13
+README.zh.md: 4016ec4ba2a204747b614d43135acc3fdb0ee841

+ 1 - 1
packages/client/README.md

@@ -73,7 +73,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section | — |
 | [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — |
-| [`ui-deliverables/`](ui-deliverables/README.md) | Produces the changed-files card, delivery cards, and clickable final-response file references | — |
+| [`ui-deliverables/`](ui-deliverables/README.md) | Produces the changed-files card with its comparison tabs, delivery cards, and clickable final-response file references | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — |
 | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — |

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

@@ -73,7 +73,7 @@ kind: "package-group"
 | [`ui-settings-general/`](ui-settings-general/README.zh.md) | 提供常规设置分区 | — |
 | [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — |
 | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — |
-| [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成改动文件卡片、交付文件卡片与可点击的最终响应文件引用 | — |
+| [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成改动文件卡片及其对比 tab、交付文件卡片与可点击的最终响应文件引用 | — |
 | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — |
 | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — |
 | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — |

+ 2 - 2
packages/client/ui-deliverables/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-deliverables/README.md
-README.md: 4547d0887b069ed40686af8821214d96b28f6c00
-README.zh.md: 8d3534b9fc2b72d3308b10b62f7536a20e8c90f1
+README.md: f0a82e44441e9cd8a0a0f259af9e903ad9eee693
+README.zh.md: fc689b3c7b969b8ef76098c79de602d076a4218e

+ 11 - 6
packages/client/ui-deliverables/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Changed files, deliveries, and clickable file references for the Web GUI: the changed-files card and delivery cards a finished turn ends with, and inline-code links in the closing prose; for users and maintainers of the deliverables experience."
+description: "Changed files, deliveries, and clickable file references for the Web GUI: the changed-files card and delivery cards a finished turn ends with, the comparison tab each changed file opens, and inline-code links in the closing prose; for users and maintainers of the deliverables experience."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package renders the changed-files card a finished turn ends with — the files the turn changed, with the line counts the Host recorded from git snapshots — plus cards for explicitly delivered files, and links matching inline-code references in the closing prose so a mentioned file opens in the right Sidebar. Listed and linked paths come from the recorded summary, successful mutations, and explicit deliveries, never from the prose. The shipped Web patch is the only composition that loads this package; removing its cordis.yml entry removes the guidance, cards, and prose links together.
+This package renders the changed-files card a finished turn ends with — the files the turn changed, with the Host's line counts, each opening a Sidebar tab comparing the file at turn start and turn end — plus cards for explicitly delivered files, and links matching inline-code references in the closing prose so a mentioned file opens in the right Sidebar. Listed and linked paths come from the recorded summary, successful mutations, and explicit deliveries, never from the prose. Only the shipped Web patch loads this package; removing its cordis.yml entry removes the guidance, cards, and prose links together.
 
 ## Table of Contents
 
@@ -36,7 +36,11 @@ The `present` tool row shows running, delivered, failed, or interrupted status;
 
 ### The changed-files card
 
-The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, or “binary” for a binary file. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager and each row opens its file in the default application, both through authenticated Host actions that show the delivery cards' pending and retryable-error states in place of the counts; a completed open shows the counts again. Without a desktop the header is a plain label and rows preview their files in the right Sidebar. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`.
+The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens its file's comparison in the right Sidebar. With a Host desktop available, the header opens the deepest workspace folder containing the listed files in the file manager through an authenticated Host action that shows the delivery cards' pending and retryable-error states in place of the totals; a completed open shows the totals again. Without a desktop the header is a plain label. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`.
+
+### The comparison tab
+
+A row opens a `changes-diff` tab addressed by the viewed Session, the announcing event's sequence, and the file's index, titled with the file name; opening the same row again reveals the existing tab. The tab reads the comparison once through the authenticated comparison route and shows the file's path in its header. A text comparison lists its hunks with the old and new line numbers of every line, additions and deletions in the success and error colours, and a note when the file was created or deleted in the turn, when both sides hold the same lines, or when the Host's line comparison timed out and every line shows as replaced. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. With a Host desktop available the header offers opening the file in the default application, with the same pending and retryable-error states as the cards. The comparison is the turn's snapshot of the file, not its current content; the current content opens from the prose links or the Files tab.
 
 ### Inline-code links
 
@@ -50,9 +54,9 @@ The closing prose links produced or delivered paths: an inline-code token resolv
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost.
+The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-diff/session/<sessionId>/<seq>/<index>/<display>` through `ctx.sidebarRight.openResource`; the package registers the `changes-diff` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat, which reads the comparison through the authenticated comparison route into a store cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty chain at zero cost.
 
-Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
+Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the changed-files route omits the index to open the common folder, which the Host derives from the recorded workspace-relative paths and verifies as a directory, and the comparison tab's native open uses the same indexed coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422 and the card directs the user to Sidebar preview; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
 
 </details>
 
@@ -99,7 +103,8 @@ These limits define the current deliverables vocabulary. They are current packag
 - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it.
 - **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and clickable references still require `present`.
 - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404.
-- **Folder opening needs a Host desktop** — without one the card header is inert and rows fall back to the right Sidebar's text preview, which shows files only.
+- **Folder and native opening need a Host desktop** — without one the card header is inert and the comparison tab offers no native open; the comparison itself needs only the Host that recorded the turn.
+- **Comparisons are unhighlighted unified diffs** — the tab shows plain hunks without syntax colouring or a side-by-side view.
 - **Package-local header glyph** — the card's angle-bracket mark lives in `src/client/icons.tsx` until the shared icon set carries it; its props already match the shared icon contract.
 - **Files outside the workspace open by absolute path only** — the recorded path is the Host path at recording time; a moved workspace or a different viewing Session cannot relocate it.
 

+ 11 - 6
packages/client/ui-deliverables/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "Web GUI 的改动文件、交付文件与可点击文件引用:已完成轮次末尾的改动文件卡片与交付文件卡片,以及收尾正文中的行内代码链接;供产出物体验的用户与维护者阅读。"
+description: "Web GUI 的改动文件、交付文件与可点击文件引用:已完成轮次末尾的改动文件卡片与交付文件卡片、每个改动文件打开的对比 tab,以及收尾正文中的行内代码链接;供产出物体验的用户与维护者阅读。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包渲染已完成轮次末尾的改动文件卡片——列出本轮改动的文件及 Host 从 git 快照记录的增删行数——以及显式交付文件的卡片,并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。列出与链接的路径来自记录的改动摘要、成功的文件修改与显式交付,而非收尾正文——无论模型是否记得点名,改动文件都会被列出。正式提供的组合中只有 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、卡片与正文链接。
+本包渲染已完成轮次末尾的改动文件卡片——列出本轮改动的文件及 Host 记录的行数,每一行打开 Sidebar 中对比该文件在轮次开始与结束时内容的 tab——以及显式交付文件的卡片,并把收尾正文中匹配的行内代码引用转为链接,让被点名的文件在右侧 Sidebar 中打开。列出与链接的路径来自记录的改动摘要、成功的文件修改与显式交付,而非收尾正文。只有正式提供的 Web patch 加载本包;删除其 cordis.yml 条目会同时移除指引、卡片与正文链接。
 
 ## 目录
 
@@ -36,7 +36,11 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 
 ### 改动文件卡片
 
-卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。Host 有桌面时,标题在文件管理器中打开包含所列文件的最深工作区文件夹,每一行用默认应用打开该文件,两者都通过经过认证的 Host 操作完成,并在计数位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示计数。没有桌面时标题是普通标签,行改为在右侧 Sidebar 中预览文件。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。
+卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。每一行在右侧 Sidebar 中打开该文件的对比。Host 有桌面时,标题通过经过认证的 Host 操作在文件管理器中打开包含所列文件的最深工作区文件夹,并在合计位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示合计。没有桌面时标题是普通标签。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。
+
+### 对比 tab
+
+行打开一个 `changes-diff` tab,其地址由当前查看的 Session、宣告事件的序号和文件下标组成,以文件名作标题;再次点击同一行会显示已有的 tab。tab 通过经过认证的对比路由读取一次对比,并在头部显示文件路径。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,新增与删除分别用成功色和错误色,并在文件是本轮新建或删除、两侧内容相同,或 Host 的逐行对比超时而按整文件替换显示时给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。Host 有桌面时,头部提供用默认应用打开该文件,等待与可重试错误状态与卡片一致。对比是本轮对该文件的快照,不是它当前的内容;当前内容可从正文链接或文件 tab 打开。
 
 ### 行内代码链接
 
@@ -50,9 +54,9 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 <details>
 <summary>实现细节——点击展开</summary>
 
-Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
+Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-diff/session/<sessionId>/<seq>/<index>/<display>`;本包在 `builtin` 档为该模式注册 `changes-diff` tab 类型,并把其 body 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的对比路由把对比读进一个连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空链以零成本留下。
 
-原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
+原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;改动文件路由省略索引时打开公共文件夹,Host 从记录的工作区相对路径推导该文件夹并验证它是目录;对比 tab 的原生打开使用同一组带下标的坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
 
 </details>
 
@@ -99,7 +103,8 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
 - **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`。
 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。
-- **打开文件夹需要 Host 桌面**——没有桌面时卡片标题不可点击,行退回到右侧 Sidebar 的文本预览,该预览仅支持文件。
+- **打开文件夹和原生打开需要 Host 桌面**——没有桌面时卡片标题不可点击,对比 tab 也不提供原生打开;对比本身只需要记录了该轮的 Host。
+- **对比是没有高亮的 unified diff**——tab 只显示纯文本 hunk,没有语法着色,也没有左右对照视图。
 - **包内的标题字形**——卡片的尖括号标记放在 `src/client/icons.tsx` 中,直到共享图标集收录它;其 props 已与共享图标契约一致。
 - **工作区外的文件只按绝对路径打开**——记录的路径是记录时的 Host 路径;工作区移动或换一个查看 Session 都无法重新定位它。
 

+ 3 - 1
packages/client/ui-deliverables/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-deliverables",
-  "description": "Changed-files card, delivery cards, and clickable final-response file references for Web",
+  "description": "Changed-files card with per-file comparison tabs, delivery cards, and clickable final-response file references for Web",
   "version": "0.1.6-alpha.1",
   "publishConfig": {
     "access": "public"
@@ -34,6 +34,7 @@
         "@deepseek-ai/dsh-client-ui-chat",
         "@deepseek-ai/dsh-client-ui-conversation",
         "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-sidebar-right",
         "@deepseek-ai/dsh-client-ui-tool"
       ],
       "platform": "web"
@@ -56,6 +57,7 @@
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",

+ 88 - 4
packages/client/ui-deliverables/src/changes.ts

@@ -1,16 +1,34 @@
-/** Validate workspace-change records that cross the Host route and address their summary and native-open actions. */
+/** Validate workspace-change records that cross the Host routes and address their summary, comparison, and native-open actions. */
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { WorkspaceChangedFile, WorkspaceChangesSummary } from '@deepseek-ai/dsh-workspace-changes/types'
+import type { WorkspaceChangedFile, WorkspaceChangesSummary, WorkspaceDiffHunk, WorkspaceFileDiff } from '@deepseek-ai/dsh-workspace-changes/types'
 
 /** Authenticated GET route serving one announced change summary while its Session lives. */
 export const CHANGED_FILES_PATH = '/api/changes.summary'
 
+/** Authenticated GET route serving one listed file's turn-start and turn-end comparison while its Session lives. */
+export const CHANGES_DIFF_PATH = '/api/changes.diff'
+
 /** Authenticated POST route for opening a changed file, or the changed files' common folder, on the Host desktop. */
 export const CHANGES_OPEN_PATH = '/api/changes.open'
 
+/** Resource-address prefix of a changed file's comparison tab in the right Sidebar. */
+export const CHANGES_DIFF_ADDRESS = 'dsh-resource://changes-diff/session/'
+
 /** The summary fields the route serves; the Host keeps the working directory and snapshot ids to itself. */
 export type ChangesSummary = Pick<WorkspaceChangesSummary, 'turn' | 'files' | 'total' | 'added' | 'deleted'>
 
+/** The comparison the route serves, as the Host computed it. */
+export type ChangesDiff = WorkspaceFileDiff
+
+/** Coordinates of one listed file's comparison: the viewed Session, the announcing event, and the file's index. */
+export interface ChangesDiffCoordinates {
+  sessionId: SessionId
+  seq: number
+  index: number
+  /** The listed file's display path, carried for the tab title. */
+  display: string
+}
+
 function isRecord(value: unknown): value is Record<string, unknown> {
   return typeof value === 'object' && value !== null && !Array.isArray(value)
 }
@@ -22,9 +40,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
  */
 export function isChangedFile(value: unknown): value is WorkspaceChangedFile {
   if (!isRecord(value)) return false
-  const { path, display, added, deleted, binary } = value
+  const { path, display, added, deleted, binary, oversized } = value
   return typeof path === 'string' && path.length > 0 && typeof display === 'string' && display.length > 0
-    && Number.isSafeInteger(added) && Number.isSafeInteger(deleted) && (binary === undefined || binary === true)
+    && Number.isSafeInteger(added) && Number.isSafeInteger(deleted)
+    && (binary === undefined || binary === true) && (oversized === undefined || oversized === true)
 }
 
 /**
@@ -40,6 +59,29 @@ export function isChangesSummary(value: unknown): value is ChangesSummary {
     && Array.isArray(files) && files.every(isChangedFile)
 }
 
+function isHunk(value: unknown): value is WorkspaceDiffHunk {
+  if (!isRecord(value)) return false
+  const { oldStart, oldLines, newStart, newLines, lines } = value
+  return [oldStart, oldLines, newStart, newLines].every(field => Number.isSafeInteger(field) && (field as number) >= 0)
+    && Array.isArray(lines) && lines.every(line => typeof line === 'string' && /^[+ -]/.test(line))
+}
+
+/**
+ * Validate a comparison read from the comparison route.
+ * @param value - decoded JSON.
+ * @returns whether the value is a text comparison with well-formed hunks, or a binary or oversized refusal.
+ */
+export function isChangesDiff(value: unknown): value is ChangesDiff {
+  if (!isRecord(value)) return false
+  const { kind, path, display } = value
+  if (typeof path !== 'string' || path.length === 0 || typeof display !== 'string' || display.length === 0) return false
+  if (kind === 'binary' || kind === 'oversized') return true
+  if (kind !== 'text') return false
+  const { before, after, hunks, coarse } = value
+  return typeof before === 'boolean' && typeof after === 'boolean' && typeof coarse === 'boolean'
+    && Array.isArray(hunks) && hunks.every(isHunk)
+}
+
 /**
  * Validate the `workspace/changes` event data read from a Session log.
  * @param value - decoded durable event data.
@@ -59,6 +101,17 @@ export function changesSummaryUrl(sessionId: SessionId, seq: number): string {
   return `${CHANGED_FILES_PATH}?${new URLSearchParams({ sessionId, seq: String(seq) })}`
 }
 
+/**
+ * Build authenticated coordinates for one listed file's comparison.
+ * @param sessionId - owning Session.
+ * @param seq - workspace/changes event sequence.
+ * @param index - original index in the summary's files array.
+ * @returns same-origin comparison URL.
+ */
+export function changesDiffUrl(sessionId: SessionId, seq: number, index: number): string {
+  return `${CHANGES_DIFF_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
+}
+
 /**
  * Build authenticated coordinates for a changed file or the changed files' common folder.
  * @param sessionId - owning Session.
@@ -71,3 +124,34 @@ export function changedFileUrl(sessionId: SessionId, seq: number, index: number
   if (index !== null) query.set('index', String(index))
   return `${CHANGES_OPEN_PATH}?${query}`
 }
+
+/**
+ * The right-Sidebar address of one listed file's comparison. The display path
+ * rides the address only for the tab title; the coordinates identify the content.
+ * @param coordinates - viewed Session, announcing event, file index, and display path.
+ * @returns a `dsh-resource://changes-diff/session/…` address.
+ */
+export function changesDiffAddress({ sessionId, seq, index, display }: ChangesDiffCoordinates): string {
+  return `${CHANGES_DIFF_ADDRESS}${encodeURIComponent(sessionId)}/${seq}/${index}/${encodeURIComponent(display)}`
+}
+
+/**
+ * Read the coordinates back out of a comparison address.
+ * @param address - a resource address.
+ * @returns the coordinates, or undefined for any other address.
+ */
+export function parseChangesDiffAddress(address: string): ChangesDiffCoordinates | undefined {
+  if (!address.startsWith(CHANGES_DIFF_ADDRESS)) return undefined
+  const parts = address.slice(CHANGES_DIFF_ADDRESS.length).split('/')
+  if (parts.length !== 4) return undefined
+  const [sessionId, seq, index, display] = parts as [string, string, string, string]
+  if (sessionId === '' || display === '' || !/^\d+$/.test(seq) || !/^\d+$/.test(index)) return undefined
+  try {
+    return {
+      sessionId: decodeURIComponent(sessionId) as SessionId, seq: Number(seq), index: Number(index), display: decodeURIComponent(display),
+    }
+  } catch {
+    // A malformed percent sequence is not an address this package minted.
+    return undefined
+  }
+}

+ 19 - 23
packages/client/ui-deliverables/src/client/ChangedFiles.tsx

@@ -1,4 +1,4 @@
-/** The changed-files card: a folder-opening header, per-file line counts, and a three-row fold. */
+/** The changed-files card: a folder-opening header, per-file line counts opening each file's comparison, and a three-row fold. */
 import { useState } from 'react'
 import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
 import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
@@ -17,9 +17,9 @@ const COLLAPSED_ROWS = 3
 const GROUPED = new Intl.NumberFormat('en-US')
 
 /**
- * Gesture state worth showing in place of the counts: pending, or failed. A
- * completed open shows the counts again. The card never reveals, so only open
- * phases occur.
+ * Folder gesture state worth showing in place of the totals: pending, or
+ * failed. A completed open shows the totals again. The card never reveals, so
+ * only open phases occur.
  */
 function gesture(phase: PresentedOpenPhase | undefined): { key: 'presented.opening' | 'presented.error' | 'presented.nativeUnavailable'; failed: boolean } | undefined {
   switch (phase) {
@@ -39,13 +39,13 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop
 }
 
 /**
- * Render one turn's changed files. Rows open files in the Host's default
- * application when a desktop is available and otherwise preview them in the
- * right Sidebar; the header opens the files' common folder only with a desktop.
- * @param props - the recorded summary, Host capabilities, gesture status, openers, and localized copy.
+ * Render one turn's changed files. Each row opens its file's turn-start and
+ * turn-end comparison in the right Sidebar; the header opens the files' common
+ * folder only with a Host desktop.
+ * @param props - the recorded summary, Host capabilities, folder gesture status, openers, and localized copy.
  * @returns the card.
  */
-export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openFile, t }: {
+export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openDiff, t }: {
   /** The served summary with the sequence of the event that announced it. */
   changes: Pick<ChangesSummary, 'files' | 'total' | 'added' | 'deleted'> & { seq: number }
   cwd: string | undefined
@@ -53,7 +53,8 @@ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, op
   host: PresentedHost | null
   phases: Record<string, PresentedOpenPhase | undefined>
   onOpen: (index: number | null) => void
-  openFile: (path: string) => void
+  /** Open the comparison of the file at an original summary index. */
+  openDiff: (index: number, display: string) => void
 } & PropsLocale<typeof NS>) {
   const [expanded, setExpanded] = useState(false)
   const native = host !== null && host.available
@@ -77,25 +78,20 @@ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, op
         disabled={folder !== undefined && !folder.failed} onClick={() => { onOpen(null) }}>{summary}</button>
       : <div className={css.header}>{summary}</div>}
     <ul className={css.list}>
-      {rows.map((file, index) => {
-        const phase = phases[changedFileUrl(sessionId, changes.seq, index)]
-        const status = gesture(phase)
-        // A file without a verified Host path falls back to the Sidebar preview the status names.
-        const opensNatively = native && phase !== 'nativeUnavailable'
-        return <li key={file.display}>
+      {rows.map((file, index) => (
+        <li key={file.display}>
           <button type="button" className={css.row} title={resolveWorkspacePath(cwd, file.path)}
-            aria-label={t(opensNatively ? 'changes.openFile' : 'presented.previewButton', { name: file.display })}
-            disabled={status !== undefined && !status.failed}
-            onClick={() => { if (opensNatively) onOpen(index); else openFile(file.path) }}>
+            aria-label={t('changes.viewDiff', { name: file.display })}
+            onClick={() => { openDiff(index, file.display) }}>
             <span className={css.path}>{file.display}</span>
-            <span className={css.counts} role={status === undefined ? undefined : 'status'} data-error={status?.failed || undefined}>
-              {status !== undefined ? t(status.key)
-                : file.binary === true ? t('changes.binary')
+            <span className={css.counts}>
+              {file.binary === true ? t('changes.binary')
+                : file.oversized === true ? t('changes.oversized')
                   : <Counts t={t} added={file.added} deleted={file.deleted} />}
             </span>
           </button>
         </li>
-      })}
+      ))}
     </ul>
     {foldable && <button type="button" className={css.toggle}
       aria-expanded={expanded}

+ 7 - 4
packages/client/ui-deliverables/src/client/Deliverables.tsx

@@ -9,7 +9,7 @@ import type { ChangesSummaryStore } from './changes-summary.ts'
 import { ChangedFiles } from './ChangedFiles.tsx'
 import { changesForClosing, presentedForClosing, type ChangesTurnData, type PresentedPath } from './turn-deliverables.ts'
 import type { NS } from './locales.ts'
-import { changesSummaryUrl } from '../changes.ts'
+import { changesSummaryUrl, type ChangesDiffCoordinates } from '../changes.ts'
 import { presentedFileUrl } from '../presented.ts'
 import { PresentedFileCard } from './PresentedFileCard.tsx'
 import css from './Deliverables.module.css'
@@ -29,6 +29,8 @@ export interface DeliverablesInjected {
   loadChangesSummary: ChangesSummaryStore['load']
   openPresented: PresentedOpenController['open']
   openChanged: PresentedOpenController['openChanged']
+  /** Open one listed file's comparison in the right Sidebar. */
+  openChangesDiff: (coordinates: ChangesDiffCoordinates) => void
 }
 
 /**
@@ -50,8 +52,8 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch
  * @returns the closing turn's file rows.
  */
 export function Deliverables({
-  matched, openFile, t, sessionId, useSessions, openPresented, openChanged, usePresentedOpen, usePresentedHost, useChangesSummary,
-  reloadPresentedHost, loadChangesSummary,
+  matched, openFile, t, sessionId, useSessions, openPresented, openChanged, openChangesDiff, usePresentedOpen, usePresentedHost,
+  useChangesSummary, reloadPresentedHost, loadChangesSummary,
 }: Pick<TurnTailOwnerProps, 'openFile'> & {
   matched: DeliverablesMatch
 } & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'> & Pick<GlobalStandardProps, 'useSessions'> & InjectFace<DeliverablesInjected>) {
@@ -76,7 +78,8 @@ export function Deliverables({
   }, [host, reloadPresentedHost])
   return <>
     {changes !== null && <ChangedFiles changes={changes} cwd={cwd} sessionId={sessionId}
-      host={host === 'error' ? null : host} phases={states} t={t} openFile={openFile}
+      host={host === 'error' ? null : host} phases={states} t={t}
+      openDiff={(index, display) => { openChangesDiff({ sessionId, seq: changes.seq, index, display }) }}
       onOpen={(index) => { void openChanged(sessionId, changes.seq, index) }} />}
     {matched.presented.length > 0 && <div
       className={css.root}

+ 20 - 0
packages/client/ui-deliverables/src/client/DiffPreview.module.css

@@ -0,0 +1,20 @@
+/** Comparison tab: the document preview's path header, then numbered unified-diff lines in the code font. */
+.root { display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); }
+.header { display: flex; flex: 0 0 auto; gap: 8px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 12px 0 16px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); }
+.path { flex: 1 1 auto; min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.pathDirectory { color: var(--dsw-alias-label-tertiary); }
+.pathName { color: var(--dsw-alias-label-primary); }
+.status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); }
+.body { flex: 1 1 auto; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); }
+.note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); }
+.hunk { margin-bottom: 8px; }
+.hunkHeader { padding: 4px 16px; color: var(--dsw-alias-label-tertiary); white-space: pre; }
+.line { display: grid; grid-template-columns: 3.5em 3.5em 1.2em minmax(0, max-content); min-height: 22px; line-height: 22px; white-space: pre; }
+.number { padding-right: 8px; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; }
+.sign { text-align: center; user-select: none; }
+.text { padding-right: 16px; }
+.add { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); }
+.add .sign, .add .text { color: var(--dsw-alias-state-success-primary); }
+.del { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); }
+.del .sign, .del .text { color: var(--dsw-alias-state-error-primary); }
+.context .text { color: var(--dsw-alias-label-secondary); }

+ 136 - 0
packages/client/ui-deliverables/src/client/DiffPreview.tsx

@@ -0,0 +1,136 @@
+/** The changed-file comparison tab: a path header with the native-open control, then the hunks the Host served. */
+import { useEffect, useMemo } from 'react'
+import type { ReactNode } from 'react'
+import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
+import type { WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types'
+import { changedFileUrl, changesDiffUrl, parseChangesDiffAddress, type ChangesDiff } from '../changes.ts'
+import type { ChangesDiffStore } from './changes-diff.ts'
+import type { PresentedOpenController } from './present-open.ts'
+import type { NS } from './locales.ts'
+import css from './DiffPreview.module.css'
+
+/** Comparison reads, desktop metadata, and the native open supplied by the plugin. */
+export interface DiffPreviewInjected {
+  hooks: {
+    changesDiff: ObservableSnapshot<ReturnType<ChangesDiffStore['state']['getSnapshot']>>
+    presentedOpen: ObservableSnapshot<ReturnType<PresentedOpenController['state']['getSnapshot']>>
+    presentedHost: ObservableSnapshot<ReturnType<PresentedOpenController['host']['getSnapshot']>>
+  }
+  loadChangesDiff: ChangesDiffStore['load']
+  reloadPresentedHost: PresentedOpenController['loadHost']
+  openChanged: PresentedOpenController['openChanged']
+}
+
+/** The body's composed props: the tab it draws, its injected face, and its copy. */
+export type DiffPreviewProps = PropsRuntime<'sidebar.right.pane.tab'> & InjectFace<DiffPreviewInjected> & PropsLocale<typeof NS>
+
+/** One drawn line of a hunk with its line numbers on each side. */
+interface DiffRow {
+  kind: 'add' | 'del' | 'context'
+  old: number | undefined
+  new: number | undefined
+  text: string
+}
+
+/**
+ * Number a hunk's lines: context lines count on both sides, deletions on the
+ * old side, additions on the new side.
+ * @param hunk - a served hunk.
+ * @returns the rows in order.
+ */
+export function hunkRows(hunk: WorkspaceDiffHunk): DiffRow[] {
+  let oldNo = hunk.oldStart
+  let newNo = hunk.newStart
+  return hunk.lines.map((line) => {
+    const text = line.slice(1)
+    switch (line[0]) {
+      case '+': return { kind: 'add', old: undefined, new: newNo++, text }
+      case '-': return { kind: 'del', old: oldNo++, new: undefined, text }
+      default: return { kind: 'context', old: oldNo++, new: newNo++, text }
+    }
+  })
+}
+
+/** The one-line fact about a text comparison worth stating above its hunks, if any. */
+function noteOf(diff: Extract<ChangesDiff, { kind: 'text' }>): 'diff.created' | 'diff.deleted' | 'diff.unchanged' | undefined {
+  if (!diff.before) return 'diff.created'
+  if (!diff.after) return 'diff.deleted'
+  if (diff.hunks.length === 0) return 'diff.unchanged'
+  return undefined
+}
+
+/**
+ * The comparison type's body, registered under `sidebar.right.pane.tab` as `changes-diff`.
+ * @param props - composed slot props.
+ * @returns the comparison, or the state that stands in for it.
+ */
+export function DiffPreview({
+  useTabInfo, useChangesDiff, usePresentedOpen, usePresentedHost, loadChangesDiff, reloadPresentedHost, openChanged, t,
+}: DiffPreviewProps): ReactNode {
+  const { tab } = useTabInfo()
+  const coordinates = useMemo(() => parseChangesDiffAddress(tab.contentId), [tab.contentId])
+  if (coordinates === undefined) throw new Error(`ui-deliverables: not a comparison address "${tab.contentId}"`)
+  const { sessionId, seq, index } = coordinates
+  const state = useChangesDiff(value => value[changesDiffUrl(sessionId, seq, index)])
+  const host = usePresentedHost(value => value)
+  const phase = usePresentedOpen(value => value[changedFileUrl(sessionId, seq, index)])
+  useEffect(() => {
+    if (state === undefined) void loadChangesDiff(sessionId, seq, index)
+  }, [state, sessionId, seq, index, loadChangesDiff])
+  useEffect(() => {
+    if (host === null) void reloadPresentedHost()
+  }, [host, reloadPresentedHost])
+  const display = typeof state === 'object' ? state.display : coordinates.display
+  const { directory, name } = pathPartsOf(display)
+  const native = host !== null && host !== 'error' && host.available && phase !== 'nativeUnavailable'
+  const opening = phase === 'opening'
+  return (
+    <div className={css.root} data-changes-diff data-diff-state={state === undefined ? 'loading' : typeof state === 'string' ? state : state.kind}>
+      <div className={css.header}>
+        <div className={css.path} title={display}>
+          {directory !== '' && <span className={css.pathDirectory}>{directory}</span>}
+          <span className={css.pathName}>{name}</span>
+        </div>
+        {native && <Button size="sm" disabled={opening} onClick={() => { void openChanged(sessionId, seq, index) }}>
+          {t(phase === 'error' ? 'diff.openNativeError' : opening ? 'presented.opening' : 'diff.openNative')}
+        </Button>}
+      </div>
+      {(state === undefined || state === 'loading') && <p className={css.status} role="status">{t('diff.loading')}</p>}
+      {state === 'missing' && <p className={css.status}>{t('diff.missing')}</p>}
+      {state === 'error' && <div className={css.status}>
+        <span>{t('diff.error')}</span>
+        <Button size="sm" onClick={() => { void loadChangesDiff(sessionId, seq, index) }}>{t('presented.retry')}</Button>
+      </div>}
+      {typeof state === 'object' && state.kind === 'binary' && <p className={css.status}>{t('diff.binary')}</p>}
+      {typeof state === 'object' && state.kind === 'oversized' && <p className={css.status}>{t('diff.oversized')}</p>}
+      {typeof state === 'object' && state.kind === 'text' && <TextDiff diff={state} t={t} />}
+    </div>
+  )
+}
+
+/** The hunks of a text comparison with their line numbers. */
+function TextDiff({ diff, t }: { diff: Extract<ChangesDiff, { kind: 'text' }> } & PropsLocale<typeof NS>): ReactNode {
+  const note = noteOf(diff)
+  return (
+    <div className={css.body}>
+      {note !== undefined && <p className={css.note}>{t(note)}</p>}
+      {diff.coarse && <p className={css.note} data-diff-coarse>{t('diff.coarse')}</p>}
+      {diff.hunks.map((hunk, position) => (
+        <section key={position} className={css.hunk}>
+          <div className={css.hunkHeader}>{`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`}</div>
+          {hunkRows(hunk).map((row, at) => (
+            <div key={at} className={`${css.line} ${css[row.kind]}`} data-diff-line={row.kind}>
+              <span className={css.number}>{row.old ?? ''}</span>
+              <span className={css.number}>{row.new ?? ''}</span>
+              <span className={css.sign}>{row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '}</span>
+              <span className={css.text}>{row.text}</span>
+            </div>
+          ))}
+        </section>
+      ))}
+    </div>
+  )
+}

+ 70 - 0
packages/client/ui-deliverables/src/client/changes-diff.ts

@@ -0,0 +1,70 @@
+/** Cache of the file comparisons the Host serves for listed changed files, read once per comparison and again on request. */
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { changesDiffUrl, isChangesDiff, type ChangesDiff } from '../changes.ts'
+
+/**
+ * A comparison, `'missing'` once the Host no longer serves it, `'error'` for
+ * a failed read a later request retries, or `'loading'` while the request runs.
+ */
+export type ChangesDiffState = ChangesDiff | 'missing' | 'error' | 'loading'
+
+/** One browser plugin's comparison reads, cleared on connection replacement and cancelled on disposal. */
+export class ChangesDiffStore {
+  /** Comparison URLs key the state across Sessions, turns, and files. */
+  readonly state = createSnapshotStore<Record<string, ChangesDiffState | undefined>>({})
+  private readonly lifetime = new AbortController()
+  /** The connection generation the current states belong to; a reset aborts it so no older read publishes. */
+  private generation = new AbortController()
+  private readonly pending = new Set<Promise<void>>()
+
+  /**
+   * Read one comparison; a cached comparison or a missing one is kept, a failed one is read again.
+   * @param sessionId - viewed Session.
+   * @param seq - the announcing event's sequence.
+   * @param index - the file's index in the summary.
+   * @returns after the state is published.
+   */
+  async load(sessionId: SessionId, seq: number, index: number): Promise<void> {
+    const url = changesDiffUrl(sessionId, seq, index)
+    const current = this.state.getSnapshot()[url]
+    if (this.lifetime.signal.aborted || (current !== undefined && current !== 'error')) return
+    this.state.update((state) => { state[url] = 'loading' })
+    const task = this.read(url, AbortSignal.any([this.lifetime.signal, this.generation.signal]))
+    this.pending.add(task)
+    try {
+      await task
+    } finally {
+      this.pending.delete(task)
+    }
+  }
+
+  /** Forget every state and abandon in-flight reads; a replaced connection may reach a Host that no longer serves them. */
+  reset(): void {
+    this.generation.abort()
+    this.generation = new AbortController()
+    this.state.set({})
+  }
+
+  /** Cancel outstanding reads and wait until none can publish state. */
+  async dispose(): Promise<void> {
+    this.lifetime.abort()
+    await Promise.all(this.pending)
+  }
+
+  private async read(url: string, signal: AbortSignal): Promise<void> {
+    let next: ChangesDiffState = 'error'
+    try {
+      const response = await fetch(url, { signal })
+      if (response.status === 404) next = 'missing'
+      else if (response.ok) {
+        const value: unknown = await response.json()
+        if (isChangesDiff(value)) next = value
+      }
+    } catch {
+      // A transport failure stays retryable until the connection is replaced and the store reset.
+      next = 'error'
+    }
+    if (!signal.aborted) this.state.update((state) => { state[url] = next })
+  }
+}

+ 34 - 0
packages/client/ui-deliverables/src/client/diff-definition.ts

@@ -0,0 +1,34 @@
+/**
+ * What the `changes-diff` tab type IS: the right-Sidebar viewer of one listed
+ * changed file's turn-start and turn-end comparison. It claims the
+ * `dsh-resource://changes-diff/session/…` addresses the changed-files card
+ * mints; the coordinates in the address identify the content, and the display
+ * path they carry names the tab.
+ */
+import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import { parseChangesDiffAddress } from '../changes.ts'
+import { basename } from '../presented.ts'
+
+/** The tab kind this package owns. */
+export const CHANGES_DIFF_KIND = 'changes-diff'
+
+/** This implementation's identity in the tab system, and the key its body registers under. */
+export const CHANGES_DIFF_ID = '@deepseek-ai/dsh-client-ui-deliverables'
+
+/**
+ * The comparison type's registry definition.
+ * @returns the definition to register.
+ */
+export function changesDiffDefinition(): SidebarRightTabDefinition {
+  return {
+    id: CHANGES_DIFF_ID,
+    kind: CHANGES_DIFF_KIND,
+    patterns: ['dsh-resource://changes-diff/**'],
+    priority: 'builtin',
+    canOpen: address => parseChangesDiffAddress(address) !== undefined,
+    title: (address) => {
+      const display = parseChangesDiffAddress(address)?.display
+      return display === undefined ? address : basename(display)
+    },
+  }
+}

+ 33 - 11
packages/client/ui-deliverables/src/client/index.ts

@@ -1,11 +1,12 @@
 /**
  * Deliverables plugin, browser half: registers the changed-files card and
- * delivery cards into the chat view's turn-tail chain, and provides the
- * `chatFileMentions` service that links inline-code mentions of produced or
- * delivered files in the closing prose. All policy lives here — the supported
- * mutation calls, mention matching, row cap, and copy — so composing this
- * plugin out of cordis.yml removes both surfaces entirely; the owning view
- * renders an empty chain and inert prose at zero cost.
+ * delivery cards into the chat view's turn-tail chain, the `changes-diff`
+ * right-Sidebar tab type that shows one listed file's turn-start and turn-end
+ * comparison, and provides the `chatFileMentions` service that links
+ * inline-code mentions of produced or delivered files in the closing prose.
+ * All policy lives here — the supported mutation calls, mention matching, row
+ * cap, and copy — so composing this plugin out of cordis.yml removes every
+ * surface; the owning view renders an empty chain and inert prose at zero cost.
  */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -14,10 +15,15 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import { changesDiffAddress } from '../changes.ts'
+import { ChangesDiffStore } from './changes-diff.ts'
 import { ChangesSummaryStore } from './changes-summary.ts'
 import { PresentedOpenController } from './present-open.ts'
 import { PresentRow } from './PresentRow.tsx'
 import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx'
+import { DiffPreview, type DiffPreviewInjected } from './DiffPreview.tsx'
+import { CHANGES_DIFF_ID, changesDiffDefinition } from './diff-definition.ts'
 import { en, NS, zh, type DeliverablesKey } from './locales.ts'
 import {
   deliverablesDefinition, presentedForClosing, producedFileMentions, selectProducedFiles,
@@ -25,25 +31,27 @@ import {
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
-    /** Changed-files card, delivery card, and file-mention copy. */
+    /** Changed-files card, comparison tab, delivery card, and file-mention copy. */
     'deliverables': DeliverablesKey
   }
 }
 
-/** Required services for the tail-slot registration and its dictionaries. */
-export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session']
+/** Required services for the tail-slot and tab-type registrations and their dictionaries. */
+export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.session', 'sidebarRightTabs', 'sidebarRight']
 
 /**
- * Client plugin body: register the dictionaries and the turn-tail entry.
+ * Client plugin body: register the dictionaries, the turn-tail entry, and the comparison tab type.
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
   const opener = new PresentedOpenController()
   const summaries = new ChangesSummaryStore()
-  ctx.effect(() => () => Promise.all([opener.dispose(), summaries.dispose()]))
+  const diffs = new ChangesDiffStore()
+  ctx.effect(() => () => Promise.all([opener.dispose(), summaries.dispose(), diffs.dispose()]))
   ctx.on('connection/reset', () => {
     opener.resetHost()
     summaries.reset()
+    diffs.reset()
   })
   ctx.uiConversation.events.register(deliverablesDefinition)
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries')
@@ -59,12 +67,26 @@ export function apply(ctx: ClientContext): void {
         loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq),
         openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action),
         openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index),
+        openChangesDiff: (coordinates) => { ctx.sidebarRight.openResource(changesDiffAddress(coordinates)) },
       }),
     }, Deliverables),
   )
   ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
     { name: 'tool.call.toolview', key: 'present', locale: NS }, PresentRow,
   ))
+  ctx.effect(() => ctx.sidebarRightTabs.register(changesDiffDefinition()), 'ui-deliverables: changes-diff type')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
+    {
+      name: 'sidebar.right.pane.tab', key: CHANGES_DIFF_ID, locale: NS,
+      inject: (): DiffPreviewInjected => ({
+        hooks: { changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host },
+        loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index),
+        reloadPresentedHost: () => opener.loadHost(),
+        openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index),
+      }),
+    },
+    DiffPreview,
+  )), 'ui-deliverables: changes-diff body')
   // The prose side of the same vocabulary: the chat view reaches this face
   // via ctx.get, so its absence — this plugin composed out — is the off state.
   const t = ctx.locale.bind(NS)

+ 27 - 1
packages/client/ui-deliverables/src/client/locales.ts

@@ -1,4 +1,4 @@
-/** `deliverables` namespace dictionaries. */
+/** `deliverables` namespace dictionaries: cards, comparison tab, and file-mention copy. */
 
 /** Dictionary namespace owned by this plugin. */
 export const NS = 'deliverables'
@@ -50,6 +50,19 @@ export const zh = {
   'changes.collapseAria': '收起改动文件列表',
   'changes.folderOpening': '正在打开文件夹…',
   'changes.folderError': '无法打开文件夹,点击重试',
+  'changes.oversized': '过大',
+  'changes.viewDiff': '查看 {name} 的改动',
+  'diff.loading': '正在读取改动…',
+  'diff.missing': '这轮改动的内容已不可用',
+  'diff.error': '无法读取改动',
+  'diff.binary': '二进制文件,无法显示改动',
+  'diff.oversized': '文件过大,无法显示改动',
+  'diff.created': '本轮新建的文件',
+  'diff.deleted': '本轮删除的文件',
+  'diff.unchanged': '两侧内容相同',
+  'diff.coarse': '逐行对比超时,按整个文件替换显示',
+  'diff.openNative': '用默认应用打开',
+  'diff.openNativeError': '打开失败,点击重试',
 }
 
 /** English dictionary (same key set). */
@@ -99,6 +112,19 @@ export const en: Record<DeliverablesKey, string> = {
   'changes.collapseAria': 'Collapse changed files',
   'changes.folderOpening': 'Opening folder…',
   'changes.folderError': 'Could not open folder. Click to retry.',
+  'changes.oversized': 'too large',
+  'changes.viewDiff': 'View changes to {name}',
+  'diff.loading': 'Reading changes…',
+  'diff.missing': 'The contents of this turn’s changes are no longer available',
+  'diff.error': 'Could not read the changes',
+  'diff.binary': 'Binary file; changes cannot be shown',
+  'diff.oversized': 'File too large; changes cannot be shown',
+  'diff.created': 'Created in this turn',
+  'diff.deleted': 'Deleted in this turn',
+  'diff.unchanged': 'Both sides hold the same lines',
+  'diff.coarse': 'Line comparison timed out; shown as a whole-file replacement',
+  'diff.openNative': 'Open in default app',
+  'diff.openNativeError': 'Could not open. Click to retry.',
 }
 
 /** Union of this namespace's dictionary keys. */

+ 26 - 6
packages/client/ui-deliverables/src/present-open.ts

@@ -1,4 +1,4 @@
-/** Serve change summaries and open declared or changed workspace paths verified by the viewed Session's filesystem. */
+/** Serve change summaries and comparisons, and open declared or changed workspace paths verified by the viewed Session's filesystem. */
 import { dirname, isAbsolute, relative, resolve } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-session-controller'
@@ -10,13 +10,13 @@ import type {} from '@deepseek-ai/dsh-client-connection'
 import type {} from '@deepseek-ai/dsh-session-query'
 import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
 import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types'
-import { CHANGES_OPEN_PATH, CHANGED_FILES_PATH, type ChangesSummary } from './changes.ts'
+import { CHANGES_DIFF_PATH, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, type ChangesSummary } from './changes.ts'
 import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH, PRESENT_HOST_PATH, type PresentedHost } from './presented.ts'
 
 /**
  * Register the deliverables routes inside Connection's authentication fence:
- * desktop metadata, change summaries, declared-file actions, and changed-file
- * or folder opening.
+ * desktop metadata, change summaries and comparisons, declared-file actions,
+ * and changed-file or folder opening.
  * @param ctx - Session lookup, change summaries, native opener, and route lifetime.
  */
 export function registerPresentOpen(ctx: Context): void {
@@ -35,10 +35,13 @@ export function registerPresentOpen(ctx: Context): void {
     lifetime.abort()
     await Promise.allSettled(pending)
   })
-  for (const [path, handler] of [[PRESENT_OPEN_PATH, handlePresentOpen], [CHANGES_OPEN_PATH, handleChangesOpen]] as const) {
+  const routes = [
+    [PRESENT_OPEN_PATH, 'POST', handlePresentOpen], [CHANGES_OPEN_PATH, 'POST', handleChangesOpen], [CHANGES_DIFF_PATH, 'GET', handleChangesDiff],
+  ] as const
+  for (const [path, method, handler] of routes) {
     ctx.connection.fetch.register({
       path,
-      methods: ['POST'],
+      methods: [method],
       requestBody: 'buffered',
       fetch: (request) => {
         const task = handler(ctx, new Request(request, {
@@ -152,6 +155,23 @@ function handleChangesSummary(ctx: Context, request: Request): Response {
   return Response.json({ turn, files, total, added, deleted } satisfies ChangesSummary, { headers: { 'cache-control': 'no-store' } })
 }
 
+/** One listed file's comparison; 404 once the Host no longer serves the summary or the index names no file. */
+async function handleChangesDiff(ctx: Context, request: Request): Promise<Response> {
+  const query = new URL(request.url).searchParams
+  const id = query.get('sessionId')
+  const seq = coordinate(query.get('seq'))
+  const index = coordinate(query.get('index'))
+  if (!id || seq === undefined || index === undefined) return new Response('Invalid changed file coordinates.', { status: 400 })
+  try {
+    const diff = await ctx.workspaceChanges.diff(id as SessionId, seq, index, request.signal)
+    if (diff === undefined) return new Response('Change comparison unavailable.', { status: 404 })
+    return Response.json(diff, { headers: { 'cache-control': 'no-store' } })
+  } catch (error: unknown) {
+    request.signal.throwIfAborted()
+    return new Response('Change comparison unavailable.', { status: failureStatus(error) })
+  }
+}
+
 async function handleChangesOpen(ctx: Context, request: Request): Promise<Response> {
   const query = new URL(request.url).searchParams
   const id = query.get('sessionId')

+ 53 - 4
packages/client/ui-deliverables/tests/changes-open.host.spec.ts

@@ -10,10 +10,13 @@ import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser
 import { SessionId } from '@deepseek-ai/dsh-session'
 import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
 import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query'
-import type { WorkspaceChangedFile, WorkspaceChangesSummary } from '@deepseek-ai/dsh-workspace-changes/types'
+import type { WorkspaceChangedFile, WorkspaceChangesSummary, WorkspaceFileDiff } from '@deepseek-ai/dsh-workspace-changes/types'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { commonChangedFolder, registerPresentOpen } from '../src/present-open.ts'
-import { changedFileUrl, changesSummaryUrl, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, isChangedFile, isChangesEvent, isChangesSummary } from '../src/changes.ts'
+import {
+  changedFileUrl, changesDiffUrl, changesSummaryUrl, CHANGES_DIFF_PATH, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, isChangedFile, isChangesDiff,
+  isChangesEvent, isChangesSummary,
+} from '../src/changes.ts'
 
 const cleanups: Array<() => Promise<unknown>> = []
 afterEach(async () => {
@@ -50,7 +53,13 @@ async function fixture() {
   })
   ctx.provide('sessionQuery', { readEvent } as never)
   const summary = vi.fn((sessionId: SessionId, seq: number) => sessionId === 'owner' && seq === 9 ? data : undefined)
-  ctx.provide('workspaceChanges', { summary })
+  const comparison: WorkspaceFileDiff = {
+    kind: 'text', path: 'src/lib/a.ts', display: 'src/lib/a.ts', before: true, after: true, coarse: false,
+    hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
+  }
+  const diff = vi.fn(async (sessionId: SessionId, seq: number, index: number, _signal: AbortSignal) =>
+    sessionId === 'owner' && seq === 9 && index === 0 ? comparison : undefined)
+  ctx.provide('workspaceChanges', { summary, diff })
   const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const }))
   ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
   const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
@@ -61,7 +70,8 @@ async function fixture() {
   const handler = connection.createSharedFetchHandler('/api')
   const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' }))
   const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`))
-  return { root, cwd, ctx, data, readEvent, open, read, opener, outside, summary }
+  const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`))
+  return { root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary }
 }
 
 describe('change summary route', () => {
@@ -82,6 +92,43 @@ describe('change summary route', () => {
   })
 })
 
+describe('change comparison route', () => {
+  it('serves the Host-computed comparison, 404 once it is gone, and 500 when the read fails', async () => {
+    const { compare, comparison, diff } = await fixture()
+    expect(changesDiffUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_DIFF_PATH}?sessionId=owner&seq=9&index=0`)
+    const response = await compare()
+    expect(response.status).toBe(200)
+    expect(response.headers.get('cache-control')).toBe('no-store')
+    expect(await response.json()).toEqual(comparison)
+    expect(diff).toHaveBeenLastCalledWith('owner', 9, 0, expect.any(AbortSignal))
+    expect((await compare('?sessionId=owner&seq=9&index=1')).status).toBe(404)
+    expect((await compare('?sessionId=owner&seq=8&index=0')).status).toBe(404)
+    expect((await compare('?sessionId=other&seq=9&index=0')).status).toBe(404)
+    for (const bad of ['', '?seq=9&index=0', '?sessionId=owner&seq=9', '?sessionId=owner&seq=9&index=-1', '?sessionId=owner&seq=x&index=0']) {
+      expect((await compare(bad)).status).toBe(400)
+    }
+    diff.mockRejectedValueOnce(new Error('/private/objects'))
+    const failed = await compare()
+    expect(failed.status).toBe(500)
+    expect(await failed.text()).not.toContain('/private/objects')
+  })
+
+  it('validates served comparisons', () => {
+    const text = { kind: 'text', path: 'a', display: 'a', before: true, after: false, coarse: true, hunks: [] }
+    expect(isChangesDiff(text)).toBe(true)
+    expect(isChangesDiff({ ...text, hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 0, lines: ['-x', ' y', '+z'] }] })).toBe(true)
+    expect(isChangesDiff({ kind: 'binary', path: 'a', display: 'a' })).toBe(true)
+    expect(isChangesDiff({ kind: 'oversized', path: 'a', display: 'a' })).toBe(true)
+    expect(isChangesDiff({ kind: 'other', path: 'a', display: 'a' })).toBe(false)
+    expect(isChangesDiff({ kind: 'binary', path: '', display: 'a' })).toBe(false)
+    expect(isChangesDiff({ ...text, before: 'yes' })).toBe(false)
+    expect(isChangesDiff({ ...text, hunks: [{ oldStart: -1, oldLines: 1, newStart: 1, newLines: 0, lines: [] }] })).toBe(false)
+    expect(isChangesDiff({ ...text, hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 0, lines: ['x'] }] })).toBe(false)
+    expect(isChangesDiff({ ...text, hunks: [null] })).toBe(false)
+    expect(isChangesDiff(null)).toBe(false)
+  })
+})
+
 describe('changed files native open route', () => {
   it('opens a listed file inside or outside the workspace with its verified Host path', async () => {
     const { cwd, open, opener, outside } = await fixture()
@@ -147,6 +194,8 @@ describe('changed files native open route', () => {
   it('validates served summaries and logged announcements', () => {
     expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: true })).toBe(true)
     expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: false })).toBe(false)
+    expect(isChangedFile({ path: 'a', display: 'a', added: 0, deleted: 0, oversized: true })).toBe(true)
+    expect(isChangedFile({ path: 'a', display: 'a', added: 0, deleted: 0, oversized: 1 })).toBe(false)
     expect(isChangedFile({ path: '', display: 'a', added: 1, deleted: 2 })).toBe(false)
     expect(isChangedFile({ path: 'a', display: '', added: 1, deleted: 2 })).toBe(false)
     expect(isChangedFile({ path: 'a', display: 'a', added: 1.5, deleted: 2 })).toBe(false)

+ 53 - 26
packages/client/ui-deliverables/tests/deliverables.client.spec.tsx

@@ -23,6 +23,7 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c
 import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
 import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
+import type { DiffPreviewInjected } from '../src/client/DiffPreview.tsx'
 import { ChangesSummaryStore } from '../src/client/changes-summary.ts'
 import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts'
 import { PresentedOpenController } from '../src/client/present-open.ts'
@@ -48,6 +49,7 @@ function openProps(controller = new PresentedOpenController(), summaries = new C
       select(controller.host.getSnapshot()),
     openPresented: vi.fn((...args: Parameters<PresentedOpenController['open']>) => controller.open(...args)),
     openChanged: vi.fn((...args: Parameters<PresentedOpenController['openChanged']>) => controller.openChanged(...args)),
+    openChangesDiff: vi.fn<DeliverablesInjected['openChangesDiff']>(),
     usePresentedOpen: <T,>(select: (state: ReturnType<typeof controller.state.getSnapshot>) => T): T =>
       select(controller.state.getSnapshot()),
   }
@@ -430,8 +432,12 @@ describe('produced-file Turn data', () => {
   })
 })
 
-const changedFile = (display: string, added = 1, deleted = 0, extra: { binary?: true; path?: string } = {}) =>
-  ({ path: extra.path ?? display, display, added, deleted, ...extra.binary === true ? { binary: true as const } : {} })
+const changedFile = (display: string, added = 1, deleted = 0, extra: { binary?: true; oversized?: true; path?: string } = {}) =>
+  ({
+    path: extra.path ?? display, display, added, deleted,
+    ...extra.binary === true ? { binary: true as const } : {},
+    ...extra.oversized === true ? { oversized: true as const } : {},
+  })
 
 const changesEvent = (seq: number, turn = 1) => at(seq, 'workspace/changes', { turn })
 
@@ -561,7 +567,7 @@ describe('ChangedFiles card', () => {
     expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('loading')
   })
 
-  it('summarizes the turn, folds after three rows, and opens rows and the folder natively', () => {
+  it('summarizes the turn, folds after three rows, opens each rows comparison, and opens the folder natively', () => {
     const { props, openFile, view } = renderCard()
     const card = view.container.querySelector('[data-changed-files]')
     if (!(card instanceof HTMLElement)) throw new Error('changed-files card missing')
@@ -572,8 +578,9 @@ describe('ChangedFiles card', () => {
     expect(within(card).getByText('config/design-token')).toBeTruthy()
     expect(within(card).getByText('+42')).toBeTruthy()
     expect(within(card).queryByText('src/index.ts')).toBeNull()
-    fireEvent.click(within(card).getByRole('button', { name: 'Open config/feature-flags.json in default app' }))
-    expect(props.openChanged).toHaveBeenLastCalledWith('child-session', 5, 1)
+    fireEvent.click(within(card).getByRole('button', { name: 'View changes to config/feature-flags.json' }))
+    expect(props.openChangesDiff).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, index: 1, display: 'config/feature-flags.json' })
+    expect(props.openChanged).not.toHaveBeenCalled()
     fireEvent.click(within(card).getByRole('button', { name: 'Open the folder containing the changed files' }))
     expect(props.openChanged).toHaveBeenLastCalledWith('child-session', 5, null)
     expect(openFile).not.toHaveBeenCalled()
@@ -583,7 +590,9 @@ describe('ChangedFiles card', () => {
     fireEvent.click(expand)
     expect(within(card).getAllByRole('listitem')).toHaveLength(5)
     expect(within(card).getByText('binary')).toBeTruthy()
-    expect(within(card).getByRole('button', { name: 'Open ~/.zshrc in default app' }).getAttribute('title')).toBe('/home/u/.zshrc')
+    expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBe('/home/u/.zshrc')
+    fireEvent.click(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }))
+    expect(props.openChangesDiff).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, index: 4, display: '~/.zshrc' })
     const collapse = within(card).getByRole('button', { name: 'Collapse changed files' })
     expect(collapse.getAttribute('aria-expanded')).toBe('true')
     expect(card.lastElementChild).toBe(collapse)
@@ -591,7 +600,7 @@ describe('ChangedFiles card', () => {
     expect(within(card).getAllByRole('listitem')).toHaveLength(3)
   })
 
-  it('previews rows in the Sidebar and offers no folder action without a desktop', () => {
+  it('still opens each row’s comparison and offers no folder action without a desktop', () => {
     const controller = new PresentedOpenController()
     const { openFile, props, view } = renderCard(controller, zh)
     controller.host.set('error')
@@ -601,39 +610,33 @@ describe('ChangedFiles card', () => {
     view.rerender(<Deliverables {...props} matched={{ changes, presented: [] }} openFile={openFile} sessionId={SessionId('child-session')} t={makeTranslate(zh)} />)
     expect(view.getByText('已编辑 11 个文件')).toBeTruthy()
     expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull()
-    fireEvent.click(view.getByRole('button', { name: '在侧边栏打开 config/design-token' }))
-    expect(openFile).toHaveBeenCalledWith('config/design-token')
+    fireEvent.click(view.getByRole('button', { name: '查看 config/design-token 的改动' }))
+    expect(props.openChangesDiff).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, index: 0, display: 'config/design-token' })
+    expect(openFile).not.toHaveBeenCalled()
     expect(props.openChanged).not.toHaveBeenCalled()
     expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件')
   })
 
-  it('shows row and folder gesture states in place of the counts', () => {
+  it('shows folder gesture states in place of the totals and keeps row counts through native opens', () => {
     const controller = new PresentedOpenController()
     controller.state.set({
       '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opening',
       '/api/changes.open?sessionId=child-session&seq=5&index=1': 'error',
-      '/api/changes.open?sessionId=child-session&seq=5&index=2': 'nativeUnavailable',
       '/api/changes.open?sessionId=child-session&seq=5': 'opened',
     })
-    const { view, openFile, props } = renderCard(controller)
-    expect(view.getByText(en['presented.opening'])).toBeTruthy()
-    expect((view.getByRole('button', { name: 'Open config/design-token in default app' }) as HTMLButtonElement).disabled).toBe(true)
-    expect(view.getByText(en['presented.error']).closest('[data-error]')).toBeTruthy()
-    expect(view.getByText(en['presented.nativeUnavailable'])).toBeTruthy()
+    const { view } = renderCard(controller)
+    // Row gestures belong to the comparison tab; the card keeps every row's counts.
+    expect(view.queryByText(en['presented.opening'])).toBeNull()
+    expect(view.queryByText(en['presented.error'])).toBeNull()
+    expect(view.getByText('+42')).toBeTruthy()
+    expect(view.getByText('+143')).toBeTruthy()
     // A completed folder open leaves the summed counts in place.
     expect(view.getByText('+1,232')).toBeTruthy()
-    // A row without a verified Host path previews in the Sidebar instead of retrying the native open.
-    fireEvent.click(view.getByRole('button', { name: 'Open config/launch-plan.yaml in sidebar' }))
-    expect(openFile).toHaveBeenCalledWith('config/launch-plan.yaml')
-    expect(props.openChanged).not.toHaveBeenCalled()
     view.unmount()
-    controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'opening', '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opened' })
+    controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'opening' })
     const { view: pending } = renderCard(controller)
     expect(pending.getByText(en['changes.folderOpening'])).toBeTruthy()
     expect((pending.getByRole('button', { name: 'Open the folder containing the changed files' }) as HTMLButtonElement).disabled).toBe(true)
-    // A completed row open shows its counts again rather than a lasting acknowledgement.
-    expect(pending.queryByText(en['presented.opened'])).toBeNull()
-    expect(pending.getByText('+42')).toBeTruthy()
     pending.unmount()
     controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'error' })
     const { view: failed } = renderCard(controller)
@@ -641,9 +644,10 @@ describe('ChangedFiles card', () => {
   })
 
   it('renders without a fold for three files or fewer and beside delivery cards', () => {
-    const short = servedStore({ turn: 1, files: files.slice(0, 2), total: 2, added: 185, deleted: 43 })
+    const short = servedStore({ turn: 1, files: [...files.slice(0, 1), changedFile('huge.bin', 0, 0, { oversized: true })], total: 2, added: 185, deleted: 43 })
     const { view } = renderCard(new PresentedOpenController(), en, { changes, presented: [{ path: 'report.pdf', seq: 6, index: 0 }] as never[] }, short)
     expect(view.getByText('Edited 2 files')).toBeTruthy()
+    expect(view.getByText('too large')).toBeTruthy()
     expect(view.queryByRole('button', { name: /Show all|Collapse changed/ })).toBeNull()
     expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(1)
     expect(view.container.querySelector('[data-presented-files-row]')?.parentElement?.getAttribute('data-after-changes')).toBe('true')
@@ -686,8 +690,17 @@ describe('plugin registration', () => {
     // The owning view's child declaration, stood up by a bench root entry.
     ctx.slots.register({
       name: 'root',
-      children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, 'tool.call.toolview': { kind: 'keyed', scope: 'session' } },
+      children: {
+        'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
+        'tool.call.toolview': { kind: 'keyed', scope: 'session' },
+        'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session' },
+      },
     } as never, () => null)
+    const registerTab = vi.fn(() => () => { registered = undefined })
+    let registered: unknown
+    ctx.provide('sidebarRightTabs', { register: (definition: unknown) => { registered = definition; return registerTab() } } as never)
+    const openResource = vi.fn()
+    ctx.provide('sidebarRight', { openResource } as never)
     // ui-theme's Appearance row binds a durable scope through these two.
     const session = {
       canOpenWorkspacePath: () => Promise.resolve({ ok: true as const, value: true }),
@@ -707,6 +720,9 @@ describe('plugin registration', () => {
     expect(entry).toBeDefined()
     expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(1)
     expect(entry?.inject).toBeDefined()
+    expect(registered).toMatchObject({ kind: 'changes-diff', patterns: ['dsh-resource://changes-diff/**'] })
+    const [tabEntry] = ctx.slots.entries('sidebar.right.pane.tab')
+    expect(tabEntry?.options.key).toBe('@deepseek-ai/dsh-client-ui-deliverables')
 
     // The prose face is live while the plugin is: a produced turn yields a
     // resolver whose matches open through the owner-supplied opener.
@@ -750,6 +766,15 @@ describe('plugin registration', () => {
     expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened')
     await face.openChanged(SessionId('child-session'), 5, null)
     expect(face.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5']).toBe('opened')
+    face.openChangesDiff({ sessionId: SessionId('child-session'), seq: 5, index: 1, display: 'src/a.ts' })
+    expect(openResource).toHaveBeenCalledWith('dsh-resource://changes-diff/session/child-session/5/1/src%2Fa.ts')
+    const tabFace = tabEntry!.inject!(SessionId('child-session') as never) as unknown as DiffPreviewInjected
+    fetcher.mockResolvedValueOnce(Response.json({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }))
+    await tabFace.loadChangesDiff(SessionId('child-session'), 5, 1)
+    expect(tabFace.hooks.changesDiff.getSnapshot()['/api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })
+    expect(tabFace.hooks.presentedHost).toBe(face.hooks.presentedHost)
+    ctx.emit('connection/reset')
+    expect(tabFace.hooks.changesDiff.getSnapshot()).toEqual({})
     // A turn that produced nothing yields no vocabulary at all.
     expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined()
 
@@ -763,6 +788,8 @@ describe('plugin registration', () => {
     unsubscribe()
     expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
     expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(0)
+    expect(ctx.slots.entries('sidebar.right.pane.tab')).toHaveLength(0)
+    expect(registered).toBeUndefined()
     // Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
     expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
   })

+ 218 - 0
packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx

@@ -0,0 +1,218 @@
+// @vitest-environment jsdom
+/** The comparison tab type: its addresses, its Host reads, and the states its body draws. */
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
+import { changesDiffAddress, changesDiffUrl, parseChangesDiffAddress, type ChangesDiff } from '../src/changes.ts'
+import { ChangesDiffStore } from '../src/client/changes-diff.ts'
+import { DiffPreview, hunkRows, type DiffPreviewInjected, type DiffPreviewProps } from '../src/client/DiffPreview.tsx'
+import { changesDiffDefinition } from '../src/client/diff-definition.ts'
+import { PresentedOpenController } from '../src/client/present-open.ts'
+import { en, zh } from '../src/client/locales.ts'
+
+afterEach(() => {
+  cleanup()
+  vi.unstubAllGlobals()
+})
+
+const SESSION = SessionId('viewed')
+const COORDINATES = { sessionId: SESSION, seq: 5, index: 1, display: 'src/app/main.ts' }
+const URL_ = changesDiffUrl(SESSION, 5, 1)
+
+const text: ChangesDiff = {
+  kind: 'text', path: 'src/app/main.ts', display: 'src/app/main.ts', before: true, after: true, coarse: false,
+  hunks: [
+    { oldStart: 1, oldLines: 3, newStart: 1, newLines: 4, lines: [' a', '-b', '+B', '+c', ' d'] },
+    { oldStart: 10, oldLines: 1, newStart: 11, newLines: 1, lines: ['-x', '+y'] },
+  ],
+}
+
+describe('comparison addresses', () => {
+  it('round-trips coordinates and titles the tab by the file name', () => {
+    const address = changesDiffAddress({ ...COORDINATES, display: 'dir/we ird#name.ts' })
+    expect(address).toBe('dsh-resource://changes-diff/session/viewed/5/1/dir%2Fwe%20ird%23name.ts')
+    expect(parseChangesDiffAddress(address)).toEqual({ ...COORDINATES, display: 'dir/we ird#name.ts' })
+    const definition = changesDiffDefinition()
+    expect(definition).toMatchObject({ kind: 'changes-diff', priority: 'builtin', patterns: ['dsh-resource://changes-diff/**'] })
+    expect(definition.canOpen?.(address)).toBe(true)
+    expect(definition.title(address)).toBe('we ird#name.ts')
+    for (const bad of [
+      'dsh-resource://file/session/viewed/a.ts', 'dsh-resource://changes-diff/session/viewed/5/1',
+      'dsh-resource://changes-diff/session//5/1/a', 'dsh-resource://changes-diff/session/viewed/x/1/a',
+      'dsh-resource://changes-diff/session/viewed/5/1/', 'dsh-resource://changes-diff/session/viewed/5/1/%E0%A4%A',
+    ]) {
+      expect(parseChangesDiffAddress(bad)).toBeUndefined()
+      expect(definition.canOpen?.(bad)).toBe(false)
+      expect(definition.title(bad)).toBe(bad)
+    }
+  })
+})
+
+describe('ChangesDiffStore', () => {
+  it('keeps served and missing comparisons, retries failures, and forgets on reset and disposal', async () => {
+    const fetcher = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>()
+    vi.stubGlobal('fetch', fetcher)
+    const store = new ChangesDiffStore()
+    fetcher.mockResolvedValueOnce(Response.json(text))
+    await store.load(SESSION, 5, 1)
+    expect(store.state.getSnapshot()[URL_]).toEqual(text)
+    await store.load(SESSION, 5, 1)
+    expect(fetcher).toHaveBeenCalledTimes(1)
+    fetcher.mockResolvedValueOnce(new Response('gone', { status: 404 }))
+    await store.load(SESSION, 5, 2)
+    expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 2)]).toBe('missing')
+    await store.load(SESSION, 5, 2)
+    expect(fetcher).toHaveBeenCalledTimes(2)
+    fetcher.mockResolvedValueOnce(new Response('boom', { status: 500 }))
+    await store.load(SESSION, 5, 3)
+    expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error')
+    fetcher.mockResolvedValueOnce(Response.json({ kind: 'text' }))
+    await store.load(SESSION, 5, 3)
+    expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error')
+    fetcher.mockRejectedValueOnce(new Error('offline'))
+    await store.load(SESSION, 5, 3)
+    expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toBe('error')
+    fetcher.mockResolvedValueOnce(Response.json({ kind: 'oversized', path: 'p', display: 'p' }))
+    await store.load(SESSION, 5, 3)
+    expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 5, 3)]).toEqual({ kind: 'oversized', path: 'p', display: 'p' })
+    store.reset()
+    expect(store.state.getSnapshot()).toEqual({})
+    let settle!: (response: Response) => void
+    fetcher.mockReturnValueOnce(new Promise<Response>((resolve) => { settle = resolve }))
+    const stale = store.load(SESSION, 5, 1)
+    expect(store.state.getSnapshot()[URL_]).toBe('loading')
+    store.reset()
+    settle(Response.json(text))
+    await stale
+    expect(store.state.getSnapshot()[URL_]).toBeUndefined()
+    fetcher.mockReturnValueOnce(new Promise<Response>((resolve) => { settle = resolve }))
+    const late = store.load(SESSION, 5, 1)
+    const disposal = store.dispose()
+    settle(Response.json(text))
+    await Promise.all([late, disposal])
+    expect(store.state.getSnapshot()[URL_]).toBe('loading')
+    await store.load(SESSION, 6, 0)
+    expect(store.state.getSnapshot()[changesDiffUrl(SESSION, 6, 0)]).toBeUndefined()
+  })
+})
+
+describe('DiffPreview', () => {
+  function mount(
+    diffs = new ChangesDiffStore(), controller = new PresentedOpenController(), locale = en, address = changesDiffAddress(COORDINATES),
+  ) {
+    const injected = {
+      loadChangesDiff: vi.fn<DiffPreviewInjected['loadChangesDiff']>(() => Promise.resolve()),
+      reloadPresentedHost: vi.fn<DiffPreviewInjected['reloadPresentedHost']>(() => Promise.resolve()),
+      openChanged: vi.fn<DiffPreviewInjected['openChanged']>(() => Promise.resolve()),
+    }
+    const runtime = {
+      useTabInfo: () => ({ tab: { contentId: address } }),
+      useChangesDiff: <T,>(select: (state: ReturnType<typeof diffs.state.getSnapshot>) => T): T => select(diffs.state.getSnapshot()),
+      usePresentedOpen: <T,>(select: (state: ReturnType<typeof controller.state.getSnapshot>) => T): T =>
+        select(controller.state.getSnapshot()),
+      usePresentedHost: <T,>(select: (state: ReturnType<typeof controller.host.getSnapshot>) => T): T =>
+        select(controller.host.getSnapshot()),
+      t: makeTranslate(locale),
+      ...injected,
+    } as unknown as DiffPreviewProps
+    const view = render(<DiffPreview {...runtime} />)
+    return { view, injected, rerender: () => { view.rerender(<DiffPreview {...runtime} />) } }
+  }
+
+  it('numbers hunk lines on the side they belong to', () => {
+    expect(hunkRows(text.hunks[0]!)).toEqual([
+      { kind: 'context', old: 1, new: 1, text: 'a' },
+      { kind: 'del', old: 2, new: undefined, text: 'b' },
+      { kind: 'add', old: undefined, new: 2, text: 'B' },
+      { kind: 'add', old: undefined, new: 3, text: 'c' },
+      { kind: 'context', old: 3, new: 4, text: 'd' },
+    ])
+  })
+
+  it('asks for the comparison once, then draws its hunks with line numbers and the path header', () => {
+    const diffs = new ChangesDiffStore()
+    const { view, injected, rerender } = mount(diffs)
+    expect(injected.loadChangesDiff).toHaveBeenCalledWith('viewed', 5, 1)
+    expect(injected.reloadPresentedHost).toHaveBeenCalledTimes(1)
+    expect(view.getByRole('status').textContent).toBe(en['diff.loading'])
+    expect(view.getByTitle('src/app/main.ts').textContent).toBe('src/app/main.ts')
+    diffs.state.set({ [URL_]: text })
+    rerender()
+    expect(injected.loadChangesDiff).toHaveBeenCalledTimes(1)
+    const root = view.container.querySelector('[data-changes-diff]')
+    expect(root?.getAttribute('data-diff-state')).toBe('text')
+    expect(view.getByText('@@ -1,3 +1,4 @@')).toBeTruthy()
+    const lines = [...view.container.querySelectorAll('[data-diff-line]')]
+    expect(lines.map(line => line.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'add', 'context', 'del', 'add'])
+    expect(lines[1]?.textContent).toBe('2-b')
+    expect(lines[2]?.textContent).toBe('2+B')
+    expect(lines[4]?.textContent).toBe('34 d')
+    expect(view.queryByText(en['diff.created'])).toBeNull()
+    // Without a desktop the header carries no native-open control.
+    expect(view.queryByRole('button')).toBeNull()
+  })
+
+  it('states created, deleted, unchanged, and coarse comparisons, and the binary, oversized, missing, and failed reads', () => {
+    const diffs = new ChangesDiffStore()
+    const { view, injected, rerender } = mount(diffs, new PresentedOpenController(), zh)
+    diffs.state.set({ [URL_]: { ...text, before: false, coarse: true } })
+    rerender()
+    expect(view.getByText(zh['diff.created'])).toBeTruthy()
+    expect(view.container.querySelector('[data-diff-coarse]')?.textContent).toBe(zh['diff.coarse'])
+    diffs.state.set({ [URL_]: { ...text, after: false } })
+    rerender()
+    expect(view.getByText(zh['diff.deleted'])).toBeTruthy()
+    diffs.state.set({ [URL_]: { ...text, hunks: [] } })
+    rerender()
+    expect(view.getByText(zh['diff.unchanged'])).toBeTruthy()
+    diffs.state.set({ [URL_]: { kind: 'binary', path: 'p', display: 'other/name.bin' } })
+    rerender()
+    expect(view.getByText(zh['diff.binary'])).toBeTruthy()
+    expect(view.getByTitle('other/name.bin')).toBeTruthy()
+    diffs.state.set({ [URL_]: { kind: 'oversized', path: 'p', display: 'p' } })
+    rerender()
+    expect(view.getByText(zh['diff.oversized'])).toBeTruthy()
+    diffs.state.set({ [URL_]: 'missing' })
+    rerender()
+    expect(view.getByText(zh['diff.missing'])).toBeTruthy()
+    diffs.state.set({ [URL_]: 'error' })
+    rerender()
+    expect(view.getByText(zh['diff.error'])).toBeTruthy()
+    fireEvent.click(view.getByRole('button', { name: zh['presented.retry'] }))
+    expect(injected.loadChangesDiff).toHaveBeenCalledTimes(2)
+  })
+
+  it('offers the native open only with a desktop and shows its pending and failed states', () => {
+    const diffs = new ChangesDiffStore()
+    diffs.state.set({ [URL_]: text })
+    const controller = new PresentedOpenController()
+    controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
+    const { view, injected, rerender } = mount(diffs, controller)
+    expect(injected.reloadPresentedHost).not.toHaveBeenCalled()
+    fireEvent.click(view.getByRole('button', { name: en['diff.openNative'] }))
+    expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1)
+    controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' })
+    rerender()
+    expect((view.getByRole('button', { name: en['presented.opening'] }) as HTMLButtonElement).disabled).toBe(true)
+    controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'error' })
+    rerender()
+    expect(view.getByRole('button', { name: en['diff.openNativeError'] })).toBeTruthy()
+    controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' })
+    rerender()
+    expect(view.queryByRole('button')).toBeNull()
+    controller.state.set({})
+    controller.host.set({ name: 'server', available: false, fileManager: null })
+    rerender()
+    expect(view.queryByRole('button')).toBeNull()
+    controller.host.set('error')
+    rerender()
+    expect(view.queryByRole('button')).toBeNull()
+  })
+
+  it('refuses an address it did not mint', () => {
+    const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    expect(() => mount(undefined, undefined, en, 'dsh-resource://file/session/viewed/a.ts')).toThrow('not a comparison address')
+    error.mockRestore()
+  })
+})

+ 3 - 0
packages/client/ui-deliverables/tsconfig.client.json

@@ -39,6 +39,9 @@
     {
       "path": "../ui-primitives"
     },
+    {
+      "path": "../ui-sidebar-right"
+    },
     {
       "path": "../ui-slots"
     },

+ 2 - 2
packages/deliverables/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/deliverables/README.md
-README.md: 92b9dfa95c4b5ded19fa2753d51551bafbd03fed
-README.zh.md: ff8e0fe0eba4ed02504e30765ede58c95b78daa0
+README.md: 3666cbbbe1695a95fe33497c22dd94f502a7f292
+README.zh.md: 72dbf338f8758ed251287461d13b18df34dff454

+ 2 - 2
packages/deliverables/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-The deliverables family records what a turn hands to the user as durable Session events that only clients read: the `present` tool declares final files the model delivered, and the workspace-changes recorder captures the files a turn changed with their line counts from git snapshots. The Web [deliverables plugin](../client/ui-deliverables/README.md) renders both at the end of a turn. Choose this family for a product that shows delivered files and per-turn changes; `present` needs `ctx.tools` and `ctx.fs`, the recorder needs `ctx.subprocess` and a git executable.
+The deliverables family records what a turn hands to the user as durable Session events that only clients read: the `present` tool declares final files the model delivered, and the workspace-changes recorder captures the files a turn changed with their line counts from git snapshots and whole-file captures, and serves each file's comparison. The Web [deliverables plugin](../client/ui-deliverables/README.md) renders both at the end of a turn. Choose this family for a product that shows delivered files and per-turn changes; `present` needs `ctx.tools` and `ctx.fs`, the recorder needs `ctx.subprocess` and a git executable.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ The deliverables family records what a turn hands to the user as durable Session
 | Package | Role | ctx key |
 |---|---|---|
 | [`tool-present`](tool-present/README.md) | Declares existing files as final deliverables through the `present` tool | registers on `ctx.tools` |
-| [`workspace-changes`](workspace-changes/README.md) | Summarizes each top-level turn's changed files from git working-tree snapshots | provides `ctx.workspaceChanges`; listens to `session/event`, appends `workspace/changes` |
+| [`workspace-changes`](workspace-changes/README.md) | Summarizes each top-level turn's changed files from git working-tree snapshots and whole-file captures, and serves their comparisons | provides `ctx.workspaceChanges`; listens to `session/event`, appends `workspace/changes` |
 
 -----
 

+ 2 - 2
packages/deliverables/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-group"
 
 ## 概述
 
-deliverables 系列把一轮交给用户的内容记录为只有客户端读取的持久 Session 事件:`present` 工具声明模型交付的最终文件,workspace-changes 记录器用 git 快照捕获一轮改动的文件及其行数。Web 的[交付插件](../client/ui-deliverables/README.zh.md)在轮次末尾渲染两者。需要展示交付文件和每轮改动的产品选择本系列;`present` 需要 `ctx.tools` 与 `ctx.fs`,记录器需要 `ctx.subprocess` 和 git 可执行文件。
+deliverables 系列把一轮交给用户的内容记录为只有客户端读取的持久 Session 事件:`present` 工具声明模型交付的最终文件,workspace-changes 记录器用 git 快照和整文件捕获记录一轮改动的文件及其行数,并提供每个文件的对比。Web 的[交付插件](../client/ui-deliverables/README.zh.md)在轮次末尾渲染两者。需要展示交付文件和每轮改动的产品选择本系列;`present` 需要 `ctx.tools` 与 `ctx.fs`,记录器需要 `ctx.subprocess` 和 git 可执行文件。
 
 ## 目录
 
@@ -25,7 +25,7 @@ deliverables 系列把一轮交给用户的内容记录为只有客户端读取
 | 包 | 角色 | ctx key |
 |---|---|---|
 | [`tool-present`](tool-present/README.zh.md) | 通过 `present` 工具把已有文件声明为最终交付物 | 注册到 `ctx.tools` |
-| [`workspace-changes`](workspace-changes/README.zh.md) | 用 git 工作树快照汇总每个顶层轮次改动的文件 | 提供 `ctx.workspaceChanges`;监听 `session/event`,追加 `workspace/changes` |
+| [`workspace-changes`](workspace-changes/README.zh.md) | 用 git 工作树快照和整文件捕获汇总每个顶层轮次改动的文件,并提供其对比 | 提供 `ctx.workspaceChanges`;监听 `session/event`,追加 `workspace/changes` |
 
 -----
 

+ 2 - 2
packages/deliverables/workspace-changes/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/deliverables/workspace-changes/README.md
-README.md: 5c304e3112c4d58a5baec54547e7341fde8e1dec
-README.zh.md: 428c3ce3d912cc17018cc39f552f1748b5ae56ff
+README.md: 80983656ac679202695abeff8a505e549785f4d2
+README.zh.md: dfe9e82abd13d408426cb8f0800dccbd25073c0c

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 6 - 5
packages/deliverables/workspace-changes/README.md


+ 11 - 8
packages/deliverables/workspace-changes/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "用 git 工作树快照汇总每个顶层轮次改动的文件,以 workspace/changes Session 事件宣告,并在 Session 存活期间提供摘要;配置、仓库要求与覆盖规则。"
+description: "用 git 工作树快照和文件工具编辑前后的整文件捕获汇总每个顶层轮次改动的文件,以 workspace/changes Session 事件宣告,并在 Session 存活期间提供摘要和逐文件对比;配置、仓库要求与覆盖规则。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本插件汇总每个顶层轮次改动了哪些文件、每个文件的增删行数:比较轮次开始和结束时的 git 工作树快照,再补上 git 覆盖不到的文件工具编辑。不在 git 仓库内或没有 git 时,摘要只列文件工具的编辑。Session 日志只收到一条写明轮号的 `workspace/changes` 事件;摘要留在 Host 上,通过 `workspaceChanges` 服务提供,直到 Session 释放。Web 的改动文件卡片渲染它;模型看不到它
+本插件汇总每个顶层轮次改动了哪些文件、每个文件的行数,并提供每个所列文件在轮次开始与结束时的内容对比。比较轮次开始和结束时的 git 工作树快照;文件工具编辑的每个文件在首次编辑之前和轮次结束时各复制一份整文件,以此覆盖 git 覆盖不到的文件。没有仓库或没有 git 时,只列文件工具的编辑。Session 日志只收到一条写明轮号的 `workspace/changes` 事件;摘要和对比留在 Host 上,直到 Session 释放。Web 的改动文件卡片渲染它
 
 ## 目录
 
@@ -38,12 +38,14 @@ kind: "package-reference"
 | `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 |
 | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 |
 | `maxFiles` | `500` | 单份摘要携带的最大文件数;`total` 仍报告完整数量 |
+| `maxFileBytes` | `2097152` | 文件在文件工具编辑前后被捕获、或从快照读出做对比所允许的最大字节数;更大的文件只列出,不带行数和对比 |
+| `diffTimeoutMs` | `100` | 逐行对比允许运行的毫秒数,超时后退化为整文件替换 |
 
 有工作目录且不是子代理来源的 Session 都会被记录;子代理 Session 不记录。快照通过私有 index 写入 Session 自己拥有的临时对象目录,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。Session 释放时删除该目录。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。不在任何 git 仓库内的工作目录不做快照。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——同样定位不到仓库,插件记录一次日志。两种情况下摘要都只列下文所述的文件工具编辑,并以工作目录作为工作区;shell 的改动不会出现。
 
-文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入,结果没有持久化 hunk 时则取调用自身的参数,也就是新建文件的 `write` 和 `str_replace_editor` 的每一种修改:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。
+在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把该路径上的文件复制到 Session 的临时目录,每轮每个路径只复制第一次,轮次结束时再复制一次;副本按其字节的 SHA-1 命名,相同内容只存一份。这一步不需要 git。快照覆盖到的路径保留 git 的行数;其余路径由副本提供,也就是匹配忽略模式的文件、仓库之外的文件,以及没有快照时的每一次文件工具编辑,行数来自两份副本的逐行对比,因此同一文件的重复编辑只计一次,文件工具编辑之后的 shell 改动也包含在内。轮次结束时内容没有变化的路径不会列出。超过 `maxFileBytes` 的副本不会保存:该文件列出时带 `oversized`,没有行数。`/tmp` 与平台临时目录下的文件被排除,除非它们位于仓库内。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。
 
-每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。`workspace/changes` 事件只携带轮号;`ctx.workspaceChanges.summary(sessionId, seq)` 返回该序号的事件宣告的摘要,Session 已释放或本 Host 进程从未记录时返回 undefined。因此 Host 重启后重新打开的对话,先前轮次没有卡片。
+每个文件携带持久的 `path`——位于工作目录内时为相对路径,否则为绝对路径——以及用于排序和标签的 `display` 路径:相对路径,仓库内位于工作目录之上的文件为 `../` 路径,家目录下的文件为 `~` 路径,其余为绝对路径。文件按 `display` 的码元顺序排序,因此上级路径和绝对路径排在工作目录自身文件之前。`workspace/changes` 事件只携带轮号;`ctx.workspaceChanges.summary(sessionId, seq)` 返回该序号的事件宣告的摘要,Session 已释放或本 Host 进程从未记录时返回 undefined。`ctx.workspaceChanges.diff(sessionId, seq, index, signal)` 对比该下标所列的文件:从两棵快照树或两份副本得出带三行上下文的 hunk;git 报告为二进制或某一侧含 NUL 字节时返回 `binary`;某一侧超过 `maxFileBytes` 时返回 `oversized`。逐行对比运行超过 `diffTimeoutMs` 时退化为一个替换全部行的 hunk,并标记 `coarse`。因此 Host 重启后重新打开的对话,先前轮次没有卡片也没有对比
 
 -----
 
@@ -53,11 +55,11 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库并创建 Session 的临时对象目录,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向临时目录、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/call` 事件保留每个修改调用由参数推出的 hunk,`tool/result` 事件收集持久化的 hunk,后者优先。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`、追加事件,以及按事件序号保存摘要。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。
+每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库并创建 Session 的临时对象目录,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向临时目录、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生;同一步骤还排入对 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用所指路径的整文件捕获,因此副本先于编辑,`tool/result` 事件只标记本轮有结果需要记录。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内已捕获路径的 `check-ignore`、对每个未覆盖路径的第二次复制与逐行对比、追加事件,以及按事件序号保存摘要和每个所列文件两侧的内容来源,即快照树中的路径或一份副本。对比在被请求时计算:`ls-tree -l` 定位快照一侧并取其大小,`cat-file blob` 在 `maxFileBytes` 之内读出,副本从磁盘读取,两侧随后走同一个带超时的逐行对比。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。
 
 git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作、忘记摘要并删除临时目录。
 
-**运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,记录器在其 Session 存活期间同时拥有摘要与快照树;没有独立观察会与它们分歧。
+**运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,记录器在其 Session 存活期间同时拥有摘要、快照树与捕获的副本;没有独立观察会与它们分歧。
 
 </details>
 
@@ -83,13 +85,14 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- 摘要与快照树只在本 Host 进程内随其 Session 存活;Host 重启后重新打开的对话,先前轮次没有卡片。这是既定行为:Host 已经打不开内容的卡片不显示。
+- 摘要、快照树与捕获的副本只在本 Host 进程内随其 Session 存活;Host 重启后重新打开的对话,先前轮次没有卡片也没有对比。这是既定行为:Host 已经打不开内容的卡片不显示。
 - 有两个 git 功能在快照期间仍会写入仓库自己的 git 目录:`core.splitIndex` 会写 `sharedindex.*` 文件,git-lfs 会对改动文件运行 clean 过滤器并把对象存到 `.git/lfs` 下。
 - 需要 git 2.13 或更高版本以支持 `rev-parse --absolute-git-dir`;不支持的仓库格式或其他 git 失败会带着警告放弃本轮,而不是被当成普通目录。
 - Session 的首次快照会把工作树里所有未跟踪且未被忽略的文件写进 Session 的临时目录;没有 `.gitignore` 却带着大体积构建产物的仓库,在 Session 释放前会占用同等的临时空间。
 - 用户在轮次进行中自己做的编辑会被算到该轮。
 - 不在任何 git 仓库内的工作目录只列文件工具的编辑,卡片里因此没有 shell 改动;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。
-- 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。
+- 快照覆盖范围之外只捕获文件工具点名的路径:那里只被 shell 命令改动的文件不会出现,在首次文件工具调用之前被两者都改过的文件从该调用起开始对比。
+- 每次文件工具编辑都会把整个文件复制一次,每轮每个路径一次,上限 `maxFileBytes`,即使快照也覆盖该路径;副本随 Session 的临时目录一起删除。
 - Windows 路径在 `path` 中保留原生分隔符;`display` 始终用斜杠分隔。
 
 <a id="dev-note"></a>

+ 88 - 0
packages/deliverables/workspace-changes/src/capture.ts

@@ -0,0 +1,88 @@
+/**
+ * Whole-file captures around file-tool edits: the content of a path before
+ * the turn's first mutation of it and at turn end, stored content-addressed
+ * under the Session's temporary directory so both sides of a comparison
+ * survive later edits without depending on git.
+ */
+import { createHash } from 'node:crypto'
+import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+
+/** Bytes git inspects for a NUL byte before treating content as binary. */
+const BINARY_PROBE_BYTES = 8000
+
+/** One captured side of a path. */
+export type Capture =
+  /** No file at the path. */
+  | { kind: 'absent' }
+  /** A regular file larger than the configured cap; its content is not stored. */
+  | { kind: 'oversized' }
+  /** A stored copy, named by the SHA-1 of its bytes. */
+  | { kind: 'file'; file: string; binary: boolean }
+
+/** Whether a filesystem error names a missing path. */
+function isMissing(error: unknown): boolean {
+  return typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'ENOENT'
+}
+
+/**
+ * Store the current content of one path.
+ * @param absolute - canonical absolute path of the file.
+ * @param directory - directory holding content-addressed copies; created when missing.
+ * @param maxBytes - inclusive byte cap on a stored copy.
+ * @returns the capture, or undefined for a path that is neither absent nor a regular file.
+ */
+export async function captureFile(absolute: string, directory: string, maxBytes: number): Promise<Capture | undefined> {
+  let size: number
+  try {
+    const info = await stat(absolute)
+    if (!info.isFile()) return undefined
+    size = info.size
+  } catch (error: unknown) {
+    if (isMissing(error)) return { kind: 'absent' }
+    throw error
+  }
+  if (size > maxBytes) return { kind: 'oversized' }
+  const bytes = await readFile(absolute)
+  if (bytes.byteLength > maxBytes) return { kind: 'oversized' }
+  const file = join(directory, createHash('sha1').update(bytes).digest('hex'))
+  await mkdir(directory, { recursive: true })
+  // Identical content across paths and turns shares one copy; `wx` keeps an existing copy as is.
+  await writeFile(file, bytes, { flag: 'wx' }).catch((error: unknown) => {
+    if ((error as { code?: unknown }).code !== 'EEXIST') throw error
+  })
+  return { kind: 'file', file, binary: bytes.subarray(0, BINARY_PROBE_BYTES).includes(0) }
+}
+
+/** Whether two captures hold the same content. */
+export function sameCapture(a: Capture, b: Capture): boolean {
+  return a.kind === b.kind && (a.kind !== 'file' || b.kind !== 'file' || a.file === b.file)
+}
+
+/** A non-blank string, or undefined. */
+function text(value: unknown): string | undefined {
+  return typeof value === 'string' && value.trim() !== '' ? value : undefined
+}
+
+/**
+ * The path a first-party file-tool call is about to mutate: `write`, `edit`,
+ * and the mutating `str_replace_editor` commands. Other tools, reads, and
+ * incomplete arguments yield undefined.
+ * @param name - wire tool name.
+ * @param args - parsed call arguments.
+ * @returns the model-facing path, or undefined.
+ */
+export function mutationPath(name: string, args: unknown): string | undefined {
+  if (typeof args !== 'object' || args === null || Array.isArray(args)) return undefined
+  const record = args as Record<string, unknown>
+  switch (name) {
+    case 'write':
+      return typeof record.content === 'string' ? text(record.file_path) : undefined
+    case 'edit':
+      return typeof record.old_string === 'string' && typeof record.new_string === 'string' ? text(record.file_path) : undefined
+    case 'str_replace_editor':
+      return record.command === 'create' || record.command === 'str_replace' || record.command === 'insert' ? text(record.path) : undefined
+    default:
+      return undefined
+  }
+}

+ 66 - 0
packages/deliverables/workspace-changes/src/compare.ts

@@ -0,0 +1,66 @@
+/** Line comparison of two whole-file texts, bounded by a timeout that degrades to whole-file replacement. */
+import { structuredPatch } from 'diff'
+import type { WorkspaceDiffHunk } from './types.ts'
+
+/** Context lines around each change, the unified-diff default. */
+const CONTEXT_LINES = 3
+
+/** Hunks, whether the timeout degraded them, and the changed-line totals they carry. */
+export interface Comparison {
+  hunks: WorkspaceDiffHunk[]
+  coarse: boolean
+  added: number
+  deleted: number
+}
+
+/**
+ * A side's text with every line terminated, so the last line compares by
+ * content alone and empty text reads as no lines rather than one empty line.
+ */
+function terminated(text: string): string {
+  return text === '' || text.endsWith('\n') ? text : `${text}\n`
+}
+
+/** Content lines of a terminated text; empty text is zero lines. */
+function lines(text: string): string[] {
+  return text === '' ? [] : text.slice(0, -1).split('\n')
+}
+
+/**
+ * Compare two texts line by line. A side that is null means the file did not
+ * exist. A comparison exceeding `timeoutMs` yields one hunk that deletes every
+ * old line and adds every new line.
+ * @param before - turn-start text, or null.
+ * @param after - turn-end text, or null.
+ * @param timeoutMs - milliseconds the line comparison may run.
+ * @returns hunks and totals; no hunks when both sides hold the same lines.
+ */
+export function compareText(before: string | null, after: string | null, timeoutMs: number): Comparison {
+  const oldText = terminated(before ?? '')
+  const newText = terminated(after ?? '')
+  const patch = structuredPatch('', '', oldText, newText, undefined, undefined, { context: CONTEXT_LINES, timeout: timeoutMs })
+  let hunks: WorkspaceDiffHunk[]
+  let coarse = false
+  if (patch === undefined) {
+    coarse = true
+    const oldLines = lines(oldText)
+    const newLines = lines(newText)
+    hunks = [{
+      oldStart: 1, oldLines: oldLines.length,
+      newStart: 1, newLines: newLines.length,
+      lines: [...oldLines.map(line => `-${line}`), ...newLines.map(line => `+${line}`)],
+    }]
+  } else {
+    hunks = patch.hunks.map(({ oldStart, oldLines, newStart, newLines, lines: body }) =>
+      ({ oldStart, oldLines, newStart, newLines, lines: body }))
+  }
+  let added = 0
+  let deleted = 0
+  for (const hunk of hunks) {
+    for (const line of hunk.lines) {
+      if (line.startsWith('+')) added += 1
+      else if (line.startsWith('-')) deleted += 1
+    }
+  }
+  return { hunks, coarse, added, deleted }
+}

+ 46 - 1
packages/deliverables/workspace-changes/src/git.ts

@@ -24,6 +24,8 @@ export interface GitRunOptions {
   cwd: string
   env?: Readonly<Record<string, string>> | undefined
   stdin?: string | undefined
+  /** In-memory stdout cap for this command, replacing the runner's `outputMaxBytes`. */
+  maxBytes?: number | undefined
   signal: AbortSignal
 }
 
@@ -58,7 +60,7 @@ export class GitRunner {
       cwd: options.cwd,
       stdio: {
         stdin: options.stdin === undefined ? 'ignore' : { data: options.stdin },
-        stdout: { maxBytes: this.limits.outputMaxBytes },
+        stdout: { maxBytes: options.maxBytes ?? this.limits.outputMaxBytes },
         stderr: { maxBytes: STDERR_TAIL_BYTES },
       },
       graceMs: TERMINATE_GRACE_MS,
@@ -168,6 +170,49 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign
   }
 }
 
+/** A blob one snapshot tree holds at a path. */
+export interface TreeBlob {
+  oid: string
+  /** Object size in bytes. */
+  size: number
+}
+
+/**
+ * The blob a snapshot tree holds at one path.
+ * @param git - command runner.
+ * @param workspace - addressed repository.
+ * @param tree - snapshot tree id.
+ * @param path - slash-separated path relative to the repository root.
+ * @param signal - cancellation.
+ * @returns the blob, or null when the tree holds nothing at the path or holds a gitlink or tree there.
+ */
+export async function treeBlob(
+  git: GitRunner, workspace: GitWorkspace, tree: string, path: string, signal: AbortSignal,
+): Promise<TreeBlob | null> {
+  const result = ok(await git.run(['ls-tree', '-z', '-l', tree, '--', path], { cwd: workspace.root, env: workspace.env, signal }), 'git ls-tree')
+  const entry = result.stdout.split('\0')[0] ?? ''
+  const match = /^\d+ (\S+) ([0-9a-f]+) +(\d+)\t/.exec(entry)
+  if (match === null || match[1] !== 'blob') return null
+  return { oid: match[2] as string, size: Number(match[3]) }
+}
+
+/**
+ * The text of one blob.
+ * @param git - command runner.
+ * @param workspace - addressed repository.
+ * @param oid - blob id.
+ * @param maxBytes - inclusive byte cap; a larger blob throws.
+ * @param signal - cancellation.
+ * @returns the blob decoded as UTF-8.
+ */
+export async function blobText(
+  git: GitRunner, workspace: GitWorkspace, oid: string, maxBytes: number, signal: AbortSignal,
+): Promise<string> {
+  const result = ok(await git.run(['cat-file', 'blob', oid], { cwd: workspace.root, env: workspace.env, maxBytes, signal }), 'git cat-file')
+  if (result.truncated) throw new Error(`blob ${oid} exceeds ${maxBytes} bytes`)
+  return result.stdout
+}
+
 /**
  * Per-file line counts between two snapshot trees, with renames detected.
  * @param git - command runner.

+ 35 - 14
packages/deliverables/workspace-changes/src/index.ts

@@ -1,10 +1,12 @@
 /**
  * Summarizes the files each top-level turn changed from git working-tree
- * snapshots taken at turn start and turn end plus the hunks file tools persist
- * for paths git does not cover. Each summary is announced by a `workspace/changes`
- * Session event that carries only the turn number and is served through the
- * `workspaceChanges` service until the Session is disposed. Outside a git
- * repository, or without git, the summary lists file-tool edits only.
+ * snapshots taken at turn start and turn end, plus whole-file captures taken
+ * around each file-tool edit for paths git does not cover, and serves each
+ * listed file's before-and-after comparison on demand. Each summary is
+ * announced by a `workspace/changes` Session event that carries only the turn
+ * number; summaries and comparisons are served through the `workspaceChanges`
+ * service until the Session is disposed. Outside a git repository, or without
+ * git, the summary lists file-tool edits only.
  */
 import { homedir, tmpdir } from 'node:os'
 import type { Context } from '@deepseek-ai/cordis'
@@ -17,7 +19,9 @@ import { GitRunner } from './git.ts'
 import { TurnRecorder } from './recorder.ts'
 import type { WorkspaceChanges } from './types.ts'
 
-export type { WorkspaceChangedFile, WorkspaceChanges, WorkspaceChangesSummary } from './types.ts'
+export type {
+  WorkspaceChangedFile, WorkspaceChanges, WorkspaceChangesSummary, WorkspaceDiffHunk, WorkspaceFileDiff,
+} from './types.ts'
 
 /** Stable Loader identity. */
 export const name = 'workspace-changes'
@@ -25,7 +29,7 @@ export const name = 'workspace-changes'
 /** Services used to run git and observe turns. */
 export const inject = ['subprocess']
 
-/** Snapshot bounds. Invalid values fail plugin load. */
+/** Snapshot, capture, and comparison bounds. Invalid values fail plugin load. */
 export interface Config {
   /** Milliseconds one git command may run before the turn's record is abandoned. */
   timeoutMs: number
@@ -33,6 +37,13 @@ export interface Config {
   outputMaxBytes: number
   /** Maximum files carried by one summary; `total` still reports the complete count. */
   maxFiles: number
+  /**
+   * Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison;
+   * a larger file is listed without counts or comparison.
+   */
+  maxFileBytes: number
+  /** Milliseconds a line comparison may run before it degrades to whole-file replacement. */
+  diffTimeoutMs: number
 }
 
 /** Schemastery validation for {@link Config}. */
@@ -40,6 +51,8 @@ export const Config: z<Config> = z.object({
   timeoutMs: z.number().default(30_000),
   outputMaxBytes: z.number().default(8 * 1024 * 1024),
   maxFiles: z.number().default(500),
+  maxFileBytes: z.number().default(2 * 1024 * 1024),
+  diffTimeoutMs: z.number().default(100),
 })
 
 function eligible(session: Session): string | undefined {
@@ -72,15 +85,16 @@ async function resolveGit(ctx: Context, signal: AbortSignal): Promise<string | n
 }
 
 /**
- * Observe top-level turns of every Session whose working directory lies in a
- * git repository, announce their change summaries, and serve them as
- * `workspaceChanges`.
+ * Observe top-level turns of every Session with a working directory, capture
+ * file-tool edits, announce change summaries, and serve them with their
+ * comparisons as `workspaceChanges`.
  * @param ctx - host context with `subprocess`.
  * @param config - validated bounds.
  */
 export function apply(ctx: Context, config: Config): void {
   for (const [field, value] of [
     ['timeoutMs', config.timeoutMs], ['outputMaxBytes', config.outputMaxBytes], ['maxFiles', config.maxFiles],
+    ['maxFileBytes', config.maxFileBytes], ['diffTimeoutMs', config.diffTimeoutMs],
   ] as const) {
     if (!Number.isSafeInteger(value) || value < 1) throw new Error(`workspace-changes requires a positive integer ${field}`)
   }
@@ -97,7 +111,10 @@ export function apply(ctx: Context, config: Config): void {
     lifetime.abort()
     await Promise.all([...recorders.keys()].map(forget))
   })
-  const service: WorkspaceChanges = { summary: (sessionId, seq) => byId.get(sessionId)?.summary(seq) }
+  const service: WorkspaceChanges = {
+    summary: (sessionId, seq) => byId.get(sessionId)?.summary(seq),
+    diff: (sessionId, seq, index, signal) => byId.get(sessionId)?.diff(seq, index, signal) ?? Promise.resolve(undefined),
+  }
   ctx.provide('workspaceChanges', service)
   let runner: Promise<GitRunner | null> | undefined
   const gitRunner = (): Promise<GitRunner | null> => {
@@ -115,6 +132,7 @@ export function apply(ctx: Context, config: Config): void {
     if (recorder === undefined) {
       recorder = new TurnRecorder(session, cwd, {
         git: gitRunner(), tempRoot: tmpdir(), maxFiles: config.maxFiles,
+        maxFileBytes: config.maxFileBytes, diffTimeoutMs: config.diffTimeoutMs,
         warn: (message) => { ctx.logger.warn(message) },
       })
       recorders.set(session, recorder)
@@ -128,8 +146,7 @@ export function apply(ctx: Context, config: Config): void {
       if (cwd !== undefined) recorderFor(session, cwd).start(event.data.turn)
       return
     }
-    if (event.type === 'tool/call') recorders.get(session)?.observeCall(event)
-    else if (event.type === 'tool/result') recorders.get(session)?.observe(event)
+    if (event.type === 'tool/result') recorders.get(session)?.observe(event)
     else if (event.type === 'turn/end') recorders.get(session)?.end(event.data.turn)
   })
   ctx.on('session/disposed', (session) => { void forget(session) })
@@ -138,7 +155,11 @@ export function apply(ctx: Context, config: Config): void {
   })
   ctx.on('tools/pre-execute', async (exec, next) => {
     const session = exec.agent?.session
-    if (session !== undefined) await recorders.get(session)?.settled()
+    const recorder = session === undefined ? undefined : recorders.get(session)
+    if (recorder !== undefined) {
+      recorder.capture(exec.name, exec.arguments)
+      await recorder.settled()
+    }
     return next()
   })
 }

+ 15 - 114
packages/deliverables/workspace-changes/src/numstat.ts

@@ -1,10 +1,10 @@
-/** Line-count derivation from `git diff-tree --numstat -z` output and from recorded file-tool hunks. */
-import { structuredPatch } from 'diff'
-import type { FileDiff } from '@deepseek-ai/dsh-tools'
+/** Parsing of `git diff-tree --numstat -z` output. */
 
-/** One `--numstat` record; `path` is slash-separated and relative to the repository root. */
+/** One `--numstat` record; paths are slash-separated and relative to the repository root. */
 export interface NumstatEntry {
   path: string
+  /** The path before a detected rename; absent for a file that kept its path. */
+  oldPath?: string
   added: number
   deleted: number
   binary: boolean
@@ -12,7 +12,7 @@ export interface NumstatEntry {
 
 /**
  * Parse NUL-terminated numstat records. A rename record carries an empty path
- * followed by the old and new paths; only the new path is kept.
+ * followed by the old and new paths.
  * @param output - complete stdout of `git diff-tree -r -M -z --numstat`.
  * @returns records in git's output order.
  * @throws when a record is malformed, which indicates truncated output.
@@ -32,120 +32,21 @@ export function parseNumstat(output: string): NumstatEntry[] {
     const deleted = record.slice(first + 1, second)
     const path = record.slice(second + 1)
     let target = path
+    let oldPath: string | undefined
     if (target === '') {
-      queue.shift()
+      oldPath = queue.shift()
       const renamed = queue.shift()
-      if (renamed === undefined) throw new Error('malformed numstat rename record')
+      if (oldPath === undefined || renamed === undefined) throw new Error('malformed numstat rename record')
       target = renamed
     }
     const binary = added === '-'
-    entries.push({ path: target, added: binary ? 0 : Number(added), deleted: binary ? 0 : Number(deleted), binary })
+    entries.push({
+      path: target,
+      ...oldPath === undefined ? {} : { oldPath },
+      added: binary ? 0 : Number(added),
+      deleted: binary ? 0 : Number(deleted),
+      binary,
+    })
   }
   return entries
 }
-
-/**
- * A side's text with every line terminated, so the last line compares by
- * content alone: the file tools persist hunk sides without a trailing newline,
- * and the same rule reads empty text as no lines rather than one empty line.
- */
-function terminated(text: string): string {
-  return text === '' || text.endsWith('\n') ? text : `${text}\n`
-}
-
-/**
- * Sum the added and deleted lines over recorded hunks. Context lines appear on
- * both sides of a hunk and cancel out; a hunk without prior text counts every
- * line as added; a trailing newline never counts as a changed line.
- * @param diffs - the applied hunks a file tool persisted with its result.
- * @returns line totals for one file.
- */
-export function hunkLineCounts(diffs: readonly FileDiff[]): { added: number; deleted: number } {
-  let added = 0
-  let deleted = 0
-  for (const diff of diffs) {
-    const patch = structuredPatch('', '', terminated(diff.oldText ?? ''), terminated(diff.newText))
-    for (const hunk of patch.hunks) {
-      for (const line of hunk.lines) {
-        if (line.startsWith('+')) added += 1
-        else if (line.startsWith('-')) deleted += 1
-      }
-    }
-  }
-  return { added, deleted }
-}
-
-/**
- * Narrow a tool result's opaque `meta` to the file-tool hunk list.
- * @param meta - persisted result metadata.
- * @returns the hunks; an empty list for a `write` that updated a file without changing it; undefined when the
- * metadata carries none, as `write` persists for a created file, so the call's arguments supply the content.
- */
-export function fileDiffsOf(meta: unknown): FileDiff[] | undefined {
-  if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
-  const { diffs, operation } = meta as Record<string, unknown>
-  if (!Array.isArray(diffs)) return undefined
-  if (diffs.length === 0) return operation === 'update' ? [] : undefined
-  const out: FileDiff[] = []
-  for (const value of diffs) {
-    if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
-    const { path, oldText, newText } = value as Record<string, unknown>
-    if (typeof path !== 'string' || (oldText !== null && typeof oldText !== 'string') || typeof newText !== 'string') return undefined
-    out.push({ path, oldText, newText })
-  }
-  return out
-}
-
-/** Non-blank string argument, or undefined. */
-function text(value: unknown): string | undefined {
-  return typeof value === 'string' && value.trim() !== '' ? value : undefined
-}
-
-/**
- * Hunks a first-party mutation call implies from its own arguments, for
- * results that persist no hunks: `write` creates, and every
- * `str_replace_editor` mutation. Malformed or non-mutating calls yield null.
- * @param name - wire tool name.
- * @param argumentsRaw - model-produced JSON arguments.
- * @returns the implied hunks, or null.
- */
-export function argumentHunks(name: string, argumentsRaw: string): FileDiff[] | null {
-  let args: unknown
-  try {
-    args = JSON.parse(argumentsRaw) as unknown
-  } catch {
-    return null
-  }
-  if (typeof args !== 'object' || args === null || Array.isArray(args)) return null
-  const record = args as Record<string, unknown>
-  switch (name) {
-    case 'write': {
-      const path = text(record.file_path)
-      return path !== undefined && typeof record.content === 'string' ? [{ path, oldText: null, newText: record.content }] : null
-    }
-    case 'edit': {
-      const path = text(record.file_path)
-      return path !== undefined && typeof record.old_string === 'string' && record.old_string !== '' && typeof record.new_string === 'string'
-        ? [{ path, oldText: record.old_string, newText: record.new_string }]
-        : null
-    }
-    case 'str_replace_editor': {
-      const path = text(record.path)
-      if (path === undefined) return null
-      switch (record.command) {
-        case 'create':
-          return typeof record.file_text === 'string' ? [{ path, oldText: null, newText: record.file_text }] : null
-        case 'str_replace':
-          return typeof record.old_str === 'string' && record.old_str !== '' && (record.new_str === undefined || typeof record.new_str === 'string')
-            ? [{ path, oldText: record.old_str, newText: record.new_str ?? '' }]
-            : null
-        case 'insert':
-          return typeof record.new_str === 'string' ? [{ path, oldText: null, newText: record.new_str }] : null
-        default:
-          return null
-      }
-    }
-    default:
-      return null
-  }
-}

+ 184 - 72
packages/deliverables/workspace-changes/src/recorder.ts

@@ -1,22 +1,28 @@
-/** Per-Session turn recorder: snapshot at turn start, diff at turn end, summary kept until disposal. */
-import { mkdtemp, realpath, rm } from 'node:fs/promises'
+/** Per-Session turn recorder: snapshots, captures around file-tool edits, the turn-end diff, and the records kept until disposal. */
+import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises'
 import { homedir } from 'node:os'
 import { join, relative, resolve } from 'node:path'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
-import type { FileDiff } from '@deepseek-ai/dsh-tools'
-import { diffTrees, gitlinkPaths, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace } from './git.ts'
-import { argumentHunks, fileDiffsOf, hunkLineCounts } from './numstat.ts'
+import { captureFile, mutationPath, sameCapture, type Capture } from './capture.ts'
+import { compareText } from './compare.ts'
+import {
+  blobText, diffTrees, gitlinkPaths, ignoredPaths, locateGitWorkspace, snapshotTree, treeBlob, type GitRunner, type GitWorkspace,
+} from './git.ts'
 import { canonicalPath, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, temporaryRoots, toPosix } from './paths.ts'
-import type { WorkspaceChangedFile, WorkspaceChangesSummary } from './types.ts'
+import type { WorkspaceChangedFile, WorkspaceChangesSummary, WorkspaceFileDiff } from './types.ts'
 
 /** Facts shared by every recorder of one plugin instance. */
 export interface RecorderEnvironment {
-  /** Resolves to the runner, or null when git is unavailable and no turn records anything. */
+  /** Resolves to the runner, or null when git is unavailable and no snapshot is taken. */
   git: Promise<GitRunner | null>
-  /** Directory that receives each Session's snapshot object directory. */
+  /** Directory that receives each Session's temporary directory. */
   tempRoot: string
   /** Maximum files carried by one summary. */
   maxFiles: number
+  /** Inclusive byte cap on a captured copy and on a snapshot blob read for a comparison. */
+  maxFileBytes: number
+  /** Milliseconds a line comparison may run before it degrades to whole-file replacement. */
+  diffTimeoutMs: number
   /** Failure reporter; a failed turn records nothing and the next turn retries. */
   warn: (message: string) => void
 }
@@ -40,18 +46,35 @@ interface Repository {
 /** The turn-start snapshot of a located repository. */
 interface Baseline extends Repository { tree: string }
 
+/** Where one side of a listed file's content lives. */
+type ContentSource =
+  /** A path in a snapshot tree; absence, size, and text are read from git when asked for. */
+  | { kind: 'snapshot'; repository: Repository; tree: string; path: string }
+  | Capture
+
+/** The two sides of one listed file, kept beside the served summary. */
+interface FileSources {
+  before: ContentSource
+  after: ContentSource
+  binary: boolean
+}
+
+/** A served summary with the content sources of its listed files, index-aligned with `summary.files`. */
+interface TurnRecord {
+  summary: WorkspaceChangesSummary
+  sources: FileSources[]
+}
+
 /** Everything one turn accumulates; a new turn gets a new object so queued work for an older turn keeps its own. */
 interface TurnState {
   readonly turn: number
   /**
    * The turn-start snapshot once it exists. `null` means no repository or no git, so the turn summarizes file-tool
-   * hunks only; `'failed'` means the repository exists but its snapshot failed, so the turn records nothing.
+   * captures only; `'failed'` means the repository exists but its snapshot failed, so the turn records nothing.
    */
   baseline: Baseline | null | 'failed'
-  /** Hunks derived from each mutation call's arguments, or null for a call that changes no file. */
-  readonly calls: Map<string, FileDiff[] | null>
-  /** File-tool hunks by their model-facing path; canonicalized when the record is built. */
-  readonly hunks: Map<string, FileDiff[]>
+  /** Content of each file-tool-mutated path before the turn's first mutation of it, by canonical absolute path. */
+  readonly captures: Map<string, Capture>
   lastToolResultSeq: number
   /** Log length when the latest record attempt started; `end()` skips a turn already attempted after its last tool result. */
   attemptedAfterSeq: number
@@ -60,17 +83,25 @@ interface TurnState {
 }
 
 function freshState(turn: number): TurnState {
-  return { turn, baseline: null, calls: new Map(), hunks: new Map(), lastToolResultSeq: -1, attemptedAfterSeq: -1, recordedAfterSeq: -1 }
+  return { turn, baseline: null, captures: new Map(), lastToolResultSeq: -1, attemptedAfterSeq: -1, recordedAfterSeq: -1 }
 }
 
+/** A listed file with the sources of its two sides. */
+interface Listed { file: WorkspaceChangedFile; sources: FileSources }
+
+/** A read side larger than the byte cap. */
+const OVERSIZED = Symbol('oversized')
+
 /**
- * Serializes one Session's git work: the turn-start snapshot, the turn-end
- * snapshot with its diff, and the appended `workspace/changes` event whose
- * summary this recorder keeps. Snapshot objects live in a temporary directory
- * owned by the recorder; disposal removes it together with the summaries.
- * Tool execution waits for pending work so a snapshot never races a mutation.
- * A working directory outside any repository, or a Host without git, gets no
- * snapshot; its summary lists the files the file tools changed.
+ * Serializes one Session's recording work: the turn-start snapshot, the
+ * whole-file capture before each file-tool mutation, the turn-end snapshot
+ * with its diff, and the appended `workspace/changes` event whose summary and
+ * comparisons this recorder keeps. Snapshot objects and captured copies live in
+ * a temporary directory owned by the recorder; disposal removes it together
+ * with the summaries. Tool execution waits for pending work so a snapshot or
+ * capture never races a mutation. A working directory outside any repository,
+ * or a Host without git, gets no snapshot; its summary lists the files the file
+ * tools changed.
  */
 export class TurnRecorder {
   private chain: Promise<void> = Promise.resolve()
@@ -80,10 +111,10 @@ export class TurnRecorder {
   private paths: Paths | undefined
   /** The located repository, reused across turns once found; null keeps retrying each turn. */
   private repository: Repository | null = null
-  /** Temporary directory holding this Session's snapshot objects and scratch indexes, created with the first located repository. */
-  private scratch: string | undefined
-  /** Summaries by the sequence of the event that announced them. */
-  private readonly summaries = new Map<number, WorkspaceChangesSummary>()
+  /** Temporary directory holding this Session's snapshot objects, scratch indexes, and captured copies. */
+  private scratch: Promise<string> | undefined
+  /** Records by the sequence of the event that announced them. */
+  private readonly records = new Map<number, TurnRecord>()
   private readonly lifetime = new AbortController()
 
   constructor(
@@ -115,30 +146,33 @@ export class TurnRecorder {
   }
 
   /**
-   * Remember a mutation call's arguments so a result without persisted hunks can still count its lines.
-   * @param event - the appended `tool/call` event.
+   * Queue the capture of the path a file tool is about to mutate, before the
+   * tool runs; only the turn's first mutation of a path captures it. Await
+   * {@link settled} afterwards so the tool cannot overtake the capture.
+   * @param name - wire tool name.
+   * @param args - parsed call arguments.
    */
-  observeCall(event: SessionEvent<'tool/call'>): void {
+  capture(name: string, args: unknown): void {
+    const path = mutationPath(name, args)
+    if (path === undefined) return
     const state = this.state
-    if (event.data.turn !== state.turn) return
-    state.calls.set(String(event.data.callId), argumentHunks(event.data.name, event.data.arguments))
+    void this.enqueue(async () => {
+      const paths = this.paths
+      if (paths === undefined) return
+      const absolute = await canonicalPath(resolve(paths.cwd, path))
+      if (state.captures.has(absolute)) return
+      const capture = await captureFile(absolute, join(await this.scratchDir(), 'captures'), this.env.maxFileBytes)
+      if (capture !== undefined) state.captures.set(absolute, capture)
+    })
   }
 
   /**
-   * Remember a settled tool result and the hunks it carries, falling back to the call's arguments.
+   * Remember a settled tool result, so a record after `turn/end` covers it.
    * @param event - the appended `tool/result` event.
    */
   observe(event: SessionEvent<'tool/result'>): void {
     const state = this.state
-    if (event.data.turn !== state.turn) return
-    state.lastToolResultSeq = event.seq
-    if (event.data.message.content[0].isError === true) return
-    const diffs = fileDiffsOf(event.data.meta) ?? state.calls.get(String(event.data.message.source.callId)) ?? []
-    for (const diff of diffs) {
-      const list = state.hunks.get(diff.path)
-      if (list === undefined) state.hunks.set(diff.path, [diff])
-      else list.push(diff)
-    }
+    if (event.data.turn === state.turn) state.lastToolResultSeq = event.seq
   }
 
   /**
@@ -162,7 +196,7 @@ export class TurnRecorder {
     void this.enqueue(signal => this.record(state, signal))
   }
 
-  /** Resolves once every queued snapshot and record has settled. */
+  /** Resolves once every queued snapshot, capture, and record has settled. */
   settled(): Promise<void> {
     return this.chain
   }
@@ -173,18 +207,47 @@ export class TurnRecorder {
    * @returns the summary, or undefined for a sequence this recorder did not announce.
    */
   summary(seq: number): WorkspaceChangesSummary | undefined {
-    return this.summaries.get(seq)
+    return this.records.get(seq)?.summary
+  }
+
+  /**
+   * Compare one listed file's contents at turn start and turn end.
+   * @param seq - the announcing event's sequence number.
+   * @param index - the file's index in the summary's `files`.
+   * @param signal - cancels the reads.
+   * @returns the comparison, or undefined for an unknown sequence or index, or once disposed.
+   * @throws when a read fails while the recorder lives.
+   */
+  async diff(seq: number, index: number, signal: AbortSignal): Promise<WorkspaceFileDiff | undefined> {
+    const record = this.records.get(seq)
+    const file = record?.summary.files[index]
+    const sources = record?.sources[index]
+    if (file === undefined || sources === undefined) return undefined
+    const { path, display } = file
+    if (sources.binary) return { kind: 'binary', path, display }
+    if (file.oversized === true) return { kind: 'oversized', path, display }
+    const combined = AbortSignal.any([signal, this.lifetime.signal])
+    try {
+      const [before, after] = await Promise.all([this.readSide(sources.before, combined), this.readSide(sources.after, combined)])
+      if (before === OVERSIZED || after === OVERSIZED) return { kind: 'oversized', path, display }
+      const { hunks, coarse } = compareText(before, after, this.env.diffTimeoutMs)
+      return { kind: 'text', path, display, before: before !== null, after: after !== null, hunks, coarse }
+    } catch (error: unknown) {
+      // Disposal removes the temporary directory under a running read; the Session is gone either way.
+      if (this.lifetime.signal.aborted) return undefined
+      throw error
+    }
   }
 
   /**
-   * Abort queued git work, forget every summary, and remove the snapshot objects.
+   * Abort queued work, forget every record, and remove the temporary directory.
    * @returns once the temporary directory is gone.
    */
   async dispose(): Promise<void> {
     this.lifetime.abort()
-    this.summaries.clear()
+    this.records.clear()
     await this.chain
-    if (this.scratch !== undefined) await rm(this.scratch, { recursive: true, force: true })
+    if (this.scratch !== undefined) await rm(await this.scratch, { recursive: true, force: true })
   }
 
   private enqueue(task: (signal: AbortSignal) => Promise<void>): Promise<void> {
@@ -205,20 +268,39 @@ export class TurnRecorder {
     if (!this.lifetime.signal.aborted) this.env.warn(`workspace-changes: ${String(error)}`)
   }
 
+  /** This Session's temporary directory, created on first use. */
+  private scratchDir(): Promise<string> {
+    this.scratch ??= mkdtemp(join(this.env.tempRoot, 'dsh-workspace-changes-'))
+    return this.scratch
+  }
+
   /** The repository enclosing the working directory, located once; null keeps retrying each turn. */
   private async locate(cwd: string, signal: AbortSignal): Promise<Repository | null> {
     if (this.repository !== null) return this.repository
     const git = await this.env.git
     if (git === null) return null
-    const workspace = await locateGitWorkspace(git, cwd, async () => {
-      this.scratch ??= await mkdtemp(join(this.env.tempRoot, 'dsh-workspace-changes-'))
-      return this.scratch
-    }, signal)
+    const workspace = await locateGitWorkspace(git, cwd, () => this.scratchDir(), signal)
     if (workspace === null) return null
     this.repository = { git, workspace }
     return this.repository
   }
 
+  /** One side's text, null for an absent file, or {@link OVERSIZED} for a side beyond the byte cap. */
+  private async readSide(source: ContentSource, signal: AbortSignal): Promise<string | null | typeof OVERSIZED> {
+    switch (source.kind) {
+      case 'absent': return null
+      case 'oversized': return OVERSIZED
+      case 'file': return readFile(source.file, 'utf8')
+      case 'snapshot': {
+        const { git, workspace } = source.repository
+        const blob = await treeBlob(git, workspace, source.tree, source.path, signal)
+        if (blob === null) return null
+        if (blob.size > this.env.maxFileBytes) return OVERSIZED
+        return blobText(git, workspace, blob.oid, this.env.maxFileBytes, signal)
+      }
+    }
+  }
+
   private async record(state: TurnState, signal: AbortSignal): Promise<void> {
     const paths = this.paths
     const { baseline } = state
@@ -226,24 +308,28 @@ export class TurnRecorder {
     state.attemptedAfterSeq = state.lastToolResultSeq
     // Without a snapshot the working directory itself bounds the workspace.
     const root = baseline?.workspace.root ?? paths.cwd
-    const files = new Map<string, WorkspaceChangedFile>()
+    const listed = new Map<string, Listed>()
     let snapshot: WorkspaceChangesSummary['snapshot']
     if (baseline !== null) {
       const after = await snapshotTree(baseline.git, baseline.workspace, signal)
       snapshot = { before: baseline.tree, after }
+      const repository: Repository = { git: baseline.git, workspace: baseline.workspace }
       for (const entry of await diffTrees(baseline.git, baseline.workspace, baseline.tree, after, signal)) {
         const absolute = resolve(root, entry.path)
-        files.set(absolute, changedFile(paths, root, absolute, entry))
+        listed.set(absolute, {
+          file: changedFile(paths, root, absolute, entry),
+          sources: {
+            before: { kind: 'snapshot', repository, tree: baseline.tree, path: entry.oldPath ?? entry.path },
+            after: { kind: 'snapshot', repository, tree: after, path: entry.path },
+            binary: entry.binary,
+          },
+        })
       }
     }
-    // File-tool hunks by canonical absolute path, for the files snapshots do not cover.
-    const hunks = new Map<string, FileDiff[]>()
-    for (const [path, list] of state.hunks) {
-      const absolute = await canonicalPath(resolve(paths.cwd, path))
-      if (!files.has(absolute)) hunks.set(absolute, [...hunks.get(absolute) ?? [], ...list])
-    }
+    // Captured paths the snapshots do not cover are compared from their copies.
+    const captured = [...state.captures.keys()].filter(absolute => !listed.has(absolute))
     const workTreePath = (absolute: string): string => toPosix(relative(root, absolute))
-    let inWorkspace = [...hunks.keys()].filter(absolute => isInside(root, absolute))
+    let inWorkspace = captured.filter(absolute => isInside(root, absolute))
     if (baseline !== null && inWorkspace.length > 0) {
       // Nested repositories and submodules are gitlinks: their contents never enter the summary.
       const gitlinks = await gitlinkPaths(baseline.git, baseline.workspace, signal)
@@ -253,38 +339,64 @@ export class TurnRecorder {
     const uncoveredInWorkspace = baseline === null
       ? new Set(inWorkspace.map(workTreePath))
       : await ignoredPaths(baseline.git, baseline.workspace, inWorkspace.map(workTreePath), signal)
-    for (const [absolute, list] of hunks) {
+    for (const absolute of captured) {
       // Outside the workspace, scratch files under a temporary root stay out.
       const uncovered = isInside(root, absolute)
         ? uncoveredInWorkspace.has(workTreePath(absolute))
         : !isTemporaryPath(absolute, paths.temporaryRoots)
-      if (uncovered) files.set(absolute, changedFile(paths, root, absolute, { ...hunkLineCounts(list), binary: false }))
+      if (!uncovered) continue
+      const before = state.captures.get(absolute) as Capture
+      const after = await captureFile(absolute, join(await this.scratchDir(), 'captures'), this.env.maxFileBytes)
+      if (after === undefined || sameCapture(before, after)) continue
+      listed.set(absolute, {
+        file: changedFile(paths, root, absolute, await this.capturedCounts(before, after)),
+        sources: { before, after, binary: isBinary(before) || isBinary(after) },
+      })
     }
-    const sorted = [...files.values()].sort(compareDisplay)
+    const sorted = [...listed.values()].sort((a, b) => compareDisplay(a.file, b.file))
     // An empty list after an earlier in-turn record supersedes that record.
     if (sorted.length === 0 && state.recordedAfterSeq < 0) return
     const event = this.session.append('workspace/changes', { turn: state.turn })
-    this.summaries.set(event.seq, {
-      turn: state.turn,
-      cwd: this.cwd,
-      files: sorted.slice(0, this.env.maxFiles),
-      total: sorted.length,
-      added: sorted.reduce((sum, file) => sum + file.added, 0),
-      deleted: sorted.reduce((sum, file) => sum + file.deleted, 0),
-      ...snapshot === undefined ? {} : { snapshot },
+    const kept = sorted.slice(0, this.env.maxFiles)
+    this.records.set(event.seq, {
+      summary: {
+        turn: state.turn,
+        cwd: this.cwd,
+        files: kept.map(entry => entry.file),
+        total: sorted.length,
+        added: sorted.reduce((sum, entry) => sum + entry.file.added, 0),
+        deleted: sorted.reduce((sum, entry) => sum + entry.file.deleted, 0),
+        ...snapshot === undefined ? {} : { snapshot },
+      },
+      sources: kept.map(entry => entry.sources),
     })
     state.recordedAfterSeq = event.seq
   }
+
+  /** Line counts of a captured pair; none for a binary or oversized side. */
+  private async capturedCounts(before: Capture, after: Capture): Promise<Counts> {
+    if (before.kind === 'oversized' || after.kind === 'oversized') return { added: 0, deleted: 0, binary: false, oversized: true }
+    if (isBinary(before) || isBinary(after)) return { added: 0, deleted: 0, binary: true }
+    const text = async (side: Capture): Promise<string | null> => side.kind === 'file' ? readFile(side.file, 'utf8') : null
+    const { added, deleted } = compareText(await text(before), await text(after), this.env.diffTimeoutMs)
+    return { added, deleted, binary: false }
+  }
 }
 
-function changedFile(
-  { cwd, home }: Paths, root: string, absolute: string, counts: { added: number; deleted: number; binary: boolean },
-): WorkspaceChangedFile {
+/** Whether a captured side holds binary content. */
+function isBinary(capture: Capture): boolean {
+  return capture.kind === 'file' && capture.binary
+}
+
+interface Counts { added: number; deleted: number; binary: boolean; oversized?: boolean }
+
+function changedFile({ cwd, home }: Paths, root: string, absolute: string, counts: Counts): WorkspaceChangedFile {
   return {
     path: durablePathOf(absolute, cwd),
     display: displayPathOf(absolute, cwd, root, home),
     added: counts.added,
     deleted: counts.deleted,
     ...counts.binary ? { binary: true as const } : {},
+    ...counts.oversized === true ? { oversized: true as const } : {},
   }
 }

+ 55 - 7
packages/deliverables/workspace-changes/src/types.ts

@@ -1,7 +1,7 @@
-/** Per-turn workspace change summaries, the Session event that announces them, and the Host service that serves them. */
+/** Per-turn workspace change summaries, the Session event announcing them, and the Host service serving them with their comparisons. */
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
-/** One file changed during a turn, with line counts from git or from the recorded file-tool hunks. */
+/** One file changed during a turn, with line counts from git or from the whole-file captures around its file-tool edits. */
 export interface WorkspaceChangedFile {
   /** Path relative to the Session working directory, or an absolute Host path outside it. */
   path: string
@@ -11,12 +11,14 @@ export interface WorkspaceChangedFile {
    * directory, otherwise the absolute path. Always slash-separated.
    */
   display: string
-  /** Lines added; zero for a binary file. */
+  /** Lines added; zero for a binary or oversized file. */
   added: number
-  /** Lines deleted; zero for a binary file. */
+  /** Lines deleted; zero for a binary or oversized file. */
   deleted: number
-  /** Present when git reported the file as binary. */
+  /** Present when git reported the file as binary, or when a captured side holds a NUL byte. */
   binary?: true
+  /** Present when a captured side exceeded the plugin's `maxFileBytes`; the file is listed without counts or comparison. */
+  oversized?: true
 }
 
 /** Files changed during one top-level turn, kept on the Host until its Session is disposed. */
@@ -37,7 +39,43 @@ export interface WorkspaceChangesSummary {
   snapshot?: { before: string; after: string }
 }
 
-/** Serves the summaries the recorder keeps for live Sessions. */
+/** One unified-diff hunk with three context lines; every line keeps its `+`, `-`, or space prefix. */
+export interface WorkspaceDiffHunk {
+  /** First line of the hunk in the turn-start content, 1-based; a side without lines starts at 1 with zero lines. */
+  oldStart: number
+  /** Lines of the hunk taken from the turn-start content. */
+  oldLines: number
+  /** First line of the hunk in the turn-end content, 1-based; a side without lines starts at 1 with zero lines. */
+  newStart: number
+  /** Lines of the hunk taken from the turn-end content. */
+  newLines: number
+  /** Hunk body in order, each line prefixed with `+`, `-`, or a space. */
+  lines: string[]
+}
+
+/** The comparison of one listed file's turn-start and turn-end contents, computed when asked for. */
+export type WorkspaceFileDiff =
+  | {
+    kind: 'text'
+    /** The listed file's `path`. */
+    path: string
+    /** The listed file's `display`. */
+    display: string
+    /** Whether the file existed at turn start. */
+    before: boolean
+    /** Whether the file existed at turn end. */
+    after: boolean
+    /** Hunks in file order; empty when both sides hold the same lines. */
+    hunks: WorkspaceDiffHunk[]
+    /** True when the line comparison exceeded the plugin's `diffTimeoutMs` and every line is shown as replaced. */
+    coarse: boolean
+  }
+  /** A side git reported as binary or that holds a NUL byte; no lines are served. */
+  | { kind: 'binary'; path: string; display: string }
+  /** A side larger than the plugin's `maxFileBytes`; no lines are served. */
+  | { kind: 'oversized'; path: string; display: string }
+
+/** Serves the summaries and file comparisons the recorder keeps for live Sessions. */
 export interface WorkspaceChanges {
   /**
    * The summary announced by one `workspace/changes` event.
@@ -46,6 +84,16 @@ export interface WorkspaceChanges {
    * @returns the summary, or undefined once its Session was disposed or when this Host never recorded it.
    */
   summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined
+  /**
+   * Compare one listed file's contents at turn start and turn end.
+   * @param sessionId - the Session that appended the event.
+   * @param seq - the event's sequence number.
+   * @param index - the file's index in the summary's `files`.
+   * @param signal - cancels the reads.
+   * @returns the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index.
+   * @throws when a snapshot read fails for a live Session.
+   */
+  diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise<WorkspaceFileDiff | undefined>
 }
 
 declare module '@deepseek-ai/dsh-session/types' {
@@ -61,7 +109,7 @@ declare module '@deepseek-ai/dsh-session/types' {
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
-    /** Per-turn changed-file summaries of live Sessions. */
+    /** Per-turn changed-file summaries and comparisons of live Sessions. */
     workspaceChanges: WorkspaceChanges
   }
 }

+ 63 - 0
packages/deliverables/workspace-changes/tests/capture.spec.ts

@@ -0,0 +1,63 @@
+/** Whole-file captures and the mutation paths that trigger them. */
+import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import { captureFile, mutationPath, sameCapture } from '../src/capture.ts'
+import { scratchDir } from './support.ts'
+
+const cleanups: Array<() => Promise<unknown>> = []
+afterEach(async () => {
+  for (const cleanup of cleanups.reverse()) await cleanup()
+  cleanups.length = 0
+})
+
+describe('captureFile', () => {
+  it('stores text once by content, and classifies absent, oversized, binary, and non-regular paths', async () => {
+    const root = await scratchDir('dsh-capture-', cleanups)
+    const store = join(root, 'captures')
+    await writeFile(join(root, 'a.txt'), 'one\ntwo\n')
+    await writeFile(join(root, 'same.txt'), 'one\ntwo\n')
+    await writeFile(join(root, 'big.txt'), 'x'.repeat(17))
+    await writeFile(join(root, 'bin.dat'), Uint8Array.of(65, 0, 66))
+    await mkdir(join(root, 'dir'))
+    const a = await captureFile(join(root, 'a.txt'), store, 16)
+    const same = await captureFile(join(root, 'same.txt'), store, 16)
+    expect(a).toMatchObject({ kind: 'file', binary: false })
+    expect(same).toEqual(a)
+    if (a?.kind !== 'file' || same === undefined) throw new Error('expected a stored copy')
+    expect(await readFile(a.file, 'utf8')).toBe('one\ntwo\n')
+    expect(await readdir(store)).toHaveLength(1)
+    expect(await captureFile(join(root, 'big.txt'), store, 16)).toEqual({ kind: 'oversized' })
+    expect(await captureFile(join(root, 'missing.txt'), store, 16)).toEqual({ kind: 'absent' })
+    expect(await captureFile(join(root, 'bin.dat'), store, 16)).toMatchObject({ kind: 'file', binary: true })
+    expect(await captureFile(join(root, 'dir'), store, 16)).toBeUndefined()
+    // Content that grew past the cap between the size check and the read is still refused.
+    await writeFile(join(root, 'grow.txt'), 'y'.repeat(16))
+    expect(await captureFile(join(root, 'grow.txt'), store, 16)).toMatchObject({ kind: 'file' })
+    expect(sameCapture({ kind: 'absent' }, { kind: 'absent' })).toBe(true)
+    expect(sameCapture({ kind: 'absent' }, { kind: 'oversized' })).toBe(false)
+    expect(sameCapture(a, same)).toBe(true)
+    expect(sameCapture(a, { kind: 'file', file: 'elsewhere', binary: false })).toBe(false)
+  })
+})
+
+describe('mutationPath', () => {
+  it('names the path of write, edit, and mutating editor calls', () => {
+    expect(mutationPath('write', { file_path: 'a.txt', content: 'x' })).toBe('a.txt')
+    expect(mutationPath('edit', { file_path: 'a.txt', old_string: 'x', new_string: 'y' })).toBe('a.txt')
+    expect(mutationPath('str_replace_editor', { command: 'create', path: 'b.txt', file_text: 'b' })).toBe('b.txt')
+    expect(mutationPath('str_replace_editor', { command: 'str_replace', path: 'b.txt', old_str: 'b' })).toBe('b.txt')
+    expect(mutationPath('str_replace_editor', { command: 'insert', path: 'b.txt', insert_line: 1, new_str: 'i' })).toBe('b.txt')
+  })
+
+  it('yields nothing for reads, unknown tools, and incomplete arguments', () => {
+    expect(mutationPath('write', undefined)).toBeUndefined()
+    expect(mutationPath('write', [])).toBeUndefined()
+    expect(mutationPath('read', { file_path: 'a.txt' })).toBeUndefined()
+    expect(mutationPath('write', { file_path: ' ', content: 'x' })).toBeUndefined()
+    expect(mutationPath('write', { file_path: 'a.txt' })).toBeUndefined()
+    expect(mutationPath('edit', { file_path: 'a.txt', old_string: 'x' })).toBeUndefined()
+    expect(mutationPath('str_replace_editor', { command: 'view', path: 'b.txt' })).toBeUndefined()
+    expect(mutationPath('str_replace_editor', { command: 'create', path: '' })).toBeUndefined()
+  })
+})

+ 39 - 0
packages/deliverables/workspace-changes/tests/compare.spec.ts

@@ -0,0 +1,39 @@
+/** Line comparison with its timeout degradation. */
+import { describe, expect, it } from 'vitest'
+import { compareText } from '../src/compare.ts'
+
+describe('compareText', () => {
+  it('yields unified hunks with context and counts only changed lines', () => {
+    const before = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'].join('\n')
+    const after = ['a', 'b', 'c', 'D', 'e', 'f', 'g', 'h', 'i', 'j', 'k'].join('\n')
+    const result = compareText(before, after, 100)
+    expect(result.coarse).toBe(false)
+    expect(result).toMatchObject({ added: 2, deleted: 1 })
+    // Changes whose context lines touch share one hunk.
+    expect(result.hunks).toEqual([
+      { oldStart: 1, oldLines: 10, newStart: 1, newLines: 11, lines: [' a', ' b', ' c', '-d', '+D', ' e', ' f', ' g', ' h', ' i', ' j', '+k'] },
+    ])
+    const far = compareText(`${before}\n${'z\n'.repeat(10)}end`, `${after}\n${'z\n'.repeat(10)}END`, 100)
+    expect(far.hunks.map(hunk => [hunk.oldStart, hunk.oldLines, hunk.newStart, hunk.newLines])).toEqual([[1, 13, 1, 14], [18, 4, 19, 4]])
+  })
+
+  it('treats a missing side as no lines and an unterminated last line by content alone', () => {
+    expect(compareText(null, 'x\ny\n', 100)).toMatchObject({ added: 2, deleted: 0, hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: 2 }] })
+    expect(compareText('x\n', null, 100)).toMatchObject({ added: 0, deleted: 1 })
+    expect(compareText('last', 'last\nadded', 100)).toMatchObject({ added: 1, deleted: 0 })
+    expect(compareText('same\n', 'same\n', 100)).toEqual({ hunks: [], coarse: false, added: 0, deleted: 0 })
+    expect(compareText(null, null, 100)).toEqual({ hunks: [], coarse: false, added: 0, deleted: 0 })
+  })
+
+  it('degrades to whole-file replacement once the timeout passes', () => {
+    const before = Array.from({ length: 4000 }, (_, i) => `old ${i}`).join('\n')
+    const after = Array.from({ length: 4000 }, (_, i) => `new ${i}`).join('\n')
+    const result = compareText(before, after, 1)
+    expect(result.coarse).toBe(true)
+    expect(result).toMatchObject({ added: 4000, deleted: 4000 })
+    expect(result.hunks).toHaveLength(1)
+    expect(result.hunks[0]).toMatchObject({ oldStart: 1, oldLines: 4000, newStart: 1, newLines: 4000 })
+    expect(result.hunks[0]!.lines[0]).toBe('-old 0')
+    expect(result.hunks[0]!.lines.at(-1)).toBe('+new 3999')
+  })
+})

+ 9 - 3
packages/deliverables/workspace-changes/tests/git.spec.ts

@@ -138,7 +138,7 @@ describe('TurnRecorder', () => {
     let release!: (runner: GitRunner | null) => void
     const gate = new Promise<GitRunner | null>((resolve) => { release = resolve })
     const tempRoot = await scratchDir('dsh-git-store-', cleanups)
-    const env = { git: gate, tempRoot, maxFiles: 10, warn: (m: string) => { warnings.push(m) } }
+    const env = { git: gate, tempRoot, maxFiles: 10, maxFileBytes: 1024, diffTimeoutMs: 100, warn: (m: string) => { warnings.push(m) } }
     const disposed = new TurnRecorder(session, cwd, env)
     disposed.start(1)
     await new Promise(resolve => setTimeout(resolve, 5))
@@ -161,7 +161,10 @@ describe('TurnRecorder', () => {
   it('removes its snapshot objects on disposal and never creates them outside a repository', async () => {
     const tempRoot = await scratchDir('dsh-git-store-', cleanups)
     const { ctx, git: runnerGit } = await runner()
-    const env = { git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, warn: (m: string) => { throw new Error(m) } }
+    const env = {
+      git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, maxFileBytes: 1024, diffTimeoutMs: 100,
+      warn: (m: string) => { throw new Error(m) },
+    }
     const plain = new TurnRecorder(ctx.sessions.create(SessionId('plain'), { meta: { cwd: tempRoot } }), tempRoot, env)
     plain.start(1)
     await plain.settled()
@@ -187,7 +190,10 @@ describe('TurnRecorder', () => {
     await mkdir(tempRoot)
     const { ctx, git: runnerGit } = await runner()
     const session = ctx.sessions.create(SessionId('tmp-in-tree'), { meta: { cwd } })
-    const env = { git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, warn: (m: string) => { throw new Error(m) } }
+    const env = {
+      git: Promise.resolve(runnerGit), tempRoot, maxFiles: 10, maxFileBytes: 1024, diffTimeoutMs: 100,
+      warn: (m: string) => { throw new Error(m) },
+    }
     const recorder = new TurnRecorder(session, cwd, env)
     startTurn(session, 1)
     recorder.start(1)

BIN
packages/deliverables/workspace-changes/tests/numstat.spec.ts


+ 132 - 43
packages/deliverables/workspace-changes/tests/plugin.spec.ts

@@ -1,14 +1,14 @@
-/** The plugin records each top-level turn's file changes from real git snapshots. */
-import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises'
+/** The plugin records each top-level turn's file changes from real git snapshots and whole-file captures, and serves their comparisons. */
+import { mkdir, mkdtemp, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
 import { homedir, tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-agent'
-import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
 import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
 import * as WorkspaceChanges from '../src/index.ts'
-import { changes, endTurn, git, scratchDir, settle, startTurn, toolCall } from './support.ts'
+import { changes, endTurn, git, mutate, scratchDir, settle, startTurn, toolCall } from './support.ts'
 
 const cleanups: Array<() => Promise<unknown>> = []
 afterEach(async () => {
@@ -17,6 +17,8 @@ afterEach(async () => {
   vi.restoreAllMocks()
 })
 
+const signal = new AbortController().signal
+
 async function boot(config: Partial<WorkspaceChanges.Config> = {}) {
   const ctx = new Context()
   cleanups.push(() => ctx.fiber.dispose())
@@ -43,6 +45,13 @@ async function repository(): Promise<string> {
   return cwd
 }
 
+/** The sequence of the latest announcement for one session. */
+function announcedSeq(session: Session): number {
+  const event = session.snapshotEvents().filter(event => event.type === 'workspace/changes').at(-1)
+  if (event === undefined) throw new Error('no workspace/changes announcement')
+  return event.seq
+}
+
 describe('workspace-changes in a repository', () => {
   it('records the turn’s own changes and excludes the user’s prior uncommitted work', async () => {
     const cwd = await repository()
@@ -54,26 +63,21 @@ describe('workspace-changes in a repository', () => {
     startTurn(session, 1)
     await settle(ctx, session)
 
-    await writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\nl4\n')
-    toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l2', newText: 'l2 model' }] } })
+    await mutate(ctx, session, 1, 'edit', { file_path: 'a.txt', old_string: 'l2', new_string: 'l2 model' },
+      () => writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\nl4\n'), { meta: { diffs: [{ path: 'a.txt', oldText: 'l2', newText: 'l2 model' }] } })
     await mkdir(join(cwd, 'sub', 'dir'), { recursive: true })
     await writeFile(join(cwd, 'sub', 'dir', 'c.txt'), 'c\n')
     await writeFile(join(cwd, 'new.txt'), 'n1\nn2\n')
     await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255))
     toolCall(session, 1, 'bash', { command: 'printf > files' })
-    await writeFile(join(cwd, '.env'), 'A=1\nB=2\n')
-    // A created file persists an empty hunk list; its arguments supply the content.
-    toolCall(session, 1, 'write', { file_path: '.env', content: 'A=1\n' }, { meta: { diffs: [] } })
-    toolCall(session, 1, 'edit', { file_path: '.env', old_string: 'A=1', new_string: 'A=1\nB=2' }, {
-      meta: { diffs: [{ path: '.env', oldText: 'A=1\n', newText: 'A=1\nB=2\n' }] },
-    })
-    toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, { meta: { diffs: [] } })
-    toolCall(session, 1, 'write', { file_path: 'failed.txt', content: 'x' }, { isError: true, meta: { diffs: [] } })
-    // The editor persists no hunks at all; its create arguments count, and the file may already be gone.
-    toolCall(session, 1, 'str_replace_editor', { command: 'create', path: '.env.gone', file_text: 'x' })
-    toolCall(session, 1, 'edit', { file_path: 'same.txt', old_string: 'same', new_string: 'same' }, {
-      meta: { diffs: [{ path: 'same.txt', oldText: 'same', newText: 'same' }] },
-    })
+    // An ignored file is captured before its first edit and read again at turn end; repeated edits count once.
+    await mutate(ctx, session, 1, 'write', { file_path: '.env', content: 'A=1\n' }, () => writeFile(join(cwd, '.env'), 'A=1\n'))
+    await mutate(ctx, session, 1, 'edit', { file_path: '.env', old_string: 'A=1', new_string: 'A=1\nB=2' }, () => writeFile(join(cwd, '.env'), 'A=1\nB=2\n'))
+    await mutate(ctx, session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, () => Promise.resolve())
+    await mutate(ctx, session, 1, 'write', { file_path: 'failed.txt', content: 'x' }, () => Promise.resolve(), { isError: true })
+    // A created ignored file that is gone again by turn end is not a change.
+    await mutate(ctx, session, 1, 'str_replace_editor', { command: 'create', path: '.env.gone', file_text: 'x' }, () => Promise.resolve())
+    await mutate(ctx, session, 1, 'edit', { file_path: 'same.txt', old_string: 'same', new_string: 'same' }, () => Promise.resolve())
     toolCall(session, 2, 'write', { file_path: 'other-turn' }, { meta: { diffs: [{ path: 'other.txt', oldText: null, newText: 'x' }] } })
     endTurn(session, 1)
     await settle(ctx, session)
@@ -82,12 +86,11 @@ describe('workspace-changes in a repository', () => {
     expect(events.map(event => event.data)).toEqual([{ turn: 1 }])
     const [recorded, ...rest] = changes(ctx, session)
     expect(rest).toEqual([])
-    expect(recorded).toMatchObject({ turn: 1, cwd, total: 6, added: 8, deleted: 1 })
+    expect(recorded).toMatchObject({ turn: 1, cwd, total: 5, added: 7, deleted: 1 })
     expect(recorded!.snapshot!.before).toMatch(/^[0-9a-f]{40,64}$/)
     expect(recorded!.snapshot!.after).toMatch(/^[0-9a-f]{40,64}$/)
     expect(recorded!.files).toEqual([
       { path: '.env', display: '.env', added: 2, deleted: 0 },
-      { path: '.env.gone', display: '.env.gone', added: 1, deleted: 0 },
       { path: 'a.txt', display: 'a.txt', added: 2, deleted: 1 },
       { path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true },
       { path: 'new.txt', display: 'new.txt', added: 2, deleted: 0 },
@@ -107,6 +110,51 @@ describe('workspace-changes in a repository', () => {
     expect(ctx.workspaceChanges.summary(session.id, seq)).toBeUndefined()
   })
 
+  it('serves each listed file’s comparison from the snapshots or the captured copies', async () => {
+    const cwd = await repository()
+    await writeFile(join(cwd, 'long.txt'), `${'0123456789'.repeat(10)}\n`)
+    git(cwd, 'add', '-A'); git(cwd, 'commit', '-q', '-m', 'long')
+    const { ctx } = await boot({ maxFileBytes: 64 })
+    const session = ctx.sessions.create(SessionId('compare'), { meta: { cwd } })
+    startTurn(session, 1)
+    await settle(ctx, session)
+    await writeFile(join(cwd, 'a.txt'), 'l1\nl2 model\nl3\n')
+    await writeFile(join(cwd, 'new.txt'), 'n1\n')
+    await rm(join(cwd, 'b.txt'))
+    await rename(join(cwd, 'same.txt'), join(cwd, 'moved.txt'))
+    await writeFile(join(cwd, 'long.txt'), `${'0123456789'.repeat(10)}\nmore\n`)
+    await writeFile(join(cwd, 'bin.dat'), Uint8Array.of(0, 1, 2, 255))
+    toolCall(session, 1, 'bash', { command: 'x' })
+    await mutate(ctx, session, 1, 'write', { file_path: '.env', content: 'A=1\nB=2\n' }, () => writeFile(join(cwd, '.env'), 'A=1\nB=2\n'))
+    endTurn(session, 1)
+    await settle(ctx, session)
+    const seq = announcedSeq(session)
+    const [recorded] = changes(ctx, session)
+    expect(recorded!.files.map(file => file.display)).toEqual(['.env', 'a.txt', 'b.txt', 'bin.dat', 'long.txt', 'moved.txt', 'new.txt'])
+    const diff = (index: number) => ctx.workspaceChanges.diff(session.id, seq, index, signal)
+    expect(await diff(0)).toEqual({
+      kind: 'text', path: '.env', display: '.env', before: false, after: true, coarse: false,
+      hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: 2, lines: ['+A=1', '+B=2'] }],
+    })
+    expect(await diff(1)).toEqual({
+      kind: 'text', path: 'a.txt', display: 'a.txt', before: true, after: true, coarse: false,
+      hunks: [{ oldStart: 1, oldLines: 3, newStart: 1, newLines: 3, lines: [' l1', '-l2', '+l2 model', ' l3'] }],
+    })
+    expect(await diff(2)).toMatchObject({ kind: 'text', path: 'b.txt', before: true, after: false, hunks: [{ lines: ['-x'] }] })
+    expect(await diff(3)).toEqual({ kind: 'binary', path: 'bin.dat', display: 'bin.dat' })
+    // A snapshot blob beyond the byte cap keeps its git counts but serves no lines.
+    expect(recorded!.files[4]).toMatchObject({ path: 'long.txt', added: 1, deleted: 0 })
+    expect(await diff(4)).toEqual({ kind: 'oversized', path: 'long.txt', display: 'long.txt' })
+    // A rename compares the old path's turn-start content with the new path's turn-end content.
+    expect(await diff(5)).toEqual({ kind: 'text', path: 'moved.txt', display: 'moved.txt', before: true, after: true, coarse: false, hunks: [] })
+    expect(await diff(6)).toMatchObject({ kind: 'text', path: 'new.txt', before: false, after: true, hunks: [{ lines: ['+n1'] }] })
+    expect(await diff(7)).toBeUndefined()
+    expect(await ctx.workspaceChanges.diff(session.id, seq + 1, 0, signal)).toBeUndefined()
+    expect(await ctx.workspaceChanges.diff(SessionId('elsewhere'), seq, 0, signal)).toBeUndefined()
+    ctx.emit('session/disposed', session)
+    expect(await diff(0)).toBeUndefined()
+  })
+
   it('places files above the working directory and outside the repository by their display rule', async () => {
     const root = await repository()
     const cwd = join(root, 'pkg')
@@ -119,7 +167,8 @@ describe('workspace-changes in a repository', () => {
     await settle(ctx, session)
     await writeFile(join(root, 'a.txt'), 'changed\n')
     await writeFile(join(cwd, 'inner.txt'), 'inner\n')
-    toolCall(session, 1, 'str_replace_editor', { command: 'insert', path: join(outside, 'note.txt'), insert_line: 0, new_str: 'one\ntwo\nthree\n' })
+    await mutate(ctx, session, 1, 'str_replace_editor', { command: 'insert', path: join(outside, 'note.txt'), insert_line: 0, new_str: 'one\ntwo\nthree\n' },
+      () => writeFile(join(outside, 'note.txt'), 'one\ntwo\nthree\n'))
     endTurn(session, 1, 'blocked')
     await settle(ctx, session)
     const [recorded] = changes(ctx, session)
@@ -128,6 +177,7 @@ describe('workspace-changes in a repository', () => {
       { path: 'inner.txt', display: 'inner.txt', added: 1, deleted: 0 },
       { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 3, deleted: 0 },
     ])
+    expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 2, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+one', '+two', '+three'] }] })
   })
 
   it('records inside the turn when the agent stops, and again after turn/end only when tools settled later', async () => {
@@ -135,7 +185,6 @@ describe('workspace-changes in a repository', () => {
     const { ctx } = await boot()
     const session = ctx.sessions.create(SessionId('stopping'), { meta: { cwd } })
     const agent = { session } as never
-    const signal = new AbortController().signal
     startTurn(session, 1)
     await ctx.serial('agent/turn-stopping', { agent, turn: 1, signal })
     expect(changes(ctx, session)).toEqual([])
@@ -197,12 +246,13 @@ describe('workspace-changes in a repository', () => {
     await settle(ctx, session)
     for (const name of ['c.txt', 'd.txt', 'e.txt']) await writeFile(join(cwd, name), `${name}\n`)
     toolCall(session, 1, 'bash', { command: 'x' })
-    toolCall(session, 1, 'edit', { file_path: 'a.txt' }, { meta: { diffs: [{ path: 'a.txt', oldText: 'l1\n', newText: 'l1\n' }] } })
+    await mutate(ctx, session, 1, 'edit', { file_path: 'a.txt', old_string: 'l1', new_string: 'l1' }, () => Promise.resolve())
     endTurn(session, 1)
     await settle(ctx, session)
     const [recorded] = changes(ctx, session)
     expect(recorded!.total).toBe(3)
     expect(recorded!.files.map(file => file.display)).toEqual(['c.txt', 'd.txt'])
+    expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 2, signal)).toBeUndefined()
   })
 
   it('warns and skips the turn when its git work fails', async () => {
@@ -215,8 +265,8 @@ describe('workspace-changes in a repository', () => {
     await settle(ctx, session)
     await writeFile(join(cwd, 'x.txt'), 'x\n')
     toolCall(session, 1, 'bash', { command: 'x' })
-    // A repository whose snapshot failed is not summarized from file-tool hunks as if it had no repository.
-    toolCall(session, 1, 'write', { file_path: 'x.txt', content: 'x\n' }, { meta: { operation: 'create', diffs: [] } })
+    // A repository whose snapshot failed is not summarized from captures as if it had no repository.
+    await mutate(ctx, session, 1, 'write', { file_path: 'y.txt', content: 'y\n' }, () => writeFile(join(cwd, 'y.txt'), 'y\n'))
     endTurn(session, 1)
     await settle(ctx, session)
     expect(changes(ctx, session)).toEqual([])
@@ -237,11 +287,10 @@ describe('workspace-changes in a repository', () => {
     const session = ctx.sessions.create(SessionId('gitlink'), { meta: { cwd } })
     startTurn(session, 1)
     await settle(ctx, session)
-    await writeFile(join(sub, 'inner.txt'), 'inner\nedited\n')
-    toolCall(session, 1, 'edit', { file_path: 'sub/inner.txt' }, { meta: { diffs: [{ path: 'sub/inner.txt', oldText: 'inner', newText: 'inner\nedited' }] } })
-    await writeFile(join(cwd, 'top.txt'), 'top\n')
-    toolCall(session, 1, 'write', { file_path: 'top.txt', content: 'top\n' }, { meta: { operation: 'create', diffs: [] } })
-    toolCall(session, 1, 'write', { file_path: 'same.txt', content: 'same\n' }, { meta: { operation: 'update', diffs: [] } })
+    await mutate(ctx, session, 1, 'edit', { file_path: 'sub/inner.txt', old_string: 'inner', new_string: 'inner\nedited' },
+      () => writeFile(join(sub, 'inner.txt'), 'inner\nedited\n'))
+    await mutate(ctx, session, 1, 'write', { file_path: 'top.txt', content: 'top\n' }, () => writeFile(join(cwd, 'top.txt'), 'top\n'))
+    await mutate(ctx, session, 1, 'write', { file_path: 'same.txt', content: 'same\n' }, () => writeFile(join(cwd, 'same.txt'), 'same\n'))
     endTurn(session, 1)
     await settle(ctx, session)
     expect(changes(ctx, session).map(summary => summary.files.map(file => file.display))).toEqual([['top.txt']])
@@ -253,6 +302,7 @@ describe('workspace-changes in a repository', () => {
     await ctx.plugin(SessionStore)
     await ctx.plugin(LocalSubprocessRuntime)
     await expect(ctx.plugin(WorkspaceChanges, { maxFiles: 0 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer maxFiles')
+    await expect(ctx.plugin(WorkspaceChanges, { diffTimeoutMs: 1.5 } as WorkspaceChanges.Config)).rejects.toThrow('positive integer diffTimeoutMs')
   })
 })
 
@@ -268,31 +318,68 @@ describe('workspace-changes without a repository', () => {
     const session = ctx.sessions.create(SessionId('plain'), { meta: { cwd } })
     startTurn(session, 1)
     await settle(ctx, session)
-    await writeFile(join(cwd, 'existing.txt'), 'after\nmore\n')
-    toolCall(session, 1, 'write', { file_path: 'existing.txt', content: 'after\nmore\n' }, {
-      meta: { diffs: [{ path: 'existing.txt', oldText: 'before', newText: 'after\nmore' }] },
-    })
-    toolCall(session, 1, 'edit', { file_path: 'existing.txt', old_string: 'more', new_string: 'more\nagain' }, {
-      meta: { diffs: [{ path: 'existing.txt', oldText: 'more', newText: 'more\nagain' }] },
-    })
+    await mutate(ctx, session, 1, 'write', { file_path: 'existing.txt', content: 'after\nmore\n' }, () => writeFile(join(cwd, 'existing.txt'), 'after\nmore\n'))
+    await mutate(ctx, session, 1, 'edit', { file_path: 'existing.txt', old_string: 'more', new_string: 'more\nagain' }, () => writeFile(join(cwd, 'existing.txt'), 'after\nmore\nagain\n'))
     // Shell edits and scratch files under a temporary root stay out; a file elsewhere outside the workspace counts.
     await writeFile(join(cwd, 'shell.txt'), 'shell\nedited\n')
     toolCall(session, 1, 'bash', { command: 'x' })
-    toolCall(session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, { meta: { diffs: [] } })
-    toolCall(session, 1, 'str_replace_editor', { command: 'create', path: join(outside, 'note.txt'), file_text: 'one\ntwo\n' })
+    await mutate(ctx, session, 1, 'write', { file_path: join(tmpdir(), 'scratch.txt'), content: 'scratch\n' }, () => Promise.resolve())
+    await mutate(ctx, session, 1, 'str_replace_editor', { command: 'create', path: join(outside, 'note.txt'), file_text: 'one\ntwo\n' },
+      () => writeFile(join(outside, 'note.txt'), 'one\ntwo\n'))
+    // A file the shell edits after a file tool touched it is compared by its final content.
+    await mutate(ctx, session, 1, 'write', { file_path: 'mixed.txt', content: 'tool\n' }, () => writeFile(join(cwd, 'mixed.txt'), 'tool\n'))
+    await writeFile(join(cwd, 'mixed.txt'), 'tool\nshell\n')
+    toolCall(session, 1, 'bash', { command: 'x' })
     endTurn(session, 1)
     await settle(ctx, session)
     expect(changes(ctx, session)).toEqual([{
-      turn: 1, cwd, total: 2, added: 5, deleted: 1,
+      turn: 1, cwd, total: 3, added: 7, deleted: 1,
       files: [
         { path: 'existing.txt', display: 'existing.txt', added: 3, deleted: 1 },
+        { path: 'mixed.txt', display: 'mixed.txt', added: 2, deleted: 0 },
         { path: join(await realpath(outside), 'note.txt'), display: `~/${outside.slice(homedir().length + 1)}/note.txt`, added: 2, deleted: 0 },
       ],
     }])
+    const seq = announcedSeq(session)
+    expect(await ctx.workspaceChanges.diff(session.id, seq, 0, signal)).toEqual({
+      kind: 'text', path: 'existing.txt', display: 'existing.txt', before: true, after: true, coarse: false,
+      hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 3, lines: ['-before', '+after', '+more', '+again'] }],
+    })
     expect(warn.mock.calls.filter(call => String(call[0]).startsWith('workspace-changes:'))).toEqual([])
     await expect(stat(join(cwd, '.git'))).rejects.toThrow()
   })
 
+  it('lists oversized and binary captured files without counts and serves no lines for them', async () => {
+    const cwd = await scratchDir('dsh-workspace-changes-bounds-', cleanups)
+    await writeFile(join(cwd, 'grows.txt'), 'small\n')
+    const { ctx } = await boot({ maxFileBytes: 16 })
+    const session = ctx.sessions.create(SessionId('bounds'), { meta: { cwd } })
+    startTurn(session, 1)
+    await settle(ctx, session)
+    await mutate(ctx, session, 1, 'write', { file_path: 'grows.txt', content: 'x' }, () => writeFile(join(cwd, 'grows.txt'), 'x'.repeat(17)))
+    await mutate(ctx, session, 1, 'write', { file_path: 'shrinks.txt', content: 'x' }, () => writeFile(join(cwd, 'shrinks.txt'), 'x'))
+    await mutate(ctx, session, 1, 'write', { file_path: 'bin.dat', content: 'x' }, () => writeFile(join(cwd, 'bin.dat'), Uint8Array.of(65, 0, 66)))
+    // A directory at the path is neither absent nor a file, so the path is not tracked.
+    await mutate(ctx, session, 1, 'write', { file_path: 'dir', content: 'x' }, () => mkdir(join(cwd, 'dir')))
+    endTurn(session, 1)
+    await settle(ctx, session)
+    const [recorded] = changes(ctx, session)
+    expect(recorded!.files).toEqual([
+      { path: 'bin.dat', display: 'bin.dat', added: 0, deleted: 0, binary: true },
+      { path: 'grows.txt', display: 'grows.txt', added: 0, deleted: 0, oversized: true },
+      { path: 'shrinks.txt', display: 'shrinks.txt', added: 1, deleted: 0 },
+    ])
+    const seq = announcedSeq(session)
+    expect(await ctx.workspaceChanges.diff(session.id, seq, 0, signal)).toEqual({ kind: 'binary', path: 'bin.dat', display: 'bin.dat' })
+    expect(await ctx.workspaceChanges.diff(session.id, seq, 1, signal)).toEqual({ kind: 'oversized', path: 'grows.txt', display: 'grows.txt' })
+    expect(await ctx.workspaceChanges.diff(session.id, seq, 2, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+x'] }] })
+    // Every copy lives under the Session's temporary directory and goes with it.
+    const scratch = (await readdir(tmpdir())).filter(name => name.startsWith('dsh-workspace-changes-'))
+    expect(scratch.length).toBeGreaterThan(0)
+    ctx.emit('session/disposed', session)
+    await settle(ctx, session)
+  })
+
   it('drops a disposed session’s recorder and starts afresh on its next turn', async () => {
     const cwd = await repository()
     const { ctx, fiber } = await boot()
@@ -334,10 +421,11 @@ describe('workspace-changes without a repository', () => {
     for (const session of sessions) {
       startTurn(session, 1)
       await settle(ctx, session)
-      toolCall(session, 1, 'bash', { command: 'x' })
+      await mutate(ctx, session, 1, 'write', { file_path: 'w.txt', content: 'w\n' }, () => writeFile(join(cwd, 'w.txt'), 'w\n'))
       endTurn(session, 1)
       await settle(ctx, session)
       expect(changes(ctx, session)).toEqual([])
+      await rm(join(cwd, 'w.txt'))
     }
     await ctx.waterfall('tools/pre-execute', {} as never, () => Promise.resolve(undefined as never))
   })
@@ -355,11 +443,12 @@ describe('workspace-changes without git', () => {
       await settle(ctx, session)
       await writeFile(join(cwd, `${turn}.txt`), 'x\n')
       toolCall(session, turn, 'bash', { command: 'x' })
-      if (turn === 2) toolCall(session, turn, 'write', { file_path: 'w.txt', content: 'w\n' }, { meta: { diffs: [] } })
+      if (turn === 2) await mutate(ctx, session, turn, 'write', { file_path: 'w.txt', content: 'w\n' }, () => writeFile(join(cwd, 'w.txt'), 'w\n'))
       endTurn(session, turn)
       await settle(ctx, session)
     }
     expect(changes(ctx, session)).toEqual([{ turn: 2, cwd, total: 1, added: 1, deleted: 0, files: [{ path: 'w.txt', display: 'w.txt', added: 1, deleted: 0 }] }])
+    expect(await ctx.workspaceChanges.diff(session.id, announcedSeq(session), 0, signal)).toMatchObject({ kind: 'text', before: false, after: true, hunks: [{ lines: ['+w'] }] })
     expect(info).toHaveBeenCalledTimes(1)
     expect(info.mock.calls[0]![0]).toContain('git is unavailable')
   })

+ 14 - 0
packages/deliverables/workspace-changes/tests/support.ts

@@ -50,6 +50,20 @@ export function toolCall(session: Session, turn: number, name: string, args: unk
   }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
 }
 
+/**
+ * Apply a file-tool mutation the way the runtime does: announce it through
+ * `tools/pre-execute` so the recorder captures the path, apply it, then log
+ * the settled call.
+ */
+export async function mutate(
+  ctx: Context, session: Session, turn: number, name: string, args: unknown, apply: () => Promise<void>,
+  result: { meta?: unknown; isError?: boolean } = {},
+) {
+  await ctx.waterfall('tools/pre-execute', { agent: { session }, name, arguments: args } as never, () => Promise.resolve(undefined as never))
+  await apply()
+  return toolCall(session, turn, name, args, result)
+}
+
 /** Close the step and the turn. */
 export function endTurn(session: Session, turn: number, reason: 'completed' | 'blocked' = 'completed'): void {
   session.append('step/end', { turn, step: 1 })

+ 18 - 3
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -3108,8 +3108,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
   },
   {
     key: 'workspaceChanges',
-    summary: 'Serves the summaries the recorder keeps for live Sessions.',
-    description: 'Serves the summaries the recorder keeps for live Sessions.',
+    summary: 'Serves the summaries and file comparisons the recorder keeps for live Sessions.',
+    description: 'Serves the summaries and file comparisons the recorder keeps for live Sessions.',
     methods: [
       {
         signature: 'summary(sessionId: SessionId, seq: number): WorkspaceChangesSummary | undefined',
@@ -3117,6 +3117,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [{ name: 'sessionId', description: 'the Session that appended the event.' }, { name: 'seq', description: 'the event\'s sequence number.' }],
         returns: 'the summary, or undefined once its Session was disposed or when this Host never recorded it.',
       },
+      {
+        signature: 'diff(sessionId: SessionId, seq: number, index: number, signal: AbortSignal): Promise<WorkspaceFileDiff | undefined>',
+        description: 'Compare one listed file\'s contents at turn start and turn end.',
+        parameters: [{ name: 'sessionId', description: 'the Session that appended the event.' }, { name: 'seq', description: 'the event\'s sequence number.' }, { name: 'index', description: 'the file\'s index in the summary\'s `files`.' }, { name: 'signal', description: 'cancels the reads.' }],
+        returns: 'the comparison, or undefined once its Session was disposed, when this Host never recorded it, or when no file has that index.',
+        throws: ['when a snapshot read fails for a live Session.'],
+      },
     ],
   },
   {
@@ -6844,7 +6851,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'WorkspaceChangedFile',
-    declaration: 'export interface WorkspaceChangedFile {\n    path: string;\n    display: string;\n    added: number;\n    deleted: number;\n    binary?: true;\n}',
+    declaration: 'export interface WorkspaceChangedFile {\n    path: string;\n    display: string;\n    added: number;\n    deleted: number;\n    binary?: true;\n    oversized?: true;\n}',
   },
   {
     name: 'WorkspaceChangesSummary',
@@ -6866,6 +6873,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'WorkspaceDeleteValue',
     declaration: 'export interface WorkspaceDeleteValue {\n    readonly deleted: true;\n}',
   },
+  {
+    name: 'WorkspaceDiffHunk',
+    declaration: 'export interface WorkspaceDiffHunk {\n    oldStart: number;\n    oldLines: number;\n    newStart: number;\n    newLines: number;\n    lines: string[];\n}',
+  },
   {
     name: 'WorkspaceDirectoryEntry',
     declaration: 'export interface WorkspaceDirectoryEntry {\n    readonly name: string;\n    readonly type: \'file\' | \'directory\' | \'other\';\n    readonly size?: number;\n}',
@@ -6882,6 +6893,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'WorkspaceFileChange',
     declaration: 'export type WorkspaceFileChange = {\n    readonly absolutePath: string;\n    readonly version: string;\n} | {\n    readonly absolutePath: string;\n    readonly absent: true;\n};',
   },
+  {
+    name: 'WorkspaceFileDiff',
+    declaration: 'export type WorkspaceFileDiff = {\n    kind: \'text\';\n    path: string;\n    display: string;\n    before: boolean;\n    after: boolean;\n    hunks: WorkspaceDiffHunk[];\n    coarse: boolean;\n} | {\n    kind: \'binary\';\n    path: string;\n    display: string;\n} | {\n    kind: \'oversized\';\n    path: string;\n    display: string;\n};',
+  },
   {
     name: 'WorkspaceFileRange',
     declaration: 'export interface WorkspaceFileRange {\n    readonly offset?: number;\n    readonly limit?: number;\n}',

+ 3 - 0
pnpm-lock.yaml

@@ -2989,6 +2989,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-renderer':
         specifier: workspace:^
         version: link:../ui-renderer
+      '@deepseek-ai/dsh-client-ui-sidebar-right':
+        specifier: workspace:^
+        version: link:../ui-sidebar-right
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -259,6 +259,7 @@ export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
  */
 export const LINK_MAP: Readonly<Record<string, string>> = {
   WorkspaceChangesSummary: 'deliverables.md',
+  WorkspaceFileDiff: 'deliverables.md',
   Reload: 'boot.md',
   PluginInfo: 'boot.md',
   BundleInfo: 'boot.md',

+ 1 - 1
scripts/gen-doc-graphs.ts

@@ -255,7 +255,7 @@ const SERVICE_ROLES: ServiceRole[] = [
     pkg: 'workspace-changes',
     title: 'Host per-turn changed-file summaries',
     mode: 'core',
-    note: 'Serves the summary each workspace/changes event announced, by Session and event sequence, until that Session is disposed; the log carries only the turn.',
+    note: 'Serves the summary each workspace/changes event announced and each listed file\'s turn-start and turn-end comparison, by Session and event sequence, until that Session is disposed; the log carries only the turn.',
   },
   {
     key: 'terminalController',

+ 10 - 0
scripts/type-equiv.manifest.json

@@ -2177,6 +2177,16 @@
       "symbol": "WorkspaceChangesSummary",
       "source": "packages/deliverables/workspace-changes/src/types.ts"
     },
+    {
+      "doc": "docs/subsystems/deliverables.md",
+      "symbol": "WorkspaceDiffHunk",
+      "source": "packages/deliverables/workspace-changes/src/types.ts"
+    },
+    {
+      "doc": "docs/subsystems/deliverables.md",
+      "symbol": "WorkspaceFileDiff",
+      "source": "packages/deliverables/workspace-changes/src/types.ts"
+    },
     {
       "doc": "docs/subsystems/deliverables.md",
       "symbol": "WorkspaceChanges",

+ 3 - 3
snapshots/web/changed-files-turn/ui.expected.md

@@ -105,11 +105,11 @@
 - text: 已编辑 4 个文件 +6 -1
 - list:
   - listitem:
-    - button "在侧边栏打开 app.local": app.local +1 -0
+    - button "查看 app.local 的改动": app.local +1 -0
   - listitem:
-    - button "在侧边栏打开 intro.md": intro.md +1 -1
+    - button "查看 intro.md 的改动": intro.md +1 -1
   - listitem:
-    - button "在侧边栏打开 notes.txt": notes.txt +1 -0
+    - button "查看 notes.txt 的改动": notes.txt +1 -0
 - button "展开全部 4 个改动文件":
   - text: 全部 4 个文件
   - img

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.