Răsfoiți Sursa

fix(web): preview unlogged plans and preserve subagent history addresses

Yichen Jiang 3 zile în urmă
părinte
comite
46b4f94802
29 a modificat fișierele cu 357 adăugiri și 101 ștergeri
  1. 2 2
      .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml
  2. 5 3
      .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md
  3. 5 3
      .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md
  4. 9 0
      apps/web/tests/expected/plan-review/temporary.expected.md
  5. 51 0
      apps/web/tests/plan-review.e2e.ts
  6. 2 2
      packages/client/ui-plan/README.i18n.yaml
  7. 3 1
      packages/client/ui-plan/README.md
  8. 3 1
      packages/client/ui-plan/README.zh.md
  9. 2 1
      packages/client/ui-plan/package.json
  10. 17 12
      packages/client/ui-plan/src/client/PlanCard.tsx
  11. 14 6
      packages/client/ui-plan/src/client/PlanPreview.tsx
  12. 19 0
      packages/client/ui-plan/src/client/failure-line.ts
  13. 22 6
      packages/client/ui-plan/src/client/index.ts
  14. 10 0
      packages/client/ui-plan/src/client/locales.ts
  15. 1 1
      packages/client/ui-plan/src/client/plan-resource.ts
  16. 25 7
      packages/client/ui-plan/src/client/plan.ts
  17. 29 0
      packages/client/ui-plan/src/client/review-preview.ts
  18. 3 4
      packages/client/ui-plan/src/client/review-store.ts
  19. 33 11
      packages/client/ui-plan/tests/browser-plugin.client.spec.ts
  20. 64 35
      packages/client/ui-plan/tests/plan-preview.client.spec.tsx
  21. 9 0
      packages/client/ui-plan/tests/plan-resource.client.spec.ts
  22. 3 0
      packages/client/ui-plan/tsconfig.json
  23. 2 2
      packages/client/ui-user-questions/README.i18n.yaml
  24. 1 1
      packages/client/ui-user-questions/README.md
  25. 1 1
      packages/client/ui-user-questions/README.zh.md
  26. 1 1
      packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx
  27. 1 1
      packages/client/ui-user-questions/src/client/contract/slots.ts
  28. 17 0
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  29. 3 0
      pnpm-lock.yaml

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # 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
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md
-2026-09-17-persistent-plan-cards.md: 341175aabc41100b98b7d2f16120fc9ac91a7020
-2026-09-17-persistent-plan-cards.zh.md: 8276bb6cc9985f0b856193a4ab861751089dd8e9
+2026-09-17-persistent-plan-cards.md: 6959cf39f4e47dbd552ee14af7c1ebc09b52bef6
+2026-09-17-persistent-plan-cards.zh.md: 0c40cb5caf4b77f5d4b5c30cead5567f21b45cfd

+ 5 - 3
.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md

@@ -12,9 +12,11 @@ A plan review occupies the composer only until the user answers or dismisses it.
 
 
 `ui-plan` accumulates each Turn’s `exit_plan_mode` submissions, including native calls and PTC dispatches. Each invocation contributes a card to the completed Turn’s final artifact area. The `conversation.chat.turnTail` list admits file deliveries and plan cards together. Review dismissal, refusal, and approval do not delete the recorded plans.
 `ui-plan` accumulates each Turn’s `exit_plan_mode` submissions, including native calls and PTC dispatches. Each invocation contributes a card to the completed Turn’s final artifact area. The `conversation.chat.turnTail` list admits file deliveries and plan cards together. Review dismissal, refusal, and approval do not delete the recorded plans.
 
 
-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 review intent may carry the tool-call identity. The question plugin declares an action slot, and the plan plugin contributes an opener there and on the historical card. When an invocation exists, both open the resource identified by its complete Session address and invocation. Subagent addresses retain the direct parent, child, and mode for snapshot reads, pagination, and reload. 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, View full plan link, title, two-line plain-text summary, and Request changes / Approve buttons; the complete text opens automatically in the sidebar. A transient Session-scoped store records each automatically opened invocation, so review remounts preserve manual closure. Historical cards never trigger automatic navigation. The cards use the file-delivery treatment with a Markdown icon, plan title, and Open action. Request changes cancels the pending wait and returns the composer for feedback. 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.
+A review without an invocation opens a temporary address keyed by browser lifetime, Session, and pending request. The sidebar’s existing navigation parameters hold its Markdown without adding another document store or persisting text in layout. Closing and reopening from the pending card supplies the text again; restored tabs without navigation parameters report an expired preview.
+
+The document is read-only. The pending review shows its status, View full plan link, title, two-line plain-text summary, and Request changes / Approve buttons; the complete text opens automatically in the sidebar. A transient Session-scoped store records each automatically opened invocation or unlogged request, so review remounts preserve manual closure. Historical cards never trigger automatic navigation. The cards use the file-delivery treatment with a Markdown icon, plan title, and Open action. Request changes cancels the pending wait and returns the composer for feedback. 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
 ## Alternatives considered
 
 
@@ -26,4 +28,4 @@ The document is read-only. The pending review shows its status, View full plan l
 
 
 ## Consequences
 ## Consequences
 
 
-Plans survive review closure and browser reload without a new Session event or file. Complete plan arguments retain a card even when the tool rejects the call, including calls outside plan mode; the card does not certify approval or execution. 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 multiple submissions, PTC deduplication, cropped history, and resource failures.
+Logged plans survive review closure and browser reload without a new Session event or file. Complete plan arguments retain a card even when the tool rejects the call, including calls outside plan mode; the card does not certify approval or execution. 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 multiple submissions, PTC deduplication, cropped history, and resource failures.

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

@@ -12,9 +12,11 @@ Status: implemented
 
 
 `ui-plan` 汇总每个回合的 `exit_plan_mode` 提交,覆盖原生调用和 PTC dispatch。每次调用在已完成回合末尾的产物区域贡献一张卡片。`conversation.chat.turnTail` 列表同时容纳文件产物和计划卡片。关闭、拒绝或批准审批都不会删除已记录的计划。
 `ui-plan` 汇总每个回合的 `exit_plan_mode` 提交,覆盖原生调用和 PTC dispatch。每次调用在已完成回合末尾的产物区域贡献一张卡片。`conversation.chat.turnTail` 列表同时容纳文件产物和计划卡片。关闭、拒绝或批准审批都不会删除已记录的计划。
 
 
-审批意图携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。两者都打开由会话和调用标识确定的资源。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。
+审批意图可以携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。存在调用标识时,两者都打开由完整会话地址和调用标识确定的资源。子会话地址保留直接父级、子会话及运行模式,用于快照读取、分页和刷新恢复。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。
 
 
-文档只读。待处理审批显示状态、“查看全文”链接、标题、两行纯文本摘要,以及“要求修改”和“同意执行”按钮;完整正文自动在侧边栏打开。临时的会话级 store 记录每个已自动打开的调用,让审批组件重新挂载时仍保留用户的关闭选择。历史卡片不会触发自动导航。卡片采用文件产物样式,包含 Markdown 图标、计划标题和“打开”操作。“要求修改”取消待处理等待并恢复编辑器,供用户输入反馈。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。
+没有调用标识的审批打开临时地址,由浏览器生命周期、会话和待处理请求共同标识。侧边栏已有的导航参数保存 Markdown,无需额外的文档 store,也不把正文持久化到布局中。关闭后从待处理卡片重新打开会再次提供正文;恢复的标签页若缺少导航参数,则显示预览已失效。
+
+文档只读。待处理审批显示状态、“查看全文”链接、标题、两行纯文本摘要,以及“要求修改”和“同意执行”按钮;完整正文自动在侧边栏打开。临时的会话级 store 记录每个已自动打开的调用或无调用标识的请求,让审批组件重新挂载时仍保留用户的关闭选择。历史卡片不会触发自动导航。卡片采用文件产物样式,包含 Markdown 图标、计划标题和“打开”操作。“要求修改”取消待处理等待并恢复编辑器,供用户输入反馈。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 
@@ -26,4 +28,4 @@ Status: implemented
 
 
 ## Consequences
 ## Consequences
 
 
-计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。即使工具拒绝调用,包括在计划模式之外调用,完整的计划参数也会保留卡片;卡片不代表批准或执行成功。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。
+已记录计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。即使工具拒绝调用,包括在计划模式之外调用,完整的计划参数也会保留卡片;卡片不代表批准或执行成功。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。

+ 9 - 0
apps/web/tests/expected/plan-review/temporary.expected.md

@@ -0,0 +1,9 @@
+- region "Temporary review":
+  - button "Copy":
+    - img
+  - heading "Temporary review" [level=1]
+  - paragraph: Review without a tool invocation.
+  - heading "Implementation" [level=2]
+  - list:
+    - listitem: Keep the complete document readable.
+    - listitem: Ask before implementation.

+ 51 - 0
apps/web/tests/plan-review.e2e.ts

@@ -14,6 +14,7 @@ import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import {
 import {
   assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
   assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
   compareOrRefreshGolden, fixtureUserPrompts,
   compareOrRefreshGolden, fixtureUserPrompts,
@@ -30,6 +31,7 @@ const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
 const PREVIEW_EXPECTED = join(SNAPSHOT_DIR, 'preview.expected.md')
 const PREVIEW_EXPECTED = join(SNAPSHOT_DIR, 'preview.expected.md')
 const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
 const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
 const APPROVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'approved-expanded.expected.md')
 const APPROVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'approved-expanded.expected.md')
+const TEMPORARY_EXPECTED = fileURLToPath(new URL('./expected/plan-review/temporary.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 const MODE = webSnapshotMode()
 
 
 // One command line: /plan enters plan mode and submits the rest as the turn's
 // One command line: /plan enters plan mode and submits the rest as the turn's
@@ -47,6 +49,7 @@ describe('web e2e: plan review takeover round trip', () => {
   let page: Page
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
   let tripwire: ReturnType<typeof watchConsole>
   const sessionEvents: SessionEvent[] = []
   const sessionEvents: SessionEvent[] = []
+  let reviewedSession: SessionId
 
 
   beforeAll(async () => {
   beforeAll(async () => {
     scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15, compareReplaySession: true })
     scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15, compareReplaySession: true })
@@ -124,6 +127,7 @@ describe('web e2e: plan review takeover round trip', () => {
     await page.mouse.move(0, 0)
     await page.mouse.move(0, 0)
 
 
     const sessionId = await settled
     const sessionId = await settled
+    reviewedSession = sessionId
     if (MODE === 'record') {
     if (MODE === 'record') {
       await recordFixture(scaffold, sessionId, FIXTURE)
       await recordFixture(scaffold, sessionId, FIXTURE)
       return
       return
@@ -158,6 +162,53 @@ describe('web e2e: plan review takeover round trip', () => {
     expect(tripwire.warnings).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 200_000)
   }, 200_000)
 
 
+  it.skipIf(MODE === 'record')('previews an unlogged review automatically and reopens it without deciding', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-temporary'))
+    const agent = scaffold.ctx.agents.get(reviewedSession)
+    if (agent === undefined) throw new Error('The reviewed Session has no active agent')
+    const controller = new AbortController()
+    // This public question request has no tool invocation; its detail is the only complete document.
+    const asked = scaffold.ctx.userQuestions.ask({
+      agent, signal: controller.signal,
+      questions: [{ id: 'temporary', question: 'Approve this temporary plan?',
+        detail: '# Temporary review\n\nReview without a tool invocation.\n\n## Implementation\n\n- Keep the complete document readable.\n- Ask before implementation.',
+        options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' },
+      }],
+    })
+    let answered = false
+    const outcome = asked.then((value) => { answered = true; return value }, (error: unknown) => ({ error }))
+    try {
+      const card = page.locator('[data-plan-review-key]')
+      const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]')
+      await preview.waitFor({ state: 'visible' })
+      expect(await preview.getByText('Ask before implementation.').isVisible()).toBe(true)
+      await compareOrRefreshGolden(TEMPORARY_EXPECTED, await captureStableAria(page, '[data-plan-preview^="dsh-resource://plan-review/"]', scaffold.workspaceCwd), MODE)
+      const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Temporary review' })
+      await card.getByRole('button', { name: 'Open plan in sidebar' }).click()
+      expect(await tab.count()).toBe(1)
+      await tab.locator('[data-dockkit-tab-close]').click()
+      await preview.waitFor({ state: 'detached' })
+      await card.getByRole('button', { name: 'Open plan in sidebar' }).click()
+      await preview.waitFor({ state: 'visible' })
+      expect(answered).toBe(false)
+      const saved = await page.evaluate(() => Object.keys(localStorage)
+        .filter(key => key.startsWith('dsh.sidebar-right.v1.')).map(key => localStorage.getItem(key)).join('\n'))
+      expect(saved).toContain('dsh-resource://plan-review/')
+      expect(saved).not.toContain('Ask before implementation.')
+      await card.getByRole('button', { name: 'Approve', exact: true }).click()
+      expect(await outcome).toEqual({ answers: [{ id: 'temporary', selected: ['Approve'] }] })
+      await card.waitFor({ state: 'detached' })
+      expect(await preview.getByText('Ask before implementation.').isVisible()).toBe(true)
+      await page.reload({ waitUntil: 'load' })
+      await page.getByText('This temporary plan preview has expired. Reopen it from the pending review card.', { exact: true }).waitFor()
+      expect(tripwire.pageErrors).toEqual([])
+      expect(tripwire.warnings).toEqual([])
+    } finally {
+      controller.abort()
+      await outcome
+    }
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', 'preview.expected.md',
       'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', 'preview.expected.md',

+ 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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md
-README.md: 86529ad22d4849a6b3530828c7939a1a73579c16
-README.zh.md: bad2e8a847b4d8ea1b80eab498ee9d1b377f7b2a
+README.md: c081d12d40377baf777d293582104b33e657e7fe
+README.zh.md: a253d2257a694c3466f376665b4dbe33e950020a

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

@@ -35,6 +35,8 @@ While the effective target is plan mode, the seat renders the warn-colored "Plan
 
 
 When a Turn ends, each submitted plan appears in its final artifact area, using the file-delivery card treatment with a Markdown icon, title, and Open action. A pending plan opens automatically once per submission in the current browser session. Closing it stays effective across review remounts; a new submission opens its own plan. Historical cards open only when clicked. Use the card or review strip’s View full plan link to read and copy the complete Markdown. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin.
 When a Turn ends, each submitted plan appears in its final artifact area, using the file-delivery card treatment with a Markdown icon, title, and Open action. A pending plan opens automatically once per submission in the current browser session. Closing it stays effective across review remounts; a new submission opens its own plan. Historical cards open only when clicked. Use the card or review strip’s View full plan link to read and copy the complete Markdown. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin.
 
 
+A review without a logged invocation also opens automatically. Its complete text lives only in the tab’s navigation memory, and the pending review card can reopen it. Reloading the page loses that text; an expired preview directs the user back to a pending review.
+
 ### Failures
 ### Failures
 
 
 Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit.
 Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit.
@@ -49,7 +51,7 @@ 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".
 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, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. 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.
+Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; 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 its request key, complete text, and optional 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>
 </details>
 
 

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

@@ -35,6 +35,8 @@ kind: "package-reference"
 
 
 回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读和复制完整 Markdown。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。
 回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读和复制完整 Markdown。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。
 
 
+没有已记录调用标识的审批也会自动打开。完整正文只保存在标签页的导航内存中,待处理审批卡片可以重新打开它。刷新页面会丢失该正文;失效的预览会提示用户返回待处理审批。
+
 ### 失败
 ### 失败
 
 
 准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,徽章保持显示直至投影确认退出。
 准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,徽章保持显示直至投影确认退出。
@@ -49,7 +51,7 @@ 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 声明的 `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 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识会话和调用;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供调用方的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。
+计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。
 
 
 </details>
 </details>
 
 

+ 2 - 1
packages/client/ui-plan/package.json

@@ -70,7 +70,8 @@
     "@deepseek-ai/dsh-client-resources": "workspace:^",
     "@deepseek-ai/dsh-client-resources": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^"
+    "@deepseek-ai/dsh-tools": "workspace:^",
+    "@deepseek-ai/dsh-util-crypto": "workspace:^"
   },
   },
   "files": [
   "files": [
     "lib/index.js",
     "lib/index.js",

+ 17 - 12
packages/client/ui-plan/src/client/PlanCard.tsx

@@ -1,4 +1,4 @@
-/** Persistent transcript card and pending-review action use the same plan resource opener. */
+/** Persistent transcript cards and pending-review sidebar navigation. */
 import { useEffect } from 'react'
 import { useEffect } from 'react'
 import { FileTypeIcon, IconChevronRightOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { FileTypeIcon, IconChevronRightOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client'
@@ -10,12 +10,18 @@ import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client'
 import type { createPlanReviewStore } from './review-store.ts'
 import type { createPlanReviewStore } from './review-store.ts'
 import css from './PlanPreview.module.css'
 import css from './PlanPreview.module.css'
 
 
-/** Session-bound navigation injected into both plan entry points. */
+/** Session-bound navigation for logged plans. */
 export interface PlanOpenInjected {
 export interface PlanOpenInjected {
   /** Open or focus the exact submitted plan. */
   /** Open or focus the exact submitted plan. */
   openPlan: (callId: ToolCallId) => void
   openPlan: (callId: ToolCallId) => void
 }
 }
 
 
+/** Session-bound preview navigation for one pending review. */
+export interface PlanReviewOpenInjected {
+  /** Open the logged plan, or the request's temporary document when no invocation exists. */
+  openReview: (review: PropsRuntime<'conversation.plan-review.actions'>['review'], requestKey: string) => void
+}
+
 /**
 /**
  * Render the completed Turn's submitted plans in invocation order.
  * Render the completed Turn's submitted plans in invocation order.
  * @param props - Logged plan, localized copy, and Session-bound navigation.
  * @param props - Logged plan, localized copy, and Session-bound navigation.
@@ -47,17 +53,16 @@ export function PlanCards({ turn, useChat, openPlan, t }: PropsRuntime<'conversa
 /**
 /**
  * Open each pending plan automatically and retain a manual opener without answering it.
  * Open each pending plan automatically and retain a manual opener without answering it.
  * @param props - Review identity, Session store, localized copy, and navigation.
  * @param props - Review identity, Session store, localized copy, and navigation.
- * @returns an opener when the asker supplied a logged invocation identity.
+ * @returns an opener for either logged or temporary plan text.
  */
  */
-export function PlanReviewOpen({ review, openPlan, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace<PlanOpenInjected> & PropsLocale<'plan'> & PropsStore<ReturnType<typeof createPlanReviewStore>>) {
-  const callId = review.callId
-  const opened = useStore(state => callId !== undefined && state.opened[callId] === true)
+export function PlanReviewOpen({ review, requestKey, openReview, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace<PlanReviewOpenInjected> & PropsLocale<'plan'> & PropsStore<ReturnType<typeof createPlanReviewStore>>) {
+  const identity = review.callId === undefined ? `review:${requestKey}` : `call:${review.callId}`
+  const opened = useStore(state => state.opened[identity] === true)
   useEffect(() => {
   useEffect(() => {
-    if (callId === undefined || opened) return
-    actions.markOpened(callId)
-    openPlan(callId)
-  }, [callId, opened, openPlan, actions])
-  if (callId === undefined) return null
+    if (opened) return
+    openReview(review, requestKey)
+    actions.markOpened(identity)
+  }, [identity, opened, openReview, review, requestKey, actions])
   return <button type="button" className={css.reviewLink} title={t('preview.open')} aria-label={t('preview.open')}
   return <button type="button" className={css.reviewLink} title={t('preview.open')} aria-label={t('preview.open')}
-    onClick={() => { openPlan(callId) }}>{t('preview.full')}<IconChevronRightOutline14 size={14} /></button>
+    onClick={() => { openReview(review, requestKey) }}>{t('preview.full')}<IconChevronRightOutline14 size={14} /></button>
 }
 }

+ 14 - 6
packages/client/ui-plan/src/client/PlanPreview.tsx

@@ -1,9 +1,11 @@
-/** Read-only Markdown viewer for an exact plan resource. */
+/** Read-only Markdown viewer for logged plans and temporary review documents. */
 import { useMemo } from 'react'
 import { useMemo } from 'react'
 import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
 import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 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 '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import type {} from './plan-resource.ts'
 import type {} from './plan-resource.ts'
+import { isReviewPreviewAddress } from './review-preview.ts'
+import { planFailureLine } from './failure-line.ts'
 import css from './PlanPreview.module.css'
 import css from './PlanPreview.module.css'
 
 
 type PlanPreviewProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'plan'>
 type PlanPreviewProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'plan'>
@@ -16,6 +18,9 @@ type PlanPreviewProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'pl
 export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) {
 export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) {
   const tab = useTabInfo()
   const tab = useTabInfo()
   const resource = useResource<'plan'>(tab.tab.navigation.address)
   const resource = useResource<'plan'>(tab.tab.navigation.address)
+  const temporary = isReviewPreviewAddress(tab.tab.navigation.address)
+  const params = tab.tab.navigation.params
+  const plan = temporary ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value
   const labels = useMemo(() => ({
   const labels = useMemo(() => ({
     code: {
     code: {
       copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'),
       copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'),
@@ -23,15 +28,15 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) {
     },
     },
     footnotes: t('markdown.footnotes'),
     footnotes: t('markdown.footnotes'),
   }), [t])
   }), [t])
-  const plan = resource.value
   if (plan === undefined) return (
   if (plan === undefined) return (
     <div className={css.message} role="status">
     <div className={css.message} role="status">
-      {resource.status === 'failed' ? t('preview.failed') : t('preview.loading')}
-      {resource.failure !== undefined && <p>{resource.failure.message}</p>}
+      {temporary ? t('preview.expired') : resource.status === 'none' ? t('preview.unavailable')
+        : resource.status === 'failed' ? t('preview.failed') : t('preview.loading')}
+      {!temporary && resource.failure !== undefined && <p>{planFailureLine(t, resource.failure)}</p>}
     </div>
     </div>
   )
   )
   return (
   return (
-    <section className={css.preview} data-plan-preview={plan.callId} aria-label={plan.title}>
+    <section className={css.preview} data-plan-preview={'callId' in plan ? plan.callId : tab.tab.navigation.address} aria-label={plan.title}>
       <div className={css.toolbar}><button type="button" className={css.iconButton} aria-label={t('copy')}
       <div className={css.toolbar}><button type="button" className={css.iconButton} aria-label={t('copy')}
         onClick={() => { void writeClipboard(plan.markdown) }}><IconCopyOutline16 /></button></div>
         onClick={() => { void writeClipboard(plan.markdown) }}><IconCopyOutline16 /></button></div>
       <div className={css.document}><MarkdownText text={plan.markdown} labels={labels} /></div>
       <div className={css.document}><MarkdownText text={plan.markdown} labels={labels} /></div>
@@ -47,5 +52,8 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) {
 export function PlanTitle({ useTabInfo, useResource }: PropsRuntime<'sidebar.right.pane.tab.title'>) {
 export function PlanTitle({ useTabInfo, useResource }: PropsRuntime<'sidebar.right.pane.tab.title'>) {
   const tab = useTabInfo()
   const tab = useTabInfo()
   const resource = useResource<'plan'>(tab.tab.navigation.address)
   const resource = useResource<'plan'>(tab.tab.navigation.address)
-  return <span>{resource.value?.title ?? tab.tab.title}</span>
+  const params = tab.tab.navigation.params
+  const plan = isReviewPreviewAddress(tab.tab.navigation.address)
+    ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value
+  return <span>{plan?.title ?? tab.tab.title}</span>
 }
 }

+ 19 - 0
packages/client/ui-plan/src/client/failure-line.ts

@@ -0,0 +1,19 @@
+/** Localized plan-owned failures and unmodified external diagnostics. */
+import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
+
+/**
+ * Explain a failed plan read in the current locale.
+ * @param t - Plan namespace translator.
+ * @param failure - Failure reported by the resource provider.
+ * @returns localized plan copy, or the external failure's diagnostic.
+ */
+export function planFailureLine(t: TranslateNS<'plan'>, failure: RemoteFailure): string {
+  switch (failure.code) {
+    case 'plan/invalid-address': return t('preview.invalidAddress')
+    case 'plan/unavailable': return t('preview.historyUnavailable')
+    case 'plan/not-found': return t('preview.notFound')
+    // Transport failures, including plan/read-failed, retain the carrier's diagnostic.
+    default: return failure.message
+  }
+}

+ 22 - 6
packages/client/ui-plan/src/client/index.ts

@@ -15,11 +15,14 @@ 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-user-questions/client'
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import type {} from '@deepseek-ai/dsh-client-resources/client'
 import type {} from '@deepseek-ai/dsh-client-resources/client'
-import { PlanCards, PlanReviewOpen, type PlanOpenInjected } from './PlanCard.tsx'
+import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
+import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
+import { PlanCards, PlanReviewOpen, type PlanOpenInjected, type PlanReviewOpenInjected } from './PlanCard.tsx'
 import { PlanPreview, PlanTitle } from './PlanPreview.tsx'
 import { PlanPreview, PlanTitle } from './PlanPreview.tsx'
 import { planDefinition } from './plan-definition.ts'
 import { planDefinition } from './plan-definition.ts'
 import { planResourceProvider } from './plan-resource.ts'
 import { planResourceProvider } from './plan-resource.ts'
 import { planAddress, parsePlanAddress } from './plan.ts'
 import { planAddress, parsePlanAddress } from './plan.ts'
+import { isReviewPreviewAddress, reviewPreviewAddress } from './review-preview.ts'
 import { createPlanReviewStore } from './review-store.ts'
 import { createPlanReviewStore } from './review-store.ts'
 import { PlanChip } from './PlanModeControl.tsx'
 import { PlanChip } from './PlanModeControl.tsx'
 import { en, zh, type PlanKey } from './locales.ts'
 import { en, zh, type PlanKey } from './locales.ts'
@@ -46,7 +49,7 @@ export interface PlanChipInjected {
 }
 }
 
 
 /** Services for plan controls, Conversation projection, and resource navigation. */
 /** Services for plan controls, Conversation projection, and resource navigation. */
-export const inject = ['slots', 'remote', 'remote.commands', 'remote.session', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs']
+export const inject = ['slots', 'remote', 'remote.commands', 'remote.session', 'sessions', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs']
 
 
 /**
 /**
  * Register plan controls, permanent Chat cards, and sidebar document reading.
  * Register plan controls, permanent Chat cards, and sidebar document reading.
@@ -60,19 +63,32 @@ export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.uiConversation.events.register(planDefinition), 'ui-plan: conversation definition')
   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.resources.register(planResourceProvider(ctx.remote.session)), 'ui-plan: resources')
   ctx.effect(() => ctx.sidebarRightTabs.register({
   ctx.effect(() => ctx.sidebarRightTabs.register({
-    id: previewId, kind: 'plan', patterns: ['dsh-resource://plan/**'], priority: 'builtin',
-    canOpen: address => parsePlanAddress(address) !== undefined,
+    id: previewId, kind: 'plan', patterns: ['dsh-resource://plan/**', 'dsh-resource://plan-review/**'], priority: 'builtin',
+    canOpen: address => parsePlanAddress(address) !== undefined || isReviewPreviewAddress(address),
     title: () => t('preview.title'),
     title: () => t('preview.title'),
   }), 'ui-plan: sidebar type')
   }), 'ui-plan: sidebar type')
   const open = (sessionId: SessionId): PlanOpenInjected => ({
   const open = (sessionId: SessionId): PlanOpenInjected => ({
-    openPlan: (callId) => { ctx.sidebarRight.openResourceIn(sessionId, planAddress({ sessionId, callId })) },
+    openPlan: (callId) => {
+      const child = ctx.sessions.subagentAddress(sessionId)
+      const session = child === undefined ? { kind: 'session' as const, sessionId } : { kind: 'subagent' as const, ...child }
+      ctx.sidebarRight.openResourceIn(sessionId, planAddress({ session, callId }))
+    },
   })
   })
+  const reviewWindow = randomUUID()
   const reviewStore = createPlanReviewStore()
   const reviewStore = createPlanReviewStore()
   ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
   ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
     name: 'conversation.chat.turnTail', id: previewId, locale: NS, inject: open,
     name: 'conversation.chat.turnTail', id: previewId, locale: NS, inject: open,
   }, PlanCards))
   }, PlanCards))
   ctx.slots.inject('conversation.plan-review.actions', () => ctx.slots.register({
   ctx.slots.inject('conversation.plan-review.actions', () => ctx.slots.register({
-    name: 'conversation.plan-review.actions', id: previewId, locale: NS, inject: open, store: reviewStore,
+    name: 'conversation.plan-review.actions', id: previewId, locale: NS, store: reviewStore,
+    inject: (sessionId: SessionId): PlanReviewOpenInjected => ({
+      openReview: (review, requestKey) => {
+        if (review.callId !== undefined) { open(sessionId).openPlan(review.callId); return }
+        ctx.sidebarRight.openResourceIn(sessionId, reviewPreviewAddress(sessionId, `${reviewWindow}:${requestKey}`), {
+          params: { planReview: { markdown: review.plan, title: extractMarkdownPlainText(review.plan, { mode: 'first-line' }) } },
+        })
+      },
+    }),
   }, PlanReviewOpen))
   }, PlanReviewOpen))
   ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
   ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
     name: 'sidebar.right.pane.tab', key: previewId, locale: NS,
     name: 'sidebar.right.pane.tab', key: previewId, locale: NS,

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

@@ -11,6 +11,11 @@ export const zh = {
   'preview.openNamed': '打开计划:{title}',
   'preview.openNamed': '打开计划:{title}',
   'preview.loading': '正在读取计划…',
   'preview.loading': '正在读取计划…',
   'preview.failed': '无法读取计划',
   'preview.failed': '无法读取计划',
+  'preview.invalidAddress': '计划地址无效',
+  'preview.historyUnavailable': '无法读取会话历史',
+  'preview.notFound': '未找到这份计划',
+  'preview.unavailable': '计划预览不可用',
+  'preview.expired': '临时计划预览已失效,请从仍在等待审批的卡片重新打开。',
   'chip.on.aria': 'plan mode 已开启,按下关闭',
   'chip.on.aria': 'plan mode 已开启,按下关闭',
   'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off)',
   'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off)',
   'chip.off.aria': 'plan mode 已关闭,按下开启',
   'chip.off.aria': 'plan mode 已关闭,按下开启',
@@ -32,6 +37,11 @@ export const en = {
   'preview.openNamed': 'Open plan: {title}',
   'preview.openNamed': 'Open plan: {title}',
   'preview.loading': 'Loading plan…',
   'preview.loading': 'Loading plan…',
   'preview.failed': 'Could not load plan',
   'preview.failed': 'Could not load plan',
+  'preview.invalidAddress': 'Invalid plan address',
+  'preview.historyUnavailable': 'Session history is unavailable',
+  'preview.notFound': 'This plan was not found',
+  'preview.unavailable': 'Plan preview is unavailable',
+  'preview.expired': 'This temporary plan preview has expired. Reopen it from the pending review card.',
   'chip.on.aria': 'Plan mode on, press to turn off',
   'chip.on.aria': 'Plan mode on, press to turn off',
   'chip.on.title': 'Plan mode on — click to turn off (/plan off)',
   'chip.on.title': 'Plan mode on — click to turn off (/plan off)',
   'chip.off.aria': 'Plan mode off, press to turn on',
   'chip.off.aria': 'Plan mode off, press to turn on',

+ 1 - 1
packages/client/ui-plan/src/client/plan-resource.ts

@@ -45,7 +45,7 @@ export function planResourceProvider(remote: Pick<Context['remote']['session'],
         yield { ok: false, error: new RemoteError('plan/invalid-address', 'Invalid plan resource address.', {}) }
         yield { ok: false, error: new RemoteError('plan/invalid-address', 'Invalid plan resource address.', {}) }
         return
         return
       }
       }
-      const sessionAddress = { kind: 'session' as const, sessionId: target.sessionId }
+      const sessionAddress = target.session
       try {
       try {
         // The snapshot supplies the page API's fixed log cut. Breaking closes the follow stream.
         // The snapshot supplies the page API's fixed log cut. Breaking closes the follow stream.
         let snapshot: Extract<SessionFollowFrame, { type: 'snapshot' }> | undefined
         let snapshot: Extract<SessionFollowFrame, { type: 'snapshot' }> | undefined

+ 25 - 7
packages/client/ui-plan/src/client/plan.ts

@@ -1,17 +1,22 @@
 /** Plan text and resource identities derived from logged native or PTC calls. */
 /** Plan text and resource identities derived from logged native or PTC calls. */
 import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
 import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SessionAddress } from '@deepseek-ai/dsh-api-session-controller/types'
 
 
-/** One submitted plan, identified by its originating tool invocation. */
-export interface SubmittedPlan {
-  readonly callId: ToolCallId
+/** Complete Markdown and the heading displayed by a plan preview. */
+export interface PlanDocument {
   readonly markdown: string
   readonly markdown: string
   readonly title: string
   readonly title: string
 }
 }
 
 
+/** One submitted plan, identified by its originating tool invocation. */
+export interface SubmittedPlan extends PlanDocument {
+  readonly callId: ToolCallId
+}
+
 /** A saved sidebar resource names one invocation in one Session. */
 /** A saved sidebar resource names one invocation in one Session. */
 export interface PlanAddress {
 export interface PlanAddress {
-  readonly sessionId: SessionId
+  readonly session: SessionAddress
   readonly callId: ToolCallId
   readonly callId: ToolCallId
 }
 }
 
 
@@ -48,7 +53,11 @@ export function submittedPlan(event: { readonly type: string; readonly data: unk
  * @returns the plan resource address.
  * @returns the plan resource address.
  */
  */
 export function planAddress(target: PlanAddress): string {
 export function planAddress(target: PlanAddress): string {
-  return `dsh-resource://plan/${encodeURIComponent(target.sessionId)}/${encodeURIComponent(target.callId)}`
+  const { session, callId } = target
+  const parts = session.kind === 'session'
+    ? [session.sessionId, callId]
+    : ['subagent', session.parentSessionId, session.childSessionId, session.mode, callId]
+  return `dsh-resource://plan/${parts.map(encodeURIComponent).join('/')}`
 }
 }
 
 
 /**
 /**
@@ -57,10 +66,19 @@ export function planAddress(target: PlanAddress): string {
  * @returns the decoded identity, or undefined for an unsupported address.
  * @returns the decoded identity, or undefined for an unsupported address.
  */
  */
 export function parsePlanAddress(address: string): PlanAddress | undefined {
 export function parsePlanAddress(address: string): PlanAddress | undefined {
-  const match = /^dsh-resource:\/\/plan\/([^/?#]+)\/([^/?#]+)$/.exec(address)
+  const match = /^dsh-resource:\/\/plan\/([^?#]+)$/.exec(address)
   if (match === null) return undefined
   if (match === null) return undefined
   try {
   try {
-    return { sessionId: decodeURIComponent(match[1] as string) as SessionId, callId: decodeURIComponent(match[2] as string) as ToolCallId }
+    const parts = (match[1] as string).split('/').map(decodeURIComponent)
+    if (parts.some(part => part === '')) return undefined
+    if (parts.length === 2) return {
+      session: { kind: 'session', sessionId: parts[0] as SessionId }, callId: parts[1] as ToolCallId,
+    }
+    if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable')) return {
+      session: { kind: 'subagent', parentSessionId: parts[1] as SessionId, childSessionId: parts[2] as SessionId, mode: parts[3] },
+      callId: parts[4] as ToolCallId,
+    }
+    return undefined
   } catch (_error) {
   } catch (_error) {
     // Invalid saved percent encoding cannot identify a Session or invocation.
     // Invalid saved percent encoding cannot identify a Session or invocation.
     return undefined
     return undefined

+ 29 - 0
packages/client/ui-plan/src/client/review-preview.ts

@@ -0,0 +1,29 @@
+/** Temporary review previews carry their document in in-memory tab navigation. */
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { PlanDocument } from './plan.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {
+  interface SidebarRightResourceParamsMap {
+    /** Review text without a logged invocation; never persisted in sidebar layout. */
+    'plan-review': { planReview: PlanDocument }
+  }
+}
+
+/**
+ * Name one temporary review within its browser lifetime and Session.
+ * @param sessionId - Session displaying the review.
+ * @param requestKey - Browser-unique pending request identity.
+ * @returns the address used to focus or reopen its preview.
+ */
+export function reviewPreviewAddress(sessionId: SessionId, requestKey: string): string {
+  return `dsh-resource://plan-review/${encodeURIComponent(sessionId)}/${encodeURIComponent(requestKey)}`
+}
+
+/**
+ * Recognize temporary plan navigation without interpreting it as logged history.
+ * @param address - Saved or caller-supplied navigation address.
+ * @returns whether the address identifies a temporary review preview.
+ */
+export function isReviewPreviewAddress(address: string): boolean {
+  return /^dsh-resource:\/\/plan-review\/[^/?#]+\/[^/?#]+$/.test(address)
+}

+ 3 - 4
packages/client/ui-plan/src/client/review-store.ts

@@ -1,13 +1,12 @@
 /** Session-owned memory of pending plans already opened automatically. */
 /** Session-owned memory of pending plans already opened automatically. */
 import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
 import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
-import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
 
 
 interface PlanReviewState {
 interface PlanReviewState {
-  opened: Partial<Record<ToolCallId, true>>
+  opened: Partial<Record<string, true>>
 }
 }
 
 
 type PlanReviewActions = {
 type PlanReviewActions = {
-  markOpened: (draft: PlanReviewState, callId: ToolCallId) => void
+  markOpened: (draft: PlanReviewState, reviewKey: string) => void
 }
 }
 
 
 /**
 /**
@@ -18,7 +17,7 @@ export function createPlanReviewStore(): EngineStoreHandle<PlanReviewState, Plan
   return defineStore({
   return defineStore({
     init: (): PlanReviewState => ({ opened: {} }),
     init: (): PlanReviewState => ({ opened: {} }),
     actions: {
     actions: {
-      markOpened: (draft, callId) => { draft.opened[callId] = true },
+      markOpened: (draft, reviewKey) => { draft.opened[reviewKey] = true },
     },
     },
   })
   })
 }
 }

+ 33 - 11
packages/client/ui-plan/tests/browser-plugin.client.spec.ts

@@ -13,7 +13,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import { PlanChip } from '../src/client/PlanModeControl.tsx'
 import { PlanChip } from '../src/client/PlanModeControl.tsx'
-import { PlanCards, PlanReviewOpen, type PlanOpenInjected } from '../src/client/PlanCard.tsx'
+import { PlanCards, PlanReviewOpen, type PlanOpenInjected, type PlanReviewOpenInjected } from '../src/client/PlanCard.tsx'
 import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx'
 import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx'
 import { submittedPlan } from '../src/client/plan.ts'
 import { submittedPlan } from '../src/client/plan.ts'
 import type { PlanChipInjected } from '../src/client/index.ts'
 import type { PlanChipInjected } from '../src/client/index.ts'
@@ -26,12 +26,14 @@ function providePreview(ctx: Context) {
   const removeResources = vi.fn()
   const removeResources = vi.fn()
   const removeType = vi.fn()
   const removeType = vi.fn()
   const registerType = vi.fn((_definition: Parameters<Context['sidebarRightTabs']['register']>[0]) => removeType)
   const registerType = vi.fn((_definition: Parameters<Context['sidebarRightTabs']['register']>[0]) => removeType)
-  const openResourceIn = vi.fn()
+  const openResourceIn = vi.fn<Context['sidebarRight']['openResourceIn']>()
+  const subagentAddress = vi.fn<Context['sessions']['subagentAddress']>(() => undefined)
+  ctx.provide('sessions', { subagentAddress })
   ctx.provide('resources', { register: vi.fn(() => removeResources) })
   ctx.provide('resources', { register: vi.fn(() => removeResources) })
   ctx.provide('sidebarRightTabs', { register: registerType })
   ctx.provide('sidebarRightTabs', { register: registerType })
   ctx.provide('sidebarRight', { openResourceIn })
   ctx.provide('sidebarRight', { openResourceIn })
   ctx.provide('remote.session', {})
   ctx.provide('remote.session', {})
-  return { events, removeResources, removeType, registerType, openResourceIn }
+  return { events, removeResources, removeType, registerType, openResourceIn, subagentAddress }
 }
 }
 
 
 const SID = 's-plan' as SessionId
 const SID = 's-plan' as SessionId
@@ -62,7 +64,7 @@ async function bench() {
 
 
 describe('ui-plan browser apply', () => {
 describe('ui-plan browser apply', () => {
   it('declares every service it binds', () => {
   it('declares every service it binds', () => {
-    expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'remote.session', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs'])
+    expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'remote.session', 'sessions', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs'])
   })
   })
 
 
   it('node-half apply is an intentional no-op', () => {
   it('node-half apply is an intentional no-op', () => {
@@ -130,13 +132,33 @@ describe('ui-plan browser apply', () => {
       expect(type.canOpen!('file:///plan.md')).toBe(false)
       expect(type.canOpen!('file:///plan.md')).toBe(false)
       expect(type.title(address)).toBeTruthy()
       expect(type.title(address)).toBeTruthy()
       const plan = submittedPlan({ type: 'tool/call', data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' } })!
       const plan = submittedPlan({ type: 'tool/call', data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' } })!
-      for (const [slot, component] of [['conversation.chat.turnTail', PlanCards], ['conversation.plan-review.actions', PlanReviewOpen]] as const) {
-        const entry = b.slots.entries(slot)[0]!
-        expect(entry.component).toBe(component)
-        const injected = (entry.inject as unknown as (sessionId: SessionId) => PlanOpenInjected)(SID)
-        injected.openPlan(plan.callId)
-        expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, address)
-      }
+      const card = b.slots.entries('conversation.chat.turnTail')[0]!
+      expect(card.component).toBe(PlanCards)
+      const injected = (card.inject as unknown as (sessionId: SessionId) => PlanOpenInjected)(SID)
+      injected.openPlan(plan.callId)
+      expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, address)
+      const review = b.slots.entries('conversation.plan-review.actions')[0]!
+      expect(review.component).toBe(PlanReviewOpen)
+      const reviewInjected = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(SID)
+      const pending = { id: 'review', question: 'Approve?', plan: plan.markdown, callId: plan.callId, approve: { label: 'Approve' } }
+      reviewInjected.openReview(pending, 'question:1')
+      expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, address)
+      b.subagentAddress.mockReturnValue({ parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' })
+      injected.openPlan(plan.callId)
+      expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, 'dsh-resource://plan/subagent/parent/s-plan/continuable/call')
+      reviewInjected.openReview(pending, 'question:1')
+      expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, 'dsh-resource://plan/subagent/parent/s-plan/continuable/call')
+      const temporary = { id: 'review', question: 'Approve?', plan: '# Temporary\n\nComplete body', approve: { label: 'Approve' } }
+      reviewInjected.openReview(temporary, 'question:2')
+      const first = b.openResourceIn.mock.calls.at(-1)!
+      expect(type.canOpen!(first[1])).toBe(true)
+      expect(first).toEqual([SID, expect.stringMatching(/^dsh-resource:\/\/plan-review\/s-plan\//), {
+        params: { planReview: { title: 'Temporary', markdown: temporary.plan } },
+      }])
+      reviewInjected.openReview(temporary, 'question:2')
+      expect(b.openResourceIn).toHaveBeenLastCalledWith(...first)
+      reviewInjected.openReview(temporary, 'question:3')
+      expect(b.openResourceIn.mock.calls.at(-1)![1]).not.toBe(first[1])
       expect(b.slots.entries('sidebar.right.pane.tab')[0]!.component).toBe(PlanPreview)
       expect(b.slots.entries('sidebar.right.pane.tab')[0]!.component).toBe(PlanPreview)
       expect(b.slots.entries('sidebar.right.pane.tab.title')[0]!.component).toBe(PlanTitle)
       expect(b.slots.entries('sidebar.right.pane.tab.title')[0]!.component).toBe(PlanTitle)
       await fiber.dispose()
       await fiber.dispose()

+ 64 - 35
packages/client/ui-plan/tests/plan-preview.client.spec.tsx

@@ -12,6 +12,7 @@ import { en, zh } from '../src/client/locales.ts'
 import { PlanCards, PlanReviewOpen } from '../src/client/PlanCard.tsx'
 import { PlanCards, PlanReviewOpen } from '../src/client/PlanCard.tsx'
 import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx'
 import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx'
 import { planAddress, parsePlanAddress, submittedPlan } from '../src/client/plan.ts'
 import { planAddress, parsePlanAddress, submittedPlan } from '../src/client/plan.ts'
+import { reviewPreviewAddress, isReviewPreviewAddress } from '../src/client/review-preview.ts'
 import { planDefinition } from '../src/client/plan-definition.ts'
 import { planDefinition } from '../src/client/plan-definition.ts'
 import { createPlanReviewStore } from '../src/client/review-store.ts'
 import { createPlanReviewStore } from '../src/client/review-store.ts'
 
 
@@ -19,7 +20,7 @@ afterEach(cleanup)
 const markdown = '# Keep this plan\n\n## Goal\n\n- Review\n- Implement'
 const markdown = '# Keep this plan\n\n## Goal\n\n- Review\n- Implement'
 const call = { type: 'tool/call', seq: 12, data: { turn: 1, name: 'exit_plan_mode', callId: 'call:1', arguments: JSON.stringify({ plan: markdown }) } }
 const call = { type: 'tool/call', seq: 12, data: { turn: 1, name: 'exit_plan_mode', callId: 'call:1', arguments: JSON.stringify({ plan: markdown }) } }
 const plan = submittedPlan(call)!
 const plan = submittedPlan(call)!
-const target = { sessionId: 'session / 中文' as SessionId, callId: plan.callId }
+const target = { session: { kind: 'session' as const, sessionId: 'session / 中文' as SessionId }, callId: plan.callId }
 const t = makeTranslate(en, commonEn)
 const t = makeTranslate(en, commonEn)
 
 
 function planNode(data: typeof plan) {
 function planNode(data: typeof plan) {
@@ -52,7 +53,12 @@ describe('submitted plan identity', () => {
   })
   })
   it('round-trips opaque identifiers and refuses malformed addresses', () => {
   it('round-trips opaque identifiers and refuses malformed addresses', () => {
     expect(parsePlanAddress(planAddress(target))).toEqual(target)
     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']) {
+    for (const mode of ['one-shot', 'continuable'] as const) {
+      const child = { session: { kind: 'subagent' as const, parentSessionId: target.session.sessionId, childSessionId: 'child / 中文' as SessionId, mode }, callId: plan.callId }
+      expect(parsePlanAddress(planAddress(child))).toEqual(child)
+    }
+    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',
+      'dsh-resource://plan//c', 'dsh-resource://plan/subagent/p/c/invalid/call', 'dsh-resource://plan/subagent/p/c/one-shot']) {
       expect(parsePlanAddress(address)).toBeUndefined()
       expect(parsePlanAddress(address)).toBeUndefined()
     }
     }
   })
   })
@@ -154,44 +160,56 @@ describe('plan entry points and document', () => {
     view.rerender(<PlanCards {...{ ...props, useChat: planHook([]) }} />)
     view.rerender(<PlanCards {...{ ...props, useChat: planHook([]) }} />)
     expect(view.container.innerHTML).toBe('')
     expect(view.container.innerHTML).toBe('')
   })
   })
-  it('opens the review without approving or cancelling it', () => {
-    const openPlan = vi.fn()
-    const store = createPlanReviewStore().create()
-    const stateProps = {
-      actions: store.actions,
-      useStore: (select: (state: ReturnType<typeof store.getSnapshot>) => unknown) => select(store.getSnapshot()),
-    }
-    const props = { review: { callId: plan.callId }, t, openPlan, ...stateProps } as unknown as Parameters<typeof PlanReviewOpen>[0]
-    const view = render(<PlanReviewOpen {...props} />)
-    expect(openPlan).toHaveBeenCalledExactlyOnceWith(plan.callId)
-    view.rerender(<PlanReviewOpen {...props} />)
-    expect(openPlan).toHaveBeenCalledTimes(1)
-    fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' }))
-    expect(openPlan).toHaveBeenCalledTimes(2)
-    view.rerender(<PlanReviewOpen {...{ ...props, review: {} } as unknown as Parameters<typeof PlanReviewOpen>[0]} />)
-    expect(screen.queryByRole('button')).toBeNull()
-  })
-  it('preserves manual closure across remounts and opens a new submission', () => {
+  it.each([true, false])('opens once, preserves manual closure, and reopens a review (logged: %s)', (logged) => {
+    const openReview = vi.fn()
     const store = createPlanReviewStore().create()
     const store = createPlanReviewStore().create()
-    const openPlan = vi.fn()
-    const props = { review: { callId: plan.callId }, t, openPlan, actions: store.actions,
+    const review = { plan: markdown, ...(logged ? { callId: plan.callId } : {}) }
+    const props = { review, requestKey: 'question:1', t, openReview, actions: store.actions,
       useStore: (select: (state: ReturnType<typeof store.getSnapshot>) => unknown) => select(store.getSnapshot()),
       useStore: (select: (state: ReturnType<typeof store.getSnapshot>) => unknown) => select(store.getSnapshot()),
     } as unknown as Parameters<typeof PlanReviewOpen>[0]
     } as unknown as Parameters<typeof PlanReviewOpen>[0]
     const first = render(<PlanReviewOpen {...props} />)
     const first = render(<PlanReviewOpen {...props} />)
+    expect(openReview).toHaveBeenCalledExactlyOnceWith(review, 'question:1')
+    first.rerender(<PlanReviewOpen {...props} />)
     first.unmount()
     first.unmount()
     const second = render(<PlanReviewOpen {...props} />)
     const second = render(<PlanReviewOpen {...props} />)
-    expect(openPlan).toHaveBeenCalledTimes(1)
-    const revised = submittedPlan({ ...call, data: { ...call.data, callId: 'call:2' } })!
-    const nextProps = { ...props, review: { callId: revised.callId } } as unknown as Parameters<typeof PlanReviewOpen>[0]
-    second.rerender(<PlanReviewOpen {...nextProps} />)
-    expect(openPlan).toHaveBeenLastCalledWith(revised.callId)
-    expect(openPlan).toHaveBeenCalledTimes(2)
+    expect(openReview).toHaveBeenCalledTimes(1)
+    fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' }))
+    expect(openReview).toHaveBeenCalledTimes(2)
+    const revised = { plan: '# Revised', ...(logged ? { callId: 'call:2' } : {}) }
+    second.rerender(<PlanReviewOpen {...{ ...props, review: revised, requestKey: 'question:2' } as unknown as Parameters<typeof PlanReviewOpen>[0]} />)
+    expect(openReview).toHaveBeenLastCalledWith(revised, 'question:2')
+    expect(openReview).toHaveBeenCalledTimes(3)
     second.unmount()
     second.unmount()
     const other = createPlanReviewStore().create()
     const other = createPlanReviewStore().create()
     render(<PlanReviewOpen {...{ ...props, actions: other.actions,
     render(<PlanReviewOpen {...{ ...props, actions: other.actions,
       useStore: (select: (state: ReturnType<typeof other.getSnapshot>) => unknown) => select(other.getSnapshot()),
       useStore: (select: (state: ReturnType<typeof other.getSnapshot>) => unknown) => select(other.getSnapshot()),
     } as unknown as Parameters<typeof PlanReviewOpen>[0]} />)
     } as unknown as Parameters<typeof PlanReviewOpen>[0]} />)
-    expect(openPlan).toHaveBeenCalledTimes(3)
+    expect(openReview).toHaveBeenCalledTimes(4)
+  })
+  it('renders temporary Markdown and reports expired navigation after reload', () => {
+    const address = reviewPreviewAddress(target.session.sessionId, 'window:question:1')
+    expect(isReviewPreviewAddress(address)).toBe(true)
+    expect(isReviewPreviewAddress(planAddress(target))).toBe(false)
+    const props = { t, useResource: () => ({ status: 'none' }),
+      useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address, params: { planReview: { title: plan.title, markdown } } } } }),
+    }
+    const view = render(<PlanPreview {...props as unknown as Parameters<typeof PlanPreview>[0]} />)
+    expect(screen.getByRole('heading', { name: plan.title })).toBeTruthy()
+    expect(screen.getByText('Implement')).toBeTruthy()
+    view.rerender(<PlanTitle {...props as unknown as Parameters<typeof PlanTitle>[0]} />)
+    expect(screen.getByText(plan.title)).toBeTruthy()
+    view.rerender(<PlanPreview {...{ ...props,
+      useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address } } }),
+    } as unknown as Parameters<typeof PlanPreview>[0]} />)
+    expect(screen.getByRole('status').textContent).toBe(en['preview.expired'])
+    view.rerender(<PlanTitle {...{ ...props,
+      useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address } } }),
+    } as unknown as Parameters<typeof PlanTitle>[0]} />)
+    expect(screen.getByText('Plan')).toBeTruthy()
+    view.rerender(<PlanPreview {...{ ...props,
+      useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address, params: { line: 2 } } } }),
+    } as unknown as Parameters<typeof PlanPreview>[0]} />)
+    expect(screen.getByRole('status').textContent).toBe(en['preview.expired'])
   })
   })
   it('shows restored Markdown and its heading as the tab title', () => {
   it('shows restored Markdown and its heading as the tab title', () => {
     const props = {
     const props = {
@@ -222,12 +240,23 @@ describe('plan entry points and document', () => {
       else Object.defineProperty(navigator, 'clipboard', clipboard)
       else Object.defineProperty(navigator, 'clipboard', clipboard)
     }
     }
   })
   })
-  it('shows loading and failed reads without an empty sidebar', () => {
-    const props = { t, useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }) }
+  it.each([en, zh])('localizes plan failures and unavailable providers', (dictionary) => {
+    const props = { t: makeTranslate(dictionary, commonEn),
+      useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }),
+    }
     const view = render(<PlanPreview {...{ ...props, useResource: () => ({ status: 'loading' }) } as unknown as Parameters<typeof PlanPreview>[0]} />)
     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()
+    expect(screen.getByRole('status').textContent).toBe(dictionary['preview.loading'])
+    view.rerender(<PlanPreview {...{ ...props, useResource: () => ({ status: 'none' }) } as unknown as Parameters<typeof PlanPreview>[0]} />)
+    expect(screen.getByRole('status').textContent).toBe(dictionary['preview.unavailable'])
+    for (const [code, message] of [
+      ['plan/invalid-address', dictionary['preview.invalidAddress']],
+      ['plan/unavailable', dictionary['preview.historyUnavailable']],
+      ['plan/not-found', dictionary['preview.notFound']],
+      ['plan/read-failed', 'connection lost'],
+      ['session/not-found', 'connection lost'],
+    ] as const) {
+      view.rerender(<PlanPreview {...{ ...props, useResource: () => ({ status: 'failed', failure: { code, message: 'connection lost' } }) } as unknown as Parameters<typeof PlanPreview>[0]} />)
+      expect(screen.getByRole('status').textContent).toBe(dictionary['preview.failed'] + message)
+    }
   })
   })
 })
 })

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

@@ -36,6 +36,15 @@ describe('plan history resource', () => {
     expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal))
     expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal))
     expect(b.closed).toHaveBeenCalledOnce()
     expect(b.closed).toHaveBeenCalledOnce()
   })
   })
+  it.each(['one-shot', 'continuable'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => {
+    const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true)
+    const address = `dsh-resource://plan/subagent/parent/child/${mode}/call`
+    const session = { kind: 'subagent', parentSessionId: 'parent', childSessionId: 'child', mode }
+    expect(await read(b.provider, address)).toMatchObject([{ ok: true, value: { title: 'Saved plan' } }])
+    expect(b.follow).toHaveBeenCalledWith({ address: session }, expect.any(AbortSignal))
+    expect(b.page).toHaveBeenCalledWith({ address: session, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal))
+    expect(b.closed).toHaveBeenCalledOnce()
+  })
   it('reports missing plans and invalid saved addresses', async () => {
   it('reports missing plans and invalid saved addresses', async () => {
     const b = setup([])
     const b = setup([])
     expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/not-found' } }])
     expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/not-found' } }])

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

@@ -61,6 +61,9 @@
     },
     },
     {
     {
       "path": "../../core/tools"
       "path": "../../core/tools"
+    },
+    {
+      "path": "../../util/crypto"
     }
     }
   ]
   ]
 }
 }

+ 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;
 # 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:
 # 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
 #   pnpm run verify-translation-pairing --write packages/client/ui-user-questions/README.md
-README.md: a734be6a2c9464d5dc6c8696f5d7287eaf6d9d0d
-README.zh.md: 38c62a969cc4440a690969a12400511489c0495a
+README.md: abbf855706b478857540b12712c5999082b0ab22
+README.zh.md: 995035cbb08ff6c64e2b91b61ab49b2bd473b9b7

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

@@ -57,7 +57,7 @@ The card accepts one question declaring the intent, carrying the plan as `detail
 
 
 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.
 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.
+A plan review exposes `conversation.plan-review.actions` with its request key, full text, and optional invocation identity. The plan plugin opens logged plans from history and unlogged reviews as temporary sidebar previews. Opening a document does not answer or dismiss the review.
 
 
 </details>
 </details>
 
 

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

@@ -57,7 +57,7 @@ kind: "package-reference"
 
 
 编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该条目,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
 编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该条目,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
 
 
-计划审批提供 `conversation.plan-review.actions` 插槽;当审批意图包含已记录调用的标识时,计划插件在其中提供侧边栏入口。打开文档不会回答或关闭审批。
+计划审批通过 `conversation.plan-review.actions` 插槽提供请求键、完整正文和可选的调用标识。计划插件从历史中打开已记录计划,并把没有调用标识的审批作为临时侧边栏预览打开。打开文档不会回答或关闭审批。
 
 
 </details>
 </details>
 
 

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

@@ -53,7 +53,7 @@ export function PlanReviewPanel({ pending, review, t, renderSlot }: PlanReviewPa
           <span className={css.dot} />
           <span className={css.dot} />
           {t('plan.header')}
           {t('plan.header')}
           <div className={css.previewActions}>
           <div className={css.previewActions}>
-            {renderSlot('conversation.plan-review.actions', { review })}
+            {renderSlot('conversation.plan-review.actions', { review, requestKey: pending.key })}
           </div>
           </div>
         </div>
         </div>
         <div className={css.summary}>
         <div className={css.summary}>

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

@@ -18,7 +18,7 @@ declare module '@deepseek-ai/dsh-client-ui-session/client' {
 declare module '@deepseek-ai/dsh-client-ui-slots' {
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
   interface SlotMap {
     /** Actions for the exact plan under review; approval remains with the question composer. */
     /** Actions for the exact plan under review; approval remains with the question composer. */
-    'conversation.plan-review.actions': { kind: 'list'; scope: 'session'; owner: { review: PlanReview } }
+    'conversation.plan-review.actions': { kind: 'list'; scope: 'session'; owner: { review: PlanReview; requestKey: PendingQuestion['key'] } }
   }
   }
 }
 }
 
 

+ 17 - 0
packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx

@@ -216,6 +216,23 @@ describe('planReviewOf', () => {
 })
 })
 
 
 describe('PlanReviewPanel', () => {
 describe('PlanReviewPanel', () => {
+  it('passes distinct pending request identities to the preview action for unlogged reviews', () => {
+    const rendered = vi.fn<(key: string, owner: unknown) => void>()
+    const renderSlot: QuestionComposerProps['renderSlot'] = (key, owner) => { rendered(key, owner); return null }
+    const first = wait()
+    const second = wait()
+    const view = render(<QuestionComposer matched={first.carrier} {...kit} renderSlot={renderSlot} />)
+    expect(rendered).toHaveBeenLastCalledWith('conversation.plan-review.actions', {
+      review: planReviewOf(first.carrier.questions), requestKey: first.carrier.key,
+    })
+    view.rerender(<QuestionComposer matched={second.carrier} {...kit} renderSlot={renderSlot} />)
+    expect(rendered).toHaveBeenLastCalledWith('conversation.plan-review.actions', {
+      review: planReviewOf(second.carrier.questions), requestKey: second.carrier.key,
+    })
+    expect(first.carrier.key).not.toBe(second.carrier.key)
+    expect(first.answer).not.toHaveBeenCalled()
+    expect(second.answer).not.toHaveBeenCalled()
+  })
   it('shows the plan title and summary above two review actions', () => {
   it('shows the plan title and summary above two review actions', () => {
     const { carrier } = wait()
     const { carrier } = wait()
     render(<QuestionComposer matched={carrier} {...kit} />)
     render(<QuestionComposer matched={carrier} {...kit} />)

+ 3 - 0
pnpm-lock.yaml

@@ -3701,6 +3701,9 @@ importers:
       '@deepseek-ai/dsh-typert-protocol':
       '@deepseek-ai/dsh-typert-protocol':
         specifier: workspace:^
         specifier: workspace:^
         version: link:../../typert/protocol
         version: link:../../typert/protocol
+      '@deepseek-ai/dsh-util-crypto':
+        specifier: workspace:^
+        version: link:../../util/crypto
       '@types/react':
       '@types/react':
         specifier: ~18.3.1
         specifier: ~18.3.1
         version: 18.3.31
         version: 18.3.31