Pārlūkot izejas kodu

feat(web): keep submitted plans in chat and open them in sidebar

Yichen Jiang 5 dienas atpakaļ
vecāks
revīzija
f6428a164e
63 mainītis faili ar 947 papildinājumiem un 135 dzēšanām
  1. 6 0
      .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml
  2. 29 0
      .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md
  3. 29 0
      .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md
  4. 71 1
      apps/web/tests/plan-review.e2e.ts
  5. 2 2
      docs/event-producer-consumer.i18n.yaml
  6. 1 1
      docs/event-producer-consumer.md
  7. 1 1
      docs/event-producer-consumer.zh.md
  8. 2 2
      docs/subsystems/slots.i18n.yaml
  9. 2 1
      docs/subsystems/slots.md
  10. 2 1
      docs/subsystems/slots.zh.md
  11. 2 2
      docs/subsystems/user-questions.i18n.yaml
  12. 2 0
      docs/subsystems/user-questions.md
  13. 2 0
      docs/subsystems/user-questions.zh.md
  14. 2 2
      packages/client/ui-chat/README.i18n.yaml
  15. 2 0
      packages/client/ui-chat/README.md
  16. 2 0
      packages/client/ui-chat/README.zh.md
  17. 1 0
      packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
  18. 2 0
      packages/client/ui-chat/src/client/contract/chat-nodes.ts
  19. 1 1
      packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts
  20. 2 2
      packages/client/ui-plan/README.i18n.yaml
  21. 7 1
      packages/client/ui-plan/README.md
  22. 7 1
      packages/client/ui-plan/README.zh.md
  23. 15 3
      packages/client/ui-plan/package.json
  24. 42 0
      packages/client/ui-plan/src/client/PlanCard.tsx
  25. 26 0
      packages/client/ui-plan/src/client/PlanPreview.module.css
  26. 45 0
      packages/client/ui-plan/src/client/PlanPreview.tsx
  27. 39 12
      packages/client/ui-plan/src/client/index.ts
  28. 10 0
      packages/client/ui-plan/src/client/locales.ts
  29. 34 0
      packages/client/ui-plan/src/client/plan-definition.ts
  30. 79 0
      packages/client/ui-plan/src/client/plan-resource.ts
  31. 68 0
      packages/client/ui-plan/src/client/plan.ts
  32. 21 2
      packages/client/ui-plan/tests/browser-plugin.client.spec.ts
  33. 98 0
      packages/client/ui-plan/tests/plan-preview.client.spec.tsx
  34. 58 0
      packages/client/ui-plan/tests/plan-resource.client.spec.ts
  35. 24 0
      packages/client/ui-plan/tsconfig.json
  36. 2 2
      packages/client/ui-user-questions/README.i18n.yaml
  37. 3 1
      packages/client/ui-user-questions/README.md
  38. 3 1
      packages/client/ui-user-questions/README.zh.md
  39. 2 1
      packages/client/ui-user-questions/package.json
  40. 6 24
      packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css
  41. 8 12
      packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx
  42. 1 1
      packages/client/ui-user-questions/src/client/QuestionComposer.tsx
  43. 13 1
      packages/client/ui-user-questions/src/client/contract/slots.ts
  44. 1 0
      packages/client/ui-user-questions/src/client/index.ts
  45. 6 4
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  46. 2 0
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  47. 3 0
      packages/client/ui-user-questions/tsconfig.json
  48. 62 1
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  49. 1 1
      packages/extensions/tool-cordis/src/api-catalog.ts
  50. 2 2
      packages/interaction/user-questions/README.i18n.yaml
  51. 2 0
      packages/interaction/user-questions/README.md
  52. 2 0
      packages/interaction/user-questions/README.zh.md
  53. 3 0
      packages/interaction/user-questions/src/types.ts
  54. 2 2
      packages/plan/plan-mode/README.i18n.yaml
  55. 2 0
      packages/plan/plan-mode/README.md
  56. 2 0
      packages/plan/plan-mode/README.zh.md
  57. 1 1
      packages/plan/plan-mode/src/index.ts
  58. 1 1
      packages/plan/plan-mode/tests/plan-mode.spec.ts
  59. 32 5
      pnpm-lock.yaml
  60. 4 2
      snapshots/web/plan-review/approved-expanded.expected.md
  61. 4 2
      snapshots/web/plan-review/approved.expected.md
  62. 39 0
      snapshots/web/plan-review/preview.expected.md
  63. 2 36
      snapshots/web/plan-review/review.expected.md

+ 6 - 0
.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md
+2026-09-17-persistent-plan-cards.md: e0a630c8395e4bf3d8d93bef010afffd177488d7
+2026-09-17-persistent-plan-cards.zh.md: c5722cd065260c5db6fd21a664b23545ca14c79b

+ 29 - 0
.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md

@@ -0,0 +1,29 @@
+# Agent Note: Persistent plan cards and sidebar reading
+
+Status: implemented
+
+English | [中文](2026-09-17-persistent-plan-cards.zh.md)
+
+## Problem
+
+A plan review occupies the composer only until the user answers or dismisses it. A completed Turn also folds its tool calls into the process disclosure. Neither lifetime gives a submitted plan a discoverable place to read while implementation continues.
+
+## Decision
+
+`ui-plan` derives one permanent Chat card from each `exit_plan_mode` invocation, including native calls and PTC dispatches. The card declares `process: 'independent'`, so Chat keeps it outside the Turn process disclosure. Review dismissal, refusal, and approval do not delete it.
+
+The review intent carries the tool-call identity. The question plugin declares an action slot, and the plan plugin contributes an opener there and on the historical card. Both open the resource identified by Session and invocation. Sidebar layout retains that address; the resource provider reads the existing Session history, paging backwards when the invocation is older than the opening window. Its temporary follow closes after the opening snapshot.
+
+The document is read-only. The pending review shows its status, sidebar opener, and decision buttons; the complete text is read in the sidebar. Approval stays in the pending-question owner; reopening a historical document does not revive a settled review or authorize implementation. Different submissions remain distinct even when they share a heading.
+
+## Alternatives considered
+
+**Keep only the pending review or an expandable tool row.** Both hide the document behind unrelated interaction or process state. The user needs a persistent artifact entry.
+
+**Write a Markdown file or persist the document in sidebar layout.** The tool arguments already own the exact submitted text. Another durable copy would need synchronization and could disagree with the reviewed plan. The [plan-state decision](../simplification/2026-07-22-plan-specific-collaboration-state.md) remains the authority for that ownership.
+
+**Teach Chat the plan tool's name.** A node-owned disclosure declaration lets the feature identify its independently readable artifact without adding plan-specific rendering or imports to Chat.
+
+## Consequences
+
+Plans survive review closure and browser reload without a new Session event or file. Reads of old plans may require several history pages. The provider validates saved addresses and logged arguments, and reports unavailable history explicitly. The recorded Web plan-review scenario covers both entry points, repeated opening, process collapse, approval, and reload; focused tests cover native/PTC decoding and resource failures.

+ 29 - 0
.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 常驻计划卡片与侧边栏阅读
+
+Status: implemented
+
+[English](2026-09-17-persistent-plan-cards.md) | 中文
+
+## Problem
+
+计划审批只在用户回答或关闭之前占据编辑器。已完成的回合也会把工具调用折叠到运行过程里。这两种生命周期都无法在实施继续时,为已提交的计划提供容易找到的阅读入口。
+
+## Decision
+
+`ui-plan` 从每次 `exit_plan_mode` 调用派生一张常驻 Chat 卡片,覆盖原生调用和 PTC dispatch。卡片声明 `process: 'independent'`,让 Chat 将其保留在回合运行过程折叠区之外。关闭、拒绝或批准审批都不会删除卡片。
+
+审批意图携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。两者都打开由会话和调用标识确定的资源。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。
+
+文档只读。待处理审批显示状态、侧边栏入口和决定按钮;完整正文在侧边栏阅读。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。
+
+## Alternatives considered
+
+**只保留待处理审批或可展开的工具行。** 两者都会让文档受其他交互或运行过程状态控制。用户需要常驻的产物入口。
+
+**写入 Markdown 文件或在侧边栏布局中保存正文。** 工具参数已经拥有准确的提交文本。另一份持久副本需要同步,且可能与审阅的计划不一致。[计划状态决策](../simplification/2026-07-22-plan-specific-collaboration-state.zh.md)仍是这项所有权的权威记录。
+
+**让 Chat 识别计划工具名称。** 节点自行声明折叠行为,让功能标识需要独立阅读的产物,无需向 Chat 添加计划专用渲染或导入。
+
+## Consequences
+
+计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖原生与 PTC 解码及资源读取失败。

+ 71 - 1
apps/web/tests/plan-review.e2e.ts

@@ -27,6 +27,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
 // transcript the approval leaves behind — the state the card cannot see.
 const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
 const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
+const PREVIEW_EXPECTED = join(SNAPSHOT_DIR, 'preview.expected.md')
 const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
 const APPROVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'approved-expanded.expected.md')
 const MODE = webSnapshotMode()
@@ -84,6 +85,8 @@ describe('web e2e: plan review takeover round trip', () => {
     // The plan-review request must NOT land on the generic question flow.
     expect(await page.locator('[data-question-key]').count()).toBe(0)
     await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0)
+    expect(await card.getByRole('heading').count()).toBe(0)
+    expect(await card.getByRole('list').count()).toBe(0)
 
     const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
     await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
@@ -96,6 +99,21 @@ describe('web e2e: plan review takeover round trip', () => {
       await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
     }
 
+    const planCard = page.locator('[data-plan-card]')
+    await planCard.waitFor({ state: 'visible' })
+    await card.getByRole('button', { name: 'Open plan in sidebar' }).click()
+    const preview = page.locator('[data-plan-preview]')
+    await preview.waitFor({ state: 'visible' })
+    expect(await preview.getByRole('heading', { level: 1 }).textContent()).toContain('--greeting')
+    await card.getByRole('button', { name: 'Open plan in sidebar' }).click()
+    expect(await page.locator('[data-plan-preview]').count()).toBe(1)
+    if (MODE !== 'record') {
+      await compareOrRefreshGolden(PREVIEW_EXPECTED, await captureStableAria(page, '[data-plan-preview]', scaffold.workspaceCwd), MODE)
+    }
+    await page.locator('[data-sidebar-right-toggle]').click()
+    await planCard.click()
+    await preview.waitFor({ state: 'visible' })
+
     await card.getByRole('button', { name: 'Approve' }).click()
     // Park the pointer: the card unmounts and the ContextMeter ring lands
     // under the click position, whose 200ms hover delay would arm a tooltip
@@ -115,6 +133,16 @@ describe('web e2e: plan review takeover round trip', () => {
     expect(await page.locator('[data-plan-review-key]').count()).toBe(0)
     expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
     await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
+    // The completed Turn collapses its process; the artifact remains on the main line.
+    await planCard.waitFor({ state: 'visible' })
+    expect(await planCard.locator('xpath=ancestor::*[@data-turn-process-member]').count()).toBe(0)
+    await page.locator('[data-sidebar-right-toggle]').click()
+    await planCard.click()
+    await preview.waitFor({ state: 'visible' })
+    await page.reload({ waitUntil: 'load' })
+    await page.locator('[data-plan-card]').waitFor({ state: 'visible' })
+    await page.locator('[data-plan-preview]').waitFor({ state: 'visible' })
+    expect(await page.locator('[data-plan-preview]').getByRole('heading', { level: 1 }).textContent()).toContain('--greeting')
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
     const expanded = await captureExpandedTurnProcessAria(
@@ -129,8 +157,50 @@ describe('web e2e: plan review takeover round trip', () => {
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md',
+      'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', 'preview.expected.md',
       'approved.expected.md', 'approved-expanded.expected.md',
     ])
   })
 })
+
+describe('web e2e: dismissed plan history', () => {
+  it.skipIf(MODE === 'record')('reopens the permanent plan card after dismissing its review', async () => {
+    // Replay supplies a finite continuation; the real question rejection and retained document are the assertions.
+    const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false, paceMs: 15 })
+    let browser: Browser | undefined
+    const events: SessionEvent[] = []
+    scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
+    try {
+      browser = await chromium.launch()
+      const page = await newEnglishPage(browser)
+      const tripwire = watchConsole(page)
+      await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+      await connectFreshWorkspace(page, scaffold.workspaceCwd)
+      const input = page.locator('[data-composer-input]').first()
+      const settled = scaffold.whenTurnSettled(30_000)
+      await input.fill(LINE)
+      await input.press('Enter')
+      const review = page.locator('[data-plan-review-key]')
+      await review.waitFor({ state: 'visible' })
+      await review.getByRole('button', { name: 'Chat about it', exact: true }).click()
+      await settled
+      const results = events.filter(event => event.type === 'tool/result')
+      expect(results.some(event => JSON.stringify(event).includes('dismissed the plan review'))).toBe(true)
+      expect(await review.count()).toBe(0)
+      const card = page.locator('[data-plan-card]')
+      await card.waitFor({ state: 'visible' })
+      await card.click()
+      await page.locator('[data-plan-preview]').waitFor({ state: 'visible' })
+      await page.locator('[data-sidebar-right-toggle]').click()
+      await card.click()
+      await page.reload({ waitUntil: 'load' })
+      await page.locator('[data-plan-card]').waitFor({ state: 'visible' })
+      await page.locator('[data-plan-preview]').waitFor({ state: 'visible' })
+      expect(await page.locator('[data-plan-preview]').getByRole('heading', { level: 1 }).textContent()).toContain('--greeting')
+      expect(tripwire.pageErrors).toEqual([])
+    } finally {
+      await browser?.close()
+      await scaffold.close()
+    }
+  })
+})

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: b494af3ece876b5b10b30c3602d6912f25e5088a
-event-producer-consumer.zh.md: 36f97f867de1a897982a0dd7a463a8c20e85484d
+event-producer-consumer.md: 2ba5b32d820d169d0cb0b4e97a294c5e2688d13f
+event-producer-consumer.zh.md: 64fbf2e197f8ceb10eea25f1bb17f2735d6acd68

+ 1 - 1
docs/event-producer-consumer.md

@@ -73,7 +73,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
-| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
+| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
 | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |

+ 1 - 1
docs/event-producer-consumer.zh.md

@@ -75,7 +75,7 @@
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
 | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
-| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
+| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
 | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |

+ 2 - 2
docs/subsystems/slots.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/slots.md
-slots.md: 177bbc5b8f56b7276f271a2ddb7ac71ac8888363
-slots.zh.md: c746ac88704c2e22e5c50ef82a893fde9f091668
+slots.md: a9f0ee6ab2ef37065ab965a7989f5989350bca8b
+slots.zh.md: da6a38eab6aac1c2670b131fa49a832ce41f711e

+ 2 - 1
docs/subsystems/slots.md

@@ -151,7 +151,8 @@ root
 │     │  ├─ conversation.session.header.utilities
 │     │  └─ conversation.session.header.corner
 │     ├─ conversation.composer
-│     │  └─ conversation.approval.detail
+│     │  ├─ conversation.approval.detail
+│     │  └─ conversation.plan-review.actions
 │     ├─ conversation.composer.bar
 │     │  ├─ conversation.input.attachments
 │     │  ├─ conversation.input.permission

+ 2 - 1
docs/subsystems/slots.zh.md

@@ -151,7 +151,8 @@ root
 │     │  ├─ conversation.session.header.utilities
 │     │  └─ conversation.session.header.corner
 │     ├─ conversation.composer
-│     │  └─ conversation.approval.detail
+│     │  ├─ conversation.approval.detail
+│     │  └─ conversation.plan-review.actions
 │     ├─ conversation.composer.bar
 │     │  ├─ conversation.input.attachments
 │     │  ├─ conversation.input.permission

+ 2 - 2
docs/subsystems/user-questions.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/user-questions.md
-user-questions.md: fbbfb1435586c7191e47a6eaa5b1783c9f172d48
-user-questions.zh.md: 054ca06cd3c8a85cb35d99658c325848ea780cb5
+user-questions.md: d6515464e562eb5b3d505fd3ff1dcb118c2a7ee3
+user-questions.zh.md: 87536c22ad717ee5c25f4846aee6bd6af6068e8d

+ 2 - 0
docs/subsystems/user-questions.md

@@ -41,6 +41,8 @@ type AskUserQuestionIntent = {
    * An `approve` naming no option of its own question is rejected at `ask()`.
    */
   approve: string
+  /** Logged tool invocation whose arguments contain the reviewed plan. */
+  callId?: ToolCallId
 }
 ```
 

+ 2 - 0
docs/subsystems/user-questions.zh.md

@@ -41,6 +41,8 @@ type AskUserQuestionIntent = {
    * An `approve` naming no option of its own question is rejected at `ask()`.
    */
   approve: string
+  /** Logged tool invocation whose arguments contain the reviewed plan. */
+  callId?: ToolCallId
 }
 ```
 

+ 2 - 2
packages/client/ui-chat/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-chat/README.md
-README.md: c7c9fb7cb2a9a149fae9b27db78e019d821dfb95
-README.zh.md: 386d7aae91b157a7b09fcd0515bfd8d075a39638
+README.md: faf5e8b955b46a84483ebfe0ec16c2a397522992
+README.zh.md: 3330ce4e37ad9b30dae3355b92430a55cdd736a6

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

@@ -91,6 +91,8 @@ None; Chat presentation does not assemble or mutate provider requests.
 
 None.
 
+A feature’s Chat Node may declare `process: 'independent'` to keep a user-facing artifact visible when the surrounding Turn process collapses; submitted plans use this declaration.
+
 </details>
 
 **Runtime invariant:** No companion is published. Conversation and Slot registration enforce Chat target consistency.

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

@@ -93,6 +93,8 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有
 
 无。
 
+功能的 Chat Node 可以声明 `process: 'independent'`,让面向用户的产物在所属回合运行过程折叠时仍保持可见;已提交的计划使用这一声明。
+
 </details>
 
 **运行时不变式:** 不发布伴生入口。Conversation 与 slot 注册已经强制 Chat target 一致。

+ 1 - 0
packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx

@@ -68,6 +68,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
     && !historyIncomplete
   const processMember = routedNode !== undefined
     && processWindowReady
+    && routedNode.process !== 'independent'
     && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind)
     && routedNode.anchorSeq >= processSpec.processStartSeq
     && routedNode.anchorSeq < processSpec.answerAnchorSeq

+ 2 - 0
packages/client/ui-chat/src/client/contract/chat-nodes.ts

@@ -10,6 +10,8 @@ export interface ChatConversationViewNode extends ConversationViewNode {
   readonly anchorSeq: number
   readonly location: ConversationLocation
   readonly visibility: 'visible' | 'hidden'
+  /** Keep a user-facing artifact outside the Turn process disclosure. */
+  readonly process?: 'independent'
 }
 
 /** Merge-extensible payload registry keyed by final Chat renderer kind. */

+ 1 - 1
packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts

@@ -54,7 +54,7 @@ function derivePresentation(
       && (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) {
       compactAnswer = false
     }
-    if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)
+    if (node.process === 'independent' || TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)
       || node.anchorSeq < spec.processStartSeq
       || (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue
     if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) {

+ 2 - 2
packages/client/ui-plan/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-plan/README.md
-README.md: 4ff275834ec069315580c205b8fd0bf0dee9cfeb
-README.zh.md: 588698b273bc52b1e601c7010516c24a9248e543
+README.md: 09c112fe395045a5fa9302efef73ee6a44eebb0c
+README.zh.md: cbb58febadeb6bda92b0d3e23109c4d9ed7a108e

+ 7 - 1
packages/client/ui-plan/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package renders the plan-mode status chip in the Web GUI: when the host-computed projection's effective target is plan mode, the composer shows a warn-colored "Plan ×" button that turns plan mode off; otherwise the seat stays empty. Plan mode itself — the `/plan` command, the committed `plan/mode` state, the projection unit, and the policy section — belongs to `dsh-plan-mode`; this package only renders the projection and sends what a user could equally type. The model exits plan mode through the stable `exit_plan_mode` tool; its plan review uses the composed Web question channel.
+Plan mode lets you review a plan before implementation. Enter with `/plan`, leave with the composer chip, and open a submitted plan in the right sidebar from its review card or permanent Chat card. Plans remain available after approval, rejection, or dismissal; reopening a plan focuses its existing tab, and browser reload restores the document from Session history.
 
 ## Table of Contents
 
@@ -31,6 +31,10 @@ Mount this plugin alongside `ui-conversation` and `dsh-plan-mode`; the chip then
 
 While the effective target is plan mode, the seat renders the warn-colored "Plan ×" status button, which executes `/plan off`. Otherwise the seat stays empty: a host without plan mode, or a Draft with no session, shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan" — unless the owning surface supplies its own placeholder.
 
+### Reading submitted plans
+
+Each submitted plan has a permanent Chat card outside the Turn process disclosure. Click its title, or the review strip’s Open plan in sidebar button, to read and copy the complete Markdown. Different submissions retain separate tabs. Closing the document or review does not delete the plan; the review buttons alone decide whether implementation may begin.
+
 ### Failures
 
 Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit.
@@ -45,6 +49,8 @@ Admission failures (`matched: false`, business errors, transport faults) surface
 
 The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint. The accessible description is "Plan mode on, press to turn off".
 
+Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition. The plan resource address identifies the Session and invocation; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies the caller’s invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate.
+
 </details>
 
 -----

+ 7 - 1
packages/client/ui-plan/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包在 Web GUI 中渲染 plan 模式状态徽章:当宿主计算的投影有效目标为 plan 模式时,composer 显示一个 warn 色「Plan ×」按钮,可关闭 plan 模式;否则该座位保持为空。plan 模式本身——`/plan` 命令、已提交的 `plan/mode` 状态、投影单元与 policy 段——归 `dsh-plan-mode` 所有;本包只渲染投影并发送用户同样可以手敲的内容。模型经稳定的 `exit_plan_mode` 工具退出 plan 模式;其 plan 评审走已组合的 Web question 通道
+计划模式让你在实施前审阅计划。通过 `/plan` 进入,通过编辑器中的状态按钮退出,并从审批卡片或聊天历史中的常驻计划卡片在右侧边栏打开全文。批准、拒绝或关闭审批后,计划仍可查看;重新打开同一计划会聚焦已有标签页,刷新浏览器后会从会话历史恢复正文
 
 ## 目录
 
@@ -31,6 +31,10 @@ kind: "package-reference"
 
 当有效目标为 plan 模式时,该座位渲染 warn 色「Plan ×」状态按钮,执行 `/plan off`。否则座位保持为空:未组合 plan-mode 的宿主,或尚无会话的 Draft,都不显示任何内容。plan 模式为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——「describe your task to generate plan」——除非所属 surface 提供自己的 placeholder。
 
+### 查看已提交的计划
+
+每次提交的计划都有一张常驻聊天卡片,不随回合运行过程折叠。点击卡片标题,或审批条带上的“在侧边栏打开计划”按钮,即可阅读和复制完整 Markdown。不同提交保留独立标签页。关闭文档或审批卡片不会删除计划;是否开始实施仍由审批按钮决定。
+
 ### 失败
 
 准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,徽章保持显示直至投影确认退出。
@@ -45,6 +49,8 @@ kind: "package-reference"
 
 徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。无障碍描述是「Plan mode on, press to turn off」。
 
+计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生。计划资源地址标识会话和调用;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供调用方的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。
+
 </details>
 
 -----

+ 15 - 3
packages/client/ui-plan/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-plan",
-  "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel",
+  "description": "Plan mode controls, persistent transcript plan cards, and sidebar Markdown previews",
   "version": "0.1.6-alpha.1",
   "publishConfig": {
     "access": "public"
@@ -30,7 +30,11 @@
       "inject": [
         "@deepseek-ai/dsh-api-remotes",
         "@deepseek-ai/dsh-client-locale",
-        "@deepseek-ai/dsh-client-ui-conversation"
+        "@deepseek-ai/dsh-client-ui-conversation",
+        "@deepseek-ai/dsh-client-ui-sidebar-right",
+        "@deepseek-ai/dsh-client-ui-chat",
+        "@deepseek-ai/dsh-client-resources",
+        "@deepseek-ai/dsh-api-session-controller"
       ],
       "platform": "web"
     }
@@ -56,7 +60,15 @@
     "react": "^18.2.0",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-session": "workspace:^"
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^",
+    "@deepseek-ai/dsh-client-resources": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 42 - 0
packages/client/ui-plan/src/client/PlanCard.tsx

@@ -0,0 +1,42 @@
+/** Persistent transcript card and pending-review action use the same plan resource opener. */
+import { IconFullscreenOutline16, IconPlanOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
+import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client'
+import css from './PlanPreview.module.css'
+
+/** Session-bound navigation injected into both plan entry points. */
+export interface PlanOpenInjected {
+  /** Open or focus the exact submitted plan. */
+  openPlan: (callId: ToolCallId) => void
+}
+
+/**
+ * Render the plan's permanent transcript entry.
+ * @param props - Logged plan, localized copy, and Session-bound navigation.
+ * @returns a keyboard-accessible plan card.
+ */
+export function PlanCard({ node, openPlan, t }: PropsRuntime<'conversation.chat.node', 'submitted-plan'> & InjectFace<PlanOpenInjected> & PropsLocale<'plan'>) {
+  return (
+    <button type="button" className={css.card} data-plan-card={node.data.callId}
+      aria-label={t('preview.openNamed', { title: node.data.title })}
+      onClick={() => { openPlan(node.data.callId) }}>
+      <IconPlanOutline14 size={18} />
+      <span className={css.cardTitle}>{node.data.title}</span>
+      <IconFullscreenOutline16 size={16} />
+    </button>
+  )
+}
+
+/**
+ * Add a sidebar opener to the pending review without changing its answer.
+ * @param props - Review identity, localized copy, and navigation.
+ * @returns an opener when the asker supplied a logged invocation identity.
+ */
+export function PlanReviewOpen({ review, openPlan, t }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace<PlanOpenInjected> & PropsLocale<'plan'>) {
+  const callId = review.callId
+  if (callId === undefined) return null
+  return <button type="button" className={css.reviewOpen} title={t('preview.open')} aria-label={t('preview.open')}
+    onClick={() => { openPlan(callId) }}><IconFullscreenOutline16 size={16} /></button>
+}

+ 26 - 0
packages/client/ui-plan/src/client/PlanPreview.module.css

@@ -0,0 +1,26 @@
+.card {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  width: 100%;
+  padding: 16px 18px;
+  margin: 12px 0;
+  border: 1px solid var(--dsw-alias-border-l1);
+  border-radius: 14px;
+  background: transparent;
+  color: var(--dsw-alias-label-secondary);
+  font: inherit;
+  text-align: left;
+  cursor: pointer;
+}
+.card:hover { background: var(--dsw-alias-interactive-bg-hover); }
+.card:focus-visible, .reviewOpen:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; }
+.card > svg { flex-shrink: 0; }
+.cardTitle { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); }
+.reviewOpen { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }
+.reviewOpen:hover { background: var(--dsw-alias-interactive-bg-hover); }
+.preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; }
+.toolbar { display: flex; justify-content: flex-end; margin-bottom: 8px; }
+.document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; }
+.message { padding: 24px; color: var(--dsw-alias-label-secondary); font-size: 14px; }
+@media (max-width: 767px) { .preview { padding: 16px 18px 32px; } }

+ 45 - 0
packages/client/ui-plan/src/client/PlanPreview.tsx

@@ -0,0 +1,45 @@
+/** Read-only Markdown viewer for an exact plan resource. */
+import { useMemo } from 'react'
+import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type {} from './plan-resource.ts'
+import css from './PlanPreview.module.css'
+
+type PlanPreviewProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'plan'>
+
+/**
+ * Render the submitted plan with its complete Markdown and a copy action.
+ * @param props - Framework-bound tab identity, resource, and copy.
+ * @returns the plan document or a localized loading/failure state.
+ */
+export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) {
+  const tab = useTabInfo()
+  const resource = useResource<'plan'>(tab.tab.navigation.address)
+  const labels = useMemo(() => ({ code: { copyLabel: t('copy'), copiedLabel: t('copied') }, footnotes: t('markdown.footnotes') }), [t])
+  const plan = resource.value
+  if (plan === undefined) return (
+    <div className={css.message} role="status">
+      {resource.status === 'failed' ? t('preview.failed') : t('preview.loading')}
+      {resource.failure !== undefined && <p>{resource.failure.message}</p>}
+    </div>
+  )
+  return (
+    <section className={css.preview} data-plan-preview={plan.callId} aria-label={plan.title}>
+      <div className={css.toolbar}><button type="button" className={css.reviewOpen} aria-label={t('copy')}
+        onClick={() => { void writeClipboard(plan.markdown) }}><IconCopyOutline16 /></button></div>
+      <div className={css.document}><MarkdownText text={plan.markdown} labels={labels} /></div>
+    </section>
+  )
+}
+
+/**
+ * Display the plan heading in its tab after resource recovery.
+ * @param props - Framework-bound tab identity and resource reader.
+ * @returns the recovered plan title or the tab's initial localized label.
+ */
+export function PlanTitle({ useTabInfo, useResource }: PropsRuntime<'sidebar.right.pane.tab.title'>) {
+  const tab = useTabInfo()
+  const resource = useResource<'plan'>(tab.tab.navigation.address)
+  return <span>{resource.value?.title ?? tab.tab.title}</span>
+}

+ 39 - 12
packages/client/ui-plan/src/client/index.ts

@@ -1,12 +1,4 @@
-/**
- * Plan control plugin, browser half: occupies the composer's named
- * `conversation.input.plan` seat with an active-state status chip. Plan mode
- * is entered through the command source; while the projection's effective
- * target is plan mode the chip renders and executes /plan off through
- * `command.execute`, otherwise the seat stays empty. Reads ride the generic
- * projection pair through the standard-kit `useProjection`; zero client-side
- * plan state.
- */
+/** Plan-mode control, persistent Chat cards, and Session-backed sidebar previews. */
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -18,6 +10,16 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-plan-mode/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type {} from '@deepseek-ai/dsh-api-session-controller/remote'
+import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
+import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type {} from '@deepseek-ai/dsh-client-resources/client'
+import { PlanCard, PlanReviewOpen, type PlanOpenInjected } from './PlanCard.tsx'
+import { PlanPreview, PlanTitle } from './PlanPreview.tsx'
+import { planDefinition } from './plan-definition.ts'
+import { planResourceProvider } from './plan-resource.ts'
+import { planAddress, parsePlanAddress } from './plan.ts'
 import { PlanChip } from './PlanModeControl.tsx'
 import { en, zh, type PlanKey } from './locales.ts'
 
@@ -42,16 +44,41 @@ export interface PlanChipInjected {
   exitPlanMode: () => Promise<string | null>
 }
 
-/** Required services: the seat's slot registry, commands Remote, and locale registry. */
-export const inject = ['slots', 'remote', 'remote.commands', 'locale']
+/** Services for plan controls, Conversation projection, and resource navigation. */
+export const inject = ['slots', 'remote', 'remote.commands', 'remote.session', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs']
 
 /**
- * Client plugin body: register the plan chip over the command channel.
+ * Register plan controls, permanent Chat cards, and sidebar document reading.
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plan: dictionaries')
 
+  const previewId = '@deepseek-ai/dsh-client-ui-plan'
+  const t = ctx.locale.bind(NS)
+  ctx.effect(() => ctx.uiConversation.events.register(planDefinition), 'ui-plan: conversation definition')
+  ctx.effect(() => ctx.resources.register(planResourceProvider(ctx.remote.session)), 'ui-plan: resources')
+  ctx.effect(() => ctx.sidebarRightTabs.register({
+    id: previewId, kind: 'plan', patterns: ['dsh-resource://plan/**'], priority: 'builtin',
+    canOpen: address => parsePlanAddress(address) !== undefined,
+    title: () => t('preview.title'),
+  }), 'ui-plan: sidebar type')
+  const open = (sessionId: SessionId): PlanOpenInjected => ({
+    openPlan: (callId) => { ctx.sidebarRight.openResourceIn(sessionId, planAddress({ sessionId, callId })) },
+  })
+  ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
+    name: 'conversation.chat.node', key: 'submitted-plan', locale: NS, inject: open,
+  }, PlanCard))
+  ctx.slots.inject('conversation.plan-review.actions', () => ctx.slots.register({
+    name: 'conversation.plan-review.actions', id: previewId, locale: NS, inject: open,
+  }, PlanReviewOpen))
+  ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
+    name: 'sidebar.right.pane.tab', key: previewId, locale: NS,
+  }, PlanPreview))
+  ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
+    name: 'sidebar.right.pane.tab.title', key: previewId,
+  }, PlanTitle))
+
   ctx.slots.inject('conversation.input.plan', () => ctx.slots.register({
     name: 'conversation.input.plan',
     locale: NS,

+ 10 - 0
packages/client/ui-plan/src/client/locales.ts

@@ -3,6 +3,11 @@
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
   'chip.label': 'Plan',
+  'preview.title': '计划',
+  'preview.open': '在侧边栏打开计划',
+  'preview.openNamed': '打开计划:{title}',
+  'preview.loading': '正在读取计划…',
+  'preview.failed': '无法读取计划',
   'chip.on.aria': 'plan mode 已开启,按下关闭',
   'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off)',
   'chip.off.aria': 'plan mode 已关闭,按下开启',
@@ -16,6 +21,11 @@ export type PlanKey = keyof typeof zh
 /** English dictionary, checked complete against the zh key set. */
 export const en = {
   'chip.label': 'Plan',
+  'preview.title': 'Plan',
+  'preview.open': 'Open plan in sidebar',
+  'preview.openNamed': 'Open plan: {title}',
+  'preview.loading': 'Loading plan…',
+  'preview.failed': 'Could not load plan',
   'chip.on.aria': 'Plan mode on, press to turn off',
   'chip.on.title': 'Plan mode on — click to turn off (/plan off)',
   'chip.off.aria': 'Plan mode off, press to turn on',

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

@@ -0,0 +1,34 @@
+/** Durable plan cards remain readable independently of a Turn's process disclosure. */
+import type {} from '@deepseek-ai/dsh-tools/types'
+import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client'
+import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { submittedPlan, type SubmittedPlan } from './plan.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-chat/client' {
+  interface ChatNodeDataMap {
+    /** Complete plan submitted through exit_plan_mode, including rejected or dismissed reviews. */
+    'submitted-plan': SubmittedPlan
+  }
+}
+
+/** One card per invocation; a later PTC settlement retains the original card position. */
+export const planDefinition: ConversationNodeDefinition<SubmittedPlan> = {
+  kind: 'submitted-plan',
+  target: 'chat',
+  match: (event) => {
+    const plan = submittedPlan(event)
+    return plan === undefined ? null : { id: plan.callId, role: event.type === 'tool/ptc-dispatch' ? 'update' : 'start' }
+  },
+  start: (_context, match) => submittedPlan(match.event) as SubmittedPlan,
+  update: context => context.state,
+  buildViewNode: (context) => {
+    const start = context.start ?? context.matches[0]
+    const data = context.state ?? (start === undefined ? undefined : submittedPlan(start.event))
+    if (data === undefined || start === undefined) return null
+    return {
+      key: context.key, kind: 'submitted-plan', id: context.id, target: 'chat',
+      anchorSeq: start.event.seq, location: start.location,
+      visibility: 'visible', process: 'independent', data,
+    } satisfies ChatNode<'submitted-plan'>
+  },
+}

+ 79 - 0
packages/client/ui-plan/src/client/plan-resource.ts

@@ -0,0 +1,79 @@
+/** Read immutable plan arguments through the existing cold-safe Session history API. */
+import type { Context } from '@deepseek-ai/cordis'
+import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
+import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client'
+import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import { parsePlanAddress, submittedPlan, type SubmittedPlan } from './plan.ts'
+
+declare module '@deepseek-ai/dsh-typert-protocol' {
+  interface RemoteErrorDetailsMap {
+    /** The resource URL does not identify a plan invocation. */
+    'plan/invalid-address': Record<string, never>
+    /** Session history ended before an opening snapshot. */
+    'plan/unavailable': Record<string, never>
+    /** The Session has no readable plan for this invocation. */
+    'plan/not-found': Record<string, never>
+    /** The Session history read failed. */
+    'plan/read-failed': Record<string, never>
+  }
+}
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface ResourceProtocolMap {
+    /** Immutable Markdown from a logged exit_plan_mode invocation. */
+    plan: SubmittedPlan
+  }
+}
+
+/**
+ * Bind plan reads to the generated Session Remote face.
+ * @param remote - Existing Session history API.
+ * @returns a provider whose reads stop after finding the exact invocation.
+ */
+export function planResourceProvider(remote: Pick<Context['remote']['session'], 'follow' | 'page'>): ResourceProvider<'plan'> {
+  return {
+    protocol: 'plan',
+    async *open(address, { signal }): AsyncIterable<RemoteResult<SubmittedPlan>> {
+      const aborted = (): boolean => signal.aborted
+      if (aborted()) return
+      const target = parsePlanAddress(address)
+      if (target === undefined) {
+        yield { ok: false, error: new RemoteError('plan/invalid-address', 'Invalid plan resource address.', {}) }
+        return
+      }
+      const sessionAddress = { kind: 'session' as const, sessionId: target.sessionId }
+      try {
+        // The snapshot supplies the page API's fixed log cut. Breaking closes the follow stream.
+        let snapshot: Extract<SessionFollowFrame, { type: 'snapshot' }> | undefined
+        for await (const frame of remote.follow({ address: sessionAddress }, signal)) {
+          if (frame.type === 'snapshot') { snapshot = frame; break }
+        }
+        if (aborted()) return
+        if (snapshot === undefined) throw new RemoteError('plan/unavailable', 'Session history ended before the plan could be read.', {})
+        let page = { records: snapshot.records, hasMore: snapshot.hasMore }
+        while (!aborted()) {
+          for (const entry of page.records) {
+            const plan = submittedPlan(entry.event)
+            if (plan?.callId === target.callId) {
+              yield { ok: true, value: plan }
+              return
+            }
+          }
+          const beforeSeq = page.records[0]?.event.seq
+          if (!page.hasMore || beforeSeq === undefined) break
+          const next = await remote.page({ address: sessionAddress, throughSeq: snapshot.cursor, beforeSeq }, signal)
+          if (aborted()) return
+          if (!next.ok) { yield next; return }
+          page = next.value
+        }
+        if (!aborted()) yield { ok: false, error: new RemoteError('plan/not-found', 'The submitted plan was not found in this Session.', {}) }
+      } catch (error) {
+        if (!aborted()) yield {
+          ok: false,
+          error: remoteErrorOf(error) ?? new RemoteError('plan/read-failed', error instanceof Error ? error.message : String(error), {}),
+        }
+      }
+    },
+  }
+}

+ 68 - 0
packages/client/ui-plan/src/client/plan.ts

@@ -0,0 +1,68 @@
+/** Plan text and resource identities derived from logged native or PTC calls. */
+import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+
+/** One submitted plan, identified by its originating tool invocation. */
+export interface SubmittedPlan {
+  readonly callId: ToolCallId
+  readonly markdown: string
+  readonly title: string
+}
+
+/** A saved sidebar resource names one invocation in one Session. */
+export interface PlanAddress {
+  readonly sessionId: SessionId
+  readonly callId: ToolCallId
+}
+
+function record(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/**
+ * Read a complete plan from untrusted logged arguments.
+ * @param event - Native call or PTC dispatch event from Session history.
+ * @returns the submitted plan, or undefined for unrelated or malformed data.
+ */
+export function submittedPlan(event: { readonly type: string; readonly data: unknown }): SubmittedPlan | undefined {
+  if (event.type !== 'tool/call' && event.type !== 'tool/ptc-dispatch-start' && event.type !== 'tool/ptc-dispatch') return undefined
+  const data = event.data
+  if (!record(data) || data.name !== 'exit_plan_mode') return undefined
+  const callId = event.type === 'tool/call' ? data.callId : data.subCallId
+  if (typeof callId !== 'string' || callId === '') return undefined
+  let args: unknown = data.arguments
+  if (event.type === 'tool/call') {
+    if (typeof args !== 'string') return undefined
+    try { args = JSON.parse(args) as unknown }
+    catch (_error) { return undefined /* Malformed model JSON remains in the generic tool row. */ }
+  }
+  if (!record(args) || typeof args.plan !== 'string') return undefined
+  const markdown = args.plan
+  const title = /^#\s+(\S[^\r\n]*)/.exec(markdown.trim())?.[1]
+  return title === undefined ? undefined : { callId: callId as ToolCallId, markdown, title }
+}
+
+/**
+ * Encode the durable identity of a plan without retaining its text in layout storage.
+ * @param target - Session and tool-call identity.
+ * @returns the plan resource address.
+ */
+export function planAddress(target: PlanAddress): string {
+  return `dsh-resource://plan/${encodeURIComponent(target.sessionId)}/${encodeURIComponent(target.callId)}`
+}
+
+/**
+ * Validate a saved or caller-supplied plan resource address.
+ * @param address - Address submitted to the sidebar or resource provider.
+ * @returns the decoded identity, or undefined for an unsupported address.
+ */
+export function parsePlanAddress(address: string): PlanAddress | undefined {
+  const match = /^dsh-resource:\/\/plan\/([^/?#]+)\/([^/?#]+)$/.exec(address)
+  if (match === null) return undefined
+  try {
+    return { sessionId: decodeURIComponent(match[1] as string) as SessionId, callId: decodeURIComponent(match[2] as string) as ToolCallId }
+  } catch (_error) {
+    // Invalid saved percent encoding cannot identify a Session or invocation.
+    return undefined
+  }
+}

+ 21 - 2
packages/client/ui-plan/tests/browser-plugin.client.spec.ts

@@ -5,6 +5,7 @@
  * outcomes into null (admitted) or a user-visible failure line; teardown
  * empties the seat (HMR safety).
  */
+import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -16,10 +17,23 @@ import type { PlanChipInjected } from '../src/client/index.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { apply as nodeApply } from '../src/index.ts'
 
+function providePreview(ctx: Context) {
+  const events = new ConversationEventRegistry(ctx)
+  ctx.provide('uiConversation', { events })
+  const removeResources = vi.fn()
+  const removeType = vi.fn()
+  ctx.provide('resources', { register: vi.fn(() => removeResources) })
+  ctx.provide('sidebarRightTabs', { register: vi.fn(() => removeType) })
+  ctx.provide('sidebarRight', { openResourceIn: vi.fn() })
+  ctx.provide('remote.session', {})
+  return { events, removeResources, removeType }
+}
+
 const SID = 's-plan' as SessionId
 
 async function bench() {
   const ctx = new Context()
+  const preview = providePreview(ctx)
   await ctx.plugin(SlotRegistry).await()
   const slots = ctx.get('slots') as SlotRegistry
   slots.register({
@@ -32,12 +46,12 @@ async function bench() {
   ctx.provide('remote', { commands: commandsRemote })
   ctx.provide('remote.commands', commandsRemote)
   ctx.provide('locale', new LocaleRuntime(ctx))
-  return { ctx, slots, execute }
+  return { ctx, slots, execute, ...preview }
 }
 
 describe('ui-plan browser apply', () => {
   it('declares every service it binds', () => {
-    expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'locale'])
+    expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'remote.session', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs'])
   })
 
   it('node-half apply is an intentional no-op', () => {
@@ -46,6 +60,7 @@ describe('ui-plan browser apply', () => {
 
   it('waits until conversation declares the plan seat', async () => {
     const ctx = new Context()
+    providePreview(ctx)
     await ctx.plugin(SlotRegistry).await()
     ctx.provide('remote', { commands: {} })
     ctx.provide('remote.commands', {})
@@ -83,7 +98,11 @@ describe('ui-plan browser apply', () => {
     b.execute.mockResolvedValueOnce({ ok: true, value: undefined } as never)
     await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off')
 
+    expect(b.events.entries().map(entry => entry.kind)).toEqual(['submitted-plan'])
     await fiber.dispose()
+    expect(b.events.entries()).toEqual([])
+    expect(b.removeResources).toHaveBeenCalledOnce()
+    expect(b.removeType).toHaveBeenCalledOnce()
     expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
   })
 })

+ 98 - 0
packages/client/ui-plan/tests/plan-preview.client.spec.tsx

@@ -0,0 +1,98 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
+import { en, zh } from '../src/client/locales.ts'
+import { PlanCard, PlanReviewOpen } from '../src/client/PlanCard.tsx'
+import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx'
+import { planAddress, parsePlanAddress, submittedPlan } from '../src/client/plan.ts'
+import { planDefinition } from '../src/client/plan-definition.ts'
+
+afterEach(cleanup)
+const markdown = '# Keep this plan\n\n## Goal\n\n- Review\n- Implement'
+const call = { type: 'tool/call', data: { name: 'exit_plan_mode', callId: 'call:1', arguments: JSON.stringify({ plan: markdown }) } }
+const plan = submittedPlan(call)!
+const target = { sessionId: 'session / 中文' as SessionId, callId: plan.callId }
+const t = makeTranslate(en, commonEn)
+
+describe('submitted plan identity', () => {
+  it('reads native, running PTC, and settled PTC calls', () => {
+    expect(plan).toEqual({ callId: 'call:1', title: 'Keep this plan', markdown })
+    for (const type of ['tool/ptc-dispatch-start', 'tool/ptc-dispatch']) {
+      expect(submittedPlan({ type, data: { name: 'exit_plan_mode', subCallId: 'call:1', arguments: { plan: markdown } } })).toEqual(plan)
+    }
+  })
+  it.each([
+    { type: 'user/message', data: call.data },
+    { type: 'tool/call', data: null },
+    { type: 'tool/call', data: [] },
+    { type: 'tool/call', data: { ...call.data, name: 'read' } },
+    { type: 'tool/call', data: { ...call.data, callId: '' } },
+    { type: 'tool/call', data: { ...call.data, arguments: {} } },
+    { type: 'tool/call', data: { ...call.data, arguments: '{' } },
+    { type: 'tool/call', data: { ...call.data, arguments: 'null' } },
+    { type: 'tool/call', data: { ...call.data, arguments: '{"plan":42}' } },
+    { type: 'tool/call', data: { ...call.data, arguments: '{"plan":"no title"}' } },
+  ])('leaves malformed or unrelated input to its existing renderer: %j', (event) => {
+    expect(submittedPlan(event)).toBeUndefined()
+  })
+  it('round-trips opaque identifiers and refuses malformed addresses', () => {
+    expect(parsePlanAddress(planAddress(target))).toEqual(target)
+    for (const address of ['file:///plan.md', 'dsh-resource://plan/s/c/extra', 'dsh-resource://plan/s/%XX', 'dsh-resource://plan/s/c?text=x']) {
+      expect(parsePlanAddress(address)).toBeUndefined()
+    }
+  })
+  it('keeps a settled PTC update correlated with its existing card', () => {
+    expect(planDefinition.match(call as never)).toEqual({ id: plan.callId, role: 'start' })
+    const event = { type: 'tool/ptc-dispatch', data: { name: 'exit_plan_mode', subCallId: plan.callId, arguments: { plan: markdown } } }
+    expect(planDefinition.match(event as never)).toEqual({ id: plan.callId, role: 'update' })
+    const context = { key: 'plan', id: plan.callId, state: plan, start: { event: { seq: 12 }, location: { kind: 'unresolved' } } }
+    expect(planDefinition.buildViewNode!(context as never)).toMatchObject({ process: 'independent', anchorSeq: 12, data: plan })
+    expect(planDefinition.buildViewNode!({ ...context, state: undefined, start: undefined, matches: [] } as never)).toBeNull()
+  })
+})
+
+describe('plan entry points and document', () => {
+  it('opens the exact persistent card in either locale', () => {
+    for (const dictionary of [en, zh]) {
+      const openPlan = vi.fn()
+      const props = { node: { data: plan }, t: makeTranslate(dictionary, commonEn), openPlan } as unknown as Parameters<typeof PlanCard>[0]
+      const view = render(<PlanCard {...props} />)
+      fireEvent.click(screen.getByRole('button'))
+      expect(openPlan).toHaveBeenCalledWith(plan.callId)
+      expect(screen.getByText(plan.title)).toBeTruthy()
+      view.unmount()
+    }
+  })
+  it('opens the review without approving or cancelling it', () => {
+    const openPlan = vi.fn()
+    const props = { review: { callId: plan.callId }, t, openPlan } as unknown as Parameters<typeof PlanReviewOpen>[0]
+    const view = render(<PlanReviewOpen {...props} />)
+    fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' }))
+    expect(openPlan).toHaveBeenCalledWith(plan.callId)
+    view.rerender(<PlanReviewOpen {...{ review: {}, t, openPlan } as unknown as Parameters<typeof PlanReviewOpen>[0]} />)
+    expect(screen.queryByRole('button')).toBeNull()
+  })
+  it('shows restored Markdown and its heading as the tab title', () => {
+    const props = {
+      t, useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }),
+      useResource: () => ({ status: 'live', value: plan }),
+    }
+    render(<PlanPreview {...props as unknown as Parameters<typeof PlanPreview>[0]} />)
+    expect(screen.getByRole('heading', { name: plan.title })).toBeTruthy()
+    expect(screen.getAllByRole('listitem')).toHaveLength(2)
+    cleanup()
+    render(<PlanTitle {...props as unknown as Parameters<typeof PlanTitle>[0]} />)
+    expect(screen.getByText(plan.title)).toBeTruthy()
+  })
+  it('shows loading and failed reads without an empty sidebar', () => {
+    const props = { t, useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }) }
+    const view = render(<PlanPreview {...{ ...props, useResource: () => ({ status: 'loading' }) } as unknown as Parameters<typeof PlanPreview>[0]} />)
+    expect(screen.getByRole('status').textContent).toBe('Loading plan…')
+    view.rerender(<PlanPreview {...{ ...props, useResource: () => ({ status: 'failed', failure: { message: 'gone' } }) } as unknown as Parameters<typeof PlanPreview>[0]} />)
+    expect(screen.getByRole('status').textContent).toContain('Could not load plan')
+    expect(screen.getByText('gone')).toBeTruthy()
+  })
+})

+ 58 - 0
packages/client/ui-plan/tests/plan-resource.client.spec.ts

@@ -0,0 +1,58 @@
+import { describe, expect, it, vi } from 'vitest'
+import { planResourceProvider } from '../src/client/plan-resource.ts'
+
+const address = 'dsh-resource://plan/session/call'
+const event = { type: 'tool/call', seq: 1, data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' } }
+const entry = { type: 'event', event }
+
+function setup(records: unknown[], hasMore = false) {
+  const closed = vi.fn()
+  const follow = vi.fn(async function* () {
+    try { yield { type: 'snapshot', cursor: 100, records, hasMore } }
+    finally { closed() }
+  })
+  const page = vi.fn(async () => ({ ok: true, value: { records: [entry], hasMore: false } }))
+  const provider = planResourceProvider({ follow, page } as unknown as Parameters<typeof planResourceProvider>[0])
+  return { provider, follow, page, closed }
+}
+
+async function read(provider: ReturnType<typeof planResourceProvider>, url = address, signal = new AbortController().signal) {
+  const values = []
+  for await (const value of provider.open(url, { signal })) values.push(value)
+  return values
+}
+
+describe('plan history resource', () => {
+  it('restores the exact invocation and releases its snapshot stream', async () => {
+    const b = setup([entry])
+    expect(await read(b.provider)).toEqual([{ ok: true, value: { callId: 'call', markdown: '# Saved plan', title: 'Saved plan' } }])
+    expect(b.closed).toHaveBeenCalledOnce()
+    expect(b.page).not.toHaveBeenCalled()
+  })
+  it('pages older history with the fixed opening cursor without changing the chat window', async () => {
+    const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true)
+    expect((await read(b.provider))[0]).toMatchObject({ ok: true })
+    expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal))
+    expect(b.closed).toHaveBeenCalledOnce()
+  })
+  it('reports missing plans and invalid saved addresses', async () => {
+    const b = setup([])
+    expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/not-found' } }])
+    expect(await read(b.provider, 'dsh-resource://plan/s/%')).toMatchObject([{ ok: false, error: { code: 'plan/invalid-address' } }])
+    expect(b.follow).toHaveBeenCalledOnce()
+  })
+  it('does not publish a response after cancellation', async () => {
+    const b = setup([entry])
+    const controller = new AbortController()
+    controller.abort()
+    expect(await read(b.provider, address, controller.signal)).toEqual([])
+    expect(b.follow).not.toHaveBeenCalled()
+  })
+  it('reports stream failures and closes an empty stream', async () => {
+    const b = setup([])
+    b.follow.mockImplementationOnce(async function* () { throw new Error('connection lost') })
+    expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'connection lost' } }])
+    b.follow.mockImplementationOnce(async function* () {})
+    expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/unavailable' } }])
+  })
+})

+ 24 - 0
packages/client/ui-plan/tsconfig.json

@@ -34,6 +34,30 @@
     },
     {
       "path": "../../plan/plan-mode"
+    },
+    {
+      "path": "../../api/session-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../ui-chat"
+    },
+    {
+      "path": "../ui-sidebar-right"
+    },
+    {
+      "path": "../ui-user-questions"
+    },
+    {
+      "path": "../resources"
+    },
+    {
+      "path": "../../typert/protocol"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/tools"
     }
   ]
 }

+ 2 - 2
packages/client/ui-user-questions/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-user-questions/README.md
-README.md: ec20bbc1c16944eb3ffec6798455a544861fec61
-README.zh.md: 9d8a2bf216be8e4d755a64e0b36fdcac8242c734
+README.md: b6604aced9c5732ca302f93610c4fd313498397d
+README.zh.md: 197427d337234eba44e1949de72d383fba1c3553

+ 3 - 1
packages/client/ui-user-questions/README.md

@@ -33,7 +33,7 @@ A multi-select draft keeps its selected labels while the user opens or edits the
 
 ### The plan-review card
 
-A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders the waiting-approval card layout: a `Plan review` strip, the plan as the scrolling markdown body, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead.
+A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders a compact waiting-approval card: a `Plan review` strip with the sidebar opener and one decision row of `Chat about it` / `Refuse` / `Approve`. The complete plan is read in the sidebar through this opener or its permanent Chat card. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead.
 
 ### Failure and recovery
 
@@ -57,6 +57,8 @@ The card claims a request only when it can send every answer that request allows
 
 Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.
 
+A plan review exposes `conversation.plan-review.actions`; the plan plugin contributes a sidebar opener when the review intent identifies its logged invocation. Opening a document does not answer or dismiss the review.
+
 </details>
 
 -----

+ 3 - 1
packages/client/ui-user-questions/README.zh.md

@@ -33,7 +33,7 @@ kind: "package-reference"
 
 ### plan-review 卡片
 
-`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染等待审批卡片的布局:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。
+`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染紧凑的等待审批卡片:一条带侧边栏入口的 `Plan review` 条带,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。通过该入口或聊天历史中的常驻卡片,在侧边栏阅读完整计划。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。
 
 ### 失败与恢复
 
@@ -57,6 +57,8 @@ kind: "package-reference"
 
 编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该条目,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
 
+计划审批提供 `conversation.plan-review.actions` 插槽;当审批意图包含已记录调用的标识时,计划插件在其中提供侧边栏入口。打开文档不会回答或关闭审批。
+
 </details>
 
 -----

+ 2 - 1
packages/client/ui-user-questions/package.json

@@ -66,7 +66,8 @@
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "clsx": "^2.0.0"
+    "clsx": "^2.0.0",
+    "@deepseek-ai/dsh-llm": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 6 - 24
packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css

@@ -12,20 +12,11 @@
   flex-direction: column;
   width: 100%;
   max-width: var(--dsh-chat-content-width);
-  /* Composer seat sits in a fixed-height conversation column (overflow
-     hidden): cap the card against the viewport and scroll the plan, so the
-     strip and the decision row stay reachable on a long plan. */
-  max-height: min(60vh, 520px);
   border: 1px solid var(--dsw-alias-state-warn-secondary);
   border-radius: 20px;
   background: var(--dsw-specific-input-major);
   box-shadow: var(--dsw-shadow-lv2);
   color: var(--dsw-alias-label-primary);
-  /* Elevated surface in dark: the plan body inside scrolls once the card hits
-     the cap above, so the thumb takes the l2 pair (see ui-theme
-     styles/scrollbar.css for the rebinding contract). */
-  --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
-  --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
 }
 
 .card,
@@ -54,17 +45,6 @@
   background: var(--dsw-alias-state-warn-primary);
 }
 
-/* The plan is the panel's message: it takes the whole body and the scroll. */
-.body {
-  flex: 1 1 auto;
-  min-height: 0;
-  overflow-y: auto;
-  overscroll-behavior: contain;
-  padding: 12px 16px 4px;
-  font-size: 14px;
-  line-height: 22px;
-}
-
 .footer {
   display: flex;
   align-items: center;
@@ -105,12 +85,14 @@
     border-radius: 16px;
   }
 
-  .body {
-    padding: 10px 12px 4px;
-  }
-
   .footer {
     align-items: flex-end;
     padding: 8px 12px 10px;
   }
 }
+
+.previewActions {
+  margin-left: auto;
+  display: flex;
+  align-items: center;
+}

+ 8 - 12
packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx

@@ -1,11 +1,11 @@
-import { useMemo, useState } from 'react'
-import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { useState } from 'react'
+import { Button, IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts'
 import css from './PlanReviewPanel.module.css'
 
 /** The panel's own props: the question domain face, the narrowed review, and the locale seat. */
 export type PlanReviewPanelProps =
-  { pending: PendingQuestion; review: PlanReview } & Pick<QuestionComposerProps, 't'>
+  { pending: PendingQuestion; review: PlanReview } & Pick<QuestionComposerProps, 't' | 'renderSlot'>
 
 /**
  * Optional-prop spread for a decision button's tooltip: `title` is optional on
@@ -19,16 +19,12 @@ function tooltip(description: string | undefined): { title?: string } {
 }
 
 /**
- * Render a plan review as a decision card.
+ * Render plan review controls; the submitted document opens in the sidebar.
  *
  * @param props - the question domain face, the narrowed plan review, and `t`.
  * @returns The plan-review takeover for this request.
  */
-export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
-  const markdownLabels = useMemo(() => ({
-    code: { copyLabel: t('copy'), copiedLabel: t('copied') },
-    footnotes: t('markdown.footnotes'),
-  }), [t])
+export function PlanReviewPanel({ pending, review, t, renderSlot }: PlanReviewPanelProps) {
   // The panel waits for the host's resolved frame before leaving, so repeated
   // clicks must not resubmit. A failed send re-enables it and shows the error.
   const [busy, setBusy] = useState(false)
@@ -52,9 +48,9 @@ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
         <div className={css.strip}>
           <span className={css.dot} />
           {t('plan.header')}
-        </div>
-        <div className={css.body} data-plan-review-scroll>
-          <MarkdownText text={review.plan} labels={markdownLabels} />
+          <div className={css.previewActions}>
+            {renderSlot('conversation.plan-review.actions', { review })}
+          </div>
         </div>
         <div className={css.footer}>
           <div className={css.feedback} role="status">{error}</div>

+ 1 - 1
packages/client/ui-user-questions/src/client/QuestionComposer.tsx

@@ -123,7 +123,7 @@ export function QuestionComposer(props: QuestionComposerProps) {
         actions={props.actions}
       />
     )
-    : <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
+    : <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} renderSlot={props.renderSlot} />
 }
 
 type QuestionFlowProps =

+ 13 - 1
packages/client/ui-user-questions/src/client/contract/slots.ts

@@ -1,6 +1,7 @@
 /** Question composer props and one pending Remote waterfall response. */
-import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 // The client module declares the conversation.composer SlotMap entry required by PropsRuntime.
+import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {
   AskUserQuestionAnswer, AskUserQuestionItem,
@@ -14,6 +15,13 @@ declare module '@deepseek-ai/dsh-client-ui-session/client' {
   }
 }
 
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotMap {
+    /** Actions for the exact plan under review; approval remains with the question composer. */
+    'conversation.plan-review.actions': { kind: 'list'; scope: 'session'; owner: { review: PlanReview } }
+  }
+}
+
 /** One structured answer batch covering every question of the request. */
 export type QuestionAnswer = AskUserQuestionAnswer
 
@@ -50,6 +58,8 @@ export interface PlanReview {
   question: string
   /** The plan markdown under review. */
   plan: string
+  /** Logged tool invocation used to reopen this plan. */
+  callId?: ToolCallId
   /** The option that approves the plan. */
   approve: QuestionOption
   /** The option that declines it; absent when the asker offered no other option. */
@@ -90,6 +100,7 @@ export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | u
     id: question.id,
     question: question.question,
     plan: question.detail,
+    ...intent.callId === undefined ? {} : { callId: intent.callId },
     approve,
     ...(decline === undefined ? {} : { decline }),
   }
@@ -220,5 +231,6 @@ export type QuestionWait = PendingQuestion
 export type QuestionComposerProps =
   PropsRuntime<'conversation.composer'>
   & PropsStore<ReturnType<typeof createQuestionDraftStore>>
+  & PropsRenderSlots<'conversation.plan-review.actions'>
   & { matched: QuestionWait }
   & PropsLocale<'question'>

+ 1 - 0
packages/client/ui-user-questions/src/client/index.ts

@@ -98,6 +98,7 @@ export function apply(ctx: ClientContext): void {
         pendingInteraction instanceof PendingQuestion ? pendingInteraction : null,
       locale: NS,
       store: questionDraftStore,
+      children: { 'conversation.plan-review.actions': { kind: 'list', scope: 'session' } },
     },
     QuestionComposer,
   ))

+ 6 - 4
packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx

@@ -108,6 +108,8 @@ const questionDraftStore = createQuestionDraftStore().create(SID)
 
 /** Framework standard-kit stubs: the panel consumes only the locale seat. */
 const kit: Omit<QuestionComposerProps, 'matched'> = {
+  renderSlot: () => null,
+  SessionProvider: ({ children }) => children,
   sessionId: SID,
   session: undefined,
   pendingInteraction: undefined,
@@ -203,15 +205,15 @@ describe('planReviewOf', () => {
 })
 
 describe('PlanReviewPanel', () => {
-  it('renders the plan under a review strip, with none of the quiz affordances', () => {
+  it('keeps the review compact with document reading left to the sidebar', () => {
     const { carrier } = wait()
     render(<QuestionComposer matched={carrier} {...kit} />)
 
     expect(document.querySelector('[data-plan-review-key]')?.getAttribute('data-plan-review-key')).toBe(carrier.key)
     expect(screen.getByText(zh['plan.header'])).toBeTruthy()
-    // The plan renders as markdown, so its heading is a heading.
-    expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy()
-    expect(screen.getByText('render the rows')).toBeTruthy()
+    expect(screen.queryByRole('heading', { name: 'Ship the picker' })).toBeNull()
+    expect(screen.queryByText('render the rows')).toBeNull()
+    expect(document.querySelector('[data-plan-review-scroll]')).toBeNull()
     // The question text stays as the card's accessible name rather than a title
     // that reads like a test item.
     expect(screen.getByLabelText('Approve this plan and leave plan mode?')).toBeTruthy()

+ 2 - 0
packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx

@@ -107,6 +107,8 @@ const inputState: InputState = {
  *  the composed props type mandates delivery of the rest (framework hooks are
  *  plain stubs per the client testing discipline). */
 const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> = {
+  renderSlot: () => null,
+  SessionProvider: ({ children }) => children,
   session: undefined,
   sessionId: SID,
   pendingInteraction: undefined,

+ 3 - 0
packages/client/ui-user-questions/tsconfig.json

@@ -46,6 +46,9 @@
     },
     {
       "path": "../ui-session"
+    },
+    {
+      "path": "../../llm/llm"
     }
   ]
 }

+ 62 - 1
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -268,7 +268,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'useProjection: UseProjection',
       'useTrajectory: UseTrajectory',
     ],
-    keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run',
+    keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, submitted-plan, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run',
     hookContext: 'ConversationLocationDataStore<ConversationTurnDataMap> | undefined',
     slotInject: 'ChatNodeTurnDataInjected',
     declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted',
@@ -288,6 +288,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'client-ui-chat TurnTailNodeView key \'turn-tail\'',
       'client-ui-chat UnknownNodeView key \'unknown\'',
       'client-ui-goal GoalCommandInputView key \'command-input\'',
+      'client-ui-plan PlanCard key \'submitted-plan\'',
       'client-ui-tool ToolCallTree key \'tool-call\'',
       'client-ui-workflow-run WorkflowRunPanel key \'workflow-run\'',
     ],
@@ -1051,6 +1052,64 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n      { name: \'conversation.message.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     source: 'packages/client/ui-chat/src/client/contract/slots.ts:199',
   },
+  {
+    key: 'conversation.plan-review.actions',
+    kind: 'list',
+    scope: 'session',
+    summary: 'Actions for the exact plan under review; approval remains with the question composer.',
+    doc: 'Actions for the exact plan under review; approval remains with the question composer.',
+    registerOptions: [
+      {
+        name: 'id',
+        requirement: 'required',
+        type: 'string',
+        doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
+      },
+      {
+        name: 'order',
+        requirement: 'optional',
+        type: 'number',
+        doc: 'Position among the entries, ascending (default 0).',
+      },
+      {
+        name: 'label',
+        requirement: 'optional',
+        type: 'string | (() => string)',
+        doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
+      },
+    ],
+    ownerProps: [
+      '/**\n * A request narrowed to the `plan-review` presentation intent: everything the\n * decision card renders and answers with, so the panel never re-reads the\n * request fields. `approve` and `decline` are the asker\'s own options — an\n * answer must carry one of those labels verbatim — and `plan` is the markdown\n * body under review.\n */\nexport interface PlanReview {\n  /** The reviewed question\'s id, echoed in the answer. */\n  id: string\n  /** The question text, kept as the card\'s accessible name. */\n  question: string\n  /** The plan markdown under review. */\n  plan: string\n  /** Logged tool invocation used to reopen this plan. */\n  callId?: ToolCallId\n  /** The option that approves the plan. */\n  approve: QuestionOption\n  /** The option that declines it; absent when the asker offered no other option. */\n  decline?: QuestionOption\n}',
+    ],
+    ownerPropsReferences: [],
+    standardProps: [
+      'useResource: UseResource',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'usePanelInfo: UsePanelInfo',
+      'useSessions: UseSessions',
+      'useSessionStatus: UseSessionStatus',
+      'useSessionRetainInfo: UseSessionRetainInfo',
+      'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
+      'useChat: UseChat',
+      'useConversation: UseConversation',
+      'useInput: SnapshotSelectorHook<InputState>',
+      'inputActions: InputActions',
+      'useSession: SessionSnapshotSelector',
+      'sessionId: SessionId',
+      'useProjection: UseProjection',
+      'useTrajectory: UseTrajectory',
+    ],
+    keyDomain: '',
+    hookContext: '',
+    slotInject: '',
+    declaredBy: 'an entry in \'conversation.composer\' (client-ui-user-questions), so it exists while that entry is mounted',
+    occupants: [
+      'client-ui-plan PlanReviewOpen',
+    ],
+    replaceRisk: 'none',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.plan-review.actions\', () => ctx.slots.register(\n      { name: \'conversation.plan-review.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    source: 'packages/client/ui-user-questions/src/client/contract/slots.ts:21',
+  },
   {
     key: 'conversation.session',
     kind: 'single',
@@ -2486,6 +2545,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted',
     occupants: [
       'client-ui-deliverables ReviewTab',
+      'client-ui-plan PlanPreview',
       'client-ui-sidebar-documentpreview TextPreview',
       'client-ui-sidebar-files FilesBody',
       'client-ui-sidebar-right GuideBody',
@@ -2533,6 +2593,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     slotInject: 'SidebarRightTabInjected',
     declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted',
     occupants: [
+      'client-ui-plan PlanTitle',
       'client-ui-sidebar-documentpreview TextTitle',
       'client-ui-sidebar-files FilesTitle',
       'client-ui-sidebar-right GuideTitle',

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

@@ -4037,7 +4037,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'AskUserQuestionIntent',
-    declaration: 'export type AskUserQuestionIntent = {\n    kind: \'plan-review\';\n    approve: string;\n};',
+    declaration: 'export type AskUserQuestionIntent = {\n    kind: \'plan-review\';\n    approve: string;\n    callId?: ToolCallId;\n};',
   },
   {
     name: 'AskUserQuestionItem',

+ 2 - 2
packages/interaction/user-questions/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/interaction/user-questions/README.md
-README.md: 5558c1483937982aad791f9c90e81ef9b02b9d83
-README.zh.md: 17cf9c973c82e4854c2807d85ff09cb9751bca14
+README.md: b7082f7b76ad974ecb75f48dc84f857784121fc9
+README.zh.md: 81f1f3e72b569dcaac96add3f6ff73cbe9038965

+ 2 - 0
packages/interaction/user-questions/README.md

@@ -74,6 +74,8 @@ No direct invalidation; the named consumer owns any request-prefix changes.
 
 None.
 
+The optional plan-review `callId` identifies the logged tool invocation for document navigation. It does not change the answer or its validation.
+
 </details>
 
 **Runtime invariant:** No companion is published. The answerer waterfall is resolved per request and returns directly to its caller; the seam publishes no independent request/answer audit stream.

+ 2 - 0
packages/interaction/user-questions/README.zh.md

@@ -74,6 +74,8 @@ kind: "package-reference"
 
 无。
 
+计划审批中可选的 `callId` 标识已记录的工具调用,供文档导航使用,不改变回答及其校验。
+
 </details>
 
 **运行时不变式:** 不发布伴生入口。answerer waterfall 按请求解析并把结果直接返回调用方;该 seam 不发布独立的请求/回答审计流。

+ 3 - 0
packages/interaction/user-questions/src/types.ts

@@ -2,6 +2,7 @@
 
 import type { Scoped } from '@deepseek-ai/dsh-scope'
 import type { Agent } from '@deepseek-ai/dsh-agent/types'
+import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
 
 /** One selectable answer offered to the user. */
 export interface AskUserQuestionOption {
@@ -27,6 +28,8 @@ export type AskUserQuestionIntent = {
    * An `approve` naming no option of its own question is rejected at `ask()`.
    */
   approve: string
+  /** Logged tool invocation whose arguments contain the reviewed plan. */
+  callId?: ToolCallId
 }
 
 /** One question in a user-questions request. */

+ 2 - 2
packages/plan/plan-mode/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/plan/plan-mode/README.md
-README.md: 693273ab4bdfddebd6145046e8354a5a211c8b8b
-README.zh.md: 4782eed381ee3bdffab22ca162696f8711b33b47
+README.md: 642caf0c6db6470e93c7b0583d2bfe145ed99f29
+README.zh.md: 4165d5b925083d10c76312d00293fd0b0ac84ba0

+ 2 - 0
packages/plan/plan-mode/README.md

@@ -105,6 +105,8 @@ When `ctx.sessionProjections` is composed, the package registers the `plan` unit
 | [`src/client.ts`](src/client.ts) | Client-namespace re-export of the types outlet |
 | [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates the `plan/mode` payload shape |
 
+The review intent carries the originating tool-call id, so the Web client can reopen the same submitted plan after the review closes. The complete Markdown remains in the existing native call or PTC dispatch log.
+
 </details>
 
 -----

+ 2 - 0
packages/plan/plan-mode/README.zh.md

@@ -105,6 +105,8 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并
 | [`src/client.ts`](src/client.ts) | types 出口的客户端命名空间再导出 |
 | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验 `plan/mode` 载荷结构 |
 
+审批意图携带发起工具调用的标识,让 Web 客户端在审批关闭后重新打开同一份计划。完整 Markdown 保留在已有的原生调用或 PTC dispatch 日志中。
+
 </details>
 
 -----

+ 1 - 1
packages/plan/plan-mode/src/index.ts

@@ -312,7 +312,7 @@ export class PlanModeController extends Service {
             // Presentation only: a capable UI renders the plan as a review
             // decision instead of a generic question, and answers with one of
             // the labels above either way.
-            intent: { kind: 'plan-review', approve: APPROVE_LABEL },
+            intent: { kind: 'plan-review', approve: APPROVE_LABEL, callId: exec.callId },
           }],
           agent,
           signal: exec.signal,

+ 1 - 1
packages/plan/plan-mode/tests/plan-mode.spec.ts

@@ -1074,7 +1074,7 @@ describe('exit_plan_mode', () => {
     const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
     await callExit(ctx, agent)
     const question = asked[0]?.questions[0]
-    expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve' })
+    expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve', callId: `call-exit-${callCounter}` })
     // The named label is one this same question offers, so a UI honouring the
     // intent answers a choice this tool accepts.
     expect(question?.options?.map(option => option.label)).toContain(question?.intent?.approve)

+ 32 - 5
pnpm-lock.yaml

@@ -3614,12 +3614,21 @@ importers:
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
+      '@deepseek-ai/dsh-api-session-controller':
+        specifier: workspace:^
+        version: link:../../api/session-controller
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale
+      '@deepseek-ai/dsh-client-resources':
+        specifier: workspace:^
+        version: link:../resources
       '@deepseek-ai/dsh-client-test-runtime':
         specifier: workspace:^
         version: link:../../test-support/client-runtime
+      '@deepseek-ai/dsh-client-ui-chat':
+        specifier: workspace:^
+        version: link:../ui-chat
       '@deepseek-ai/dsh-client-ui-conversation':
         specifier: workspace:^
         version: link:../ui-conversation
@@ -3632,15 +3641,30 @@ importers:
       '@deepseek-ai/dsh-client-ui-session':
         specifier: workspace:^
         version: link:../ui-session
+      '@deepseek-ai/dsh-client-ui-sidebar-right':
+        specifier: workspace:^
+        version: link:../ui-sidebar-right
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-client-ui-user-questions':
+        specifier: workspace:^
+        version: link:../ui-user-questions
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
       '@deepseek-ai/dsh-plan-mode':
         specifier: workspace:^
         version: link:../../plan/plan-mode
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -4927,6 +4951,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
@@ -8106,7 +8133,7 @@ importers:
         version: link:../../../vendor/schemastery
       '@earendil-works/pi-ai':
         specifier: ^0.85.1
-        version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)
+        version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -20561,14 +20588,14 @@ snapshots:
     transitivePeerDependencies:
       - '@algolia/client-search'
 
-  '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)':
+  '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)':
     dependencies:
       '@anthropic-ai/sdk': 0.123.0(zod@4.4.3)
       '@aws-sdk/client-bedrock-runtime': 3.1048.0
       '@earendil-works/pi-telemetry': 0.85.1
       '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))
       '@smithy/node-http-handler': 4.7.3
-      http-proxy-agent: 7.0.2(supports-color@9.4.0)
+      http-proxy-agent: 7.0.2
       https-proxy-agent: 7.0.6
       openai: 6.40.0(ws@8.21.0)(zod@4.4.3)
       partial-json: 0.1.7
@@ -23196,7 +23223,7 @@ snapshots:
       cross-spawn: 7.0.6
       debug: 4.4.3(supports-color@9.4.0)
       fs-extra: 10.1.0
-      http-proxy-agent: 7.0.2(supports-color@9.4.0)
+      http-proxy-agent: 7.0.2
       https-proxy-agent: 7.0.6
       js-yaml: 4.3.1
       sanitize-filename: 1.6.4
@@ -24511,7 +24538,7 @@ snapshots:
       statuses: 2.0.2
       toidentifier: 1.0.1
 
-  http-proxy-agent@7.0.2(supports-color@9.4.0):
+  http-proxy-agent@7.0.2:
     dependencies:
       agent-base: 7.1.4
       debug: 4.4.3(supports-color@9.4.0)

+ 4 - 2
snapshots/web/plan-review/approved-expanded.expected.md

@@ -5,8 +5,6 @@
   - text: Standard mode
   - button "More actions":
     - img
-  - button "Open right sidebar":
-    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
@@ -34,6 +32,10 @@
   - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with
   - code: exit_plan_mode
   - text: .
+- 'button "Open plan: Add `--greeting` flag to CLI"':
+  - img
+  - text: "Add `--greeting` flag to CLI"
+  - img
 - 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"':
   - img
   - img

+ 4 - 2
snapshots/web/plan-review/approved.expected.md

@@ -5,8 +5,6 @@
   - text: Standard mode
   - button "More actions":
     - img
-  - button "Open right sidebar":
-    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"
@@ -22,6 +20,10 @@
 - button "1 tool call · 1 message":
   - text: 1 tool call · 1 message
   - img
+- 'button "Open plan: Add `--greeting` flag to CLI"':
+  - img
+  - text: "Add `--greeting` flag to CLI"
+  - img
 - button "System prompt":
   - img
   - img

+ 39 - 0
snapshots/web/plan-review/preview.expected.md

@@ -0,0 +1,39 @@
+- 'region "Add `--greeting` flag to CLI"':
+  - button "Copy":
+    - img
+  - heading "Add --greeting flag to CLI" [level=1]:
+    - text: Add
+    - code: "--greeting"
+    - text: flag to CLI
+  - list:
+    - listitem:
+      - strong: Locate the CLI entry point
+      - text: (e.g.,
+      - code: cli.py
+      - text: ","
+      - code: main.go
+      - text: ","
+      - code: index.js
+      - text: etc.) and find the argument parser definition (argparse, click, cobra, yargs, or similar).
+    - listitem:
+      - strong: Register a new optional string argument
+      - text: named
+      - code: "--greeting"
+      - text: with a short alias (
+      - code: "-g"
+      - text: if available) and a sensible default value (e.g.,
+      - code: "\"Hello\""
+      - text: ).
+    - listitem:
+      - strong: Thread the parsed value
+      - text: through the main handler function so it is passed where the greeting string is used (e.g., the welcome/response message).
+    - listitem:
+      - strong: Update the help text
+      - text: so
+      - code: "--help"
+      - text: or
+      - code: "-h"
+      - text: shows the new flag with its description.
+    - listitem:
+      - strong: No tests or config changes
+      - text: unless they already exist and directly validate the flag's presence.

+ 2 - 36
snapshots/web/plan-review/review.expected.md

@@ -1,41 +1,7 @@
 - region "Approve this plan and leave plan mode?":
   - text: Plan review
-  - heading "Add --greeting flag to CLI" [level=1]:
-    - text: Add
-    - code: "--greeting"
-    - text: flag to CLI
-  - list:
-    - listitem:
-      - strong: Locate the CLI entry point
-      - text: (e.g.,
-      - code: cli.py
-      - text: ","
-      - code: main.go
-      - text: ","
-      - code: index.js
-      - text: etc.) and find the argument parser definition (argparse, click, cobra, yargs, or similar).
-    - listitem:
-      - strong: Register a new optional string argument
-      - text: named
-      - code: "--greeting"
-      - text: with a short alias (
-      - code: "-g"
-      - text: if available) and a sensible default value (e.g.,
-      - code: "\"Hello\""
-      - text: ).
-    - listitem:
-      - strong: Thread the parsed value
-      - text: through the main handler function so it is passed where the greeting string is used (e.g., the welcome/response message).
-    - listitem:
-      - strong: Update the help text
-      - text: so
-      - code: "--help"
-      - text: or
-      - code: "-h"
-      - text: shows the new flag with its description.
-    - listitem:
-      - strong: No tests or config changes
-      - text: unless they already exist and directly validate the flag's presence.
+  - button "Open plan in sidebar":
+    - img
   - status
   - button "Chat about it":
     - img