diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml index f7b369d628..e80e658859 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml @@ -21,8 +21,8 @@ en: 774c3bf80a48ffbe zh: c513245789aa8efd /agent-note-right-sidebar-tab-types-and-navigation/decision/navigation-ctx-sidebarright: - en: b32bad4c3b8c2cc8 - zh: 57ba7b8d3451e478 + en: c52363f57001b7ac + zh: bbcbdbc038cbf316 /agent-note-right-sidebar-tab-types-and-navigation/decision/addresses: en: eeb1e7e7478a2f2c zh: bddbc5bc78245f63 @@ -30,8 +30,8 @@ en: 6386bde2654f9a0a zh: 700439be6a01e162 /agent-note-right-sidebar-tab-types-and-navigation/alternatives-considered: - en: 91fbca006e8de5bc - zh: f48d3ce1dd7b63bf + en: c47eb4e2f3c307f9 + zh: fd4515b8be88506e /agent-note-right-sidebar-tab-types-and-navigation/consequences: en: 3cdb1159ee55d851 zh: dcd068005e678a89 @@ -39,5 +39,5 @@ en: 49449a6c8465c098 zh: 1f476cc1226aa9cd /agent-note-right-sidebar-tab-types-and-navigation/deferred: - en: fa27b7684e38329b - zh: d270e0cdcab73732 + en: 5b2416692028bf89 + zh: 65930102aa253bf2 diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md index 599ce36ed4..4e7fcb8cb1 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md @@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar `openResource` accepts the union of every declared resource shape and `openTab` the shape declared for `K`; a body narrows `navigation.params` by the protocol or kind it knows it serves. Parameters belong to the resource type rather than to the viewer because a line number is a fact about a file location, not about the text preview, and any type that claims `file` addresses receives the same shape. Values must be JSON-serializable, and a record must be rebuildable from address and parameters alone, because undo, redo, reload, and HMR rebuild tabs after the opener is gone. -Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The seat publishes its binding — its session, its store's actions, and its surface — while mounted; a command on the public face acts on the mounted session and throws with no mounted session surface. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted. +Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The plugin names the Session on screen — the selected Session while the Conversation fills the main column — from the Session selection and the main panel, before React renders either change, and publishes it as `mounted`; a command on the public face acts on that Session through its adopted store and throws when no Session is on screen or its store has not been minted. The seat reports only what it renders with: the room its docking kit measured and whether its frame width presents the panel fullscreen automatically. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted. ### Addresses @@ -100,6 +100,8 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced **Slot priorities to express an extension taking over a builtin, then registry-minted slot keys.** The first attempt had the overriding type register its body at a lower slot priority through an exported constant — a value import across dynamic plugins, and a second rule system (slot priority) standing in for the registry's. The second attempt had the registry mint a key per registration and return it from `register()`, which made registration a two-step dance whose ordering mattered. Letting the implementation declare its own `id` — required, unique, the same string it registers its seats under — needs no constant, no minting, and no ordering, and gives the registry the identity it needs to reject duplicates. +**A binding the seat publishes from its own effects.** The first form had the mounted seat publish its session, actions, and surface after each commit and release them when it left. React runs a commit's passive effects in tree order, and the frame renders the Conversation column before the right column, so in a commit that switched Sessions or returned from a global panel a Conversation component opening content from its effect ran while no seat was bound — after the departing seat's release and before the arriving seat's bind — and the open threw. The Session selection and the main panel change before React renders, so deriving the on-screen Session from them removes that ordering dependency. The seat still measures pane room, because only its rendered kit can. + ## Consequences - A type is one static object plus one or two keyed seat registrations; its occurrence information is read through injected `useTabInfo()`. The framework grows no per-type API surface, and a type shipped from outside this repository imports only types from the Sidebar package. @@ -116,5 +118,5 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced ## Deferred - A navigation protocol beyond `sidebar://`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes. -- Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session. +- Opening into a session other than the one on screen from the public face, which acts on the on-screen Session only; a tab's own actions already act on their tab's session. - A localized message when an open fails from the conversation; the failure is currently the thrown error's text. diff --git a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md index e4e8de5a38..ca10161a62 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md @@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar `openResource` 接受所有已声明资源形状的联合,`openTab` 接受为 `K` 声明的形状;正文按自己所服务的协议或 kind 收窄 `navigation.params`。参数属于资源类型而非查看器,因为行号是关于文件位置的事实,不是关于文本预览的,任何认领 `file` 地址的类型收到同一形状。值必须可 JSON 序列化,一条记录必须只凭地址与参数就能重建,因为撤销、重做、刷新与 HMR 都在开启方已不在时重建 tab。 -除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。座位挂载期间发布其绑定——自己的会话、其 store 的 action 与其面;公开面上的命令作用于已挂载会话,没有已挂载会话面时抛错。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。 +除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。插件依据会话选择与主面板确定屏上会话——Conversation 占据主栏时的选中会话——并在 React 渲染这两者的变化之前将其发布为 `mounted`;公开面上的命令经该会话被收养的 store 作用于它,没有会话在屏上或其 store 尚未铸出时抛错。座位只上报它渲染时依据的事实:停靠套件测得的空间,以及框架宽度是否让面板自动全屏。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。 ### 地址 @@ -100,6 +100,8 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar **用 Slot 优先级表达 extension 接管 builtin,随后是注册表铸造的坑位键。** 第一次尝试让覆盖方经一个导出常量以更低的 Slot 优先级注册正文——这是动态插件间的值引用,也是拿第二套规则(Slot 优先级)替注册表的规则站台。第二次尝试让注册表为每次注册铸一个键并从 `register()` 返回,这把注册变成了两步且顺序敏感的舞步。让实现自己声明 `id`——必填、唯一、与它注册坑位所用的同一个串——既不需要常量,也不需要铸键与顺序,还给了注册表拒绝重复所需的身份。 +**由座位在自己的 effect 里发布绑定。** 最初的形式由已挂载的座位在每次提交后发布自己的会话、action 与面,离开时释放。React 按树序运行一次提交的被动 effect,而框架先渲染 Conversation 列、后渲染右栏,于是在切换会话或从全局面板返回的那次提交里,从自己 effect 打开内容的 Conversation 组件运行时没有任何座位已绑定——在离开的座位释放之后、到来的座位绑定之前——打开随之抛错。会话选择与主面板都在 React 渲染之前变化,从它们推导屏上会话便消除了这一顺序依赖。分栏空间仍由座位测量,因为只有它渲染出的套件能测。 + ## Consequences - 一个类型 = 一个静态对象 + 一到两个 keyed 坑位注册;其实例信息通过注入的 `useTabInfo()` 读取。框架不长任何按类型的 API 面,仓外类型从 Sidebar 包只引类型。 @@ -116,5 +118,5 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar ## Deferred - `sidebar://` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。 -- 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。 +- 从公开面往屏上会话之外的会话里打开;公开面只作用于屏上会话,而 tab 自己的动作已作用于其所在会话。 - 从会话区打开失败时的本地化提示;目前是抛错文本本身。 diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 788b6aa871..ea2a30d938 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -16,9 +16,9 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -264,6 +264,130 @@ describe('web e2e: plan review takeover round trip', () => { }) }) +describe.skipIf(MODE === 'record')('web e2e: pending plan review across Sidebar seat changes', () => { + // Two recorded Sessions seeded side by side; opening one in the page gives it + // a live Agent, so a review can be delivered to it while it is off screen. + const OTHER_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let planned: SessionId + let other: SessionId + const crashes: string[] = [] + const row = (session: SessionId) => page.locator(`[data-row-key="session:${session}"]`) + + async function showSessions(): Promise { + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + // Expand the Workspace group by its state, not by whether its rows have rendered yet. + const group = page.locator('[data-row-key^="workspace:"]').first() + await group.waitFor({ timeout: 15_000 }) + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await row(planned).waitFor({ timeout: 15_000 }) + } + + async function open(session: SessionId): Promise { + await row(session).click() + await page.locator(`[data-conversation-session="${session}"]`).waitFor({ timeout: 15_000 }) + await page.locator(`[data-sidebar-right-session="${session}"]:not([hidden])`).first().waitFor({ state: 'attached', timeout: 15_000 }) + // `expect.poll` is test-scoped and this also runs from beforeAll, so poll by hand. + const deadline = Date.now() + 15_000 + while (scaffold.ctx.agents.get(session) === undefined) { + if (Date.now() > deadline) throw new Error(`opening Session ${session} published no live Agent`) + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + + async function showPlugins(): Promise { + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + await expect.poll(() => page.locator('[data-composer-input]').count(), { timeout: 10_000 }).toBe(0) + } + + /** Deliver a plan review to one Session, run the scenario, and withdraw the review. */ + async function whileReviewing(session: SessionId, detail: string, scenario: () => Promise): Promise { + const agent = scaffold.ctx.agents.get(session) + if (agent === undefined) throw new Error(`Session ${session} has no live Agent`) + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent, signal: controller.signal, + questions: [{ id: 'seat-change', question: 'Approve this plan?', detail, + options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' }, + }], + }) + const outcome = asked.then(value => value, (error: unknown) => ({ error })) + try { + await row(session).locator('[data-state="warning"]').waitFor({ timeout: 10_000 }) + await scenario() + } finally { + controller.abort() + await outcome + } + } + + /** The review opened itself in the Sidebar and kept its manual opener. */ + async function expectOpened(text: string): Promise { + const card = page.locator('[data-plan-review-key]') + await card.waitFor({ timeout: 10_000 }) + const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]') + await preview.waitFor({ state: 'visible', timeout: 10_000 }) + expect(await preview.getByText(text).isVisible()).toBe(true) + expect(await card.getByRole('button', { name: 'Open plan in sidebar' }).count()).toBe(1) + expect(await page.locator('[data-slot-error]').count()).toBe(0) + expect(crashes).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + planned = await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), 'plan-review-seat-planned') + other = await seedSession(scaffold, await readFile(OTHER_FIXTURE, 'utf8'), 'plan-review-seat-other') + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + page.on('console', (message: ConsoleMessage) => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) crashes.push(message.text()) + }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await showSessions() + await open(planned) + await open(other) + }) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('opens a review that arrives while another Session is on screen', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-session-return')) + // Returning mounts the review in the commit that brings its Session back on + // screen and swaps the Sidebar seats; the review's automatic open runs + // before either seat's effects. + await whileReviewing(planned, '# Session review\n\nSubmitted while another Session was on screen.', async () => { + await row(planned).click() + await expectOpened('Submitted while another Session was on screen.') + }) + }) + + it('opens a review for a Session whose Sidebar layout was never saved', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-first-layout')) + // The layout is stored per browser. Without it the arriving seat creates + // one, and that store commit lands in the commit where the review opens. + await open(other) + const warningStart = tripwire.warnings.length + await page.evaluate((session) => { localStorage.removeItem(`dsh.sidebar-right.v1.${session}`) }, planned) + await page.reload({ waitUntil: 'load' }) + await showSessions() + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await showPlugins() + await whileReviewing(planned, '# Layout review\n\nSubmitted to a Session without a saved Sidebar layout.', async () => { + await row(planned).click() + await expectOpened('Submitted to a Session without a saved Sidebar layout.') + }) + }) +}) + describe('web e2e: dismissed plan history', () => { it.skipIf(MODE === 'record')('reopens the permanent plan card after dismissing its review', async () => { // Replay supplies a finite continuation; the real question rejection and retained document are the assertions. diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index aec8a85468..29501d3d4e 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -27,8 +27,8 @@ en: a13078ade4ef3b9d zh: 2c8b3e8b62ad5652 /deepseek-ai-dsh-client-ui-plan/understand-the-implementation: - en: 1360fc232aee29e7 - zh: 0beca76c9214a079 + en: 9b663a9a30286847 + zh: 9dca5298fce6a6dd /deepseek-ai-dsh-client-ui-plan/further-exploration: en: 59f62ef6269abf82 zh: c8b4ee607be1e432 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index e9e38251ff..e02091c34b 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -51,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. -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 automatic open reads `ctx.sidebarRight.mounted` through a bound hook and runs once a seat is on screen: a review arriving while a global panel is active mounts ahead of the returning seat in the same commit, before that seat binds. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. +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 automatic open reads `ctx.sidebarRight.mounted` through a bound hook and runs once a Session is on screen: the Sidebar names the returning Session before the commit that mounts the review renders, so a review that arrived while a global panel or another Session was on screen opens from that commit's effect. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor. The framework-bound `usePlans(turn)` exposes only submitted-plan data for that Turn. Chat indexes membership on Node updates and orders this collection when read; card rendering neither scans the transcript nor subscribes to other Turns or Node kinds. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 91fe232d27..5bb898b582 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -51,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` 命令的提示逐字共用。 -计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在座位出现在屏幕上后才执行:全局面板打开期间到达的审批,会在返回时与座位在同一次提交里先挂载、早于座位完成绑定。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在有会话出现在屏幕上后才执行:Sidebar 在挂载审批的那次提交渲染之前就已给出返回的会话,因此在全局面板或其他会话占据屏幕期间到达的审批,会在那次提交的 effect 里打开。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。 框架绑定的 `usePlans(turn)` 只提供该回合的已提交计划数据。Chat 随节点更新维护成员索引,并在读取时排列该集合;卡片渲染不扫描整段对话,也不订阅其他回合或节点类型。 diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index b78454d0e7..e629b024c3 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -30,7 +30,7 @@ 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 readonly hooks: { - /** The session whose right Sidebar seat is mounted; `undefined` while none is on screen. */ + /** The Session on screen, whose right Sidebar the opens land in; `undefined` while none is. */ readonly sidebarMounted: HostObservable } } @@ -62,10 +62,10 @@ export function PlanCards({ turn, usePlans, openPlan, t }: PropsRuntime<'convers /** * Open each pending plan automatically and retain a manual opener without answering it. * - * The automatic open waits for a mounted Sidebar seat: a review that arrives - * while the Conversation is off screen mounts in the same commit as the seat, - * ahead of it, and the seat binds from its own effect. Reading the bound - * session through the hook opens once that binding exists. + * The automatic open waits for a Session on screen. `ctx.sidebarRight` names + * the Session before the commit that brings its Conversation back renders, so + * a review that arrived while the Conversation was off screen opens from the + * effect of the commit that mounts it. * @param props - Review identity, Session store, localized copy, and navigation. * @returns an opener for either logged or temporary plan text. */ 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 6567c45cb1..266ddeda6a 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -109,14 +109,14 @@ describe('ui-plan browser apply', () => { 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 { controller, adopt, show } = createSidebarRightController(tabs, vi.fn(), { + autoFullscreen: () => false, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) const release = adopt(parent, store) store.actions.open(parent) - const unbind = controller.bind({ - sessionId: parent, actions: store.actions, surfaces: store.getSnapshot().bySession, canSplitPane: () => true, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - }) + show(parent) b.openResource.mockImplementation(controller.openResource.bind(controller)) b.openResourceIn.mockImplementation(controller.openResourceIn.bind(controller)) b.subagentAddress.mockReturnValue({ parentSessionId: parent, childSessionId: child, mode: 'continuable' }) @@ -137,7 +137,7 @@ describe('ui-plan browser apply', () => { expect(store.getSnapshot().bySession[child]).toBeUndefined() } finally { await fiber.dispose() - unbind() + show(undefined) release() controller.tabDomain.dispose() } @@ -220,7 +220,7 @@ describe('ui-plan browser apply', () => { 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) - // The automatic open reads the service's mounted-seat source, not a copy. + // The automatic open reads the service's on-screen Session source, not a copy. expect(reviewInjected.hooks.sidebarMounted).toBe(b.mounted) const pending = { id: 'review', question: 'Approve?', plan: plan.markdown, callId: plan.callId, approve: { label: 'Approve' } } reviewInjected.openReview(pending, 'question:1') diff --git a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx index 73e08d4b2d..1a4fa9aac9 100644 --- a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx @@ -29,13 +29,13 @@ function planHook(plans: readonly typeof plan[]): Parameters[0 const source = createSnapshotStore(plans) return keyedObservableHook(() => source) as Parameters[0]['usePlans'] } -/** The opener's props share; each test supplies the seat hook beside it. */ +/** The opener's props share; each test supplies the on-screen Session hook beside it. */ function reviewProps(review: { plan: string; callId?: typeof plan.callId }, openReview: Mock, store: ReturnType['create']>) { return { review, requestKey: 'question:1', t, openReview, actions: store.actions, useStore: (select: (state: ReturnType) => unknown) => select(store.getSnapshot()), } as unknown as Parameters[0] } -/** A bound `useSidebarMounted` reading one observable seat value. */ +/** A bound `useSidebarMounted` reading one on-screen Session value. */ function seatHook(read: () => SessionId | undefined) { return (select: (session: SessionId | undefined) => S): S => select(read()) } @@ -257,9 +257,8 @@ describe('plan entry points and document', () => { } as Parameters[0]} />) expect(openReview).toHaveBeenCalledTimes(4) }) - it('waits for a mounted sidebar seat before opening automatically, then opens once', () => { - // The review and the seat mount in one commit, the review first: its effect - // runs while no seat is bound, and the seat's own effect binds afterwards. + it('waits for a Session on screen before opening automatically, then opens once', () => { + // A retained Sidebar can hold the review while no Session is on screen. const openReview = vi.fn() const store = createPlanReviewStore().create() const mounted = createSnapshotStore(undefined) @@ -268,7 +267,7 @@ describe('plan entry points and document', () => { const view = render() expect(openReview).not.toHaveBeenCalled() expect(store.getSnapshot().opened).toEqual({}) - // The manual opener stays available without a seat; the service decides what to do. + // The manual opener stays available without a Session on screen; the service decides what to do. fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' })) expect(openReview).toHaveBeenCalledTimes(1) mounted.set(target.session.sessionId) diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx index 3cfb4fe189..da67649ce5 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx @@ -40,7 +40,7 @@ async function boot() { const rt = await SlotTestRuntime.create() runtime = rt rt.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) } } as never) - rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) + rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: rt.panelInfo } as never) rt.ctx.provide('shortcuts', { register: () => () => {}, catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} } } as never) const locale = new LocaleRuntime(rt.ctx) rt.ctx.provide('locale', locale) diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index a101c8c61b..dfd1b69803 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -21,8 +21,8 @@ en: 6d7f145ecb1fd140 zh: 666d142ef249f6cb /deepseek-ai-dsh-client-ui-sidebar-right/the-expand-button: - en: 28b0bc3d4050b0e0 - zh: 733b44ad9309f49a + en: 2614d3f179290606 + zh: 88c08ee260ba57b7 /deepseek-ai-dsh-client-ui-sidebar-right/state: en: 2b15cdf5f1383291 zh: 57018036d77363ba @@ -30,8 +30,8 @@ en: ef2af36136c4a919 zh: 48d992e2c923a130 /deepseek-ai-dsh-client-ui-sidebar-right/ctx-sidebarright: - en: 8cffef22360aa177 - zh: a09eef5bb7fdbd61 + en: 4519c4633d160c2a + zh: e4019a62532f885a /deepseek-ai-dsh-client-ui-sidebar-right/the-tab-domain: en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 @@ -51,5 +51,5 @@ en: 76a763b4dd71babd zh: fdc966dd427a82fb /deepseek-ai-dsh-client-ui-sidebar-right/known-limitations-and-deferred-work/dev-note: - en: 33274d681fac9a21 - zh: e024c1c4766b289f + en: 9fcbcccf09b43c5d + zh: 224b8a0881ab6890 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 846557709f..980b192ce0 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -58,7 +58,7 @@ While the panel is hidden, one button in the conversation header's corner seat ( The panel takes the conversation's ground colour and content font sizes rather than a raised layer of its own: it is a column of the page, not a card over it. -The root-scoped `rightbar` entry renders the selected Session and background Sessions with initialized `keepMounted` bodies through separate `rightbar.session` subtrees. Each View owns its Session reference. Only the foreground Conversation reports a frame track and binds public navigation; other subtrees stay hidden without deleting tab state. +The root-scoped `rightbar` entry renders the selected Session and background Sessions with initialized `keepMounted` bodies through separate `rightbar.session` subtrees. Each View owns its Session reference. Only the foreground subtree reports a frame track; other subtrees stay hidden without deleting tab state. ## State @@ -94,9 +94,9 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r `preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference. -`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. `mounted` is the observable of the session whose seat is on screen, `undefined` while a global panel replaces the Conversation or no session is selected. A component that opens content from its own mount effect reads it through a bound hook and acts once it is defined: the frame mounts the Conversation column ahead of this seat, and the seat publishes its binding from a passive effect of the same commit, so an effect that assumed a bound seat would run first and throw. The plan review's automatic open reads it this way. +`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook. -`focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the mounted Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection. +`focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the on-screen Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection. ## The Tab domain @@ -156,4 +156,4 @@ None. -**Runtime invariant:** No companion is published. The two services (`sidebarRight`, `sidebarRightTabs`) are provided through `ctx.reflect.provide` inside one effect and torn down with it; the seat's binding and the Tab domain's occurrence lifetimes are asserted directly by this package's specs, and no independent observation exists to diverge from them. +**Runtime invariant:** No companion is published. The two services (`sidebarRight`, `sidebarRightTabs`) are provided through `ctx.reflect.provide` inside one effect and torn down with it; the on-screen Session and the Tab domain's occurrence lifetimes are asserted directly by this package's specs, and no independent observation exists to diverge from them. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 3f0a64c7ee..96f8e284d9 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -58,7 +58,7 @@ kind: "package-reference" 面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。 -root 作用域的 `rightbar` 入口通过独立的 `rightbar.session` 子树渲染选中的 Session,以及拥有已初始化 `keepMounted` 正文的后台 Session。每个 View 拥有自身的 Session reference。只有前台 Conversation 上报框架列宽并绑定公共导航;其他子树保持隐藏,不删除 tab 状态。 +root 作用域的 `rightbar` 入口通过独立的 `rightbar.session` 子树渲染选中的 Session,以及拥有已初始化 `keepMounted` 正文的后台 Session。每个 View 拥有自身的 Session reference。只有前台子树上报框架列宽;其他子树保持隐藏,不删除 tab 状态。 ## 状态 @@ -94,9 +94,9 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 `preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。 -`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。`mounted` 是「席位正在屏幕上的那个会话」的可观察值,全局面板替代 Conversation 或没有选中会话时为 `undefined`。要在自己的挂载 effect 里打开内容的组件通过绑定的 hook 读取它,并在它有值后再行动:框架把 Conversation 列挂载在这个席位之前,而席位在同一次提交的 passive effect 里才发布绑定,一个假定席位已绑定的 effect 会先运行并 throw。计划审阅的自动打开就是这样读取它的。 +`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。 -`focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用已挂载会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。 +`focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用屏幕上会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。 ## Tab 域 @@ -156,4 +156,4 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止 -**运行时不变量:** 不发布 companion。两个服务(`sidebarRight`、`sidebarRightTabs`)在同一个 effect 内经 `ctx.reflect.provide` 提供并随之拆除;席位绑定与 Tab 域 occurrence 的生命周期由本包的 spec 直接断言,不存在会与之分歧的独立观察。 +**运行时不变量:** 不发布 companion。两个服务(`sidebarRight`、`sidebarRightTabs`)在同一个 effect 内经 `ctx.reflect.provide` 提供并随之拆除;屏幕上的会话与 Tab 域 occurrence 的生命周期由本包的 spec 直接断言,不存在会与之分歧的独立观察。 diff --git a/packages/client/ui-sidebar-right/src/client/index.ts b/packages/client/ui-sidebar-right/src/client/index.ts index 2c0613698a..5974b81723 100644 --- a/packages/client/ui-sidebar-right/src/client/index.ts +++ b/packages/client/ui-sidebar-right/src/client/index.ts @@ -36,6 +36,7 @@ import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx' import { GuideTitle } from './tabs/guide/GuideTitle.tsx' import { ExpandButton } from './shell/ExpandButton.tsx' import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx' +import { closeWithPaneFocus, openWithPaneFocus } from './shell/close-focus.ts' import { RightbarRoot, type RightbarRootInjected } from './shell/RightbarRoot.tsx' import { SidebarSessionViews } from './session-views.ts' import { createSidebarRightController, type SidebarRightController } from './service.ts' @@ -53,7 +54,7 @@ export type { GuideBodyProps, GuideInjected } from './tabs/guide/GuideBody.tsx' export type { ExpandButtonProps } from './shell/ExpandButton.tsx' export type { SidebarRightState, SurfaceState } from './stores.ts' export type { - ISidebarRight, SidebarRightBinding, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, + ISidebarRight, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, SidebarRightPlacement, SidebarRightCloseHandler, SurfaceActions, } from './service.ts' export type { @@ -115,10 +116,37 @@ export function apply(ctx: ClientContext): void { sync() return () => { unsubscribe(); views.dispose() } }, 'ui-sidebar-right: retained Session views') - const { controller, adopt, forget } = createSidebarRightController( + const layout: ILayout = ctx.layout + // The automatic fullscreen rule as the seats last rendered it: the frame + // hands them its width as owner props, and every seat reports the same rule. + let autoFullscreen = false + const { controller, adopt, forget, show, measure } = createSidebarRightController( tabs, (address, signal) => { ctx.resources.pin(address, signal) }, + { + autoFullscreen: () => autoFullscreen, + openWithFocus: (sessionId, open) => { openWithPaneFocus(document, sessionId, open) }, + closeWithFocus: (sessionId, paneId, close) => { closeWithPaneFocus(document, sessionId, paneId, close) }, + }, ) + // The Session on screen: the selected one while the Conversation fills the + // main column. Both sources notify before React renders their change, so the + // service names the arriving Session before any component of that commit + // reads it, and its seat mints the Session's store in the same render. + ctx.effect(() => { + const sync = (): void => { + const selected = views.source.getSnapshot().find(view => view.selected) + show(layout.panelInfo.getSnapshot().activePanelId === null ? selected?.sessionId : undefined) + } + const unsubscribeViews = views.source.subscribe(sync) + const unsubscribePanel = layout.panelInfo.subscribe(sync) + sync() + return () => { + unsubscribeViews() + unsubscribePanel() + show(undefined) + } + }, 'ui-sidebar-right: on-screen Session') const disposeRegistry = ctx.reflect.provide('sidebarRightTabs', tabs) const disposeService = ctx.reflect.provide('sidebarRight', controller) // Registered first, so it tears down last: the faces outlive every seat and @@ -157,13 +185,12 @@ export function apply(ctx: ClientContext): void { } } }, } - const layout: ILayout = ctx.layout - const injected: Omit = { + const injected: Omit = { syncPresentation({ shown, track, fullscreen }) { if (shown) layout.openRightbar(track, fullscreen) else layout.closeRightbar() }, - bindService: binding => controller.bind(binding), + reportAutoFullscreen: (value) => { autoFullscreen = value }, splitPane: (paneId) => { controller.split(paneId) }, toggleFullscreen: () => { const target = controller.commandTarget(); if (target !== undefined) controller.toggleFullscreen(target) }, openTab: (kind, options) => { controller.openTab(kind, options) }, @@ -194,6 +221,7 @@ export function apply(ctx: ClientContext): void { store, inject: (sessionId): SidebarRightInjected => ({ ...injected, + measureRoom: (canSplitPane) => { measure(sessionId, canSplitPane) }, closeTab: (tabId) => { try { controller.closeIn(sessionId, tabId) } catch (error) { console.error('Sidebar tab close failed:', error) } diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 193d40873b..c4bfc1252b 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -3,19 +3,22 @@ * * The surface is per session and its state lives in that session's store * instance, which the slot runtime mints per session and a root service cannot - * reach on its own. Two paths lead in. The mounted seat publishes its binding — - * session id, bound actions, its surface — for exactly as long as it is mounted, - * and every command on the public face goes through that binding; a command - * arriving with no seat mounted has no session to act on and fails loudly rather - * than writing into a surface nobody is drawing. `mounted` publishes that - * binding's session as an observable, so a consumer that wants to open content - * as soon as a seat is on screen subscribes to it instead of assuming one is - * bound when its own effect runs. And the plugin adopts each - * session's store instance as the runtime mints it, so the controller reaches - * any session's store by id and syncs the Tab domain from that store's commits. + * reach on its own. The plugin adopts each session's store instance as the + * runtime mints it, so the controller reaches any session's store by id and + * syncs the Tab domain from that store's commits, on screen or not. + * + * The plugin also names the Session on screen — the selected Session while the + * Conversation fills the main column — from the selection and the main panel, + * before React renders either change, and publishes it as `mounted`. Every + * command on the public face acts on that Session through its adopted store; a + * command with no Session on screen, or with one whose store the runtime has not + * minted, has nothing to act on and fails loudly rather than writing into a + * surface nobody is drawing. The seats never publish which Session they draw: + * they report only what they render with, the room their docking kit measured + * and the automatic fullscreen rule of their frame width. * * A tab's own actions (`tabActions`) aim at the session the tab is in, not at - * the mounted one: they run through that session's adopted store, so a callback + * the on-screen one: they run through that session's adopted store, so a callback * fired after the user switched sessions still lands where its tab is, and they * do nothing for a session whose store was never minted. * @@ -27,8 +30,8 @@ * hand the store one settled intent and record the navigation in the Tab * domain. Placement is the caller's option, never a type's property. * - * The registration adopts Session stores and injects the mounted seat binding; - * callers use the service's navigation methods. + * The registration adopts Session stores, names the on-screen Session, and + * forwards the seats' reports; callers use the service's navigation methods. */ import { sidebarTargetFromElement, type SidebarRightTarget } from './focus.ts' import { randomUUID } from '@deepseek-ai/dsh-util-crypto' @@ -61,24 +64,49 @@ interface Adoption { readonly unsubscribe: () => void } +/** The room rule's verdict for a docked pane, as a seat's docking kit last measured it: whether two working halves would fit. */ +type RoomRule = (paneId: PaneId) => boolean + +/** Plugin-owned Session state the controller reads; the plugin writes it through the factory's callbacks. */ +interface SidebarRightSessions { + /** Each Session's latest adopted store. */ + readonly adopted: ReadonlyMap + /** Each Session's room rule as its seat last reported it; a Session without one has room in every pane. */ + readonly rooms: ReadonlyMap + /** The Session the plugin names as on screen. */ + readonly onScreen: ObservableSnapshot + /** Open tab metadata across saved and adopted Sessions. */ + readonly openTabs: SidebarTabInventory['source'] +} + /** - * Create the public controller and the plugin-private store adoption callback. + * Create the public controller and the plugin-private Session callbacks. * Adoption reconciles restored records before any seat renders, then follows commits. * @param tabs - registered tab types. * @param pin - resource retention for an occurrence's lifetime. - * @returns the controller and plugin-owned adoption and scope-removal callbacks. + * @param host - the viewport rule and focus continuity the commands use. + * @returns the controller; store adoption and scope removal; naming the on-screen Session; and recording a seat's room rule. */ -export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource): { +export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource, host: SidebarRightHost): { controller: SidebarRightController adopt: (sessionId: SessionId, store: SidebarRightSurfaceStore) => () => void forget: (sessionId: SessionId) => void + show: (sessionId: SessionId | undefined) => void + measure: (sessionId: SessionId, canSplitPane: RoomRule) => void } { const adopted = new Map() + const rooms = new Map() + const onScreen = createSnapshotStore(undefined) const inventory = new SidebarTabInventory() - const controller = new SidebarRightController(tabs, pin, adopted, inventory.source) + const controller = new SidebarRightController(tabs, pin, host, { adopted, rooms, onScreen, openTabs: inventory.source }) return { controller, - forget: (sessionId) => { inventory.remove(sessionId) }, + forget: (sessionId) => { + inventory.remove(sessionId) + rooms.delete(sessionId) + }, + show: (sessionId) => { if (onScreen.getSnapshot() !== sessionId) onScreen.set(sessionId) }, + measure: (sessionId, canSplitPane) => { rooms.set(sessionId, canSplitPane) }, adopt(sessionId, store) { adopted.get(sessionId)?.unsubscribe() const sync = (): void => { @@ -97,29 +125,26 @@ export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: } } -/** Everything a command needs, as the mounted seat sees it. */ -export interface SidebarRightBinding { - /** The session the mounted seat is drawing. */ - readonly sessionId: SessionId - /** The seat's store's bound actions; every action names the session it acts on. */ - readonly actions: SurfaceActions +/** What the controller needs from the page around it: the viewport rule and focus continuity. */ +export interface SidebarRightHost { /** - * The seat's store surfaces as last committed, keyed by session id; the - * mounted session's is `surfaces[sessionId]`, absent before the seat's first - * open. The runtime mints one store per session, so this holds that session. + * Whether the viewport is narrow enough that an expanded panel is presented fullscreen. + * @returns the rule's verdict for the frame width the seats last rendered at. */ - readonly surfaces: Readonly> + readonly autoFullscreen: () => boolean /** - * The room rule's verdict for a docked pane, as the kit last measured it: - * whether two working halves would fit. Unmeasured panes fit. + * Commit a page operation and focus the pane it selects. + * @param sessionId - the Session whose page is opening. + * @param open - the synchronous operation; returns the selected pane, or `undefined` when unchanged. */ - readonly canSplitPane: (paneId: PaneId) => boolean - /** Commit a keyboard/menu close and retain focus on a surviving visible pane. */ - readonly closeWithFocus: (paneId: PaneId, close: () => void) => void - /** Commit a page operation and focus the pane it selects. */ - readonly openWithFocus: (open: () => PaneId | undefined) => void - /** Narrow viewports present an expanded panel fullscreen. */ - readonly autoFullscreen?: boolean + readonly openWithFocus: (sessionId: SessionId, open: () => PaneId | undefined) => void + /** + * Commit a keyboard/menu close and retain focus on a surviving visible pane. + * @param sessionId - the Session whose page is closing. + * @param paneId - the pane whose page is closing. + * @param close - the synchronous cleanup and removal. + */ + readonly closeWithFocus: (sessionId: SessionId, paneId: PaneId, close: () => void) => void } /** Where an open lands; every field is optional and the defaults are the common case. */ @@ -160,12 +185,13 @@ export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) => /** The outward right-Sidebar face (`ctx.sidebarRight`). */ export interface ISidebarRight { /** - * The session whose seat is mounted, or `undefined` while no seat is on - * screen (a global panel is active, or no session is selected). Moves when a - * seat binds or releases, so a component that opens content from its own - * mount effect reads it through a bound hook and acts once it is defined: - * the frame mounts the Conversation column ahead of the seat, and the seat - * publishes its binding from a passive effect of the same commit. + * The Session on screen, or `undefined` while none is (a global panel fills + * the main column, or no Session is selected). It changes before React + * renders the selection or panel change that causes it, so every component + * rendered in that commit reads the arriving Session, and the commands act on + * it from that commit's effects: its seat mints its store in the same render. + * Moves only when the on-screen Session changes; the Session's own store + * commits are silent. */ readonly mounted: ObservableSnapshot /** @@ -189,13 +215,13 @@ export interface ISidebarRight { */ openTab(kind: K, options?: SidebarRightOpenTabOptions): void /** - * Close one tab of the mounted session; the sole docked guide remains open. + * Close one tab of the on-screen Session; the sole docked guide remains open. * @param tabId - the tab to close. */ close(tabId: TabId): void /** * The active tab of the active pane. - * @returns the record, or `undefined` when no seat is mounted. + * @returns the record, or `undefined` with no Session on screen. */ active(): TabRecord | undefined /** @@ -236,10 +262,10 @@ export interface ISidebarRight { export class SidebarRightController implements ISidebarRight { /** Open tab metadata across saved and adopted Sessions, independent of visible seats. */ readonly openTabs: SidebarTabInventory['source'] - private readonly mountedSession = createSnapshotStore(undefined) - /** The mounted seat's session; see {@link ISidebarRight.mounted}. */ - readonly mounted: ObservableSnapshot = this.mountedSession - private binding: SidebarRightBinding | undefined + /** The on-screen Session; see {@link ISidebarRight.mounted}. */ + readonly mounted: ObservableSnapshot + private readonly adopted: ReadonlyMap + private readonly rooms: ReadonlyMap private readonly closeHandlers = new Map() /** @@ -263,16 +289,19 @@ export class SidebarRightController implements ISidebarRight { /** * @param tabs - the tab-type registry consulted to claim an address. * @param pin - `ctx.resources.pin`, which the Tab domain holds addresses with. - * @param adopted - plugin-owned session stores used by occurrence actions. - * @param openTabs - plugin-owned metadata source across saved and adopted layouts. + * @param host - the viewport rule and focus continuity the commands use. + * @param sessions - plugin-owned adopted stores, room rules, on-screen Session, and open tab metadata. */ constructor( private readonly tabs: SidebarRightTabRegistry, pin: PinResource, - private readonly adopted = new Map(), - openTabs: SidebarTabInventory['source'] = new SidebarTabInventory().source, + private readonly host: SidebarRightHost, + sessions: SidebarRightSessions, ) { - this.openTabs = openTabs + this.adopted = sessions.adopted + this.rooms = sessions.rooms + this.mounted = sessions.onScreen + this.openTabs = sessions.openTabs this.tabDomain = new TabDomain(this, pin) } @@ -285,31 +314,6 @@ export class SidebarRightController implements ISidebarRight { return Object.values(this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout.tabs ?? {}) } - /** - * Adopt the mounted seat's binding, replacing any previous one. - * - * Called from the seat while it is mounted, and released when it leaves. - * @param binding - the mounted seat's session, actions, and the store's surfaces. - * @returns a release callback that clears exactly this binding. - */ - bind(binding: SidebarRightBinding): () => void { - this.binding = binding - this.publishMounted() - return () => { - // A newer seat may already have taken over; only the binding that is - // still ours may be cleared. - if (this.binding !== binding) return - this.binding = undefined - this.publishMounted() - } - } - - /** Publish the mounted session only when it changes; a republished binding for the same session is silent. */ - private publishMounted(): void { - const next = this.binding?.sessionId - if (this.mountedSession.getSnapshot() !== next) this.mountedSession.set(next) - } - /** * Open a resource: claim it, place it, reveal the column, record the navigation. * @param address - a `dsh-resource:///…` address. @@ -413,7 +417,6 @@ export class SidebarRightController implements ISidebarRight { params: SidebarRightNavigationParams, ): void { const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] - ?? (this.binding?.sessionId === sessionId ? this.binding.surfaces[sessionId] : undefined) const targetPane = surface === undefined ? undefined : placement.paneId ?? activeDockPaneId(surface.layout) const target = targetPane === undefined ? undefined : surface?.layout.nodes[targetPane] const preferNewPane = placement.preferNewPane === true @@ -424,8 +427,8 @@ export class SidebarRightController implements ISidebarRight { && target.tabs.length > 0 && canSplit(surface.layout) && dockPaneIds(surface.layout).length < 2 - && this.binding?.sessionId === sessionId - && this.binding.canSplitPane(target.id) + && this.mounted.getSnapshot() === sessionId + && this.canSplitPane(sessionId, target.id) const commit = (): void => { actions.openContent(sessionId, { kind: claim.kind, contentId: claim.contentId, @@ -444,18 +447,16 @@ export class SidebarRightController implements ISidebarRight { } /** - * Close one tab of the mounted session; the sole docked guide remains open. + * Close one tab of the on-screen Session; the sole docked guide remains open. * @param tabId - the tab to close. */ close(tabId: TabId): void { - const { sessionId, actions } = this.require() - if (this.adopted.has(sessionId)) { this.closeIn(sessionId, tabId); return } - actions.closeTab(sessionId, tabId) + this.closeIn(this.require().sessionId, tabId) } /** * The active tab of the active pane. - * @returns the record, or `undefined` with no mounted surface. + * @returns the record, or `undefined` without an on-screen surface. */ active(): TabRecord | undefined { const layout = this.mountedSurface()?.layout @@ -466,7 +467,7 @@ export class SidebarRightController implements ISidebarRight { /** * Whether the column is currently showing its panel. - * @returns `true` while expanded; `false` while collapsed or with no mounted surface. + * @returns `true` while expanded; `false` while collapsed or without an on-screen surface. */ isExpanded(): boolean { return this.mountedSurface()?.layout.expanded ?? false @@ -474,8 +475,8 @@ export class SidebarRightController implements ISidebarRight { /** Collapse the column, or expand it and focus its active dock pane after rendering. */ toggleExpanded(): void { - const { sessionId, actions, openWithFocus } = this.require() - openWithFocus(() => { + const { sessionId, actions } = this.require() + this.host.openWithFocus(sessionId, () => { actions.toggleExpanded(sessionId) const layout = this.mountedSurface()?.layout return layout?.expanded ? activeDockPaneId(layout) : undefined @@ -498,28 +499,27 @@ export class SidebarRightController implements ISidebarRight { * @returns current focused page identity, or undefined outside a visible sidebar page. */ focusedTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined { - const binding = this.binding - const surface = this.mountedSurface() - return binding === undefined || surface === undefined ? undefined - : sidebarTargetFromElement(element, binding.sessionId, surface.layout, - tabId => this.tabDomain.occurrence(binding.sessionId, { id: tabId })) + const screen = this.screen() + return screen === undefined ? undefined + : sidebarTargetFromElement(element, screen.sessionId, screen.surface.layout, + tabId => this.tabDomain.occurrence(screen.sessionId, { id: tabId })) } /** - * Choose a focused sidebar pane, or the mounted Session's active dock pane for an outside open. + * Choose a focused sidebar pane, or the on-screen Session's active dock pane for an outside open. * @param element - live command input target; stale sidebar markup never falls back to another pane. - * @returns captured target, or undefined without a mounted Session. + * @returns captured target, or undefined without an on-screen surface. */ commandTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined { const focused = this.focusedTarget(element) if (focused !== undefined || element?.closest('[data-sidebar-right-session]')) return focused - const binding = this.binding - const layout = this.mountedSurface()?.layout - if (binding === undefined || layout === undefined) return undefined + const screen = this.screen() + if (screen === undefined) return undefined + const { sessionId, surface: { layout } } = screen const pane = getPane(layout, activeDockPaneId(layout)) const tabId = pane.activeTabId - const held = tabId === undefined ? undefined : this.tabDomain.occurrence(binding.sessionId, { id: tabId }) - return { sessionId: binding.sessionId, paneId: pane.id, host: pane.host, tabId, + const held = tabId === undefined ? undefined : this.tabDomain.occurrence(sessionId, { id: tabId }) + return { sessionId, paneId: pane.id, host: pane.host, tabId, occurrence: held, navigationRevision: held?.navigation.getSnapshot().revision } } @@ -530,10 +530,11 @@ export class SidebarRightController implements ISidebarRight { */ openTabFromTarget(kind: string, target: SidebarRightTarget): void { if (!this.isTargetCurrent(target)) return - this.require().openWithFocus(() => { + const { sessionId, actions } = this.require() + this.host.openWithFocus(sessionId, () => { const tab = target.tabId === undefined ? undefined : this.mountedSurface()?.layout.tabs[target.tabId] if (target.host === 'float' && tab?.kind === kind && this.tabs.get(kind)?.multiple !== true) { - this.require().actions.setExpanded(target.sessionId, true) + actions.setExpanded(sessionId, true) this.focus(tab.id) } else { this.openTab(kind, { @@ -565,10 +566,10 @@ export class SidebarRightController implements ISidebarRight { const surface = this.mountedSurface() if (surface === undefined || target.tabId === undefined) return 'unavailable' const tabId = target.tabId - const binding = this.require() - binding.closeWithFocus(target.paneId, () => { + const { sessionId, actions } = this.require() + this.host.closeWithFocus(sessionId, target.paneId, () => { if (canCloseTab(surface, tabId)) this.close(tabId) - else binding.actions.setExpanded(target.sessionId, false) + else actions.setExpanded(sessionId, false) }) return 'closed' } @@ -576,10 +577,10 @@ export class SidebarRightController implements ISidebarRight { /** * Check a captured target before acting, including reopened records and intervening navigation. * @param target - identity captured while resolving the input. - * @returns whether the mounted Session, pane, tab lifetime and navigation still match. + * @returns whether the on-screen Session, pane, tab lifetime and navigation still match. */ isTargetCurrent(target: SidebarRightTarget): boolean { - if (this.binding?.sessionId !== target.sessionId) return false + if (this.mounted.getSnapshot() !== target.sessionId) return false const layout = this.mountedSurface()?.layout const pane = layout?.nodes[target.paneId] if (pane?.kind !== 'pane' || pane.host !== target.host) return false @@ -603,7 +604,7 @@ export class SidebarRightController implements ISidebarRight { if (!layout.expanded) return 'collapsed' if (getPane(layout, target.paneId).tabs.length === 0) return 'empty' if (!canSplit(layout) || dockPaneIds(layout).length >= 2) return 'budget' - if (!this.require().canSplitPane(target.paneId)) return 'width' + if (!this.canSplitPane(target.sessionId, target.paneId)) return 'width' return undefined } @@ -613,9 +614,10 @@ export class SidebarRightController implements ISidebarRight { */ toggleFullscreen(target: SidebarRightTarget): void { if (!this.isTargetCurrent(target) || target.host === 'float' || !this.isExpanded()) return - const { sessionId, actions, autoFullscreen } = this.require() - const fullscreen = autoFullscreen === true || this.mountedSurface()?.layout.mode === 'fullscreen' - if (fullscreen && autoFullscreen === true) actions.setExpanded(sessionId, false) + const { sessionId, actions } = this.require() + const autoFullscreen = this.host.autoFullscreen() + const fullscreen = autoFullscreen || this.mountedSurface()?.layout.mode === 'fullscreen' + if (fullscreen && autoFullscreen) actions.setExpanded(sessionId, false) actions.setMode(sessionId, fullscreen ? 'push' : 'fullscreen') } @@ -625,15 +627,15 @@ export class SidebarRightController implements ISidebarRight { * @returns the new pane's id, or `undefined` when nothing was split. */ split(paneId?: PaneId): PaneId | undefined { - const { sessionId, actions, canSplitPane } = this.require() + const { sessionId, actions } = this.require() const layout = this.mountedSurface()?.layout if (layout === undefined) return undefined const target = paneId ?? activeDockPaneId(layout) const node = layout.nodes[target] if (node === undefined || node.kind !== 'pane' || node.host !== 'dock') return undefined - if (!canSplit(layout) || dockPaneIds(layout).length >= 2 || !canSplitPane(target)) return undefined + if (!canSplit(layout) || dockPaneIds(layout).length >= 2 || !this.canSplitPane(sessionId, target)) return undefined let created: PaneId | undefined - this.require().openWithFocus(() => { + this.host.openWithFocus(sessionId, () => { actions.splitPane(sessionId, target, (id) => { created = id }) return created }) @@ -665,7 +667,7 @@ export class SidebarRightController implements ISidebarRight { } /** - * Step the mounted session's surface back one intent. + * Step the on-screen Session's surface back one intent. * * @internal Not part of the product: the sequence is an architectural fact * with no user-facing control yet. Kept reachable for tests. @@ -676,7 +678,7 @@ export class SidebarRightController implements ISidebarRight { } /** - * Step the mounted session's surface forward one intent. + * Step the on-screen Session's surface forward one intent. * * @internal See `_undo`. */ @@ -685,11 +687,25 @@ export class SidebarRightController implements ISidebarRight { actions.redo(sessionId) } - /** The mounted session's surface; `undefined` without a seat or before its first open. */ + /** + * The on-screen Session and its committed surface; `undefined` with no Session + * on screen, before the runtime mints its store, or before its first open. + */ + private screen(): { readonly sessionId: SessionId; readonly surface: SurfaceState } | undefined { + const sessionId = this.mounted.getSnapshot() + if (sessionId === undefined) return undefined + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] + return surface === undefined ? undefined : { sessionId, surface } + } + + /** The on-screen Session's surface; see {@link SidebarRightController.screen}. */ private mountedSurface(): SurfaceState | undefined { - const { binding } = this - return binding === undefined ? undefined - : this.adopted.get(binding.sessionId)?.store.getSnapshot().bySession[binding.sessionId] ?? binding.surfaces[binding.sessionId] + return this.screen()?.surface + } + + /** Whether a Session's docked pane has room for two working halves, as its seat last measured; unmeasured panes do. */ + private canSplitPane(sessionId: SessionId, paneId: PaneId): boolean { + return this.rooms.get(sessionId)?.(paneId) ?? true } /** @@ -701,13 +717,15 @@ export class SidebarRightController implements ISidebarRight { return this.adopted.get(sessionId)?.store.actions } - private require(): SidebarRightBinding { + private require(): { readonly sessionId: SessionId; readonly actions: SurfaceActions } { // Reads answer for the no-session case (there is nothing expanded), but a // write has no session to write to. Callers are UI gestures and tool // results, both of which belong to a session that is on screen. - if (this.binding === undefined) { + const sessionId = this.mounted.getSnapshot() + const actions = sessionId === undefined ? undefined : this.actionsFor(sessionId) + if (sessionId === undefined || actions === undefined) { throw new Error('sidebarRight: no session surface is mounted') } - return this.binding + return { sessionId, actions } } } diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 3b12fed68a..39fc5f6778 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -49,7 +49,6 @@ import type { TabOccurrence } from '../tab-domain.ts' import type { SidebarRightTabNavigation } from '../contract/slots.ts' import type { TabHookContext } from '../tab-info.ts' import css from './SidebarRight.module.css' -import { closeWithPaneFocus, openWithPaneFocus } from './close-focus.ts' /** The store share the seat receives. */ type Store = PropsStore> @@ -78,27 +77,21 @@ export interface SidebarRightInjected { */ readonly syncPresentation: (presentation: SidebarRightPresentation) => void /** - * Publish this seat's session, actions, and the store's surfaces to `ctx.sidebarRight`. + * Record this seat's room rule for `ctx.sidebarRight`'s splits, replacing its previous one. * - * The service is root-scoped and cannot read a per-entry store, so the only - * honest source is the mounted seat. Held for as long as the seat is mounted; - * the service publishes the bound session through `ctx.sidebarRight.mounted`. - * @param binding - what a command needs to act on this session, and what a tab's own action needs to act on its. - * @returns a release callback. + * The docking kit measures its panes after it renders; the seat forwards each + * reading, and the service applies it when it splits this seat's Session. + * @param canSplitPane - whether two working halves of a docked pane would fit; unmeasured panes fit. */ - readonly bindService: (binding: { - sessionId: SessionId - actions: Store['actions'] - /** Every session's surface as last committed; the mounted one is `surfaces[sessionId]`. */ - surfaces: Readonly> - /** The room rule's verdict for a docked pane, as the kit last measured it. */ - canSplitPane: (paneId: PaneId) => boolean - /** Commit a keyboard/menu close and retain focus on a surviving visible pane. */ - closeWithFocus: (paneId: PaneId, close: () => void) => void - /** Commit a page operation and focus the pane it selects. */ - openWithFocus: (open: () => PaneId | undefined) => void - autoFullscreen?: boolean - }) => () => void + readonly measureRoom: (canSplitPane: (paneId: PaneId) => boolean) => void + /** + * Record whether the frame width this seat renders at presents an expanded panel fullscreen automatically. + * + * The fullscreen command reads the latest report: while it holds, the command + * closes the panel instead of switching its recorded mode. + * @param autoFullscreen - whether the frame is narrower than the automatic fullscreen width. + */ + readonly reportAutoFullscreen: (autoFullscreen: boolean) => void /** * The navigation face's `openTab`, for the strip's add control: a new tab is * the guide opened by kind, through the same path as every other open. @@ -353,29 +346,26 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject entries) - const surfaces = useStore(state => state.bySession) - const surface = surfaces[sessionId] + const surface = useStore(state => state.bySession[sessionId]) const shown = active && surface !== undefined && surface.layout.expanded const autoFullscreen = viewportWidth < 768 const fullscreen = autoFullscreen || surface?.layout.mode === 'fullscreen' const panelRef = useRef(null) - // The kit's room-rule readings, kept in a ref: the service reads them at - // call time through the binding, and a reading never re-renders anything. - const room = useRef>(new Map()) - const reportRoom = useCallback((fits: ReadonlyMap): void => { room.current = fits }, []) + // A reading never re-renders anything: the service applies it when it splits. + const reportRoom = useCallback((fits: ReadonlyMap): void => { + measureRoom(paneId => fits.get(paneId)?.row !== false) + }, [measureRoom]) const track = shown && !autoFullscreen useEffect(() => { @@ -386,6 +376,9 @@ export function RightbarSeat({ if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false) }, [actions, sessionId, shown, fullscreen, canShow]) + // Read only when the fullscreen command runs, after this commit has settled. + useLayoutEffect(() => { reportAutoFullscreen(autoFullscreen) }, [reportAutoFullscreen, autoFullscreen]) + // The open/close slide needs no pulse of its own: the shell's window drag // watcher (ui-web) measures the marked rows every frame the surface moves and // sets the recall mark itself (electron#32341). @@ -419,17 +412,6 @@ export function RightbarSeat({ ? () => { syncPresentation({ shown: false, track: false, fullscreen: false }) } : undefined, [syncPresentation, active]) - // Republished on every committed change: the service's readers answer from the - // last commit, and its commands act on the session actually on screen. - useEffect( - () => active - ? bindService({ sessionId, actions, surfaces, autoFullscreen, - closeWithFocus: (paneId, close) => { closeWithPaneFocus(document, sessionId, paneId, close) }, - openWithFocus: (open) => { openWithPaneFocus(document, sessionId, open) }, - canSplitPane: paneId => room.current.get(paneId)?.row !== false }) - : undefined, - [bindService, sessionId, actions, surfaces, autoFullscreen, active], - ) // The Tab domain is not synced here: the controller adopted this session's // store as the runtime minted it and reconciles on the store's own commits, // on screen or not. diff --git a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts index 42e27709bd..fa83737536 100644 --- a/packages/client/ui-sidebar-right/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/apply.client.spec.ts @@ -5,7 +5,7 @@ * means what those faces do; the slot, locale, frame, and resource faces are * recorders, because what matters here is what was handed to them — two seats * over one store, the guide's body under its own id, the frame reports, the - * service binding — and that every registration is gone after dispose, which + * on-screen Session — and that every registration is gone after dispose, which * is what makes a reload safe. The seats' components have their own specs. */ import { describe, expect, it, onTestFinished, vi } from 'vitest' @@ -63,7 +63,11 @@ async function boot(shortcuts: Partial = {}) { return () => { dictionaries.delete(ns) } }), } - const layout = { openRightbar: vi.fn(), closeRightbar: vi.fn() } + const layout = { + openRightbar: vi.fn(), closeRightbar: vi.fn(), + panelInfo: createSnapshotStore<{ activePanelId: string | null }>({ activePanelId: null }), + } + const current = createSnapshotStore<{ key: SessionId | undefined }>({ key: undefined }) const resources = { pin: vi.fn<(address: string, signal: AbortSignal) => void>() } ctx.provide('slots', slots as never) ctx.provide('locale', locale as never) @@ -71,8 +75,8 @@ async function boot(shortcuts: Partial = {}) { catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} }, ...shortcuts } as never) ctx.provide('layout', layout as never) ctx.provide('resources', resources as never) - ctx.provide('sessions', { retain: vi.fn() } as never) - ctx.provide('uiSession', { adapter: { current: createSnapshotStore({ key: undefined }) } } as never) + ctx.provide('sessions', { retain: vi.fn(() => ({ ready: Promise.resolve(), release: vi.fn() })) } as never) + ctx.provide('uiSession', { adapter: { current } } as never) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() const seat = (name: string): Recorded => { @@ -84,7 +88,7 @@ async function boot(shortcuts: Partial = {}) { if (entry.inject === undefined) throw new Error(`expected ${entry.name} to inject`) return entry.inject(SESSION) } - return { ctx, registered, dictionaries, layout, resources, fiber, seat, injectedOf } + return { ctx, registered, dictionaries, layout, current, resources, fiber, seat, injectedOf } } describe('ui-sidebar-right apply', () => { @@ -143,8 +147,21 @@ describe('ui-sidebar-right apply', () => { expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar.session').store) }) - it('hands the panel seat the frame report, the service binding, the opens, the observable registry, and the Tab domain', async () => { - const { ctx, layout, resources, seat, injectedOf } = await boot() + it('names the selected Session as on screen while the Conversation fills the main column', async () => { + const { ctx, layout, current } = await boot() + expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined() + current.set({ key: SESSION }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBe(SESSION) + layout.panelInfo.set({ activePanelId: 'plugins' }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined() + layout.panelInfo.set({ activePanelId: null }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBe(SESSION) + current.set({ key: undefined }) + expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined() + }) + + it('hands the panel seat the frame report, the room rule, the opens, the observable registry, and the Tab domain', async () => { + const { ctx, layout, current, resources, seat, injectedOf } = await boot() const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected // The frame learns the composition of expanded and presentation, nothing else. injected.syncPresentation({ shown: true, track: true, fullscreen: false }) @@ -162,31 +179,42 @@ describe('ui-sidebar-right apply', () => { ctx.sidebarRightTabs.register({ id: 'spec/text', kind: 'text', patterns: ['dsh-resource://file/**'], title: () => 'text' }) expect(seen).toHaveBeenCalledOnce() unsubscribe() - // The binding makes the service act on this seat's session; the seat's - // store instance is minted here from the handle the registration declared. + // The runtime mints each Session's store from the handle the registration + // declared; selecting the Session puts it on screen, and the service acts on + // its adopted store. const handle = seat('rightbar.session').store as ReturnType - const instance = handle.create() - instance.clearPersisted() - vi.stubGlobal('document', { activeElement: null }) + handle.create().clearPersisted() + const instance = handle.create(SESSION) + vi.stubGlobal('document', { activeElement: null, querySelectorAll: () => [] }) onTestFinished(() => { vi.unstubAllGlobals() }) injected.toggleFullscreen() expect(injectedOf(seat('conversation.session.header.corner'))).toHaveProperty('hooks.shortcuts') - const release = injected.bindService({ - sessionId: SESSION, actions: instance.actions, surfaces: {}, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => true, - }) + current.set({ key: SESSION }) injected.openTab('guide', { revealIfOpened: false }) const surface = instance.getSnapshot().bySession[SESSION] expect(surface?.layout.expanded).toBe(true) expect(Object.values(surface?.layout.tabs ?? {}).map(tab => tab.kind)).toEqual(['guide']) // Holding a record pins its address through the resource model. if (surface === undefined) throw new Error('expected a surface') - ctx.sidebarRight.tabDomain.sync(SESSION, surface.layout) expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal)) + // The seat reports the room rule its kit measured; a narrow pane is not split. + const panes = () => Object.values(instance.getSnapshot().bySession[SESSION]?.layout.nodes ?? {}).filter(node => node.kind === 'pane') + injected.measureRoom(() => false) injected.splitPane(surface.layout.activePaneId) - release() + expect(panes()).toHaveLength(1) + injected.measureRoom(() => true) + injected.splitPane(surface.layout.activePaneId) + expect(panes()).toHaveLength(2) + // The fullscreen command follows the automatic fullscreen rule the seats report. + const layoutOf = () => instance.getSnapshot().bySession[SESSION]?.layout + injected.toggleFullscreen() + expect(layoutOf()?.mode).toBe('fullscreen') + injected.reportAutoFullscreen(true) + injected.toggleFullscreen() + expect(layoutOf()?.expanded).toBe(false) + expect(layoutOf()?.mode).toBe('push') + // A global panel takes the Conversation's place: no Session is on screen. + layout.panelInfo.set({ activePanelId: 'plugins' }) expect(() => { ctx.sidebarRight.toggleExpanded() }).toThrow('no session surface is mounted') }) @@ -274,17 +302,12 @@ describe('ui-sidebar-right apply', () => { }) it('takes every registration and both faces back when disposed, aborting the open records, so a reload registers again', async () => { - const { ctx, registered, dictionaries, fiber, seat, injectedOf } = await boot() + const { ctx, registered, dictionaries, current, fiber, seat, injectedOf } = await boot() const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected const handle = seat('rightbar.session').store as ReturnType // Minted under the session key, so the instance is adopted and the teardown releases it. const instance = handle.create(SESSION) - injected.bindService({ - sessionId: SESSION, actions: instance.actions, surfaces: {}, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => true, - }) + current.set({ key: SESSION }) injected.openTab('guide') const surface = instance.getSnapshot().bySession[SESSION] const guide = Object.values(surface?.layout.tabs ?? {})[0] diff --git a/packages/client/ui-sidebar-right/tests/focus.client.spec.ts b/packages/client/ui-sidebar-right/tests/focus.client.spec.ts index fd88240f6e..b949dd5e53 100644 --- a/packages/client/ui-sidebar-right/tests/focus.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/focus.client.spec.ts @@ -26,17 +26,22 @@ function harness() { const ctx = new Context() const tabs = new SidebarRightTabRegistry(ctx) for (const kind of ['guide', 'files', 'terminal']) tabs.register({ id: kind, kind, title: () => kind, multiple: kind === 'terminal' }) - const { controller, adopt } = createSidebarRightController(tabs, vi.fn()) + const room = { allowed: true, autoFullscreen: false } + const { controller, adopt, show: showSession, measure } = createSidebarRightController(tabs, vi.fn(), { + autoFullscreen: () => room.autoFullscreen, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) const store = createSidebarRightStore(() => ({ kind: 'guide', title: 'guide' })).create() releases.push(adopt(SESSION, store), () => { controller.tabDomain.dispose() }) store.actions.setExpanded(SESSION, true) - const room = { allowed: true, autoFullscreen: false } - const bind = (sessionId = SESSION) => controller.bind({ sessionId, actions: store.actions, - surfaces: store.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => room.allowed, autoFullscreen: room.autoFullscreen }) - releases.push(bind()) + /** Put a Session on screen the way the plugin does, with the room rule its seat reports. */ + const show = (sessionId = SESSION) => { + showSession(sessionId) + measure(sessionId, () => room.allowed) + return () => { if (controller.mounted.getSnapshot() === sessionId) showSession(undefined) } + } + releases.push(show()) const layout = () => store.getSnapshot().bySession[SESSION]!.layout const owner = document.createElement('section') owner.dataset.sidebarRightSession = SESSION @@ -57,7 +62,7 @@ function harness() { parent.append(element) return element } - return { controller, store, layout, paneElement, tabElement, room, bind } + return { controller, store, layout, paneElement, tabElement, room, show } } describe('sidebar focus targets', () => { @@ -121,10 +126,10 @@ describe('sidebar focus targets', () => { expect(h.controller.focusedTarget(chip)).toBeUndefined() expect(h.controller.commandTarget(chip)).toBeUndefined() h.store.actions.setExpanded(SESSION, true) - releases.push(h.bind('another-session' as SessionId)) + releases.push(h.show('another-session' as SessionId)) expect(h.controller.isTargetCurrent(target)).toBe(false) expect(h.controller.focusedTarget(chip)).toBeUndefined() - releases.push(h.bind()) + releases.push(h.show()) h.controller.float(files) expect(h.controller.isTargetCurrent(target)).toBe(false) h.controller.dock(findTabPane(h.layout(), files).id) @@ -190,7 +195,7 @@ describe('sidebar focus targets', () => { h.controller.toggleFullscreen(floated) expect(h.layout().mode).toBe('push') h.room.autoFullscreen = true - releases.push(h.bind()) + releases.push(h.show()) h.controller.toggleFullscreen(h.controller.commandTarget(null)!) expect(h.layout().expanded).toBe(false) }) @@ -233,7 +238,7 @@ describe('sidebar focus targets', () => { expect(findTabPane(h.layout(), floatingFile).id).toBe(floating.paneId) expect(h.layout().expanded).toBe(true) expect(h.controller.splitBlock(floating)).toBe('float') - releases.push(h.bind('missing-session' as SessionId)) + releases.push(h.show('missing-session' as SessionId)) expect(h.controller.commandTarget(null)).toBeUndefined() expect(h.controller.splitBlock(floating)).toBe('stale') }) @@ -462,7 +467,7 @@ describe('sidebar keyboard commands', () => { const pendingSplit = split.resolve(context) const pendingFullscreen = fullscreen.resolve(context) const pendingToggle = toggle.resolve(context) - releases.push(h.bind('missing-session' as SessionId)) + releases.push(h.show('missing-session' as SessionId)) for (const pending of [pendingSplit, pendingFullscreen, pendingToggle]) { expect(pending.status).toBe('handled') if (pending.status === 'handled') pending.run() @@ -470,7 +475,7 @@ describe('sidebar keyboard commands', () => { expect(h.layout().mode).toBe('push') expect(h.layout().expanded).toBe(true) expect(toggle.resolve(context).status).toBe('blocked') - releases.push(h.bind()) + releases.push(h.show()) const splitNow = split.resolve(context) if (splitNow.status === 'handled') splitNow.run() expect(h.controller.splitBlock(h.controller.commandTarget(null)!)).toBe('budget') diff --git a/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts b/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts index 7a4678e1a9..4b6aea9bc9 100644 --- a/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/persistence.client.spec.ts @@ -63,7 +63,11 @@ it('pins restored records during adoption before the first render or store mutat first.actions.openContent(sessionId, { kind: 'text', contentId: address, title: 'a' }, (id) => { file = id }) const ctx = new Context() const pin = vi.fn() - const { controller, adopt } = createSidebarRightController(new SidebarRightTabRegistry(ctx), pin) + const { controller, adopt } = createSidebarRightController(new SidebarRightTabRegistry(ctx), pin, { + autoFullscreen: () => false, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) expect(controller.tabsIn(sessionId)).toEqual([]) const restored = createSidebarRightStore(seed).create(sessionId) const release = adopt(sessionId, restored) diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index 8c692290c9..b6e7c85fca 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, fireEvent } from '@testing-library/react' import { useEffect, useState, useSyncExternalStore } from 'react' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, type SlotView } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ShortcutCatalogEntry, ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' @@ -71,7 +71,7 @@ function transition(property = 'transform') { async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, opener = false, keepMounted = false) { const runtime = await SlotTestRuntime.create() runtimes.push(runtime) - const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn() } + const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: runtime.panelInfo } const pin = vi.fn<(address: string, signal: AbortSignal) => void>() runtime.ctx.provide('layout', frame as never) runtime.ctx.provide('resources', { pin } as never) @@ -110,10 +110,12 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, o return armed && visible ? : null } let arm = (_armed: boolean): void => { throw new Error('mountSeat(opener = true) renders the opener') } + let openerView: SlotView<'sidebar-right.test.opener'> | undefined if (opener) { await act(async () => { runtime.slots.register({ name: 'sidebar-right.test.opener' }, Opener) }) - const openerView = runtime.renderSlot('sidebar-right.test.opener', { armed: false }, { session: reference }) - arm = (armed) => { openerView.update({ armed }) } + const view = runtime.renderSlot('sidebar-right.test.opener', { armed: false }, { session: reference }) + openerView = view + arm = (armed) => { view.update({ armed }) } } const bodies = new Map() const titles = new Map() @@ -162,6 +164,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, o return { runtime, feature, controller, instance, actions: instance.actions, layout, open, selectSession, frame, pin, bodies, titles, hooks, view, arm, opened, registerPage, catalog, + openerView: () => openerView!, } } @@ -321,19 +324,20 @@ describe('RightbarSeat presentation', () => { expect(h.layout()).toBe(retained) }) - it('publishes the mounted seat so a Conversation opener acts in the commit that returns from a global panel', async () => { + it('names the on-screen Session before a Conversation opener acts in the commit that returns from a global panel', async () => { const h = await mountSeat(1440, true, 0, true) const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) expect(h.controller.mounted.getSnapshot()).toBe(SESSION) - // Armed while the seat is already bound: the ordinary case. + // Armed while the Session is already on screen: the ordinary case. act(() => { h.arm(true) }) expect(h.opened).toHaveLength(1) expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toEqual(h.opened) - // A global panel hides the Sidebar and releases its public navigation binding. + // A global panel takes the Conversation's place and hides the Sidebar. act(() => { h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) }) expect(element(h.view.container, '[data-sidebar-right-session]').hidden).toBe(true) expect(h.controller.mounted.getSnapshot()).toBeUndefined() - // The opener waits until the foreground seat republishes its navigation binding. + // Returning names the Session before the Conversation renders, so the + // opener's effect in that commit opens into the Session's store. act(() => { h.runtime.panelInfo.set({ activePanelId: null }) }) expect(h.controller.mounted.getSnapshot()).toBe(SESSION) expect(h.opened).toHaveLength(2) @@ -343,6 +347,47 @@ describe('RightbarSeat presentation', () => { expect(document.querySelector('[data-slot-error]')).toBeNull() }) + it('keeps the on-screen Session through its own store commits', async () => { + const h = await mountSeat(1440, true, 0, true) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + const published: (SessionId | undefined)[] = [] + const stop = h.controller.mounted.subscribe(() => { published.push(h.controller.mounted.getSnapshot()) }) + try { + // The opener mounts in the commit that also carries a store commit of the + // seat, and its effect runs before the seat's own effects. + act(() => { + h.actions.toggleExpanded(SESSION) + h.arm(true) + }) + expect(published).toEqual([]) + expect(h.opened).toHaveLength(1) + expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toContain(h.opened[0]) + expect(errors.mock.calls.map(call => String(call[0])).filter(text => text.includes('slot entry crashed'))).toEqual([]) + } finally { stop() } + }) + + it('names the arriving Session before a Conversation opener acts in the commit that switches Sessions', async () => { + const h = await mountSeat(1440, true, 0, true) + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + // The opener holds its own reference, as the Conversation does for its Session. + const conversation = h.runtime.sessions.retainFor(h.runtime.ctx, SESSION) + act(() => { h.openerView().update({ armed: false }, { session: conversation }) }) + await act(async () => { await h.runtime.sessions.add({ id: OTHER }) }) + await act(async () => { h.selectSession(OTHER) }) + expect(h.controller.mounted.getSnapshot()).toBe(OTHER) + // Switching back swaps the seats in the commit that mounts the opener: the + // departing seat leaves, and the arriving one renders after the opener. + await act(async () => { + h.selectSession(SESSION) + h.openerView().update({ armed: true }, { session: conversation }) + }) + expect(h.controller.mounted.getSnapshot()).toBe(SESSION) + expect(h.opened).toHaveLength(1) + expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toContain(h.opened[0]) + expect(errors.mock.calls.map(call => String(call[0])).filter(text => text.includes('slot entry crashed'))).toEqual([]) + conversation.release() + }) + it.each([0, 1, 2])('selects the default from %i guide entries and protects only a sole guide', async (entryCount) => { const h = await mountSeat(1440, true, entryCount) act(() => { h.controller.toggleExpanded() }) @@ -465,6 +510,22 @@ describe('RightbarSeat presentation', () => { expect(h.layout().expanded).toBe(false) }) + it('applies the automatic fullscreen rule of the width it renders at to the fullscreen command', async () => { + const h = await mountSeat() + h.open() + // Narrowed below the automatic fullscreen width, the command closes the panel. + h.view.update({ width: 420, viewportWidth: 700, canShow: false }) + fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + expect(h.layout().expanded).toBe(false) + expect(h.layout().mode).toBe('push') + // Widened again, the command switches the recorded mode. + h.view.update({ width: 420, viewportWidth: 1440, canShow: true }) + act(() => { h.controller.toggleExpanded() }) + fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]')) + expect(h.layout().expanded).toBe(true) + expect(h.layout().mode).toBe('fullscreen') + }) + it('preserves manual fullscreen through narrow and wide viewport changes', async () => { const h = await mountSeat() h.open() diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 65b520ce97..9dd8979cea 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -2,8 +2,8 @@ * What `ctx.sidebarRight` promises other plugins. * * The service is root-scoped and the surface is per session, so every command - * depends on a binding the mounted seat publishes; the interesting cases are all - * about that seam. Opening is asserted against a real store instance, because + * acts on the Session the plugin names as on screen, through the store the + * runtime minted for it; the interesting cases are all about that seam. Opening is asserted against a real store instance, because * "already open" and "in that tab's place" mean whatever the planner means by * them and nothing else, and against the Tab domain, because an open is not * complete until the tab knows how it was navigated to. @@ -31,7 +31,7 @@ const SESSION = 's-test' as SessionId /** Key-echoing translate: this file asserts behaviour, not copy. */ const t = ((key: string) => key) as Parameters[0] -/** A controller over a real registry and a real store, bound as a mounted seat would be. */ +/** A controller over a real registry and a real store, adopted as the runtime mints it. */ function harness() { const ctx = new Context() const tabs = new SidebarRightTabRegistry(ctx) @@ -47,25 +47,26 @@ function harness() { title: address => address.slice(address.lastIndexOf('/') + 1), }) const pin = vi.fn<(address: string, signal: AbortSignal) => void>() - const { controller, adopt } = createSidebarRightController(tabs, pin) + const { controller, adopt, show, measure } = createSidebarRightController(tabs, pin, { + autoFullscreen: () => false, + openWithFocus: (_sessionId, open) => { open() }, + closeWithFocus: (_sessionId, _paneId, close) => { close() }, + }) const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create() + // The runtime mints the Session's store before any seat renders, and the plugin adopts it. + const unadopt = adopt(SESSION, instance) const layout = (): LayoutState => { const surface = instance.getSnapshot().bySession[SESSION] if (surface === undefined) throw new Error('expected a surface') return surface.layout } - /** Republish the way the seat does after each commit, and sync the domain the way it does too. */ /** The room rule's verdict the seat would report; a spec flips it to model a narrow pane. */ const room = { allowed: true } + /** Put the Session on screen the way the plugin does from the selection, with the room rule its seat reports. */ const publish = (): (() => void) => { - const surface = instance.getSnapshot().bySession[SESSION] - if (surface !== undefined) controller.tabDomain.sync(SESSION, surface.layout) - return controller.bind({ - sessionId: SESSION, actions: instance.actions, surfaces: instance.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => room.allowed, - }) + show(SESSION) + measure(SESSION, () => room.allowed) + return () => { show(undefined) } } const titles = (): string[] => { const surface = instance.getSnapshot().bySession[SESSION] @@ -79,7 +80,7 @@ function harness() { const entries = (): number => instance.getSnapshot().bySession[SESSION]?.history.entries.length ?? 0 // A surface starts empty; expanding seeds the default guide ('seed'). const expand = (): void => { instance.actions.setExpanded(SESSION, true) } - return { controller, adopt, tabs, instance, pin, publish, titles, tabOf, layout, entries, room, expand } + return { controller, adopt, unadopt, show, measure, tabs, instance, pin, publish, titles, tabOf, layout, entries, room, expand } } describe('SidebarRightController — opening', () => { @@ -102,7 +103,7 @@ describe('SidebarRightController — opening', () => { } finally { release() } }) - it('refuses every write while no seat is mounted', () => { + it('refuses every write while no Session is on screen', () => { const { controller } = harness() expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') expect(() => { controller.openTab('guide') }).toThrow('no session surface is mounted') @@ -114,21 +115,19 @@ describe('SidebarRightController — opening', () => { expect(() => { controller.dock('pane1' as PaneId) }).toThrow('no session surface is mounted') }) - it('publishes the mounted session only on real transitions of the seat binding', () => { + it('publishes the on-screen Session only when it changes', () => { const h = harness() const seen: (string | undefined)[] = [] const unsubscribe = h.controller.mounted.subscribe(() => { seen.push(h.controller.mounted.getSnapshot()) }) try { expect(h.controller.mounted.getSnapshot()).toBeUndefined() - const first = h.publish() + h.show(SESSION) expect(h.controller.mounted.getSnapshot()).toBe(SESSION) - // The seat republishes on every store commit; the same session is silent. - const second = h.publish() + // Naming the same Session again, and the Session's own store commits, are silent. + h.show(SESSION) + h.expand() expect(seen).toEqual([SESSION]) - // A stale release — the first seat's, after a newer one took over — changes nothing. - first() - expect(h.controller.mounted.getSnapshot()).toBe(SESSION) - second() + h.show(undefined) expect(h.controller.mounted.getSnapshot()).toBeUndefined() expect(seen).toEqual([SESSION, undefined]) } finally { unsubscribe() } @@ -519,25 +518,14 @@ describe('SidebarRightController — a tab\'s own actions', () => { const B_TXT = 'dsh-resource://file/session/s-test/b.txt' it('opens into an adopted store before that store has created its session surface', () => { - const { controller, adopt, instance, titles } = harness() - const releaseOwn = adopt(SESSION, instance) - const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER) - const releaseBinding = controller.bind({ - sessionId: OTHER, - actions: other.actions, - surfaces: other.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, canSplitPane: () => true, - }) + const { controller, show, titles } = harness() + show(OTHER) controller.openResourceIn(SESSION, A_TXT) expect(titles()).toContain('a.txt') - releaseBinding() - releaseOwn() }) - it('land in the session the tab is in through its own adopted store, after another session\'s seat took over', () => { - const { controller, adopt, instance, publish, layout, expand } = harness() - const releaseOwn = adopt(SESSION, instance) + it('land in the session the tab is in through its own adopted store, after another session came on screen', () => { + const { controller, adopt, show, publish, layout, expand } = harness() expand() publish() controller.openResource(A_TXT) @@ -546,7 +534,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { if (own === undefined || guide === undefined) throw new Error('expected the opened tab and the seeded guide') const fromOwn = controller.tabDomain.occurrence(SESSION, own).tabActions const guideOccurrence = controller.tabDomain.occurrence(SESSION, guide) - // The user switches sessions: the other seat binds with the other session's + // The user switches sessions: the other Session comes on screen with its // own instance, whose store knows nothing of this session. const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER) const releaseOther = adopt(OTHER, other) @@ -554,12 +542,7 @@ describe('SidebarRightController — a tab\'s own actions', () => { other.actions.setExpanded(OTHER, true) const otherSurface = other.getSnapshot().bySession[OTHER] if (otherSurface === undefined) throw new Error('expected the other surface') - controller.bind({ - sessionId: OTHER, actions: other.actions, surfaces: other.getSnapshot().bySession, - closeWithFocus: (_paneId, close) => { close() }, - openWithFocus: (open) => { open() }, - canSplitPane: () => true, - }) + show(OTHER) fromOwn.openResource(B_TXT) fromOwn.openTab('guide', { revealIfOpened: false }) expect(Object.values(layout().tabs).map(tab => tab.title)).toContain('b.txt') @@ -574,17 +557,17 @@ describe('SidebarRightController — a tab\'s own actions', () => { expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface) expect(controller.active()?.kind).toBe('guide') releaseOther() - releaseOwn() }) it('do nothing for a session whose store is not adopted, and again once its adoption is released', () => { - const { controller, adopt, instance, publish, layout, titles } = harness() + const { controller, adopt, unadopt, instance, publish, layout, titles } = harness() publish() controller.openResource(A_TXT) - publish() const own = Object.values(layout().tabs).find(tab => tab.title === 'a.txt') if (own === undefined) throw new Error('expected the opened tab') const { tabActions } = controller.tabDomain.occurrence(SESSION, own) + // The runtime disposed the Session's store generation. + unadopt() const before = instance.getSnapshot().bySession tabActions.openResource(B_TXT) tabActions.openTab('guide') @@ -661,17 +644,16 @@ describe('SidebarRightController — the readable slice', () => { }) }) -describe('SidebarRightController — binding lifetime', () => { - it('acts on the newest binding when a seat republishes', () => { +describe('SidebarRightController — the on-screen Session', () => { + it('acts on the on-screen Session through its adopted store', () => { const { controller, publish, layout } = harness() publish() controller.toggleExpanded() - publish() expect(controller.isExpanded()).toBe(true) expect(layout().expanded).toBe(true) }) - it('goes back to refusing writes once the seat releases', () => { + it('goes back to refusing writes once no Session is on screen', () => { const { controller, publish } = harness() const release = publish() release() @@ -679,12 +661,20 @@ describe('SidebarRightController — binding lifetime', () => { expect(controller.isExpanded()).toBe(false) }) - it('a stale release does not clear a newer binding', () => { - const { controller, publish } = harness() - const stale = publish() - publish() - stale() - expect(() => { controller.toggleExpanded() }).not.toThrow() + it('refuses writes for an on-screen Session whose store the runtime has not minted', () => { + const { controller, show } = harness() + show('s-unminted' as SessionId) + expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted') + expect(controller.active()).toBeUndefined() + }) + + it('treats panes its seat has not measured as having room', () => { + const { controller, show, instance, layout, expand } = harness() + instance.actions.open(SESSION) + expand() + show(SESSION) + expect(controller.split()).toBeDefined() + expect(dockPaneIds(layout())).toHaveLength(2) }) })