1
0
Эх сурвалжийг харах

test(client): migrate panel navigation fixtures and snapshots

imccyu 1 долоо хоног өмнө
parent
commit
f95e781f3d
45 өөрчлөгдсөн 657 нэмэгдсэн , 224 устгасан
  1. 2 1
      packages/client/locale/tests/language-row.client.spec.tsx
  2. 2 1
      packages/client/ui-attachment/tests/message-image.client.spec.tsx
  3. 2 1
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  4. 4 2
      packages/client/ui-chat/tests/chat-apply.client.spec.tsx
  5. 1 0
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  6. 1 0
      packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
  7. 7 4
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  8. 17 8
      packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
  9. 19 7
      packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
  10. 1 0
      packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
  11. 1 0
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  12. 1 0
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  13. 1 0
      packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
  14. 2 0
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  15. 4 0
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  16. 64 46
      packages/client/ui-layout/tests/app-frame.client.spec.tsx
  17. 81 18
      packages/client/ui-layout/tests/apply.client.spec.ts
  18. 60 14
      packages/client/ui-layout/tests/document-title.client.spec.tsx
  19. 115 49
      packages/client/ui-layout/tests/layout-store.client.spec.ts
  20. 31 21
      packages/client/ui-layout/tests/service.client.spec.ts
  21. 2 1
      packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
  22. 2 1
      packages/client/ui-settings-general/tests/components.client.spec.tsx
  23. 2 1
      packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
  24. 2 1
      packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx
  25. 2 1
      packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx
  26. 15 14
      packages/client/ui-sidebar-right/tests/apply.client.spec.ts
  27. 16 2
      packages/client/ui-sidebar-right/tests/seat.client.spec.tsx
  28. 107 0
      packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap
  29. 30 6
      packages/client/ui-sidebar/tests/apply.client.spec.tsx
  30. 2 1
      packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx
  31. 5 4
      packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx
  32. 2 1
      packages/client/ui-theme/tests/appearance-row.client.spec.tsx
  33. 2 1
      packages/client/ui-theme/tests/font-size-row.client.spec.tsx
  34. 4 3
      packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx
  35. 5 3
      packages/client/ui-tool/tests/toolview-slot.client.spec.tsx
  36. 3 2
      packages/client/ui-trajectory/tests/views.client.spec.tsx
  37. 2 1
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  38. 2 1
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  39. 2 1
      packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx
  40. 4 2
      packages/client/ui-workspace/tests/apply.client.spec.ts
  41. 1 0
      packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx
  42. 1 0
      packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx
  43. 2 1
      packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
  44. 4 3
      packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx
  45. 22 1
      packages/client/ui-workspace/tests/workspaces-service.client.spec.ts

+ 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,

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

@@ -58,6 +58,7 @@ async function bench() {
   new TestRemote(runtime.ctx, { session: { openWorkspacePath } })
   runtime.ctx.provide('uiWorkspace', {
     connectWorkspace: vi.fn(async () => ROOT),
+    openSession: (id: SessionId) => { runtime.sessions.open(id) },
   } as never)
   const session = sessionFakeFor()
   await runtime.sessions.add({
@@ -69,7 +70,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 })

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

@@ -45,6 +45,7 @@ async function bench() {
   runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
   runtime.ctx.provide('uiWorkspace', {
     connectWorkspace: vi.fn(async () => 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 +54,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 +121,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(),

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

@@ -49,7 +49,10 @@ 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', {
+    connectWorkspace,
+    openSession: (id: SessionId) => { runtime.sessions.open(id) },
+  } as never)
   const sessionFake = sessionFakeFor()
   await runtime.sessions.add({
     id: ROOT,
@@ -60,12 +63,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 +80,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) => {

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

@@ -14,14 +14,17 @@ 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', {
+    connectWorkspace: vi.fn(async () => 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 +34,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 +42,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 +72,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 +117,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()
   })

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

@@ -40,13 +40,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 +60,10 @@ 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', {
+    connectWorkspace: vi.fn(async () => 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 +85,10 @@ 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', {
+      connectWorkspace: vi.fn(async () => 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 +115,10 @@ 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', {
+      connectWorkspace: vi.fn(async () => 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 +183,10 @@ 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', {
+      connectWorkspace: vi.fn(async () => 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,

+ 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)
   })
 })

+ 81 - 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,41 @@ 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
     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(instance.getSnapshot().layoutInfo.sidebar).toBe(0)
   })
 
   it('theme presenter applies the initial snapshot, follows theme/change, and unwinds on dispose', async () => {
@@ -85,13 +142,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 })
   })
 })

+ 31 - 21
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)
 
     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)
 
     service.toggleSidebar()
 
@@ -43,21 +43,31 @@ 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)
+    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)
-
-    service.toggleSidebar()
-
-    expect(stale.toggleSidebar).not.toHaveBeenCalled()
-    expect(fresh.toggleSidebar).toHaveBeenCalledTimes(1)
+  it('keeps separately constructed controllers bound to their own instances', () => {
+    const first = fakePanels()
+    const second = fakePanels()
+    const firstService = new LayoutController(first)
+    const secondService = new LayoutController(second)
+    firstService.toggleSidebar()
+    expect(first.toggleSidebar).toHaveBeenCalledTimes(1)
+    expect(second.toggleSidebar).not.toHaveBeenCalled()
+    secondService.closeRightbar()
+    expect(first.closeRightbar).not.toHaveBeenCalled()
+    expect(second.closeRightbar).toHaveBeenCalledTimes(1)
   })
 })

+ 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),

+ 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]')

+ 107 - 0
packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap

@@ -60,6 +60,35 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad
         xmlns="http://www.w3.org/2000/svg"
       />
     </button>
+    <nav
+      aria-label="Global panels"
+      class="panelList"
+    >
+      <button
+        aria-label="Hello World"
+        class="panelRow"
+        type="button"
+      >
+        <span
+          aria-hidden="true"
+          class="panelGlyph"
+        >
+          <div
+            data-slot="sidebar.panellist"
+            style="display: contents;"
+          >
+            <svg
+              data-content="f23fa4b8"
+              fill="none"
+              height="18"
+              viewBox="0 0 14 14"
+              width="18"
+              xmlns="http://www.w3.org/2000/svg"
+            />
+          </div>
+        </span>
+      </button>
+    </nav>
     <div
       class="regionArea"
     >
@@ -190,6 +219,45 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul
         New Session
       </span>
     </button>
+    <nav
+      aria-label="Global panels"
+      class="panelList"
+    >
+      <button
+        aria-label="Hello World"
+        class="panelRow"
+        type="button"
+      >
+        <span
+          aria-hidden="true"
+          class="panelGlyph"
+        >
+          <div
+            data-slot="sidebar.panellist"
+            style="display: contents;"
+          >
+            <svg
+              data-content="f23fa4b8"
+              fill="none"
+              height="16"
+              viewBox="0 0 14 14"
+              width="16"
+              xmlns="http://www.w3.org/2000/svg"
+            />
+          </div>
+        </span>
+        <span
+          class="panelTitle wide"
+        >
+          <div
+            data-slot="sidebar.panellist.title"
+            style="display: contents;"
+          >
+            Hello World
+          </div>
+        </span>
+      </button>
+    </nav>
     <div
       class="regionArea"
     >
@@ -320,6 +388,45 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo
         新会话
       </span>
     </button>
+    <nav
+      aria-label="全局面板"
+      class="panelList"
+    >
+      <button
+        aria-label="Hello World"
+        class="panelRow"
+        type="button"
+      >
+        <span
+          aria-hidden="true"
+          class="panelGlyph"
+        >
+          <div
+            data-slot="sidebar.panellist"
+            style="display: contents;"
+          >
+            <svg
+              data-content="f23fa4b8"
+              fill="none"
+              height="16"
+              viewBox="0 0 14 14"
+              width="16"
+              xmlns="http://www.w3.org/2000/svg"
+            />
+          </div>
+        </span>
+        <span
+          class="panelTitle wide"
+        >
+          <div
+            data-slot="sidebar.panellist.title"
+            style="display: contents;"
+          >
+            Hello World
+          </div>
+        </span>
+      </button>
+    </nav>
     <div
       class="regionArea"
     >

+ 30 - 6
packages/client/ui-sidebar/tests/apply.client.spec.tsx

@@ -2,15 +2,20 @@
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, 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 { apply as hostApply } from '../src/index.ts'
 
+function SidebarFrame({ renderSlot }: PropsRenderSlots<'sidebar'>) {
+  return renderSlot('sidebar', { collapsed: false, width: 300 })
+}
+
 async function bench(declare = true) {
   const ctx = new Context()
   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 +23,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 +51,15 @@ 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' })
+    expect(b.slots.spec('sidebar.panellist.title')).toEqual({ kind: 'keyed', 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([
+      { id: 'hello-world', order: 100, label: 'Hello World' },
+    ])
     // Both arms delegate to the Workspace UI's shared New Session action.
     injected.startSession('workspace' as never)
     expect(b.uiWorkspace.startSession).toHaveBeenCalledWith('workspace')
@@ -56,9 +69,18 @@ describe('ui-sidebar apply', () => {
     expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
   })
 
-  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('removes the entry and child declaration on teardown', async () => {
@@ -71,5 +93,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)
   })
 })

+ 2 - 1
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,7 +41,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } {
     <SidebarRoot
       collapsed={false} width={300}
       useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
-      useResource={useResource} useWorkspaces={neverHook}
+      usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook}
       startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
       renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>
         <div data-testid="region" data-wide={owner.wide} />) as SidebarRootComponentProps['renderSlot']}

+ 5 - 4
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,7 +46,7 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
     <SidebarRoot
       collapsed={current.collapsed} width={current.width}
       useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
-      useResource={useResource} useWorkspaces={neverHook}
+      usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useResource={useResource} useWorkspaces={neverHook}
       startSession={startSession} toggleSidebar={toggleSidebar} t={t}
       renderSlot={((
         key: string,
@@ -110,7 +111,7 @@ describe('SidebarRoot shell', () => {
     const { container } = render(<SidebarRoot
       collapsed={false} width={300}
       useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
-      useResource={useResource} useWorkspaces={neverHook}
+      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 }) =>
         options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
@@ -129,7 +130,7 @@ describe('SidebarRoot shell', () => {
     render(<SidebarRoot
       collapsed={false} width={300}
       useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
-      useResource={useResource} useWorkspaces={neverHook}
+      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 }) =>
         options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
@@ -143,7 +144,7 @@ describe('SidebarRoot shell', () => {
     render(<SidebarRoot
       collapsed={false} width={300}
       useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
-      useResource={useResource} useWorkspaces={neverHook}
+      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 }) =>
         options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}

+ 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,

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

@@ -59,13 +59,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[]) {
@@ -80,6 +80,7 @@ async function bench(nodes: ToolResultNode[]) {
   runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
   runtime.ctx.provide('uiWorkspace', {
     connectWorkspace: vi.fn(async () => SID),
+    openSession: (id: SessionId) => { runtime.sessions.open(id) },
   } as never)
   const locale = new LocaleRuntime(runtime.ctx)
   runtime.ctx.provide('locale', locale)

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

@@ -42,13 +42,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
 
 /**
@@ -67,6 +67,7 @@ async function bench(nodes: ToolResultNode[]) {
   runtime.ctx.provide('sidebarRight', sidebarRight as never)
   runtime.ctx.provide('uiWorkspace', {
     connectWorkspace: vi.fn(async () => SID),
+    openSession: (id: SessionId) => { runtime.sessions.open(id) },
   } as never)
   const locale = new LocaleRuntime(runtime.ctx)
   runtime.ctx.provide('locale', locale)
@@ -213,6 +214,7 @@ describe('registrant declaration injection', () => {
     runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
     runtime.ctx.provide('uiWorkspace', {
       connectWorkspace: vi.fn(async () => 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,

+ 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 })
   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.

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

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

+ 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}
       />,

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

@@ -189,6 +189,8 @@ interface BenchOptions {
 
 function bench(options: BenchOptions = {}) {
   const ctx = new Context()
+  const selectPanel = vi.fn()
+  ctx.provide('layout', { selectPanel })
   const directoryPicker = new FakeDirectoryPicker()
   const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
   const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending'))
@@ -198,7 +200,7 @@ function bench(options: BenchOptions = {}) {
     workspaces,
     sessions as unknown as ISessions,
   )
-  return { ctx, directoryPicker, sessions, uiWorkspace, workspaces }
+  return { ctx, directoryPicker, sessions, uiWorkspace, workspaces, selectPanel }
 }
 
 async function flush(): Promise<void> {
@@ -207,6 +209,25 @@ 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('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
     const b = bench()
     const memberBlank = sid('member-blank')