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

fix(web): collect submitted plans in the final turn artifacts

Yichen Jiang 4 дней назад
Родитель
Сommit
577e4a036d
30 измененных файлов с 207 добавлено и 95 удалено
  1. 2 2
      .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml
  2. 3 3
      .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md
  3. 3 3
      .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md
  4. 4 4
      apps/web/tests/plan-review.e2e.ts
  5. 2 2
      packages/client/ui-chat/README.i18n.yaml
  6. 0 2
      packages/client/ui-chat/README.md
  7. 0 2
      packages/client/ui-chat/README.zh.md
  8. 0 1
      packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
  9. 3 3
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
  10. 1 1
      packages/client/ui-chat/src/client/chat/register-node-renderers.ts
  11. 0 2
      packages/client/ui-chat/src/client/contract/chat-nodes.ts
  12. 4 4
      packages/client/ui-chat/src/client/contract/slots.ts
  13. 1 1
      packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts
  14. 0 3
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  15. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  16. 2 2
      packages/client/ui-deliverables/README.md
  17. 2 2
      packages/client/ui-deliverables/README.zh.md
  18. 11 1
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  19. 5 5
      packages/client/ui-deliverables/src/client/index.ts
  20. 12 2
      packages/client/ui-deliverables/tests/deliverables.client.spec.tsx
  21. 2 2
      packages/client/ui-plan/README.i18n.yaml
  22. 3 3
      packages/client/ui-plan/README.md
  23. 3 3
      packages/client/ui-plan/README.zh.md
  24. 24 14
      packages/client/ui-plan/src/client/PlanCard.tsx
  25. 2 1
      packages/client/ui-plan/src/client/PlanPreview.module.css
  26. 4 4
      packages/client/ui-plan/src/client/index.ts
  27. 2 2
      packages/client/ui-plan/src/client/plan-definition.ts
  28. 7 4
      packages/client/ui-plan/tests/browser-plugin.client.spec.ts
  29. 81 5
      packages/client/ui-plan/tests/plan-preview.client.spec.tsx
  30. 22 10
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

+ 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;
 # 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: 5b790123bf10800b94ed28595f4127031df8bd04
-2026-09-17-persistent-plan-cards.zh.md: 155280d94f250ee2725c92f1f601331259e68728
+2026-09-17-persistent-plan-cards.md: dabf5c970f8cf77fb2f4ff5cedcc11e2a094751f
+2026-09-17-persistent-plan-cards.zh.md: c84a20ca464ac69206f008b99e5039cd0791a2ca

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

@@ -10,7 +10,7 @@ A plan review occupies the composer only until the user answers or dismisses it.
 
 ## 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.
+`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.
 
@@ -22,8 +22,8 @@ The document is read-only. The pending review shows its status, sidebar opener,
 
 **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.
+**Select a single owner for the Turn tail.** Plans and file deliveries can occur in the same Turn. An additive list lets both plugins contribute without either knowing the other’s data or rendering.
 
 ## 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.
+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 multiple submissions, PTC deduplication, cropped history, and resource failures.

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

@@ -10,7 +10,7 @@ Status: implemented
 
 ## Decision
 
-`ui-plan` 从每次 `exit_plan_mode` 调用派生一张常驻 Chat 卡片,覆盖原生调用和 PTC dispatch。卡片声明 `process: 'independent'`,让 Chat 将其保留在回合运行过程折叠区之外。关闭、拒绝或批准审批都不会删除卡片
+`ui-plan` 汇总每个回合的 `exit_plan_mode` 提交,覆盖原生调用和 PTC dispatch。每次调用在已完成回合末尾的产物区域贡献一张卡片。`conversation.chat.turnTail` 列表同时容纳文件产物和计划卡片。关闭、拒绝或批准审批都不会删除已记录的计划
 
 审批意图携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。两者都打开由会话和调用标识确定的资源。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。
 
@@ -22,8 +22,8 @@ Status: implemented
 
 **写入 Markdown 文件或在侧边栏布局中保存正文。** 工具参数已经拥有准确的提交文本。另一份持久副本需要同步,且可能与审阅的计划不一致。[计划状态决策](../simplification/2026-07-22-plan-specific-collaboration-state.zh.md)仍是这项所有权的权威记录。
 
-**让 Chat 识别计划工具名称。** 节点自行声明折叠行为,让功能标识需要独立阅读的产物,无需向 Chat 添加计划专用渲染或导入
+**只选择一个回合末尾区域的属主。** 计划和文件产物可能出现在同一回合。可追加列表让两个插件都能贡献内容,无需彼此了解数据或渲染
 
 ## Consequences
 
-计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖原生与 PTC 解码及资源读取失败。
+计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。

+ 4 - 4
apps/web/tests/plan-review.e2e.ts

@@ -100,7 +100,7 @@ describe('web e2e: plan review takeover round trip', () => {
     }
 
     const planCard = page.locator('[data-plan-card]')
-    await planCard.waitFor({ state: 'visible' })
+    expect(await planCard.count()).toBe(0)
     const preview = page.locator('[data-plan-preview]')
     await preview.waitFor({ state: 'visible' })
     expect(await card.getByRole('button', { name: 'Approve' }).isVisible()).toBe(true)
@@ -112,7 +112,7 @@ describe('web e2e: plan review takeover round trip', () => {
     }
     await page.locator('[data-sidebar-right-toggle]').click()
     await preview.waitFor({ state: 'hidden' })
-    await planCard.click()
+    await card.getByRole('button', { name: 'Open plan in sidebar' }).click()
     await preview.waitFor({ state: 'visible' })
 
     await card.getByRole('button', { name: 'Approve' }).click()
@@ -134,9 +134,9 @@ 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.
+    // Completed plans join the final artifacts after the Turn's closing reply.
     await planCard.waitFor({ state: 'visible' })
-    expect(await planCard.locator('xpath=ancestor::*[@data-turn-process-member]').count()).toBe(0)
+    expect(await planCard.locator('xpath=ancestor::*[@data-turn-tail]').count()).toBe(1)
     await page.locator('[data-sidebar-right-toggle]').click()
     await planCard.click()
     await preview.waitFor({ state: 'visible' })

+ 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: 0a3f5cad6e0bcd2da6f8ff993dea2e32a3e7e4a6
-README.zh.md: 028467df8ec75154f585fd7d928c51436e8b82c6
+README.md: 9217ff678690f6eb659bb6b7c3a808d97a26171b
+README.zh.md: 1ac5159a466c61ddada5b55e62354d6bfb1e6652

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

@@ -91,8 +91,6 @@ 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.

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

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

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

@@ -68,7 +68,6 @@ 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

+ 3 - 3
packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx

@@ -11,7 +11,7 @@ type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
 
 /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
 export const TurnTailNodeView = memo(function TurnTailNodeView({
-  node, openFile, forkAt, renderSlot, renderSlotChain, t, useChat,
+  node, openFile, forkAt, renderSlot, t, useChat,
 }: TurnTailNodeViewProps) {
   const data = node.data
   const hasLaterChatNode = useChat(snapshot =>
@@ -23,8 +23,8 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
   if (turn === undefined) return null
   const closing = data.closing
   const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile }
-  const tail = renderSlotChain('conversation.chat.turnTail', owner)
-  if (closing === null) return tail === null ? null : <div className={css.root}>{tail}</div>
+  const tail = renderSlot('conversation.chat.turnTail', owner)
+  if (closing === null) return tail === null ? null : <div className={css.root} data-turn-tail={data.turn}>{tail}</div>
   const runMs = turn.start === undefined || turn.end === undefined
     ? undefined
     : Math.max(0, turn.end.time - turn.start.time)

+ 1 - 1
packages/client/ui-chat/src/client/chat/register-node-renderers.ts

@@ -48,7 +48,7 @@ export function registerChatNodeRenderers(ctx: Context): void {
     key: 'turn-tail',
     locale: NS,
     children: {
-      'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
+      'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
       'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
     },
   }, TurnTailNodeView))

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

@@ -10,8 +10,6 @@ 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. */

+ 4 - 4
packages/client/ui-chat/src/client/contract/slots.ts

@@ -204,11 +204,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      */
     'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
     /**
-     * Selector-routed extension before a completed Turn's action row. The
-     * component receives the Turn, closing sequence, and file opener. The first
-     * selector that accepts the owner renders; an all-declined chain is empty.
+     * Ordered feature contributions before a completed Turn's action row. Each
+     * entry receives the Turn, closing sequence, and file opener. A fresh `id`
+     * adds an entry; entries without content return null.
      */
-    'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
+    'conversation.chat.turnTail': { kind: 'list'; scope: 'session'; owner: TurnTailOwnerProps }
     /**
      * Ordered actions for one finalized assistant message. Each entry receives
      * the durable message id; a fresh `id` adds an action and reusing one replaces

+ 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 (node.process === 'independent' || TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)
+    if (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) {

+ 0 - 3
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -282,8 +282,6 @@ function makeHarness(
   }> = []
   const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
     opts?.fallback ?? null) as unknown as React.ComponentProps<typeof CommandNodeView>['renderSlot']
-  const renderTurnTail = ((_key: string, _owner: object) => null) as unknown as
-    React.ComponentProps<typeof TurnTailNodeView>['renderSlotChain']
   const renderTurnTailSlot = (() => null) as unknown as
     React.ComponentProps<typeof TurnTailNodeView>['renderSlot']
   let nodeSlotOverride: React.ComponentProps<typeof ChatNodeSeat>['renderSlot'] | undefined
@@ -335,7 +333,6 @@ function makeHarness(
           <TurnTailNodeView
             {...nodeProps<'turn-tail'>()}
             renderSlot={renderTurnTailSlot}
-            renderSlotChain={renderTurnTail}
             SessionProvider={props.SessionProvider}
           />
         )

+ 2 - 2
packages/client/ui-deliverables/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
-README.md: b0137675a207a7aaee3d38060319d5948c8ba432
-README.zh.md: 8dfc6767919ae186962d49da393ef8316f62c417
+README.md: 125c84c91553b93e97f9158c9429014051804ed9
+README.zh.md: ef5b7e97f90f1101e765c776a0c1a90dfe7c1319

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

@@ -54,7 +54,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty chain at zero cost.
+The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` list alongside other feature artifacts. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty list at zero cost.
 
 Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the review tab's native open of a changed file uses the same coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422, after which the review tab hides its native open; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
 
@@ -68,7 +68,7 @@ Native opening uses an authenticated POST addressed by the viewed Session, event
 Read these pages when the deliverables surface is not enough. They move from the card to the Host recorder, the turn-tail hole, and the decisions behind the vocabulary.
 
 - [workspace-changes](../../deliverables/workspace-changes/README.md) — the Host plugin that records and serves the summary the card renders.
-- [ui-conversation](../ui-conversation/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose.
+- [ui-chat](../ui-chat/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose.
 - [Turn changed-files card](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — the decision behind git-recorded summaries replacing the mutation-call row.
 - [Workspace file links](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md) — the decision behind the earlier produced-files row; its Host open path is superseded by the [right Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md).
 - [Inline file mentions](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md) — the decision behind clickable mentions in the closing prose.

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

@@ -54,7 +54,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 <details>
 <summary>实现细节——点击展开</summary>
 
-Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空以零成本留下。
+Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 列表,与其他功能产物并列。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空列表以零成本留下。
 
 原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;review tab 对改动文件的原生打开使用同一组坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,之后 review tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
 
@@ -68,7 +68,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 当产出物面不够用时阅读以下页面。它们从卡片进入 Host 记录器、turn-tail 洞与词表背后的决策。
 
 - [workspace-changes](../../deliverables/workspace-changes/README.zh.md)——记录并提供卡片所渲染摘要的 Host 插件。
-- [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。
+- [ui-chat](../ui-chat/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。
 - [本轮改动文件卡片](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——用 git 记录的摘要取代修改调用行背后的决策。
 - [工作区文件链接](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)——早先产出文件行背后的决策;其 Host 打开路径已被[右侧 Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)取代。
 - [行内文件提及](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md)——收尾正文可点击提及背后的决策。

+ 11 - 1
packages/client/ui-deliverables/src/client/Deliverables.tsx

@@ -2,7 +2,7 @@
 import { useEffect, useState } from 'react'
 import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { Button, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { GlobalStandardProps, InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
+import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { PresentedOpenController } from './present-open.ts'
 import type { ChangesSummaryStore } from './changes-summary.ts'
@@ -44,6 +44,16 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch
   return changes === null && presented.length === 0 ? null : { changes, presented }
 }
 
+/**
+ * Contribute file deliveries alongside other completed-Turn artifacts.
+ * @param props - closing Turn, file actions, and localized copy.
+ * @returns file rows, or null when the Turn declares none.
+ */
+export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail'> & PropsLocale<typeof NS> & InjectFace<DeliverablesInjected>) {
+  const matched = selectDeliverables(props)
+  return matched === null ? null : <Deliverables {...props} matched={matched} />
+}
+
 /**
  * Render the changed-files card, once the Host has served the announced
  * summary and it lists a file, and default-application buttons for declared

+ 5 - 5
packages/client/ui-deliverables/src/client/index.ts

@@ -1,12 +1,12 @@
 /**
  * Deliverables plugin, browser half: registers the changed-files card and
- * delivery cards into the chat view's turn-tail chain, the `changes-review`
+ * delivery cards into the chat view's turn-tail list, the `changes-review`
  * right-Sidebar tab type that reviews one turn's changed files one comparison
  * at a time, and provides the `chatFileMentions` service that links
  * inline-code mentions of produced or delivered files in the closing prose.
  * All policy lives here — the supported mutation calls, mention matching, row
  * cap, and copy — so composing this plugin out of cordis.yml removes every
- * surface; the owning view renders an empty chain and inert prose at zero cost.
+ * surface; the owning view renders an empty list and inert prose at zero cost.
  */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -21,7 +21,7 @@ import { ChangesDiffStore } from './changes-diff.ts'
 import { ChangesSummaryStore } from './changes-summary.ts'
 import { PresentedOpenController } from './present-open.ts'
 import { PresentRow } from './PresentRow.tsx'
-import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx'
+import { DeliverablesTail, type DeliverablesInjected } from './Deliverables.tsx'
 import { ReviewTab, type ReviewInjected } from './ReviewTab.tsx'
 import { CHANGES_REVIEW_ID, changesReviewDefinition } from './review-definition.ts'
 import { createReviewStore } from './review-store.ts'
@@ -60,7 +60,7 @@ export function apply(ctx: ClientContext): void {
     'conversation.chat.turnTail',
     () => ctx.slots.register({
       name: 'conversation.chat.turnTail',
-      select: selectDeliverables,
+      id: '@deepseek-ai/dsh-client-ui-deliverables',
       locale: NS,
       inject: (): DeliverablesInjected => ({
         hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state },
@@ -72,7 +72,7 @@ export function apply(ctx: ClientContext): void {
           ctx.sidebarRight.openResource(changesReviewAddress(coordinates), { params: { index } })
         },
       }),
-    }, Deliverables),
+    }, DeliverablesTail),
   )
   ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
     { name: 'tool.call.toolview', key: 'present', locale: NS }, PresentRow,

+ 12 - 2
packages/client/ui-deliverables/tests/deliverables.client.spec.tsx

@@ -22,7 +22,7 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
 import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
-import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
+import { Deliverables, DeliverablesTail, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
 import type { ReviewInjected } from '../src/client/ReviewTab.tsx'
 import { ChangesSummaryStore } from '../src/client/changes-summary.ts'
 import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts'
@@ -681,7 +681,7 @@ describe('plugin registration', () => {
     ctx.slots.register({
       name: 'root',
       children: {
-        'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
+        'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
         'tool.call.toolview': { kind: 'keyed', scope: 'session' },
         'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session' },
       },
@@ -942,3 +942,13 @@ it('loads desktop information once the tail renders and not again while it is kn
   view.rerender(<Deliverables {...shared} matched={{ changes: null, presented: [{ path: 'report.txt', seq: 2, index: 0 }] }} />)
   expect(props.reloadPresentedHost).toHaveBeenCalledOnce()
 })
+
+it('contributes file artifacts to the tail list only for turns with deliveries', () => {
+  const owner = tailOwner(undefined, 3)
+  const props = { ...openProps(), ...owner, sessionId: SessionId('session'), t: makeTranslate(en) } as unknown as Parameters<typeof DeliverablesTail>[0]
+  const view = render(<DeliverablesTail {...props} />)
+  expect(view.container.innerHTML).toBe('')
+  const withFile = tailOwner({ produced: [], presented: [{ path: 'report.md', seq: 2, index: 0 }] }, 3)
+  view.rerender(<DeliverablesTail {...props} {...withFile} />)
+  expect(view.getByText('report.md')).toBeTruthy()
+})

+ 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: 7f68df2d44e6e540a559e4769b7550fc52e5cfae
-README.zh.md: ac9977aa72787b73fb84f46a0e9f0f9515e72ad5
+README.md: e0a0949ce4b09b4a4b87455b424b85094625d661
+README.zh.md: 2d1fc1654618abe75975645301b86588a7e9532c

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

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Plan mode lets you review a plan before implementation. Enter with `/plan` and leave with the composer chip. Submitted plans open automatically in the right sidebar for review and remain available from their permanent Chat cards after approval, rejection, or dismissal. Reopening a plan focuses its existing tab, and browser reload restores the document from Session history.
+Plan mode lets you review a plan before implementation. Enter with `/plan` and leave with the composer chip. Submitted plans open automatically in the right sidebar for review and remain available from cards in the completed Turn’s final artifact area after approval, rejection, or dismissal. Reopening a plan focuses its existing tab, and browser reload restores the document from Session history.
 
 ## Table of Contents
 
@@ -33,7 +33,7 @@ While the effective target is plan mode, the seat renders the warn-colored "Plan
 
 ### Reading submitted plans
 
-Each submitted plan has a permanent Chat card outside the Turn process disclosure, 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 Open plan in sidebar button 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 Open plan in sidebar button to read and copy the complete Markdown. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin.
 
 ### Failures
 
@@ -49,7 +49,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".
 
-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.
+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.
 
 </details>
 

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

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-计划模式让你在实施前审阅计划。通过 `/plan` 进入,通过编辑器中的状态按钮退出。提交的计划自动在右侧边栏打开供审阅;批准、拒绝或关闭审批后,仍可通过聊天历史中的常驻卡片查看。重新打开同一计划会聚焦已有标签页,刷新浏览器后会从会话历史恢复正文。
+计划模式让你在实施前审阅计划。通过 `/plan` 进入,通过编辑器中的状态按钮退出。提交的计划自动在右侧边栏打开供审阅;批准、拒绝或关闭审批后,仍可通过已完成回合末尾的产物卡片查看。重新打开同一计划会聚焦已有标签页,刷新浏览器后会从会话历史恢复正文。
 
 ## 目录
 
@@ -33,7 +33,7 @@ kind: "package-reference"
 
 ### 查看已提交的计划
 
-每次提交的计划都有一张常驻聊天卡片,不随回合运行过程折叠,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“在侧边栏打开计划”按钮可阅读和复制完整 Markdown。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。
+回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“在侧边栏打开计划”按钮可阅读和复制完整 Markdown。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。
 
 ### 失败
 
@@ -49,7 +49,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 Definition 从原生 `tool/call` 或 PTC dispatch 参数派生。计划资源地址标识会话和调用;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>
 

+ 24 - 14
packages/client/ui-plan/src/client/PlanCard.tsx

@@ -1,7 +1,9 @@
 /** Persistent transcript card and pending-review action use the same plan resource opener. */
 import { useEffect } from 'react'
 import { FileTypeIcon, IconFullscreenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
+import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client'
+import { shallowEqual } from '@deepseek-ai/dsh-client-store'
+import type {} from './plan-definition.ts'
 import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } 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'
@@ -15,22 +17,30 @@ export interface PlanOpenInjected {
 }
 
 /**
- * Render the plan's permanent transcript entry.
+ * Render the completed Turn's submitted plans in invocation order.
  * @param props - Logged plan, localized copy, and Session-bound navigation.
- * @returns a keyboard-accessible plan card.
+ * @returns keyboard-accessible plan cards, or null for a Turn without plans.
  */
-export function PlanCard({ node, openPlan, t }: PropsRuntime<'conversation.chat.node', 'submitted-plan'> & InjectFace<PlanOpenInjected> & PropsLocale<'plan'>) {
+export function PlanCards({ turn, useChat, openPlan, t }: PropsRuntime<'conversation.chat.turnTail'> & InjectFace<PlanOpenInjected> & PropsLocale<'plan'>) {
+  const plans = useChat(snapshot => snapshot.nodes.values()
+    .filter((node): node is ChatNode<'submitted-plan'> => node.kind === 'submitted-plan'
+      && (node.location.kind === 'turn' || node.location.kind === 'step')
+      && node.location.turn.turn === turn.turn)
+    .sort((a, b) => a.anchorSeq - b.anchorSeq), shallowEqual)
+  if (plans.length === 0) return null
   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) }}>
-      <span className={css.cardIcon}><FileTypeIcon kind="markdown" size={20} /></span>
-      <span className={css.cardDetails}>
-        <span className={css.cardTitle}>{node.data.title}</span>
-        <span className={css.cardDescription}>{t('preview.document')}</span>
-      </span>
-      <span className={css.cardOpen}>{t('preview.action')}</span>
-    </button>
+    <div className={css.cards} data-plan-artifacts>
+      {plans.map(({ data: plan }) => <button key={plan.callId} type="button" className={css.card} data-plan-card={plan.callId}
+        aria-label={t('preview.openNamed', { title: plan.title })}
+        onClick={() => { openPlan(plan.callId) }}>
+        <span className={css.cardIcon}><FileTypeIcon kind="markdown" size={20} /></span>
+        <span className={css.cardDetails}>
+          <span className={css.cardTitle}>{plan.title}</span>
+          <span className={css.cardDescription}>{t('preview.document')}</span>
+        </span>
+        <span className={css.cardOpen}>{t('preview.action')}</span>
+      </button>)}
+    </div>
   )
 }
 

+ 2 - 1
packages/client/ui-plan/src/client/PlanPreview.module.css

@@ -1,3 +1,4 @@
+.cards { display: flex; flex-direction: column; gap: 10px; }
 .card {
   --plan-card-fill: var(--dsw-static-neutral-50);
   --plan-card-hover: var(--dsw-static-neutral-100);
@@ -9,7 +10,7 @@
   width: 100%;
   height: 60px;
   padding: 8px 10px;
-  margin: 12px 0;
+  margin: 0;
   border: 0.5px solid var(--dsw-alias-border-l1);
   border-radius: 18px;
   background: var(--plan-card-fill);

+ 4 - 4
packages/client/ui-plan/src/client/index.ts

@@ -15,7 +15,7 @@ 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 { PlanCards, PlanReviewOpen, type PlanOpenInjected } from './PlanCard.tsx'
 import { PlanPreview, PlanTitle } from './PlanPreview.tsx'
 import { planDefinition } from './plan-definition.ts'
 import { planResourceProvider } from './plan-resource.ts'
@@ -68,9 +68,9 @@ export function apply(ctx: ClientContext): void {
     openPlan: (callId) => { ctx.sidebarRight.openResourceIn(sessionId, planAddress({ sessionId, callId })) },
   })
   const reviewStore = createPlanReviewStore()
-  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.chat.turnTail', () => ctx.slots.register({
+    name: 'conversation.chat.turnTail', id: previewId, locale: NS, inject: open,
+  }, PlanCards))
   ctx.slots.inject('conversation.plan-review.actions', () => ctx.slots.register({
     name: 'conversation.plan-review.actions', id: previewId, locale: NS, inject: open, store: reviewStore,
   }, PlanReviewOpen))

+ 2 - 2
packages/client/ui-plan/src/client/plan-definition.ts

@@ -1,4 +1,4 @@
-/** Durable plan cards remain readable independently of a Turn's process disclosure. */
+/** Per-invocation plans use resolved Conversation locations for final Turn artifacts. */
 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'
@@ -28,7 +28,7 @@ export const planDefinition: ConversationNodeDefinition<SubmittedPlan> = {
     return {
       key: context.key, kind: 'submitted-plan', id: context.id, target: 'chat',
       anchorSeq: start.event.seq, location: start.location,
-      visibility: 'visible', process: 'independent', data,
+      visibility: 'hidden', data,
     } satisfies ChatNode<'submitted-plan'>
   },
 }

+ 7 - 4
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 { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import { PlanChip } from '../src/client/PlanModeControl.tsx'
-import { PlanCard, PlanReviewOpen, type PlanOpenInjected } from '../src/client/PlanCard.tsx'
+import { PlanCards, PlanReviewOpen, type PlanOpenInjected } from '../src/client/PlanCard.tsx'
 import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx'
 import { submittedPlan } from '../src/client/plan.ts'
 import type { PlanChipInjected } from '../src/client/index.ts'
@@ -45,7 +45,7 @@ async function bench() {
     name: 'root',
     children: {
       'conversation.input.plan': { kind: 'single', scope: 'session' },
-      'conversation.chat.node': { kind: 'keyed', scope: 'session' },
+      'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
       'conversation.plan-review.actions': { kind: 'list', scope: 'session' },
       'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session' },
       'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session' },
@@ -121,13 +121,16 @@ describe('ui-plan browser apply', () => {
     const fiber = b.ctx.plugin({ inject: [...inject], apply })
     try {
       await fiber.await()
+      const removeFileEntry = b.slots.register({ name: 'conversation.chat.turnTail', id: 'test-file-deliveries' }, () => null)
+      expect(b.slots.entries('conversation.chat.turnTail')).toHaveLength(2)
+      removeFileEntry()
       const address = 'dsh-resource://plan/s-plan/call'
       const type = b.registerType.mock.calls[0]![0]
       expect(type.canOpen!(address)).toBe(true)
       expect(type.canOpen!('file:///plan.md')).toBe(false)
       expect(type.title(address)).toBeTruthy()
       const plan = submittedPlan({ type: 'tool/call', data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' } })!
-      for (const [slot, component] of [['conversation.chat.node', PlanCard], ['conversation.plan-review.actions', PlanReviewOpen]] as const) {
+      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)
@@ -137,7 +140,7 @@ describe('ui-plan browser apply', () => {
       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)
       await fiber.dispose()
-      expect(b.slots.entries('conversation.chat.node')).toEqual([])
+      expect(b.slots.entries('conversation.chat.turnTail')).toEqual([])
       expect(b.slots.entries('conversation.plan-review.actions')).toEqual([])
       expect(b.slots.entries('sidebar.right.pane.tab')).toEqual([])
       expect(b.slots.entries('sidebar.right.pane.tab.title')).toEqual([])

+ 81 - 5
packages/client/ui-plan/tests/plan-preview.client.spec.tsx

@@ -1,11 +1,15 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
+import { chatViewDefinition } from '../../ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts'
 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 { PlanCards, 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'
@@ -13,11 +17,18 @@ import { createPlanReviewStore } from '../src/client/review-store.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 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 target = { sessionId: 'session / 中文' as SessionId, callId: plan.callId }
 const t = makeTranslate(en, commonEn)
 
+function planNode(data: typeof plan) {
+  return { kind: 'submitted-plan', anchorSeq: 12, location: { kind: 'turn', turn: { turn: 1 } }, data }
+}
+function planHook(nodes: readonly unknown[]): Parameters<typeof PlanCards>[0]['useChat'] {
+  return ((select: (snapshot: unknown) => unknown) => select({ nodes: { values: () => nodes } })) as Parameters<typeof PlanCards>[0]['useChat']
+}
+
 describe('submitted plan identity', () => {
   it('reads native, running PTC, and settled PTC calls', () => {
     expect(plan).toEqual({ callId: 'call:1', title: 'Keep this plan', markdown })
@@ -50,7 +61,7 @@ describe('submitted plan identity', () => {
     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 as never)).toMatchObject({ visibility: 'hidden', anchorSeq: 12, data: plan })
     expect(planDefinition.buildViewNode!({ ...context, state: undefined, start: undefined, matches: [] } as never)).toBeNull()
   })
   it('retains the submitted version and recovers a cropped PTC start from settlement', () => {
@@ -63,12 +74,55 @@ describe('submitted plan identity', () => {
   })
 })
 
+it('projects native and PTC submissions into their resolved turns without duplicate cards', () => {
+  const assembler = new ConversationNodeAssembler(
+    { entries: () => [planDefinition], fallbackEntry: () => undefined },
+    { entries: () => [chatViewDefinition] },
+  )
+  const events = [
+    { type: 'turn/start', data: { turn: 1 } },
+    { type: 'step/start', data: { turn: 1, step: 1 } },
+    call,
+    { type: 'tool/call', data: { turn: 1, step: 1, callId: 'root', name: 'run_code', arguments: '{}' } },
+    { type: 'tool/ptc-dispatch-start', data: {
+      rootCallId: 'root', parentCallId: 'root', subCallId: 'ptc', name: 'exit_plan_mode', arguments: { plan: markdown },
+    } },
+    { type: 'tool/ptc-dispatch', data: {
+      rootCallId: 'root', parentCallId: 'root', subCallId: 'ptc', name: 'exit_plan_mode', arguments: { plan: markdown },
+      isError: false, content: [],
+    } },
+    { type: 'turn/end', data: { turn: 1 } },
+    { type: 'turn/start', data: { turn: 2 } },
+    { ...call, data: { ...call.data, turn: 2, callId: 'next-turn' } },
+    { type: 'turn/end', data: { turn: 2 } },
+  ]
+  const entries = events.map((event, index) => ({
+    type: 'event', event: { ...event, seq: index + 1, time: index },
+  })) as SessionLiveEventEntry[]
+  assembler.replaceWindow(entries, false)
+  assembler.activateTarget('chat')
+  const snapshot = assembler.snapshot('chat') as ChatSnapshot
+  expect(snapshot.order).toEqual([])
+  expect(snapshot.nodes.values()).toHaveLength(3)
+  const props = { turn: { turn: 1 }, useChat: (select: (snapshot: ChatSnapshot) => unknown) => select(snapshot),
+    t, openPlan: vi.fn(),
+  } as unknown as Parameters<typeof PlanCards>[0]
+  const view = render(<PlanCards {...props} />)
+  expect(view.container.querySelectorAll('[data-plan-card]')).toHaveLength(2)
+  view.rerender(<PlanCards {...{ ...props, turn: { turn: 2 } } as unknown as Parameters<typeof PlanCards>[0]} />)
+  expect(view.container.querySelector('[data-plan-card]')?.getAttribute('data-plan-card')).toBe('next-turn')
+  assembler.replaceWindow(entries.filter((_, index) => index !== 4), true)
+  expect((assembler.snapshot('chat') as ChatSnapshot).nodes.values()).toHaveLength(3)
+})
+
 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} />)
+      const props = { turn: { turn: 1 }, useChat: planHook([planNode(plan)]), seq: 30,
+        t: makeTranslate(dictionary, commonEn), openPlan,
+      } as unknown as Parameters<typeof PlanCards>[0]
+      const view = render(<PlanCards {...props} />)
       expect(openPlan).not.toHaveBeenCalled()
       expect(screen.getByText(dictionary['preview.action'])).toBeTruthy()
       expect(screen.queryByText('Implement')).toBeNull()
@@ -78,6 +132,28 @@ describe('plan entry points and document', () => {
       view.unmount()
     }
   })
+  it('keeps only the current turn’s plans in invocation order', () => {
+    const revised = { ...plan, callId: 'call:2' as typeof plan.callId, title: 'Second plan' }
+    const nodes = [
+      { ...planNode(revised), anchorSeq: 20 }, planNode(plan),
+      { ...planNode(plan), location: { kind: 'turn', turn: { turn: 2 } } },
+      { ...planNode(plan), location: { kind: 'unresolved' } },
+      { ...planNode(plan), kind: 'tool-call' },
+      { ...planNode({ ...revised, callId: 'call:3' as typeof plan.callId }),
+        anchorSeq: 30, location: { kind: 'step', turn: { turn: 1 } },
+      },
+    ]
+    const openPlan = vi.fn()
+    const props = { turn: { turn: 1 }, useChat: planHook(nodes), t, openPlan } as unknown as Parameters<typeof PlanCards>[0]
+    const view = render(<PlanCards {...props} />)
+    expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual([
+      expect.stringContaining(plan.title), expect.stringContaining(revised.title), expect.stringContaining(revised.title),
+    ])
+    fireEvent.click(screen.getAllByRole('button')[1]!)
+    expect(openPlan).toHaveBeenCalledWith('call:2')
+    view.rerender(<PlanCards {...{ ...props, useChat: planHook([]) }} />)
+    expect(view.container.innerHTML).toBe('')
+  })
   it('opens the review without approving or cancelling it', () => {
     const openPlan = vi.fn()
     const store = createPlanReviewStore().create()

+ 22 - 10
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, submitted-plan, 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, 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,7 +288,6 @@ 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\'',
     ],
@@ -298,16 +297,28 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
   },
   {
     key: 'conversation.chat.turnTail',
-    kind: 'chain',
+    kind: 'list',
     scope: 'session',
-    summary: 'Selector-routed extension before a completed Turn\'s action row.',
-    doc: 'Selector-routed extension before a completed Turn\'s action row. The\ncomponent receives the Turn, closing sequence, and file opener. The first\nselector that accepts the owner renders; an all-declined chain is empty.',
+    summary: 'Ordered feature contributions before a completed Turn\'s action row.',
+    doc: 'Ordered feature contributions before a completed Turn\'s action row. Each\nentry receives the Turn, closing sequence, and file opener. A fresh `id`\nadds an entry; entries without content return null.',
     registerOptions: [
       {
-        name: 'select',
+        name: 'id',
         requirement: 'required',
-        type: '(owner) => unknown | null',
-        doc: 'Pure routing selector. Entries are tried in ascending order; the first non-null result wins and arrives as the component\'s `matched` prop. All-null falls through to the owner\'s fallback.',
+        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: [
@@ -338,10 +349,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     slotInject: '',
     declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted',
     occupants: [
-      'client-ui-deliverables Deliverables',
+      'client-ui-deliverables DeliverablesTail id \'@deepseek-ai/dsh-client-ui-deliverables\'',
+      'client-ui-plan PlanCards',
     ],
     replaceRisk: 'none',
-    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n      { name: \'conversation.chat.turnTail\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
+    example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n      { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     source: 'packages/client/ui-chat/src/client/contract/slots.ts:211',
   },
   {