Parcourir la source

fix(deliverables): open the review from the card header and scroll unwrapped split columns separately

The card header opens the turn's review on its first file instead of the common folder, so the folder route and its Host lookup go away and the native open lives only in the review tab. The split view without wrapping draws each side as its own column that scrolls sideways, so a long line no longer runs under the other side; with wrapping both sides still share one row per pair.
creatixchu il y a 1 semaine
Parent
commit
91d9346aa1
23 fichiers modifiés avec 135 ajouts et 183 suppressions
  1. 2 2
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml
  2. 2 2
      .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md
  3. 2 2
      .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. 1 1
      .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.md
  6. 1 1
      .agents/notes/implemented/feature/2026-09-15-changed-file-diff-preview.zh.md
  7. 10 5
      apps/web/tests/changed-files-turn.e2e.ts
  8. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  9. 3 3
      packages/client/ui-deliverables/README.md
  10. 3 3
      packages/client/ui-deliverables/README.zh.md
  11. 5 7
      packages/client/ui-deliverables/src/changes.ts
  12. 13 45
      packages/client/ui-deliverables/src/client/ChangedFiles.tsx
  13. 3 5
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  14. 6 2
      packages/client/ui-deliverables/src/client/ReviewTab.module.css
  15. 43 3
      packages/client/ui-deliverables/src/client/ReviewTab.tsx
  16. 2 6
      packages/client/ui-deliverables/src/client/locales.ts
  17. 3 4
      packages/client/ui-deliverables/src/client/present-open.ts
  18. 3 32
      packages/client/ui-deliverables/src/present-open.ts
  19. 3 20
      packages/client/ui-deliverables/tests/changes-open.host.spec.ts
  20. 11 25
      packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
  21. 3 6
      packages/client/ui-deliverables/tests/present-open.client.spec.ts
  22. 11 4
      packages/client/ui-deliverables/tests/review-tab.client.spec.tsx
  23. 1 1
      snapshots/web/changed-files-turn/ui.expected.md

+ 2 - 2
.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md
-2026-09-11-turn-changed-files-card.md: 4b39e2b339331bbe11ee98373707e1d7310afa3c
-2026-09-11-turn-changed-files-card.zh.md: c0c6f8d161c3e87ae999b1d75a6661fc2a2a8793
+2026-09-11-turn-changed-files-card.md: 0c41783c6d5d02fecd0c85298aed1f07f5c8a65f
+2026-09-11-turn-changed-files-card.zh.md: f417b31b899529b664664926c832c28f4b1be6ba

+ 2 - 2
.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 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 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 and the header opens it on the first file ([review decision](2026-09-15-changed-file-diff-preview.md)); with a Host desktop 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.
 
@@ -50,4 +50,4 @@ Every turn with tool results costs two snapshots and one diff on the Host, and w
 
 The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card whenever a turn changes a file; one dedicated scenario seeds a git repository so the card also carries a shell edit, while the others list their file-tool writes alone. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries.
 
-Focused tests cover repositories with real git, directories outside any repository, coverage classification of captured paths, an interrupted turn overlapped by the next, ordering, caps, disposal, the macOS stub, the changed-file and folder routes, the card's fold and gesture states, and a Loader composition. The keyless Web scenario replays the recorder end to end, including a created file the repository ignores.
+Focused tests cover repositories with real git, directories outside any repository, coverage classification of captured paths, an interrupted turn overlapped by the next, ordering, caps, disposal, the macOS stub, the changed-file route, the card's fold and gesture states, and a Loader composition. The keyless Web scenario replays the recorder end to end, including a created file the repository ignores.

+ 2 - 2
.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 打开本轮的 review tab 并选中该文件([review 决定](2026-09-15-changed-file-diff-preview.zh.md));Host 有桌面时,标题打开包含所列文件的最深工作区文件夹——按工作区相对路径计算,越界时退回工作区根目录——review tab 提供用默认应用打开。
+列表按展示路径的码元顺序排序:相对工作目录的路径,仓库内位于其上的文件为 `../`,家目录下为 `~`,其余为绝对路径;上级路径与绝对路径因此自然排在最前,不需要单独分组。卡片标题显示总数与增删行数合计,折叠前显示三行,展开后底部有收起控件。每一行在右侧 Sidebar 打开本轮的 review tab 并选中该文件,标题则在第一个文件上打开它([review 决定](2026-09-15-changed-file-diff-preview.zh.md));Host 有桌面时,review tab 提供用默认应用打开。
 
 记录器在 `agent/turn-stopping` 时于轮内追加事件,并且仅当最后一次记录之后仍有工具结果结束时在 `turn/end` 后再次追加,因此中止、失败和被转向的轮次都被覆盖。Client 保留每轮最新的宣告,并通过经过认证的摘要路由读取一次摘要。
 
@@ -50,4 +50,4 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。
 
 只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只要某一轮改动了文件就会新增该事件与卡片;一个专门的场景种入 git 仓库,让卡片也带上 shell 的改动,其余场景只列它们的文件工具写入。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。
 
-聚焦测试用真实 git 覆盖仓库、不在任何仓库内的目录、对已捕获路径的覆盖范围分类、被下一轮压上的中断轮次、排序、上限、释放、macOS 桩程序、改动文件与文件夹路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器,其中包括一个被仓库忽略的新建文件。
+聚焦测试用真实 git 覆盖仓库、不在任何仓库内的目录、对已捕获路径的覆盖范围分类、被下一轮压上的中断轮次、排序、上限、释放、macOS 桩程序、改动文件路由、卡片的折叠与手势状态,以及一次 Loader 组合。无密钥的 Web 场景端到端回放记录器,其中包括一个被仓库忽略的新建文件。

+ 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: f939fe224899d8329b4a9c2d8fdc267f250403da
-2026-09-15-changed-file-diff-preview.zh.md: 688bd9c841cda406f2b53825203753aaad4d6004
+2026-09-15-changed-file-diff-preview.md: e265c1f4528f2de3a95ce0fab3e20cab1c83a3f6
+2026-09-15-changed-file-diff-preview.zh.md: 48571cd9866cb01e31609c90500d26b19802bb8e

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

@@ -10,7 +10,7 @@ The [changed-files card](2026-09-11-turn-changed-files-card.md) tells the user w
 
 ## Decision
 
-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.
+Each card row opens the turn's `changes-review` tab in the right Sidebar on that file, and the card's header opens it on the first 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 and the header open the review with and without a desktop, so the earlier row-level native open and the header's folder open move 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.
 

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

@@ -10,7 +10,7 @@ Status: implemented
 
 ## Decision
 
-卡片的每一行在右侧 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,卡片只保留一种行为。
+卡片的每一行在右侧 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。
 

+ 10 - 5
apps/web/tests/changed-files-turn.e2e.ts

@@ -110,8 +110,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
     expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1)
     expect(await card.getByRole('listitem').count()).toBe(3)
     expect(await card.getByRole('button', { name: '展开全部 4 个改动文件' }).count()).toBe(1)
-    // Without a Host desktop the header is a label; every row opens its comparison in the Sidebar.
-    expect(await card.getByRole('button', { name: '打开改动文件所在的文件夹' }).count()).toBe(0)
+    // The header and every row open the turn's review in the Sidebar, with or without a Host desktop.
+    expect(await card.getByRole('button', { name: '在侧边栏查看本轮改动' }).count()).toBe(1)
     expect(await card.getByRole('button', { name: '查看 notes.txt 的改动' }).count()).toBe(1)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
@@ -122,8 +122,11 @@ describe('web e2e: a git workspace turn ends with its 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 review = column.locator('[data-changes-review]')
+    // The header lands on the first listed file; a row lands on its own.
+    await card.getByRole('button', { name: '在侧边栏查看本轮改动' }).click()
+    await review.locator('[data-review-file="app.local"]').waitFor({ state: 'visible' })
+    await card.getByRole('button', { name: '查看 notes.txt 的改动' }).click()
     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'])
@@ -139,9 +142,11 @@ describe('web e2e: a git workspace turn ends with its changed files', () => {
     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 expect.poll(() => drawn(review.locator('[data-diff-side="left"]'))).toEqual(['del:1# 示例项目', 'context:2', 'context:3一个用于演示的仓库。'])
+    expect(await drawn(review.locator('[data-diff-side="right"]'))).toEqual(['del:1# 项目说明', 'context:2', 'context:3一个用于演示的仓库。'])
     await review.getByRole('button', { name: '自动换行' }).click()
-    expect(await review.locator('[data-review-view][data-review-wrap]').count()).toBe(1)
+    await review.locator('[data-review-view][data-review-wrap]').waitFor({ state: 'visible' })
+    await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。'])
     // 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)

+ 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: ab9e25c7b351a8eb82461543654c28155945a63b
-README.zh.md: 194d92305fe30e1836838b3af73172b00608a1a6
+README.md: b0137675a207a7aaee3d38060319d5948c8ba432
+README.zh.md: 8dfc6767919ae186962d49da393ef8316f62c417

+ 3 - 3
packages/client/ui-deliverables/README.md

@@ -36,7 +36,7 @@ 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 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 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, and the header opens it on the first file. 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 review tab
 
@@ -56,7 +56,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv
 
 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 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.
+Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the review tab's native open of a changed file uses the same 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,7 +103,7 @@ 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 review tab offers no native open; the comparison itself needs only the Host that recorded the turn.
+- **Native opening needs a Host desktop** — without one 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.

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

@@ -36,7 +36,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 
 ### 改动文件卡片
 
-卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件。Host 有桌面时,标题通过经过认证的 Host 操作在文件管理器中打开包含所列文件的最深工作区文件夹,并在合计位置显示交付卡片的等待与可重试错误状态;打开成功后重新显示合计。没有桌面时标题是普通标签。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。
+卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。
 
 ### review tab
 
@@ -56,7 +56,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 
 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,之后 review tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
+原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;review tab 对改动文件的原生打开使用同一组坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,之后 review tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
 
 </details>
 
@@ -103,7 +103,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
 - **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和可点击引用仍需调用 `present`。
 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。
-- **打开文件夹和原生打开需要 Host 桌面**——没有桌面时卡片标题不可点击,review tab 也不提供原生打开;对比本身只需要记录了该轮的 Host。
+- **原生打开需要 Host 桌面**——没有桌面时 review tab 不提供原生打开;对比本身只需要记录了该轮的 Host。
 - **对比没有语法高亮**——tab 只显示纯文本 hunk,最多绘制 5000 行并给出提示。
 - **对比携带整个文件的文本**——对比路由会送出所列文件在 Host 上记录时的完整文本,包括被忽略的文件和 Sidebar 预览所限定的工作区根目录之外的文件。
 - **包内的标题字形**——卡片的尖括号标记放在 `src/client/icons.tsx` 中,直到共享图标集收录它;其 props 已与共享图标契约一致。

+ 5 - 7
packages/client/ui-deliverables/src/changes.ts

@@ -8,7 +8,7 @@ export const CHANGED_FILES_PATH = '/api/changes.summary'
 /** Authenticated GET route serving one listed file's turn-start and turn-end comparison while its Session lives. */
 export const CHANGES_DIFF_PATH = '/api/changes.diff'
 
-/** Authenticated POST route for opening a changed file, or the changed files' common folder, on the Host desktop. */
+/** Authenticated POST route for opening a changed file on the Host desktop. */
 export const CHANGES_OPEN_PATH = '/api/changes.open'
 
 /** Resource-address prefix of a turn's review tab in the right Sidebar. */
@@ -112,16 +112,14 @@ export function changesDiffUrl(sessionId: SessionId, seq: number, index: number)
 }
 
 /**
- * Build authenticated coordinates for a changed file or the changed files' common folder.
+ * Build authenticated coordinates for a changed file's native open.
  * @param sessionId - owning Session.
  * @param seq - workspace/changes event sequence.
- * @param index - original index in the summary's files array, or null for the common folder.
+ * @param index - original index in the summary's files array.
  * @returns same-origin action URL.
  */
-export function changedFileUrl(sessionId: SessionId, seq: number, index: number | null): string {
-  const query = new URLSearchParams({ sessionId, seq: String(seq) })
-  if (index !== null) query.set('index', String(index))
-  return `${CHANGES_OPEN_PATH}?${query}`
+export function changedFileUrl(sessionId: SessionId, seq: number, index: number): string {
+  return `${CHANGES_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
 }
 
 /**

+ 13 - 45
packages/client/ui-deliverables/src/client/ChangedFiles.tsx

@@ -1,13 +1,10 @@
-/** 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. */
+/** The changed-files card: a header and per-file rows that open the turn's review, 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'
 import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import { changedFileUrl, type ChangesSummary } from '../changes.ts'
-import type { PresentedHost } from '../presented.ts'
+import type { ChangesSummary } from '../changes.ts'
 import { IconCodeBracketsOutline16 } from './icons.tsx'
-import type { PresentedOpenPhase } from './present-open.ts'
 import type { NS } from './locales.ts'
 import css from './ChangedFiles.module.css'
 
@@ -16,20 +13,6 @@ const COLLAPSED_ROWS = 3
 
 const GROUPED = new Intl.NumberFormat('en-US')
 
-/**
- * Folder gesture state worth showing in place of the totals: pending, or
- * failed. A completed open shows the totals again. The card never reveals, so
- * only open phases occur.
- */
-function gesture(phase: PresentedOpenPhase | undefined): { key: 'presented.opening' | 'presented.error' | 'presented.nativeUnavailable'; failed: boolean } | undefined {
-  switch (phase) {
-    case 'opening': return { key: 'presented.opening', failed: false }
-    case 'error': return { key: 'presented.error', failed: true }
-    case 'nativeUnavailable': return { key: 'presented.nativeUnavailable', failed: true }
-    default: return undefined
-  }
-}
-
 /** Added and deleted line counts in the card's colors. */
 function Counts({ added, deleted, t }: { added: number; deleted: number } & PropsLocale<typeof NS>) {
   return <>
@@ -39,44 +22,29 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop
 }
 
 /**
- * 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.
+ * Render one turn's changed files. The header opens the turn's review in the
+ * right Sidebar on its first file; each row opens it on that row's file.
+ * @param props - the recorded summary, the review opener, and localized copy.
  * @returns the card.
  */
-export function ChangedFiles({ changes, cwd, sessionId, host, phases, onOpen, openReview, t }: {
+export function ChangedFiles({ changes, cwd, 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
-  sessionId: SessionId
-  host: PresentedHost | null
-  phases: Record<string, PresentedOpenPhase | undefined>
-  onOpen: (index: number | null) => 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
   const foldable = changes.files.length > COLLAPSED_ROWS
   const rows = foldable && !expanded ? changes.files.slice(0, COLLAPSED_ROWS) : changes.files
-  const folder = gesture(phases[changedFileUrl(sessionId, changes.seq, null)])
-  const summary = <>
-    <span className={css.tile}><IconCodeBracketsOutline16 size={18} /></span>
-    <span className={css.titles}>
-      <span className={css.title}>{t('changes.title', { count: String(changes.total) })}</span>
-      <span className={css.stat} role={folder === undefined ? undefined : 'status'} data-error={folder?.failed || undefined}>
-        {folder === undefined
-          ? <Counts t={t} added={changes.added} deleted={changes.deleted} />
-          : t(folder.failed ? 'changes.folderError' : 'changes.folderOpening')}
-      </span>
-    </span>
-  </>
   return <div className={css.card} data-changed-files>
-    {native
-      ? <button type="button" className={css.header} aria-label={t('changes.openFolder')}
-        disabled={folder !== undefined && !folder.failed} onClick={() => { onOpen(null) }}>{summary}</button>
-      : <div className={css.header}>{summary}</div>}
+    <button type="button" className={css.header} aria-label={t('changes.openReview')} onClick={() => { openReview(0) }}>
+      <span className={css.tile}><IconCodeBracketsOutline16 size={18} /></span>
+      <span className={css.titles}>
+        <span className={css.title}>{t('changes.title', { count: String(changes.total) })}</span>
+        <span className={css.stat}><Counts t={t} added={changes.added} deleted={changes.deleted} /></span>
+      </span>
+    </button>
     <ul className={css.list}>
       {rows.map((file, index) => (
         <li key={file.display}>

+ 3 - 5
packages/client/ui-deliverables/src/client/Deliverables.tsx

@@ -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, openChangesReview, usePresentedOpen, usePresentedHost,
+  matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost,
   useChangesSummary, reloadPresentedHost, loadChangesSummary,
 }: Pick<TurnTailOwnerProps, 'openFile'> & {
   matched: DeliverablesMatch
@@ -77,10 +77,8 @@ export function Deliverables({
     if (host === null) void reloadPresentedHost()
   }, [host, reloadPresentedHost])
   return <>
-    {changes !== null && <ChangedFiles changes={changes} cwd={cwd} sessionId={sessionId}
-      host={host === 'error' ? null : host} phases={states} t={t}
-      openReview={(index) => { openChangesReview({ sessionId, seq: changes.seq, turn: changes.turn }, index) }}
-      onOpen={(index) => { void openChanged(sessionId, changes.seq, index) }} />}
+    {changes !== null && <ChangedFiles changes={changes} cwd={cwd} t={t}
+      openReview={(index) => { openChangesReview({ sessionId, seq: changes.seq, turn: changes.turn }, index) }} />}
     {matched.presented.length > 0 && <div
       className={css.root}
       data-after-changes={changes !== null || undefined}

+ 6 - 2
packages/client/ui-deliverables/src/client/ReviewTab.module.css

@@ -1,4 +1,4 @@
-/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. */
+/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns scroll sideways on their own; wrapped ones share rows so the sides stay aligned. */
 .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; }
@@ -20,7 +20,11 @@
 .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); }
+.body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); }
+.columns { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; }
+.column { min-width: 0; overflow-x: auto; }
+.column + .column { border-left: 0.5px solid var(--dsw-alias-border-l3); }
+.sideLine { display: grid; grid-template-columns: 3.5em max-content; box-sizing: border-box; min-width: 100%; width: max-content; min-height: 22px; line-height: 22px; white-space: pre; }
 .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; }

+ 43 - 3
packages/client/ui-deliverables/src/client/ReviewTab.tsx

@@ -261,6 +261,46 @@ function FileBody({ state, split, wrap, retry, t }: {
   return <TextDiff diff={state} split={split} wrap={wrap} t={t} />
 }
 
+/** The kind a paired row carries: a deletion or addition on either side, otherwise context. */
+function splitRowKind(row: SplitRow): DiffRow['kind'] {
+  return row.left?.kind === 'del' ? 'del' : row.right?.kind === 'add' ? 'add' : 'context'
+}
+
+function hunkHeader(hunk: WorkspaceDiffHunk): string {
+  return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`
+}
+
+/**
+ * The side-by-side view without wrapping: two columns that scroll sideways on
+ * their own, so a long line on one side never runs under the other. Every
+ * line is one fixed-height row, which keeps the sides aligned.
+ */
+function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): ReactNode {
+  const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks])
+  return (
+    <div className={css.columns}>
+      {(['left', 'right'] as const).map(side => (
+        <div key={side} className={css.column} data-diff-side={side}>
+          {paired.map((hunk, position) => (
+            <section key={position} className={css.hunk}>
+              <div className={css.hunkHeader}>{hunk.header}</div>
+              {hunk.rows.map((row, at) => {
+                const cell = row[side]
+                return (
+                  <div key={at} className={`${css.sideLine} ${cell === undefined ? css.empty : css[cell.kind]}`} data-diff-line={splitRowKind(row)}>
+                    <span className={css.number}>{cell?.no ?? ''}</span>
+                    <span className={css.text}>{cell?.text ?? ''}</span>
+                  </div>
+                )
+              })}
+            </section>
+          ))}
+        </div>
+      ))}
+    </div>
+  )
+}
+
 /** 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)
@@ -270,11 +310,11 @@ function TextDiff({ diff, split, wrap, t }: { diff: Extract<ChangesDiff, { kind:
       {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) => (
+      {split && !wrap ? <SplitColumns hunks={hunks} /> : hunks.map((hunk, position) => (
         <section key={position} className={css.hunk}>
-          <div className={css.hunkHeader}>{`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`}</div>
+          <div className={css.hunkHeader}>{hunkHeader(hunk)}</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'}>
+            <div key={at} className={css.splitLine} data-diff-line={splitRowKind(row)}>
               <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>

+ 2 - 6
packages/client/ui-deliverables/src/client/locales.ts

@@ -42,13 +42,11 @@ export const zh = {
   'changes.added': '+{count}',
   'changes.deleted': '-{count}',
   'changes.binary': '二进制',
-  'changes.openFolder': '打开改动文件所在的文件夹',
+  'changes.openReview': '在侧边栏查看本轮改动',
   'changes.all': '全部 {count} 个文件',
   'changes.expandAria': '展开全部 {count} 个改动文件',
   'changes.collapse': '收起',
   'changes.collapseAria': '收起改动文件列表',
-  'changes.folderOpening': '正在打开文件夹…',
-  'changes.folderError': '无法打开文件夹,点击重试',
   'changes.oversized': '过大',
   'changes.viewDiff': '查看 {name} 的改动',
   'review.title': '第 {turn} 轮改动',
@@ -115,13 +113,11 @@ export const en: Record<DeliverablesKey, string> = {
   'changes.added': '+{count}',
   'changes.deleted': '-{count}',
   'changes.binary': 'binary',
-  'changes.openFolder': 'Open the folder containing the changed files',
+  'changes.openReview': 'Review this turn’s changes in the sidebar',
   'changes.all': 'All {count} files',
   'changes.expandAria': 'Show all {count} changed files',
   'changes.collapse': 'Collapse',
   'changes.collapseAria': 'Collapse changed files',
-  'changes.folderOpening': 'Opening folder…',
-  'changes.folderError': 'Could not open folder. Click to retry.',
   'changes.oversized': 'too large',
   'changes.viewDiff': 'View changes to {name}',
   'review.title': 'Review · turn {turn}',

+ 3 - 4
packages/client/ui-deliverables/src/client/present-open.ts

@@ -32,14 +32,13 @@ export class PresentedOpenController {
   }
 
   /**
-   * Open one recorded changed file, or the changed files' common folder, in
-   * the Host's default application.
+   * Open one recorded changed file in the Host's default application.
    * @param sessionId - viewed Session.
    * @param seq - durable workspace/changes event sequence.
-   * @param index - original file index within that event, or null for the common folder.
+   * @param index - original file index within that event.
    * @returns after the Host acknowledges opening or the error state is published.
    */
-  openChanged(sessionId: SessionId, seq: number, index: number | null): Promise<void> {
+  openChanged(sessionId: SessionId, seq: number, index: number): Promise<void> {
     return this.openUrl(changedFileUrl(sessionId, seq, index), 'open')
   }
 

+ 3 - 32
packages/client/ui-deliverables/src/present-open.ts

@@ -1,5 +1,4 @@
-/** Serve change summaries and comparisons, and open declared or changed workspace paths verified by the viewed Session's filesystem. */
-import { dirname, isAbsolute, relative, resolve } from 'node:path'
+/** Serve change summaries and comparisons, and open declared or changed workspace files verified by the viewed Session's filesystem. */
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-session-controller'
 import type {} from '@deepseek-ai/dsh-api-workspace-files'
@@ -9,14 +8,13 @@ import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import type {} from '@deepseek-ai/dsh-client-connection'
 import type {} from '@deepseek-ai/dsh-session-query'
 import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
-import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types'
 import { CHANGES_DIFF_PATH, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, type ChangesSummary } from './changes.ts'
 import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH, PRESENT_HOST_PATH, type PresentedHost } from './presented.ts'
 
 /**
  * Register the deliverables routes inside Connection's authentication fence:
  * desktop metadata, change summaries and comparisons, declared-file actions,
- * and changed-file or folder opening.
+ * and changed-file opening.
  * @param ctx - Session lookup, change summaries, native opener, and route lifetime.
  */
 export function registerPresentOpen(ctx: Context): void {
@@ -123,26 +121,6 @@ async function handlePresentOpen(ctx: Context, request: Request): Promise<Respon
   }
 }
 
-/**
- * The deepest directory containing every changed file inside the workspace,
- * or the workspace itself when no listed file lies inside it.
- * @param cwd - absolute workspace root.
- * @param files - the recorded changed files.
- * @returns an absolute directory inside the workspace.
- */
-export function commonChangedFolder(cwd: string, files: readonly WorkspaceChangedFile[]): string {
-  let common: string | undefined
-  for (const file of files) {
-    if (isAbsolute(file.path)) continue
-    const directory = dirname(resolve(cwd, file.path))
-    if (common === undefined) common = directory
-    while (relative(common, directory).startsWith('..')) common = dirname(common)
-  }
-  if (common === undefined) return cwd
-  const rel = relative(cwd, common)
-  return rel.startsWith('..') || isAbsolute(rel) ? cwd : common
-}
-
 /** The summary one `workspace/changes` event announced, without the Host working directory; 404 once the Host no longer serves it. */
 function handleChangesSummary(ctx: Context, request: Request): Response {
   const query = new URL(request.url).searchParams
@@ -176,8 +154,7 @@ async function handleChangesOpen(ctx: Context, request: Request): Promise<Respon
   const query = new URL(request.url).searchParams
   const id = query.get('sessionId')
   const seq = coordinate(query.get('seq'))
-  const rawIndex = query.get('index')
-  const index = rawIndex === null ? null : coordinate(rawIndex)
+  const index = coordinate(query.get('index'))
   if (!id || seq === undefined || index === undefined) return new Response('Invalid changed file coordinates.', { status: 400 })
   try {
     request.signal.throwIfAborted()
@@ -185,12 +162,6 @@ async function handleChangesOpen(ctx: Context, request: Request): Promise<Respon
     const changes = ctx.workspaceChanges.summary(id as SessionId, seq)
     if (changes === undefined) return new Response('Change summary unavailable.', { status: 404 })
     const workspaceRoot = changes.cwd
-    if (index === null) {
-      const folder = commonChangedFolder(workspaceRoot, changes.files)
-      const target = await ctx.fs.resolve(folder, { signal: request.signal })
-      if ((await ctx.fs.stat(target, request.signal))?.type !== 'directory') return new Response('Changed files folder unavailable.', { status: 404 })
-      return await openVerified(ctx, request, ctx.fs.processPath(target), 'open')
-    }
     const file = changes.files[index]
     if (file === undefined) return new Response('Changed file not found in this summary.', { status: 404 })
     const { absolutePath: path } = await ctx.workspaceFiles.stat({ sessionId: id as SessionId, workspaceRoot }, file.path, request.signal)

+ 3 - 20
packages/client/ui-deliverables/tests/changes-open.host.spec.ts

@@ -1,7 +1,7 @@
 /** The change summary route and the changed-file and common-folder native opens over the Host-served summaries. */
 import { mkdtemp, rm, writeFile, mkdir, realpath, unlink } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
-import { join, resolve } from 'node:path'
+import { join } from 'node:path'
 import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
 import { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files'
 import { Context } from '@deepseek-ai/cordis'
@@ -12,7 +12,7 @@ import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
 import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query'
 import type { WorkspaceChangedFile, WorkspaceChangesSummary, WorkspaceFileDiff } from '@deepseek-ai/dsh-workspace-changes/types'
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { commonChangedFolder, registerPresentOpen } from '../src/present-open.ts'
+import { registerPresentOpen } from '../src/present-open.ts'
 import {
   changedFileUrl, changesDiffUrl, changesSummaryUrl, CHANGES_DIFF_PATH, CHANGES_OPEN_PATH, CHANGED_FILES_PATH, isChangedFile, isChangesDiff,
   isChangesEvent, isChangesSummary,
@@ -133,7 +133,6 @@ describe('changed files native open route', () => {
   it('opens a listed file inside or outside the workspace with its verified Host path', async () => {
     const { cwd, open, opener, outside } = await fixture()
     expect(changedFileUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9&index=0`)
-    expect(changedFileUrl(SessionId('owner'), 9, null)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9`)
     const response = await open()
     expect(response.status).toBe(204)
     expect(response.headers.get('cache-control')).toBe('no-store')
@@ -142,20 +141,7 @@ describe('changed files native open route', () => {
     expect(opener.mock.lastCall?.[0].path).toBe(await realpath(outside))
   })
 
-  it('opens the deepest folder containing the workspace files, falling back to the workspace', async () => {
-    const { cwd, open, opener, data } = await fixture()
-    expect((await open('?sessionId=owner&seq=9')).status).toBe(204)
-    expect(opener).toHaveBeenLastCalledWith({ path: await realpath(join(cwd, 'src')) }, expect.any(AbortSignal))
-    data.files = [changed('../escaped.ts', '../escaped.ts'), changed('/etc/hosts', '/etc/hosts')]
-    expect((await open('?sessionId=owner&seq=9')).status).toBe(204)
-    expect(opener.mock.lastCall?.[0].path).toBe(await realpath(cwd))
-    const w = resolve('/w')
-    expect(commonChangedFolder(w, [changed('a/b/c.ts'), changed('a/d.ts'), changed(resolve('/x/y.ts'))])).toBe(resolve(w, 'a'))
-    expect(commonChangedFolder(w, [changed(resolve('/x/y.ts'))])).toBe(w)
-    expect(commonChangedFolder(w, [changed('../up.ts')])).toBe(w)
-  })
-
-  it.each(['', '?seq=9', '?sessionId=owner', '?sessionId=owner&seq=9&index=-1', '?sessionId=owner&seq=9&index=1.5', '?sessionId=owner&seq=x'])(
+  it.each(['', '?seq=9', '?sessionId=owner', '?sessionId=owner&seq=9', '?sessionId=owner&seq=9&index=-1', '?sessionId=owner&seq=9&index=1.5', '?sessionId=owner&seq=x'])(
     'rejects invalid coordinates before reading: %s', async (query) => {
       const { open, readEvent } = await fixture()
       expect((await open(query)).status).toBe(400)
@@ -170,8 +156,6 @@ describe('changed files native open route', () => {
     expect(readEvent).not.toHaveBeenCalled()
     await unlink(join(cwd, 'src', 'lib', 'a.ts'))
     expect((await open()).status).toBe(404)
-    await rm(join(cwd, 'src'), { recursive: true })
-    expect((await open('?sessionId=owner&seq=9')).status).toBe(404)
     expect(opener).not.toHaveBeenCalled()
   })
 
@@ -182,7 +166,6 @@ describe('changed files native open route', () => {
     desktop.mockRestore()
     const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
     expect((await open()).status).toBe(422)
-    expect((await open('?sessionId=owner&seq=9')).status).toBe(422)
     mapping.mockRestore()
     opener.mockRejectedValueOnce(new Error('/private/host/path'))
     const failed = await open()

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

@@ -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 the review on each row, and opens the folder natively', () => {
+  it('summarizes the turn, folds after three rows, and opens the review from the header and each row', () => {
     const { props, openFile, view } = renderCard()
     const card = view.container.querySelector('[data-changed-files]')
     if (!(card instanceof HTMLElement)) throw new Error('changed-files card missing')
@@ -581,8 +581,8 @@ describe('ChangedFiles card', () => {
     fireEvent.click(within(card).getByRole('button', { name: 'View changes to 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)
+    fireEvent.click(within(card).getByRole('button', { name: 'Review this turn’s changes in the sidebar' }))
+    expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 0)
     expect(openFile).not.toHaveBeenCalled()
     const expand = within(card).getByRole('button', { name: 'Show all 5 changed files' })
     expect(expand.getAttribute('aria-expanded')).toBe('false')
@@ -600,16 +600,17 @@ describe('ChangedFiles card', () => {
     expect(within(card).getAllByRole('listitem')).toHaveLength(3)
   })
 
-  it('still opens the review on each row and offers no folder action without a desktop', () => {
+  it('opens the review the same way without a desktop', () => {
     const controller = new PresentedOpenController()
     const { openFile, props, view } = renderCard(controller, zh)
     controller.host.set('error')
     view.rerender(<Deliverables {...props} matched={{ changes, presented: [] }} openFile={openFile} sessionId={SessionId('child-session')} t={makeTranslate(zh)} />)
-    expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull()
+    expect(view.getByRole('button', { name: '在侧边栏查看本轮改动' })).toBeTruthy()
     controller.host.set({ name: 'server', available: false, fileManager: null })
     view.rerender(<Deliverables {...props} matched={{ changes, presented: [] }} openFile={openFile} sessionId={SessionId('child-session')} t={makeTranslate(zh)} />)
     expect(view.getByText('已编辑 11 个文件')).toBeTruthy()
-    expect(view.queryByRole('button', { name: '打开改动文件所在的文件夹' })).toBeNull()
+    fireEvent.click(view.getByRole('button', { name: '在侧边栏查看本轮改动' }))
+    expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 0)
     fireEvent.click(view.getByRole('button', { name: '查看 config/design-token 的改动' }))
     expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 0)
     expect(openFile).not.toHaveBeenCalled()
@@ -617,34 +618,19 @@ describe('ChangedFiles card', () => {
     expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件')
   })
 
-  it('shows folder gesture states in place of the totals and keeps row counts through native opens', () => {
+  it('keeps every count in place whatever the native-open gestures of the review tab are doing', () => {
     const controller = new PresentedOpenController()
     controller.state.set({
       '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opening',
       '/api/changes.open?sessionId=child-session&seq=5&index=1': 'error',
-      '/api/changes.open?sessionId=child-session&seq=5': 'opened',
     })
     const { view } = renderCard(controller)
-    // Row gestures belong to the comparison tab; the card keeps every row's counts.
+    // Native-open gestures belong to the review tab; the card shows counts only.
     expect(view.queryByText(en['presented.opening'])).toBeNull()
     expect(view.queryByText(en['presented.error'])).toBeNull()
     expect(view.getByText('+42')).toBeTruthy()
     expect(view.getByText('+143')).toBeTruthy()
-    // A completed folder open leaves the summed counts in place.
     expect(view.getByText('+1,232')).toBeTruthy()
-    view.unmount()
-    controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'opening' })
-    const { view: pending } = renderCard(controller)
-    expect(pending.getByText(en['changes.folderOpening'])).toBeTruthy()
-    expect((pending.getByRole('button', { name: 'Open the folder containing the changed files' }) as HTMLButtonElement).disabled).toBe(true)
-    pending.unmount()
-    controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'error' })
-    const { view: failed } = renderCard(controller)
-    expect(failed.getByText(en['changes.folderError']).closest('[data-error]')).toBeTruthy()
-    failed.unmount()
-    controller.state.set({ '/api/changes.open?sessionId=child-session&seq=5': 'nativeUnavailable' })
-    const { view: unmapped } = renderCard(controller)
-    expect(unmapped.getByText(en['changes.folderError']).closest('[data-error]')).toBeTruthy()
   })
 
   it('renders without a fold for three files or fewer and beside delivery cards', () => {
@@ -768,8 +754,8 @@ describe('plugin registration', () => {
     expect(face.hooks.changesSummary.getSnapshot()).toEqual({})
     await face.openPresented(SessionId('child-session'), 2, 0)
     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')
+    await face.openChanged(SessionId('child-session'), 5, 0)
+    expect(face.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5&index=0']).toBe('opened')
     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')

+ 3 - 6
packages/client/ui-deliverables/tests/present-open.client.spec.ts

@@ -26,16 +26,13 @@ it('coalesces concurrent card and mention gestures, then allows another open', a
   await controller.dispose()
 })
 
-it('opens changed files and their common folder through their own coordinates', async () => {
+it('opens changed files through their own coordinates', async () => {
   const fetcher = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>().mockResolvedValue(new Response(null, { status: 204 }))
   vi.stubGlobal('fetch', fetcher)
   const controller = new PresentedOpenController()
   await controller.openChanged(id, 9, 1)
-  await controller.openChanged(id, 9, null)
-  expect(fetcher.mock.calls.map(call => call[0])).toEqual([
-    '/api/changes.open?sessionId=fork&seq=9&index=1', '/api/changes.open?sessionId=fork&seq=9',
-  ])
-  expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9']).toBe('opened')
+  expect(fetcher.mock.calls.map(call => call[0])).toEqual(['/api/changes.open?sessionId=fork&seq=9&index=1'])
+  expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('opened')
   fetcher.mockResolvedValueOnce(new Response(null, { status: 422 }))
   await controller.openChanged(id, 9, 1)
   expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable')

+ 11 - 4
packages/client/ui-deliverables/tests/review-tab.client.spec.tsx

@@ -232,15 +232,22 @@ describe('ReviewTab', () => {
     expect(store.getSnapshot().byTab[TAB]?.split).toBe(true)
     const body = view.container.querySelector('[data-review-view]')
     expect(body?.getAttribute('data-review-view')).toBe('split')
+    expect(body?.hasAttribute('data-review-wrap')).toBe(false)
+    // Without wrapping each side is its own column, so a long line scrolls within its side.
+    const side = (name: string) => [...view.container.querySelectorAll(`[data-diff-side="${name}"] [data-diff-line]`)]
+    expect(side('left').map(line => line.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'context', 'del', 'del'])
+    expect(side('left').map(line => line.textContent)).toEqual(['1a', '2b', '', '3d', '10x', '20z'])
+    expect(side('right').map(line => line.textContent)).toEqual(['1a', '2B', '3c', '4d', '11y', ''])
+    expect(view.getByRole('button', { name: en['review.splitAria'] }).getAttribute('aria-pressed')).toBe('true')
+    // Wrapped lines vary in height, so both sides share one row per pair.
+    fireEvent.click(view.getByRole('button', { name: en['review.wrapAria'] }))
+    expect(view.container.querySelector('[data-review-view]')?.hasAttribute('data-review-wrap')).toBe(true)
+    expect(view.container.querySelectorAll('[data-diff-side]')).toHaveLength(0)
     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 })
   })
 

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

@@ -102,7 +102,7 @@
   - code:
     - button "在侧边栏打开 notes.txt": notes.txt
   - text: 的追加则完全走 bash。
-- text: 已编辑 4 个文件 +6 -1
+- button "在侧边栏查看本轮改动": 已编辑 4 个文件 +6 -1
 - list:
   - listitem:
     - button "查看 app.local 的改动": app.local +1 -0