Просмотр исходного кода

feat(deliverables): review one turn's changed files in a single tab

The per-file comparison tab becomes a `changes-review` tab per turn: a file selector in the header lists every recorded file, a card row opens the tab on its file through a navigation parameter, and the header's tools switch between the unified and a side-by-side view, switch line wrapping, open the whole file in the Sidebar, and open it in the default application when a desktop exists. View and wrap choices are kept per tab in an exclusive store.
creatixchu 5 дней назад
Родитель
Сommit
fc10bc18e0
29 измененных файлов с 1028 добавлено и 559 удалено
  1. 2 2
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml
  2. 1 1
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md
  3. 1 1
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.i18n.yaml
  5. 6 4
      .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md
  6. 6 4
      .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md
  7. 25 15
      apps/web/tests/changed-files-turn.e2e.ts
  8. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  9. 9 9
      packages/client/ui-deliverables/README.md
  10. 9 9
      packages/client/ui-deliverables/README.zh.md
  11. 1 0
      packages/client/ui-deliverables/package.json
  12. 20 23
      packages/client/ui-deliverables/src/changes.ts
  13. 8 8
      packages/client/ui-deliverables/src/client/ChangedFiles.tsx
  14. 5 5
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  15. 0 20
      packages/client/ui-deliverables/src/client/DiffPreview.module.css
  16. 0 157
      packages/client/ui-deliverables/src/client/DiffPreview.tsx
  17. 41 0
      packages/client/ui-deliverables/src/client/ReviewTab.module.css
  18. 299 0
      packages/client/ui-deliverables/src/client/ReviewTab.tsx
  19. 0 34
      packages/client/ui-deliverables/src/client/diff-definition.ts
  20. 19 15
      packages/client/ui-deliverables/src/client/index.ts
  21. 22 0
      packages/client/ui-deliverables/src/client/locales.ts
  22. 50 0
      packages/client/ui-deliverables/src/client/review-definition.ts
  23. 92 0
      packages/client/ui-deliverables/src/client/review-store.ts
  24. 63 0
      packages/client/ui-deliverables/tests/changes-diff.client.spec.ts
  25. 15 11
      packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
  26. 0 237
      packages/client/ui-deliverables/tests/diff-preview.client.spec.tsx
  27. 324 0
      packages/client/ui-deliverables/tests/review-tab.client.spec.tsx
  28. 3 0
      packages/client/ui-deliverables/tsconfig.client.json
  29. 3 0
      pnpm-lock.yaml

+ 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: c0c5a3da59cbc82a215ef8970f8e32f65578af6f
-2026-09-11-turn-changed-files-card.zh.md: 2b647f31e351a5aea8e911aee17fc7f457124301
+2026-09-11-turn-changed-files-card.md: 4b39e2b339331bbe11ee98373707e1d7310afa3c
+2026-09-11-turn-changed-files-card.zh.md: c0c6f8d161c3e87ae999b1d75a6661fc2a2a8793

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

@@ -18,7 +18,7 @@ Git is the default executable on `PATH`; no environment plugin is consulted. Out
 
 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. Each row opens the file's turn-start and turn-end comparison in the right Sidebar ([comparison decision](2026-09-15-changed-file-diff-preview.md)); 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 the comparison tab offers the default-application open.
+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. Each row opens the turn's review tab in the right Sidebar on that file ([review decision](2026-09-15-changed-file-diff-preview.md)); 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 the review tab offers the default-application open.
 
 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.
 

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

@@ -18,7 +18,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。
 
 快照覆盖之外的改动来自整文件捕获:在 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用运行之前,记录器把所指文件复制到 Session 的临时目录,每轮每个路径一次,轮次结束时再复制一次;被忽略的文件、工作树之外的文件,以及没有快照时的每一次文件工具编辑,都由两份副本的逐行对比列出([对比决定](2026-09-15-changed-file-diff-preview.zh.md))。临时目录下的文件被省略,除非它们位于工作目录内;留在 `/tmp` 里的文件需要 `present` 才能到达用户。覆盖之外的 shell 编辑是已知限制。
 
-列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。每一行在右侧 Sidebar 打开该文件在轮次开始与结束时的对比([对比决定](2026-09-15-changed-file-diff-preview.zh.md));Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——对比 tab 提供用默认应用打开。
+列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。每一行在右侧 Sidebar 打开本轮的 review tab 并选中该文件([review 决定](2026-09-15-changed-file-diff-preview.zh.md));Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——review tab 提供用默认应用打开。
 
 记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖。Client 保留每轮最新的宣告,并通过经过认证的摘要路由读取一次摘要。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.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-15-changed-file-diff-preview.md
-2026-09-15-changed-file-diff-preview.md: 06b34b33d6c83287b48b158d27039268229a111b
-2026-09-15-changed-file-diff-preview.zh.md: 32d319ab5ff0da96eacac21bcd9adcb0fed4e96a
+2026-09-15-changed-file-diff-preview.md: f939fe224899d8329b4a9c2d8fdc267f250403da
+2026-09-15-changed-file-diff-preview.zh.md: 688bd9c841cda406f2b53825203753aaad4d6004

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

@@ -1,4 +1,4 @@
-# Agent Note: Changed-file comparison tab
+# Agent Note: Turn review tab
 
 Status: implemented
 
@@ -10,13 +10,13 @@ The [changed-files card](2026-09-11-turn-changed-files-card.md) tells the user w
 
 ## 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.
+Each card row opens the turn's `changes-review` tab in the right Sidebar on that file: one tab per turn, addressed by the viewed Session and the announcing event's sequence, with a file selector in its header that lists every recorded file and shows one file's turn-start and turn-end comparison at a time. The Host [workspace-changes](../../../../packages/deliverables/workspace-changes/README.md) recorder serves each comparison through `workspaceChanges.diff(sessionId, seq, index, signal)`; the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) registers the tab type, passes the row's file index as a navigation parameter, and reads the summary and the comparisons through authenticated routes. The header's tools switch between the unified view and a side-by-side view, switch line wrapping, open the whole current file in the Sidebar, and, with a Host desktop, open it in the default application; view and wrap choices are kept per tab. Rows open the review with and without a desktop, so the earlier row-level native open moves into the tab 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.
+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; the side-by-side view pairs each run of deletions with the additions that follow it row by row. 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.
 
@@ -30,7 +30,9 @@ Content still lives only as long as the Session in this Host process, as the car
 
 **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.
+**One tab per file**, titled by the file name, was the first implementation; reviewing a turn meant one tab per row. One tab per turn with a file selector keeps the turn's changes together and lets the row still land on its file.
+
+**Syntax highlighting** in the tab is deferred until the plain 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.
 

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

@@ -1,4 +1,4 @@
-# Agent Note: 改动文件对比 tab
+# Agent Note: 轮次 review tab
 
 Status: implemented
 
@@ -10,13 +10,13 @@ Status: implemented
 
 ## 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 头部提供用默认应用打开,先前行级别的原生打开移到那里,卡片只保留一种行为。
+卡片的每一行在右侧 Sidebar 打开本轮的 `changes-review` tab 并选中该文件:每轮一个 tab,由当前查看的 Session 和宣告事件的序号定址,头部有一个列出所有记录文件的文件选择器,一次显示一个文件在轮次开始与结束时的对比。Host 侧的 [workspace-changes](../../../../packages/deliverables/workspace-changes/README.zh.md) 记录器通过 `workspaceChanges.diff(sessionId, seq, index, signal)` 提供每个对比;[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)注册该 tab 类型,把行的文件下标作为导航参数传入,并通过经过认证的路由读取摘要和对比。头部的工具在单栏和左右视图之间切换、切换自动换行、在 Sidebar 中打开当前整个文件,有 Host 桌面时用默认应用打开它;视图与换行的选择按 tab 保留。有没有桌面,行都打开 review,先前行级别的原生打开移进 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 上计算,来源是保存在所提供摘要旁边的两侧内容来源:快照树中的路径,用 `ls-tree -l` 和 `cat-file blob` 在字节上限之内读出;或者从磁盘读取的副本。git 报告为二进制的快照一侧和含 NUL 字节的副本不提供行。tab 渲染带旧新行号的 hunk,没有语法高亮;左右视图把每一段删除与紧随其后的新增逐行配对。Host 已不再提供的对比、读取失败、二进制文件和过大的文件各显示一行。
 
 内容仍然只在本 Host 进程内随 Session 存活,这是卡片决定已经定下的;对比与卡片同寿命,因此 Host 重启后重新打开的对话两者都没有。
 
@@ -30,7 +30,9 @@ git 覆盖不到的文件按 Codex 的 turn diff tracker 对比其 `apply_patch`
 
 **给仓库之外的工作目录建影子仓库**仍然推迟;副本已经覆盖那里的文件工具编辑,这是用户能采取行动的部分。
 
-**tab 里的语法高亮和左右对照视图**推迟到纯 unified 视图被证明不够用时再做。
+**每个文件一个 tab**、以文件名作标题,是第一版实现;审阅一轮意味着每行一个 tab。每轮一个 tab 加文件选择器把一轮的改动放在一起,行仍然能落到自己的文件上。
+
+**tab 里的语法高亮**推迟到纯文本视图被证明不够用时再做。
 
 **保留行打开当前文件**会让对比多一次点击才能到达;当前文件仍可从正文链接和文件 tab 打开。
 

+ 25 - 15
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; its rows open comparisons. */
+/** A turn that edits, creates, and shell-appends files in a git workspace ends with the changed-files card; its rows open the review. */
 import { execFileSync } from 'node:child_process'
 import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
@@ -117,24 +117,34 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
     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 () => {
+  it('reviews a shell-appended file from the snapshots and an ignored file from its captured copies in one tab', async () => {
     const card = page.locator('[data-changed-files]')
     const column = page.locator('[data-rightbar-col]')
+    const drawn = (root: ReturnType<typeof column.locator>) =>
+      root.locator('[data-diff-line]').evaluateAll(lines => lines.map(line => `${line.getAttribute('data-diff-line')}:${line.textContent}`))
     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',
-    ])
+    const review = column.locator('[data-changes-review]')
+    await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' })
+    expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1)
+    await expect.poll(() => drawn(review)).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)
+    await review.getByRole('button', { name: '选择要查看的文件' }).click()
+    await page.getByRole('menuitem').filter({ hasText: 'app.local' }).click()
+    await review.locator('[data-review-file="app.local"]').waitFor({ state: 'visible' })
+    await expect.poll(() => drawn(review)).toEqual(['add:1+mode=demo'])
+    expect(await review.getByText('本轮新建的文件').count()).toBe(1)
+    // A card row opens the same tab on another file; the split and wrap choices switch the drawing.
+    await card.getByRole('button', { name: '查看 intro.md 的改动' }).click()
+    await review.locator('[data-review-file="intro.md"]').waitFor({ state: 'visible' })
+    expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1)
+    await review.getByRole('button', { name: '左右对比' }).click()
+    await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' })
+    await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。'])
+    await review.getByRole('button', { name: '自动换行' }).click()
+    expect(await review.locator('[data-review-view][data-review-wrap]').count()).toBe(1)
+    // No desktop, so the tools offer the sidebar file but no native open.
+    expect(await review.locator('[data-review-tool="open-file"]').count()).toBe(1)
+    expect(await review.locator('[data-review-tool="open-native"]').count()).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   })

+ 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: 12c5805ff78ab8d27c6be409cda6d62a8a78e74f
-README.zh.md: a23c503ae18583bb3109e877adbc4c3f17e1d7c3
+README.md: ab9e25c7b351a8eb82461543654c28155945a63b
+README.zh.md: 194d92305fe30e1836838b3af73172b00608a1a6

+ 9 - 9
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, the comparison tab each changed file opens, 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 review tab that compares each changed file, 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 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.
+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 the turn's review tab on that file — 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,11 +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, “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 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 the turn's review in the right Sidebar on that file. 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
+### The review 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.
+A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison; the selected file's counts follow it. The header's tools switch between the unified view and a side-by-side view that pairs each run of deletions with the additions that follow it, switch line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. 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, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. 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. The comparison is the turn's snapshot of the file, not its current content.
 
 ### Inline-code links
 
@@ -54,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. 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.
+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-review/session/<sessionId>/<seq>/<turn>` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores 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, 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, after which the comparison tab hides its native open; 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, after which the review tab hides its native open; 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>
 
@@ -103,8 +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 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, and draws at most 5,000 lines before saying so.
+- **Folder and native opening need a Host desktop** — without one the card header is inert and the review tab offers no native open; the comparison itself needs only the Host that recorded the turn.
+- **Comparisons are unhighlighted** — the tab shows plain hunks without syntax colouring, and draws at most 5,000 lines before saying so.
 - **Comparisons carry whole file text** — the comparison route serves a listed file's complete text wherever the Host recorded it, including ignored files and files outside the workspace root the Sidebar previews are confined to.
 - **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.

+ 9 - 9
packages/client/ui-deliverables/README.zh.md

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

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

@@ -56,6 +56,7 @@
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",

+ 20 - 23
packages/client/ui-deliverables/src/changes.ts

@@ -11,8 +11,8 @@ 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/'
+/** Resource-address prefix of a turn's review tab in the right Sidebar. */
+export const CHANGES_REVIEW_ADDRESS = 'dsh-resource://changes-review/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'>
@@ -20,13 +20,12 @@ export type ChangesSummary = Pick<WorkspaceChangesSummary, 'turn' | 'files' | 't
 /** 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 {
+/** Coordinates of one turn's review: the viewed Session, the announcing event, and the turn it summarized. */
+export interface ChangesReviewCoordinates {
   sessionId: SessionId
   seq: number
-  index: number
-  /** The listed file's display path, carried for the tab title. */
-  display: string
+  /** The summarized turn, carried for the tab title. */
+  turn: number
 }
 
 function isRecord(value: unknown): value is Record<string, unknown> {
@@ -126,30 +125,28 @@ export function changedFileUrl(sessionId: SessionId, seq: number, index: number
 }
 
 /**
- * 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.
+ * The right-Sidebar address of one turn's review. The Session and the event
+ * sequence identify the content; the turn rides along for the tab title.
+ * @param coordinates - viewed Session, announcing event, and turn.
+ * @returns a `dsh-resource://changes-review/session/…` address.
  */
-export function changesDiffAddress({ sessionId, seq, index, display }: ChangesDiffCoordinates): string {
-  return `${CHANGES_DIFF_ADDRESS}${encodeURIComponent(sessionId)}/${seq}/${index}/${encodeURIComponent(display)}`
+export function changesReviewAddress({ sessionId, seq, turn }: ChangesReviewCoordinates): string {
+  return `${CHANGES_REVIEW_ADDRESS}${encodeURIComponent(sessionId)}/${seq}/${turn}`
 }
 
 /**
- * Read the coordinates back out of a comparison address.
+ * Read the coordinates back out of a review 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
+export function parseChangesReviewAddress(address: string): ChangesReviewCoordinates | undefined {
+  if (!address.startsWith(CHANGES_REVIEW_ADDRESS)) return undefined
+  const parts = address.slice(CHANGES_REVIEW_ADDRESS.length).split('/')
+  if (parts.length !== 3) return undefined
+  const [sessionId, seq, turn] = parts as [string, string, string]
+  if (sessionId === '' || !/^\d+$/.test(seq) || !/^[1-9]\d*$/.test(turn)) return undefined
   try {
-    return {
-      sessionId: decodeURIComponent(sessionId) as SessionId, seq: Number(seq), index: Number(index), display: decodeURIComponent(display),
-    }
+    return { sessionId: decodeURIComponent(sessionId) as SessionId, seq: Number(seq), turn: Number(turn) }
   } catch {
     // A malformed percent sequence is not an address this package minted.
     return undefined

+ 8 - 8
packages/client/ui-deliverables/src/client/ChangedFiles.tsx

@@ -1,4 +1,4 @@
-/** The changed-files card: a folder-opening header, per-file line counts opening each file's comparison, and a three-row fold. */
+/** The changed-files card: a folder-opening header, per-file line counts opening the turn's review on that file, 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'
@@ -39,13 +39,13 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop
 }
 
 /**
- * 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.
+ * Render one turn's changed files. Each row opens the turn's review in the
+ * right Sidebar on that file; 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, openDiff, t }: {
+export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openReview, 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,8 +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
-  /** Open the comparison of the file at an original summary index. */
-  openDiff: (index: number, display: string) => void
+  /** Open the turn's review on the file at an original summary index. */
+  openReview: (index: number) => void
 } & PropsLocale<typeof NS>) {
   const [expanded, setExpanded] = useState(false)
   const native = host !== null && host.available
@@ -82,7 +82,7 @@ export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, op
         <li key={file.display}>
           <button type="button" className={css.row} title={resolveWorkspacePath(cwd, file.path)}
             aria-label={t('changes.viewDiff', { name: file.display })}
-            onClick={() => { openDiff(index, file.display) }}>
+            onClick={() => { openReview(index) }}>
             <span className={css.path}>{file.display}</span>
             <span className={css.counts}>
               {file.binary === true ? t('changes.binary')

+ 5 - 5
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, type ChangesDiffCoordinates } from '../changes.ts'
+import { changesSummaryUrl, type ChangesReviewCoordinates } from '../changes.ts'
 import { presentedFileUrl } from '../presented.ts'
 import { PresentedFileCard } from './PresentedFileCard.tsx'
 import css from './Deliverables.module.css'
@@ -29,8 +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
+  /** Open one turn's review in the right Sidebar on the file at an index. */
+  openChangesReview: (coordinates: ChangesReviewCoordinates, index: number) => void
 }
 
 /**
@@ -52,7 +52,7 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch
  * @returns the closing turn's file rows.
  */
 export function Deliverables({
-  matched, openFile, t, sessionId, useSessions, openPresented, openChanged, openChangesDiff, usePresentedOpen, usePresentedHost,
+  matched, openFile, t, sessionId, useSessions, openPresented, openChanged, openChangesReview, usePresentedOpen, usePresentedHost,
   useChangesSummary, reloadPresentedHost, loadChangesSummary,
 }: Pick<TurnTailOwnerProps, 'openFile'> & {
   matched: DeliverablesMatch
@@ -79,7 +79,7 @@ export function Deliverables({
   return <>
     {changes !== null && <ChangedFiles changes={changes} cwd={cwd} sessionId={sessionId}
       host={host === 'error' ? null : host} phases={states} t={t}
-      openDiff={(index, display) => { openChangesDiff({ sessionId, seq: changes.seq, index, display }) }}
+      openReview={(index) => { openChangesReview({ sessionId, seq: changes.seq, turn: changes.turn }, index) }}
       onOpen={(index) => { void openChanged(sessionId, changes.seq, index) }} />}
     {matched.presented.length > 0 && <div
       className={css.root}

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

@@ -1,20 +0,0 @@
-/** 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); }

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

@@ -1,157 +0,0 @@
-/** 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>
-
-/** Lines drawn before the tab stops; a coarse comparison of a file near the byte cap would otherwise draw every line. */
-export const MAX_RENDERED_LINES = 5000
-
-/** 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 to draw, cut at {@link MAX_RENDERED_LINES} lines in total.
- * @param hunks - served hunks.
- * @returns the hunks with the last one shortened as needed, and whether anything was cut.
- */
-export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } {
-  let budget = MAX_RENDERED_LINES
-  const kept: WorkspaceDiffHunk[] = []
-  for (const hunk of hunks) {
-    if (budget === 0) return { hunks: kept, truncated: true }
-    kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) })
-    budget -= Math.min(budget, hunk.lines.length)
-  }
-  return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) }
-}
-
-/** 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)
-  const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks])
-  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>}
-      {truncated && <p className={css.note} data-diff-truncated>{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}</p>}
-      {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>
-  )
-}

+ 41 - 0
packages/client/ui-deliverables/src/client/ReviewTab.module.css

@@ -0,0 +1,41 @@
+/** Review tab: a file selector and icon tools in the document preview's header row, then numbered 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: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); }
+.selector { flex: 0 1 auto; min-width: 0; }
+.selectorLabel { flex: 1 1 auto; min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.selectorButton { display: inline-flex; gap: 4px; align-items: center; box-sizing: border-box; max-width: 100%; height: 28px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; }
+.selectorButton:hover, .selectorButton[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); }
+.selectorText { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.item { display: flex; gap: 12px; align-items: center; justify-content: space-between; min-width: 0; }
+.itemPath { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.itemCounts, .counts { display: inline-flex; flex: none; gap: 6px; font-family: var(--ds-font-family-code); font-size: 12px; color: var(--dsw-alias-label-tertiary); }
+.counts { min-width: 0; margin-right: auto; }
+.added { color: var(--dsw-alias-state-success-primary); }
+.deleted { color: var(--dsw-alias-state-error-primary); }
+.label { color: var(--dsw-alias-label-tertiary); }
+.tools { display: inline-flex; flex: none; gap: 2px; align-items: center; margin-left: auto; }
+.tool { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 6px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; }
+.tool svg { width: 15px; height: 15px; }
+.tool:hover:not(:disabled), .tool[aria-pressed='true'] { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
+.tool:disabled { cursor: progress; }
+.tool[data-error] { color: var(--dsw-alias-state-error-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, 1fr); min-height: 22px; line-height: 22px; white-space: pre; }
+.splitLine { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 22px; line-height: 22px; white-space: pre; }
+.cell { display: grid; grid-template-columns: 3.5em minmax(0, 1fr); min-width: 0; }
+.cell + .cell { border-left: 0.5px solid var(--dsw-alias-border-l3); }
+.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; }
+.body[data-review-wrap] .line, .body[data-review-wrap] .splitLine { white-space: pre-wrap; }
+.body[data-review-wrap] .text { overflow-wrap: anywhere; }
+.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); }
+.empty { background: var(--dsw-alias-interactive-bg-hover); }

+ 299 - 0
packages/client/ui-deliverables/src/client/ReviewTab.tsx

@@ -0,0 +1,299 @@
+/**
+ * The review tab: one turn's changed files behind a file selector, with the
+ * selected file's turn-start and turn-end comparison drawn unified or side by
+ * side, wrapped or scrolling, and controls to open the file itself.
+ */
+import { useEffect, useMemo, useState } from 'react'
+import type { ReactNode } from 'react'
+import {
+  Button, IconChevronDownOutline14, IconCodeOutline16, IconPanelLeftOutline16, IconRightUpOutline16, IconWrapLinesOutline16, Menu, Tooltip,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
+import type { WorkspaceChangedFile, WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types'
+import { changedFileUrl, changesDiffUrl, changesSummaryUrl, parseChangesReviewAddress, type ChangesDiff } from '../changes.ts'
+import type { ChangesDiffStore } from './changes-diff.ts'
+import type { ChangesSummaryStore } from './changes-summary.ts'
+import type { PresentedOpenController } from './present-open.ts'
+import type { ChangesReviewParams } from './review-definition.ts'
+import type { createReviewStore } from './review-store.ts'
+import type { NS } from './locales.ts'
+import css from './ReviewTab.module.css'
+
+/** Lines drawn before the tab stops; a coarse comparison of a file near the byte cap would otherwise draw every line. */
+export const MAX_RENDERED_LINES = 5000
+
+const GROUPED = new Intl.NumberFormat('en-US')
+
+/** Summary and comparison reads, desktop metadata, and the native open supplied by the plugin. */
+export interface ReviewInjected {
+  hooks: {
+    changesSummary: ObservableSnapshot<ReturnType<ChangesSummaryStore['state']['getSnapshot']>>
+    changesDiff: ObservableSnapshot<ReturnType<ChangesDiffStore['state']['getSnapshot']>>
+    presentedOpen: ObservableSnapshot<ReturnType<PresentedOpenController['state']['getSnapshot']>>
+    presentedHost: ObservableSnapshot<ReturnType<PresentedOpenController['host']['getSnapshot']>>
+  }
+  loadChangesSummary: ChangesSummaryStore['load']
+  loadChangesDiff: ChangesDiffStore['load']
+  reloadPresentedHost: PresentedOpenController['loadHost']
+  openChanged: PresentedOpenController['openChanged']
+}
+
+/** The body's composed props: the tab it draws, its store, its injected face, and its copy. */
+export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore<ReturnType<typeof createReviewStore>>
+  & InjectFace<ReviewInjected> & PropsLocale<typeof NS>
+
+/** One drawn line of a hunk with its line numbers on each side. */
+export interface DiffRow {
+  kind: 'add' | 'del' | 'context'
+  old: number | undefined
+  new: number | undefined
+  text: string
+}
+
+/** One side-by-side row: the old side, the new side, or both. */
+export interface SplitRow {
+  left?: { no: number; text: string; kind: 'del' | 'context' }
+  right?: { no: number; text: string; kind: 'add' | 'context' }
+}
+
+/**
+ * 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 }
+    }
+  })
+}
+
+/**
+ * Pair a hunk's lines for the side-by-side view: each run of deletions is
+ * aligned with the run of additions that follows it, row by row, and context
+ * lines sit on both sides.
+ * @param hunk - a served hunk.
+ * @returns the rows in order.
+ */
+export function splitRows(hunk: WorkspaceDiffHunk): SplitRow[] {
+  const rows: SplitRow[] = []
+  let dels: NonNullable<SplitRow['left']>[] = []
+  let adds: NonNullable<SplitRow['right']>[] = []
+  const flush = (): void => {
+    for (let at = 0; at < Math.max(dels.length, adds.length); at += 1) {
+      const left = dels[at]
+      const right = adds[at]
+      rows.push({ ...left === undefined ? {} : { left }, ...right === undefined ? {} : { right } })
+    }
+    dels = []
+    adds = []
+  }
+  for (const row of hunkRows(hunk)) {
+    if (row.kind === 'del') dels.push({ no: row.old as number, text: row.text, kind: 'del' })
+    else if (row.kind === 'add') adds.push({ no: row.new as number, text: row.text, kind: 'add' })
+    else {
+      flush()
+      rows.push({ left: { no: row.old as number, text: row.text, kind: 'context' }, right: { no: row.new as number, text: row.text, kind: 'context' } })
+    }
+  }
+  flush()
+  return rows
+}
+
+/**
+ * The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total.
+ * @param hunks - served hunks.
+ * @returns the hunks with the last one shortened as needed, and whether anything was cut.
+ */
+export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } {
+  let budget = MAX_RENDERED_LINES
+  const kept: WorkspaceDiffHunk[] = []
+  for (const hunk of hunks) {
+    if (budget === 0) return { hunks: kept, truncated: true }
+    kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) })
+    budget -= Math.min(budget, hunk.lines.length)
+  }
+  return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) }
+}
+
+/** 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 file index a navigation names, when it names one. */
+function navigatedIndex(params: unknown): number | undefined {
+  const index = (params as ChangesReviewParams | undefined)?.index
+  return typeof index === 'number' && Number.isSafeInteger(index) && index >= 0 ? index : undefined
+}
+
+/** Added and deleted line counts in the card's colors. */
+function Counts({ file, t }: { file: WorkspaceChangedFile } & PropsLocale<typeof NS>): ReactNode {
+  if (file.binary === true) return <span className={css.label}>{t('changes.binary')}</span>
+  if (file.oversized === true) return <span className={css.label}>{t('changes.oversized')}</span>
+  return <>
+    <span className={css.added}>{t('changes.added', { count: GROUPED.format(file.added) })}</span>
+    <span className={css.deleted}>{t('changes.deleted', { count: GROUPED.format(file.deleted) })}</span>
+  </>
+}
+
+/**
+ * The review type's body, registered under `sidebar.right.pane.tab` as `changes-review`.
+ * @param props - composed slot props.
+ * @returns the selected file's comparison behind the file selector, or the state that stands in for it.
+ */
+export function ReviewTab({
+  useTabInfo, sessionId, useSessions, useStore, actions, useChangesSummary, useChangesDiff, usePresentedOpen, usePresentedHost,
+  loadChangesSummary, loadChangesDiff, reloadPresentedHost, openChanged, t,
+}: ReviewTabProps): ReactNode {
+  const { tab } = useTabInfo()
+  const { navigation, signal } = tab
+  const coordinates = useMemo(() => parseChangesReviewAddress(tab.contentId), [tab.contentId])
+  if (coordinates === undefined) throw new Error(`ui-deliverables: not a review address "${tab.contentId}"`)
+  const { seq } = coordinates
+  const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd)
+  const summary = useChangesSummary(value => value[changesSummaryUrl(sessionId, seq)])
+  const state = useStore(store => store.byTab[tab.id])
+  const host = usePresentedHost(value => value)
+  // Every navigation to this tab applies its file index once; the first one seeds the tab's choices.
+  useEffect(() => {
+    if (state?.navigated === navigation.revision) return
+    actions.navigated(tab.id, navigation.revision, navigatedIndex(navigation.params) ?? state?.index ?? 0)
+  }, [state, navigation.revision, navigation.params, actions, tab.id])
+  useEffect(() => {
+    const forget = (): void => { actions.forget(tab.id) }
+    signal.addEventListener('abort', forget, { once: true })
+    return () => { signal.removeEventListener('abort', forget) }
+  }, [signal, actions, tab.id])
+  useEffect(() => {
+    if (summary === undefined) void loadChangesSummary(sessionId, seq)
+  }, [summary, sessionId, seq, loadChangesSummary])
+  useEffect(() => {
+    if (host === null) void reloadPresentedHost()
+  }, [host, reloadPresentedHost])
+  const files = typeof summary === 'object' ? summary.files : []
+  // A navigated index the summary does not list falls back to the first file.
+  const index = state !== undefined && files[state.index] !== undefined ? state.index : 0
+  const file = files[index]
+  const diffState = useChangesDiff(value => file === undefined ? undefined : value[changesDiffUrl(sessionId, seq, index)])
+  useEffect(() => {
+    if (file !== undefined && diffState === undefined) void loadChangesDiff(sessionId, seq, index)
+  }, [file, diffState, sessionId, seq, index, loadChangesDiff])
+  const phase = usePresentedOpen(value => file === undefined ? undefined : value[changedFileUrl(sessionId, seq, index)])
+  const [menuOpen, setMenuOpen] = useState(false)
+  const split = state?.split === true
+  const wrap = state?.wrap === true
+  const native = host !== null && host !== 'error' && host.available && phase !== 'nativeUnavailable'
+  const summaryState = summary === undefined || summary === 'loading' ? 'loading' : summary === 'missing' ? 'missing' : 'ready'
+  return (
+    <div className={css.root} data-changes-review data-review-state={summaryState}>
+      <div className={css.header}>
+        {file === undefined
+          ? <span className={css.selectorLabel}>{t('review.title', { turn: String(coordinates.turn) })}</span>
+          : <Menu className={css.selector} open={menuOpen} autoFocus portal align="start" dense onClose={() => { setMenuOpen(false) }}
+            anchor={<button type="button" className={css.selectorButton} aria-haspopup="menu" aria-expanded={menuOpen}
+              aria-label={t('review.selectFile')} title={file.display} data-review-file={file.path}
+              onClick={() => { setMenuOpen(value => !value) }}>
+              <span className={css.selectorText}>{file.display}</span>
+              <IconChevronDownOutline14 size={12} />
+            </button>}
+            items={files.map((entry, at) => ({ id: String(at), label: <span className={css.item}>
+              <span className={css.itemPath}>{entry.display}</span>
+              <span className={css.itemCounts}><Counts file={entry} t={t} /></span>
+            </span> }))}
+            selectedId={String(index)}
+            onSelect={(id) => { actions.selected(tab.id, Number(id)); setMenuOpen(false) }} />}
+        {file !== undefined && <span className={css.counts}><Counts file={file} t={t} /></span>}
+        <span className={css.tools}>
+          <Tooltip label={t(split ? 'review.unified' : 'review.split')} side="bottom" delayMs={500}>
+            <button type="button" className={css.tool} aria-pressed={split} aria-label={t('review.splitAria')} data-review-tool="split"
+              onClick={() => { actions.toggledSplit(tab.id) }}><IconPanelLeftOutline16 /></button>
+          </Tooltip>
+          <Tooltip label={t(wrap ? 'review.nowrap' : 'review.wrap')} side="bottom" delayMs={500}>
+            <button type="button" className={css.tool} aria-pressed={wrap} aria-label={t('review.wrapAria')} data-review-tool="wrap"
+              onClick={() => { actions.toggledWrap(tab.id) }}><IconWrapLinesOutline16 /></button>
+          </Tooltip>
+          {file !== undefined && <Tooltip label={t('review.openFile')} side="bottom" delayMs={500}>
+            <button type="button" className={css.tool} aria-label={t('review.openFileAria', { name: file.display })} data-review-tool="open-file"
+              onClick={() => { tab.actions.openResource(fileAddressFor(sessionId, cwd, file.path)) }}><IconCodeOutline16 /></button>
+          </Tooltip>}
+          {file !== undefined && native && <Tooltip label={t(phase === 'error' ? 'diff.openNativeError' : 'diff.openNative')} side="bottom" delayMs={500}>
+            <button type="button" className={css.tool} disabled={phase === 'opening'} data-review-tool="open-native"
+              aria-label={t('diff.openNativeAria', { name: file.display })} data-error={phase === 'error' || undefined}
+              onClick={() => { void openChanged(sessionId, seq, index) }}><IconRightUpOutline16 /></button>
+          </Tooltip>}
+        </span>
+      </div>
+      {summaryState === 'loading' && <p className={css.status} role="status">{t('diff.loading')}</p>}
+      {summaryState === 'missing' && <p className={css.status}>{t('diff.missing')}</p>}
+      {file !== undefined && <FileBody state={diffState} split={split} wrap={wrap} t={t}
+        retry={() => { void loadChangesDiff(sessionId, seq, index) }} />}
+    </div>
+  )
+}
+
+/** The selected file's comparison, or the state that stands in for it. */
+function FileBody({ state, split, wrap, retry, t }: {
+  state: ChangesDiffStore['state'] extends { getSnapshot(): infer S } ? (S extends Record<string, infer V> ? V : never) : never
+  split: boolean
+  wrap: boolean
+  retry: () => void
+} & PropsLocale<typeof NS>): ReactNode {
+  if (state === undefined || state === 'loading') return <p className={css.status} role="status">{t('diff.loading')}</p>
+  if (state === 'missing') return <p className={css.status}>{t('diff.missing')}</p>
+  if (state === 'error') {
+    return <div className={css.status}><span>{t('diff.error')}</span><Button size="sm" onClick={retry}>{t('presented.retry')}</Button></div>
+  }
+  if (state.kind === 'binary') return <p className={css.status}>{t('diff.binary')}</p>
+  if (state.kind === 'oversized') return <p className={css.status}>{t('diff.oversized')}</p>
+  return <TextDiff diff={state} split={split} wrap={wrap} t={t} />
+}
+
+/** The hunks of a text comparison with their line numbers, unified or side by side. */
+function TextDiff({ diff, split, wrap, t }: { diff: Extract<ChangesDiff, { kind: 'text' }>; split: boolean; wrap: boolean } & PropsLocale<typeof NS>): ReactNode {
+  const note = noteOf(diff)
+  const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks])
+  return (
+    <div className={css.body} data-review-view={split ? 'split' : 'unified'} data-review-wrap={wrap || undefined}>
+      {note !== undefined && <p className={css.note}>{t(note)}</p>}
+      {diff.coarse && <p className={css.note} data-diff-coarse>{t('diff.coarse')}</p>}
+      {truncated && <p className={css.note} data-diff-truncated>{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}</p>}
+      {hunks.map((hunk, position) => (
+        <section key={position} className={css.hunk}>
+          <div className={css.hunkHeader}>{`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`}</div>
+          {split ? splitRows(hunk).map((row, at) => (
+            <div key={at} className={css.splitLine} data-diff-line={row.left?.kind === 'del' ? 'del' : row.right?.kind === 'add' ? 'add' : 'context'}>
+              <span className={`${css.cell} ${row.left === undefined ? css.empty : css[row.left.kind]}`}>
+                <span className={css.number}>{row.left?.no ?? ''}</span>
+                <span className={css.text}>{row.left?.text ?? ''}</span>
+              </span>
+              <span className={`${css.cell} ${row.right === undefined ? css.empty : css[row.right.kind]}`}>
+                <span className={css.number}>{row.right?.no ?? ''}</span>
+                <span className={css.text}>{row.right?.text ?? ''}</span>
+              </span>
+            </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>
+  )
+}

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

@@ -1,34 +0,0 @@
-/**
- * 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)
-    },
-  }
-}

+ 19 - 15
packages/client/ui-deliverables/src/client/index.ts

@@ -1,8 +1,8 @@
 /**
  * Deliverables plugin, browser half: registers the changed-files card and
- * 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
+ * delivery cards into the chat view's turn-tail chain, the `changes-review`
+ * right-Sidebar tab type that reviews one turn's changed files one comparison
+ * at a time, 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
@@ -16,14 +16,15 @@ 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 { changesReviewAddress } 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 { ReviewTab, type ReviewInjected } from './ReviewTab.tsx'
+import { CHANGES_REVIEW_ID, changesReviewDefinition } from './review-definition.ts'
+import { createReviewStore } from './review-store.ts'
 import { en, NS, zh, type DeliverablesKey } from './locales.ts'
 import {
   deliverablesDefinition, presentedForClosing, producedFileMentions, selectProducedFiles,
@@ -31,7 +32,7 @@ import {
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
-    /** Changed-files card, comparison tab, delivery card, and file-mention copy. */
+    /** Changed-files card, review tab, delivery card, and file-mention copy. */
     'deliverables': DeliverablesKey
   }
 }
@@ -67,29 +68,32 @@ 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)) },
+        openChangesReview: (coordinates, index) => {
+          ctx.sidebarRight.openResource(changesReviewAddress(coordinates), { params: { index } })
+        },
       }),
     }, 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')
+  const t = ctx.locale.bind(NS)
+  ctx.effect(() => ctx.sidebarRightTabs.register(changesReviewDefinition(t)), 'ui-deliverables: changes-review 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 },
+      name: 'sidebar.right.pane.tab', key: CHANGES_REVIEW_ID, locale: NS, store: createReviewStore(),
+      inject: (): ReviewInjected => ({
+        hooks: { changesSummary: summaries.state, changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host },
+        loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq),
         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')
+    ReviewTab,
+  )), 'ui-deliverables: changes-review 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)
   const mentions: ChatFileMentions = {
     forClosing(owner) {
       const paths = selectProducedFiles(owner)

+ 22 - 0
packages/client/ui-deliverables/src/client/locales.ts

@@ -51,6 +51,17 @@ export const zh = {
   'changes.folderError': '无法打开文件夹,点击重试',
   'changes.oversized': '过大',
   'changes.viewDiff': '查看 {name} 的改动',
+  'review.title': '第 {turn} 轮改动',
+  'review.selectFile': '选择要查看的文件',
+  'review.split': '切换为左右对比',
+  'review.unified': '切换为单栏对比',
+  'review.splitAria': '左右对比',
+  'review.wrap': '开启自动换行',
+  'review.nowrap': '关闭自动换行',
+  'review.wrapAria': '自动换行',
+  'review.openFile': '在侧边栏打开整个文件',
+  'review.openFileAria': '在侧边栏打开 {name}',
+  'diff.openNativeAria': '用默认应用打开 {name}',
   'diff.loading': '正在读取改动…',
   'diff.missing': '这轮改动的内容已不可用',
   'diff.error': '无法读取改动',
@@ -113,6 +124,17 @@ export const en: Record<DeliverablesKey, string> = {
   'changes.folderError': 'Could not open folder. Click to retry.',
   'changes.oversized': 'too large',
   'changes.viewDiff': 'View changes to {name}',
+  'review.title': 'Review · turn {turn}',
+  'review.selectFile': 'Choose the file to review',
+  'review.split': 'Switch to split view',
+  'review.unified': 'Switch to unified view',
+  'review.splitAria': 'Split view',
+  'review.wrap': 'Enable line wrap',
+  'review.nowrap': 'Disable line wrap',
+  'review.wrapAria': 'Line wrap',
+  'review.openFile': 'Open the whole file in the sidebar',
+  'review.openFileAria': 'Open {name} in sidebar',
+  'diff.openNativeAria': 'Open {name} in default app',
   'diff.loading': 'Reading changes…',
   'diff.missing': 'The contents of this turn’s changes are no longer available',
   'diff.error': 'Could not read the changes',

+ 50 - 0
packages/client/ui-deliverables/src/client/review-definition.ts

@@ -0,0 +1,50 @@
+/**
+ * What the `changes-review` tab type IS: the right-Sidebar review of one
+ * turn's changed files, showing one file's turn-start and turn-end
+ * comparison at a time. It claims the `dsh-resource://changes-review/session/…`
+ * addresses the changed-files card mints; the Session and the event sequence
+ * in the address identify the content, and the turn they carry names the tab.
+ * A row opens the tab with the file's index as its navigation parameter.
+ */
+import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
+import { parseChangesReviewAddress } from '../changes.ts'
+import type { NS } from './locales.ts'
+
+/** The tab kind this package owns. */
+export const CHANGES_REVIEW_KIND = 'changes-review'
+
+/** This implementation's identity in the tab system, and the key its body registers under. */
+export const CHANGES_REVIEW_ID = '@deepseek-ai/dsh-client-ui-deliverables'
+
+/** The file a review opens on. */
+export interface ChangesReviewParams {
+  /** Original index in the summary's files array. */
+  index?: number
+}
+
+declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {
+  interface SidebarRightResourceParamsMap {
+    /** The file a review tab opens on. */
+    'changes-review': ChangesReviewParams
+  }
+}
+
+/**
+ * The review type's registry definition.
+ * @param t - namespace-bound translate, read fresh on every title call.
+ * @returns the definition to register.
+ */
+export function changesReviewDefinition(t: TranslateNS<typeof NS>): SidebarRightTabDefinition {
+  return {
+    id: CHANGES_REVIEW_ID,
+    kind: CHANGES_REVIEW_KIND,
+    patterns: ['dsh-resource://changes-review/**'],
+    priority: 'builtin',
+    canOpen: address => parseChangesReviewAddress(address) !== undefined,
+    title: (address) => {
+      const turn = parseChangesReviewAddress(address)?.turn
+      return turn === undefined ? address : t('review.title', { turn: String(turn) })
+    },
+  }
+}

+ 92 - 0
packages/client/ui-deliverables/src/client/review-store.ts

@@ -0,0 +1,92 @@
+/**
+ * The review tab's view state: which listed file is shown, whether hunks are
+ * drawn side by side, and whether long lines wrap. One bucket per tab, so two
+ * reviews in one session keep their own choices; the bucket ends with the
+ * tab record's signal.
+ */
+import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+
+/** One review tab's choices. */
+export interface ReviewTabState {
+  /** Original index of the shown file in the summary's files array. */
+  index: number
+  /** Whether deletions and additions are drawn in two columns. */
+  split: boolean
+  /** Whether long lines wrap instead of scrolling. */
+  wrap: boolean
+  /** The navigation revision whose file index was last applied. */
+  navigated: number
+}
+
+/** Every review tab's choices, keyed by tab id. */
+export interface ReviewState {
+  byTab: Record<TabId, ReviewTabState>
+}
+
+function bucket(state: ReviewState, tabId: TabId): ReviewTabState {
+  const tab = state.byTab[tabId]
+  if (tab === undefined) throw new Error(`ui-deliverables: no review state for tab "${tabId}"`)
+  return tab
+}
+
+/** The review store's write set; every action names the tab it writes. */
+type ReviewActions = {
+  navigated: (draft: ReviewState, tabId: TabId, revision: number, index: number) => void
+  selected: (draft: ReviewState, tabId: TabId, index: number) => void
+  toggledSplit: (draft: ReviewState, tabId: TabId) => void
+  toggledWrap: (draft: ReviewState, tabId: TabId) => void
+  forget: (draft: ReviewState, tabId: TabId) => void
+}
+
+/**
+ * Declare the review tab's store; the registration declares it as an
+ * exclusive store, so the framework mints one instance per session.
+ * @returns the store handle to declare on the registration.
+ */
+export function createReviewStore(): EngineStoreHandle<ReviewState, ReviewActions> {
+  return defineStore({
+    init: (): ReviewState => ({ byTab: {} }),
+    actions: {
+      /**
+       * Apply a navigation: seed the tab on its first one, then show the navigated file.
+       * @param d - draft state.
+       * @param tabId - the tab being drawn.
+       * @param revision - the navigation revision being applied.
+       * @param index - the file index the navigation named, or the current one.
+       */
+      navigated: (d, tabId: TabId, revision: number, index: number) => {
+        const tab = d.byTab[tabId]
+        if (tab === undefined) d.byTab[tabId] = { index, split: false, wrap: false, navigated: revision }
+        else { tab.index = index; tab.navigated = revision }
+      },
+      /**
+       * Show another listed file.
+       * @param d - draft state.
+       * @param tabId - the tab being drawn.
+       * @param index - original index in the summary's files array.
+       */
+      selected: (d, tabId: TabId, index: number) => { bucket(d, tabId).index = index },
+      /**
+       * Switch between the unified and the side-by-side view.
+       * @param d - draft state.
+       * @param tabId - the tab being drawn.
+       */
+      toggledSplit: (d, tabId: TabId) => { const tab = bucket(d, tabId); tab.split = !tab.split },
+      /**
+       * Switch line wrapping.
+       * @param d - draft state.
+       * @param tabId - the tab being drawn.
+       */
+      toggledWrap: (d, tabId: TabId) => { const tab = bucket(d, tabId); tab.wrap = !tab.wrap },
+      /**
+       * Drop a tab's bucket once its record is gone.
+       * @param d - draft state.
+       * @param tabId - the tab that ended.
+       */
+      forget: (d, tabId: TabId) => {
+        d.byTab = Object.fromEntries(Object.entries(d.byTab).filter(([id]) => id !== tabId))
+      },
+    },
+  })
+}

+ 63 - 0
packages/client/ui-deliverables/tests/changes-diff.client.spec.ts

@@ -0,0 +1,63 @@
+// @vitest-environment jsdom
+/** The comparison store: served and missing comparisons stay, failures retry, resets and disposal forget. */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
+import { changesDiffUrl, type ChangesDiff } from '../src/changes.ts'
+import { ChangesDiffStore } from '../src/client/changes-diff.ts'
+
+afterEach(() => { vi.unstubAllGlobals() })
+
+const SESSION = SessionId('viewed')
+const URL_ = changesDiffUrl(SESSION, 5, 1)
+const text: ChangesDiff = {
+  kind: 'text', path: 'a.ts', display: 'a.ts', before: true, after: true, coarse: false,
+  hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
+}
+
+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()
+  })
+})

+ 15 - 11
packages/client/ui-deliverables/tests/deliverables.client.spec.tsx

@@ -23,7 +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 type { ReviewInjected } from '../src/client/ReviewTab.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'
@@ -49,7 +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']>(),
+    openChangesReview: vi.fn<DeliverablesInjected['openChangesReview']>(),
     usePresentedOpen: <T,>(select: (state: ReturnType<typeof controller.state.getSnapshot>) => T): T =>
       select(controller.state.getSnapshot()),
   }
@@ -567,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, opens each row’s comparison, and opens the folder natively', () => {
+  it('summarizes the turn, folds after three rows, opens the review on each row, 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')
@@ -579,7 +579,7 @@ describe('ChangedFiles card', () => {
     expect(within(card).getByText('+42')).toBeTruthy()
     expect(within(card).queryByText('src/index.ts')).toBeNull()
     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.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 1)
     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)
@@ -592,7 +592,7 @@ describe('ChangedFiles card', () => {
     expect(within(card).getByText('binary')).toBeTruthy()
     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' })
+    expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 4)
     const collapse = within(card).getByRole('button', { name: 'Collapse changed files' })
     expect(collapse.getAttribute('aria-expanded')).toBe('true')
     expect(card.lastElementChild).toBe(collapse)
@@ -600,7 +600,7 @@ describe('ChangedFiles card', () => {
     expect(within(card).getAllByRole('listitem')).toHaveLength(3)
   })
 
-  it('still opens each row’s comparison and offers no folder action without a desktop', () => {
+  it('still opens the review on each row and offers no folder action without a desktop', () => {
     const controller = new PresentedOpenController()
     const { openFile, props, view } = renderCard(controller, zh)
     controller.host.set('error')
@@ -611,7 +611,7 @@ describe('ChangedFiles card', () => {
     expect(view.getByText('已编辑 11 个文件')).toBeTruthy()
     expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull()
     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(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 0)
     expect(openFile).not.toHaveBeenCalled()
     expect(props.openChanged).not.toHaveBeenCalled()
     expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件')
@@ -724,7 +724,7 @@ 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/**'] })
+    expect(registered).toMatchObject({ kind: 'changes-review', patterns: ['dsh-resource://changes-review/**'] })
     const [tabEntry] = ctx.slots.entries('sidebar.right.pane.tab')
     expect(tabEntry?.options.key).toBe('@deepseek-ai/dsh-client-ui-deliverables')
 
@@ -770,9 +770,13 @@ 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
+    face.openChangesReview({ sessionId: SessionId('child-session'), seq: 5, turn: 3 }, 1)
+    expect(openResource).toHaveBeenCalledWith('dsh-resource://changes-review/session/child-session/5/3', { params: { index: 1 } })
+    expect((registered as { title(address: string): string }).title('dsh-resource://changes-review/session/child-session/5/3')).toBe('Review · turn 3')
+    const tabFace = tabEntry!.inject!(SessionId('child-session') as never) as unknown as ReviewInjected
+    fetcher.mockResolvedValueOnce(Response.json({ turn: 3, files: [], total: 0, added: 0, deleted: 0 }))
+    await tabFace.loadChangesSummary(SessionId('child-session'), 6)
+    expect(tabFace.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 })
     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' })

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

@@ -1,237 +0,0 @@
-// @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, MAX_RENDERED_LINES, renderedHunks, 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('stops drawing after the rendered-line cap and says so', () => {
-    const long = (count: number, prefix = '+'): string[] => Array.from({ length: count }, (_, at) => `${prefix}line ${at}`)
-    const whole = { oldStart: 1, oldLines: 0, newStart: 1, newLines: MAX_RENDERED_LINES + 2, lines: long(MAX_RENDERED_LINES + 2) }
-    const cut = renderedHunks([whole])
-    expect(cut.truncated).toBe(true)
-    expect(cut.hunks[0]!.lines).toHaveLength(MAX_RENDERED_LINES)
-    const small = { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }
-    expect(renderedHunks([small, small])).toEqual({ hunks: [small, small], truncated: false })
-    // A later hunk past the budget is dropped whole, an earlier one shortened.
-    const two = renderedHunks([{ ...whole, lines: long(MAX_RENDERED_LINES) }, small])
-    expect(two.hunks).toHaveLength(1)
-    expect(two.truncated).toBe(true)
-    const diffs = new ChangesDiffStore()
-    diffs.state.set({ [URL_]: { ...text, hunks: [{ ...whole, lines: long(MAX_RENDERED_LINES + 1) }] } })
-    const { view } = mount(diffs)
-    expect(view.container.querySelectorAll('[data-diff-line]')).toHaveLength(MAX_RENDERED_LINES)
-    expect(view.container.querySelector('[data-diff-truncated]')?.textContent).toBe(`Showing the first ${MAX_RENDERED_LINES} lines`)
-  })
-
-  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()
-  })
-})

+ 324 - 0
packages/client/ui-deliverables/tests/review-tab.client.spec.tsx

@@ -0,0 +1,324 @@
+// @vitest-environment jsdom
+/** The review tab type: its addresses, its store, the row pairing of the split view, and the states its body draws. */
+import { useSyncExternalStore } from 'react'
+import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { SidebarRightTabActions } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
+import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
+import type { WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types'
+import {
+  changesDiffUrl, changesReviewAddress, changesSummaryUrl, parseChangesReviewAddress, type ChangesDiff, type ChangesSummary,
+} from '../src/changes.ts'
+import { ChangesDiffStore } from '../src/client/changes-diff.ts'
+import { ChangesSummaryStore } from '../src/client/changes-summary.ts'
+import { PresentedOpenController } from '../src/client/present-open.ts'
+import {
+  hunkRows, MAX_RENDERED_LINES, renderedHunks, ReviewTab, splitRows, type ReviewInjected, type ReviewTabProps,
+} from '../src/client/ReviewTab.tsx'
+import { changesReviewDefinition } from '../src/client/review-definition.ts'
+import { createReviewStore } from '../src/client/review-store.ts'
+import { en, zh } from '../src/client/locales.ts'
+
+afterEach(() => {
+  cleanup()
+  vi.unstubAllGlobals()
+})
+
+const SESSION = SessionId('viewed')
+const TAB = 'tab-1' as TabId
+const COORDINATES = { sessionId: SESSION, seq: 5, turn: 2 }
+const ADDRESS = changesReviewAddress(COORDINATES)
+const SUMMARY_URL = changesSummaryUrl(SESSION, 5)
+
+const summary: ChangesSummary = {
+  turn: 2, total: 4, added: 4, deleted: 2,
+  files: [
+    { path: 'src/app/main.ts', display: 'src/app/main.ts', added: 3, deleted: 1 },
+    { path: '/tmp/out/big.bin', display: '~/out/big.bin', added: 0, deleted: 0, oversized: true },
+    { path: 'notes.txt', display: 'notes.txt', added: 1, deleted: 1 },
+    { path: 'logo.png', display: 'logo.png', added: 0, deleted: 0, binary: true },
+  ],
+}
+
+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'] },
+    { oldStart: 20, oldLines: 1, newStart: 22, newLines: 0, lines: ['-z'] },
+  ],
+}
+
+/** Test-local selector hook over a framework-neutral store instance. */
+function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
+  return function useSelector<S>(sel: (s: T) => S): S {
+    return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
+  }
+}
+
+describe('review addresses', () => {
+  it('round-trips coordinates and titles the tab by the turn', () => {
+    expect(ADDRESS).toBe('dsh-resource://changes-review/session/viewed/5/2')
+    expect(parseChangesReviewAddress(ADDRESS)).toEqual(COORDINATES)
+    const definition = changesReviewDefinition(makeTranslate(zh))
+    expect(definition).toMatchObject({ kind: 'changes-review', priority: 'builtin', patterns: ['dsh-resource://changes-review/**'] })
+    expect(definition.canOpen?.(ADDRESS)).toBe(true)
+    expect(definition.title(ADDRESS)).toBe('第 2 轮改动')
+    for (const bad of [
+      'dsh-resource://file/session/viewed/a.ts', 'dsh-resource://changes-review/session/viewed/5',
+      'dsh-resource://changes-review/session//5/2', 'dsh-resource://changes-review/session/viewed/x/2',
+      'dsh-resource://changes-review/session/viewed/5/0', 'dsh-resource://changes-review/session/%E0%A4%A/5/2',
+    ]) {
+      expect(parseChangesReviewAddress(bad)).toBeUndefined()
+      expect(definition.canOpen?.(bad)).toBe(false)
+      expect(definition.title(bad)).toBe(bad)
+    }
+  })
+})
+
+describe('review store', () => {
+  it('seeds a tab on its first navigation, applies later ones, toggles views, and forgets', () => {
+    const store = createReviewStore().create()
+    store.actions.navigated(TAB, 1, 2)
+    expect(store.getSnapshot().byTab[TAB]).toEqual({ index: 2, split: false, wrap: false, navigated: 1 })
+    store.actions.toggledSplit(TAB)
+    store.actions.toggledWrap(TAB)
+    store.actions.selected(TAB, 0)
+    store.actions.navigated(TAB, 2, 1)
+    expect(store.getSnapshot().byTab[TAB]).toEqual({ index: 1, split: true, wrap: true, navigated: 2 })
+    store.actions.forget(TAB)
+    expect(store.getSnapshot().byTab[TAB]).toBeUndefined()
+    expect(() => { store.actions.selected(TAB, 0) }).toThrow('no review state')
+  })
+})
+
+describe('hunk rows', () => {
+  it('numbers unified rows on the side they belong to and pairs deletions with additions for the split view', () => {
+    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' },
+    ])
+    expect(splitRows(text.hunks[0]!)).toEqual([
+      { left: { no: 1, text: 'a', kind: 'context' }, right: { no: 1, text: 'a', kind: 'context' } },
+      { left: { no: 2, text: 'b', kind: 'del' }, right: { no: 2, text: 'B', kind: 'add' } },
+      { right: { no: 3, text: 'c', kind: 'add' } },
+      { left: { no: 3, text: 'd', kind: 'context' }, right: { no: 4, text: 'd', kind: 'context' } },
+    ])
+    // A trailing run of deletions with no additions ends the hunk on the left alone.
+    expect(splitRows({ oldStart: 1, oldLines: 2, newStart: 1, newLines: 0, lines: ['-p', '-q'] })).toEqual([
+      { left: { no: 1, text: 'p', kind: 'del' } }, { left: { no: 2, text: 'q', kind: 'del' } },
+    ])
+  })
+
+  it('cuts the drawn hunks at the rendered-line cap', () => {
+    const long = (count: number): string[] => Array.from({ length: count }, (_, at) => `+line ${at}`)
+    const whole: WorkspaceDiffHunk = {
+      oldStart: 1, oldLines: 0, newStart: 1, newLines: MAX_RENDERED_LINES + 2, lines: long(MAX_RENDERED_LINES + 2),
+    }
+    const cut = renderedHunks([whole])
+    expect(cut.truncated).toBe(true)
+    expect(cut.hunks[0]!.lines).toHaveLength(MAX_RENDERED_LINES)
+    const small: WorkspaceDiffHunk = { oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }
+    expect(renderedHunks([small, small])).toEqual({ hunks: [small, small], truncated: false })
+    const two = renderedHunks([{ ...whole, lines: long(MAX_RENDERED_LINES) }, small])
+    expect(two.hunks).toHaveLength(1)
+    expect(two.truncated).toBe(true)
+  })
+})
+
+describe('ReviewTab', () => {
+  function mount(options: {
+    summaries?: ChangesSummaryStore
+    diffs?: ChangesDiffStore
+    controller?: PresentedOpenController
+    locale?: typeof en
+    params?: unknown
+    revision?: number
+    address?: string
+    cwd?: string | undefined
+  } = {}) {
+    const summaries = options.summaries ?? new ChangesSummaryStore()
+    const diffs = options.diffs ?? new ChangesDiffStore()
+    const controller = options.controller ?? new PresentedOpenController()
+    const store = createReviewStore().create()
+    const aborter = new AbortController()
+    const tabActions = {
+      openResource: vi.fn<SidebarRightTabActions['openResource']>(),
+      openTab: vi.fn<SidebarRightTabActions['openTab']>(),
+      close: vi.fn<SidebarRightTabActions['close']>(),
+    }
+    const injected = {
+      loadChangesSummary: vi.fn<ReviewInjected['loadChangesSummary']>(() => Promise.resolve()),
+      loadChangesDiff: vi.fn<ReviewInjected['loadChangesDiff']>(() => Promise.resolve()),
+      reloadPresentedHost: vi.fn<ReviewInjected['reloadPresentedHost']>(() => Promise.resolve()),
+      openChanged: vi.fn<ReviewInjected['openChanged']>(() => Promise.resolve()),
+    }
+    const sessions = { byId: { [SESSION]: { cwd: options.cwd ?? '/work/app' } } } as unknown as SessionListState
+    const navigation = { address: options.address ?? ADDRESS, params: options.params, revision: options.revision ?? 1 }
+    const runtime = {
+      useTabInfo: () => ({
+        sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-1' },
+        tab: { id: TAB, kind: 'changes-review', contentId: options.address ?? ADDRESS, title: 'Review', visible: true, navigation, signal: aborter.signal, actions: tabActions },
+      }),
+      sessionId: SESSION,
+      useSessions: <T,>(select: (state: SessionListState) => T): T => select(sessions),
+      useStore: hookOf(store),
+      actions: store.actions,
+      useChangesSummary: hookOf(summaries.state),
+      useChangesDiff: hookOf(diffs.state),
+      usePresentedOpen: hookOf(controller.state),
+      usePresentedHost: hookOf(controller.host),
+      t: makeTranslate(options.locale ?? en),
+      ...injected,
+    } as unknown as ReviewTabProps
+    const view = render(<ReviewTab {...runtime} />)
+    return {
+      view, injected, tabActions, store, aborter, summaries, diffs, controller,
+      rerender: () => { view.rerender(<ReviewTab {...runtime} />) },
+    }
+  }
+
+  it('reads the summary, opens on the navigated file, and switches files through the selector', () => {
+    const summaries = new ChangesSummaryStore()
+    const diffs = new ChangesDiffStore()
+    const { view, injected, store } = mount({ summaries, diffs, params: { index: 2 } })
+    expect(injected.loadChangesSummary).toHaveBeenCalledWith('viewed', 5)
+    expect(injected.reloadPresentedHost).toHaveBeenCalledTimes(1)
+    expect(view.container.querySelector('[data-changes-review]')?.getAttribute('data-review-state')).toBe('loading')
+    expect(view.getByText('Review · turn 2')).toBeTruthy()
+    act(() => { summaries.state.set({ [SUMMARY_URL]: summary }) })
+    expect(store.getSnapshot().byTab[TAB]).toMatchObject({ index: 2, navigated: 1 })
+    const selector = view.getByRole('button', { name: en['review.selectFile'] })
+    expect(selector.getAttribute('data-review-file')).toBe('notes.txt')
+    expect(injected.loadChangesDiff).toHaveBeenLastCalledWith('viewed', 5, 2)
+    expect(view.getAllByText('+1')).toHaveLength(1)
+    fireEvent.click(selector)
+    expect(selector.getAttribute('aria-expanded')).toBe('true')
+    // Escape closes the selector without changing the file.
+    fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' })
+    expect(view.getByRole('button', { name: en['review.selectFile'] }).getAttribute('aria-expanded')).toBe('false')
+    fireEvent.click(view.getByRole('button', { name: en['review.selectFile'] }))
+    const items = view.getAllByRole('menuitem')
+    expect(items.map(item => item.textContent)).toEqual([
+      'src/app/main.ts+3-1', '~/out/big.bin' + en['changes.oversized'], 'notes.txt+1-1', 'logo.png' + en['changes.binary'],
+    ])
+    fireEvent.click(items[0]!)
+    expect(store.getSnapshot().byTab[TAB]?.index).toBe(0)
+    expect(view.getByRole('button', { name: en['review.selectFile'] }).getAttribute('data-review-file')).toBe('src/app/main.ts')
+    expect(injected.loadChangesDiff).toHaveBeenLastCalledWith('viewed', 5, 0)
+    act(() => { diffs.state.set({ [changesDiffUrl(SESSION, 5, 0)]: text }) })
+    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', 'del'])
+    expect(lines[1]?.textContent).toBe('2-b')
+    expect(lines[4]?.textContent).toBe('34 d')
+    expect(view.container.querySelector('[data-review-view]')?.getAttribute('data-review-view')).toBe('unified')
+  })
+
+  it('draws the split view and wraps lines on request, and keeps both choices in the tab store', () => {
+    const summaries = new ChangesSummaryStore()
+    summaries.state.set({ [SUMMARY_URL]: summary })
+    const diffs = new ChangesDiffStore()
+    diffs.state.set({ [changesDiffUrl(SESSION, 5, 0)]: text })
+    const { view, store } = mount({ summaries, diffs })
+    expect(store.getSnapshot().byTab[TAB]?.index).toBe(0)
+    fireEvent.click(view.getByRole('button', { name: en['review.splitAria'] }))
+    expect(store.getSnapshot().byTab[TAB]?.split).toBe(true)
+    const body = view.container.querySelector('[data-review-view]')
+    expect(body?.getAttribute('data-review-view')).toBe('split')
+    const rows = [...view.container.querySelectorAll('[data-diff-line]')]
+    expect(rows.map(row => row.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'context', 'del', 'del'])
+    expect(rows[1]?.textContent).toBe('2b2B')
+    expect(rows[2]?.textContent).toBe('3c')
+    expect(rows[5]?.textContent).toBe('20z')
+    expect(view.getByRole('button', { name: en['review.splitAria'] }).getAttribute('aria-pressed')).toBe('true')
+    expect(body?.hasAttribute('data-review-wrap')).toBe(false)
+    fireEvent.click(view.getByRole('button', { name: en['review.wrapAria'] }))
+    expect(view.container.querySelector('[data-review-view]')?.hasAttribute('data-review-wrap')).toBe(true)
+    expect(store.getSnapshot().byTab[TAB]).toMatchObject({ split: true, wrap: true })
+  })
+
+  it('opens the whole file in the sidebar and the native open only with a desktop', () => {
+    const summaries = new ChangesSummaryStore()
+    summaries.state.set({ [SUMMARY_URL]: summary })
+    const controller = new PresentedOpenController()
+    controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' })
+    const { view, injected, tabActions } = mount({ summaries, controller, params: { index: 1 } })
+    expect(injected.reloadPresentedHost).not.toHaveBeenCalled()
+    fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in sidebar' }))
+    expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, '/work/app', '/tmp/out/big.bin'))
+    fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }))
+    expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1)
+    act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) })
+    expect((view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }) as HTMLButtonElement).disabled).toBe(true)
+    act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) })
+    expect(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }).hasAttribute('data-error')).toBe(true)
+    act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) })
+    expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull()
+    act(() => {
+      controller.state.set({})
+      controller.host.set({ name: 'server', available: false, fileManager: null })
+    })
+    expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull()
+    expect(view.getByText(en['changes.oversized'])).toBeTruthy()
+  })
+
+  it('states the summary and comparison that stand in for hunks, and retries a failed read', () => {
+    const summaries = new ChangesSummaryStore()
+    summaries.state.set({ [SUMMARY_URL]: 'missing' })
+    const missing = mount({ summaries, locale: zh })
+    expect(missing.view.getByText(zh['diff.missing'])).toBeTruthy()
+    expect(missing.view.getByText('第 2 轮改动')).toBeTruthy()
+    missing.view.unmount()
+    summaries.state.set({ [SUMMARY_URL]: summary })
+    const diffs = new ChangesDiffStore()
+    const { view, injected } = mount({ summaries, diffs, locale: zh })
+    const url = changesDiffUrl(SESSION, 5, 0)
+    expect(view.getByRole('status').textContent).toBe(zh['diff.loading'])
+    act(() => { diffs.state.set({ [url]: 'error' }) })
+    fireEvent.click(view.getByRole('button', { name: zh['presented.retry'] }))
+    expect(injected.loadChangesDiff).toHaveBeenCalledTimes(2)
+    act(() => { diffs.state.set({ [url]: 'missing' }) })
+    expect(view.getByText(zh['diff.missing'])).toBeTruthy()
+    act(() => { diffs.state.set({ [url]: { kind: 'binary', path: 'p', display: 'p' } }) })
+    expect(view.getByText(zh['diff.binary'])).toBeTruthy()
+    act(() => { diffs.state.set({ [url]: { kind: 'oversized', path: 'p', display: 'p' } }) })
+    expect(view.getByText(zh['diff.oversized'])).toBeTruthy()
+    act(() => { diffs.state.set({ [url]: { ...text, before: false, coarse: true } }) })
+    expect(view.getByText(zh['diff.created'])).toBeTruthy()
+    expect(view.container.querySelector('[data-diff-coarse]')?.textContent).toBe(zh['diff.coarse'])
+    act(() => { diffs.state.set({ [url]: { ...text, after: false } }) })
+    expect(view.getByText(zh['diff.deleted'])).toBeTruthy()
+    act(() => { diffs.state.set({ [url]: { ...text, hunks: [] } }) })
+    expect(view.getByText(zh['diff.unchanged'])).toBeTruthy()
+    const long = Array.from({ length: MAX_RENDERED_LINES + 1 }, (_, at) => `+line ${at}`)
+    act(() => {
+      diffs.state.set({ [url]: { ...text, hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: long.length, lines: long }] } })
+    })
+    expect(view.container.querySelectorAll('[data-diff-line]')).toHaveLength(MAX_RENDERED_LINES)
+    expect(view.container.querySelector('[data-diff-truncated]')?.textContent).toBe(`只显示前 ${MAX_RENDERED_LINES} 行`)
+  })
+
+  it('falls back to the first file for an index the summary does not list, and forgets its state with the tab', () => {
+    const summaries = new ChangesSummaryStore()
+    summaries.state.set({ [SUMMARY_URL]: summary })
+    const { view, store, aborter, injected } = mount({ summaries, params: { index: 9 } })
+    expect(view.getByRole('button', { name: en['review.selectFile'] }).getAttribute('data-review-file')).toBe('src/app/main.ts')
+    expect(injected.loadChangesDiff).toHaveBeenLastCalledWith('viewed', 5, 0)
+    expect(store.getSnapshot().byTab[TAB]).toBeDefined()
+    aborter.abort()
+    expect(store.getSnapshot().byTab[TAB]).toBeUndefined()
+  })
+
+  it('refuses an address it did not mint', () => {
+    const error = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+    expect(() => mount({ address: 'dsh-resource://file/session/viewed/a.ts' })).toThrow('not a review address')
+    error.mockRestore()
+  })
+})

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

@@ -30,6 +30,9 @@
     {
       "path": "../ui-conversation"
     },
+    {
+      "path": "../ui-dockkit"
+    },
     {
       "path": "../ui-chat"
     },

+ 3 - 0
pnpm-lock.yaml

@@ -2993,6 +2993,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-conversation':
         specifier: workspace:^
         version: link:../ui-conversation
+      '@deepseek-ai/dsh-client-ui-dockkit':
+        specifier: workspace:^
+        version: link:../ui-dockkit
       '@deepseek-ai/dsh-client-ui-primitives':
         specifier: workspace:^
         version: link:../ui-primitives