Преглед на файлове

Merge pull request #3784 from deepseek-harness/worktree-leftsideslot

feat(web): add panellist to left-sidebar
imccyu преди 2 седмици
родител
ревизия
bfe82d4c2d
променени са 100 файла, в които са добавени 1917 реда и са изтрити 574 реда
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml
  2. 3 1
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
  3. 3 1
      .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md
  4. 6 0
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml
  5. 37 0
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md
  6. 37 0
      .agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md
  7. 13 5
      apps/web/tests/lifecycle-chrome.e2e.ts
  8. 27 13
      apps/web/tests/settings-chrome.e2e.ts
  9. 2 2
      docs/subsystems/sidebar-right.i18n.yaml
  10. 1 1
      docs/subsystems/sidebar-right.md
  11. 1 1
      docs/subsystems/sidebar-right.zh.md
  12. 2 2
      docs/subsystems/slots.i18n.yaml
  13. 40 36
      docs/subsystems/slots.md
  14. 40 36
      docs/subsystems/slots.zh.md
  15. 2 1
      packages/client/locale/tests/language-row.client.spec.tsx
  16. 2 1
      packages/client/ui-attachment/tests/message-image.client.spec.tsx
  17. 7 2
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  18. 9 3
      packages/client/ui-chat/tests/chat-apply.client.spec.tsx
  19. 1 0
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  20. 1 0
      packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
  21. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  22. 3 1
      packages/client/ui-conversation/README.md
  23. 3 1
      packages/client/ui-conversation/README.zh.md
  24. 15 9
      packages/client/ui-conversation/src/client/apply.ts
  25. 3 1
      packages/client/ui-conversation/src/client/contract/slots.ts
  26. 12 0
      packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx
  27. 11 4
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  28. 20 8
      packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
  29. 32 7
      packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
  30. 1 0
      packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
  31. 1 0
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  32. 1 0
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  33. 1 0
      packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
  34. 2 0
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  35. 4 0
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  36. 2 2
      packages/client/ui-layout/README.i18n.yaml
  37. 7 3
      packages/client/ui-layout/README.md
  38. 7 3
      packages/client/ui-layout/README.zh.md
  39. 1 0
      packages/client/ui-layout/package.json
  40. 36 44
      packages/client/ui-layout/src/client/AppFrame.tsx
  41. 9 4
      packages/client/ui-layout/src/client/DocumentTitle.tsx
  42. 42 34
      packages/client/ui-layout/src/client/index.ts
  43. 55 28
      packages/client/ui-layout/src/client/service.ts
  44. 53 31
      packages/client/ui-layout/src/client/stores.ts
  45. 64 46
      packages/client/ui-layout/tests/app-frame.client.spec.tsx
  46. 86 18
      packages/client/ui-layout/tests/apply.client.spec.ts
  47. 60 14
      packages/client/ui-layout/tests/document-title.client.spec.tsx
  48. 115 49
      packages/client/ui-layout/tests/layout-store.client.spec.ts
  49. 60 19
      packages/client/ui-layout/tests/service.client.spec.ts
  50. 3 0
      packages/client/ui-layout/tsconfig.json
  51. 2 1
      packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
  52. 2 1
      packages/client/ui-settings-general/tests/components.client.spec.tsx
  53. 2 1
      packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
  54. 2 1
      packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx
  55. 2 1
      packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx
  56. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  57. 2 0
      packages/client/ui-sidebar-right/README.md
  58. 2 0
      packages/client/ui-sidebar-right/README.zh.md
  59. 3 0
      packages/client/ui-sidebar-right/src/client/contract/slots.ts
  60. 22 15
      packages/client/ui-sidebar-right/src/client/index.ts
  61. 2 2
      packages/client/ui-sidebar-right/src/client/service.ts
  62. 20 0
      packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx
  63. 2 3
      packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx
  64. 15 14
      packages/client/ui-sidebar-right/tests/apply.client.spec.ts
  65. 16 2
      packages/client/ui-sidebar-right/tests/seat.client.spec.tsx
  66. 2 2
      packages/client/ui-sidebar/README.i18n.yaml
  67. 6 2
      packages/client/ui-sidebar/README.md
  68. 6 2
      packages/client/ui-sidebar/README.zh.md
  69. 1 0
      packages/client/ui-sidebar/package.json
  70. 71 1
      packages/client/ui-sidebar/src/client/SidebarRoot.module.css
  71. 60 4
      packages/client/ui-sidebar/src/client/SidebarRoot.tsx
  72. 36 10
      packages/client/ui-sidebar/src/client/contract/slots.ts
  73. 40 22
      packages/client/ui-sidebar/src/client/index.ts
  74. 3 1
      packages/client/ui-sidebar/src/client/locales.ts
  75. 81 9
      packages/client/ui-sidebar/tests/apply.client.spec.tsx
  76. 276 0
      packages/client/ui-sidebar/tests/panel-list.client.spec.tsx
  77. 2 0
      packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx
  78. 5 0
      packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx
  79. 3 0
      packages/client/ui-sidebar/tsconfig.json
  80. 2 1
      packages/client/ui-theme/tests/appearance-row.client.spec.tsx
  81. 2 1
      packages/client/ui-theme/tests/font-size-row.client.spec.tsx
  82. 9 4
      packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx
  83. 14 5
      packages/client/ui-tool/tests/toolview-slot.client.spec.tsx
  84. 3 2
      packages/client/ui-trajectory/tests/views.client.spec.tsx
  85. 2 1
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  86. 2 1
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  87. 2 1
      packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx
  88. 2 2
      packages/client/ui-workspace/README.i18n.yaml
  89. 2 0
      packages/client/ui-workspace/README.md
  90. 2 0
      packages/client/ui-workspace/README.zh.md
  91. 2 0
      packages/client/ui-workspace/package.json
  92. 7 4
      packages/client/ui-workspace/src/client/index.ts
  93. 46 7
      packages/client/ui-workspace/src/client/navigation.ts
  94. 16 7
      packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
  95. 4 2
      packages/client/ui-workspace/tests/apply.client.spec.ts
  96. 1 0
      packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx
  97. 1 0
      packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx
  98. 27 1
      packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
  99. 4 3
      packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx
  100. 132 1
      packages/client/ui-workspace/tests/workspaces-service.client.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md
-2026-07-22-slot-type-chain-implementation.md: 0e493a19e232acf6f289714e53e0cf64b3ffc4b0
-2026-07-22-slot-type-chain-implementation.zh.md: 5e5c2ec808cec6949396a333c6a86e267bb4268b
+2026-07-22-slot-type-chain-implementation.md: 98585a42f71844594da4ada19d7438a7d8321702
+2026-07-22-slot-type-chain-implementation.zh.md: d1093537ccbc5ee5ff2dc18c234233ffca39879d

+ 3 - 1
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md

@@ -12,6 +12,8 @@ The page is composed at runtime from independently loaded plugins, so the UI nee
 
 ## Decision
 
+Global main-panel selection and its root lifetime are defined by the [global main-panels decision](2026-09-08-global-main-panels.md).
+
 One sentence: **the ui-renderer renders only `'root'`; a plugin composes UI through a single `register` call that simultaneously occupies a slot, declares+authorizes its child slots, declares its store, and injects its business face; components are pure functions whose props arrive in four shares, each auto-derived from its single source of truth.**
 
 ### 'root' is the only a-priori slot
@@ -25,7 +27,7 @@ ctx.slots.register({
   name: 'root',
   children: {
     'sidebar':      { kind: 'single', scope: 'root' },
-    'conversation': { kind: 'single', scope: 'session' },
+    'main':         { kind: 'keyed', scope: 'root' },
   },
   store: createLayoutStore,      // StoreHandle or factory (below)
   inject: injectFrame,           // business face (below)

+ 3 - 1
.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md

@@ -12,6 +12,8 @@ Status: implemented
 
 ## 决策
 
+全局主面板选择及其 root 生命周期由[全局主面板决策](2026-09-08-global-main-panels.zh.md)定义。
+
 一句话:**ui-renderer 只渲染 `'root'`;插件用单独一次 `register` 调用组合 UI——这一次调用同时占用 slot、声明并授权子 slot、声明 store、注入业务面;组件是纯函数,props 分四份额到达,每一份额都从各自唯一的真源自动推导。**
 
 ### 'root' 是唯一的先验 slot
@@ -25,7 +27,7 @@ ctx.slots.register({
   name: 'root',
   children: {
     'sidebar':      { kind: 'single', scope: 'root' },
-    'conversation': { kind: 'single', scope: 'session' },
+    'main':         { kind: 'keyed', scope: 'root' },
   },
   store: createLayoutStore,      // StoreHandle or factory (below)
   inject: injectFrame,           // business face (below)

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-08-global-main-panels.md
+2026-09-08-global-main-panels.md: 75be68ac1bf6dceb812a5aedbca74ce58df929b3
+2026-09-08-global-main-panels.zh.md: 343f183367a5cfbd13127e32542f689c50306c89

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.md

@@ -0,0 +1,37 @@
+# Agent Note: Global main panels without default UI additions
+
+Status: implemented
+
+English | [中文](2026-09-08-global-main-panels.zh.md)
+
+## Problem
+
+Plugins need application-wide views that do not belong to a Session. A Session-scoped Conversation view cannot provide that lifetime, and replacing the Conversation's single slot removes the ordinary conversation surface. Adding this extension must not add navigation controls or reserved space to the default application.
+
+## Decision
+
+The layout declares a root-scoped keyed `main` slot. The reserved `conversation` key belongs to the Conversation plugin, whose `main.conversation` child retains optional-Session binding. Other main entries receive no implicit Session binding.
+
+The sidebar owns the root-scoped `sidebar.panellist` list. Each list entry supplies its icon and an id matching its main entry; its string or locale-aware label provides plain visible text, the accessible name, and the collapsed tooltip. The shipped composition registers no panel entry, so the empty list has no DOM or spacing. Selection validates the live main entry and rejects a missing key without replacing the current panel.
+
+One eagerly created root store is shared by the renderer and layout controller. Its `panelInfo` and `layoutInfo` objects preserve independent references. The framework supplies `usePanelInfo`; individual rows and main content subscribe to their required selection values, while AppFrame reads only layout information. The right Sidebar's root controller decides whether to mount its Session subtree and reports the resulting track requirements to the frame.
+
+`uiWorkspace.openSession(id)` selects the Session before returning the main area to the Conversation, including when the same Session is selected again. `openWorkspace` and `forkSession` use the layout's `beginNavigation()` abort signal and their own service lifetime to commit only the latest navigation. The Workspace preparation callback moves drafts synchronously only while the request remains current. Supersession prevents a late UI commit, not Session creation. Panel navigation neither cancels the retained Session nor writes a Session event.
+
+DOM focus is not navigation selection. Search and directory-picker controls can receive focus while the global panel and its selected sidebar row remain visible; opening a Session changes the main selection.
+
+## Alternatives considered
+
+**Session-scoped main views.** Their lifetime and standard props bind application-wide state to whichever Session happens to be current.
+
+**A second navigation stack.** Back buttons and saved return destinations are unnecessary when New Session and workspace Session rows already provide explicit destinations.
+
+**React title slots.** Navigation entries use the same plain label for visible text and accessibility; a separate title registration is outside that presentation.
+
+**Flat selection and layout state with shallow comparison.** Separating the two stored objects preserves reference equality directly and avoids allocating and comparing a fresh layout projection on every panel selection.
+
+## Consequences
+
+The default sidebar snapshots remain unchanged. Extension panels have no right Sidebar, and selecting a different global panel does not change layout preferences. Switching between a Conversation with a visible right Sidebar and a global panel still changes the required column widths; this is not a promise of zero browser layout work.
+
+Panel selection is transient and resets on reload. Plugin disposal removes its contributions; removing the selected main entry returns the main area to the Conversation. Tests register real temporary panels and cover row interaction, focus, independent stored references, invalid ids, superseded asynchronous navigation, declaration lifetimes, and the empty default sidebar. The [Slots reference](../../../../docs/subsystems/slots.md) owns the composition API.

+ 37 - 0
.agents/notes/implemented/architecture/2026-09-08-global-main-panels.zh.md

@@ -0,0 +1,37 @@
+# Agent Note: 不增加默认界面的全局主面板
+
+Status: implemented
+
+[English](2026-09-08-global-main-panels.md) | 中文
+
+## 问题
+
+插件需要不属于任何会话的应用级视图。会话作用域的 Conversation 视图无法提供这种生命周期,而替换 Conversation 的 single slot 又会移除普通会话界面。增加此扩展不能在默认应用中增加导航控件或预留空间。
+
+## 决策
+
+布局声明 root 作用域的 keyed `main` slot。保留的 `conversation` key 属于 Conversation 插件,其 `main.conversation` 子 slot 保留可选的会话绑定。其他主面板条目不获得隐式会话绑定。
+
+侧栏拥有 root 作用域的 `sidebar.panellist` list。每个 list 条目提供图标,以及与主面板条目匹配的 id;字符串或随语言变化的标签提供普通可见文字、无障碍名称和折叠提示。默认组合不注册面板条目,因此空列表没有 DOM 或间距。选中操作检查实时主面板条目,对缺失的 key 报错而不替换当前面板。
+
+渲染器与布局控制器共享一个直接创建的 root 存储。其 `panelInfo` 和 `layoutInfo` 对象保持独立的引用。框架提供 `usePanelInfo`;各行和中央内容订阅所需的选中态值,AppFrame 仅读取布局信息。右侧 Sidebar 的 root 控制器决定是否挂载其会话子树,并把最终所需的列宽报告给框架。
+
+`uiWorkspace.openSession(id)` 先选中会话,再将中央区域切回 Conversation,包括再次选中同一个会话的情况。`openWorkspace` 和 `forkSession` 使用布局的 `beginNavigation()` abort signal 与自身 service 生命周期,只提交最新导航。工作区准备回调仅在请求仍有效时同步搬移草稿。请求过期会阻止晚到的 UI 提交,但不阻止会话创建。面板导航既不取消保留的会话,也不写入会话事件。
+
+DOM 焦点不是导航选中态。搜索和目录选择控件可以获得焦点,同时保留全局面板及其侧栏行的选中态;打开会话才改变中央区域的选中态。
+
+## 考虑过的替代方案
+
+**会话作用域的主视图。** 其生命周期和标准 props 会把应用级状态绑定到恰好处于当前态的会话。
+
+**另一套导航栈。** 新会话和工作区会话行已经提供明确目标,不需要返回按钮或保存返回目的地。
+
+**React 标题 slot。** 导航条目的可见文字与无障碍名称使用同一个普通标签;独立的标题注册不属于这一呈现方式。
+
+**平铺选中态和布局状态,再做浅比较。** 将两者存为独立对象可以直接保持引用相等,避免每次选择面板都分配并比较新的布局投影。
+
+## 后果
+
+默认侧栏快照保持不变。扩展面板没有右侧 Sidebar,选择另一个全局面板不会改变布局偏好。在显示右侧 Sidebar 的 Conversation 与全局面板之间切换时,所需列宽仍会变化;这并不保证浏览器完全不计算布局。
+
+面板选中态是瞬时状态,刷新后重置。插件 dispose(资源释放)会移除其贡献;移除当前选中的主面板条目会使中央区域回到 Conversation。测试注册真实临时面板,覆盖行交互、焦点、存储引用的独立性、无效 id、过期异步导航、声明生命周期和默认空侧栏。[Slots 参考](../../../../docs/subsystems/slots.zh.md)拥有组合 API 的说明。

+ 13 - 5
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -15,7 +15,7 @@ import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
 import type { Browser, Page, WebSocketRoute } from 'playwright'
 import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
   acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria,
@@ -72,10 +72,17 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
 
   it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
+    const input = page.locator('[data-composer-input]').first()
+    onTestFinished(async () => {
+      await input.press('Escape')
+      await writeComposerDraft(page, input, '')
+      await page.getByRole('listbox', { name: 'Trigger suggestions' }).waitFor({ state: 'hidden' })
+    })
     const launcher = page.getByRole('button', { name: 'Commands' })
     await launcher.click()
     const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
-    await menu.waitFor({ timeout: 10_000 })
+    await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
+    await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 })
     const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
     expect(snapshot).toContain('text: Commands')
@@ -84,9 +91,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     const launchedBox = await menu.boundingBox()
     await page.locator('[data-composer-input]').first().press('Escape')
     await expect.poll(() => menu.count()).toBe(0)
-    const input = page.locator('[data-composer-input]').first()
     await writeComposerDraft(page, input, '/')
-    await menu.waitFor({ timeout: 10_000 })
+    await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
+    await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 })
     const typedBox = await menu.boundingBox()
     expect(launchedBox).not.toBeNull()
     expect(typedBox).not.toBeNull()
@@ -114,7 +121,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
       const launcher = zhPage.getByRole('button', { name: '指令' })
       await launcher.click()
       const menu = zhPage.getByRole('listbox', { name: '触发候选建议' })
-      await menu.waitFor({ timeout: 10_000 })
+      await menu.getByRole('option').first().waitFor({ timeout: 10_000 })
+      await menu.getByRole('status').waitFor({ state: 'hidden', timeout: 10_000 })
       const snapshot = await captureStableAria(zhPage, '[role="listbox"]', scaffold.workspaceCwd)
       await compareOrRefreshGolden(COMMAND_MENU_ZH_EXPECTED, snapshot, MODE)
       expect(zhTripwire.pageErrors).toEqual([])

+ 27 - 13
apps/web/tests/settings-chrome.e2e.ts

@@ -12,7 +12,7 @@ import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import type { Browser, Locator, Page } from 'playwright'
 import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest'
 import { join } from 'node:path'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import {
@@ -378,6 +378,10 @@ describe('web e2e: settings modal and General preferences', () => {
 
   it('steps the content font size, applies it to body, and persists across reload', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-font-size'))
+    onTestFinished(async () => {
+      await page.keyboard.press('Escape')
+      await page.getByRole('dialog', { name: '设置', exact: true }).waitFor({ state: 'hidden' })
+    })
     const readFontSize = async (target: Page = page): Promise<string> => await target.evaluate(
       () => document.body.style.getPropertyValue('--dsh-content-font-size'),
     )
@@ -392,6 +396,24 @@ describe('web e2e: settings modal and General preferences', () => {
       probe.remove()
       return size
     })
+    // The displayed value is optimistic; wait for the write before the next step.
+    const stepFontSize = async (button: Locator, px: number): Promise<void> => {
+      const [response] = await Promise.all([
+        page.waitForResponse((reply) => {
+          if (new URL(reply.url()).pathname !== '/api/settings/mutate' || reply.request().method() !== 'POST') return false
+          const request = reply.request().postDataJSON() as { payload: { args: { ns: string } } }
+          return request.payload.args.ns === 'ui-theme'
+        }),
+        button.click(),
+      ])
+      expect(await response.finished()).toBeNull()
+      const envelope = await response.json() as { result: { ok: boolean } }
+      expect(envelope.result.ok).toBe(true)
+      await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
+        .toMatch(new RegExp(`ui-theme:\n(?:\\s+\\w+: .*\n)*?\\s+fontSize: ${px}`))
+      await page.getByRole('dialog', { name: '设置' }).getByText(String(px), { exact: true }).waitFor({ timeout: 5_000 })
+      await expect.poll(readFontSize, { timeout: 5_000 }).toBe(`${px}px`)
+    }
     expect(await readFontSize()).toBe('14px')
     expect(await readSecondaryFontSize()).toBe('13px')
     await page.getByRole('button', { name: '设置', exact: true }).click()
@@ -400,17 +422,12 @@ describe('web e2e: settings modal and General preferences', () => {
     // The stepper reveals its arrows on hover; the up arrow steps 14 → 15 → 16.
     await dialog.getByText('14', { exact: true }).hover()
     const increase = dialog.getByRole('button', { name: '增大字号' })
-    await increase.click()
-    await dialog.getByText('15', { exact: true }).waitFor({ timeout: 5_000 })
+    await stepFontSize(increase, 15)
     // 15 is the piecewise boundary: the secondary tier holds at 13px (−2)
     // where the ≤14 branch would have given 14px (−1).
     await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('13px')
-    await increase.click()
-    await dialog.getByText('16', { exact: true }).waitFor({ timeout: 5_000 })
-    await expect.poll(readFontSize, { timeout: 5_000 }).toBe('16px')
+    await stepFontSize(increase, 16)
     await expect.poll(readSecondaryFontSize, { timeout: 5_000 }).toBe('14px')
-    await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
-      .toMatch(/ui-theme:\n(?:\s+\w+: .*\n)*?\s+fontSize: 16/)
     await page.keyboard.press('Escape')
 
     // Reload: the boot script embeds the durable size and ThemeRuntime seeds
@@ -429,11 +446,8 @@ describe('web e2e: settings modal and General preferences', () => {
     await restored.waitFor({ timeout: 10_000 })
     await restored.getByText('16', { exact: true }).hover()
     const decrease = restored.getByRole('button', { name: '减小字号' })
-    await decrease.click()
-    await restored.getByText('15', { exact: true }).waitFor({ timeout: 5_000 })
-    await decrease.click()
-    await restored.getByText('14', { exact: true }).waitFor({ timeout: 5_000 })
-    await expect.poll(readFontSize, { timeout: 5_000 }).toBe('14px')
+    await stepFontSize(decrease, 15)
+    await stepFontSize(decrease, 14)
     await page.keyboard.press('Escape')
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)

+ 2 - 2
docs/subsystems/sidebar-right.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md
-sidebar-right.md: db4103a282287d8ea163d66b19efec27e0a0630f
-sidebar-right.zh.md: 1cb0b1e67e99acd7cb6aa12272cd57a5fdd207e0
+sidebar-right.md: 59eda91862282d240d49764d53c17f531ff4bf21
+sidebar-right.zh.md: 3ecb5e9ef496471316414b6aeb0c683a78188025

+ 1 - 1
docs/subsystems/sidebar-right.md

@@ -8,7 +8,7 @@ This page is the reference for the subsystem's contracts: addresses, tab-type re
 
 ## Position and ownership
 
-One docking surface exists per Session, held in a session-scoped slot store and drawn by the `rightbar` seat; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab.
+One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab.
 
 A tab type is two registrations that share one `kind`: a static definition in `ctx.sidebarRightTabs` saying which addresses the type opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types.
 

+ 1 - 1
docs/subsystems/sidebar-right.zh.md

@@ -8,7 +8,7 @@
 
 ## 定位与归属
 
-每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar` 席位绘制;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。
+每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。
 
 一个 tab 类型是共用一个 `kind` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明该类型打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。
 

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

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

+ 40 - 36
docs/subsystems/slots.md

@@ -82,6 +82,7 @@ The shipped adapters add these standard props. They are available according to t
 |---|---|---|
 | every scope | `useSessions`, `useSessionPendingInteraction` | `ui-session` |
 | every scope | `useWorkspaces` | `ui-workspace` |
+| every scope | `usePanelInfo` | `ui-layout` |
 | `session` | `sessionId`, `useSession`, `useProjection` | `ui-session` |
 | `session-maybe` | optional `sessionId`, `useSession`, `useProjection` results | `ui-session` |
 | `session` | `useConversation`, `useInput`, `inputActions` | `ui-conversation` |
@@ -112,6 +113,7 @@ root
 ├─ sidebar
 │  ├─ sidebar.brand.mark
 │  ├─ sidebar.brand.name
+│  ├─ sidebar.panellist
 │  ├─ sidebar.footer.action
 │  ├─ sidebar.workspaces
 │  │  └─ sidebar.workspaces.directoryFlow
@@ -127,43 +129,45 @@ root
 │        ├─ settings.models.footer
 │        └─ settings.plugins.tab
 │           └─ settings.plugin.item
-├─ conversation
-│  ├─ conversation.session
-│  │  └─ conversation.view
-│  │     ├─ conversation.chat.node
-│  │     │  ├─ conversation.chat.assistant-actions
-│  │     │  ├─ conversation.chat.commandview
-│  │     │  ├─ conversation.chat.turnTail
-│  │     │  └─ tool.call.toolview
-│  │     │     ├─ tool.call.images
-│  │     │     └─ tool.view.cordis
-│  │     ├─ conversation.message.images
-│  │     └─ conversation.trajectory.images
-│  ├─ conversation.session.header
-│  │  ├─ conversation.session.header.lineage
-│  │  ├─ conversation.session.header.actions
-│  │  ├─ conversation.session.header.utilities
-│  │  └─ conversation.session.header.corner
-│  ├─ conversation.composer
-│  │  └─ conversation.approval.detail
-│  ├─ conversation.composer.bar
-│  │  ├─ conversation.input.attachments
-│  │  ├─ conversation.input.plan
-│  │  └─ conversation.input.model
-│  ├─ conversation.input.overlay
-│  ├─ conversation.input.dock
-│  ├─ conversation.composer.dock
-│  ├─ conversation.input.left
-│  ├─ conversation.input.right
-│  ├─ conversation.hero.brand.mark
-│  ├─ conversation.hero.workspace
-│  │  └─ conversation.hero.workspace.directoryFlow
-│  └─ conversation.hero.agentPreset
+├─ main
+│  └─ main.conversation
+│     ├─ conversation.session
+│     │  └─ conversation.view
+│     │     ├─ conversation.chat.node
+│     │     │  ├─ conversation.chat.assistant-actions
+│     │     │  ├─ conversation.chat.commandview
+│     │     │  ├─ conversation.chat.turnTail
+│     │     │  └─ tool.call.toolview
+│     │     │     ├─ tool.call.images
+│     │     │     └─ tool.view.cordis
+│     │     ├─ conversation.message.images
+│     │     └─ conversation.trajectory.images
+│     ├─ conversation.session.header
+│     │  ├─ conversation.session.header.lineage
+│     │  ├─ conversation.session.header.actions
+│     │  ├─ conversation.session.header.utilities
+│     │  └─ conversation.session.header.corner
+│     ├─ conversation.composer
+│     │  └─ conversation.approval.detail
+│     ├─ conversation.composer.bar
+│     │  ├─ conversation.input.attachments
+│     │  ├─ conversation.input.plan
+│     │  └─ conversation.input.model
+│     ├─ conversation.input.overlay
+│     ├─ conversation.input.dock
+│     ├─ conversation.composer.dock
+│     ├─ conversation.input.left
+│     ├─ conversation.input.right
+│     ├─ conversation.hero.brand.mark
+│     ├─ conversation.hero.workspace
+│     │  └─ conversation.hero.workspace.directoryFlow
+│     └─ conversation.hero.agentPreset
 ├─ rightbar
-│  ├─ sidebar.right.pane.tab
-│  │  └─ sidebar.right.tab.guide
-│  ├─ sidebar.right.pane.tab.title
-│  └─ sidebar.right.tab.menu.item
+│  └─ rightbar.session
+│     ├─ sidebar.right.pane.tab
+│     │  └─ sidebar.right.tab.guide
+│     ├─ sidebar.right.pane.tab.title
+│     └─ sidebar.right.tab.menu.item
 └─ shell.overlay
 ```
 

+ 40 - 36
docs/subsystems/slots.zh.md

@@ -82,6 +82,7 @@ Slot 声明固定两个相互独立的维度。
 |---|---|---|
 | 所有 scope | `useSessions`、`useSessionPendingInteraction` | `ui-session` |
 | 所有 scope | `useWorkspaces` | `ui-workspace` |
+| 所有作用域 | `usePanelInfo` | `ui-layout` |
 | `session` | `sessionId`、`useSession`、`useProjection` | `ui-session` |
 | `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection` | `ui-session` |
 | `session` | `useConversation`、`useInput`、`inputActions` | `ui-conversation` |
@@ -112,6 +113,7 @@ root
 ├─ sidebar
 │  ├─ sidebar.brand.mark
 │  ├─ sidebar.brand.name
+│  ├─ sidebar.panellist
 │  ├─ sidebar.footer.action
 │  ├─ sidebar.workspaces
 │  │  └─ sidebar.workspaces.directoryFlow
@@ -127,43 +129,45 @@ root
 │        ├─ settings.models.footer
 │        └─ settings.plugins.tab
 │           └─ settings.plugin.item
-├─ conversation
-│  ├─ conversation.session
-│  │  └─ conversation.view
-│  │     ├─ conversation.chat.node
-│  │     │  ├─ conversation.chat.assistant-actions
-│  │     │  ├─ conversation.chat.commandview
-│  │     │  ├─ conversation.chat.turnTail
-│  │     │  └─ tool.call.toolview
-│  │     │     ├─ tool.call.images
-│  │     │     └─ tool.view.cordis
-│  │     ├─ conversation.message.images
-│  │     └─ conversation.trajectory.images
-│  ├─ conversation.session.header
-│  │  ├─ conversation.session.header.lineage
-│  │  ├─ conversation.session.header.actions
-│  │  ├─ conversation.session.header.utilities
-│  │  └─ conversation.session.header.corner
-│  ├─ conversation.composer
-│  │  └─ conversation.approval.detail
-│  ├─ conversation.composer.bar
-│  │  ├─ conversation.input.attachments
-│  │  ├─ conversation.input.plan
-│  │  └─ conversation.input.model
-│  ├─ conversation.input.overlay
-│  ├─ conversation.input.dock
-│  ├─ conversation.composer.dock
-│  ├─ conversation.input.left
-│  ├─ conversation.input.right
-│  ├─ conversation.hero.brand.mark
-│  ├─ conversation.hero.workspace
-│  │  └─ conversation.hero.workspace.directoryFlow
-│  └─ conversation.hero.agentPreset
+├─ main
+│  └─ main.conversation
+│     ├─ conversation.session
+│     │  └─ conversation.view
+│     │     ├─ conversation.chat.node
+│     │     │  ├─ conversation.chat.assistant-actions
+│     │     │  ├─ conversation.chat.commandview
+│     │     │  ├─ conversation.chat.turnTail
+│     │     │  └─ tool.call.toolview
+│     │     │     ├─ tool.call.images
+│     │     │     └─ tool.view.cordis
+│     │     ├─ conversation.message.images
+│     │     └─ conversation.trajectory.images
+│     ├─ conversation.session.header
+│     │  ├─ conversation.session.header.lineage
+│     │  ├─ conversation.session.header.actions
+│     │  ├─ conversation.session.header.utilities
+│     │  └─ conversation.session.header.corner
+│     ├─ conversation.composer
+│     │  └─ conversation.approval.detail
+│     ├─ conversation.composer.bar
+│     │  ├─ conversation.input.attachments
+│     │  ├─ conversation.input.plan
+│     │  └─ conversation.input.model
+│     ├─ conversation.input.overlay
+│     ├─ conversation.input.dock
+│     ├─ conversation.composer.dock
+│     ├─ conversation.input.left
+│     ├─ conversation.input.right
+│     ├─ conversation.hero.brand.mark
+│     ├─ conversation.hero.workspace
+│     │  └─ conversation.hero.workspace.directoryFlow
+│     └─ conversation.hero.agentPreset
 ├─ rightbar
-│  ├─ sidebar.right.pane.tab
-│  │  └─ sidebar.right.tab.guide
-│  ├─ sidebar.right.pane.tab.title
-│  └─ sidebar.right.tab.menu.item
+│  └─ rightbar.session
+│     ├─ sidebar.right.pane.tab
+│     │  └─ sidebar.right.tab.guide
+│     ├─ sidebar.right.pane.tab.title
+│     └─ sidebar.right.tab.menu.item
 └─ shell.overlay
 ```
 

+ 2 - 1
packages/client/locale/tests/language-row.client.spec.tsx

@@ -12,6 +12,7 @@ import { createLanguageRowStore } from '../src/client/settings-store.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 afterEach(cleanup)
 
@@ -41,7 +42,7 @@ function mount(active = 'en') {
   const props: LanguageRowComponentProps = {
     useSessions: emptySessions(),
     useSessionPendingInteraction,
-    useResource,
+    usePanelInfo, useResource,
     useWorkspaces: emptyWorkspaces(),
     useStore: bindSnapshotSelector(store),
     actions: store.actions,

+ 2 - 1
packages/client/ui-attachment/tests/message-image.client.spec.tsx

@@ -12,6 +12,7 @@ import { MessageImages } from '../src/client/MessageImages.tsx'
 
 // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 afterEach(cleanup)
 
@@ -272,7 +273,7 @@ describe('ImageGallery', () => {
       sessionId: 'message-images-test' as MessageImagesProps['sessionId'],
       useSession,
       useSessions,
-      useResource,
+      usePanelInfo, useResource,
       useSessionPendingInteraction,
       useWorkspaces,
       useProjection: () => undefined,

+ 7 - 2
packages/client/ui-chat/tests/apply-inject.client.spec.tsx

@@ -16,6 +16,7 @@ import {
   apply as applyChat, inject as injectChat, type ChatViewInjected,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import { createChatStore } from '../src/client/stores.ts'
 
 usePinnedBrowserLanguages('zh-CN')
@@ -57,7 +58,11 @@ async function bench() {
   )
   new TestRemote(runtime.ctx, { session: { openWorkspacePath } })
   runtime.ctx.provide('uiWorkspace', {
-    connectWorkspace: vi.fn(async () => ROOT),
+    openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+      beforeOpen(ROOT)
+      runtime.sessions.open(ROOT)
+    }),
+    openSession: (id: SessionId) => { runtime.sessions.open(id) },
   } as never)
   const session = sessionFakeFor()
   await runtime.sessions.add({
@@ -69,7 +74,7 @@ async function bench() {
   runtime.ctx.provide('locale', locale)
   runtime.slots.installLocale(locale)
   await runtime.root.declare({
-    'conversation': { kind: 'single', scope: 'session-maybe' },
+    'main': { kind: 'keyed', scope: 'root' },
   }, (_props: { renderSlot?: unknown }) => null)
   await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
   await runtime.mount({ inject: [...injectChat], apply: applyChat })

+ 9 - 3
packages/client/ui-chat/tests/chat-apply.client.spec.tsx

@@ -9,6 +9,7 @@ import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import {
   apply as applyConversation, inject as injectConversation,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -44,7 +45,11 @@ async function bench() {
   runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
   runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
   runtime.ctx.provide('uiWorkspace', {
-    connectWorkspace: vi.fn(async () => SID),
+    openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+      beforeOpen(SID)
+      runtime.sessions.open(SID)
+    }),
+    openSession: (id: SessionId) => { runtime.sessions.open(id) },
   } as never)
   new TestRemote(runtime.ctx, {
     session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })) },
@@ -53,7 +58,7 @@ async function bench() {
   runtime.ctx.provide('locale', locale)
   runtime.slots.installLocale(locale)
   await runtime.root.declare({
-    'conversation': { kind: 'single', scope: 'session-maybe' },
+    'main': { kind: 'keyed', scope: 'root' },
     'conversation.approval.detail': { kind: 'single', scope: 'session' },
     'settings.general.item': { kind: 'list', scope: 'root' },
   }, (_props: { renderSlot?: unknown }) => null)
@@ -120,7 +125,8 @@ describe('Chat apply wiring', () => {
     await b.chat.dispose()
     expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0)
     expect(b.runtime.slots.spec('conversation.chat.node')).toBeUndefined()
-    expect(b.runtime.slots.entries('conversation')).toHaveLength(1)
+    expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation'])
+    expect(b.runtime.slots.entries('main.conversation')).toHaveLength(1)
     expect(b.runtime.ctx.get('uiConversation')).toBeDefined()
     await b.runtime.dispose()
   })

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

@@ -364,6 +364,7 @@ function makeHarness(
   // ChatView never invokes it (pass-through stub).
   const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}</>
   const props: ChatViewSlotProps = {
+    usePanelInfo: selector => selector({ activePanelId: null }),
     sessionId: SID,
     useSession: bindSnapshotSelector(session.source),
     useChat: bindSnapshotSelector(chatSource.source),

+ 1 - 0
packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx

@@ -35,6 +35,7 @@ function mount(mode: 'normal' | 'compact' = 'compact') {
   const source = createSnapshotStore(mode)
   const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) })
   const props: TranscriptViewRowProps = {
+    usePanelInfo: selector => selector({ activePanelId: null }),
     useSessions: emptySessions(),
     useSessionPendingInteraction: noPendingInteraction(),
     useWorkspaces: emptyWorkspaces(),

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: edfcb83193d4513fba846f673d3572c35e8c0d1e
-README.zh.md: f92e4c77ba3d3a2ceb96758e5817e447ba2ec1ac
+README.md: 19a210bb200dae359920e85ff944b68242f45b4b
+README.zh.md: c5a56871eb99607c924b2b2e048de72d918e6a8b

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

@@ -36,7 +36,9 @@ Target packages declaration-merge their snapshot and Location data maps, then re
 <a id="shell-and-standard-props"></a>
 ## Shell and standard props
 
-The package registers the optional-Session `conversation` shell, strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
+Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact.
+
+The package occupies the root-scoped `main` key `conversation`, whose wrapper declares the optional-Session `main.conversation` shell. It registers strict Session header/body entries, View list, composer chain and bar, input regions, Hero regions, queue dock, draft persistence, and phase calculation. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
 
 View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
 

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

@@ -36,7 +36,9 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
 <a id="shell-and-standard-props"></a>
 ## Shell 与标准 props
 
-本包注册 optional-Session `conversation` shell、strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
+工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在请求仍有效时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。
+
+本包占据 root 作用域 `main` 中的 `conversation` key,其包装层声明 optional-Session `main.conversation` shell。本包注册strict Session header/body、View list、composer chain 与 bar、输入区域、Hero 区域、queue dock、草稿持久化和 phase 计算。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
 
 View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific snapshot。
 

+ 15 - 9
packages/client/ui-conversation/src/client/apply.ts

@@ -28,6 +28,7 @@ import { queueDockEntry } from './queue/QueueDock.tsx'
 import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
 import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
 import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
+import { ConversationPanel } from './skeleton/ConversationPanel.tsx'
 import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
 import { InputBar } from './skeleton/InputBar.tsx'
 import { todoDockEntry } from './skeleton/TodoPanel.tsx'
@@ -84,9 +85,11 @@ const ABSENT_FILE_UPLOADS = {
 }
 
 interface WorkspaceNavigation {
-  connectWorkspace(
+  openSession(sessionId: SessionId): void
+  openWorkspace(
     workspaceId: Parameters<ConversationInjected['selectWorkspace']>[0],
-  ): Promise<SessionId>
+    beforeOpen: (sessionId: SessionId) => void,
+  ): Promise<void>
 }
 
 /** Resolve the session-scoped Conversation action face, failing loud. */
@@ -214,7 +217,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
   })
 
   const registerConversationRoot = () => slots.register({
-    name: 'conversation',
+    name: 'main.conversation',
     locale: NS,
     children: {
       'conversation.session': { kind: 'single', scope: 'session' },
@@ -230,8 +233,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
       hooks: {
         composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId),
       },
-      selectWorkspace: async (workspaceId) => {
-        const nextId = await workspaceNavigation.connectWorkspace(workspaceId)
+      selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => {
         if (sessionId !== undefined && nextId !== sessionId) {
           const from = inputHub.shell(sessionId)
           const draft = from.snapshot.draft
@@ -251,8 +253,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
             }
           }
         }
-        sessions.open(nextId)
-      },
+      }),
     }),
   }, ConversationRoot)
 
@@ -284,7 +285,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
     store: conversationStore,
     inject: (sessionId: SessionId, actions: BoundActions<typeof conversationStore>): ConversationSessionHeaderInjected => ({
       hooks: { conversationViews },
-      open: (id) => { sessions.open(id) },
+      open: (id) => { workspaceNavigation.openSession(id) },
       selectView: (view) => {
         activateView(sessionId, view)
         actions.setView(view)
@@ -384,7 +385,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
     },
   }, InputBar)
 
-  slots.inject('conversation', function* () {
+  slots.inject('main', function* () {
+    yield slots.register({
+      name: 'main',
+      key: 'conversation',
+      children: { 'main.conversation': { kind: 'single', scope: 'session-maybe' } },
+    }, ConversationPanel)
     yield registerConversationRoot()
     yield registerConversationSession()
     yield registerConversationHeader()

+ 3 - 1
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -117,6 +117,8 @@ export type UseConversationViews = SnapshotSelectorHook<readonly ViewTab[]>
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
+    /** Conversation shell beneath its root-scoped main-panel entry. */
+    'main.conversation': { kind: 'single'; scope: 'session-maybe' }
     /** Strict per-Session Conversation body. */
     'conversation.session': { kind: 'single'; scope: 'session' }
     /** Strict per-Session title, actions, and View navigation. */
@@ -364,7 +366,7 @@ export interface HeroBrandMarkOwnerProps {
 
 /** Full props of the resident optional-Session Conversation shell. */
 export type ConversationSlotProps =
-  PropsRuntime<'conversation'>
+  PropsRuntime<'main.conversation'>
   & PropsRenderSlots<
     | 'conversation.session' | 'conversation.session.header'
     | 'conversation.composer' | 'conversation.composer.bar'

+ 12 - 0
packages/client/ui-conversation/src/client/skeleton/ConversationPanel.tsx

@@ -0,0 +1,12 @@
+/** Root-scoped main occupant; Session binding belongs to its Conversation child. */
+import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '../contract/slots.ts'
+
+/**
+ * Render the Conversation with optional current-Session binding.
+ * @param props - main-slot inputs and the declared Conversation renderer.
+ * @returns the Conversation subtree.
+ */
+export function ConversationPanel({ renderSlot }: PropsRuntime<'main'> & PropsRenderSlots<'main.conversation'>) {
+  return renderSlot('main.conversation', {})
+}

+ 11 - 4
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -49,7 +49,14 @@ async function bench() {
   }
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   const connectWorkspace = vi.fn(async () => ROOT)
-  runtime.ctx.provide('uiWorkspace', { connectWorkspace } as never)
+  runtime.ctx.provide('uiWorkspace', {
+    openWorkspace: async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+      const id = await connectWorkspace()
+      beforeOpen(id)
+      runtime.sessions.open(id)
+    },
+    openSession: (id: SessionId) => { runtime.sessions.open(id) },
+  } as never)
   const sessionFake = sessionFakeFor()
   await runtime.sessions.add({
     id: ROOT,
@@ -60,12 +67,12 @@ async function bench() {
   runtime.ctx.provide('locale', locale)
   runtime.slots.installLocale(locale)
   await runtime.root.declare({
-    'conversation': { kind: 'single', scope: 'session-maybe' },
+    'main': { kind: 'keyed', scope: 'root' },
   }, (_props: { renderSlot?: unknown }) => null)
 
   const feature = await runtime.mount({ inject: [...inject], apply })
   runtime.renderRoot()
-  const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') =>
+  const entryOf = (key: 'main.conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar') =>
     runtime.slots.entries(key)[0]!
   const conversationApi = (id: SessionId) => {
     const entry = entryOf('conversation.session')
@@ -77,7 +84,7 @@ async function bench() {
     return { instance, injected }
   }
   const residentApi = (id: SessionId | undefined) => {
-    const entry = entryOf('conversation')
+    const entry = entryOf('main.conversation')
     return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
   }
   const headerApi = (id: SessionId) => {

+ 20 - 8
packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx

@@ -14,14 +14,20 @@ const SID = 'session-1' as SessionId
 
 async function bench(options: { declareConversation?: boolean } = {}) {
   const runtime = await SlotTestRuntime.create()
-  runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
+  runtime.ctx.provide('uiWorkspace', {
+    openWorkspace: vi.fn(async (_workspaceId: unknown, beforeOpen: (id: SessionId) => void) => {
+      beforeOpen(SID)
+      runtime.sessions.open(SID)
+    }),
+    openSession: (id: SessionId) => { runtime.sessions.open(id) },
+  } as never)
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   const locale = new LocaleRuntime(runtime.ctx)
   runtime.ctx.provide('locale', locale)
   runtime.slots.installLocale(locale)
   if (options.declareConversation !== false) {
     await runtime.root.declare({
-      'conversation': { kind: 'single', scope: 'session-maybe' },
+      'main': { kind: 'keyed', scope: 'root' },
       'settings.general.item': { kind: 'list', scope: 'root' },
     }, (_props: { renderSlot?: unknown }) => null)
   }
@@ -31,7 +37,7 @@ async function bench(options: { declareConversation?: boolean } = {}) {
 
 function entry(
   runtime: SlotTestRuntime,
-  key: 'conversation' | 'conversation.session' | 'conversation.session.header',
+  key: 'main.conversation' | 'conversation.session' | 'conversation.session.header',
 ) {
   return runtime.slots.entries(key)[0] as { store?: unknown } | undefined
 }
@@ -39,14 +45,18 @@ function entry(
 describe('target-neutral Conversation apply wiring', () => {
   it('waits for the layout-owned conversation declaration before registering its subtree', async () => {
     const b = await bench({ declareConversation: false })
-    expect(b.runtime.slots.entries('conversation')).toHaveLength(0)
+    expect(b.runtime.slots.entries('main')).toHaveLength(0)
+    expect(b.runtime.slots.entries('main.conversation')).toHaveLength(0)
 
     await b.runtime.root.declare({
-      'conversation': { kind: 'single', scope: 'session-maybe' },
+      'main': { kind: 'keyed', scope: 'root' },
       'settings.general.item': { kind: 'list', scope: 'root' },
     }, (_props: { renderSlot?: unknown }) => null)
 
-    expect(b.runtime.slots.entries('conversation')).toHaveLength(1)
+    expect(b.runtime.slots.entries('main').map(row => row.options.key)).toEqual(['conversation'])
+    expect(b.runtime.slots.entries('main.conversation')).toHaveLength(1)
+    expect(b.runtime.slots.spec('main.conversation'))
+      .toEqual({ kind: 'single', scope: 'session-maybe' })
     expect(b.runtime.slots.entries('conversation.session')).toHaveLength(1)
     expect(b.runtime.slots.entries('conversation.session.header')).toHaveLength(1)
     expect(b.runtime.slots.entries('conversation.composer.bar')).toHaveLength(1)
@@ -65,7 +75,7 @@ describe('target-neutral Conversation apply wiring', () => {
     const b = await bench()
     const session = entry(b.runtime, 'conversation.session')
     const header = entry(b.runtime, 'conversation.session.header')
-    expect(entry(b.runtime, 'conversation')?.store).toBeUndefined()
+    expect(entry(b.runtime, 'main.conversation')?.store).toBeUndefined()
     expect(session?.store).toBeDefined()
     expect(header?.store).toBe(session?.store)
     expect(b.runtime.slots.spec('conversation.composer'))
@@ -110,7 +120,9 @@ describe('target-neutral Conversation apply wiring', () => {
     await b.feature.dispose()
     expect(b.runtime.ctx.get('conversation')).toBeUndefined()
     expect(b.runtime.ctx.get('uiConversation')).toBeUndefined()
-    expect(b.runtime.slots.entries('conversation')).toHaveLength(0)
+    expect(b.runtime.slots.entries('main')).toHaveLength(0)
+    expect(b.runtime.slots.entries('main.conversation')).toHaveLength(0)
+    expect(b.runtime.slots.spec('main.conversation')).toBeUndefined()
     expect(b.runtime.slots.spec('conversation.view')).toBeUndefined()
     await b.runtime.dispose()
   })

+ 32 - 7
packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx

@@ -12,6 +12,7 @@ import {
 import { InputHub } from '../src/client/input/hub.ts'
 import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 
 // jsdom implements no Range geometry (Lexical's scroll-into-view measures the
 // caret with one once the surface is genuinely contenteditable).
@@ -40,13 +41,13 @@ beforeEach(() => {
   vi.stubGlobal('ResizeObserver', ResizeObserverStub)
 })
 
-type AppRootProps = PropsRenderSlots<'conversation'>
+type AppRootProps = PropsRenderSlots<'main'>
 function AppRoot({ renderSlot }: AppRootProps) {
-  return <>{renderSlot('conversation', {})}</>
+  return <>{renderSlot('main', {}, { entryKey: 'conversation' })}</>
 }
 
 const LAYOUT_CHILDREN = {
-  'conversation': { kind: 'single', scope: 'session-maybe' },
+  'main': { kind: 'keyed', scope: 'root' },
 } as const
 
 function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
@@ -60,7 +61,13 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
 
 async function bench(opts?: { blank?: boolean }) {
   const runtime = await SlotTestRuntime.create()
-  runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
+  runtime.ctx.provide('uiWorkspace', {
+    openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+      beforeOpen(SID)
+      runtime.sessions.open(SID)
+    }),
+    openSession: (id: SessionId) => { runtime.sessions.open(id) },
+  } as never)
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   const locale = new LocaleRuntime(runtime.ctx)
   runtime.ctx.provide('locale', locale)
@@ -82,7 +89,13 @@ async function bench(opts?: { blank?: boolean }) {
 describe('resident composer', () => {
   it('renders the locked view state while no session exists at all', async () => {
     const runtime = await SlotTestRuntime.create()
-    runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
+    runtime.ctx.provide('uiWorkspace', {
+      openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+        beforeOpen(SID)
+        runtime.sessions.open(SID)
+      }),
+      openSession: (id: SessionId) => { runtime.sessions.open(id) },
+    } as never)
     runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
     const locale = new LocaleRuntime(runtime.ctx)
     runtime.ctx.provide('locale', locale)
@@ -109,7 +122,13 @@ describe('resident composer', () => {
 
   it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
     const runtime = await SlotTestRuntime.create()
-    runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
+    runtime.ctx.provide('uiWorkspace', {
+      openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+        beforeOpen(SID)
+        runtime.sessions.open(SID)
+      }),
+      openSession: (id: SessionId) => { runtime.sessions.open(id) },
+    } as never)
     runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
     const locale = new LocaleRuntime(runtime.ctx)
     runtime.ctx.provide('locale', locale)
@@ -174,7 +193,13 @@ describe('resident composer', () => {
 describe('prompt rejection through the assembled composer', () => {
   it('renders the promptError alert strip and keeps the draft in the machine', async () => {
     const runtime = await SlotTestRuntime.create()
-    runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID) } as never)
+    runtime.ctx.provide('uiWorkspace', {
+      openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+        beforeOpen(SID)
+        runtime.sessions.open(SID)
+      }),
+      openSession: (id: SessionId) => { runtime.sessions.open(id) },
+    } as never)
     runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
     const locale = new LocaleRuntime(runtime.ctx)
     runtime.ctx.provide('locale', locale)

+ 1 - 0
packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx

@@ -40,6 +40,7 @@ function mount() {
   const policy = new ComposerSubmissionPolicy()
   const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) })
   const props: EnterBehaviorRowProps = {
+    usePanelInfo: selector => selector({ activePanelId: null }),
     useSessions: emptySessions(),
     useSessionPendingInteraction: noPendingInteraction(),
     useResource,

+ 1 - 0
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -164,6 +164,7 @@ function bench(over?: BenchOptions) {
     return null
   }) as never
   const props: InputBarProps = {
+    usePanelInfo: selector => selector({ activePanelId: null }),
     sessionId: SID,
     SessionProvider: ({ children }) => children,
     useSession: bindSnapshotSelector(session),

+ 1 - 0
packages/client/ui-conversation/tests/input-matrix.client.spec.tsx

@@ -48,6 +48,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
     removed: over?.disabled ?? false,
   })
   const props: InputBarProps = {
+    usePanelInfo: selector => selector({ activePanelId: null }),
     sessionId: SID,
     SessionProvider: ({ children }) => children,
     useSession: bindSnapshotSelector(session),

+ 1 - 0
packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx

@@ -137,6 +137,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
   const wiring = shell
   const sessionStore = createSnapshotStore<SessionSnapshot>(sessionSnapshot(sessionId))
   const barProps: InputBarProps = {
+    usePanelInfo: selector => selector({ activePanelId: null }),
     sessionId,
     SessionProvider: ({ children }) => children,
     useSession: bindSnapshotSelector(sessionStore),

+ 2 - 0
packages/client/ui-conversation/tests/queue-dock.client.spec.tsx

@@ -73,11 +73,13 @@ function liveSession(initial: SessionSnapshot) {
 const INPUT_STATE: InputState = { draft: '', attachmentIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] }
 
 const t: QueueDockProps['t'] = makeTranslate(zh, commonZh)
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 function kitFor(snapshot: SessionSnapshot, injected: Partial<QueueDockInjected> = {}) {
   return {
     sessionId: SID,
     t,
+    usePanelInfo,
     useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
     useResource,
     useSessionPendingInteraction: bindSnapshotSelector(

+ 4 - 0
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -199,6 +199,7 @@ function mount(
           useChat={useChat}
           useTrajectory={useTrajectory}
           useSessions={props.useSessions}
+          usePanelInfo={props.usePanelInfo}
           useResource={useResource}
           useSessionPendingInteraction={useSessionPendingInteraction}
           useWorkspaces={props.useWorkspaces}
@@ -225,6 +226,7 @@ function mount(
           useChat={useChat}
           useTrajectory={useTrajectory}
           useSessions={props.useSessions}
+          usePanelInfo={props.usePanelInfo}
           useResource={useResource}
           useSessionPendingInteraction={useSessionPendingInteraction}
           useWorkspaces={props.useWorkspaces}
@@ -251,6 +253,7 @@ function mount(
           useSession={useSession}
           useConversation={useConversation}
           useSessions={props.useSessions}
+          usePanelInfo={props.usePanelInfo}
           useSessionPendingInteraction={useSessionPendingInteraction}
           useWorkspaces={props.useWorkspaces}
           useProjection={(() => undefined)}
@@ -295,6 +298,7 @@ function mount(
       : (opts?.fallback ?? null)
   )) as ConversationRootProps['renderSlotChain']
   const props: ConversationRootProps = {
+    usePanelInfo: selector => selector({ activePanelId: null }),
     sessionId: SID,
     SessionProvider: ({ children }) => children,
     useSession,

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
-README.md: 0e353e21f39ba8a84e661f1c540db2c2424bcbfa
-README.zh.md: 16471fdabb46bbfb35158ef85aa2544db4ceae93
+README.md: 4fe19fb9ec0a549dc76ca311d0564cbe86c9f5f9
+README.zh.md: 7a61e7808ffb3d81214388a2792a901ced8d9564

+ 7 - 3
packages/client/ui-layout/README.md

@@ -25,7 +25,9 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a
 <a id="use-this-package"></a>
 ## Use this package
 
-The root slot composes the sidebar, conversation, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen.
+The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen.
+
+Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition.
 
 ### Theme presentation
 
@@ -39,7 +41,9 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-One registration declares four child slots and binds `ctx.layout` methods `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. The store owns the single frame-width measurement, width preferences, and the occupant's presentation report. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame always mounts the conversation and right columns; a connected Session renders through `SessionProvider`, and without one the right column is an empty zero-width track. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
+`selectPanel(id)` checks the live `main` registry before changing selection; an absent key throws and leaves the current panel intact. `beginNavigation()` returns an abort signal for an asynchronous UI navigation. A later call, a valid panel selection (including repeated selection), or layout disposal aborts that signal without cancelling underlying Session creation. Consumers check the signal before committing navigation or moving drafts.
+
+One registration declares four child slots and binds `ctx.layout` methods `selectPanel`, `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. One root store separates `panelInfo` selection from `layoutInfo` measurements, width preferences, and presentation reports. `usePanelInfo` subscribes to the stable selection object; AppFrame subscribes to the stable layout object. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame keeps the column containers mounted. The right column's root controller renders `rightbar.session` through `SessionProvider` only while the Conversation is selected; its unmount report releases the track. The independent title component uses the selected Session title only while the Conversation is visible, with the build-configured product title or localized `common.brand.localBuild` as its fallback; locale revisions update that fallback. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
 
 </details>
 
@@ -51,7 +55,7 @@ One registration declares four child slots and binds `ctx.layout` methods `toggl
 Read these pages when the layout surface is not enough. They move from the frame to the columns it renders and the theme it presents.
 
 - [ui-sidebar](../ui-sidebar/README.md) — occupies the `sidebar` column and its seats.
-- [ui-conversation](../ui-conversation/README.md) — occupies the `conversation` column.
+- [ui-conversation](../ui-conversation/README.md) — occupies the `main` key `conversation`.
 - [ui-sidebar-right](../ui-sidebar-right/README.md) — occupies the `rightbar` column with one docking surface per session.
 - [ui-theme](../ui-theme/README.md) — the theme seam whose resolved snapshots the presenter consumes.
 - [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots.

+ 7 - 3
packages/client/ui-layout/README.zh.md

@@ -25,7 +25,9 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-本插件在 root slot 组合侧栏、会话与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。
+本插件在 root slot 组合侧栏、中央内容与右栏。左栏为264~420px,默认280px,收起后保留56px;窗口低于1024px时自动收起,打开右栏也会收起手动展开的左栏。右栏首次打开使用窗口宽度的45%,之后保留用户像素偏好,上限为70%;中栏不足400px时先把右栏压到300px,仍不足则通知占用方收起,最后才继续压缩中栏。拖拽跟手且无过渡延迟,关闭或全屏时不显示右栏拖拽区。
+
+全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。
 
 ### 主题呈现
 
@@ -39,7 +41,9 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-一次注册声明四个子slot并绑定 `ctx.layout` 的 `toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。store持有唯一的frame宽度测量、左右栏偏好及占用方报告的呈现状态。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 始终挂载会话与右栏;已连接 Session 经 `SessionProvider` 渲染,没有 Session 时右栏是一条空的零宽轨道。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
+`selectPanel(id)` 在改变选中态前检查实时 `main` 注册表;缺失的 key 会抛错并保留当前面板。`beginNavigation()` 为异步 UI 导航返回 abort signal。后续调用、有效面板选择(包括重复选择)或布局释放会中止该 signal,但不取消底层会话创建。消费者在提交导航或搬移草稿前检查 signal。
+
+一次注册声明四个子slot并绑定 `ctx.layout` 的 `selectPanel`、`toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。同一个 root 存储把 `panelInfo` 选中态与 `layoutInfo` 测量、宽度偏好、呈现报告分开。`usePanelInfo` 订阅引用稳定的选中态对象,AppFrame 订阅引用稳定的布局对象。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 保持各列容器挂载。右栏的 root 控制器仅在选中会话界面时,经 `SessionProvider` 渲染 `rightbar.session`;内容卸载时的报告释放列宽。独立的标题组件仅在会话界面可见时使用所选会话标题,以构建配置的产品标题或本地化 `common.brand.localBuild` 为回退值;语言变化会更新该回退值。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
 
 </details>
 
@@ -51,7 +55,7 @@ kind: "package-reference"
 当布局面不够用时阅读以下页面。它们从框架进入它所渲染的栏与它所呈现的主题。
 
 - [ui-sidebar](../ui-sidebar/README.zh.md)——占据 `sidebar` 栏及其座位。
-- [ui-conversation](../ui-conversation/README.zh.md)——占据 `conversation` 栏
+- [ui-conversation](../ui-conversation/README.zh.md)——占据 `main` 中的 `conversation` key
 - [ui-sidebar-right](../ui-sidebar-right/README.zh.md)——以每会话一个停靠面占据 `rightbar` 栏。
 - [ui-theme](../ui-theme/README.zh.md)——呈现器消费其解析快照的主题 seam。
 - [Web 客户端架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册槽位。

+ 1 - 0
packages/client/ui-layout/package.json

@@ -45,6 +45,7 @@
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",

+ 36 - 44
packages/client/ui-layout/src/client/AppFrame.tsx

@@ -3,10 +3,9 @@
  * shell renders only 'root'). Owns the grid tracks (sidebar | center |
  * rightbar), the drag handles (pointer capture + rAF throttle), the column
  * solve (columns.ts), and the child-slot render decisions: the sidebar slot
- * renders HERE with live parameters from that solve, and the session-aware
- * occupants render in fixed column positions; the strict right-column entry
- * gates itself on current-session availability while the session-maybe
- * conversation retains identity.
+ * receives live parameters from that solve. The root-scoped main slot selects
+ * the Conversation or a global panel. Each column occupant owns its Session
+ * binding and reports the geometry it needs.
  *
  * The right column is a track, not a box: its occupant draws its panel anchored
  * to the frame's right edge at the resolved normal width, and the
@@ -15,7 +14,7 @@
  * track but hides the outer resize handle. Everything arrives through the framework
  * shares — zero cordis or framework imports, zero self-made hooks.
  */
-import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
 import type { ReactNode } from 'react'
 import type {
   PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
@@ -28,7 +27,7 @@ import css from './AppFrame.module.css'
 /** Full composed props: runtime share + child-slot render share + store share. */
 export type AppFrameProps =
   & PropsRuntime<'root'>
-  & PropsRenderSlots<'sidebar' | 'conversation' | 'rightbar' | 'shell.overlay'>
+  & PropsRenderSlots<'sidebar' | 'main' | 'rightbar' | 'shell.overlay'>
   & PropsStore<ReturnType<typeof createLayoutStore>>
   & PropsLocale<'common'>
 
@@ -37,6 +36,12 @@ function CenterColumn(props: { children?: ReactNode }) {
   return <div className={css.centerCol}>{props.children}</div>
 }
 
+/** Subscribe to the main key without subscribing the column frame to each panel id. */
+function MainPanel({ usePanelInfo, renderSlot }: Pick<PropsRuntime<'root'>, 'usePanelInfo'> & PropsRenderSlots<'main'>) {
+  const panelId = usePanelInfo(info => info.activePanelId)
+  return renderSlot('main', {}, { entryKey: panelId ?? 'conversation' })
+}
+
 /**
  * Right column grid item. Zero-width unless the occupant asked for a track; the
  * occupant's panel is positioned against the column's right edge, which never
@@ -116,18 +121,14 @@ function DragHandle(props: { side: 'sidebar' | 'rightbar'; left: number; onStart
 export function AppFrame({
   useStore,
   useSessions,
+  usePanelInfo,
   actions,
   renderSlot,
-  SessionProvider,
   t,
 }: AppFrameProps) {
-  const panels = useStore(s => s)
-  const documentTitle = useSessions((s) => {
-    const current = s.current
-    return current === undefined ? undefined : s.byId[current]?.title
-  })
+  const layoutInfo = useStore(state => state.layoutInfo)
   const frameRef = useRef<HTMLDivElement | null>(null)
-  const viewport = panels.viewportWidth
+  const viewport = layoutInfo.viewportWidth
 
   // Track the frame's own box (not the window): rAF-throttled ResizeObserver.
   useLayoutEffect(() => {
@@ -157,15 +158,15 @@ export function AppFrame({
   }, [actions])
 
   const narrow = viewport < SIDEBAR_AUTO_COLLAPSE
-  const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0
+  const sidebarCollapsed = narrow ? !layoutInfo.narrowExpanded : layoutInfo.sidebar === 0
   const sidebarPreference = sidebarCollapsed
     ? 0
-    : panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar
-  const rightbarPreference = panels.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO
+    : layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : layoutInfo.sidebar
+  const rightbarPreference = layoutInfo.rightbar ?? viewport * RIGHTBAR_DEFAULT_RATIO
   // Opening on a narrow frame collapses the left sidebar. Eligibility must
   // include that space before the occupant's first shown report arrives.
-  const normal = computeColumns(viewport, !panels.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference)
-  const cols = computeColumns(viewport, sidebarPreference, panels.rightbarTrack ? rightbarPreference : 0)
+  const normal = computeColumns(viewport, !layoutInfo.rightbarShown && narrow ? 0 : sidebarPreference, rightbarPreference)
+  const cols = computeColumns(viewport, sidebarPreference, layoutInfo.rightbarTrack ? rightbarPreference : 0)
   const colsRef = useRef(cols)
   colsRef.current = cols
   const rightbarWidth = useRef(normal.rightbar)
@@ -189,6 +190,14 @@ export function AppFrame({
     actions.setRightbar(rightbarBase.current - dx)
   }, [actions])
   const productTitle = process.env.DSH_CLIENT_TITLE ?? t('brand.localBuild')
+  const sidebar = useMemo(() => renderSlot('sidebar', {
+    collapsed: sidebarCollapsed,
+    width: cols.sidebar,
+  }), [renderSlot, sidebarCollapsed, cols.sidebar])
+  const main = useMemo(() => (
+    <MainPanel usePanelInfo={usePanelInfo} renderSlot={renderSlot} />
+  ), [usePanelInfo, renderSlot])
+  const overlays = useMemo(() => renderSlot('shell.overlay', {}), [renderSlot])
 
   return (
     <div
@@ -200,47 +209,30 @@ export function AppFrame({
       }}
       data-sidebar-collapsed={sidebarCollapsed || undefined}
       data-rightbar-collapsed={cols.rightbar === 0 || undefined}
-      data-rightbar-fullscreen={panels.rightbarFullscreen || undefined}
-      data-rightbar-instant={panels.rightbarInstant || undefined}
+      data-rightbar-fullscreen={layoutInfo.rightbarFullscreen || undefined}
+      data-rightbar-instant={layoutInfo.rightbarInstant || undefined}
       data-dragging={dragging || undefined}
     >
       <DocumentTitle
         productTitle={productTitle}
-        {...documentTitle === undefined ? {} : { title: documentTitle }}
+        useSessions={useSessions}
+        usePanelInfo={usePanelInfo}
       />
       <div className={css.sidebarCol}>
-        {/* Render-site slot call with live concession output: a closed
-            sidebar keeps the mounted slot at the compact-rail width, and the
-            component sees its rendered state as owner params decided here
-            (collapsed follows the resolved rail, so a derived auto-collapse
-            renders the rail UI too). */}
-        {renderSlot('sidebar', {
-          collapsed: sidebarCollapsed,
-          width: cols.sidebar,
-        })}
+        {sidebar}
       </div>
       <>
-        {/* Both column occupants stay at fixed tree positions from first
-            paint — no loading gate: a bare status line reads worse than
-            the shell's own pending rendering. The conversation is
-            session-maybe; SessionProvider withholds the strict right-column
-            entry while no session is current. */}
-        <CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
+        <CenterColumn>{main}</CenterColumn>
         <RightbarColumn>
-          {/* Strict session entry: with no session there is no surface, and the
-              column is an empty zero-width track. The occupant receives the
-              panel width it should draw at; the track is the frame's business. */}
-          <SessionProvider>
-            {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
-          </SessionProvider>
+          {renderSlot('rightbar', { width: normal.rightbar, viewportWidth: viewport, canShow: normal.rightbar > 0 })}
         </RightbarColumn>
       </>
       <div className={css.overlayLayer} data-shell-overlay>
-        {renderSlot('shell.overlay', {})}
+        {overlays}
       </div>
       {/* The collapsed rail is fixed-width: no resize handle while closed. */}
       {!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
-      {panels.rightbarShown && !panels.rightbarFullscreen && normal.rightbar > 0 && (
+      {layoutInfo.rightbarShown && !layoutInfo.rightbarFullscreen && normal.rightbar > 0 && (
         <DragHandle side="rightbar" left={viewport - normal.rightbar} onStart={onRightbarStart} onDrag={onRightbarDrag} onEnd={onDragEnd} />
       )}
     </div>

+ 9 - 4
packages/client/ui-layout/src/client/DocumentTitle.tsx

@@ -1,9 +1,9 @@
+/** Browser title selection follows the active main panel without subscribing the frame. */
 import { useEffect } from 'react'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 
 /** Props for the browser title projection. */
-export interface DocumentTitleProps {
-  /** Durable title of the selected session, or undefined for the product title. */
-  title?: string
+export type DocumentTitleProps = Pick<PropsRuntime<'root'>, 'useSessions' | 'usePanelInfo'> & {
   /** Build-configured or localized product title. */
   productTitle: string
 }
@@ -14,7 +14,12 @@ export interface DocumentTitleProps {
  * @param props - Selected session title projection.
  * @returns No rendered content.
  */
-export function DocumentTitle({ title, productTitle }: DocumentTitleProps): null {
+export function DocumentTitle({ useSessions, usePanelInfo, productTitle }: DocumentTitleProps): null {
+  const showSessionTitle = usePanelInfo(info => info.activePanelId === null)
+  const title = useSessions((state) => {
+    const current = state.current
+    return !showSessionTitle || current === undefined ? undefined : state.byId[current]?.title
+  })
   useEffect(() => {
     document.title = title === undefined ? productTitle : `${title} — ${productTitle}`
     return () => { document.title = productTitle }

+ 42 - 34
packages/client/ui-layout/src/client/index.ts

@@ -3,8 +3,8 @@
  * the runtime's built-in 'root' slot and, in the same breath, declares the
  * four child slots (declaration = exclusive render authority), seats the
  * layout store (panel geometry), and wires the panel-action service face.
- * ctx.layout is the cross-plugin panel-action contract; navigation state lives
- * with the runtime sessions service. A second effect seats the theme
+ * ctx.layout selects the main panel and controls column geometry; Session
+ * selection belongs to the Session Controller. A second effect seats the theme
  * presenter, which projects ctx.theme snapshots onto document.body.
  */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
@@ -12,7 +12,8 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type {} from '@deepseek-ai/dsh-client-ui-theme/client'
-import type { PanelActions } from './service.ts'
+import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PanelInfo } from './service.ts'
 import { AppFrame } from './AppFrame.tsx'
 import { createLayoutStore } from './stores.ts'
 import { LayoutController } from './service.ts'
@@ -24,7 +25,10 @@ import { ThemePresenter } from './theme-presenter.ts'
 // OwnerShare contracts below are the render-side halves registrants compose
 // against; the frame components and the store factory are package-internal.
 export { LayoutController } from './service.ts'
-export type { ILayout } from './service.ts'
+export type { ILayout, MainPanelId, PanelInfo } from './service.ts'
+
+/** Selector hook over root-scoped panel selection. */
+export type UsePanelInfo = SnapshotSelectorHook<PanelInfo>
 
 declare module '@deepseek-ai/cordis' {
   interface Context {
@@ -34,6 +38,11 @@ declare module '@deepseek-ai/cordis' {
 }
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface GlobalStandardProps {
+    /** Subscribe to the selected main panel independently of parent renders. */
+    usePanelInfo: UsePanelInfo
+  }
+
   interface SlotMap {
     // The 'root' entry itself is the runtime's built-in slot (declared
     // there); these four are the frame's children, declared by the same
@@ -51,18 +60,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      */
     'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
     /**
-     * The whole center column, across both the no-session hero and a live
-     * conversation. OCCUPIED by ui-conversation's ConversationRoot, which
-     * declares the session body, composer, and input seats inside it —
-     * registering here replaces the entire conversation surface (and removes
-     * every seat it declares) rather than adding to it.
-     *
-     * Current-session-optional: the occupant owns both states without
-     * changing its React identity, so it keeps its own state across a session
-     * switch. It receives no owner props; session facts arrive through the
-     * framework hooks of the `session-maybe` scope.
+     * Central panel selected by sidebar entry id. The reserved `conversation`
+     * key hosts the Conversation; other keys receive no Session binding.
      */
-    'conversation': { kind: 'single'; scope: 'session-maybe'; owner: ConvOwnerProps }
+    'main': { kind: 'keyed'; scope: 'root' }
     /**
      * The right column: a track the centre makes room for, or nothing. OCCUPIED
      * by the right Sidebar, which uses the resolved column width in normal
@@ -73,10 +74,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      * occupant's own recorded business — it reports the composition of its
      * expanded and presentation state through `ctx.layout`, and the frame sizes
      * the track and places the resize handle from that. The expand control is
-     * not this column's: it is a button in the conversation header. With no
-     * current session nothing is mounted here.
+     * not this column's: it is a button in the conversation header. The root
+     * occupant decides when to render its Session-bound content.
      */
-    'rightbar': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps }
+    'rightbar': { kind: 'single'; scope: 'root'; owner: RightbarOwnerProps }
     /**
      * Frame-wide floating layer, above every column and outside their scroll
      * containers. Deliberately generic and unowned by any feature: a badge, a
@@ -105,9 +106,6 @@ export interface SidebarOwnerProps {
   width: number
 }
 
-/** Conversation owner share: business state and actions belong to the registrant. */
-export interface ConvOwnerProps {}
-
 /** Right column owner share: resolved normal geometry and opening eligibility. */
 export interface RightbarOwnerProps {
   /** Resolved normal panel width in px, not the saved preference; zero if it cannot fit. */
@@ -127,34 +125,44 @@ export const inject = ['slots', 'theme', 'locale']
 /**
  * Client plugin body: provide ctx.layout, then one register() call — AppFrame
  * into 'root' with the four child-slot declarations, the layout store seat,
- * and the inject hook that hands the store's bound actions to the service.
+ * and the shared root instance supplying commands and the panel-info source.
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
-  const layout = new LayoutController()
   ctx.effect(() => {
+    const handle = createLayoutStore()
+    const instance = handle.create()
+    const store: typeof handle = { ...handle, create: () => instance }
+    const layout = new LayoutController(instance.actions, id =>
+      ctx.slots.entries('main').some(entry => entry.options.key === id))
+    const retainMainPanels = (): void => {
+      instance.actions.retainMainPanels(ctx.slots.entries('main').flatMap(entry =>
+        entry.options.key === undefined ? [] : [entry.options.key]))
+    }
+    const panelInfo: HostObservable<PanelInfo> = {
+      getSnapshot: () => instance.getSnapshot().panelInfo,
+      subscribe: listener => instance.subscribe(listener),
+    }
+    const disposePanelInfo = ctx.slots.provideRoot({ hooks: { panelInfo } })
     const disposeService = ctx.reflect.provide('layout', layout)
     const disposeRegistration = ctx.slots.register({
       name: 'root',
       locale: 'common',
       children: {
         'sidebar': { kind: 'single', scope: 'root' },
-        'conversation': { kind: 'single', scope: 'session-maybe' },
-        'rightbar': { kind: 'single', scope: 'session' },
+        'main': { kind: 'keyed', scope: 'root' },
+        'rightbar': { kind: 'single', scope: 'root' },
         'shell.overlay': { kind: 'list', scope: 'root' },
       },
-      // Exclusive store: the factory itself — the framework instantiates per
-      // entry and delivers useStore/actions to AppFrame as standard props.
-      store: createLayoutStore,
-      // The hook's only side effect connects the root store to ctx.layout;
-      // conversation business actions belong to their registrants.
-      inject: (actions: PanelActions) => {
-        layout.attachPanels(actions)
-        return {}
-      },
+      store,
     }, AppFrame)
+    const disposePanels = ctx.slots.subscribe('main', retainMainPanels)
+    retainMainPanels()
     return () => {
+      layout.dispose()
+      disposePanels()
       disposeRegistration()
+      disposePanelInfo()
       // provide()'s disposer settles asynchronously; teardown is synchronous fire-and-forget.
       void disposeService()
     }

+ 55 - 28
packages/client/ui-layout/src/client/service.ts

@@ -1,26 +1,42 @@
 /**
  * LayoutController: the cross-plugin panel-action face behind ctx.layout.
- * Panel geometry itself lives in the root entry's layout store (stores.ts);
+ * Panel geometry and main-panel selection live in the root layout store;
  * the current-session selection lives with the runtime sessions service, and
  * the per-session active view dissolved into ui-conversation's session store
  * (its only consumer). What remains here is the contract other plugins'
- * apply worlds reach for panel transitions (sidebar toggle from ui-sidebar,
+ * apply worlds reach for panel transitions (main-panel selection and sidebar toggle,
  * right-panel show/hide from ui-sidebar-right) — writes stay inside the
- * store's declared action set, delivered as the registration's bound actions.
+ * store's declared action set, shared with the root registration.
  */
 import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
+import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { createLayoutStore } from './stores.ts'
 
+/** Identity shared by a sidebar panel entry and its main-slot occupant. */
+export type MainPanelId = Branded<'MainPanelId'>
+
+/** Root-scoped navigation state exposed to panel-aware components. */
+export interface PanelInfo {
+  /** Selected global panel; null displays the current Conversation. */
+  readonly activePanelId: MainPanelId | null
+}
+
 /** The layout store's bound action set (framework-baked, draft params peeled). */
 export type PanelActions = BoundActions<ReturnType<typeof createLayoutStore>>
 
-/**
- * The outward layout face (`ctx.layout`): the panel transitions other
- * plugins may trigger — and exactly what a test fake must supply. The
- * attachPanels wiring hook stays on the concrete class (root-entry assembly
- * only).
- */
+/** Panel navigation and geometry actions exposed through ctx.layout. */
 export interface ILayout {
+  /**
+   * Select a global central panel without changing the current Session.
+   * @param panelId - registered main key, or null to show the Conversation.
+   * @throws if the selected main key is not registered; preserves the current selection.
+   */
+  selectPanel(panelId: MainPanelId | null): void
+  /**
+   * Start an asynchronous navigation, superseding any earlier pending navigation.
+   * @returns a signal aborted by the next navigation or layout disposal; check it before committing UI state.
+   */
+  beginNavigation(): AbortSignal
   /** Toggle the sidebar panel (closed ⟷ contract default width). */
   toggleSidebar(): void
   /**
@@ -37,39 +53,50 @@ export interface ILayout {
 
 /** Cross-plugin panel-action face (ctx.layout). */
 export class LayoutController implements ILayout {
-  #panels: PanelActions | undefined
+  private navigation = new AbortController()
 
   /**
-   * Adopt the root entry's bound store actions. Called from the root
-   * registration's inject hook (a sanctioned assembly side effect), so the
-   * face is live from the entry's first render; on entry re-register the
-   * fresh actions overwrite the stale set.
-   * @param actions - bound actions of the entry's layout store instance.
+   * @param panels - actions of the instance shared with the root entry.
+   * @param hasMainPanel - checks the live main-slot registry for a panel id.
    */
-  attachPanels(actions: PanelActions): void {
-    this.#panels = actions
+  constructor(
+    private readonly panels: PanelActions,
+    private readonly hasMainPanel: (id: MainPanelId) => boolean,
+  ) {}
+
+  /** Select a global panel or return to the Conversation. */
+  selectPanel(panelId: MainPanelId | null): void {
+    if (panelId !== null && !this.hasMainPanel(panelId)) {
+      throw new Error(`layout.selectPanel: main panel "${panelId}" is not registered`)
+    }
+    this.navigation.abort()
+    this.panels.selectPanel(panelId)
+  }
+
+  /** @returns the new pending navigation's cancellation signal. */
+  beginNavigation(): AbortSignal {
+    this.navigation.abort()
+    this.navigation = new AbortController()
+    return this.navigation.signal
+  }
+
+  /** Invalidate pending navigations when the layout owner is unloaded. */
+  dispose(): void {
+    this.navigation.abort()
   }
 
   /** Toggle the sidebar panel (closed ⟷ contract default width). */
   toggleSidebar(): void {
-    this.#require().toggleSidebar()
+    this.panels.toggleSidebar()
   }
 
   /** Report the right panel's track and fullscreen presentation. */
   openRightbar(track: boolean, fullscreen: boolean): void {
-    this.#require().openRightbar(track, fullscreen)
+    this.panels.openRightbar(track, fullscreen)
   }
 
   /** Report the right panel as hidden: no track, no handle. */
   closeRightbar(): void {
-    this.#require().closeRightbar()
-  }
-
-  #require(): PanelActions {
-    // Callers are UI gestures, which cannot fire before the root entry
-    // rendered (the inject hook runs in its first render) — reaching this
-    // unwired is a boot-order bug, not a race to tolerate.
-    if (this.#panels === undefined) throw new Error('layout: panel actions not wired (root entry not mounted)')
-    return this.#panels
+    this.panels.closeRightbar()
   }
 }

+ 53 - 31
packages/client/ui-layout/src/client/stores.ts

@@ -3,6 +3,7 @@
  * The registration supplies a fresh store and binds its actions to ctx.layout.
  */
 import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import type { MainPanelId } from './service.ts'
 import {
   clampWidth, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN,
   SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
@@ -13,6 +14,14 @@ import {
  * the right panel's expanded state belongs to its occupant.
  */
 type LayoutState = {
+  panelInfo: {
+    /** Null selects the Conversation; global panels keep the current Session intact. */
+    activePanelId: MainPanelId | null
+  }
+  layoutInfo: LayoutInfo
+}
+
+type LayoutInfo = {
   sidebar: number
   /** Last positive frame measurement; window width bootstraps the first render. */
   viewportWidth: number
@@ -47,6 +56,8 @@ type LayoutState = {
  * return type); drift fails assignability at the defineStore call.
  */
 type LayoutActions = {
+  selectPanel: (draft: LayoutState, panelId: MainPanelId | null) => void
+  retainMainPanels: (draft: LayoutState, panelIds: readonly string[]) => void
   setSidebar: (draft: LayoutState, px: number) => void
   toggleSidebar: (draft: LayoutState) => void
   setViewportWidth: (draft: LayoutState, width: number) => void
@@ -67,56 +78,67 @@ type LayoutActions = {
 export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutActions>  {
   const handle = defineStore({
     init: (): LayoutState => ({
-      sidebar: SIDEBAR_DEFAULT,
-      viewportWidth: window.innerWidth,
-      narrowExpanded: false,
-      rightbar: null,
-      rightbarShown: false,
-      rightbarTrack: false,
-      rightbarFullscreen: false,
-      rightbarInstant: false,
+      panelInfo: { activePanelId: null },
+      layoutInfo: {
+        sidebar: SIDEBAR_DEFAULT,
+        viewportWidth: window.innerWidth,
+        narrowExpanded: false,
+        rightbar: null,
+        rightbarShown: false,
+        rightbarTrack: false,
+        rightbarFullscreen: false,
+        rightbarInstant: false,
+      },
     }),
     actions: {
+      selectPanel: (d, panelId: MainPanelId | null) => {
+        d.panelInfo.activePanelId = panelId
+      },
+      retainMainPanels: (d, panelIds: readonly string[]) => {
+        if (d.panelInfo.activePanelId !== null && !panelIds.includes(d.panelInfo.activePanelId)) {
+          d.panelInfo.activePanelId = null
+        }
+      },
       setSidebar: (d, px: number) => {
-        d.rightbarInstant = false
-        d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX)
+        d.layoutInfo.rightbarInstant = false
+        d.layoutInfo.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX)
       },
       // Narrow toggles flip only the override: the width preference survives
       // untouched, so re-widening restores the pre-squeeze layout.
       toggleSidebar: (d) => {
-        d.rightbarInstant = false
-        if (d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = !d.narrowExpanded
-        else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0
+        d.layoutInfo.rightbarInstant = false
+        if (d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.layoutInfo.narrowExpanded = !d.layoutInfo.narrowExpanded
+        else d.layoutInfo.sidebar = d.layoutInfo.sidebar === 0 ? SIDEBAR_DEFAULT : 0
       },
       // Crossing the breakpoint in either direction drops the override: the
       // narrow default is auto-collapsed, the wide state is the preference.
       setViewportWidth: (d, width: number) => {
-        if (d.viewportWidth === width) return
-        d.rightbarInstant = false
-        if ((d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) {
-          d.narrowExpanded = false
+        if (d.layoutInfo.viewportWidth === width) return
+        d.layoutInfo.rightbarInstant = false
+        if ((d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) {
+          d.layoutInfo.narrowExpanded = false
         }
-        d.viewportWidth = width
+        d.layoutInfo.viewportWidth = width
       },
       setRightbar: (d, px: number) => {
-        d.rightbarInstant = false
-        d.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.viewportWidth * RIGHTBAR_MAX_RATIO))
+        d.layoutInfo.rightbarInstant = false
+        d.layoutInfo.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.layoutInfo.viewportWidth * RIGHTBAR_MAX_RATIO))
       },
       openRightbar: (d, track: boolean, fullscreen: boolean) => {
-        if (!d.rightbarShown || d.rightbarTrack !== track || d.rightbarFullscreen !== fullscreen) {
-          d.rightbarInstant = d.rightbarFullscreen && !fullscreen
+        if (!d.layoutInfo.rightbarShown || d.layoutInfo.rightbarTrack !== track || d.layoutInfo.rightbarFullscreen !== fullscreen) {
+          d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen && !fullscreen
         }
-        if (!d.rightbarShown && d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = false
-        d.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.viewportWidth * RIGHTBAR_DEFAULT_RATIO))
-        d.rightbarShown = true
-        d.rightbarTrack = track
-        d.rightbarFullscreen = fullscreen
+        if (!d.layoutInfo.rightbarShown && d.layoutInfo.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.layoutInfo.narrowExpanded = false
+        d.layoutInfo.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.layoutInfo.viewportWidth * RIGHTBAR_DEFAULT_RATIO))
+        d.layoutInfo.rightbarShown = true
+        d.layoutInfo.rightbarTrack = track
+        d.layoutInfo.rightbarFullscreen = fullscreen
       },
       closeRightbar: (d) => {
-        if (d.rightbarShown) d.rightbarInstant = d.rightbarFullscreen
-        d.rightbarShown = false
-        d.rightbarTrack = false
-        d.rightbarFullscreen = false
+        if (d.layoutInfo.rightbarShown) d.layoutInfo.rightbarInstant = d.layoutInfo.rightbarFullscreen
+        d.layoutInfo.rightbarShown = false
+        d.layoutInfo.rightbarTrack = false
+        d.layoutInfo.rightbarFullscreen = false
       },
     },
   })

+ 64 - 46
packages/client/ui-layout/tests/app-frame.client.spec.tsx

@@ -1,14 +1,13 @@
 // @vitest-environment jsdom
 /** Frame interactions with a real store and explicitly driven browser measurements. */
-import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
+import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, render } from '@testing-library/react'
-import { useSyncExternalStore } from 'react'
 import { AppFrame } from '../src/client/AppFrame.tsx'
 import type { AppFrameProps } from '../src/client/AppFrame.tsx'
-import type { RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts'
+import type { MainPanelId, RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts'
 import { createLayoutStore } from '../src/client/stores.ts'
-import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
@@ -19,8 +18,6 @@ let workspacesReady = true
 type AttentionSnapshot = Parameters<Parameters<AppFrameProps['useSessionPendingInteraction']>[0]>[0]
 const noAttention: AttentionSnapshot = new Map()
 const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention)
-const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
-  selectedSession === undefined ? <>{empty?.() ?? null}</> : <>{children}</>
 
 let observers: ResizeObserverStub[]
 class ResizeObserverStub {
@@ -63,19 +60,15 @@ function resize(width: number): void {
   })
 }
 
-function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
-  return function useSelector<S>(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) }
-}
-
 function mountFrame(windowWidth = frameWidth) {
   vi.stubGlobal('innerWidth', windowWidth)
   const instance = createLayoutStore().create()
-  const slotCalls: { key: string; props: object }[] = []
-  const renderSlot = ((key: string, owner: object) => {
-    slotCalls.push({ key, props: owner })
-    return <div data-testid={`${key}-content`} />
-  }) as AppFrameProps['renderSlot']
-  const useSessions = ((sel: (s: SessionListState) => unknown) => sel({
+  const slotCalls: { key: string; props: object; options: RenderOpts | undefined }[] = []
+  const renderSlot: AppFrameProps['renderSlot'] = (key, owner, options) => {
+    slotCalls.push({ key, props: owner, options })
+    return <div data-testid={`${key}-content`} data-entry-key={options?.entryKey} />
+  }
+  const useSessions: AppFrameProps['useSessions'] = sel => sel({
     ids: selectedSession === undefined ? [] : [selectedSession],
     byId: selectedSession === undefined ? {} : {
       [selectedSession]: {
@@ -85,22 +78,29 @@ function mountFrame(windowWidth = frameWidth) {
     },
     current: selectedSession,
     phase: 'ready',
-  } as SessionListState)) as AppFrameProps['useSessions']
+    subagentsByParent: {},
+    jobsBySession: {},
+    currentAddress: undefined,
+  })
   const workspaceState: WorkspaceSnapshot = {
     items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
     ...(workspacesReady ? {} : { state: 'loading' as const, phase: 'pending' as const }),
   }
-  const useStore = hookOf(instance)
+  const useStore = bindSnapshotSelector(instance)
+  const usePanelInfo = bindSnapshotSelector({
+    getSnapshot: () => instance.getSnapshot().panelInfo,
+    subscribe: listener => instance.subscribe(listener),
+  })
   const element = () => (
     <AppFrame
       useStore={useStore}
       actions={instance.actions}
       renderSlot={renderSlot}
       useSessions={useSessions}
+      usePanelInfo={usePanelInfo}
       useSessionPendingInteraction={useSessionPendingInteraction}
       useResource={useResource}
-      useWorkspaces={((sel: (s: WorkspaceSnapshot) => unknown) => sel(workspaceState)) as AppFrameProps['useWorkspaces']}
-      SessionProvider={SessionProviderStub}
+      useWorkspaces={sel => sel(workspaceState)}
       t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key}
     />
   )
@@ -202,22 +202,22 @@ describe('AppFrame', () => {
     expect(tracks(frame)).toEqual([280, 0])
     expect(sidebarOwner()).toEqual({ collapsed: false, width: 280 })
     expect(rightOwner()).toEqual({ width: 864, viewportWidth: 1920, canShow: true })
-    expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({})
+    expect(slotCalls.find(c => c.key === 'main')).toEqual({ key: 'main', props: {}, options: { entryKey: 'conversation' } })
   })
 
-  it('retains conversation and sidebar content without a current Session', () => {
+  it('renders the main, sidebar, and root-scoped rightbar outlets without a current Session', () => {
     selectedSession = undefined
-    const { frame, getByTestId, queryByTestId } = mountFrame()
-    expect(getByTestId('conversation-content')).toBeTruthy()
+    const { frame, getByTestId } = mountFrame()
+    expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation')
     expect(getByTestId('sidebar-content')).toBeTruthy()
-    expect(queryByTestId('rightbar-content')).toBeNull()
+    expect(getByTestId('rightbar-content')).toBeTruthy()
     expect(tracks(frame)).toEqual([280, 0])
   })
 
   it('renders both occupants before workspace baselines settle', () => {
     workspacesReady = false
     const { getByTestId } = mountFrame()
-    expect(getByTestId('conversation-content')).toBeTruthy()
+    expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe('conversation')
     expect(getByTestId('rightbar-content')).toBeTruthy()
   })
 
@@ -229,16 +229,34 @@ describe('AppFrame', () => {
     expect(getByTestId('sidebar-content')).toBeTruthy()
     expect(frame.querySelector('[data-side="sidebar"]')).toBeNull()
   })
+
+  it('switches only the keyed main outlet when the active panel changes', () => {
+    selectedSessionTitle = 'Session title'
+    const { instance, frame, slotCalls, getByTestId } = mountFrame()
+    const sessionId = selectedSession
+    const layoutInfo = instance.getSnapshot().layoutInfo
+    for (const panelId of ['panel-a' as MainPanelId, 'panel-b' as MainPanelId, null]) {
+      slotCalls.length = 0
+      act(() => { instance.actions.selectPanel(panelId) })
+      expect(slotCalls).toEqual([{ key: 'main', props: {}, options: { entryKey: panelId ?? 'conversation' } }])
+      expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe(panelId ?? 'conversation')
+      expect(instance.getSnapshot().panelInfo).toEqual({ activePanelId: panelId })
+      expect(instance.getSnapshot().layoutInfo).toBe(layoutInfo)
+      expect(tracks(frame)).toEqual([280, 0])
+      expect(selectedSession).toBe(sessionId)
+      expect(document.title).toBe(panelId === null ? 'Session title — DSH Local Build' : 'DSH Local Build')
+    }
+  })
 })
 
 describe('AppFrame normal width concessions', () => {
   it('measures the frame, not the window, before choosing the first-open preference', () => {
     frameWidth = 1000
     const { instance, rightOwner } = mountFrame(1920)
-    expect(instance.getSnapshot().viewportWidth).toBe(1000)
+    expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1000)
     expect(rightOwner()).toEqual({ width: 450, viewportWidth: 1000, canShow: true })
     act(() => { instance.actions.openRightbar(true, false) })
-    expect(instance.getSnapshot().rightbar).toBe(450)
+    expect(instance.getSnapshot().layoutInfo.rightbar).toBe(450)
     resize(1920)
     expect(rightOwner().width).toBe(450)
   })
@@ -255,7 +273,7 @@ describe('AppFrame normal width concessions', () => {
     expect(tracks(frame)).toEqual([420, 0])
     expect(rightOwner()).toEqual({ width: 0, viewportWidth: 1119, canShow: false })
     expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
-    expect(instance.getSnapshot()).toMatchObject({ rightbarShown: true, rightbar: 864 })
+    expect(instance.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbar: 864 })
     act(() => { instance.actions.closeRightbar() })
     resize(455)
     expect(tracks(frame)).toEqual([56, 0])
@@ -271,7 +289,7 @@ describe('AppFrame normal width concessions', () => {
     expect(rightOwner()).toEqual({ width: 344, viewportWidth: 800, canShow: true })
     act(() => { instance.actions.openRightbar(true, false) })
     expect(tracks(frame)).toEqual([56, 344])
-    expect(instance.getSnapshot()).toMatchObject({ narrowExpanded: false, rightbar: 360 })
+    expect(instance.getSnapshot().layoutInfo).toMatchObject({ narrowExpanded: false, rightbar: 360 })
     expect(rightOwner().canShow).toBe(true)
   })
 
@@ -312,7 +330,7 @@ describe('AppFrame normal width concessions', () => {
     resize(980)
     act(() => { instance.actions.toggleSidebar() })
     expect(tracks(frame)[0]).toBe(280)
-    expect(instance.getSnapshot().sidebar).toBe(0)
+    expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
   })
 })
 
@@ -404,7 +422,7 @@ describe('AppFrame right panel presentation', () => {
     act(() => { instance.actions.openRightbar(false, true) })
     expect(tracks(frame)).toEqual([56, 0])
     expect(rightOwner()).toEqual({ width: 0, viewportWidth: 700, canShow: false })
-    expect(instance.getSnapshot().rightbarShown).toBe(true)
+    expect(instance.getSnapshot().layoutInfo.rightbarShown).toBe(true)
     expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
   })
 
@@ -415,7 +433,7 @@ describe('AppFrame right panel presentation', () => {
     expect(tracks(frame)).toEqual([280, 0])
     expect(rightOwner().width).toBe(420)
     drag(handleFor(frame, 'rightbar'), 680, 690)
-    expect(instance.getSnapshot().rightbar).toBe(410)
+    expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410)
     expect(rightOwner().width).toBe(410)
     expect(tracks(frame)[1]).toBe(0)
   })
@@ -436,7 +454,7 @@ describe('AppFrame pointer resizing', () => {
     act(flushFrames)
     expect(tracks(frame)[0]).toBe(360)
     pointer(handle, 'pointerup', 360)
-    expect(instance.getSnapshot().sidebar).toBe(360)
+    expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360)
     expect(frame.dataset.dragging).toBeUndefined()
     expect(handle.hasPointerCapture(1)).toBe(false)
   })
@@ -450,7 +468,7 @@ describe('AppFrame pointer resizing', () => {
     expect(tracks(frame)[1]).toBe(420)
     expect(handle.style.left).toBe('680px')
     drag(handle, 680, 690)
-    expect(instance.getSnapshot().rightbar).toBe(410)
+    expect(instance.getSnapshot().layoutInfo.rightbar).toBe(410)
     expect(rightOwner().width).toBe(410)
     expect(tracks(frame)[1]).toBe(410)
     expect(handle.style.left).toBe('690px')
@@ -474,10 +492,10 @@ describe('AppFrame pointer resizing', () => {
     pointer(handle, 'pointerdown', 280)
     pointer(handle, 'pointermove', 320)
     pointer(handle, 'pointerup', 360)
-    expect(instance.getSnapshot().sidebar).toBe(360)
+    expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360)
     expect(animationFrames.size).toBe(0)
     act(flushFrames)
-    expect(instance.getSnapshot().sidebar).toBe(360)
+    expect(instance.getSnapshot().layoutInfo.sidebar).toBe(360)
   })
 
   it('ignores uncaptured motion, secondary buttons, and a second pointer', () => {
@@ -493,9 +511,9 @@ describe('AppFrame pointer resizing', () => {
     pointer(handle, 'pointermove', 500, 9)
     pointer(handle, 'pointerup', 500, 9)
     expect(animationFrames.size).toBe(0)
-    expect(instance.getSnapshot().sidebar).toBe(280)
+    expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280)
     pointer(handle, 'pointerup', 300)
-    expect(instance.getSnapshot().sidebar).toBe(300)
+    expect(instance.getSnapshot().layoutInfo.sidebar).toBe(300)
   })
 
   it.each(['pointercancel', 'lostpointercapture'])('ends %s without committing queued motion', (event) => {
@@ -506,7 +524,7 @@ describe('AppFrame pointer resizing', () => {
     if (event === 'lostpointercapture') handle.releasePointerCapture(1)
     pointer(handle, event, 340)
     act(flushFrames)
-    expect(instance.getSnapshot().sidebar).toBe(280)
+    expect(instance.getSnapshot().layoutInfo.sidebar).toBe(280)
     expect(animationFrames.size).toBe(0)
     expect(frame.dataset.dragging).toBeUndefined()
     expect(handle.hasPointerCapture(1)).toBe(false)
@@ -526,7 +544,7 @@ describe('AppFrame pointer resizing', () => {
     const settled = instance.getSnapshot()
     act(flushFrames)
     expect(instance.getSnapshot()).toBe(settled)
-    expect(instance.getSnapshot().rightbar).toBe(864)
+    expect(instance.getSnapshot().layoutInfo.rightbar).toBe(864)
     expect(animationFrames.size).toBe(0)
     expect(handle.hasPointerCapture(1)).toBe(false)
     if (change !== 'unmount') expect(frame.dataset.dragging).toBeUndefined()
@@ -544,17 +562,17 @@ describe('AppFrame frame measurement lifecycle', () => {
       observer.fire()
     })
     expect(animationFrames.size).toBe(1)
-    expect(instance.getSnapshot().viewportWidth).toBe(1920)
+    expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920)
     act(flushFrames)
-    expect(instance.getSnapshot().viewportWidth).toBe(1200)
+    expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1200)
     expect(rightOwner().viewportWidth).toBe(1200)
-    expect(instance.getSnapshot().rightbar).toBeNull()
+    expect(instance.getSnapshot().layoutInfo.rightbar).toBeNull()
   })
 
   it('retains the last positive measurement while the frame is hidden', () => {
     const { instance, rightOwner } = mountFrame()
     resize(0)
-    expect(instance.getSnapshot().viewportWidth).toBe(1920)
+    expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920)
     expect(rightOwner().viewportWidth).toBe(1920)
   })
 
@@ -568,7 +586,7 @@ describe('AppFrame frame measurement lifecycle', () => {
     expect(observer.disconnected).toBe(true)
     expect(animationFrames.size).toBe(0)
     act(() => { observer.fire(); flushFrames() })
-    expect(instance.getSnapshot().viewportWidth).toBe(1920)
+    expect(instance.getSnapshot().layoutInfo.viewportWidth).toBe(1920)
     expect(animationFrames.size).toBe(0)
   })
 })

+ 86 - 18
packages/client/ui-layout/tests/apply.client.spec.ts

@@ -1,20 +1,58 @@
 // @vitest-environment jsdom
 
-import { Context } from '@deepseek-ai/cordis'
+import { Context, type Fiber } from '@deepseek-ai/cordis'
 import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { apply as themeApply, inject as themeInject, ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
 import { apply, inject, LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client'
 import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-layout'
+import type { MainPanelId } from '../src/client/service.ts'
+import type { createLayoutStore } from '../src/client/stores.ts'
+
+const owners = new Set<Fiber>()
+let originalRootStyle: string | null
+let originalBodyStyle: string | null
+let originalDarkTheme: string | null
+let originalThemeMetadata: Element[]
 
 beforeEach(() => {
-  document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() })
+  originalRootStyle = document.documentElement.getAttribute('style')
+  originalBodyStyle = document.body.getAttribute('style')
+  originalDarkTheme = document.body.getAttribute('data-ds-dark-theme')
+  originalThemeMetadata = [...document.head.querySelectorAll('meta[name="theme-color"]')]
+  originalThemeMetadata.forEach((node) => { node.remove() })
+  vi.stubGlobal('innerWidth', 1920)
+})
+
+afterEach(async () => {
+  try {
+    for (const owner of owners) await owner.dispose()
+  } finally {
+    owners.clear()
+    restoreAttribute(document.documentElement, 'style', originalRootStyle)
+    restoreAttribute(document.body, 'style', originalBodyStyle)
+    restoreAttribute(document.body, 'data-ds-dark-theme', originalDarkTheme)
+    document.head.querySelectorAll('meta[name="theme-color"]').forEach((node) => { node.remove() })
+    document.head.append(...originalThemeMetadata)
+    vi.unstubAllGlobals()
+  }
 })
 
+function restoreAttribute(element: Element, name: string, value: string | null): void {
+  if (value === null) element.removeAttribute(name)
+  else element.setAttribute(name, value)
+}
+
 async function bench() {
-  const ctx = new Context()
+  const root = new Context()
+  let ctx: Context | undefined
+  const owner = root.plugin((ownedContext: Context) => { ctx = ownedContext })
+  owners.add(owner)
+  await owner.await()
+  if (ctx === undefined) throw new Error('the fixture owner did not activate')
   const slotsFiber = ctx.plugin(SlotRegistry)
   // Theme registers its Appearance settings row and requires the connection
   // seam for persistence; model this bench as a remote, memory-only browser.
@@ -25,7 +63,15 @@ async function bench() {
   ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   await ctx.plugin({ inject: themeInject, apply: themeApply }).await()
   await slotsFiber.await()
-  return { ctx, slots: ctx.get('slots') as SlotRegistry }
+  const slots = ctx.get('slots') as SlotRegistry
+  let host: SlotRendererHost | undefined
+  slots.install({ renderRoot: (value) => { host = value; return null } })
+  const rendererHost = (): SlotRendererHost => {
+    slots.renderSlot('root', {})
+    if (host === undefined) throw new Error('the root renderer did not receive its host')
+    return host
+  }
+  return { ctx, slots, rendererHost }
 }
 
 describe('ui-layout client apply', () => {
@@ -33,30 +79,46 @@ describe('ui-layout client apply', () => {
     expect(inject).toEqual(['slots', 'theme', 'locale'])
   })
 
-  it('provides ctx.layout and registers AppFrame into root with the three child declarations', async () => {
+  it('provides ctx.layout and declares the four root-scoped frame slots', async () => {
     const { ctx, slots } = await bench()
     const fiber = ctx.plugin({ inject: [...inject], apply })
     await fiber.await()
     expect(ctx.get('layout')).toBeInstanceOf(LayoutController)
-    // The one register() call occupied 'root'…
     expect(slots.entries('root')).toHaveLength(1)
-    // …and declared the three children in the ledger.
     expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' })
-    expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session-maybe' })
+    expect(slots.spec('main')).toEqual({ kind: 'keyed', scope: 'root' })
+    expect(slots.spec('rightbar')).toEqual({ kind: 'single', scope: 'root' })
+    expect(slots.spec('shell.overlay')).toEqual({ kind: 'list', scope: 'root' })
   })
 
-  it('injects no business face and attaches the layout actions', async () => {
-    const { ctx, slots } = await bench()
+  it('shares a pre-created instance between service actions, root rendering, and panelInfo', async () => {
+    const { ctx, slots, rendererHost } = await bench()
     const fiber = ctx.plugin({ inject: [...inject], apply })
     await fiber.await()
-    const actions = {
-      setSidebar: vi.fn(), setDetails: vi.fn(), toggleSidebar: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
-    }
-    const injected = (slots.entries('root')[0]!.inject as (actions: never) => object)(actions as never)
-    expect(injected).toEqual({})
+    const entry = slots.entries('root')[0]!
+    expect(entry.inject).toBeUndefined()
+    const handle = entry.store as ReturnType<typeof createLayoutStore>
+    const instance = handle.create()
+    expect(handle.create()).toBe(instance)
     const layout = ctx.get('layout') as LayoutController
+    expect(() => { layout.selectPanel('missing' as MainPanelId) }).toThrow('main panel "missing" is not registered')
     layout.toggleSidebar()
-    expect(actions.toggleSidebar).toHaveBeenCalledOnce()
+    expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
+    const host = rendererHost()
+    expect(host.storeOf(entry, undefined)).toBe(instance)
+    const panelInfo = host.root.getSnapshot().hooks.panelInfo!
+    expect(panelInfo.getSnapshot()).toBe(instance.getSnapshot().panelInfo)
+    const panelId = 'panel-a' as MainPanelId
+    const disposePanel = slots.register({ name: 'main', key: panelId }, () => null)
+    layout.selectPanel(panelId)
+    expect(panelInfo.getSnapshot()).toEqual({ activePanelId: panelId })
+    disposePanel()
+    await vi.waitFor(() => { expect(panelInfo.getSnapshot()).toEqual({ activePanelId: null }) })
+    expect(() => { layout.selectPanel(panelId) }).toThrow('main panel "panel-a" is not registered')
+    expect(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
+    const pending = layout.beginNavigation()
+    await fiber.dispose()
+    expect(pending.aborted).toBe(true)
   })
 
   it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => {
@@ -85,13 +147,19 @@ describe('ui-layout client apply', () => {
   })
 
   it('teardown unwinds the service, the root registration, and the child declarations', async () => {
-    const { ctx, slots } = await bench()
+    const { ctx, slots, rendererHost } = await bench()
     const fiber = ctx.plugin({ inject: [...inject], apply })
     await fiber.await()
+    const host = rendererHost()
+    expect(host.root.getSnapshot().hooks.panelInfo).toBeDefined()
     await fiber.dispose()
     expect(ctx.get('layout')).toBeUndefined()
     expect(slots.entries('root')).toHaveLength(0)
     expect(slots.spec('sidebar')).toBeUndefined()
+    expect(slots.spec('main')).toBeUndefined()
+    expect(slots.spec('rightbar')).toBeUndefined()
+    expect(slots.spec('shell.overlay')).toBeUndefined()
+    expect(host.root.getSnapshot().hooks.panelInfo).toBeUndefined()
     // The built-in root declaration survives entry teardown (renderer-owned).
     expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
   })

+ 60 - 14
packages/client/ui-layout/tests/document-title.client.spec.tsx

@@ -1,36 +1,82 @@
 // @vitest-environment jsdom
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, render } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { act, cleanup, render } from '@testing-library/react'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { DocumentTitle } from '../src/client/DocumentTitle.tsx'
+import type { MainPanelId, PanelInfo } from '../src/client/service.ts'
 
+let originalTitle: string
+beforeEach(() => { originalTitle = document.title })
 afterEach(() => {
-  cleanup()
-  document.title = ''
-  vi.unstubAllEnvs()
+  try { cleanup() } finally { document.title = originalTitle }
 })
 
+function titleSources() {
+  const sessionId = 'session-title' as SessionId
+  const sessions = createSnapshotStore<SessionListState>({
+    ids: [sessionId],
+    byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } },
+    current: sessionId,
+    phase: 'ready',
+    subagentsByParent: {},
+    jobsBySession: {},
+    currentAddress: undefined,
+  })
+  const panelInfo = createSnapshotStore<PanelInfo>({ activePanelId: null })
+  return {
+    sessionId, sessions, panelInfo,
+    props: { useSessions: bindSnapshotSelector(sessions), usePanelInfo: bindSnapshotSelector(panelInfo) },
+  }
+}
+
 describe('DocumentTitle', () => {
   it('projects a durable title and restores the product title', () => {
-    vi.stubEnv('DSH_CLIENT_TITLE', 'DeepSeek Harness')
+    const { sessionId, sessions, props } = titleSources()
     document.title = 'stale title'
-    const mounted = render(<DocumentTitle productTitle="DeepSeek Harness" />)
+    const mounted = render(<DocumentTitle {...props} productTitle="DeepSeek Harness" />)
     expect(document.title).toBe('DeepSeek Harness')
-    mounted.rerender(<DocumentTitle title="First title" productTitle="DeepSeek Harness" />)
+    act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'First title' }) })
     expect(document.title).toBe('First title — DeepSeek Harness')
-    mounted.rerender(<DocumentTitle title="Revised title" productTitle="DeepSeek Harness" />)
+    act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Revised title' }) })
     expect(document.title).toBe('Revised title — DeepSeek Harness')
-    mounted.rerender(<DocumentTitle productTitle="DeepSeek Harness" />)
+    act(() => { sessions.update((state) => { state.current = undefined }) })
     expect(document.title).toBe('DeepSeek Harness')
     mounted.unmount()
     expect(document.title).toBe('DeepSeek Harness')
   })
 
-  it('uses the generic title when the build provides no title', () => {
-    vi.stubEnv('DSH_CLIENT_TITLE', '')
-    delete process.env.DSH_CLIENT_TITLE
-    const mounted = render(<DocumentTitle title="First title" productTitle="DSH Local Build" />)
+  it('uses the localized product title supplied by the frame', () => {
+    const { sessionId, sessions, props } = titleSources()
+    sessions.update((state) => { state.byId[sessionId]!.title = 'First title' })
+    const mounted = render(<DocumentTitle {...props} productTitle="DSH Local Build" />)
     expect(document.title).toBe('First title — DSH Local Build')
     mounted.unmount()
     expect(document.title).toBe('DSH Local Build')
   })
+
+  it('keeps the product title across global panels and restores the latest Session title on return', () => {
+    const { sessionId, sessions, panelInfo, props } = titleSources()
+    sessions.update((state) => { state.byId[sessionId]!.title = 'Session title' })
+    render(<DocumentTitle {...props} productTitle="Product" />)
+    expect(document.title).toBe('Session title — Product')
+    act(() => { panelInfo.set({ activePanelId: 'panel-a' as MainPanelId }) })
+    expect(document.title).toBe('Product')
+    act(() => { sessions.update((state) => { state.byId[sessionId]!.title = 'Updated title' }) })
+    expect(document.title).toBe('Product')
+    act(() => { panelInfo.set({ activePanelId: 'panel-b' as MainPanelId }) })
+    expect(document.title).toBe('Product')
+    expect(sessions.getSnapshot().current).toBe(sessionId)
+    act(() => { panelInfo.set({ activePanelId: null }) })
+    expect(document.title).toBe('Updated title — Product')
+  })
+
+  it('uses the product title when the current Session row is not available', () => {
+    const { sessions, props } = titleSources()
+    sessions.update((state) => { state.byId = {}; state.ids = [] })
+    render(<DocumentTitle {...props} productTitle="Product" />)
+    expect(document.title).toBe('Product')
+  })
 })

+ 115 - 49
packages/client/ui-layout/tests/layout-store.client.spec.ts

@@ -2,6 +2,7 @@
 
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { createLayoutStore } from '../src/client/stores.ts'
+import type { MainPanelId } from '../src/client/service.ts'
 
 beforeEach(() => { vi.stubGlobal('innerWidth', 1920) })
 afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
@@ -10,14 +11,17 @@ describe('createLayoutStore', () => {
   it('starts with the default sidebar and no right panel preference', () => {
     const { store } = createLayoutStore().create()
     expect(store.getSnapshot()).toEqual({
-      sidebar: 280,
-      viewportWidth: 1920,
-      narrowExpanded: false,
-      rightbar: null,
-      rightbarShown: false,
-      rightbarTrack: false,
-      rightbarFullscreen: false,
-      rightbarInstant: false,
+      panelInfo: { activePanelId: null },
+      layoutInfo: {
+        sidebar: 280,
+        viewportWidth: 1920,
+        narrowExpanded: false,
+        rightbar: null,
+        rightbarShown: false,
+        rightbarTrack: false,
+        rightbarFullscreen: false,
+        rightbarInstant: false,
+      },
     })
   })
 
@@ -27,26 +31,26 @@ describe('createLayoutStore', () => {
     const b = createLayoutStore().create()
     a.actions.setSidebar(400)
     a.actions.openRightbar(true, false)
-    expect(b.store.getSnapshot().sidebar).toBe(280)
-    expect(b.store.getSnapshot().rightbar).toBeNull()
+    expect(b.store.getSnapshot().layoutInfo.sidebar).toBe(280)
+    expect(b.store.getSnapshot().layoutInfo.rightbar).toBeNull()
     expect(write).not.toHaveBeenCalled()
   })
 
   it('clamps the sidebar to 264–420px', () => {
     const { store, actions } = createLayoutStore().create()
     actions.setSidebar(1)
-    expect(store.getSnapshot().sidebar).toBe(264)
+    expect(store.getSnapshot().layoutInfo.sidebar).toBe(264)
     actions.setSidebar(9999)
-    expect(store.getSnapshot().sidebar).toBe(420)
+    expect(store.getSnapshot().layoutInfo.sidebar).toBe(420)
   })
 
   it('toggles the wide sidebar between closed and default width', () => {
     const { store, actions } = createLayoutStore().create()
     actions.setSidebar(400)
     actions.toggleSidebar()
-    expect(store.getSnapshot().sidebar).toBe(0)
+    expect(store.getSnapshot().layoutInfo.sidebar).toBe(0)
     actions.toggleSidebar()
-    expect(store.getSnapshot().sidebar).toBe(280)
+    expect(store.getSnapshot().layoutInfo.sidebar).toBe(280)
   })
 
   it('keeps the sidebar preference while toggling its narrow override', () => {
@@ -54,9 +58,9 @@ describe('createLayoutStore', () => {
     actions.setSidebar(400)
     actions.setViewportWidth(980)
     actions.toggleSidebar()
-    expect(store.getSnapshot()).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true })
+    expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true })
     actions.toggleSidebar()
-    expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false })
+    expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false })
   })
 
   it('clears the manual override only when crossing 1024px', () => {
@@ -65,39 +69,101 @@ describe('createLayoutStore', () => {
     actions.toggleSidebar()
     actions.setViewportWidth(980)
     actions.setViewportWidth(1023)
-    expect(store.getSnapshot().narrowExpanded).toBe(true)
+    expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true)
     actions.setViewportWidth(1024)
-    expect(store.getSnapshot().narrowExpanded).toBe(false)
+    expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false)
     actions.setViewportWidth(980)
-    expect(store.getSnapshot().narrowExpanded).toBe(false)
+    expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false)
   })
 })
 
+describe('main panel selection', () => {
+  const panelA = 'panel-a' as MainPanelId
+  const panelB = 'panel-b' as MainPanelId
+
+  it('changes only panelInfo when switching panels and returning to the Conversation', () => {
+    const { store, actions } = createLayoutStore().create()
+    actions.setSidebar(400)
+    actions.openRightbar(true, true)
+    actions.closeRightbar()
+    const layoutInfo = store.getSnapshot().layoutInfo
+    for (const activePanelId of [panelA, panelB, null]) {
+      const previousPanelInfo = store.getSnapshot().panelInfo
+      actions.selectPanel(activePanelId)
+      expect(store.getSnapshot().panelInfo).toEqual({ activePanelId })
+      expect(store.getSnapshot().panelInfo).not.toBe(previousPanelInfo)
+      expect(store.getSnapshot().layoutInfo).toBe(layoutInfo)
+    }
+  })
+
+  it('keeps the complete snapshot when selecting the current panel again', () => {
+    const { store, actions } = createLayoutStore().create()
+    actions.selectPanel(panelA)
+    const selected = store.getSnapshot()
+    actions.selectPanel(panelA)
+    expect(store.getSnapshot()).toBe(selected)
+    expect(store.getSnapshot().panelInfo.activePanelId).toBe(panelA)
+  })
+
+  it('returns to the Conversation only when the selected main registration disappears', () => {
+    const { store, actions } = createLayoutStore().create()
+    const initial = store.getSnapshot()
+    actions.retainMainPanels([])
+    expect(store.getSnapshot()).toBe(initial)
+    actions.selectPanel(panelA)
+    const selected = store.getSnapshot()
+    actions.retainMainPanels(['conversation', panelA, panelB])
+    expect(store.getSnapshot()).toBe(selected)
+    actions.retainMainPanels(['conversation', panelB])
+    expect(store.getSnapshot().panelInfo).toEqual({ activePanelId: null })
+    expect(store.getSnapshot().layoutInfo).toBe(selected.layoutInfo)
+  })
+
+  it.each(['setSidebar', 'toggleSidebar', 'setViewportWidth', 'setRightbar', 'openRightbar', 'closeRightbar'] as const)(
+    'preserves panelInfo identity when %s changes layoutInfo', (action) => {
+      const { store, actions } = createLayoutStore().create()
+      actions.selectPanel(panelA)
+      if (action === 'closeRightbar') actions.openRightbar(true, true)
+      const previous = store.getSnapshot()
+      switch (action) {
+        case 'setSidebar': actions.setSidebar(400); break
+        case 'toggleSidebar': actions.toggleSidebar(); break
+        case 'setViewportWidth': actions.setViewportWidth(980); break
+        case 'setRightbar': actions.setRightbar(500); break
+        case 'openRightbar': actions.openRightbar(true, true); break
+        case 'closeRightbar': actions.closeRightbar(); break
+      }
+      expect(store.getSnapshot().panelInfo).toBe(previous.panelInfo)
+      expect(store.getSnapshot().layoutInfo).not.toBe(previous.layoutInfo)
+    },
+  )
+})
+
 describe('right panel', () => {
   it('initializes at 45% of the latest frame only on first opening', () => {
     const { store, actions } = createLayoutStore().create()
     actions.setViewportWidth(1000)
-    expect(store.getSnapshot().rightbar).toBeNull()
+    expect(store.getSnapshot().layoutInfo.rightbar).toBeNull()
     actions.openRightbar(true, false)
-    expect(store.getSnapshot().rightbar).toBe(450)
+    expect(store.getSnapshot().layoutInfo.rightbar).toBe(450)
     actions.setViewportWidth(2000)
     actions.openRightbar(true, true)
-    expect(store.getSnapshot().rightbar).toBe(450)
+    expect(store.getSnapshot().layoutInfo.rightbar).toBe(450)
     actions.closeRightbar()
     actions.openRightbar(true, false)
-    expect(store.getSnapshot().rightbar).toBe(450)
+    expect(store.getSnapshot().layoutInfo.rightbar).toBe(450)
   })
 
   it('keeps track and fullscreen reports independent and clears both on close', () => {
     const { store, actions } = createLayoutStore().create()
     actions.openRightbar(true, false)
-    expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false })
+    expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false })
     actions.openRightbar(true, true)
-    expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true })
+    expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true })
     actions.openRightbar(false, true)
-    expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true })
+    expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true })
     actions.closeRightbar()
-    expect(store.getSnapshot()).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false })
+    expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false })
   })
 
   it('keeps dragged px preferences across resize, close, and reopen', () => {
@@ -105,29 +171,29 @@ describe('right panel', () => {
     actions.openRightbar(true, false)
     actions.setRightbar(1100)
     actions.setViewportWidth(800)
-    expect(store.getSnapshot().rightbar).toBe(1100)
+    expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100)
     actions.closeRightbar()
     actions.openRightbar(false, true)
-    expect(store.getSnapshot().rightbar).toBe(1100)
+    expect(store.getSnapshot().layoutInfo.rightbar).toBe(1100)
   })
 
   it('clamps drag preferences to 300px and 70% of the current frame', () => {
     const { store, actions } = createLayoutStore().create()
     actions.setViewportWidth(1600)
     actions.setRightbar(9999)
-    expect(store.getSnapshot().rightbar).toBe(1120)
+    expect(store.getSnapshot().layoutInfo.rightbar).toBe(1120)
     actions.setViewportWidth(1000)
     actions.setRightbar(9999)
-    expect(store.getSnapshot().rightbar).toBe(700)
+    expect(store.getSnapshot().layoutInfo.rightbar).toBe(700)
     actions.setRightbar(1)
-    expect(store.getSnapshot().rightbar).toBe(300)
+    expect(store.getSnapshot().layoutInfo.rightbar).toBe(300)
   })
 
   it('retains a minimum normal preference when first opened fullscreen on a phone', () => {
     const { store, actions } = createLayoutStore().create()
     actions.setViewportWidth(320)
     actions.openRightbar(false, true)
-    expect(store.getSnapshot().rightbar).toBe(300)
+    expect(store.getSnapshot().layoutInfo.rightbar).toBe(300)
   })
 
   it('collapses a manually expanded narrow sidebar on opening, not presentation reports', () => {
@@ -136,23 +202,23 @@ describe('right panel', () => {
     actions.setViewportWidth(800)
     actions.toggleSidebar()
     actions.openRightbar(true, false)
-    expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false })
+    expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 400, narrowExpanded: false })
     actions.toggleSidebar()
     actions.openRightbar(true, true)
-    expect(store.getSnapshot().narrowExpanded).toBe(true)
+    expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(true)
     actions.closeRightbar()
     actions.openRightbar(true, false)
-    expect(store.getSnapshot().narrowExpanded).toBe(false)
+    expect(store.getSnapshot().layoutInfo.narrowExpanded).toBe(false)
   })
 
   it('keeps the wide sidebar preference and never opens a closed right panel on resize', () => {
     const { store, actions } = createLayoutStore().create()
     actions.setSidebar(420)
     actions.openRightbar(true, false)
-    expect(store.getSnapshot().sidebar).toBe(420)
+    expect(store.getSnapshot().layoutInfo.sidebar).toBe(420)
     actions.closeRightbar()
     actions.setViewportWidth(3000)
-    expect(store.getSnapshot()).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false })
+    expect(store.getSnapshot().layoutInfo).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false })
   })
 })
 
@@ -161,7 +227,7 @@ describe('right panel instant geometry', () => {
     const { store, actions } = createLayoutStore().create()
     actions.openRightbar(track, true)
     actions.closeRightbar()
-    expect(store.getSnapshot()).toMatchObject({
+    expect(store.getSnapshot().layoutInfo).toMatchObject({
       rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false, rightbarInstant: true,
     })
     const closed = store.getSnapshot()
@@ -173,38 +239,38 @@ describe('right panel instant geometry', () => {
     const { store, actions } = createLayoutStore().create()
     actions.openRightbar(true, true)
     actions.openRightbar(true, false)
-    expect(store.getSnapshot()).toMatchObject({
+    expect(store.getSnapshot().layoutInfo).toMatchObject({
       rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false, rightbarInstant: true,
     })
     const restored = store.getSnapshot()
     actions.openRightbar(true, false)
     expect(store.getSnapshot()).toBe(restored)
     actions.openRightbar(false, false)
-    expect(store.getSnapshot().rightbarInstant).toBe(false)
+    expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
   })
 
   it('allows a normal close to animate, including after restoring from fullscreen', () => {
     const { store, actions } = createLayoutStore().create()
     actions.openRightbar(true, false)
     actions.closeRightbar()
-    expect(store.getSnapshot().rightbarInstant).toBe(false)
+    expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
     actions.openRightbar(true, true)
     actions.openRightbar(true, false)
-    expect(store.getSnapshot().rightbarInstant).toBe(true)
+    expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true)
     actions.closeRightbar()
-    expect(store.getSnapshot()).toMatchObject({ rightbarTrack: false, rightbarInstant: false })
+    expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarTrack: false, rightbarInstant: false })
   })
 
   it.each(['setSidebar', 'toggleSidebar', 'setRightbar', 'setViewportWidth'] as const)('clears instant geometry on %s', (action) => {
     const { store, actions } = createLayoutStore().create()
     actions.openRightbar(true, true)
     actions.closeRightbar()
-    expect(store.getSnapshot().rightbarInstant).toBe(true)
+    expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(true)
     if (action === 'toggleSidebar') actions.toggleSidebar()
     else actions[action](action === 'setViewportWidth' ? 1800 : 350)
-    expect(store.getSnapshot().rightbarInstant).toBe(false)
+    expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
     actions.closeRightbar()
-    expect(store.getSnapshot().rightbarInstant).toBe(false)
+    expect(store.getSnapshot().layoutInfo.rightbarInstant).toBe(false)
   })
 
   it('does not let an unchanged frame measurement reset the fullscreen-exit marker', () => {
@@ -212,7 +278,7 @@ describe('right panel instant geometry', () => {
     actions.openRightbar(true, true)
     actions.closeRightbar()
     const closed = store.getSnapshot()
-    actions.setViewportWidth(closed.viewportWidth)
+    actions.setViewportWidth(closed.layoutInfo.viewportWidth)
     expect(store.getSnapshot()).toBe(closed)
   })
 
@@ -221,6 +287,6 @@ describe('right panel instant geometry', () => {
     actions.openRightbar(true, true)
     actions.closeRightbar()
     actions.openRightbar(true, fullscreen)
-    expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false })
+    expect(store.getSnapshot().layoutInfo).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false })
   })
 })

+ 60 - 19
packages/client/ui-layout/tests/service.client.spec.ts

@@ -1,9 +1,11 @@
 import { describe, expect, it, vi } from 'vitest'
 import { LayoutController } from '../src/client/service.ts'
-import type { PanelActions } from '../src/client/service.ts'
+import type { MainPanelId, PanelActions } from '../src/client/service.ts'
 
 function fakePanels(): PanelActions {
   return {
+    selectPanel: vi.fn(),
+    retainMainPanels: vi.fn(),
     setSidebar: vi.fn(),
     toggleSidebar: vi.fn(),
     setViewportWidth: vi.fn(),
@@ -14,10 +16,9 @@ function fakePanels(): PanelActions {
 }
 
 describe('LayoutController', () => {
-  it('forwards the right column transitions to the attached set', () => {
-    const service = new LayoutController()
+  it('forwards right column transitions to the constructor-supplied actions', () => {
     const panels = fakePanels()
-    service.attachPanels(panels)
+    const service = new LayoutController(panels, () => true)
 
     service.openRightbar(true, false)
     service.openRightbar(true, true)
@@ -32,10 +33,9 @@ describe('LayoutController', () => {
     expect(panels.setRightbar).not.toHaveBeenCalled()
   })
 
-  it('forwards the three panel actions to the attached set', () => {
-    const service = new LayoutController()
+  it('can toggle the sidebar immediately after construction', () => {
     const panels = fakePanels()
-    service.attachPanels(panels)
+    const service = new LayoutController(panels, () => true)
 
     service.toggleSidebar()
 
@@ -43,21 +43,62 @@ describe('LayoutController', () => {
     expect(panels.setSidebar).not.toHaveBeenCalled()
   })
 
-  it('fails loud before the root entry wired its actions', () => {
-    const service = new LayoutController()
-    expect(() => { service.toggleSidebar() }).toThrow(/panel actions not wired/)
+  it('forwards panel selection and returning to the Conversation without changing geometry', () => {
+    const panels = fakePanels()
+    const service = new LayoutController(panels, () => true)
+    const panelId = 'panel-a' as MainPanelId
+    service.selectPanel(panelId)
+    service.selectPanel(panelId)
+    service.selectPanel(null)
+    expect(panels.selectPanel).toHaveBeenNthCalledWith(1, panelId)
+    expect(panels.selectPanel).toHaveBeenNthCalledWith(2, panelId)
+    expect(panels.selectPanel).toHaveBeenNthCalledWith(3, null)
+    expect(panels.toggleSidebar).not.toHaveBeenCalled()
+    expect(panels.openRightbar).not.toHaveBeenCalled()
+    expect(panels.closeRightbar).not.toHaveBeenCalled()
   })
 
-  it('re-attach overwrites the stale action set (entry re-register)', () => {
-    const service = new LayoutController()
-    const stale = fakePanels()
-    const fresh = fakePanels()
-    service.attachPanels(stale)
-    service.attachPanels(fresh)
+  it('keeps separately constructed controllers bound to their own instances', () => {
+    const first = fakePanels()
+    const second = fakePanels()
+    const firstService = new LayoutController(first, () => true)
+    const secondService = new LayoutController(second, () => true)
+    firstService.toggleSidebar()
+    expect(first.toggleSidebar).toHaveBeenCalledTimes(1)
+    expect(second.toggleSidebar).not.toHaveBeenCalled()
+    secondService.closeRightbar()
+    expect(first.closeRightbar).not.toHaveBeenCalled()
+    expect(second.closeRightbar).toHaveBeenCalledTimes(1)
+  })
 
-    service.toggleSidebar()
+  it('rejects an absent main entry without changing selection or cancelling pending navigation', () => {
+    const panels = fakePanels()
+    const present = new Set(['panel-a'])
+    const service = new LayoutController(panels, id => present.has(id))
+    service.selectPanel('panel-a' as MainPanelId)
+    const navigation = service.beginNavigation()
+    present.delete('panel-a')
+    expect(() => { service.selectPanel('panel-a' as MainPanelId) }).toThrow('main panel "panel-a" is not registered')
+    expect(panels.selectPanel).toHaveBeenCalledOnce()
+    expect(navigation.aborted).toBe(false)
+    service.selectPanel(null)
+    expect(navigation.aborted).toBe(true)
+  })
 
-    expect(stale.toggleSidebar).not.toHaveBeenCalled()
-    expect(fresh.toggleSidebar).toHaveBeenCalledTimes(1)
+  it('supersedes asynchronous navigation on another request, any valid selection, and disposal', () => {
+    const service = new LayoutController(fakePanels(), () => true)
+    const first = service.beginNavigation()
+    const second = service.beginNavigation()
+    expect(first.aborted).toBe(true)
+    expect(second.aborted).toBe(false)
+    service.selectPanel('panel-a' as MainPanelId)
+    expect(second.aborted).toBe(true)
+    const repeated = service.beginNavigation()
+    service.selectPanel('panel-a' as MainPanelId)
+    expect(repeated.aborted).toBe(true)
+    const pending = service.beginNavigation()
+    service.dispose()
+    expect(pending.aborted).toBe(true)
+    service.dispose()
   })
 })

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

@@ -8,6 +8,9 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../../util/brand"
+    },
     {
       "path": "../../../vendor/cordis"
     },

+ 2 - 1
packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx

@@ -13,6 +13,7 @@ import { PermissionPresetSettingsController } from '../src/client/settings-store
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 const schema = new SettingsSchemaService(new Context())
 
@@ -60,7 +61,7 @@ const useSessionPendingInteraction: PermissionRowProps['useSessionPendingInterac
 const runtime = {
   useSessions: (() => { throw new Error('unused') }) as never,
   useSessionPendingInteraction,
-  useResource,
+  usePanelInfo, useResource,
   useWorkspaces: (() => { throw new Error('unused') }) as never,
 }
 

+ 2 - 1
packages/client/ui-settings-general/tests/components.client.spec.tsx

@@ -13,6 +13,7 @@ import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 /** Store over a real mirror derived from the same scripted context. */
 function derivedDocumentStore(remote: object) {
@@ -32,7 +33,7 @@ const unusedHook = (() => { throw new Error('unused by settings-general componen
 type AttentionSnapshot = Parameters<Parameters<TriggerContentProps['useSessionPendingInteraction']>[0]>[0]
 const noAttention: AttentionSnapshot = new Map()
 const useSessionPendingInteraction: TriggerContentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
-const kit = { useSessions: unusedHook, useSessionPendingInteraction, useResource, useWorkspaces: unusedHook }
+const kit = { useSessions: unusedHook, useSessionPendingInteraction, usePanelInfo, useResource, useWorkspaces: unusedHook }
 
 describe('chrome content', () => {
   it('TriggerContent renders the icon with the label in the wide column', () => {

+ 2 - 1
packages/client/ui-settings-general/tests/settings-root.client.spec.tsx

@@ -10,6 +10,7 @@ import { en } from '../src/client/locales.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 afterEach(() => {
   cleanup()
@@ -76,7 +77,7 @@ function mount({
   const props: SettingsRootComponentProps = {
     useSessions,
     useSessionPendingInteraction,
-    useResource,
+    usePanelInfo, useResource,
     useWorkspaces: unusedHook,
     wide,
     reconnect,

+ 2 - 1
packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx

@@ -17,6 +17,7 @@ import { settingsSchema } from './settings-schema.client.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 afterEach(() => {
   cleanup()
@@ -147,7 +148,7 @@ function harness(options: {
     openSection,
     useSessions: unusedHook,
     useSessionPendingInteraction,
-    useResource,
+    usePanelInfo, useResource,
     useWorkspaces: unusedHook,
     controller,
     useModels: bindSnapshotSelector(controller.store),

+ 2 - 1
packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx

@@ -10,6 +10,7 @@ import { SettingsScopeController } from '@deepseek-ai/dsh-client-ui-settings/src
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 /** Stateless schema service for scope construction in this jsdom fixture. */
 const schemaService = new SettingsSchemaService(new Context())
@@ -93,7 +94,7 @@ function mount(
     openSection: vi.fn(),
     useSessions: unusedHook,
     useSessionPendingInteraction,
-    useResource,
+    usePanelInfo, useResource,
     useWorkspaces: unusedHook,
     controller,
     useWelcome: bindSnapshotSelector(controller.store),

+ 2 - 2
packages/client/ui-sidebar-right/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md
-README.md: feb75c6184098f54961ca161b4dc5ca5d0c7fc78
-README.zh.md: 03c841dfdaac970e938cf934e7f09d151268be8b
+README.md: 7520a927f8e77b4fca1bbb7e61fe415dda814b4b
+README.zh.md: 648a2c20d8e55a48a684a92a351b7900f3a8d517

+ 2 - 0
packages/client/ui-sidebar-right/README.md

@@ -54,6 +54,8 @@ 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 `rightbar` entry is a root-scoped controller. It reads `usePanelInfo` and mounts the Session-scoped `rightbar.session` subtree only while the Conversation is selected. Switching to a global panel hides the right Sidebar and releases its frame track without deleting the Session's tab state.
+
 <a id="state"></a>
 ## State
 

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

@@ -54,6 +54,8 @@ kind: "package-reference"
 
 面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。
 
+`rightbar` 入口是 root 作用域的控制器。它读取 `usePanelInfo`,仅在选中会话界面时挂载 session 作用域的 `rightbar.session` 子树。切换到全局面板会隐藏右侧 Sidebar 并释放框架列宽,但不删除会话的 tab 状态。
+
 <a id="state"></a>
 ## 状态
 

+ 3 - 0
packages/client/ui-sidebar-right/src/client/contract/slots.ts

@@ -21,6 +21,7 @@
  * therefore live with their declarer.
  */
 import type {} from '@deepseek-ai/dsh-client-ui-slots'
+import type { RightbarOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
 // The locale plugin's own merge carries the shared `common` vocabulary that the
 // lookup chain consults after this namespace misses.
 import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -37,6 +38,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
   }
 
   interface SlotMap {
+    /** Session content selected by the root-scoped right Sidebar controller. */
+    'rightbar.session': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps }
     /**
      * One tab's body, dispatched with the `id` of the type in force for
      * `tab.kind`. A tab type registers here under its definition's `id` and

+ 22 - 15
packages/client/ui-sidebar-right/src/client/index.ts

@@ -32,6 +32,7 @@ import type {} from './contract/slots.ts'
 import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx'
 import { ExpandButton } from './shell/ExpandButton.tsx'
 import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx'
+import { RightbarRoot } from './shell/RightbarRoot.tsx'
 import { createSidebarRightController, type SidebarRightController } from './service.ts'
 import { SidebarRightTabRegistry } from './tab-registry.ts'
 import { createSidebarRightStore } from './stores.ts'
@@ -143,21 +144,27 @@ export function apply(ctx: ClientContext): void {
     }
 
     const disposeTypes = [tabs.register(guideDefinition(t))]
-    const disposeSeat = ctx.slots.inject('rightbar', () => ctx.slots.register({
-      name: 'rightbar',
-      locale: NS,
-      children: {
-        'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } },
-        'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } },
-        'sidebar.right.tab.menu.item': { kind: 'list', scope: 'session' },
-      },
-      store,
-      inject: (sessionId): SidebarRightInjected => ({
-        ...injected,
-        keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation },
-        occurrence: tab => controller.tabDomain.occurrence(sessionId, tab),
-      }),
-    }, RightbarSeat))
+    const disposeSeat = ctx.slots.inject('rightbar', function* () {
+      yield ctx.slots.register({
+        name: 'rightbar',
+        children: { 'rightbar.session': { kind: 'single', scope: 'session' } },
+      }, RightbarRoot)
+      yield ctx.slots.register({
+        name: 'rightbar.session',
+        locale: NS,
+        children: {
+          'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } },
+          'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } },
+          'sidebar.right.tab.menu.item': { kind: 'list', scope: 'session' },
+        },
+        store,
+        inject: (sessionId): SidebarRightInjected => ({
+          ...injected,
+          keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation },
+          occurrence: tab => controller.tabDomain.occurrence(sessionId, tab),
+        }),
+      }, RightbarSeat)
+    })
     // The expand button shares the panel's store: it only needs to know whether
     // the panel is expanded, and to ask for it to be. The header's corner seat
     // is its own place, past the utilities, so showing and hiding it moves

+ 2 - 2
packages/client/ui-sidebar-right/src/client/service.ts

@@ -24,8 +24,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.
  *
- * Wiring follows `LayoutController.attachPanels`: the registration hands the
- * service its store actions, and the service is the face other plugins hold.
+ * The registration adopts Session stores and injects the mounted seat binding;
+ * callers use the service's navigation methods.
  */
 import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
 import { activeDockPaneId, canSplit, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'

+ 20 - 0
packages/client/ui-sidebar-right/src/client/shell/RightbarRoot.tsx

@@ -0,0 +1,20 @@
+/** Root-scoped controller for the right Sidebar's Session content. */
+import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {} from '../contract/slots.ts'
+
+/**
+ * Render the Session-bound Sidebar only while the Conversation is selected.
+ * @param props - frame geometry, panel selection, and the authorized Session renderer.
+ * @returns the current Session's right Sidebar, or no content for a global panel.
+ */
+export function RightbarRoot({
+  usePanelInfo, SessionProvider, renderSlot, width, viewportWidth, canShow,
+}: PropsRuntime<'rightbar'> & PropsRenderSlots<'rightbar.session'>) {
+  const visible = usePanelInfo(info => info.activePanelId === null)
+  if (!visible) return null
+  return (
+    <SessionProvider>
+      {renderSlot('rightbar.session', { width, viewportWidth, canShow })}
+    </SessionProvider>
+  )
+}

+ 2 - 3
packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx

@@ -33,8 +33,7 @@ import { createPortal } from 'react-dom'
 import type {
   HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
 } from '@deepseek-ai/dsh-client-ui-slots'
-// The frame declares the `rightbar` seat this component fills.
-import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
+import type {} from '../contract/slots.ts'
 import type { DockIntents, DockMode, FloatRect, TabId, TabRecord, TabRenderer } from '@deepseek-ai/dsh-client-ui-dockkit'
 import { canSplit, dockPaneIds, DockSurface, findPaneContentTab, FloatLayer } from '@deepseek-ai/dsh-client-ui-dockkit'
 import type { HalvesFit, LayoutState, PaneId } from '@deepseek-ai/dsh-client-ui-dockkit'
@@ -108,7 +107,7 @@ export interface SidebarRightInjected {
 
 /** The column seat's props: session scope, so the session arrives as a standard prop. */
 export type RightbarSeatProps =
-  & PropsRuntime<'rightbar'>
+  & PropsRuntime<'rightbar.session'>
   & Children
   & Store
   & PropsLocale<'sidebarRight'>

+ 15 - 14
packages/client/ui-sidebar-right/tests/apply.client.spec.ts

@@ -10,6 +10,7 @@
  */
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
+import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { apply, inject } from '../src/client/index.ts'
 import type { GuideInjected, SidebarRightInjected } from '../src/client/index.ts'
@@ -18,6 +19,7 @@ import { SidebarRightController } from '../src/client/service.ts'
 import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts'
 import type { createSidebarRightStore } from '../src/client/stores.ts'
 import { RightbarSeat } from '../src/client/shell/SidebarRight.tsx'
+import { RightbarRoot } from '../src/client/shell/RightbarRoot.tsx'
 import { ExpandButton } from '../src/client/shell/ExpandButton.tsx'
 import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx'
 import { GUIDE_ID } from '../src/client/tabs/guide/definition.ts'
@@ -28,7 +30,7 @@ const SESSION = 's-test' as SessionId
 interface Recorded {
   name: string
   key?: string
-  locale: string
+  locale?: string
   store?: unknown
   children?: unknown
   inject?: (sessionId: SessionId) => unknown
@@ -39,7 +41,7 @@ async function boot() {
   const ctx = new Context()
   const registered: Recorded[] = []
   const slots = {
-    inject: vi.fn((_name: string, register: () => () => void) => register()),
+    inject: vi.fn((_name: string, register: Parameters<SlotRegistry['inject']>[1]) => ctx.effect(register)),
     register: vi.fn((options: Omit<Recorded, 'component'>, component: unknown) => {
       const entry: Recorded = { ...options, component }
       registered.push(entry)
@@ -90,26 +92,25 @@ describe('ui-sidebar-right apply', () => {
     expect(guide?.id).toBe(GUIDE_ID)
     expect(guide?.priority).toBe('builtin')
     expect(guide?.title('sidebar://guide')).toBe('tab.guide.title')
-    // Three registrations: the panel seat, the header's corner seat, and the
-    // guide body under the guide implementation's id.
     expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
-      ['rightbar', undefined, 'sidebarRight', RightbarSeat],
+      ['rightbar', undefined, undefined, RightbarRoot],
+      ['rightbar.session', undefined, 'sidebarRight', RightbarSeat],
       ['conversation.session.header.corner', undefined, 'sidebarRight', ExpandButton],
       ['sidebar.right.pane.tab', GUIDE_ID, 'sidebarRight', GuideBody],
     ])
     // The panel declares the extension seats; the guide declares its chain child.
-    expect(Object.keys(seat('rightbar').children as object)).toEqual([
+    expect(Object.keys(seat('rightbar.session').children as object)).toEqual([
       'sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'sidebar.right.tab.menu.item',
     ])
     expect(seat('sidebar.right.pane.tab').children).toMatchObject({ 'sidebar.right.tab.guide': { kind: 'chain', scope: 'session' } })
     // Both seats read one store: the button only needs to know whether the panel is expanded.
-    expect(seat('rightbar').store).toBeDefined()
-    expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar').store)
+    expect(seat('rightbar.session').store).toBeDefined()
+    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()
-    const injected = injectedOf(seat('rightbar')) as SidebarRightInjected
+    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 })
     expect(layout.openRightbar).toHaveBeenLastCalledWith(true, false)
@@ -128,7 +129,7 @@ describe('ui-sidebar-right apply', () => {
     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.
-    const handle = seat('rightbar').store as ReturnType<typeof createSidebarRightStore>
+    const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
     const instance = handle.create()
     const release = injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true })
     injected.openTab('guide', { revealIfOpened: false })
@@ -145,7 +146,7 @@ describe('ui-sidebar-right apply', () => {
 
   it('adopts each session\'s store instance as the runtime mints it, so a tab\'s own actions land with no seat bound', async () => {
     const { ctx, resources, seat } = await boot()
-    const handle = seat('rightbar').store as ReturnType<typeof createSidebarRightStore>
+    const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
     // Both seats declare the same wrapped handle, so either minting adopts.
     expect(seat('conversation.session.header.corner').store).toBe(handle)
     const instance = handle.create(SESSION)
@@ -178,8 +179,8 @@ 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 injected = injectedOf(seat('rightbar')) as SidebarRightInjected
-    const handle = seat('rightbar').store as ReturnType<typeof createSidebarRightStore>
+    const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected
+    const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
     // 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: {}, canSplitPane: () => true })
@@ -199,6 +200,6 @@ describe('ui-sidebar-right apply', () => {
     expect(dictionaries.size).toBe(0)
     await ctx.plugin({ inject: [...inject], apply }).await()
     expect(ctx.sidebarRightTabs.get('guide')?.id).toBe(GUIDE_ID)
-    expect(registered).toHaveLength(3)
+    expect(registered).toHaveLength(4)
   })
 })

+ 16 - 2
packages/client/ui-sidebar-right/tests/seat.client.spec.tsx

@@ -6,6 +6,7 @@ import { useState } from 'react'
 import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
 import type { PaneId, SplitId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
 import { dockPaneIds, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -67,7 +68,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true) {
   runtime.ctx.provide('locale', locale)
   runtime.slots.installLocale(locale)
   await runtime.declare({
-    'rightbar': { kind: 'single', scope: 'session' },
+    'rightbar': { kind: 'single', scope: 'root' },
     'conversation.session.header.corner': { kind: 'single', scope: 'session' },
   })
   await runtime.sessions.add({ id: SESSION })
@@ -98,7 +99,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true) {
     runtime.slots.register({ name: 'sidebar.right.pane.tab.title', key: 'test/text' }, Title)
   })
   const view = runtime.renderSlot('rightbar', { width: 420, viewportWidth, canShow })
-  const instance = runtime.storeOf('rightbar', SESSION) as ReturnType<ReturnType<typeof createSidebarRightStore>['create']>
+  const instance = runtime.storeOf('rightbar.session', SESSION) as ReturnType<ReturnType<typeof createSidebarRightStore>['create']>
   const controller = runtime.ctx.sidebarRight
   const layout = () => instance.getSnapshot().bySession[SESSION]!.layout
   const open = (name = 'a.txt', options?: Parameters<typeof controller.openResource>[1]) => {
@@ -115,6 +116,19 @@ function element(container: HTMLElement, selector: string): HTMLElement {
 }
 
 describe('RightbarSeat presentation', () => {
+  it('hides for a global main panel and retains the Session sidebar state', async () => {
+    const h = await mountSeat()
+    h.open('retained.txt')
+    const retained = h.layout()
+    act(() => { h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) })
+    expect(h.view.container.querySelector('[data-sidebar-right-panel]')).toBeNull()
+    expect(h.frame.closeRightbar).toHaveBeenCalled()
+    expect(h.layout()).toBe(retained)
+    act(() => { h.runtime.panelInfo.set({ activePanelId: null }) })
+    expect(h.view.container.querySelector('[data-sidebar-right-panel]')).not.toBeNull()
+    expect(h.layout()).toBe(retained)
+  })
+
   it('keeps the panel mounted while collapsed and releases the frame on unmount', async () => {
     const h = await mountSeat()
     const panel = element(h.view.container, '[data-sidebar-right-panel]')

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
-README.md: 87b04e965fbe26591e38d49a5c272113edfd28aa
-README.zh.md: 5a7f050d7099437e5c8be965e89be405492d5353
+README.md: 1b520213809ca98a6819483a4ee5ea4f451841d2
+README.zh.md: 8caae9f14eb4b7a70e674b9cef7245aaf077b378

+ 6 - 2
packages/client/ui-sidebar/README.md

@@ -31,6 +31,10 @@ The sidebar is the navigation shell: users see the brand, start new sessions, co
 
 The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots; the collapsed rail renders the same mark slot. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks a code badge below the label as `version[-commit][-dirty]`, using `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. New Session targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page.
 
+### Global panel entries
+
+Plugins add an icon component to the root-scoped `sidebar.panellist` list with an `id`, optional `order`, and a string or locale-aware `label`. The same id addresses the component registered in the layout's root-scoped `main` keyed slot; selecting a missing main entry throws without changing the current selection. The label supplies plain visible text, the accessible name, and the collapsed tooltip. Each row reads its own selected state through `usePanelInfo`; moving DOM focus to search or a directory picker does not change the displayed panel or its selected row. With no registrations, neither the list nor spacing for it is rendered. The shipped composition registers no example panel.
+
 ### Collapse behavior
 
 During a live collapse, the expanded content fades out at its current width, the upper controls share one fade and leftward translation into the 56px rail, and the layout's column slide ends the motion. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation.
@@ -47,7 +51,7 @@ Scrollbars in the column are a pointer affordance: the shell rebinds the scrollb
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store.
+The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. Panel entries and their optional titles use the same composition path. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store.
 
 ### Slot discipline
 
@@ -100,4 +104,4 @@ None.
 
 </details>
 
-**Runtime invariant:** No companion is published. A pure-consumer plugin deriving its rows in-component from the standard useSessions delivery — it emits no cordis events and owns no cross-plugin mutable state; derivation and interaction behavior are asserted directly by this package's tree/component specs.
+**Runtime invariant:** No companion is published. Panel metadata is a read-only presentation projection of the Slot registry and locale, with no independent write API. The registry owns entry identity and disposal; this package's assembly tests assert the projection after registration and locale notifications settle. The shell owns no separate navigation state to reconcile with those sources.

+ 6 - 2
packages/client/ui-sidebar/README.zh.md

@@ -31,6 +31,10 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
 
 展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot;收起轨道则渲染同一个 mark slot。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标使用 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。New Session 优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。
 
+### 全局面板入口
+
+插件在 root 作用域的 `sidebar.panellist` list 中注册图标组件,提供 `id`、可选 `order`,以及字符串或随语言变化的 `label`。同一个 id 寻址布局中 root 作用域 `main` keyed slot 的组件;选择不存在的主面板条目会抛错,并保留当前选中态。标签提供普通可见文字、无障碍名称和折叠提示。每一行通过 `usePanelInfo` 读取自己的选中态;DOM 焦点移到搜索框或目录选择器时,显示的面板及其列表项选中态不变。没有注册项时,列表及其间距均不渲染。默认组合不注册示例面板。
+
 ### 折叠行为
 
 实时收起时,展开内容在当前宽度淡出,上方控件共用一次淡入并左移进入 56px 轨道,由布局的栏滑动结束整段动画。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。固定在底部的 `sidebar.settings` 控件只共用淡入时序,不发生横向位移。
@@ -47,7 +51,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
 <details>
 <summary>实现细节——点击展开</summary>
 
-外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store
+外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。面板入口及其可选标题使用相同的组合方式。面板元数据由列表注册和语言变化派生;选中态属于布局存储
 
 ### Slot 纪律
 
@@ -100,4 +104,4 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
 
 </details>
 
-**运行时不变式:** 不发布伴生入口。这是从标准 `useSessions` delivery 直接派生行的纯消费插件,不发出 Cordis 事件,也不持有跨插件可变状态。
+**运行时不变式:** 不发布伴生入口。面板元数据是 Slot 注册表与 locale 的只读呈现投影,没有独立写入 API。注册表拥有条目身份和释放;本包的装配测试在注册和语言通知完成后断言投影。外壳没有需要与这些来源协调的独立导航状态。

+ 1 - 0
packages/client/ui-sidebar/package.json

@@ -52,6 +52,7 @@
   "devDependencies": {
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",

+ 71 - 1
packages/client/ui-sidebar/src/client/SidebarRoot.module.css

@@ -58,13 +58,14 @@
   from { opacity: 0; }
 }
 
-/* At the 150ms rail settle, the four upper controls enter from the former
+/* At the 150ms rail settle, the upper controls enter from the former
    rail right edge over the remaining 150ms of the AppFrame track transition.
    The bottom-pinned settings seat shares their opacity timeline but stays
    horizontally fixed. Only a live collapse gets .railIn; a cold collapsed
    render stays static. */
 .railIn .iconButton,
 .railIn .newSession,
+.railIn .panelList,
 .railIn .regionArea {
   animation: rail-in 150ms var(--ds-ease-in-out) backwards;
 }
@@ -296,6 +297,74 @@
   max-width: 0;
 }
 
+.panelList {
+  flex: none;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  margin-bottom: 8px;
+}
+
+.panelRow {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+  min-height: 36px;
+  padding: 7px 8px;
+  box-sizing: border-box;
+  border: none;
+  border-radius: 8px;
+  background: transparent;
+  color: var(--dsw-alias-label-secondary);
+  font: inherit;
+  line-height: 22px;
+  text-align: left;
+  cursor: pointer;
+}
+
+.panelRow:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.panelRow.panelActive {
+  background: var(--dsw-alias-interactive-bg-active);
+  color: var(--dsw-alias-label-primary);
+  font-weight: 500;
+}
+
+.panelRow:focus-visible {
+  outline: 2px solid var(--dsw-alias-label-primary);
+  outline-offset: -2px;
+}
+
+.panelGlyph {
+  flex: none;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.panelTitle {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.collapsed .panelList {
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.collapsed .panelRow {
+  width: 36px;
+  height: 36px;
+  justify-content: center;
+  padding: 0;
+  color: var(--dsw-alias-label-primary);
+}
+
 /* Region seat: always mounted so the foot never moves. Its trailing margin
    cancels the wide shell inset so the nested scrollbar can sit at the sidebar
    edge; the browser restores that inset inside its own rows. */
@@ -351,6 +420,7 @@
   .fading > *,
   .railIn .iconButton,
   .railIn .newSession,
+  .railIn .panelList,
   .railIn .footArea,
   .railIn .regionArea {
     transition: none;

+ 60 - 4
packages/client/ui-sidebar/src/client/SidebarRoot.tsx

@@ -1,12 +1,13 @@
 /**
- * Sidebar shell: column geometry only. Collapse is a slide plus crossfade:
+ * Sidebar shell: column geometry and global panel navigation.
+ * Collapse is a slide plus crossfade:
  * content freezes at its expanded width (inline style) and fades out in place
  * while the sliding column (AppFrame grid tracks) clips it — nothing reflows
- * mid-slide. At settle the wide-only content unmounts and the four upper
+ * mid-slide. At settle the wide-only content unmounts and the upper
  * controls enter the 56px rail from the same horizontal offset (one icon each,
  * same top-down order) on one fade that ends with the slide. The bottom-pinned
  * settings control only fades. The workspace/session browsing region between
- * the New Session button and the foot is the `sidebar.workspaces` registrant's,
+ * global panel rows and the foot is the `sidebar.workspaces` registrant's,
  * and the foot holds `sidebar.settings` plus `sidebar.footer.action`; the shell
  * hands them the wide flag (plus an expand request callback for the browser).
  *
@@ -20,7 +21,10 @@ import clsx from 'clsx'
 import {
   FishLogo, IconNewChatOutline16, IconPanelLeftOutline16, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { SidebarRootComponentProps } from './contract/slots.ts'
+import type { InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type {
+  SidebarPanelMetadata, SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps,
+} from './contract/slots.ts'
 import css from './SidebarRoot.module.css'
 
 /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
@@ -44,6 +48,38 @@ function localBuildVersion(): string | undefined {
     + (process.env.DSH_CLIENT_GIT_DIRTY === 'true' ? '-dirty' : '')
 }
 
+type PanelRowProps =
+  Pick<SidebarPanelMetadata, 'id' | 'label'>
+  & Pick<SidebarSectionOwnerProps, 'wide'>
+  & Pick<PropsRuntime<'sidebar'>, 'usePanelInfo'>
+  & Pick<InjectFace<SidebarRootInjected>, 'selectPanel'>
+  & PropsRenderSlots<'sidebar.panellist'>
+
+/** Each panel row subscribes only to its own selection state. */
+function PanelRow({ id, label, wide, usePanelInfo, selectPanel, renderSlot }: PanelRowProps) {
+  const active = usePanelInfo(info => info.activePanelId === id)
+  return (
+    <Tooltip label={label} delayMs={500} disabled={wide}>
+      <button
+        type="button"
+        className={clsx(css.panelRow, active && css.panelActive)}
+        aria-label={label}
+        aria-current={active ? 'page' : undefined}
+        onClick={() => { selectPanel(id) }}
+      >
+        <span className={css.panelGlyph} aria-hidden="true">
+          {renderSlot('sidebar.panellist', { size: wide ? 16 : 18, active }, { only: id })}
+        </span>
+        {wide && (
+          <span className={clsx(css.panelTitle, css.wide)}>
+            {label}
+          </span>
+        )}
+      </button>
+    </Tooltip>
+  )
+}
+
 /**
  * Render the sidebar column shell.
  * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
@@ -54,9 +90,13 @@ export function SidebarRoot({
   width,
   startSession,
   toggleSidebar,
+  selectPanel,
+  usePanels,
+  usePanelInfo,
   t,
   renderSlot,
 }: SidebarRootComponentProps) {
+  const panels = usePanels(snapshot => snapshot)
   // Wide content stays mounted while the collapse animates (fading via
   // .collapsed .wide), unmounts at settle, and remounts right away on expand.
   const [settled, setSettled] = useState(collapsed)
@@ -199,6 +239,22 @@ export function SidebarRoot({
         </button>
       </Tooltip>
 
+      {panels.length > 0 && (
+        <nav className={css.panelList} aria-label={t('panels.label')}>
+          {panels.map(({ id, label }) => (
+            <PanelRow
+              key={id}
+              id={id}
+              label={label}
+              wide={wide}
+              usePanelInfo={usePanelInfo}
+              selectPanel={selectPanel}
+              renderSlot={renderSlot}
+            />
+          ))}
+        </nav>
+      )}
+
       {/* The browsing region fills the column between the controls and the
           foot in both states; its rail icon column rides the same slot. */}
       <div className={css.regionArea}>

+ 36 - 10
packages/client/ui-sidebar/src/client/contract/slots.ts

@@ -1,17 +1,16 @@
 /**
  * Sidebar slot contract: the registrant-side props composition for the
  * layout-owned `sidebar` slot, plus the holes this shell declares. The shell
- * owns column geometry (fold state machine, brand row, New Session);
- * everything between the section header and the list bottom is the
+ * owns column geometry, the brand row, New Session, and global panel rows;
+ * everything between the workspace section header and the list bottom is the
  * `sidebar.workspaces` registrant's (ui-workspace), and the foot is the
  * `sidebar.settings` registrant's (ui-settings), followed by optional footer
  * actions in `sidebar.footer.action`.
  */
-import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
-// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
-// program that sees this contract, so PropsRuntime<'sidebar'> resolves.
-import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
+import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
@@ -26,6 +25,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
      * package's `sidebar` entry; the shell supplies a generic text fallback.
      */
     'sidebar.brand.name': { kind: 'single'; scope: 'root'; owner: SidebarBrandNameOwnerProps }
+    /**
+     * Global panel icons. Each list id addresses the matching main panel;
+     * the sidebar owns the button and resolves its label from list metadata.
+     */
+    'sidebar.panellist': { kind: 'list'; scope: 'root'; owner: SidebarPanelIconOwnerProps }
     /**
      * The workspace/session browsing region: section header, search, the
      * grouped/flat session list, and every workspace dialog. Declared by this
@@ -59,6 +63,24 @@ export interface SidebarBrandNameOwnerProps {
   children?: never
 }
 
+/** Icon presentation supplied by the global panel row. */
+export interface SidebarPanelIconOwnerProps {
+  /** Requested square edge in pixels. */
+  size: number
+  /** Whether this panel is selected in the main column. */
+  active: boolean
+}
+
+/** Serializable metadata for one active global panel list registration. */
+export interface SidebarPanelMetadata {
+  /** List id and matching main panel key. */
+  id: MainPanelId
+  /** Ascending row order; ties retain registration order. */
+  order: number
+  /** Row title and accessible name: resolved label, or the id when omitted. */
+  label: string
+}
+
 /**
  * Owner share of the browser hole — the only facts crossing the shell/region
  * boundary. Business data and actions arrive through the region's own inject.
@@ -87,8 +109,7 @@ export interface SidebarFooterActionOwnerProps {
 
 /**
  * Registrant-private injected share (arrives via the register inject
- * factory). The shell keeps only its own controls: starting a Session from
- * the New Session button and toggling the column.
+ * factory). The renderer binds the panel metadata source to usePanels.
  */
 export type SidebarRootInjected = {
   /**
@@ -99,20 +120,25 @@ export type SidebarRootInjected = {
   startSession: (workspaceId?: WorkspaceId) => void
   /** Toggle the sidebar column through the layout service. */
   toggleSidebar: () => void
+  /** Select the global panel addressed by a sidebar row. */
+  selectPanel: (id: MainPanelId) => void
+  /** Private reactive sources bound to framework selector hooks. */
+  hooks: { panels: ObservableSnapshot<readonly SidebarPanelMetadata[]> }
 }
 
 /**
  * Full component props: layout owner state/actions plus the declared holes'
  * render shares, this package's injected callbacks, and the standard locale
- * seat. No store is registered.
+ * seat. Panel metadata arrives through an injected observable.
  */
 export type SidebarRootComponentProps =
   PropsRuntime<'sidebar'>
   & PropsRenderSlots<
     | 'sidebar.brand.mark'
     | 'sidebar.brand.name'
+    | 'sidebar.panellist'
     | 'sidebar.workspaces'
     | 'sidebar.settings'
     | 'sidebar.footer.action'
   >
-  & SidebarRootInjected & PropsLocale<'sidebar'>
+  & InjectFace<SidebarRootInjected> & PropsLocale<'sidebar'>

+ 40 - 22
packages/client/ui-sidebar/src/client/index.ts

@@ -1,29 +1,33 @@
-/** Registers the sidebar shell into the layout-owned slot. */
+/** Registers the sidebar shell and global panel navigation. */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
+import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 // Type-only: pulls the SlotRegistry service merge (ctx.slots).
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 // Type-only: pulls the Session root standard-props merge.
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
-import type { SidebarRootInjected } from './contract/slots.ts'
+import type { SidebarPanelMetadata, SidebarRootInjected } from './contract/slots.ts'
 import { SidebarRoot } from './SidebarRoot.tsx'
 import { en, zh, type SidebarKey } from './locales.ts'
 
 export type {
   SidebarBrandMarkOwnerProps, SidebarBrandNameOwnerProps, SidebarFooterActionOwnerProps,
+  SidebarPanelIconOwnerProps, SidebarPanelMetadata,
   SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps,
 } from './contract/slots.ts'
 export type { SidebarKey } from './locales.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
-    /** Sidebar shell controls copy. */
+    /** Sidebar controls and global panel copy. */
     sidebar: SidebarKey
   }
 }
 
-/** Dictionary namespace owned by this plugin (shell controls copy). */
+/** Dictionary namespace owned by this plugin. */
 const NS = 'sidebar'
 
 interface WorkspaceNavigation {
@@ -39,29 +43,43 @@ export const inject = ['slots', 'layout', 'uiWorkspace', 'locale']
 export function apply(ctx: ClientContext): void {
   const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries')
+  const panels = createSnapshotStore<readonly SidebarPanelMetadata[]>([])
+  const syncPanels = (): void => {
+    const next = ctx.slots.entriesOfSlot('sidebar.panellist').map(({ options }) => {
+      // The list registration requires an id; StoredEntry erases the slot kind.
+      const id = options.id as MainPanelId
+      return { id, order: options.order ?? 0, label: resolveSlotLabel(options.label) ?? id }
+    }).sort((a, b) => a.order - b.order)
+    const previous = panels.getSnapshot()
+    if (previous.length === next.length && previous.every((panel, index) => {
+      const candidate = next[index] as SidebarPanelMetadata
+      return panel.id === candidate.id && panel.order === candidate.order && panel.label === candidate.label
+    })) return
+    panels.set(next)
+  }
+  ctx.effect(() => ctx.slots.subscribe('sidebar.panellist', syncPanels), 'ui-sidebar: panel entries')
+  ctx.effect(() => ctx.locale.subscribe(syncPanels), 'ui-sidebar: panel labels')
 
   const injectProps = (): SidebarRootInjected => ({
     // The shell's New Session button rides the Workspace UI's shared action
     // (current Session Workspace, then recent Workspace).
     startSession: (workspaceId) => { workspaceNavigation.startSession(workspaceId) },
     toggleSidebar: () => { ctx.layout.toggleSidebar() },
+    selectPanel: (id) => { ctx.layout.selectPanel(id) },
+    hooks: { panels },
   })
-  ctx.effect(
-    () => ctx.slots.register({
-      name: 'sidebar',
-      locale: NS,
-      // The shell owns geometry; ui-workspace registers the whole browsing
-      // region (header, search, session list, workspace dialogs), ui-settings
-      // registers the foot trigger + settings panel.
-      children: {
-        'sidebar.brand.mark': { kind: 'single', scope: 'root' },
-        'sidebar.brand.name': { kind: 'single', scope: 'root' },
-        'sidebar.workspaces': { kind: 'single', scope: 'root' },
-        'sidebar.settings': { kind: 'single', scope: 'root' },
-        'sidebar.footer.action': { kind: 'list', scope: 'root' },
-      },
-      inject: injectProps,
-    }, SidebarRoot),
-    'ui-sidebar: slot registration',
-  )
+  ctx.slots.inject('sidebar', () => ctx.slots.register({
+    name: 'sidebar',
+    locale: NS,
+    children: {
+      'sidebar.brand.mark': { kind: 'single', scope: 'root' },
+      'sidebar.brand.name': { kind: 'single', scope: 'root' },
+      'sidebar.panellist': { kind: 'list', scope: 'root' },
+      'sidebar.workspaces': { kind: 'single', scope: 'root' },
+      'sidebar.settings': { kind: 'single', scope: 'root' },
+      'sidebar.footer.action': { kind: 'list', scope: 'root' },
+    },
+    inject: injectProps,
+  }, SidebarRoot))
+  syncPanels()
 }

+ 3 - 1
packages/client/ui-sidebar/src/client/locales.ts

@@ -1,4 +1,4 @@
-/** `sidebar` namespace dictionaries: shell controls (brand row, New Session, fold toggle). */
+/** `sidebar` namespace dictionaries for shell controls and global panels. */
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
@@ -6,6 +6,7 @@ export const zh = {
   'session.new.label': '新建会话',
   'toggle.open': '打开侧边栏',
   'toggle.collapse': '收起侧边栏',
+  'panels.label': '全局面板',
 } satisfies Record<string, string>
 
 /** The sidebar namespace key union. */
@@ -17,4 +18,5 @@ export const en = {
   'session.new.label': 'New session',
   'toggle.open': 'Open sidebar',
   'toggle.collapse': 'Collapse sidebar',
+  'panels.label': 'Global panels',
 } satisfies Record<SidebarKey, string>

+ 81 - 9
packages/client/ui-sidebar/tests/apply.client.spec.tsx

@@ -1,16 +1,36 @@
 /** Sidebar shell slot registration and its Session/layout callbacks. */
-import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
+import { Context, type Fiber } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
+import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
 import { apply as hostApply } from '../src/index.ts'
 
+const owners = new Set<Fiber>()
+afterEach(async () => {
+  try {
+    for (const owner of owners) await owner.dispose()
+  } finally {
+    owners.clear()
+  }
+})
+
+function SidebarFrame({ renderSlot }: PropsRenderSlots<'sidebar'>) {
+  return renderSlot('sidebar', { collapsed: false, width: 300 })
+}
+
 async function bench(declare = true) {
-  const ctx = new Context()
+  const root = new Context()
+  let ctx: Context | undefined
+  const owner = root.plugin((owned: Context) => { ctx = owned })
+  owners.add(owner)
+  await owner.await()
+  if (ctx === undefined) throw new Error('the sidebar fixture owner did not activate')
   await ctx.plugin(SlotRegistry).await()
-  const layout = { toggleSidebar: vi.fn() }
+  const layout = { toggleSidebar: vi.fn(), selectPanel: vi.fn() }
   const uiWorkspace = { startSession: vi.fn() }
   ctx.provide('layout', layout)
   ctx.provide('uiWorkspace', uiWorkspace as never)
@@ -18,8 +38,11 @@ async function bench(declare = true) {
   const slots = ctx.get('slots') as SlotRegistry
   if (declare) {
     slots.register(
-      { name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
-      () => null,
+      { name: 'root', children: {
+        'sidebar': { kind: 'single', scope: 'root' },
+        'main': { kind: 'keyed', scope: 'root' },
+      } },
+      SidebarFrame,
     )
   }
   return { ctx, slots, layout, uiWorkspace }
@@ -43,10 +66,13 @@ describe('ui-sidebar apply', () => {
     expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
     expect(b.slots.spec('sidebar.settings')).toEqual({ kind: 'single', scope: 'root' })
     expect(b.slots.spec('sidebar.footer.action')).toEqual({ kind: 'list', scope: 'root' })
+    expect(b.slots.spec('sidebar.panellist')).toEqual({ kind: 'list', scope: 'root' })
     // Copy rides the standard locale seat, not the inject face.
     expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
     const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
-    expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
+    expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar', 'selectPanel', 'hooks'])
+    expect(injected.hooks.panels.getSnapshot()).toEqual([])
+    expect(b.slots.entries('main')).toEqual([])
     // Both arms delegate to the Workspace UI's shared New Session action.
     injected.startSession('workspace' as never)
     expect(b.uiWorkspace.startSession).toHaveBeenCalledWith('workspace')
@@ -54,11 +80,55 @@ describe('ui-sidebar apply', () => {
     expect(b.uiWorkspace.startSession).toHaveBeenLastCalledWith(undefined)
     injected.toggleSidebar()
     expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
+    const panelId = 'custom-panel' as MainPanelId
+    injected.selectPanel(panelId)
+    expect(b.layout.selectPanel).toHaveBeenCalledExactlyOnceWith(panelId)
+    expectTypeOf<Parameters<SidebarRootInjected['selectPanel']>[0]>().toEqualTypeOf<MainPanelId>()
   })
 
-  it('fails when no live owner declared the sidebar slot', async () => {
+  it('waits for the sidebar declaration before registering', async () => {
     const b = await bench(false)
-    await expect(b.ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/not declared/)
+    const fiber = b.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    expect(b.slots.entries('sidebar')).toHaveLength(0)
+    const disposeRoot = b.slots.register({
+      name: 'root',
+      children: { sidebar: { kind: 'single', scope: 'root' } },
+    }, SidebarFrame)
+    expect(b.slots.entries('sidebar')).toHaveLength(1)
+    disposeRoot()
+    await fiber.dispose()
+  })
+
+  it('projects plugin-owned panel entries and removes them with their owner', async () => {
+    const b = await bench()
+    const sidebar = b.ctx.plugin({ inject: [...inject], apply })
+    await sidebar.await()
+    const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
+    const panel = b.ctx.plugin({
+      inject: ['slots'],
+      apply(ctx: Context) {
+        ctx.slots.register({ name: 'main', key: 'custom-panel' }, () => 'Custom panel')
+        ctx.slots.register({
+          name: 'sidebar.panellist', id: 'custom-panel', order: 20, label: 'Custom panel',
+        }, () => null)
+      },
+    })
+    try {
+      await panel.await()
+      await vi.waitFor(() => {
+        expect(injected.hooks.panels.getSnapshot()).toEqual([
+          { id: 'custom-panel', order: 20, label: 'Custom panel' },
+        ])
+      })
+      expect(b.slots.entries('main').map(entry => entry.options.key)).toEqual(['custom-panel'])
+      await panel.dispose()
+      await vi.waitFor(() => { expect(injected.hooks.panels.getSnapshot()).toEqual([]) })
+      expect(b.slots.entries('main')).toEqual([])
+    } finally {
+      await panel.dispose()
+      await sidebar.dispose()
+    }
   })
 
   it('removes the entry and child declaration on teardown', async () => {
@@ -71,5 +141,7 @@ describe('ui-sidebar apply', () => {
     expect(b.slots.spec('sidebar.brand.name')).toBeUndefined()
     expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
     expect(b.slots.spec('sidebar.footer.action')).toBeUndefined()
+    expect(b.slots.spec('sidebar.panellist')).toBeUndefined()
+    expect(b.slots.entries('main')).toHaveLength(0)
   })
 })

+ 276 - 0
packages/client/ui-sidebar/tests/panel-list.client.spec.tsx

@@ -0,0 +1,276 @@
+// @vitest-environment jsdom
+/** Global panel rows and DOM focus through the production slot renderer. */
+import type { Context } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, waitFor, within } from '@testing-library/react'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ILayout, MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
+import type { PropsRenderSlots, PropsRuntime, SlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
+import { apply, inject } from '../src/client/index.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    'sidebar-panel-test': 'alpha'
+  }
+}
+
+const ALPHA = 'sidebar-test-alpha' as MainPanelId
+const BETA = 'sidebar-test-beta' as MainPanelId
+const runtimes = new Set<SlotTestRuntime>()
+
+afterEach(async () => {
+  try {
+    for (const runtime of runtimes) await runtime.dispose()
+  } finally {
+    runtimes.clear()
+    cleanup()
+  }
+})
+
+async function bench(collapsed = false) {
+  const runtime = await SlotTestRuntime.create()
+  runtimes.add(runtime)
+  const locale = new LocaleRuntime(runtime.ctx)
+  locale.setLocale('en')
+  const layout = {
+    beginNavigation: vi.fn(() => new AbortController().signal),
+    toggleSidebar: vi.fn(),
+    selectPanel: vi.fn((activePanelId: MainPanelId | null) => { runtime.panelInfo.set({ activePanelId }) }),
+    openRightbar: vi.fn(),
+    closeRightbar: vi.fn(),
+  } satisfies ILayout
+  await runtime.mount({
+    inject: ['slots'],
+    apply(ctx: Context) {
+      ctx.provide('layout', layout)
+      ctx.provide('uiWorkspace', { startSession: vi.fn() } as never)
+      ctx.provide('locale', locale)
+      ctx.effect(() => locale.register('common', { zh: commonZh, en: commonEn }), 'panel test: common locale')
+      ctx.effect(() => locale.register('sidebar-panel-test', {
+        zh: { alpha: '甲面板' }, en: { alpha: 'Alpha panel' },
+      }), 'panel test: panel locale')
+      ctx.slots.installLocale(locale)
+      ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: 'conversation' }, () => (
+        <p>Conversation content</p>
+      )))
+    },
+  })
+  function Frame({ usePanelInfo, renderSlot }: PropsRuntime<'root'> & PropsRenderSlots<'sidebar' | 'main'>) {
+    const activePanelId = usePanelInfo(info => info.activePanelId)
+    return (
+      <>
+        <aside>{renderSlot('sidebar', { collapsed, width: collapsed ? 56 : 300 })}</aside>
+        <main>{renderSlot('main', {}, { entryKey: activePanelId ?? 'conversation' })}</main>
+      </>
+    )
+  }
+  await runtime.root.declare({
+    sidebar: { kind: 'single', scope: 'root' },
+    main: { kind: 'keyed', scope: 'root' },
+  }, Frame)
+  const sidebar = await runtime.mount({ inject: [...inject], apply })
+  const view = runtime.renderRoot()
+  return { runtime, locale, layout, sidebar, view }
+}
+
+interface TestPanel {
+  id: MainPanelId
+  heading: string
+  order?: number
+  label?: SlotLabel
+}
+
+async function mountPanel(runtime: SlotTestRuntime, { heading, ...metadata }: TestPanel) {
+  function Icon({ size, active }: PropsRuntime<'sidebar.panellist'>) {
+    return (
+      <span data-testid={`${metadata.id}-icon`} data-active={active}>
+        <IconGlobeOutline14 size={size} />
+      </span>
+    )
+  }
+  function Body() {
+    return (
+      <section>
+        <h1>{heading}</h1>
+        <input aria-label={`${heading} input`} />
+        <button type="button">{heading} action</button>
+      </section>
+    )
+  }
+  return runtime.mount({
+    inject: ['slots'],
+    apply(ctx: Context) {
+      ctx.slots.inject('main', () => ctx.slots.register({ name: 'main', key: metadata.id }, Body))
+      ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ name: 'sidebar.panellist', ...metadata }, Icon))
+    },
+  })
+}
+
+async function mountPanels(runtime: SlotTestRuntime, locale: LocaleRuntime, betaOrder = 10) {
+  const t = locale.bind('sidebar-panel-test')
+  const alpha = await mountPanel(runtime, { id: ALPHA, heading: 'Alpha content', label: () => t('alpha'), order: 20 })
+  const beta = await mountPanel(runtime, { id: BETA, heading: 'Beta content', label: 'Beta panel', order: betaOrder })
+  return { alpha, beta }
+}
+
+describe('sidebar global panels', () => {
+  it('adds late registrations, removes each plugin contribution, and leaves no empty panel-list DOM', async () => {
+    const { runtime, locale, view } = await bench()
+    expect(runtime.slots.entries('sidebar.panellist')).toEqual([])
+    expect(view.queryByRole('navigation')).toBeNull()
+    expect(view.container.querySelector('[data-slot="sidebar.panellist"]')).toBeNull()
+    expect(view.getByText('Conversation content')).toBeTruthy()
+
+    const { alpha, beta } = await mountPanels(runtime, locale)
+    const navigation = await view.findByRole('navigation', { name: 'Global panels' })
+    expect(within(navigation).getAllByRole('button')).toHaveLength(2)
+    fireEvent.click(within(navigation).getByRole('button', { name: 'Alpha panel' }))
+    expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy()
+
+    await alpha.dispose()
+    await waitFor(() => {
+      expect(view.queryByRole('button', { name: 'Alpha panel' })).toBeNull()
+      expect(view.queryByRole('heading', { name: 'Alpha content' })).toBeNull()
+    })
+    expect(runtime.slots.entries('main').map(entry => entry.options.key)).toEqual(['conversation', BETA])
+    fireEvent.click(within(navigation).getByRole('button', { name: 'Beta panel' }))
+    expect(view.getByRole('heading', { name: 'Beta content' })).toBeTruthy()
+
+    await beta.dispose()
+    await waitFor(() => { expect(view.queryByRole('navigation')).toBeNull() })
+    expect(view.queryByRole('heading', { name: 'Beta content' })).toBeNull()
+    expect(view.container.querySelector('[data-slot="sidebar.panellist"]')).toBeNull()
+    expect(runtime.slots.entries('sidebar.panellist')).toEqual([])
+    expect(runtime.slots.entries('main').map(entry => entry.options.key)).toEqual(['conversation'])
+  })
+
+  it.each([false, true])('renders ordered rows, icon sizes, and tooltip labels with collapsed=%s', async (collapsed) => {
+    const { runtime, locale, view } = await bench(collapsed)
+    await mountPanels(runtime, locale)
+    const navigation = await view.findByRole('navigation', { name: 'Global panels' })
+    const rows = within(navigation).getAllByRole('button')
+    expect(rows.map(row => row.getAttribute('aria-label'))).toEqual(['Beta panel', 'Alpha panel'])
+    for (const [id, label] of [[BETA, 'Beta panel'], [ALPHA, 'Alpha panel']] as const) {
+      const row = within(navigation).getByRole('button', { name: label })
+      expect(row.getAttribute('aria-current')).toBeNull()
+      expect(row.textContent).toBe(collapsed ? '' : label)
+      expect(row.querySelectorAll('svg')).toHaveLength(1)
+      const icon = within(row).getByTestId(`${id}-icon`)
+      expect(icon.getAttribute('data-active')).toBe('false')
+      expect(icon.querySelector('svg')?.getAttribute('width')).toBe(collapsed ? '18' : '16')
+      expect(icon.querySelector('svg')?.getAttribute('height')).toBe(collapsed ? '18' : '16')
+      act(() => { row.focus() })
+      expect(document.activeElement).toBe(row)
+      if (collapsed) expect(view.getByRole('tooltip').textContent).toBe(label)
+      else expect(view.queryByRole('tooltip')).toBeNull()
+      act(() => { row.blur() })
+      expect(document.activeElement).not.toBe(row)
+      expect(view.queryByRole('tooltip')).toBeNull()
+    }
+  })
+
+  it('keeps registration order when panel rows have equal order', async () => {
+    const { runtime, locale, view } = await bench()
+    await mountPanels(runtime, locale, 20)
+    const navigation = await view.findByRole('navigation', { name: 'Global panels' })
+    expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual(['Alpha panel', 'Beta panel'])
+  })
+
+  it('selects registered main content and keeps a repeated selection active', async () => {
+    const { runtime, locale, layout, view } = await bench()
+    await mountPanels(runtime, locale)
+    const navigation = await view.findByRole('navigation', { name: 'Global panels' })
+    const alpha = within(navigation).getByRole('button', { name: 'Alpha panel' })
+    const beta = within(navigation).getByRole('button', { name: 'Beta panel' })
+    fireEvent.click(alpha)
+    expect(layout.selectPanel).toHaveBeenLastCalledWith(ALPHA)
+    expect(runtime.panelInfo.getSnapshot()).toEqual({ activePanelId: ALPHA })
+    expect(alpha.getAttribute('aria-current')).toBe('page')
+    expect(beta.getAttribute('aria-current')).toBeNull()
+    expect(within(alpha).getByTestId(`${ALPHA}-icon`).getAttribute('data-active')).toBe('true')
+    expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy()
+    expect(view.queryByRole('heading', { name: 'Beta content' })).toBeNull()
+
+    fireEvent.click(alpha)
+    expect(layout.selectPanel).toHaveBeenLastCalledWith(ALPHA)
+    expect(alpha.getAttribute('aria-current')).toBe('page')
+    expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy()
+
+    fireEvent.click(beta)
+    expect(layout.selectPanel).toHaveBeenLastCalledWith(BETA)
+    expect(runtime.panelInfo.getSnapshot()).toEqual({ activePanelId: BETA })
+    expect(alpha.getAttribute('aria-current')).toBeNull()
+    expect(beta.getAttribute('aria-current')).toBe('page')
+    expect(within(alpha).getByTestId(`${ALPHA}-icon`).getAttribute('data-active')).toBe('false')
+    expect(within(beta).getByTestId(`${BETA}-icon`).getAttribute('data-active')).toBe('true')
+    expect(view.queryByRole('heading', { name: 'Alpha content' })).toBeNull()
+    expect(view.getByRole('heading', { name: 'Beta content' })).toBeTruthy()
+  })
+
+  it('refreshes locale labels without re-registering icons or changing plain labels', async () => {
+    const { runtime, locale, view } = await bench()
+    await mountPanels(runtime, locale)
+    const navigation = await view.findByRole('navigation', { name: 'Global panels' })
+    const entries = runtime.slots.entries('sidebar.panellist')
+    act(() => { locale.setLocale('zh') })
+    await waitFor(() => {
+      expect(within(navigation).getByRole('button', { name: '甲面板' }).textContent).toBe('甲面板')
+    })
+    expect(within(navigation).getByRole('button', { name: 'Beta panel' }).textContent).toBe('Beta panel')
+    expect(runtime.slots.entries('sidebar.panellist')).toBe(entries)
+  })
+
+  it('uses an omitted label and order as the panel id and order zero', async () => {
+    const { runtime, view } = await bench()
+    await mountPanel(runtime, { id: ALPHA, heading: 'Alpha content', label: 'Alpha panel', order: 20 })
+    await mountPanel(runtime, { id: BETA, heading: 'Beta content' })
+    const navigation = await view.findByRole('navigation', { name: 'Global panels' })
+    expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual([BETA, 'Alpha panel'])
+    fireEvent.click(within(navigation).getByRole('button', { name: BETA }))
+    expect(view.getByRole('heading', { name: 'Beta content' })).toBeTruthy()
+  })
+
+  it.each([false, true])('keeps main selection while DOM focus moves between controls with collapsed=%s', async (collapsed) => {
+    const { runtime, locale, layout, view } = await bench(collapsed)
+    await mountPanels(runtime, locale)
+    const navigation = await view.findByRole('navigation', { name: 'Global panels' })
+    const alpha = within(navigation).getByRole('button', { name: 'Alpha panel' })
+    const beta = within(navigation).getByRole('button', { name: 'Beta panel' })
+    act(() => { alpha.focus() })
+    expect(document.activeElement).toBe(alpha)
+    expect(runtime.panelInfo.getSnapshot()).toEqual({ activePanelId: null })
+    expect(alpha.getAttribute('aria-current')).toBeNull()
+    expect(layout.selectPanel).not.toHaveBeenCalled()
+    fireEvent.click(alpha)
+    const selected = runtime.panelInfo.getSnapshot()
+    const textbox = view.getByRole('textbox', { name: 'Alpha content input' })
+    const action = view.getByRole('button', { name: 'Alpha content action' })
+    for (const target of [textbox, action, beta, alpha]) {
+      act(() => { target.focus() })
+      expect(document.activeElement).toBe(target)
+      expect(runtime.panelInfo.getSnapshot()).toBe(selected)
+      expect(alpha.getAttribute('aria-current')).toBe('page')
+      expect(beta.getAttribute('aria-current')).toBeNull()
+      expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy()
+      expect(view.queryByRole('heading', { name: 'Beta content' })).toBeNull()
+    }
+    expect(layout.selectPanel).toHaveBeenCalledExactlyOnceWith(ALPHA)
+  })
+
+  it('disposes the sidebar while retaining independently owned main panel bodies', async () => {
+    const { runtime, locale, sidebar, view } = await bench()
+    await mountPanels(runtime, locale)
+    fireEvent.click(await view.findByRole('button', { name: 'Alpha panel' }))
+    await sidebar.dispose()
+    expect(view.queryByRole('navigation')).toBeNull()
+    expect(runtime.slots.spec('sidebar.panellist')).toBeUndefined()
+    expect(runtime.slots.entries('sidebar.panellist')).toEqual([])
+    expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy()
+    act(() => { locale.setLocale('zh') })
+    expect(view.queryByRole('navigation')).toBeNull()
+  })
+})

+ 2 - 0
packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx

@@ -14,6 +14,7 @@ import { en } from '../src/client/locales.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 /** Pinned column box; the shell compares pointer coordinates against it. */
 const COLUMN_WIDTH = 280
@@ -40,6 +41,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } {
     <SidebarRoot
       collapsed={false} width={300}
       useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
+      usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
       useResource={useResource} useWorkspaces={neverHook}
       startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
       renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>

+ 5 - 0
packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx

@@ -13,6 +13,7 @@ import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 // English-dictionary translate stub: the shell renders the same copy the
 // assertions below query by accessible name.
@@ -45,6 +46,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
     <SidebarRoot
       collapsed={current.collapsed} width={current.width}
       useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
+      usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
       useResource={useResource} useWorkspaces={neverHook}
       startSession={startSession} toggleSidebar={toggleSidebar} t={t}
       renderSlot={((
@@ -110,6 +112,7 @@ describe('SidebarRoot shell', () => {
     const { container } = render(<SidebarRoot
       collapsed={false} width={300}
       useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
+      usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
       useResource={useResource} useWorkspaces={neverHook}
       startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
       renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
@@ -129,6 +132,7 @@ describe('SidebarRoot shell', () => {
     render(<SidebarRoot
       collapsed={false} width={300}
       useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
+      usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
       useResource={useResource} useWorkspaces={neverHook}
       startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
       renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
@@ -143,6 +147,7 @@ describe('SidebarRoot shell', () => {
     render(<SidebarRoot
       collapsed={false} width={300}
       useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
+      usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])}
       useResource={useResource} useWorkspaces={neverHook}
       startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
       renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>

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

@@ -17,6 +17,9 @@
     {
       "path": "../ui-slots"
     },
+    {
+      "path": "../store"
+    },
     {
       "path": "../ui-primitives"
     },

+ 2 - 1
packages/client/ui-theme/tests/appearance-row.client.spec.tsx

@@ -13,6 +13,7 @@ import type { ThemePreference } from '../src/client/index.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 afterEach(cleanup)
 
@@ -47,7 +48,7 @@ function mount(preference: ThemePreference = 'system') {
   const props: AppearanceRowComponentProps = {
     useSessions: emptySessions(),
     useSessionPendingInteraction,
-    useResource,
+    usePanelInfo, useResource,
     useWorkspaces: emptyWorkspaces(),
     useStore: bindSnapshotSelector(store),
     actions: store.actions,

+ 2 - 1
packages/client/ui-theme/tests/font-size-row.client.spec.tsx

@@ -14,6 +14,7 @@ import { createFontSizeRowStore } from '../src/client/settings-store.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 afterEach(cleanup)
 
@@ -49,7 +50,7 @@ function mount(fontSize = 14) {
   const props: FontSizeRowComponentProps = {
     useSessions: emptySessions(),
     useSessionPendingInteraction,
-    useResource,
+    usePanelInfo, useResource,
     useWorkspaces: emptyWorkspaces(),
     useStore: bindSnapshotSelector(store),
     actions: store.actions,

+ 9 - 4
packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx

@@ -6,6 +6,7 @@ import { cleanup, fireEvent, waitFor } from '@testing-library/react'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import type { TodoItem } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import {
   apply as applyChat, inject as injectChat, type ToolResultNode,
@@ -59,13 +60,13 @@ const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>)
 })
 
 /** Test-owned AppFrame role: declares and renders the resident conversation area. */
-type AppRootProps = PropsRenderSlots<'conversation'>
+type AppRootProps = PropsRenderSlots<'main'>
 function AppRoot({ renderSlot }: AppRootProps) {
-  return <>{renderSlot('conversation', {})}</>
+  return <>{renderSlot('main', {}, { entryKey: 'conversation' })}</>
 }
 
 const LAYOUT_CHILDREN = {
-  'conversation': { kind: 'single', scope: 'session-maybe' },
+  'main': { kind: 'keyed', scope: 'root' },
 } as const
 
 async function bench(nodes: ToolResultNode[]) {
@@ -79,7 +80,11 @@ async function bench(nodes: ToolResultNode[]) {
   runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
   runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
   runtime.ctx.provide('uiWorkspace', {
-    connectWorkspace: vi.fn(async () => SID),
+    openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+      beforeOpen(SID)
+      runtime.sessions.open(SID)
+    }),
+    openSession: (id: SessionId) => { runtime.sessions.open(id) },
   } as never)
   const locale = new LocaleRuntime(runtime.ctx)
   runtime.ctx.provide('locale', locale)

+ 14 - 5
packages/client/ui-tool/tests/toolview-slot.client.spec.tsx

@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { cleanup } from '@testing-library/react'
 import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import {
   apply as applyChat, inject as injectChat, type ToolResultNode,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
@@ -42,13 +43,13 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
 })
 
 /** Test-owned AppFrame role: declares and renders the resident conversation area. */
-type AppRootProps = PropsRenderSlots<'conversation'>
+type AppRootProps = PropsRenderSlots<'main'>
 function AppRoot({ renderSlot }: AppRootProps) {
-  return <>{renderSlot('conversation', {})}</>
+  return <>{renderSlot('main', {}, { entryKey: 'conversation' })}</>
 }
 
 const LAYOUT_CHILDREN = {
-  'conversation': { kind: 'single', scope: 'session-maybe' },
+  'main': { kind: 'keyed', scope: 'root' },
 } as const
 
 /**
@@ -66,7 +67,11 @@ async function bench(nodes: ToolResultNode[]) {
   const sidebarRight = { openResource: vi.fn<(address: string) => void>() }
   runtime.ctx.provide('sidebarRight', sidebarRight as never)
   runtime.ctx.provide('uiWorkspace', {
-    connectWorkspace: vi.fn(async () => SID),
+    openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+      beforeOpen(SID)
+      runtime.sessions.open(SID)
+    }),
+    openSession: (id: SessionId) => { runtime.sessions.open(id) },
   } as never)
   const locale = new LocaleRuntime(runtime.ctx)
   runtime.ctx.provide('locale', locale)
@@ -212,7 +217,11 @@ describe('registrant declaration injection', () => {
     runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
     runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
     runtime.ctx.provide('uiWorkspace', {
-      connectWorkspace: vi.fn(async () => SID),
+      openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {
+        beforeOpen(SID)
+        runtime.sessions.open(SID)
+      }),
+      openSession: (id: SessionId) => { runtime.sessions.open(id) },
     } as never)
     const locale = new LocaleRuntime(runtime.ctx)
     runtime.ctx.provide('locale', locale)

+ 3 - 2
packages/client/ui-trajectory/tests/views.client.spec.tsx

@@ -55,6 +55,7 @@ import { t as tTrajectory, tZh } from './locale.client.ts'
 
 // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 function TrajectoryTimeline(
   props: Omit<ComponentProps<typeof LocalizedTrajectoryTimeline>, 't'>,
@@ -224,7 +225,7 @@ function standaloneProps(
     sessionId: SID,
     useChat: bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)),
     useSessions: emptySessions(),
-    useResource,
+    usePanelInfo, useResource,
     useSessionPendingInteraction: bindSnapshotSelector(
       createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
     ),
@@ -351,7 +352,7 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
     useConversation,
     useConversationViews,
     useSessions,
-    useResource,
+    usePanelInfo, useResource,
     useSessionPendingInteraction,
     useWorkspaces,
     useProjection,

+ 2 - 1
packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx

@@ -14,6 +14,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
 
 // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 afterEach(cleanup)
 
@@ -115,7 +116,7 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
   pendingInteraction: undefined,
   useSession: selector => selector(sessionState),
   useSessions: selector => selector(sessionList),
-  useResource,
+  usePanelInfo, useResource,
   useSessionPendingInteraction: selector => selector(attentionState),
   useWorkspaces: selector => selector(workspaceState),
   useConversation: selector => selector(conversationState),

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

@@ -13,6 +13,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
 
 // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 afterEach(cleanup)
 
@@ -114,7 +115,7 @@ const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> =
   pendingInteraction: undefined,
   useSession: selector => selector(sessionState),
   useSessions: selector => selector(sessionList),
-  useResource,
+  usePanelInfo, useResource,
   useSessionPendingInteraction: selector => selector(attentionState),
   useWorkspaces: selector => selector(workspaceState),
   useConversation: selector => selector(conversationState),

+ 2 - 1
packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx

@@ -34,6 +34,7 @@ import type {} from '../src/client/index.ts'
 
 // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 afterEach(cleanup)
 
@@ -314,7 +315,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
     node: node(data),
     sessionId: PARENT_ID,
     useSessions: selector => selector(sessions),
-    useResource,
+    usePanelInfo, useResource,
     useSessionPendingInteraction: selector => selector(panelAttention),
     useSession: selector => selector(panelSession),
     useProjection: () => undefined,

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
-README.md: a819b07fecb74faeb3bebb6665aaabfdc8ecfba6
-README.zh.md: f9565b12bccd596fa61d1f1436f82b090139f506
+README.md: 857b38c2ca08ca4e9078d8f9dfcfe8462be50722
+README.zh.md: cf6f2d273f00add793cc5ac588db968c22007256

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

@@ -51,6 +51,8 @@ The value is intentionally best effort for cold Sessions. An identity-matching u
 
 -----
 
+`ctx.uiWorkspace.openSession(id)` selects the Session and returns the main area to the Conversation as one UI navigation action, including when that Session was already current. `openWorkspace(id, beforeOpen?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. The optional synchronous preparation callback runs only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
+
 <a id="understand-the-implementation"></a>
 ## Understand the implementation
 

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

@@ -51,6 +51,8 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
 
 -----
 
+`ctx.uiWorkspace.openSession(id)` 将选中会话与中央区域返回会话界面作为一次 UI 导航操作,包括目标会话已经是当前会话的情况。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调仅对仍有效的工作区请求执行,因此过期请求不会搬移 composer 草稿。后续导航或所有者释放会阻止晚到的 UI 提交,但不取消底层会话创建。选中失败时保留当前全局面板。会话行读取 `usePanelInfo`,在全局面板活跃时不显示会话选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
+
 <a id="understand-the-implementation"></a>
 ## 理解实现
 

+ 2 - 0
packages/client/ui-workspace/package.json

@@ -34,6 +34,7 @@
         "@deepseek-ai/dsh-client-connection",
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-conversation",
+        "@deepseek-ai/dsh-client-ui-layout",
         "@deepseek-ai/dsh-client-ui-renderer",
         "@deepseek-ai/dsh-client-ui-session",
         "@deepseek-ai/dsh-client-ui-sidebar"
@@ -61,6 +62,7 @@
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",

+ 7 - 4
packages/client/ui-workspace/src/client/index.ts

@@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 // Type-only: pulls the SlotRegistry service merge (ctx.slots).
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 // Type-only: pulls the Session root standard-hook merge.
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
@@ -60,7 +61,7 @@ const NS = 'workspace'
  * declaration through `slots.inject()` instead of assuming order.
  */
 export const inject = [
-  'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker',
+  'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout',
 ]
 
 /**
@@ -95,11 +96,14 @@ export function apply(ctx: Context): void {
     subscribe: listener => ctx.on('connection/reset', listener),
   }
   const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow')
+  const openSession: WorkspaceBrowserInjected['open'] = (sessionId) => {
+    uiWorkspace.openSession(sessionId)
+  }
   const browserInjected = (): WorkspaceBrowserInjected => ({
     // Explicit group actions keep their target; unscoped New Session inherits
     // the current Session Workspace before the recent-Workspace fallback.
     startSession: (workspaceId) => { uiWorkspace.startSession(workspaceId) },
-    open: (sessionId) => { sessions.open(sessionId) },
+    open: openSession,
     searchSessions,
     searchResultLimit: sessions.searchResultLimit,
     renameSession: async (sessionId, title) => {
@@ -111,8 +115,7 @@ export function apply(ctx: Context): void {
       if (!result.ok) throw new Error(result.error.message)
     },
     forkSession: (sessionId) => {
-      sessions.fork({ sessionId, increaseTitle: true })
-        .then((childId) => { sessions.open(childId) })
+      uiWorkspace.forkSession(sessionId)
         .catch(() => {
           // Fork or child-rename failure keeps the current selection.
         })

+ 46 - 7
packages/client/ui-workspace/src/client/navigation.ts

@@ -10,9 +10,28 @@ import type {
   IWorkspaces, WorkspaceId, WorkspaceView,
 } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 
 /** Workspace archive and directory operations consumed by Client UI domains. */
 export interface UiWorkspace {
+  /**
+   * Select a Session and show its Conversation as one UI navigation action.
+   * @param sessionId - listed or retained Session to display.
+   */
+  openSession(sessionId: SessionId): void
+  /**
+   * Connect a Workspace and open its Session unless a later navigation supersedes it.
+   * @param workspaceId - target Workspace.
+   * @param beforeOpen - optional synchronous preparation for the selected Session, skipped after supersession.
+   * @returns completion; a superseded request may create a Session but does not open it.
+   */
+  openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise<void>
+  /**
+   * Fork a Session and open the child unless a later navigation supersedes it.
+   * @param sessionId - source Session.
+   * @returns completion; a superseded request leaves its child available without selecting it.
+   */
+  forkSession(sessionId: SessionId): Promise<void>
   /**
    * Resolve the reusable or newly created blank Session for a Workspace.
    * @param workspaceId - target Workspace.
@@ -70,6 +89,7 @@ export class DirectoryBrowseError extends Error {
 /** Implements Workspace archive and directory UI operations. */
 class UiWorkspaceService extends Service implements UiWorkspace {
   private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
+  private readonly lifetime = new AbortController()
 
   /**
    * @param ctx - Client root Context.
@@ -111,6 +131,26 @@ class UiWorkspaceService extends Service implements UiWorkspace {
     return attempt
   }
 
+  openSession(sessionId: SessionId): void {
+    this.sessions.open(sessionId)
+    this.ctx.layout.selectPanel(null)
+  }
+
+  async openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise<void> {
+    const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal])
+    const isCurrent = (): boolean => !navigation.aborted
+    const sessionId = await this.connectWorkspace(workspaceId)
+    if (!isCurrent()) return
+    beforeOpen?.(sessionId)
+    if (isCurrent()) this.openSession(sessionId)
+  }
+
+  async forkSession(sessionId: SessionId): Promise<void> {
+    const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal])
+    const childId = await this.sessions.fork({ sessionId, increaseTitle: true })
+    if (!navigation.aborted) this.openSession(childId)
+  }
+
   startSession(workspaceId?: WorkspaceId): void {
     const workspace = this.workspaces.list.getSnapshot()
     const sessions = this.sessions.list.getSnapshot()
@@ -124,10 +164,10 @@ class UiWorkspaceService extends Service implements UiWorkspace {
     const target = workspaceId ?? currentWorkspaceId ?? recent
     if (target === undefined) {
       this.sessions.clear()
+      this.ctx.layout.selectPanel(null)
       return
     }
-    void this.connectWorkspace(target).then(
-      (sessionId) => { this.sessions.open(sessionId) },
+    void this.openWorkspace(target).catch(
       (reason: unknown) => { console.warn('new session failed:', reason) },
     )
   }
@@ -156,9 +196,8 @@ class UiWorkspaceService extends Service implements UiWorkspace {
 
   private watchNavigation(): () => void {
     let initial: 'waiting' | 'connecting' | 'done' = 'waiting'
-    let disposed = false
     const reconcile = (): void => {
-      if (disposed) return
+      if (this.lifetime.signal.aborted) return
       if (this.clearArchivedCurrent()) return
       if (initial !== 'waiting') return
       const workspace = this.workspaces.list.getSnapshot()
@@ -176,14 +215,14 @@ class UiWorkspaceService extends Service implements UiWorkspace {
       initial = 'connecting'
       void this.connectWorkspace(target).then(
         (sessionId) => {
-          if (disposed) return
+          if (this.lifetime.signal.aborted) return
           if (this.sessions.list.getSnapshot().current === undefined) {
             this.sessions.open(sessionId)
           }
           initial = 'done'
         },
         (reason: unknown) => {
-          if (disposed) return
+          if (this.lifetime.signal.aborted) return
           initial = 'waiting'
           console.warn('initial workspace selection failed:', reason)
         },
@@ -193,7 +232,7 @@ class UiWorkspaceService extends Service implements UiWorkspace {
     const disposeSessions = this.sessions.list.subscribe(reconcile)
     reconcile()
     return () => {
-      disposed = true
+      this.lifetime.abort()
       disposeSessions()
       disposeWorkspaces()
     }

+ 16 - 7
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -235,7 +235,7 @@ function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }):
 type SessionTreeProps = Pick<
   WorkspaceBrowserProps,
   'useSessions' | 'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession'
-  | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't'
+  | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't' | 'usePanelInfo'
 > & {
   /** Host account home for POSIX hover-path abbreviation. */
   home?: string | undefined
@@ -275,16 +275,17 @@ type SessionTreeProps = Pick<
 /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
 function SessionTree({
   useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds,
-  workspaceReady,
+  workspaceReady, usePanelInfo,
   onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
   insertWorkspaceBefore, insertSessionBefore, orderBy,
   groupExpansion, setGroupExpanded,
   sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t,
   revealSessionId, onSessionRevealed,
 }: SessionTreeProps) {
+  const panelActive = usePanelInfo(info => info.activePanelId !== null)
   const list = useSessions(s => s)
   const pendingInteractions = useSessionPendingInteraction(s => s)
-  const current = list.current
+  const current = panelActive ? undefined : list.current
   const revealGroup = revealSessionId === undefined || !workspaceReady
     ? undefined
     : owningGroupKey(workspaces, revealSessionId)
@@ -619,7 +620,7 @@ function SessionTree({
 /** The flat "In one list" body: every session is one draggable top-level row. */
 function FlatList({
   useSessions, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive,
-  archivedSessionIds,
+  archivedSessionIds, usePanelInfo,
   orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder,
   revealSessionId, onSessionRevealed, t,
 }: Pick<
@@ -631,6 +632,7 @@ function FlatList({
   | 'onSessionRename'
   | 'onSessionArchive'
   | 'archivedSessionIds'
+  | 'usePanelInfo'
   | 'orderBy'
   | 'sessionOrderByAccount'
   | 'sessionUpdatedAtByAccount'
@@ -640,6 +642,7 @@ function FlatList({
   | 'onSessionRevealed'
   | 't'
 >) {
+  const panelActive = usePanelInfo(info => info.activePanelId !== null)
   const list = useSessions(s => s)
   const pendingInteractions = useSessionPendingInteraction(s => s)
   const baseRows = useMemo(
@@ -706,7 +709,7 @@ function FlatList({
             <SessionNodeItem
               key={node.id}
               node={node}
-              currentId={list.current}
+              currentId={panelActive ? undefined : list.current}
               now={now}
               onOpen={open}
               onRename={onSessionRename}
@@ -762,14 +765,16 @@ function SearchResults({
   query,
   remote,
   resultLimit,
+  usePanelInfo,
   t,
-}: Pick<SessionTreeProps, 'useSessions' | 'useSessionPendingInteraction' | 'open' | 't'> & {
+}: Pick<SessionTreeProps, 'useSessions' | 'useSessionPendingInteraction' | 'open' | 't' | 'usePanelInfo'> & {
   workspaces: readonly WorkspaceView[]
   archivedSessionIds: readonly SessionNode['id'][]
   query: string
   remote: RemoteSearchState
   resultLimit: number
 }) {
+  const panelActive = usePanelInfo(info => info.activePanelId !== null)
   const list = useSessions(s => s)
   const pendingInteractions = useSessionPendingInteraction(s => s)
   const currentRemote = remote.query === query
@@ -798,7 +803,7 @@ function SearchResults({
             <SearchResultItem
               key={result.id}
               result={result}
-              currentId={list.current}
+              currentId={panelActive ? undefined : list.current}
               onOpen={open}
               t={t}
             />
@@ -833,6 +838,7 @@ function SearchResults({
  */
 export function WorkspaceBrowser({
   wide,
+  usePanelInfo,
   expandSidebar,
   useSessions,
   useSessionPendingInteraction,
@@ -1251,6 +1257,7 @@ export function WorkspaceBrowser({
         {wide && (normalizedQuery !== ''
           ? (
             <SearchResults
+              usePanelInfo={usePanelInfo}
               useSessions={useSessions}
               useSessionPendingInteraction={useSessionPendingInteraction}
               open={openSearchResult}
@@ -1265,6 +1272,7 @@ export function WorkspaceBrowser({
           : groupBy === 'flat'
             ? (
               <FlatList
+                usePanelInfo={usePanelInfo}
                 useSessions={useSessions} useSessionPendingInteraction={useSessionPendingInteraction}
                 open={open} forkSession={forkSession}
                 onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
@@ -1281,6 +1289,7 @@ export function WorkspaceBrowser({
             )
             : (
               <SessionTree
+                usePanelInfo={usePanelInfo}
                 useSessions={useSessions}
                 useSessionPendingInteraction={useSessionPendingInteraction}
                 onSessionRename={onSessionRename}

+ 4 - 2
packages/client/ui-workspace/tests/apply.client.spec.ts

@@ -21,6 +21,8 @@ async function bench() {
   const insertSessionBefore = vi.fn(async () => ({}))
   const open = vi.fn()
   const clear = vi.fn()
+  const selectPanel = vi.fn()
+  ctx.provide('layout', { selectPanel, beginNavigation: () => new AbortController().signal })
   const search = vi.fn(async () => ({
     ok: true as const,
     value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
@@ -71,7 +73,7 @@ async function bench() {
   ctx.provide('locale', locale)
   return {
     ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename,
-    insertSessionBefore, open, clear, search, renameSession, binding, fork, pickDirectory,
+    insertSessionBefore, open, clear, selectPanel, search, renameSession, binding, fork, pickDirectory,
   }
 }
 
@@ -90,7 +92,7 @@ describe('ui-workspace apply', () => {
 
   it('declares the services it drives', () => {
     expect(inject).toEqual([
-      'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker',
+      'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout',
     ])
   })
 

+ 1 - 0
packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx

@@ -28,6 +28,7 @@ function SidebarFrame({ renderSlot }: FrameProps) {
 /** The assembled sidebar over one Workspace inside the POSIX home the Host reports. */
 async function bench() {
   const runtime = await SlotTestRuntime.create()
+  runtime.ctx.provide('layout', { selectPanel: vi.fn() })
   runtime.releaseWorkspaceSource()
   const directoryPicker = {}
   const remote = new TestRemote(runtime.ctx)

+ 1 - 0
packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx

@@ -33,6 +33,7 @@ beforeEach(() => { localStorage.clear() })
 /** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */
 async function createRuntime(): Promise<SlotTestRuntime> {
   const runtime = await SlotTestRuntime.create()
+  runtime.ctx.provide('layout', { selectPanel: vi.fn() })
   runtime.releaseWorkspaceSource()
   // The rename flow never picks a directory; the namespace only has to be there
   // for ui-workspace's inject to settle.

+ 27 - 1
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -9,6 +9,7 @@ import type {
 } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
 import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts'
@@ -18,6 +19,7 @@ import { zh } from '../src/client/locales.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 afterEach(cleanup)
 const scrollIntoView = vi.fn()
@@ -78,7 +80,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     expandSidebar: vi.fn(),
     useSessions: hook(sessionState([])),
     useSessionPendingInteraction: hook(noPendingInteraction),
-    useResource,
+    usePanelInfo, useResource,
     useWorkspaces: hook(workspaceState([])),
     useStore: bindSnapshotSelector(store),
     actions: store.actions,
@@ -111,6 +113,30 @@ function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrows
 }
 
 describe('WorkspaceBrowser', () => {
+  it('moves focus into Workspace controls without selecting a Session while a main panel is active', () => {
+    const panelInfo = { activePanelId: 'panel-a' as MainPanelId }
+    const b = mount({
+      usePanelInfo: hook(panelInfo),
+      useSessions: hook(sessionState([summary('current', 1)], { current: sid('current') })),
+      useWorkspaces: hook(workspaceState([workspace('alpha', ['current'])])),
+    })
+    fireEvent.click(screen.getByText('alpha'))
+    const current = screen.getByText('current').closest('[role="treeitem"]')
+    expect(current?.getAttribute('aria-selected')).toBe('false')
+    fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
+    const input = screen.getByPlaceholderText('搜索会话…')
+    expect(document.activeElement).toBe(input)
+    fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
+    const add = screen.getByRole('button', { name: '添加工作区' })
+    add.focus()
+    fireEvent.click(add)
+    expect(document.activeElement).toBe(add)
+    expect(screen.getByTestId('directory-flow')).toBeTruthy()
+    expect(panelInfo.activePanelId).toBe('panel-a')
+    expect(b.props.open).not.toHaveBeenCalled()
+    expect(b.props.startSession).not.toHaveBeenCalled()
+  })
+
   it('workspace hover card shows a POSIX home descendant as ~', () => {
     vi.useFakeTimers()
     try {

+ 4 - 3
packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx

@@ -16,6 +16,7 @@ import { zh } from '../src/client/locales.ts'
 
 // Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
 const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+const usePanelInfo: GlobalStandardProps['usePanelInfo'] = selector => selector({ activePanelId: null })
 
 afterEach(cleanup)
 
@@ -98,7 +99,7 @@ function mount(
       anchorRef={anchorRef}
       useSessions={hook(sessions)}
       useSessionPendingInteraction={hook(noPendingInteraction)}
-      useResource={useResource}
+      usePanelInfo={usePanelInfo} useResource={useResource}
       useWorkspaces={hook(workspaceState(nextItems))}
       onPick={onPick}
       onClose={onClose}
@@ -220,7 +221,7 @@ describe('WorkspacePicker', () => {
       <WorkspacePicker
         open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([workspace('alpha', 'Alpha')]))}
         useSessionPendingInteraction={hook(noPendingInteraction)}
-        useResource={useResource}
+        usePanelInfo={usePanelInfo} useResource={useResource}
         onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
         useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
       />,
@@ -237,7 +238,7 @@ describe('WorkspacePicker', () => {
       <WorkspacePicker
         open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
         useSessionPendingInteraction={hook(noPendingInteraction)}
-        useResource={useResource}
+        usePanelInfo={usePanelInfo} useResource={useResource}
         onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
         useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
       />,

+ 132 - 1
packages/client/ui-workspace/tests/workspaces-service.client.spec.ts

@@ -10,6 +10,8 @@ import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remote
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
 import { SessionId } from '@deepseek-ai/dsh-session/types'
+import { LayoutController } from '@deepseek-ai/dsh-client-ui-layout/client'
+import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client'
 import { DirectoryBrowseError, UiWorkspaceService } from '../src/client/navigation.ts'
 
 const sid = (id: string): SessionId => SessionId(id)
@@ -108,6 +110,7 @@ class FakeSessions {
   readonly create: ReturnType<typeof vi.fn<ISessions['create']>>
   readonly open: ReturnType<typeof vi.fn<(id: SessionId) => void>>
   readonly clear: ReturnType<typeof vi.fn<() => void>>
+  readonly fork = vi.fn<ISessions['fork']>(async () => sid('forked'))
 
   constructor(initial: SessionListState) {
     this.list = new MutableSource(initial)
@@ -189,6 +192,14 @@ interface BenchOptions {
 
 function bench(options: BenchOptions = {}) {
   const ctx = new Context()
+  const layout = new LayoutController({
+    selectPanel: vi.fn(), retainMainPanels: vi.fn(),
+    setSidebar: vi.fn(), toggleSidebar: vi.fn(), setViewportWidth: vi.fn(),
+    setRightbar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn(),
+  }, () => true)
+  const selectPanel = vi.spyOn(layout, 'selectPanel')
+  ctx.provide('layout', layout)
+  ctx.effect(() => () => { layout.dispose() })
   const directoryPicker = new FakeDirectoryPicker()
   const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
   const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending'))
@@ -198,7 +209,7 @@ function bench(options: BenchOptions = {}) {
     workspaces,
     sessions as unknown as ISessions,
   )
-  return { ctx, directoryPicker, sessions, uiWorkspace, workspaces }
+  return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, layout, selectPanel }
 }
 
 async function flush(): Promise<void> {
@@ -207,6 +218,126 @@ async function flush(): Promise<void> {
 }
 
 describe('UiWorkspaceService', () => {
+  it('selects a Session before revealing its Conversation, including the current Session', () => {
+    const current = sid('current')
+    const b = bench({ sessions: sessionState([summary('current')], current) })
+    b.selectPanel.mockImplementation(() => {
+      expect(b.sessions.list.getSnapshot().current).toBe(current)
+    })
+    b.uiWorkspace.openSession(current)
+    expect(b.sessions.open).toHaveBeenCalledWith(current)
+    expect(b.selectPanel).toHaveBeenCalledWith(null)
+    expect(b.sessions.open.mock.invocationCallOrder[0]).toBeLessThan(b.selectPanel.mock.invocationCallOrder[0]!)
+  })
+
+  it('keeps the current panel when selecting a Session throws', () => {
+    const b = bench()
+    b.sessions.open.mockImplementationOnce(() => { throw new Error('selection failed') })
+    expect(() => { b.uiWorkspace.openSession(sid('target')) }).toThrow('selection failed')
+    expect(b.selectPanel).not.toHaveBeenCalled()
+  })
+
+  it('leaves a later panel selection in place when New Session finishes', async () => {
+    const b = bench({
+      sessions: sessionState([summary('current')], sid('current')),
+      workspaces: workspaceState([workspace('alpha')]),
+    })
+    const created = Promise.withResolvers<SessionId>()
+    b.sessions.create.mockReturnValue(created.promise)
+    const opening = vi.spyOn(b.uiWorkspace, 'openWorkspace')
+    b.uiWorkspace.startSession(wid('alpha'))
+    b.layout.selectPanel('panel-a' as MainPanelId)
+    created.resolve(sid('late'))
+    await opening.mock.results[0]!.value
+    expect(b.sessions.open).not.toHaveBeenCalled()
+    expect(b.selectPanel).toHaveBeenCalledExactlyOnceWith('panel-a')
+    expect(b.sessions.list.getSnapshot().current).toBe(sid('current'))
+  })
+
+  it('opens only the latest Workspace request when creation completes out of order', async () => {
+    const b = bench({
+      sessions: sessionState([summary('current')], sid('current')),
+      workspaces: workspaceState([workspace('alpha'), workspace('beta')]),
+    })
+    const older = Promise.withResolvers<SessionId>()
+    const newer = Promise.withResolvers<SessionId>()
+    b.sessions.create.mockImplementation(options => options?.workspaceId === wid('alpha') ? older.promise : newer.promise)
+    const oldDraft = vi.fn()
+    const newDraft = vi.fn()
+    const first = b.uiWorkspace.openWorkspace(wid('alpha'), oldDraft)
+    const second = b.uiWorkspace.openWorkspace(wid('beta'), newDraft)
+    newer.resolve(sid('newer'))
+    await second
+    older.resolve(sid('older'))
+    await first
+    expect(oldDraft).not.toHaveBeenCalled()
+    expect(newDraft).toHaveBeenCalledExactlyOnceWith(sid('newer'))
+    expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('newer'))
+  })
+
+  it('does not move drafts or reopen a Workspace after reselecting the current Session', async () => {
+    const b = bench({
+      sessions: sessionState([summary('current')], sid('current')),
+      workspaces: workspaceState([workspace('alpha')]),
+    })
+    const created = Promise.withResolvers<SessionId>()
+    b.sessions.create.mockReturnValue(created.promise)
+    const moveDraft = vi.fn()
+    const pending = b.uiWorkspace.openWorkspace(wid('alpha'), moveDraft)
+    b.uiWorkspace.openSession(sid('current'))
+    created.resolve(sid('late'))
+    await pending
+    expect(moveDraft).not.toHaveBeenCalled()
+    expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('current'))
+  })
+
+  it('keeps navigation performed by preparation and propagates preparation failures', async () => {
+    const b = bench({
+      sessions: sessionState([summary('current')], sid('current')),
+      workspaces: workspaceState([workspace('alpha')]),
+    })
+    await b.uiWorkspace.openWorkspace(wid('alpha'), () => { b.layout.selectPanel('panel-a' as MainPanelId) })
+    expect(b.sessions.open).not.toHaveBeenCalled()
+    await expect(b.uiWorkspace.openWorkspace(wid('alpha'), () => { throw new Error('draft failed') })).rejects.toThrow('draft failed')
+    expect(b.sessions.open).not.toHaveBeenCalled()
+  })
+
+  it('opens a fork only while its navigation is current and preserves fork failures', async () => {
+    const b = bench()
+    await b.uiWorkspace.forkSession(sid('source'))
+    expect(b.sessions.fork).toHaveBeenCalledWith({ sessionId: sid('source'), increaseTitle: true })
+    expect(b.sessions.open).toHaveBeenCalledExactlyOnceWith(sid('forked'))
+    const forked = Promise.withResolvers<SessionId>()
+    b.sessions.fork.mockReturnValueOnce(forked.promise)
+    const pending = b.uiWorkspace.forkSession(sid('source'))
+    b.layout.selectPanel('panel-a' as MainPanelId)
+    forked.resolve(sid('late-fork'))
+    await pending
+    expect(b.sessions.open).toHaveBeenCalledOnce()
+    b.sessions.fork.mockRejectedValueOnce(new Error('fork failed'))
+    await expect(b.uiWorkspace.forkSession(sid('source'))).rejects.toThrow('fork failed')
+    expect(b.sessions.open).toHaveBeenCalledOnce()
+  })
+
+  it('does not open a pending Workspace or fork after the navigation owner is disposed', async () => {
+    for (const kind of ['workspace', 'fork'] as const) {
+      const b = bench({
+        sessions: sessionState([summary('current')], sid('current')),
+        workspaces: workspaceState([workspace('alpha')]),
+      })
+      const resolved = Promise.withResolvers<SessionId>()
+      b.sessions.create.mockReturnValue(resolved.promise)
+      b.sessions.fork.mockReturnValue(resolved.promise)
+      const pending = kind === 'workspace'
+        ? b.uiWorkspace.openWorkspace(wid('alpha'))
+        : b.uiWorkspace.forkSession(sid('current'))
+      await b.ctx.fiber.dispose()
+      resolved.resolve(sid('late'))
+      await pending
+      expect(b.sessions.open).not.toHaveBeenCalled()
+    }
+  })
+
   it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
     const b = bench()
     const memberBlank = sid('member-blank')

Някои файлове не бяха показани, защото твърде много файлове са промени