From 64c669fc79bef05a256989f9ec71d449fa6382d2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:55:11 +0800 Subject: [PATCH] fix(plan): open embedded plan previews in the visible sidebar A child conversation rendered in a parent Sidebar keeps the child Session identity for reading its plan. Routing navigation through openResourceIn(child) confused that document identity with the layout owner: a child with no adopted Sidebar store silently opened nothing, and an existing child store was invisible. Use the mounted Sidebar navigation service for both logged and temporary plan previews. Keep the complete child Session resource address, so reads and reload still target the correct document. A real-controller regression adopts only the visible parent store and proves both child entry points populate it without creating child layout state; it fails before the fix. All 47 ui-plan owner tests and all four built Web plan-review cases pass, along with the full build, scoped typed lint, and bilingual pairing. No Session event, stored document, fixture generation, or resource-address format changed. --- ...2026-09-17-persistent-plan-cards.i18n.yaml | 4 +- .../2026-09-17-persistent-plan-cards.md | 2 +- .../2026-09-17-persistent-plan-cards.zh.md | 2 +- packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 2 +- packages/client/ui-plan/README.zh.md | 2 +- packages/client/ui-plan/src/client/index.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 67 +++++++++++++++---- 8 files changed, 65 insertions(+), 22 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml index 2a7ae98b1e..da8a49ddb6 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml +++ b/.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: 6959cf39f4e47dbd552ee14af7c1ebc09b52bef6 -2026-09-17-persistent-plan-cards.zh.md: 0c40cb5caf4b77f5d4b5c30cead5567f21b45cfd +2026-09-17-persistent-plan-cards.md: 096c89c7ed3e52293787a2724b0c7231ae3740ae +2026-09-17-persistent-plan-cards.zh.md: 95cce3fed84dd802195b87f593654b7d511933cb diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md index 6959cf39f4..096c89c7ed 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md @@ -12,7 +12,7 @@ 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. -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 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. The resource address selects the document’s Session, while the mounted Sidebar selects where its preview opens; embedded child conversations do not require a child Sidebar store. 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. 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. diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md index 0c40cb5caf..95cce3fed8 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md @@ -12,7 +12,7 @@ Status: implemented `ui-plan` 汇总每个回合的 `exit_plan_mode` 提交,覆盖原生调用和 PTC dispatch。每次调用在已完成回合末尾的产物区域贡献一张卡片。`conversation.chat.turnTail` 列表同时容纳文件产物和计划卡片。关闭、拒绝或批准审批都不会删除已记录的计划。 -审批意图可以携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。存在调用标识时,两者都打开由完整会话地址和调用标识确定的资源。子会话地址保留直接父级、子会话及运行模式,用于快照读取、分页和刷新恢复。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。 +审批意图可以携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。存在调用标识时,两者都打开由完整会话地址和调用标识确定的资源。子会话地址保留直接父级、子会话及运行模式,用于快照读取、分页和刷新恢复。资源地址确定文档所属的会话,当前挂载的侧边栏决定预览打开的位置;嵌入式子对话不需要子会话自己的侧边栏 store。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。 没有调用标识的审批打开临时地址,由浏览器生命周期、会话和待处理请求共同标识。侧边栏已有的导航参数保存 Markdown,无需额外的文档 store,也不把正文持久化到布局中。关闭后从待处理卡片重新打开会再次提供正文;恢复的标签页若缺少导航参数,则显示预览已失效。 diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 0023f8b06c..bc9b25797c 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/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: f7e7aede2c76717228d101153e4d3d2f8d3f88c4 -README.zh.md: 9ab37a25dd7cb406b42bf5c531445357d6725e2e +README.md: 5aba7939210ca611d43b99c044d21cb8674c9112 +README.zh.md: 1c7f8ea5e9e7fea1767d03caca05e558902359dc diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index f7e7aede2c..5aba793921 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -35,7 +35,7 @@ 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. Plan tabs show a document-and-pen icon. 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. +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. Plans opened from an embedded child conversation use the visible sidebar while retaining the child’s address for document reads. ### Failures diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 9ab37a25dd..1c7f8ea5e9 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" 回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读和复制完整 Markdown。计划标签页显示文档加笔的图标。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 -没有已记录调用标识的审批也会自动打开。完整正文只保存在标签页的导航内存中,待处理审批卡片可以重新打开它。刷新页面会丢失该正文;失效的预览会提示用户返回待处理审批。 +没有已记录调用标识的审批也会自动打开。完整正文只保存在标签页的导航内存中,待处理审批卡片可以重新打开它。刷新页面会丢失该正文;失效的预览会提示用户返回待处理审批。在嵌入式子对话中打开计划时,预览使用当前可见的侧栏,读取文档仍保留子会话地址。 ### 失败 diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 4a69be9469..bd0a270474 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -71,7 +71,7 @@ export function apply(ctx: ClientContext): void { 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 })) + ctx.sidebarRight.openResource(planAddress({ session, callId })) }, }) const reviewWindow = randomUUID() @@ -84,7 +84,7 @@ export function apply(ctx: ClientContext): void { 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}`), { + ctx.sidebarRight.openResource(reviewPreviewAddress(sessionId, `${reviewWindow}:${requestKey}`), { params: { planReview: { markdown: review.plan, title: extractMarkdownPlainText(review.plan, { mode: 'first-line' }) } }, }) }, diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index bbc55871ed..71bc23a044 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -19,21 +19,25 @@ import { submittedPlan } from '../src/client/plan.ts' import type { PlanChipInjected } from '../src/client/index.ts' import { apply, inject } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' +import { createSidebarRightController } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/service.ts' +import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts' +import { createSidebarRightStore } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/stores.ts' function providePreview(ctx: Context) { const events = new ConversationEventRegistry(ctx) ctx.provide('uiConversation', { events }) const removeResources = vi.fn() const removeType = vi.fn() - const registerType = vi.fn((_definition: Parameters[0]) => removeType) + const registerType = vi.fn(() => removeType) const openResourceIn = vi.fn() + const openResource = vi.fn() const subagentAddress = vi.fn(() => undefined) ctx.provide('sessions', { subagentAddress }) ctx.provide('resources', { register: vi.fn(() => removeResources) }) ctx.provide('sidebarRightTabs', { register: registerType }) - ctx.provide('sidebarRight', { openResourceIn }) + ctx.provide('sidebarRight', { openResourceIn, openResource }) ctx.provide('remote.session', {}) - return { events, removeResources, removeType, registerType, openResourceIn, subagentAddress } + return { events, removeResources, removeType, registerType, openResourceIn, openResource, subagentAddress } } const SID = 's-plan' as SessionId @@ -63,6 +67,45 @@ async function bench() { } describe('ui-plan browser apply', () => { + it('opens an embedded child plan in the visible parent sidebar without adopting a child store', async () => { + const b = await bench() + const parent = 'visible-parent' as SessionId + const child = 'embedded-child' as SessionId + const tabs = new SidebarRightTabRegistry(b.ctx) + b.registerType.mockImplementation(definition => tabs.register(definition)) + const store = createSidebarRightStore(() => ({ kind: 'guide', title: 'Guide' })).create() + const { controller, adopt } = createSidebarRightController(tabs, vi.fn()) + const release = adopt(parent, store) + store.actions.open(parent) + const unbind = controller.bind({ + sessionId: parent, actions: store.actions, surfaces: store.getSnapshot().bySession, canSplitPane: () => true, + }) + b.openResource.mockImplementation(controller.openResource.bind(controller)) + b.openResourceIn.mockImplementation(controller.openResourceIn.bind(controller)) + b.subagentAddress.mockReturnValue({ parentSessionId: parent, childSessionId: child, mode: 'continuable' }) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + try { + await fiber.await() + const card = b.slots.entries('conversation.chat.turnTail')[0]! + const opener = (card.inject as unknown as (sessionId: SessionId) => PlanOpenInjected)(child) + const plan = submittedPlan({ type: 'tool/call', data: { callId: 'child-plan', name: 'exit_plan_mode', arguments: '{"plan":"# Child plan"}' } })! + opener.openPlan(plan.callId) + const resources = () => Object.values(store.getSnapshot().bySession[parent]!.layout.tabs).map(tab => tab.contentId) + expect(resources()).toContain('dsh-resource://plan/subagent/visible-parent/embedded-child/continuable/child-plan') + expect(store.getSnapshot().bySession[child]).toBeUndefined() + const review = b.slots.entries('conversation.plan-review.actions')[0]! + const reviewOpener = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(child) + reviewOpener.openReview({ id: 'pending', question: 'Approve?', plan: '# Temporary child plan', approve: { label: 'Approve' } }, 'child-question') + expect(resources().some(address => address.startsWith('dsh-resource://plan-review/embedded-child/'))).toBe(true) + expect(store.getSnapshot().bySession[child]).toBeUndefined() + } finally { + await fiber.dispose() + unbind() + release() + controller.tabDomain.dispose() + } + }) + it('declares every service it binds', () => { expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'remote.session', 'sessions', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs']) }) @@ -136,29 +179,29 @@ describe('ui-plan browser apply', () => { 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) + expect(b.openResource).toHaveBeenLastCalledWith(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) + expect(b.openResource).toHaveBeenLastCalledWith(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') + expect(b.openResource).toHaveBeenLastCalledWith('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') + expect(b.openResource).toHaveBeenLastCalledWith('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\//), { + const first = b.openResource.mock.calls.at(-1)! + expect(type.canOpen!(first[0])).toBe(true) + expect(first).toEqual([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) + expect(b.openResource).toHaveBeenLastCalledWith(...first) reviewInjected.openReview(temporary, 'question:3') - expect(b.openResourceIn.mock.calls.at(-1)![1]).not.toBe(first[1]) + expect(b.openResource.mock.calls.at(-1)![0]).not.toBe(first[0]) 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()