Sfoglia il codice sorgente

refactor(client): bind Session UI through providers

imccyu 2 settimane fa
parent
commit
b9b14dc05e
55 ha cambiato i file con 908 aggiunte e 427 eliminazioni
  1. 5 5
      packages/bundle/web-app/cordis.patch.yml
  2. 2 3
      packages/client/store/src/contract.ts
  3. 1 0
      packages/client/ui-agent-preset/package.json
  4. 6 2
      packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx
  5. 56 54
      packages/client/ui-agent-preset/src/client/index.ts
  6. 24 10
      packages/client/ui-agent-preset/src/client/seat-store.ts
  7. 3 0
      packages/client/ui-agent-preset/tsconfig.json
  8. 2 2
      packages/client/ui-chat/src/client/apply.ts
  9. 1 0
      packages/client/ui-commands/package.json
  10. 21 12
      packages/client/ui-commands/src/client/service.ts
  11. 3 0
      packages/client/ui-commands/tsconfig.json
  12. 1 0
      packages/client/ui-conversation/package.json
  13. 12 17
      packages/client/ui-conversation/src/client/apply.ts
  14. 1 1
      packages/client/ui-conversation/src/client/contract/slots.ts
  15. 12 9
      packages/client/ui-conversation/src/client/conversation/assembly.ts
  16. 41 44
      packages/client/ui-conversation/src/client/conversation/historical-images.ts
  17. 19 14
      packages/client/ui-conversation/src/client/input/hub.ts
  18. 3 3
      packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
  19. 3 0
      packages/client/ui-conversation/tsconfig.json
  20. 1 0
      packages/client/ui-input-trigger/package.json
  21. 21 14
      packages/client/ui-input-trigger/src/client/service.ts
  22. 3 0
      packages/client/ui-input-trigger/tsconfig.json
  23. 2 1
      packages/client/ui-layout/src/client/DocumentTitle.tsx
  24. 1 0
      packages/client/ui-model-selection/package.json
  25. 9 8
      packages/client/ui-model-selection/src/client/service.ts
  26. 3 0
      packages/client/ui-model-selection/tsconfig.json
  27. 43 11
      packages/client/ui-renderer/src/client/bindings.tsx
  28. 10 13
      packages/client/ui-renderer/src/client/registry.ts
  29. 47 14
      packages/client/ui-renderer/src/client/scoped-slots.tsx
  30. 3 0
      packages/client/ui-session/package.json
  31. 249 72
      packages/client/ui-session/src/client/index.ts
  32. 4 5
      packages/client/ui-session/src/client/session-provider.tsx
  33. 6 0
      packages/client/ui-session/tsconfig.json
  34. 5 3
      packages/client/ui-settings-general/src/client/SettingsRoot.tsx
  35. 13 8
      packages/client/ui-slots/src/index.ts
  36. 5 5
      packages/client/ui-slots/src/renderer.ts
  37. 4 2
      packages/client/ui-subagent/package.json
  38. 2 2
      packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
  39. 3 2
      packages/client/ui-subagent/src/client/index.ts
  40. 3 0
      packages/client/ui-subagent/tsconfig.json
  41. 3 1
      packages/client/ui-workflow-run/package.json
  42. 17 5
      packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx
  43. 4 3
      packages/client/ui-workflow-run/src/client/index.ts
  44. 3 0
      packages/client/ui-workflow-run/tsconfig.json
  45. 1 0
      packages/client/ui-workspace/package.json
  46. 11 5
      packages/client/ui-workspace/src/client/index.ts
  47. 98 22
      packages/client/ui-workspace/src/client/navigation.ts
  48. 34 24
      packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
  49. 36 25
      packages/client/ui-workspace/src/client/tree.ts
  50. 3 0
      packages/client/ui-workspace/tsconfig.json
  51. 4 1
      packages/experimental/client-ui-agent-team/package.json
  52. 5 4
      packages/experimental/client-ui-agent-team/src/client/mount.ts
  53. 1 0
      packages/experimental/client-ui-agent-team/tsconfig.json
  54. 2 1
      packages/extensions/ui-cordis/src/client/CordisPanel.tsx
  55. 33 0
      pnpm-lock.yaml

+ 5 - 5
packages/bundle/web-app/cordis.patch.yml

@@ -282,11 +282,6 @@
     - id: ui-cordis
       name: '@deepseek-ai/dsh-client-ui-cordis'
 
-    # Durable workflow lifecycle as an independent Chat node after the
-    # existing generic workflow tool row.
-    - id: ui-workflow-run
-      name: '@deepseek-ai/dsh-client-ui-workflow-run'
-
     # Turn tail: the changed-files card and delivery cards under each closing
     # assistant message. Remove this entry to turn the surface off; the tail
     # hole renders empty.
@@ -302,6 +297,11 @@
     - id: ui-workspace
       name: '@deepseek-ai/dsh-client-ui-workspace'
 
+    # Durable workflow lifecycle as an independent Chat node after the
+    # existing generic workflow tool row.
+    - id: ui-workflow-run
+      name: '@deepseek-ai/dsh-client-ui-workflow-run'
+
     # Input triggers: the '/' | '@' pipeline (ui-input-trigger), the command surface over
     # it (ui-commands), and the reference sources (ui-skill / ui-reference).
     - id: ui-input-trigger

+ 2 - 3
packages/client/store/src/contract.ts

@@ -77,9 +77,8 @@ export interface StoreInstance<T, A extends ActionsDecl<T>> {
    */
   subscribe(fn: () => void): () => void
   /**
-   * Drop this instance's persisted value (no-op for non-persist specs). The
-   * framework calls it when the owning scope dies for good — a pruned session
-   * must not leave orphaned storage keys behind.
+   * Explicitly drop this instance's persisted value.
+   * Runtime scope teardown does not call this operation.
    */
   clearPersisted(): void
 }

+ 1 - 0
packages/client/ui-agent-preset/package.json

@@ -61,6 +61,7 @@
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0",

+ 6 - 2
packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx

@@ -82,8 +82,12 @@ export type AgentPresetSeatProps =
  * @param props - composed slot props.
  * @returns the chip, or null when the deployment composes no presets.
  */
-export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) {
+export function AgentPresetSeat({
+  sessionId, useSessionRetainInfo, load, select, introduced, useAgentPresetSeat, t,
+}: AgentPresetSeatProps) {
   const state = useAgentPresetSeat(snapshot => snapshot)
+  const main = useSessionRetainInfo(info => sessionId === undefined
+    || (info?.retainedBy.mainView ?? 0) > 0)
   const [open, setOpen] = useState(false)
   // The seq keys the banner, so picking the same broken preset twice replays
   // it rather than leaving the first one silently in place.
@@ -132,7 +136,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
 
   // Nothing to choose between: the deployment composes no presets and every
   // session shares the host composition.
-  if (!state.showPicker || !ready) return null
+  if (!main || !state.showPicker || !ready) return null
 
   // One wrapper span: the chip is a flex row with a gap, so loose character
   // spans would each pick up the gap between them.

+ 56 - 54
packages/client/ui-agent-preset/src/client/index.ts

@@ -13,7 +13,9 @@
  */
 
 // Type-only: pulls the Session Controller service merge (ctx.sessions).
-import type {} from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 // Type-only: pulls the ctx.remote merge and the forwarded-event key face
@@ -31,7 +33,7 @@ import { AgentPresetSeat } from './AgentPresetSeat.tsx'
 import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
 import { AgentPresetSection } from './AgentPresetSection.tsx'
 import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
-import { AgentPresetSeatController } from './seat-store.ts'
+import { AgentPresetSeatController, type AgentPresetStage } from './seat-store.ts'
 import { AgentPresetSectionController } from './section-store.ts'
 import { en, zh, type AgentPresetSettingsKey } from './locales.ts'
 import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
@@ -55,7 +57,7 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t
 
 /** Required services (cordis fiber inject). */
 export const inject = [
-  'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings',
+  'slots', 'sessions', 'locale', 'remote', 'remote.agentPresets', 'remote.settings',
 ]
 
 /**
@@ -64,13 +66,40 @@ export const inject = [
  */
 export function apply(ctx: ClientContext): void {
   const controller = new AgentPresetSettingsController(ctx)
-  // One roster, three surfaces. The chip is registered in a later scope, so it
-  // subscribes here rather than being reached from this one.
-  const rosterReaders = new Set<() => void>()
+  const staged: AgentPresetStage = { id: undefined, introduce: false }
+  const seats = new WeakMapWithValues<SessionBinding, AgentPresetSeatController>()
+  const unboundSeat = new AgentPresetSeatController(ctx, () => undefined, staged)
+  const seatFor = (scope: ClientContext, binding: SessionBinding): AgentPresetSeatController => {
+    let seat = seats.get(binding)
+    if (seat !== undefined) return seat
+    seat = new AgentPresetSeatController(scope, () => {
+      if (scope.sessions.binding(binding.sessionId) !== binding) return undefined
+      const summary = scope.sessions.list.getSnapshot().byId[binding.sessionId]
+      return summary !== undefined
+        && (scope.sessions.retainInfo(binding.sessionId).getSnapshot().retainedBy.mainView ?? 0) > 0
+        ? summary
+        : undefined
+    }, staged)
+    seats.set(binding, seat)
+    binding.ctx.effect(() => () => {
+      seats.delete(binding)
+    }, 'ui-agent-preset: Provider binding')
+    return seat
+  }
   const section = new AgentPresetSectionController(ctx, () => {
     void controller.load()
-    for (const read of rosterReaders) read()
+    void unboundSeat.load()
+    for (const seat of seats.values) void seat.load()
   })
+  const mainBlankSeat = (scope: ClientContext): AgentPresetSeatController | undefined => {
+    const summary = Object.values(scope.sessions.list.getSnapshot().byId)
+      .find((session) => {
+        /* v8 ignore next -- retained source counts omit zero-valued entries. */
+        return session.blank && (session.retainedBy.mainView ?? 0) > 0
+      })
+    const binding = summary === undefined ? undefined : scope.sessions.binding(summary.id)
+    return binding === undefined ? undefined : seatFor(scope, binding)
+  }
 
   ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
 
@@ -82,6 +111,8 @@ export function apply(ctx: ClientContext): void {
       // The section reads the same roster and marks the same default, so a
       // change made from either surface converges both.
       if (section.store.getSnapshot().status !== 'idle') void section.load()
+      void unboundSeat.load()
+      for (const seat of seats.values) void seat.load()
     }
     const disposers = [
       ctx.remote.$on('settings/document-updated', (ns) => {
@@ -90,7 +121,6 @@ export function apply(ctx: ClientContext): void {
       }),
       ctx.on('connection/reset', () => {
         refresh()
-        for (const read of rosterReaders) read()
       }),
     ]
     return () => { for (const dispose of disposers) dispose() }
@@ -102,22 +132,17 @@ export function apply(ctx: ClientContext): void {
   // unbound with it, so the section's face reads the current binding per
   // render and simply hides the button while no flow exists.
   let creatorDraft: (() => void) | undefined
-  let activeSeat: AgentPresetSeatController | undefined
-
-  // The new-session chip and the header label: one controller, because the
-  // staged choice belongs to the flow rather than to any one session.
   ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => {
-    const seat = new AgentPresetSeatController(scope, () => {
-      const state = scope.sessions.list.getSnapshot()
-      return state.current === undefined ? undefined : state.byId[state.current]
-    })
-    activeSeat = seat
-    const seatInjected = (): AgentPresetSeatInjected => ({
-      hooks: { agentPresetSeat: seat.store },
-      load: () => seat.load(),
-      select: (id: string) => seat.select(id),
-      introduced: () => { seat.introduced() },
-    })
+    const seatInjected = (sessionId: SessionId | undefined): AgentPresetSeatInjected => {
+      const binding = sessionId === undefined ? undefined : scope.sessions.binding(sessionId)
+      const seat = binding === undefined ? unboundSeat : seatFor(scope, binding)
+      return {
+        hooks: { agentPresetSeat: seat.store },
+        load: () => seat.load(),
+        select: (id: string) => seat.select(id),
+        introduced: () => { seat.introduced() },
+      }
+    }
 
     const labelInjected = (): AgentPresetLabelInjected => ({
       hooks: { agentPresets: controller.store },
@@ -125,35 +150,12 @@ export function apply(ctx: ClientContext): void {
     })
 
     scope.effect(() => {
-      // Connecting a workspace either creates a blank session or reuses one,
-      // and either way the chip's pick predates it — so the stage is applied
-      // when the session arrives, not when it was made.
-      const stop = scope.sessions.list.subscribe(() => { void seat.apply() })
-      // The chip opens on the deployment default, so a default changed from
-      // the settings surface moves it too — otherwise the screen that starts
-      // the next session keeps offering the previous default until a reload,
-      // which is exactly the session the setting claims to govern. A staged
-      // pick survives: `load()` prefers it over the refreshed fallback.
-      const settingsMoved = scope.remote.$on('settings/document-updated', (ns) => {
-        if (ns !== AGENT_PRESET_SETTINGS_NS) return
-        void seat.load()
-      })
-      // Authoring writes a FILE, not a setting, so nothing on the wire
-      // announces it — without this the screen that starts the next session
-      // keeps offering the roster as it stood when the chip first loaded, and
-      // a preset authored to be used is missing from the one place it is used.
-      const readRoster = (): void => { void seat.load() }
-      rosterReaders.add(readRoster)
-      // Stage WITHOUT applying — the still-current running session would
-      // refuse the swap and drop the stage — then start the session it lands
-      // on: the chip's list-change applier composes the blank session the
-      // workspace connect produces or reuses.
       creatorDraft = () => {
         if (!section.store.getSnapshot().showPicker) return
-        // The introduce cue makes the chip announce the pick the user never
-        // made on this screen — the stage happened back in settings.
+        const seat = mainBlankSeat(scope) ?? unboundSeat
         seat.stage('cordis', true)
         scope.uiWorkspace.startSession()
+        void seat.apply()
       }
       const chip = scope.slots.register({
         name: 'conversation.hero.agentPreset',
@@ -169,11 +171,7 @@ export function apply(ctx: ClientContext): void {
         inject: labelInjected,
       }, AgentPresetLabel)
       return () => {
-        stop()
-        settingsMoved()
-        rosterReaders.delete(readRoster)
         creatorDraft = undefined
-        activeSeat = undefined
         chip()
         label()
       }
@@ -182,10 +180,14 @@ export function apply(ctx: ClientContext): void {
 
   /** Capture the exact blank Session one Settings action may update. */
   const captureBlankSessionSync = (): ((id: string) => Promise<string | undefined>) => {
-    const seat = activeSeat
+    const summary = Object.values(ctx.sessions.list.getSnapshot().byId)
+      .find(session => session.blank && (session.retainedBy.mainView ?? 0) > 0)
+    const binding = summary === undefined ? undefined : ctx.sessions.binding(summary.id)
+    const seat = binding === undefined ? undefined : seats.get(binding)
     const sessionId = seat?.blankSessionId()
     return async (id: string) => {
-      if (seat === undefined || sessionId === undefined || activeSeat !== seat) return undefined
+      if (seat === undefined || sessionId === undefined || binding === undefined
+        || seats.get(binding) !== seat) return undefined
       return await seat.syncBlankSession(sessionId, id)
     }
   }

+ 24 - 10
packages/client/ui-agent-preset/src/client/seat-store.ts

@@ -42,6 +42,14 @@ const INITIAL: AgentPresetSeatState = {
   showPicker: false, options: [], current: '', error: null, busy: false, introduce: false,
 }
 
+/** Mutable one-shot preset choice shared across Provider-bound seat controllers. */
+export interface AgentPresetStage {
+  /** Preset awaiting application; absence means no staged choice. */
+  id: string | undefined
+  /** Whether the receiving chip should announce the applied choice once. */
+  introduce: boolean
+}
+
 /** Stages the next session's preset and applies it when one appears. */
 export class AgentPresetSeatController {
   /** Chip snapshot the renderer subscribes to. */
@@ -53,9 +61,6 @@ export class AgentPresetSeatController {
    */
   private fallback = ''
 
-  /** Set while a pick is waiting for a session; cleared once applied. */
-  private staged: string | undefined
-
   /** Only the newest roster read may publish after overlapping refreshes. */
   private loadGeneration = 0
 
@@ -66,6 +71,7 @@ export class AgentPresetSeatController {
       SessionSummary,
       'id' | 'blank' | 'projectionValues'
     > | undefined,
+    private readonly staged: AgentPresetStage = { id: undefined, introduce: false },
   ) {}
 
   private set(patch: Partial<AgentPresetSeatState>): void {
@@ -85,7 +91,10 @@ export class AgentPresetSeatController {
       return
     }
     const { presets, modeSelectionEnabled } = roster.value
-    if (!modeSelectionEnabled) this.staged = undefined
+    if (!modeSelectionEnabled) {
+      this.staged.id = undefined
+      this.staged.introduce = false
+    }
     this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
     const session = this.currentSession()
     this.set({
@@ -97,10 +106,11 @@ export class AgentPresetSeatController {
       // an applied stage was consumed — the chip mounts (and loads) only
       // once the flow's session is current, so the reply can arrive after
       // apply() already composed it.
-      current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''),
+      current: this.staged.id ?? (session === undefined ? this.fallback : presetOf(session) ?? ''),
       error: null,
-      ...modeSelectionEnabled ? {} : { introduce: false },
+      introduce: modeSelectionEnabled && this.staged.introduce,
     })
+    await this.apply()
   }
 
   /**
@@ -134,7 +144,8 @@ export class AgentPresetSeatController {
    * chip should announce itself on the session it lands on.
    */
   stage(id: string, introduce = false): void {
-    this.staged = id
+    this.staged.id = id
+    this.staged.introduce = introduce
     this.set({ current: id, error: null, introduce })
   }
 
@@ -168,6 +179,7 @@ export class AgentPresetSeatController {
   /** Acknowledge the introduction cue once the chip has played it. */
   introduced(): void {
     if (!this.store.getSnapshot().introduce) return
+    this.staged.introduce = false
     this.set({ introduce: false })
   }
 
@@ -179,7 +191,7 @@ export class AgentPresetSeatController {
    * @returns once the switch settled, or immediately when there is nothing to do.
    */
   async apply(): Promise<void> {
-    const staged = this.staged
+    const staged = this.staged.id
     const session = this.currentSession()
     if (staged === undefined) {
       const current = session === undefined ? this.fallback : presetOf(session) ?? ''
@@ -190,12 +202,14 @@ export class AgentPresetSeatController {
     // A started session's history was produced under its own composition; the
     // host refuses the swap, so the stage is no longer meaningful.
     if (!session.blank || presetOf(session) === staged) {
-      this.staged = undefined
+      this.staged.id = undefined
+      this.staged.introduce = false
       return
     }
     this.set({ busy: true, error: null })
     const result = await this.ctx.remote.agentPresets.select(session.id, staged)
-    this.staged = undefined
+    this.staged.id = undefined
+    this.staged.introduce = false
     if (!result.ok) {
       const { error } = result
       this.set({

+ 3 - 0
packages/client/ui-agent-preset/tsconfig.json

@@ -52,6 +52,9 @@
     },
     {
       "path": "../../core/session"
+    },
+    {
+      "path": "../../util/values"
     }
   ]
 }

+ 2 - 2
packages/client/ui-chat/src/client/apply.ts

@@ -46,7 +46,7 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
 
 /** Services required by the Chat target and its presentation registrations. */
 export const inject = [
-  'slots', 'sessions', 'uiSession', 'uiConversation', 'locale',
+  'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale',
   'settingsScope', 'remote', 'remote.session', 'sidebarRight',
 ]
 
@@ -157,7 +157,7 @@ export function apply(ctx: Context): void {
           },
           forkAt: (seq) => {
             ctx.sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
-              .then((childId) => { ctx.sessions.open(childId) })
+              .then((childId) => { ctx.uiWorkspace.openSession(childId) })
               .catch(() => {
                 // Fork or child-title failure leaves the source view unchanged.
               })

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

@@ -60,6 +60,7 @@
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "clsx": "^2.0.0"

+ 21 - 12
packages/client/ui-commands/src/client/service.ts

@@ -17,8 +17,9 @@ import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import type { CommandResult } from '@deepseek-ai/dsh-commands/types'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
-import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
 import { rankByName } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {
@@ -59,7 +60,7 @@ function submittedCommandName(line: string): string {
 interface LiveState {
   readonly contributions: Map<string, CommandContribution>
   readonly decorations: Map<string, CommandDecoration>
-  readonly popups: Map<SessionId, PopupSelectController<ClientSessionContext>>
+  readonly popups: WeakMapWithValues<SessionBinding, PopupSelectController<ClientSessionContext>>
 }
 
 /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */
@@ -67,7 +68,11 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
   static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands']
 
   private readonly directory: CommandDirectory
-  private readonly live: LiveState = { contributions: new Map(), decorations: new Map(), popups: new Map() }
+  private readonly live: LiveState = {
+    contributions: new Map(),
+    decorations: new Map(),
+    popups: new WeakMapWithValues(),
+  }
   /** `command`-namespace translator (composer refusal notices). */
   private readonly t: TranslateNS<'command'>
 
@@ -147,7 +152,7 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * @param name - command name without the leading slash.
    */
   dismiss(name: string): void {
-    for (const popup of this.live.popups.values()) {
+    for (const popup of this.live.popups.values) {
       // A catalog that went stale underneath the card takes its rows away; the
       // composer keeps the keyboard the card was holding, like every other
       // dismissal path.
@@ -162,27 +167,31 @@ export class CommandUiRuntime extends Service implements CommandUiContract {
    * session's composer through the conversation input face.
    * @param actx - session-scope ctx.
    * @returns the resident controller.
+   * @throws when the Context no longer belongs to a retained Session generation.
    */
   popupFor(actx: ClientContext): PopupSelectController<ClientSessionContext> {
     const sessions = this.sessions()
-    const id = sessions.scopeOf(actx)
-    if (id === undefined) throw new Error('command.popupFor requires a session scope')
+    const session = sessions.sessionOf(actx)
+    const binding = session === undefined ? undefined : sessions.binding(session.sessionId)
+    if (binding === undefined || binding.session !== session) {
+      throw new Error('command.popupFor requires a retained Session scope')
+    }
     const { popups } = this.live
-    const existing = popups.get(id)
+    const existing = popups.get(binding)
     if (existing !== undefined) return existing
     const controller = new PopupSelectController<ClientSessionContext>({
-      consume: segment => actx.bail(actx, 'slash/input-consume-token', {
+      consume: segment => binding.ctx.bail(binding.ctx, 'slash/input-consume-token', {
         guard: segment.via === 'menu'
           ? { kind: 'span', span: segment.span }
           : { kind: 'bare-token', token: segment.token },
       }) === true,
       // The shell took the keyboard; the composer restores it, caret included.
-      focusComposer: () => { actx.get('conversation')?.input.for(actx).focus() },
+      focusComposer: () => { binding.ctx.get('conversation')?.input.for(binding.ctx).focus() },
     })
-    popups.set(id, controller)
-    actx.effect(() => () => {
+    popups.set(binding, controller)
+    binding.ctx.effect(() => () => {
       controller.dispose()
-      popups.delete(id)
+      popups.delete(binding)
     }, 'command: session popup')
     return controller
   }

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

@@ -46,6 +46,9 @@
     },
     {
       "path": "../../core/session"
+    },
+    {
+      "path": "../../util/values"
     }
   ]
 }

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

@@ -75,6 +75,7 @@
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tool-todo": "workspace:^",
     "@deepseek-ai/dsh-util-crypto": "workspace:^",

+ 12 - 17
packages/client/ui-conversation/src/client/apply.ts

@@ -1,7 +1,7 @@
 /** Registers the target-neutral Conversation assembly, shell, input, and docks. */
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
 import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
@@ -171,13 +171,15 @@ export function apply(ctx: Context, config: Config = Config({})): void {
   const restoreView = (sessionId: SessionId): void => {
     activateView(sessionId, readConversationViewPreference(sessionId))
   }
-  const restoreCurrentView = (): void => {
-    const sessionId = sessions.list.getSnapshot().current
-    if (sessionId !== undefined && sessions.binding(sessionId) !== undefined) {
-      restoreView(sessionId)
-    }
-  }
   const conversationViews = createSnapshotStore<readonly ViewTab[]>(viewTabs())
+  const bindings = new Set<SessionBinding>()
+  const trackedBindings = new WeakSet<SessionBinding>()
+  const trackBinding = (binding: SessionBinding): void => {
+    if (trackedBindings.has(binding)) return
+    trackedBindings.add(binding)
+    bindings.add(binding)
+    binding.ctx.effect(() => () => { bindings.delete(binding) }, 'ui-conversation: active Provider binding')
+  }
   const refreshViews = (): void => {
     const current = conversationViews.getSnapshot()
     const next = viewTabs()
@@ -187,20 +189,12 @@ export function apply(ctx: Context, config: Config = Config({})): void {
         return candidate !== undefined && tab.id === candidate.id && tab.label === candidate.label
       })
     if (!unchanged) conversationViews.set(next)
-    restoreCurrentView()
+    for (const binding of bindings) restoreView(binding.sessionId)
   }
   ctx.effect(() => {
-    let currentSessionId = sessions.list.getSnapshot().current
     const disposeViews = slots.subscribe('conversation.view', refreshViews)
     const disposeLocale = ctx.locale.subscribe(refreshViews)
-    const disposeCurrent = sessions.list.subscribe(() => {
-      const nextSessionId = sessions.list.getSnapshot().current
-      if (nextSessionId === currentSessionId) return
-      currentSessionId = nextSessionId
-      restoreCurrentView()
-    })
     return () => {
-      disposeCurrent()
       disposeLocale()
       disposeViews()
     }
@@ -226,6 +220,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
     hooks: ['conversation', 'input'],
     props: ['inputActions'],
     resolve: (binding) => {
+      trackBinding(binding)
       const shell = inputHub.shellFor(binding)
       const conversation = uiConversation.binding(binding)
       restoreView(binding.sessionId)
@@ -250,7 +245,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
       'conversation.input.dock': { kind: 'list', scope: 'session' },
       'conversation.hero.brand.mark': { kind: 'single', scope: 'root' },
       'conversation.hero.workspace': { kind: 'single', scope: 'root' },
-      'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
+      'conversation.hero.agentPreset': { kind: 'single', scope: 'session-maybe' },
     },
     inject: (sessionId: SessionId | undefined): ConversationInjected => ({
       hooks: {

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

@@ -172,7 +172,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     /** Brand mark shown before the blank-session headline. */
     'conversation.hero.brand.mark': { kind: 'single'; scope: 'root'; owner: HeroBrandMarkOwnerProps }
     /** Agent-preset control staged for a New Session. */
-    'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps }
+    'conversation.hero.agentPreset': { kind: 'single'; scope: 'session-maybe'; owner: HeroAgentPresetOwnerProps }
     /** Full-width entries above the composer card. */
     'conversation.input.dock': { kind: 'list'; scope: 'session'; owner: InputZone }
     /** Floating entries rendered inside the resident composer card. */

+ 12 - 9
packages/client/ui-conversation/src/client/conversation/assembly.ts

@@ -5,6 +5,7 @@ import type {
   ISessions, SessionBinding, SessionEventSource, SessionEventWindow,
 } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 import {
   createSnapshotStore, type ObservableSnapshot, type SnapshotStore,
 } from '@deepseek-ai/dsh-client-store'
@@ -177,7 +178,7 @@ export class UiConversation extends Service {
   readonly events: ConversationEventRegistry
   /** Registry of target View definitions. */
   readonly views: ConversationViewRegistry
-  private readonly bindings = new Map<SessionId, BindingRecord>()
+  private readonly bindings = new WeakMapWithValues<SessionBinding, BindingRecord>()
   private readonly images: HistoricalImageCache
 
   /**
@@ -190,7 +191,7 @@ export class UiConversation extends Service {
     this.views = new ConversationViewRegistry(ctx)
     this.images = new HistoricalImageCache(ctx, sessions)
     const rebuild = (): void => {
-      for (const record of this.bindings.values()) record.binding.rebuild()
+      for (const record of this.bindings.values) record.binding.rebuild()
     }
     let rebuildQueued = false
     const scheduleRebuild = (): void => {
@@ -207,7 +208,7 @@ export class UiConversation extends Service {
       return () => {
         disposeViews()
         disposeEvents()
-        for (const record of [...this.bindings.values()]) this.drop(record, true)
+        for (const record of [...this.bindings.values]) this.drop(record, true)
       }
     }, 'ui-conversation assembly')
   }
@@ -221,15 +222,17 @@ export class UiConversation extends Service {
     const sessionId = typeof source === 'string' ? source : source.sessionId
     const owner = typeof source === 'string' ? this.sessions.binding(source) : source
     if (owner === undefined) throw new Error(`uiConversation.binding: unknown session "${sessionId}"`)
-    const current = this.bindings.get(owner.sessionId)
-    if (current?.source === owner) return current.binding
-    if (current !== undefined) this.drop(current, true)
+    if (this.sessions.binding(sessionId) !== owner) {
+      throw new Error(`uiConversation.binding: inactive session "${sessionId}"`)
+    }
+    const current = this.bindings.get(owner)
+    if (current !== undefined) return current.binding
     const binding = new BoundConversation(
       owner.eventSource,
       new ConversationNodeAssembler(this.events, this.views),
     )
     const record: BindingRecord = { source: owner, binding, disposeScope: () => {} }
-    this.bindings.set(owner.sessionId, record)
+    this.bindings.set(owner, record)
     const disposeScope = owner.ctx.effect(
       () => () => { this.drop(record, false) },
       'ui-conversation binding',
@@ -303,8 +306,8 @@ export class UiConversation extends Service {
   }
 
   private drop(record: BindingRecord, releaseScope: boolean): void {
-    if (this.bindings.get(record.source.sessionId) !== record) return
-    this.bindings.delete(record.source.sessionId)
+    if (this.bindings.get(record.source) !== record) return
+    this.bindings.delete(record.source)
     record.binding.dispose()
     if (releaseScope) record.disposeScope()
   }

+ 41 - 44
packages/client/ui-conversation/src/client/conversation/historical-images.ts

@@ -1,22 +1,21 @@
 /** Session-scoped durable image URL cache shared by Conversation targets. */
 import type { Context } from '@deepseek-ai/cordis'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
-import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 
 interface ImageUrlEntry {
-  readonly sessionId: SessionId
-  readonly generation: number
+  readonly binding: SessionBinding
   current?: string
   pending: Promise<string>
 }
 
 /** Resolve durable Conversation images and release their browser URLs with Session scope. */
 export class HistoricalImageCache {
-  private readonly entries = new Map<string, ImageUrlEntry>()
-  private readonly generations = new Map<SessionId, number>()
-  private readonly scopeDisposers = new Map<SessionId, () => void>()
+  private readonly entries = new WeakMapWithValues<SessionBinding, Map<string, ImageUrlEntry>>()
+  private readonly scopeDisposers = new WeakMapWithValues<SessionBinding, () => void>()
   private readonly urls = new Set<string>()
   private disposed = false
 
@@ -36,20 +35,19 @@ export class HistoricalImageCache {
    */
   resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise<string> {
     if (this.disposed) return Promise.reject(new Error('ui-conversation image cache is disposed'))
-    const key = this.key(sessionId, attachment)
-    const cached = this.entries.get(key)
-    if (cached !== undefined) return cached.pending
     const binding = this.sessions.binding(sessionId)
     if (binding === undefined) {
       return Promise.reject(new Error(`ui-conversation: unknown session "${sessionId}"`))
     }
-    this.bindScope(sessionId, binding.ctx)
+    const entries = this.bindScope(binding)
+    const key = attachment.attachmentId
+    const cached = entries.get(key)
+    if (cached !== undefined) return cached.pending
     const entry: ImageUrlEntry = {
-      sessionId,
-      generation: this.generations.get(sessionId) ?? 0,
+      binding,
       pending: Promise.resolve(''),
     }
-    this.entries.set(key, entry)
+    entries.set(key, entry)
     entry.pending = this.loadCanonical(key, entry, attachment)
     return entry.pending
   }
@@ -61,7 +59,8 @@ export class HistoricalImageCache {
    * @returns current preview or canonical URL when cached.
    */
   peek(sessionId: SessionId, attachment: ImageAttachmentRef): string | undefined {
-    return this.entries.get(this.key(sessionId, attachment))?.current
+    const binding = this.sessions.binding(sessionId)
+    return binding === undefined ? undefined : this.entries.get(binding)?.get(attachment.attachmentId)?.current
   }
 
   /**
@@ -75,22 +74,21 @@ export class HistoricalImageCache {
    */
   seed(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean {
     if (this.disposed) return false
-    const key = this.key(sessionId, attachment)
-    if (this.entries.has(key)) return false
     const binding = this.sessions.binding(sessionId)
     if (binding === undefined) return false
-    this.bindScope(sessionId, binding.ctx)
+    const entries = this.bindScope(binding)
+    const key = attachment.attachmentId
+    if (entries.has(key)) return false
     const entry: ImageUrlEntry = {
-      sessionId,
-      generation: this.generations.get(sessionId) ?? 0,
+      binding,
       current: url,
       pending: Promise.resolve(url),
     }
     this.urls.add(url)
-    this.entries.set(key, entry)
+    entries.set(key, entry)
     entry.pending = this.loadCanonical(key, entry, attachment).catch((error: unknown) => {
-      if (this.entries.get(key) === entry && entry.current === url) {
-        this.entries.delete(key)
+      if (entries.get(key) === entry && entry.current === url) {
+        entries.delete(key)
         this.releaseUrl(url)
       }
       throw error
@@ -102,18 +100,12 @@ export class HistoricalImageCache {
     return true
   }
 
-  private key(sessionId: SessionId, attachment: ImageAttachmentRef): string {
-    return `${sessionId}:${attachment.attachmentId}`
-  }
-
   private loadCanonical(
     key: string,
     entry: ImageUrlEntry,
     attachment: ImageAttachmentRef,
   ): Promise<string> {
-    const binding = this.sessions.binding(entry.sessionId)
-    if (binding === undefined) return Promise.reject(new Error(`ui-conversation: unknown session "${entry.sessionId}"`))
-    return binding.session.readAttachment(attachment.attachmentId)
+    return entry.binding.session.readAttachment(attachment.attachmentId)
       .then((result) => {
         if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`)
         this.assertLive(key, entry)
@@ -132,35 +124,39 @@ export class HistoricalImageCache {
         return url
       })
       .catch((error: unknown) => {
-        if (this.entries.get(key) === entry && entry.current === undefined) this.entries.delete(key)
+        const entries = this.entries.get(entry.binding)
+        if (entries?.get(key) === entry && entry.current === undefined) entries.delete(key)
         throw error
       })
   }
 
   private assertLive(key: string, entry: ImageUrlEntry): void {
     if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed')
-    if (this.entries.get(key) !== entry
-      || (this.generations.get(entry.sessionId) ?? 0) !== entry.generation) {
+    if (this.entries.get(entry.binding)?.get(key) !== entry) {
       throw new Error('ui-conversation image scope was released before loading completed')
     }
   }
 
-  private bindScope(sessionId: SessionId, scope: Context): void {
-    if (this.scopeDisposers.has(sessionId)) return
-    const dispose = scope.effect(() => () => {
-      this.scopeDisposers.delete(sessionId)
-      this.release(sessionId)
+  private bindScope(binding: SessionBinding): Map<string, ImageUrlEntry> {
+    const existing = this.entries.get(binding)
+    if (existing !== undefined) return existing
+    const entries = new Map<string, ImageUrlEntry>()
+    this.entries.set(binding, entries)
+    const dispose = binding.ctx.effect(() => () => {
+      this.scopeDisposers.delete(binding)
+      this.release(binding, entries)
     }, 'ui-conversation historical image scope')
-    this.scopeDisposers.set(sessionId, () => { void dispose() })
+    const release = (): void => { void dispose() }
+    this.scopeDisposers.set(binding, release)
+    return entries
   }
 
-  private release(sessionId: SessionId): void {
-    this.generations.set(sessionId, (this.generations.get(sessionId) ?? 0) + 1)
-    for (const [key, entry] of this.entries) {
-      if (entry.sessionId !== sessionId) continue
-      this.entries.delete(key)
+  private release(binding: SessionBinding, entries: Map<string, ImageUrlEntry>): void {
+    if (this.entries.get(binding) === entries) this.entries.delete(binding)
+    for (const entry of entries.values()) {
       if (entry.current !== undefined) this.releaseUrl(entry.current)
     }
+    entries.clear()
   }
 
   private releaseUrl(url: string): void {
@@ -171,10 +167,11 @@ export class HistoricalImageCache {
   private dispose(): void {
     if (this.disposed) return
     this.disposed = true
-    for (const dispose of [...this.scopeDisposers.values()]) dispose()
+    for (const dispose of [...this.scopeDisposers.values]) dispose()
     this.scopeDisposers.clear()
     for (const url of this.urls) revokeUrl(url)
     this.urls.clear()
+    for (const entries of this.entries.values) entries.clear()
     this.entries.clear()
   }
 }

+ 19 - 14
packages/client/ui-conversation/src/client/input/hub.ts

@@ -50,7 +50,7 @@ interface ConversationAttachmentFace {
 
 /** Session-addressed input facade registry (SessionInputResolver face + composer-layer extras). */
 export class InputHub implements SessionInputResolver {
-  private readonly shells = new Map<SessionId, SessionInputShell>()
+  private readonly shells = new WeakMap<SessionBinding, SessionInputShell>()
 
   /**
    * @param ctx - client root context (services resolved lazily per call — boot order stays free).
@@ -68,9 +68,12 @@ export class InputHub implements SessionInputResolver {
    */
   for(actx: Context): SessionInput {
     const sessions = this.sessions()
-    const id = sessions.scopeOf(actx)
-    if (id === undefined) throw new Error('conversation.input.for requires a session scope')
-    return this.shell(id)
+    const session = sessions.sessionOf(actx)
+    const binding = session === undefined ? undefined : sessions.binding(session.sessionId)
+    if (binding === undefined || binding.session !== session) {
+      throw new Error('conversation.input.for requires a retained Session scope')
+    }
+    return this.shellFor(binding)
   }
 
   /**
@@ -82,9 +85,9 @@ export class InputHub implements SessionInputResolver {
    * @returns the shell.
    */
   shellFor(binding: SessionBinding): SessionInputShell {
-    const existing = this.shells.get(binding.sessionId)
+    const existing = this.shells.get(binding)
     if (existing !== undefined) return existing
-    const { sessionId: id, session, ctx: actx } = binding
+    const { session, ctx: actx } = binding
     const shell = new SessionInputShell({
       actx,
       inputTriggers: () => this.controller(actx),
@@ -110,7 +113,7 @@ export class InputHub implements SessionInputResolver {
         }),
       },
     })
-    this.shells.set(id, shell)
+    this.shells.set(binding, shell)
     // The one teardown axis: listeners, shell, and map entries all ride the
     // scope fiber (nothing here outlives the scope).
     actx.effect(() => {
@@ -127,7 +130,7 @@ export class InputHub implements SessionInputResolver {
       return () => {
         for (const off of offs) off()
         const drafts = shell.dispose()
-        this.shells.delete(id)
+        this.shells.delete(binding)
         const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined
         for (const attachmentId of drafts) conversation?.releaseDraftAttachment(attachmentId)
       }
@@ -142,8 +145,6 @@ export class InputHub implements SessionInputResolver {
    * @returns the shell.
    */
   shell(id: SessionId): SessionInputShell {
-    const existing = this.shells.get(id)
-    if (existing !== undefined) return existing
     const binding = this.sessions().binding(id)
     if (binding === undefined) throw new Error(`conversation.input: session "${id}" resolved no binding`)
     return this.shellFor(binding)
@@ -166,7 +167,8 @@ export class InputHub implements SessionInputResolver {
    * @returns whether its mounted composer currently accepts files.
    */
   canPickFiles(id: SessionId): boolean {
-    return this.shells.get(id)?.canPickFiles() === true
+    const binding = this.sessions().binding(id)
+    return binding !== undefined && this.shells.get(binding)?.canPickFiles() === true
   }
 
   /**
@@ -174,7 +176,8 @@ export class InputHub implements SessionInputResolver {
    * @param id - target Session.
    */
   pickFiles(id: SessionId): void {
-    this.shells.get(id)?.pickFiles()
+    const binding = this.sessions().binding(id)
+    if (binding !== undefined) this.shells.get(binding)?.pickFiles()
   }
 
   /**
@@ -184,8 +187,8 @@ export class InputHub implements SessionInputResolver {
    * @returns the resident controller, or undefined when no trigger provider is installed.
    */
   inputTriggers(id: SessionId): InputTriggerController | undefined {
-    const actx = this.sessions().scope(id)
-    return actx === undefined ? undefined : this.controller(actx)
+    const binding = this.sessions().binding(id)
+    return binding === undefined ? undefined : this.controller(binding.ctx)
   }
 
   /**
@@ -231,11 +234,13 @@ export class InputHub implements SessionInputResolver {
   }
 
   private controller(actx: Context): InputTriggerController | undefined {
+    if (this.sessions().sessionOf(actx) === undefined) return undefined
     const inputTriggers = this.rootCtx.get('inputTriggers') as InputTriggerServiceFace | undefined
     return inputTriggers?.sessionOf(actx)
   }
 
   private popup(actx: Context): PopupDismissFace | undefined {
+    if (this.sessions().sessionOf(actx) === undefined) return undefined
     const command = this.rootCtx.get('commandUi') as CommandFace | undefined
     return command?.popupFor(actx)
   }

+ 3 - 3
packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx

@@ -133,12 +133,12 @@ function WidthHandle(props: {
  * @returns the unchanged Conversation body subtree.
  */
 export function ConversationContent({
-  sessionId, session, phase, hero, useSessions, useSessionPendingInteraction,
+  sessionId, session, phase, hero, useSessions, useSessionStatus,
   useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain,
   selectWorkspace, t, onHandleStart, onHandleDrag, onHandleCommit, onHandleEnd,
 }: ConversationContentProps) {
-  const pendingInteraction = useSessionPendingInteraction(snapshot =>
-    sessionId === undefined ? undefined : snapshot.get(sessionId))
+  const pendingInteraction = useSessionStatus(snapshot =>
+    sessionId === undefined ? undefined : snapshot.get(sessionId)?.pendingInteraction)
   const inputState = useInput(s => s)
   const cwd = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.cwd)
   const workspaces = useWorkspaces(s => s)

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

@@ -89,6 +89,9 @@
     {
       "path": "../../util/crypto"
     },
+    {
+      "path": "../../util/values"
+    },
     {
       "path": "../../util/workspace-path"
     }

+ 1 - 0
packages/client/ui-input-trigger/package.json

@@ -58,6 +58,7 @@
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "clsx": "^2.0.0"
   },

+ 21 - 14
packages/client/ui-input-trigger/src/client/service.ts

@@ -8,9 +8,9 @@
 import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
-import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { InputTriggerSource } from '../types.ts'
 import { InputTriggerController } from './controller.ts'
 import type { InputTriggerServiceContract } from './contract.ts'
@@ -24,14 +24,16 @@ interface LiveState {
   /** Registration order = menu group order = matchSpace/matchEnter poll order. */
   readonly sources: InputTriggerSource[]
   /** Per-session controllers; entries are deleted by their scope disposer. */
-  readonly controllers: Map<SessionId, InputTriggerController>
+  readonly controllers: WeakMapWithValues<SessionBinding, InputTriggerController>
 }
 
 /** The `ctx.inputTriggers` trigger pipeline service (root registry + controller resolution). */
 export class InputTriggerService extends Service implements InputTriggerServiceContract {
   static inject = ['sessions']
 
-  private readonly live: LiveState = { sources: [], controllers: new Map() }
+  private readonly live: LiveState = {
+    sources: [], controllers: new WeakMapWithValues(),
+  }
 
   /**
    * @param ctx - owning root context (the service registers itself as `slash`).
@@ -39,7 +41,7 @@ export class InputTriggerService extends Service implements InputTriggerServiceC
   constructor(ctx: Context) {
     super(ctx, 'inputTriggers')
     ctx.on('locale/change', () => {
-      for (const controller of this.live.controllers.values()) controller.refreshOpenMenu()
+      for (const controller of this.live.controllers.values) controller.refreshOpenMenu()
     })
   }
 
@@ -56,7 +58,7 @@ export class InputTriggerService extends Service implements InputTriggerServiceC
       throw new Error(`slash source "${src.trigger}${src.name}" is already registered`)
     }
     live.sources.push(src)
-    for (const controller of live.controllers.values()) {
+    for (const controller of live.controllers.values) {
       try {
         controller.sourceAdded(src)
       } catch (error) {
@@ -70,7 +72,7 @@ export class InputTriggerService extends Service implements InputTriggerServiceC
       const at = live.sources.indexOf(src)
       if (at < 0) return
       live.sources.splice(at, 1)
-      for (const controller of live.controllers.values()) controller.sourceRemoved(src)
+      for (const controller of live.controllers.values) controller.sourceRemoved(src)
     }
   }
 
@@ -81,26 +83,31 @@ export class InputTriggerService extends Service implements InputTriggerServiceC
    * single prewarm moment.
    * @param actx - session-scope ctx.
    * @returns the resident controller.
+   * @throws when the Context no longer belongs to a retained Session generation.
    */
   sessionOf(actx: ClientContext): InputTriggerController {
     const sessions = this.sessions()
-    const id = sessions.scopeOf(actx)
-    if (id === undefined) throw new Error('slash.sessionOf requires a session scope')
+    const session = sessions.sessionOf(actx)
+    const binding = session === undefined ? undefined : sessions.binding(session.sessionId)
+    if (binding === undefined || binding.session !== session) {
+      throw new Error('slash.sessionOf requires a retained Session scope')
+    }
+    const id = binding.sessionId
     const { live } = this
-    const existing = live.controllers.get(id)
+    const existing = live.controllers.get(binding)
     if (existing !== undefined) return existing
     const controller = new InputTriggerController({
-      actx,
+      actx: binding.ctx,
       sessionId: id,
       roster: {
         sources: trigger => live.sources.filter(s => s.trigger === trigger).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
         all: () => live.sources,
       },
     })
-    live.controllers.set(id, controller)
-    actx.effect(() => () => {
+    live.controllers.set(binding, controller)
+    binding.ctx.effect(() => () => {
       controller.dispose()
-      live.controllers.delete(id)
+      live.controllers.delete(binding)
     }, 'slash: session controller')
     return controller
   }

+ 3 - 0
packages/client/ui-input-trigger/tsconfig.json

@@ -26,6 +26,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../util/values"
+    },
     {
       "path": "../ui-primitives"
     },

+ 2 - 1
packages/client/ui-layout/src/client/DocumentTitle.tsx

@@ -17,7 +17,8 @@ export type DocumentTitleProps = Pick<PropsRuntime<'root'>, 'useSessions' | 'use
 export function DocumentTitle({ useSessions, usePanelInfo, productTitle }: DocumentTitleProps): null {
   const showSessionTitle = usePanelInfo(info => info.activePanelId === null)
   const title = useSessions((state) => {
-    const current = state.current
+    const current = Object.values(state.byId)
+      .find(session => (session.retainedBy.mainView ?? 0) > 0)?.id
     return !showSessionTitle || current === undefined ? undefined : state.byId[current]?.title
   })
   useEffect(() => {

+ 1 - 0
packages/client/ui-model-selection/package.json

@@ -62,6 +62,7 @@
     "@types/react-dom": "~18.3.0",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "clsx": "^2.1.1"

+ 9 - 8
packages/client/ui-model-selection/src/client/service.ts

@@ -14,8 +14,9 @@
  */
 import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
-import type {} from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 import { ModelCatalogDirectory } from './catalog.ts'
 import { ModelDirectory } from './directory.ts'
 
@@ -28,14 +29,14 @@ declare module '@deepseek-ai/cordis' {
 /** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */
 interface LiveState {
   /** Per-session directories; entries are deleted by their scope disposer. */
-  readonly directories: Map<SessionId, ModelDirectory>
+  readonly directories: WeakMapWithValues<SessionBinding, ModelDirectory>
 }
 
 /** The `ctx.modelDirectories` session model-selection service. */
 export class ModelDirectoryResolver extends Service {
   static inject = ['sessions', 'remote', 'remote.session']
 
-  private readonly live: LiveState = { directories: new Map() }
+  private readonly live: LiveState = { directories: new WeakMapWithValues() }
   private readonly catalog: ModelCatalogDirectory
 
   /** Localized composer-block copy; this plugin owns the string it raises. */
@@ -52,7 +53,7 @@ export class ModelDirectoryResolver extends Service {
     void this.catalog.load().catch(() => { /* selectors expose the shared error */ })
     ctx.on('connection/reset', () => {
       this.catalog.resetGeneration()
-      for (const directory of this.live.directories.values()) directory.resetConnected()
+      for (const directory of this.live.directories.values) directory.resetConnected()
     })
     ctx.remote.$on('llm/adapters-updated', () => { this.catalog.refresh() })
     ctx.remote.$on('settings/document-updated', () => { this.catalog.refresh() })
@@ -67,13 +68,13 @@ export class ModelDirectoryResolver extends Service {
    */
   directoryFor(sessionId: SessionId): ModelDirectory {
     const { live } = this
-    const existing = live.directories.get(sessionId)
-    if (existing !== undefined) return existing
     const sessions = this.ctx.sessions
     const actx = sessions.scope(sessionId)
     if (actx === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no scope`)
     const binding = sessions.binding(sessionId)
     if (binding === undefined) throw new Error(`ui-model-selection: session "${String(sessionId)}" resolved no binding`)
+    const existing = live.directories.get(binding)
+    if (existing !== undefined) return existing
     const directory = new ModelDirectory(
       this.ctx.remote.session,
       sessionId,
@@ -81,7 +82,7 @@ export class ModelDirectoryResolver extends Service {
       this.catalog,
       binding.session.projections.faceOf('modelSelection'),
     )
-    live.directories.set(sessionId, directory)
+    live.directories.set(binding, directory)
     // The composer cannot read this plugin (the dependency runs one way), so
     // the block is pushed: the Host says whether an adapter serves the
     // session's route, and only a definite `false` makes the input inert.
@@ -105,7 +106,7 @@ export class ModelDirectoryResolver extends Service {
     }
     actx.effect(() => () => {
       directory.dispose()
-      live.directories.delete(sessionId)
+      live.directories.delete(binding)
     }, 'ui-model-selection: session directory')
     return directory
   }

+ 3 - 0
packages/client/ui-model-selection/tsconfig.json

@@ -49,6 +49,9 @@
     },
     {
       "path": "../../api/session-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../../util/values"
     }
   ]
 }

+ 43 - 11
packages/client/ui-renderer/src/client/bindings.tsx

@@ -49,6 +49,20 @@ export function useScopeBinding(): StandardSourceBinding {
   return binding
 }
 
+/**
+ * Publish one resolved scope binding to a renderer subtree.
+ * @param props - provider inputs.
+ * @param props.binding - binding exposed to scoped entries.
+ * @param props.children - subtree that inherits the binding.
+ * @returns the scoped React provider.
+ */
+export function ScopeBindingProvider({ binding, children }: {
+  binding: StandardSourceBinding
+  children: ReactNode
+}): ReactNode {
+  return <ScopeBindingContext.Provider value={binding}>{children}</ScopeBindingContext.Provider>
+}
+
 /**
  * Bind one observable source to an identity-stable selector Hook.
  * @param source - observable source.
@@ -91,8 +105,8 @@ function useAbsentSnapshot<S>(
 
 /** Erased open-key selector Hook synthesized from one keyed source family. */
 export type KeyedSnapshotHook = (
-  key: string,
-  selector?: (value: unknown) => unknown,
+  keyOrSelector: string | ((value: unknown) => unknown),
+  selectorOrEqual?: ((value: unknown) => unknown) | ((left: unknown, right: unknown) => boolean),
   equal?: (left: unknown, right: unknown) => boolean,
 ) => unknown
 
@@ -101,23 +115,41 @@ export type KeyedSnapshotHook = (
  * @param source - keyed resolver, or absence for an optional scope.
  * @returns cached keyed selector Hook.
  */
-export function keyedObservableHook(source: KeyedStandardSource | undefined): KeyedSnapshotHook {
+export function keyedObservableHook(
+  source: KeyedStandardSource | undefined,
+  defaultKey?: string,
+): KeyedSnapshotHook {
   if (source === undefined) return absentKeyedHook
-  let hook = keyedHookCache.get(source)
+  let hooks = keyedHookCache.get(source)
+  if (hooks === undefined) {
+    hooks = new Map()
+    keyedHookCache.set(source, hooks)
+  }
+  const cacheKey = defaultKey ?? NO_DEFAULT_KEY
+  let hook = hooks.get(cacheKey)
   if (hook === undefined) {
-    hook = (key, selector, equal) => {
-      const useValue = observableHook(source(key) ?? absentSource)
-      return useValue(selector ?? identity, equal)
+    hook = (keyOrSelector, selectorOrEqual, equal) => {
+      const keyed = typeof keyOrSelector === 'string'
+      const key = keyed ? keyOrSelector : defaultKey
+      const selector = (keyed ? selectorOrEqual : keyOrSelector) as ((value: unknown) => unknown) | undefined
+      const comparison = (keyed ? equal : selectorOrEqual) as ((left: unknown, right: unknown) => boolean) | undefined
+      const useValue = observableHook(key === undefined ? absentSource : source(key) ?? absentSource)
+      return useValue(selector ?? identity, comparison)
     }
-    keyedHookCache.set(source, hook)
+    hooks.set(cacheKey, hook)
   }
   return hook
 }
 
-const keyedHookCache = new WeakMap<KeyedStandardSource, KeyedSnapshotHook>()
+const NO_DEFAULT_KEY = Symbol('no default key')
+const keyedHookCache = new WeakMap<KeyedStandardSource, Map<string | symbol, KeyedSnapshotHook>>()
 const identity = (value: unknown): unknown => value
-const absentKeyedHook: KeyedSnapshotHook = (_key, selector, equal) =>
-  observableHook(absentSource)(selector ?? identity, equal)
+const absentKeyedHook: KeyedSnapshotHook = (keyOrSelector, selectorOrEqual, equal) => {
+  const keyed = typeof keyOrSelector === 'string'
+  const selector = (keyed ? selectorOrEqual : keyOrSelector) as ((value: unknown) => unknown) | undefined
+  const comparison = (keyed ? equal : selectorOrEqual) as ((left: unknown, right: unknown) => boolean) | undefined
+  return observableHook(absentSource)(selector ?? identity, comparison)
+}
 
 /** Subscribe the tree to the atomically assembled root standard-source roster. */
 export function RootStandardProvider({ children }: { children: ReactNode }) {

+ 10 - 13
packages/client/ui-renderer/src/client/registry.ts

@@ -6,8 +6,8 @@
  * declaration injection through the caller's ctx.effect (fiber unload
  * collects both), the renderer installation contract (install()/renderSlot('root') +
  * the SlotRendererHost face), and the store INSTANCE axis — handle x scope
- * key -> create/cache, dropped with the last holding entry, session instances
- * cleared (with persisted state) on scope death.
+ * key -> create/cache, dropped with the last holding entry, and in-memory
+ * session instances released without clearing persisted state on scope death.
  */
 /* oxlint-disable typescript/no-redundant-type-constituents --
  * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap only
@@ -315,11 +315,10 @@ export class SlotRegistry extends Service {
   }
 
   /**
-   * Bind all scoped Store handles to one owner Context lifetime. The cleanup
-   * materializes an otherwise-unused handle before clearing it, because a
-   * previous application run may have persisted state for a Slot that this
-   * scope never rendered. Rebinding the same key transfers cleanup ownership
-   * to the newest Context generation.
+   * Bind scoped Store instances to one owner Context lifetime. Cleanup drops
+   * only materialized in-memory instances; persisted state belongs to the
+   * durable scope key. Rebinding the same key transfers cleanup ownership to
+   * the newest Context generation.
    *
    * @param binding - materialized scope identity and its owning Context.
    */
@@ -330,7 +329,7 @@ export class SlotRegistry extends Service {
     binding.ctx.effect(() => () => {
       if (this._storeScopeOwners.get(binding.key) !== binding.ctx) return
       this._storeScopeOwners.delete(binding.key)
-      this.clearStoreScope(binding.key)
+      this.releaseStoreScope(binding.key)
     }, `slots: store scope ${binding.key}`)
   }
 
@@ -548,12 +547,10 @@ export class SlotRegistry extends Service {
     return instance
   }
 
-  /** Clear every live non-root Store handle for one dead scope key. */
-  private clearStoreScope(key: string): void {
-    for (const [handle, record] of this._stores) {
+  /** Drop every materialized non-root Store instance for one ended Context generation. */
+  private releaseStoreScope(key: string): void {
+    for (const record of this._stores.values()) {
       if (record.scope === 'root') continue
-      const instance = record.instances.get(key) ?? handle.create(key)
-      instance.clearPersisted()
       record.instances.delete(key)
     }
   }

+ 47 - 14
packages/client/ui-renderer/src/client/scoped-slots.tsx

@@ -11,7 +11,7 @@ import {
   type StoredEntry, type Translate,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import {
-  HostContext, RootStandardProvider, ScopeProvider, SlotAssemblyError,
+  HostContext, RootStandardProvider, ScopeBindingProvider, ScopeProvider, SlotAssemblyError,
   keyedObservableHook, maybeObservableHook, observableHook, useHost, useRootBinding,
   useScopeBinding,
 } from './bindings.tsx'
@@ -312,6 +312,18 @@ function entryKeyOf(entry: StoredEntry): number {
   return key
 }
 
+let nextSessionGenerationKey = 0
+const sessionGenerationKeys = new WeakMap<object, number>()
+
+function sessionGenerationKeyOf(binding: ScopedStandardSourceBinding): number {
+  let key = sessionGenerationKeys.get(binding.ctx)
+  if (key === undefined) {
+    key = nextSessionGenerationKey++
+    sessionGenerationKeys.set(binding.ctx, key)
+  }
+  return key
+}
+
 /**
  * Per-entry isolation: one registrant crashing (component render or inject
  * factory) must not take down siblings. Assembly errors (missing providers)
@@ -345,7 +357,11 @@ const sessionStandardCache = new WeakMap<StandardSourceBinding, WeakMap<Standard
 const sessionMaybeStandardCache = new WeakMap<StandardSourceBinding, WeakMap<StandardSourceBinding, InjectedProps>>()
 
 /** Materialize one binding into stable framework Hook and plain-prop seats. */
-function materializeStandardBinding(binding: StandardSourceBinding, optional: boolean): InjectedProps {
+function materializeStandardBinding(
+  binding: StandardSourceBinding,
+  optional: boolean,
+  defaultKey?: string,
+): InjectedProps {
   const standard: InjectedProps = { ...binding.props }
   for (const [name, source] of Object.entries(binding.hooks)) {
     if (source === undefined && !optional) {
@@ -359,7 +375,7 @@ function materializeStandardBinding(binding: StandardSourceBinding, optional: bo
     if (source === undefined && !optional) {
       throw new SlotAssemblyError(`strict keyed standard hook '${name}' has no source resolver`)
     }
-    standard[standardHookPropName(name)] = keyedObservableHook(source)
+    standard[standardHookPropName(name)] = keyedObservableHook(source, defaultKey)
   }
   return standard
 }
@@ -386,7 +402,7 @@ function standardProps(
   let standard = perScope.get(scopeBinding)
   if (standard !== undefined) return standard
   standard = {
-    ...root,
+    ...materializeStandardBinding(rootBinding, false, scopeBinding.key),
     ...materializeStandardBinding(scopeBinding, scope === 'session-maybe'),
   }
   perScope.set(scopeBinding, standard)
@@ -404,7 +420,20 @@ function scopeAreaProvider(adapter: SlotScopeAdapter): SessionProviderComponent
   }
   const renderArea = adapter.renderArea.bind(adapter)
   Provider = function ScopeAreaProvider(props: SessionAreaProps): ReactNode {
-    return renderArea(useScopeBinding(), props)
+    const inherited = useScopeBinding()
+    const explicit = Object.hasOwn(props, 'session')
+    const source = adapter.bindingSource(props.session)
+    const resolved = useSyncExternalStore(
+      listener => source.subscribe(listener),
+      () => source.getSnapshot(),
+      () => source.getSnapshot(),
+    )
+    const binding = explicit ? resolved : inherited
+    return (
+      <ScopeBindingProvider binding={binding}>
+        {renderArea(binding, props)}
+      </ScopeBindingProvider>
+    )
   }
   scopeAreaCache.set(adapter, Provider)
   return Provider
@@ -464,7 +493,7 @@ function standardKit(
     }
     // The session owner supplies area semantics; the renderer only binds its
     // adapter to the current generic scope source.
-    if (Object.values(entry.children).some(spec => spec.scope === 'session')) {
+    if (Object.values(entry.children).some(spec => spec.scope !== 'root')) {
       const adapter = host.scope('session')
       if (adapter === undefined) {
         throw new SlotAssemblyError("entry declares a session child without an installed 'session' scope adapter")
@@ -590,6 +619,9 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
   hasHookContext: boolean
 }) {
   const binding = useScopeBinding()
+  const identity = binding.key === undefined
+    ? undefined
+    : (binding as ScopedStandardSourceBinding).ctx
   // The child key is an incarnation counter, NOT the session id: adoption
   // must keep the key constant across undefined → first id. Bookkeeping
   // lives in this stable (unkeyed) wrapper via the render-phase setState
@@ -598,16 +630,16 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
   // guard conditions make it convergent — StrictMode-safe).
   const [state, setState] = useState<MaybeIncarnation>(FIRST_INCARNATION)
   let { adopted, epoch } = state
-  if (binding.key !== undefined && adopted === undefined) {
+  if (identity !== undefined && adopted === undefined) {
     // Adoption: same epoch — no remount.
-    adopted = binding.key
+    adopted = identity
     setState({ adopted, epoch })
-  } else if (adopted !== undefined && binding.key !== undefined && binding.key !== adopted) {
+  } else if (adopted !== undefined && identity !== undefined && identity !== adopted) {
     // Post-adoption session switch: next incarnation, born already adopted.
-    adopted = binding.key
+    adopted = identity
     epoch += 1
     setState({ adopted, epoch })
-  } else if (adopted !== undefined && binding.key === undefined) {
+  } else if (adopted !== undefined && identity === undefined) {
     // Back to no-session: next incarnation, born blank (adopts anew later).
     adopted = undefined
     epoch += 1
@@ -630,7 +662,7 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
 /** Adoption bookkeeping of one session-maybe outlet (see SessionMaybeEntry). */
 interface MaybeIncarnation {
   /** Session this incarnation adopted; undefined while born blank and unadopted. */
-  readonly adopted: string | undefined
+  readonly adopted: object | undefined
   /** Incarnation counter — the child key; bumps exactly when an incarnation dies. */
   readonly epoch: number
 }
@@ -666,14 +698,15 @@ function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookCont
   if (binding.key === undefined) {
     throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`)
   }
+  const scopedBinding = binding as ScopedStandardSourceBinding
   // Per-session remount rides this key; per-entry remount rides the outer
   // element's entry-identity key (the outlet's guarded() call).
   return (
-    <SlotErrorBoundary slotKey={slotKey} key={binding.key} onEntryError={onEntryError}>
+    <SlotErrorBoundary slotKey={slotKey} key={sessionGenerationKeyOf(scopedBinding)} onEntryError={onEntryError}>
       <SessionEntry
         entry={entry}
         ownerProps={ownerProps}
-        binding={binding as StandardSourceBinding & { readonly key: string }}
+        binding={scopedBinding}
         slotKey={slotKey}
         slotInjected={slotInjected}
         hookContext={hookContext}

+ 3 - 0
packages/client/ui-session/package.json

@@ -28,6 +28,7 @@
   "dsh": {
     "client": {
       "inject": [
+        "@deepseek-ai/dsh-api-remotes",
         "@deepseek-ai/dsh-api-session-controller",
         "@deepseek-ai/dsh-client-ui-renderer"
       ],
@@ -44,11 +45,13 @@
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-remotes": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@types/react": "~18.3.1",
     "react": "^18.2.0"
   },

+ 249 - 72
packages/client/ui-session/src/client/index.ts

@@ -4,11 +4,15 @@ import type {
   ISessions,
   SessionBinding,
   SessionListState,
+  SessionReference,
+  SessionRetainInfo,
   SessionSnapshot,
   UseProjection,
 } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type {} from '@deepseek-ai/dsh-api-remotes/client'
 import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
+import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values'
 import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots'
 import type {
   HostObservable,
@@ -41,19 +45,61 @@ export interface SessionPendingInteractionBase {
   readonly sessionId: SessionId
 }
 
-/** Declaration-merged roster of domain-owned pending interaction values. */
+/** Declaration-merged map of domain keys to their pending-interaction values. */
 export interface SessionPendingInteractionMap {}
 
-/** Every pending interaction contributed by the assembled Client. */
+/** Union of every pending-interaction value contributed by the assembled Client. */
 export type SessionPendingInteraction =
   [keyof SessionPendingInteractionMap] extends [never]
     ? SessionPendingInteractionBase
     : SessionPendingInteractionMap[keyof SessionPendingInteractionMap]
 
-/** Current effective pending interaction by Session. */
-export type SessionPendingInteractionSnapshot = ReadonlyMap<SessionId, SessionPendingInteraction>
-/** Selector hook over Session-scoped pending interactions. */
-export type UseSessionPendingInteraction = SnapshotSelectorHook<SessionPendingInteractionSnapshot>
+/** Independent UI status facts for one Session identity. */
+export interface SessionStatus {
+  /** Latest known running state; absent until a baseline or event establishes it. */
+  readonly running: boolean | undefined
+  /** Highest-precedence domain request currently awaiting user interaction. */
+  readonly pendingInteraction: SessionPendingInteraction | undefined
+  /** Whether an observed stop outside the main view still needs acknowledgement. */
+  readonly completionUnread: boolean
+}
+
+/** Current UI status indexed by Session identity. */
+export type SessionStatusSnapshot = ReadonlyMap<SessionId, SessionStatus>
+/** Selector hook over the unified Session UI status snapshot. */
+export type UseSessionStatus = SnapshotSelectorHook<SessionStatusSnapshot>
+
+/** Selector hook for explicit or surrounding-Provider Session reference counts. */
+export interface UseSessionRetainInfo {
+  /**
+   * Read the complete retain information for an explicit Session identity.
+   * @param sessionId - Session identity to inspect without retaining it.
+   * @returns current local reference counts, or absence while the source is unavailable.
+   */
+  (sessionId: SessionId): SessionRetainInfo | undefined
+  /**
+   * Select from the retain information for an explicit Session identity.
+   * @param sessionId - Session identity to inspect without retaining it.
+   * @param selector - projection over the current value.
+   * @param equal - optional selected-value equality.
+   * @returns selected value.
+   */
+  <Selected>(
+    sessionId: SessionId,
+    selector: (value: SessionRetainInfo | undefined) => Selected,
+    equal?: (left: Selected, right: Selected) => boolean,
+  ): Selected
+  /**
+   * Select from the surrounding Provider's Session retain information.
+   * @param selector - projection receiving absence outside a Session binding.
+   * @param equal - optional selected-value equality.
+   * @returns selected value.
+   */
+  <Selected>(
+    selector: (value: SessionRetainInfo | undefined) => Selected,
+    equal?: (left: Selected, right: Selected) => boolean,
+  ): Selected
+}
 
 /** Publish one pending interaction and define how plugin teardown delegates it. */
 export type PendingInteractionPublisher<T extends SessionPendingInteractionBase> = (
@@ -102,11 +148,15 @@ class PendingInteractionDomain<T extends SessionPendingInteractionBase> {
 }
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotScopeTargetMap {
+    session: SessionReference
+  }
+
   interface GlobalStandardProps {
     /** Session list and current selection. */
     useSessions: UseSessions
-    /** Pending user interaction presented by a Session-scoped UI consumer. */
-    useSessionPendingInteraction: UseSessionPendingInteraction
+    useSessionStatus: UseSessionStatus
+    useSessionRetainInfo: UseSessionRetainInfo
   }
 
   interface SessionStandardProps {
@@ -128,6 +178,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
   }
 }
 
+declare module '@deepseek-ai/dsh-api-session-controller/client' {
+  interface SessionReferenceSourceMap {
+    mainView: unknown
+  }
+}
+
 declare module '@deepseek-ai/cordis' {
   interface Context {
     /** Session Controller adapter and session-scoped source registry. */
@@ -190,10 +246,15 @@ type RuntimePendingDomain = PendingInteractionDomain<SessionPendingInteractionBa
 
 interface MaterializedBinding {
   readonly owner: SessionBinding
-  readonly value: ScopedStandardSourceBinding
+  readonly source: BindingSource
   readonly release: () => void
 }
 
+interface BindingSource extends HostObservable<StandardSourceBinding> {
+  value: StandardSourceBinding
+  readonly listeners: Set<() => void>
+}
+
 const BUILTIN_SOURCE = {
   hooks: ['session'],
   keyedHooks: ['projection'],
@@ -214,19 +275,24 @@ export class UiSession extends Service {
   private readonly descriptors: RuntimeSessionSourceDescriptor[] = [
     BUILTIN_SOURCE,
   ]
-  private bindings = new Map<SessionId, MaterializedBinding>()
-  private absent: StandardSourceBinding
-  private currentBinding: StandardSourceBinding
-  private readonly currentListeners = new Set<() => void>()
+  private readonly bindings = new WeakMapWithValues<SessionBinding, MaterializedBinding>()
+  private readonly absent: BindingSource
+  private readonly current: BindingSource
   private readonly pendingDomains: RuntimePendingDomain[] = []
   private pendingSnapshot: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map()
-  private readonly pendingListeners = new Set<() => void>()
-  /** Root source of pending UI interactions, independent from Controller snapshots. */
-  readonly pendingInteractions: HostObservable<SessionPendingInteractionSnapshot> = {
-    getSnapshot: () => this.pendingSnapshot,
+  private readonly running = new Map<SessionId, boolean>()
+  private readonly completionUnread = new Set<SessionId>()
+  private statusSnapshot: SessionStatusSnapshot = new Map()
+  private readonly statusListeners = new Set<() => void>()
+  private mainRetainId: SessionId | undefined
+  private disposeMainRetain = (): void => {}
+  private active = true
+  /** Root source combining running, pending-interaction, and completion-reminder facts. */
+  readonly sessionStatus: HostObservable<SessionStatusSnapshot> = {
+    getSnapshot: () => this.statusSnapshot,
     subscribe: (listener) => {
-      this.pendingListeners.add(listener)
-      return () => { this.pendingListeners.delete(listener) }
+      this.statusListeners.add(listener)
+      return () => { this.statusListeners.delete(listener) }
     },
   }
   /** Renderer-facing adapter for `session` and `session-maybe` scopes. */
@@ -241,31 +307,51 @@ export class UiSession extends Service {
     private readonly sessions: ISessions,
   ) {
     super(ctx, 'uiSession')
-    this.absent = this.materializeAbsent()
-    this.currentBinding = this.resolveCurrent()
+    this.absent = createBindingSource(this.materializeAbsent())
+    this.current = createBindingSource(this.absent.value)
     this.adapter = {
-      current: {
-        getSnapshot: () => this.currentBinding,
-        subscribe: (listener) => {
-          this.currentListeners.add(listener)
-          return () => { this.currentListeners.delete(listener) }
-        },
-      },
-      resolve: key => this.resolve(key as SessionId),
+      current: this.current,
+      bindingSource: target => this.bindingSource(target),
       renderArea: renderSessionArea,
     }
 
     ctx.effect(() => {
-      const disposeList = sessions.list.subscribe(() => { this.publishCurrent() })
+      const disposeList = sessions.list.subscribe(() => { this.publishMain() })
+      const disposeStatus = sessions.list.subscribe(() => { this.reconcileStatus() })
+      const disposeRemoteStatus = ctx.remote.$on('api-session/status', (sessionId, running) => {
+        this.observeRunning(sessionId, running)
+      })
+      this.publishMain()
+      this.reconcileStatus()
       return () => {
+        this.active = false
         disposeList()
-        const records = [...this.bindings.values()]
+        disposeStatus()
+        disposeRemoteStatus()
+        this.disposeMainRetain()
+        const records = [...this.bindings.values]
         this.bindings.clear()
         for (const record of records) record.release()
       }
     }, 'ui-session: Session binding projection')
   }
 
+  /**
+   * Resolve a stable renderer source for an owned Session reference or explicit absence.
+   * @param reference - active reference supplied by the Provider owner, or absence.
+   * @returns the binding source, which falls back to the absent projection when its generation ends.
+   * @throws when the reference does not belong to the active Controller generation.
+   */
+  bindingSource(reference: SessionReference | undefined): HostObservable<StandardSourceBinding> {
+    if (!this.active) return this.absent
+    if (reference === undefined) return this.absent
+    const owner = reference.binding
+    if (this.sessions.binding(reference.sessionId) !== owner) {
+      throw new Error('ui-session: Session reference is not active in this Controller')
+    }
+    return this.sourceFor(owner)
+  }
+
   /**
    * Register one Session-scoped standard-source contribution.
    * @param descriptor - static member roster and per-binding resolver.
@@ -324,43 +410,51 @@ export class UiSession extends Service {
 
   private rebuildBindings(): void {
     const absent = this.materializeAbsent()
-    const bindings = new Map<SessionId, MaterializedBinding>()
-    try {
-      for (const [sessionId, cached] of this.bindings) {
-        bindings.set(sessionId, this.createMaterializedBinding(cached.owner))
-      }
-    } catch (error) {
-      for (const record of bindings.values()) record.release()
-      throw error
+    const updates = [...this.bindings.values].map(record => ({
+      source: record.source,
+      value: this.materialize(record.owner),
+    }))
+    this.absent.value = absent
+    for (const { source, value } of updates) source.value = value
+    notifySubscribers(this.absent.listeners, '[ui-session] absent binding')
+    for (const { source } of updates) {
+      notifySubscribers(source.listeners, '[ui-session] Session binding')
     }
-    const previous = this.bindings
-    this.absent = absent
-    this.bindings = bindings
-    for (const record of previous.values()) record.release()
-    this.publishCurrent()
-  }
-
-  private resolve(key: SessionId): ScopedStandardSourceBinding | undefined {
-    const owner = this.sessions.binding(key)
-    if (owner === undefined) return undefined
-    const cached = this.bindings.get(key)
-    if (cached?.owner === owner) return cached.value
+    this.publishMain()
+  }
+
+  private sourceFor(owner: SessionBinding): BindingSource {
+    const cached = this.bindings.get(owner)
+    if (cached !== undefined) return cached.source
     const record = this.createMaterializedBinding(owner)
-    this.bindings.set(key, record)
-    cached?.release()
-    return record.value
+    this.bindings.set(owner, record)
+    return record.source
   }
 
-  private resolveCurrent(): StandardSourceBinding {
-    const current = this.sessions.list.getSnapshot().current
-    return current === undefined ? this.absent : this.resolve(current) ?? this.absent
+  private publishMain(): void {
+    if (!this.active) return
+    const byId = this.sessions.list.getSnapshot().byId
+    const currentId = this.current.value.key as SessionId | undefined
+    const currentIsMain = currentId !== undefined
+      && (this.sessions.retainInfo(currentId).getSnapshot().retainedBy.mainView ?? 0) > 0
+    const nextId = currentIsMain
+      ? currentId
+      : Object.values(byId).find(candidate => (candidate.retainedBy.mainView ?? 0) > 0)?.id
+    this.watchMainRetention(nextId)
+    const owner = nextId === undefined ? undefined : this.sessions.binding(nextId)
+    const value = owner === undefined ? this.absent.value : this.sourceFor(owner).value
+    if (this.current.value === value) return
+    this.current.value = value
+    notifySubscribers(this.current.listeners, '[ui-session] main binding')
   }
 
-  private publishCurrent(): void {
-    const next = this.resolveCurrent()
-    if (next === this.currentBinding) return
-    this.currentBinding = next
-    notifySubscribers(this.currentListeners, '[ui-session] current binding')
+  private watchMainRetention(sessionId: SessionId | undefined): void {
+    if (sessionId === this.mainRetainId) return
+    this.disposeMainRetain()
+    this.mainRetainId = sessionId
+    this.disposeMainRetain = sessionId === undefined
+      ? () => {}
+      : this.sessions.retainInfo(sessionId).subscribe(() => { this.publishMain() })
   }
 
   private publishPendingInteractions(): void {
@@ -382,21 +476,77 @@ export class UiSession extends Service {
     )
     if (samePendingInteractions(this.pendingSnapshot, projected)) return
     this.pendingSnapshot = projected
-    notifySubscribers(this.pendingListeners, '[ui-session] pending interactions')
+    this.publishStatus()
+  }
+
+  private observeRunning(sessionId: SessionId, running: boolean): void {
+    const previous = this.running.get(sessionId)
+    const beforeBaseline = this.sessions.list.getSnapshot().phase === 'pending'
+    this.running.set(sessionId, running)
+    if (running) this.completionUnread.delete(sessionId)
+    else if ((previous === true || (previous === undefined && beforeBaseline))
+      && !this.isMain(sessionId)) this.completionUnread.add(sessionId)
+    this.publishStatus()
+  }
+
+  private reconcileStatus(): void {
+    const list = this.sessions.list.getSnapshot()
+    const present = new Set(Object.keys(list.byId) as SessionId[])
+    for (const id of present) {
+      const row = list.byId[id]
+      if (row === undefined) continue
+      const previous = this.running.get(id)
+      if (previous === undefined) this.running.set(id, row.running)
+      else if (previous !== row.running) this.observeRunning(id, row.running)
+      if ((row.retainedBy.mainView ?? 0) > 0) this.completionUnread.delete(id)
+    }
+    if (list.phase === 'ready') {
+      for (const id of this.running.keys()) {
+        if (present.has(id)) continue
+        this.running.delete(id)
+        this.completionUnread.delete(id)
+      }
+    }
+    this.publishStatus()
+  }
+
+  private isMain(sessionId: SessionId): boolean {
+    return (this.sessions.list.getSnapshot().byId[sessionId]?.retainedBy.mainView ?? 0) > 0
+  }
+
+  private publishStatus(): void {
+    const ids = new Set<SessionId>([
+      ...(Object.keys(this.sessions.list.getSnapshot().byId) as SessionId[]),
+      ...this.running.keys(),
+      ...this.pendingSnapshot.keys(),
+      ...this.completionUnread,
+    ])
+    const next = new Map<SessionId, SessionStatus>()
+    for (const id of ids) {
+      next.set(id, {
+        running: this.running.get(id),
+        pendingInteraction: this.pendingSnapshot.get(id),
+        completionUnread: this.completionUnread.has(id),
+      })
+    }
+    if (sameSessionStatus(this.statusSnapshot, next)) return
+    this.statusSnapshot = next
+    notifySubscribers(this.statusListeners, '[ui-session] Session status')
   }
 
   private createMaterializedBinding(owner: SessionBinding): MaterializedBinding {
     const value = this.materialize(owner)
+    this.ctx.slots.bindStoreScope(value)
+    const source = createBindingSource(value)
     const releaseEffect = owner.ctx.effect(() => () => {
-      if (this.bindings.get(owner.sessionId) !== record) return
-      this.bindings.delete(owner.sessionId)
-      if (this.currentBinding !== value) return
-      this.currentBinding = this.absent
-      notifySubscribers(this.currentListeners, '[ui-session] current binding')
+      if (this.bindings.get(owner) === record) this.bindings.delete(owner)
+      source.value = this.absent.value
+      notifySubscribers(source.listeners, '[ui-session] Session binding')
+      this.publishMain()
     }, `ui-session: binding ${owner.sessionId}`)
     const record: MaterializedBinding = {
       owner,
-      value,
+      source,
       release: () => { void releaseEffect() },
     }
     return record
@@ -421,7 +571,6 @@ export class UiSession extends Service {
       keyedHooks,
       props,
     }
-    this.ctx.slots.bindStoreScope(value)
     return value
   }
 
@@ -439,6 +588,19 @@ export class UiSession extends Service {
   }
 }
 
+function createBindingSource(value: StandardSourceBinding): BindingSource {
+  const source: BindingSource = {
+    value,
+    listeners: new Set(),
+    getSnapshot: () => source.value,
+    subscribe: (listener) => {
+      source.listeners.add(listener)
+      return () => { source.listeners.delete(listener) }
+    },
+  }
+  return source
+}
+
 function validateContribution(
   descriptor: RuntimeSessionSourceDescriptor,
   contribution: RuntimeSessionSourceContribution,
@@ -496,7 +658,7 @@ function claimStandardProp(kind: StandardMemberKind, name: string, finalProps: S
 }
 
 /** Required Controller and renderer services. */
-export const inject = ['sessions', 'slots']
+export const inject = ['sessions', 'slots', 'remote']
 
 /**
  * Install the Session root source and scoped adapter.
@@ -507,12 +669,27 @@ export function apply(ctx: Context): void {
   ctx.slots.provideRoot({
     hooks: {
       sessions: ctx.sessions.list,
-      sessionPendingInteraction: service.pendingInteractions,
+      sessionStatus: service.sessionStatus,
+    },
+    keyedHooks: {
+      sessionRetainInfo: key => ctx.sessions.retainInfo(key as SessionId),
     },
   } satisfies RootStandardSourceContribution)
   ctx.slots.installScope('session', service.adapter)
 }
 
+function sameSessionStatus(left: SessionStatusSnapshot, right: SessionStatusSnapshot): boolean {
+  if (left.size !== right.size) return false
+  for (const [id, status] of left) {
+    const candidate = right.get(id)
+    if (candidate === undefined
+      || candidate.running !== status.running
+      || candidate.pendingInteraction !== status.pendingInteraction
+      || candidate.completionUnread !== status.completionUnread) return false
+  }
+  return true
+}
+
 function samePendingInteractions(
   left: ReadonlyMap<SessionId, SessionPendingInteractionBase>,
   right: ReadonlyMap<SessionId, SessionPendingInteractionBase>,

+ 4 - 5
packages/client/ui-session/src/client/session-provider.tsx

@@ -1,5 +1,5 @@
 /** Session-owned rendering semantics for the standard SessionProvider seat. */
-import { Fragment, type ReactNode } from 'react'
+import type { ReactNode } from 'react'
 import type {
   SessionAreaProps, StandardSourceBinding,
 } from '@deepseek-ai/dsh-client-ui-slots'
@@ -8,13 +8,12 @@ import type {
  * Render the selected Session body or its empty branch.
  * @param binding - current Session scope binding.
  * @param props - standard Session area render props.
- * @returns the selected Session subtree, keyed by Session identity.
+ * @returns the selected Session subtree.
  */
 export function renderSessionArea(
   binding: StandardSourceBinding,
   { empty, children }: SessionAreaProps,
 ): ReactNode {
-  const sessionId = binding.key
-  if (sessionId === undefined) return <>{empty?.() ?? null}</>
-  return <Fragment key={sessionId}>{children}</Fragment>
+  if (binding.key === undefined) return <>{empty?.() ?? null}</>
+  return <>{children}</>
 }

+ 6 - 0
packages/client/ui-session/tsconfig.json

@@ -11,12 +11,18 @@
     {
       "path": "../../../vendor/cordis"
     },
+    {
+      "path": "../../api/remotes/tsconfig.client.json"
+    },
     {
       "path": "../../api/session-controller/tsconfig.client.json"
     },
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../util/values"
+    },
     {
       "path": "../store"
     },

+ 5 - 3
packages/client/ui-settings-general/src/client/SettingsRoot.tsx

@@ -143,9 +143,11 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
   const connectionState = useConnectionState(state => state)
   const previousConnectionState = useRef(connectionState)
   const onboardingSteps = useOnboardingSteps(s => s)
-  const onboardingActive = useSessions(state =>
-    state.phase === 'ready'
-    && (state.current === undefined || state.byId[state.current]?.blank === true))
+  const onboardingActive = useSessions((state) => {
+    const main = Object.values(state.byId)
+      .find(session => (session.retainedBy.mainView ?? 0) > 0)
+    return state.phase === 'ready' && (main === undefined || main.blank)
+  })
   const onboardingStep = onboardingActive
     ? onboardingSteps.find(step => !completedOnboarding.has(step.id))
     : undefined

+ 13 - 8
packages/client/ui-slots/src/index.ts

@@ -100,6 +100,9 @@ export type SlotKind = 'single' | 'list' | 'keyed' | 'chain'
 /** Slot data context: global, current-session-optional, or strict session-bound. */
 export type SlotScope = 'root' | 'session-maybe' | 'session'
 
+/** Declaration-merged explicit target types for non-root scope Providers. */
+export interface SlotScopeTargetMap {}
+
 /**
  * One SlotMap entry: kind/scope axes plus the optional owner-supplied props
  * share (`owner` is what the parent passes at its renderSlot call site; the
@@ -317,16 +320,21 @@ export type MatchedShare<E extends SlotEntryDef, M> =
 
 /** Props of the standard-kit SessionProvider seat. */
 export interface SessionAreaProps {
+  /**
+   * Explicit Session-scope target. Omit this property to inherit the surrounding
+   * binding; pass `undefined` to establish an explicitly absent binding.
+   */
+  readonly session?: SlotScopeTargetMap[keyof SlotScopeTargetMap & 'session'] | undefined
   /** No-session body (also covers a current id whose session cannot be resolved). */
   empty?: (() => ReactNode) | undefined
-  /** Session body; the framework remounts it per session identity. */
+  /** Session body; scoped entries apply their declared remount semantics. */
   children: ReactNode
 }
 
 /**
  * Framework-wired session area component. `ui-session` supplies the current
  * Controller binding through the renderer scope adapter; entries that declare
- * session-scoped children receive this component without importing it.
+ * `session` or `session-maybe` children receive this component without importing it.
  */
 export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode
 
@@ -361,12 +369,9 @@ export type PropsRenderSlots<S extends keyof SlotMap & string> = {
    * @returns rendered node(s).
    */
   renderSlotChain: <K extends ChainKeysOf<S>>(key: K, owner: OwnerOf<K>, opts?: ChainRenderOpts) => ReactNode
-}) & ('session' extends ScopeOf<S>
-  // The SessionProvider seat rides the same source as renderSlot: declaring
-  // a session-scope child is what makes a session area exist, so the seat
-  // derives from the children key set's scopes (renderer injects the value).
-  ? { SessionProvider: SessionProviderComponent }
-  : object)
+}) & ([Extract<ScopeOf<S>, 'session' | 'session-maybe'>] extends [never]
+  ? object
+  : { SessionProvider: SessionProviderComponent })
 
 /**
  * Registration-position component shape: the bare call signature, so composed

+ 5 - 5
packages/client/ui-slots/src/renderer.ts

@@ -88,14 +88,14 @@ export interface ScopedStandardSourceBinding extends StandardSourceBinding {
 
 /** One installed source of bindings for a non-root Slot scope. */
 export interface SlotScopeAdapter {
-  /** Binding that follows the current selection, including its absent projection. */
+  /** Default binding inherited by scoped entries, including its absent projection. */
   readonly current: HostObservable<StandardSourceBinding>
   /**
-   * Resolve an already-materialized binding.
-   * @param key - scope identity.
-   * @returns the binding, or `undefined` when the identity is unavailable.
+   * Resolve a stable observable for an explicit scope target or explicit absence.
+   * @param target - domain-owned Provider target, or absence.
+   * @returns the target's current standard-source binding.
    */
-  resolve(key: string): ScopedStandardSourceBinding | undefined
+  bindingSource(target: SessionAreaProps['session']): HostObservable<StandardSourceBinding>
   /**
    * Render the scope owner's area seat over the current binding. The renderer
    * binds this function to the standard `SessionProvider` prop without owning

+ 4 - 2
packages/client/ui-subagent/package.json

@@ -32,7 +32,8 @@
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-conversation",
         "@deepseek-ai/dsh-client-ui-primitives",
-        "@deepseek-ai/dsh-client-ui-input-trigger"
+        "@deepseek-ai/dsh-client-ui-input-trigger",
+        "@deepseek-ai/dsh-client-ui-workspace"
       ],
       "platform": "web"
     }
@@ -63,7 +64,8 @@
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-session": "workspace:^"
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-workspace": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 2 - 2
packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx

@@ -666,8 +666,8 @@ function CatalogDropdown({
 
   // Visibility needs evidence of children (entries, summary-known descendants,
   // or a failed load worth retrying). A bare loading catalog is not evidence:
-  // selecting any session schedules a refresh whose loading snapshot would
-  // otherwise flash the action in and out on childless sessions.
+  // an opened menu or another active catalog consumer can start a refresh whose
+  // loading snapshot would otherwise flash the action on childless sessions.
   const visible = presentedCatalog !== undefined
     && (variant === 'switcher'
       || presentedCatalog.state === 'error'

+ 3 - 2
packages/client/ui-subagent/src/client/index.ts

@@ -10,6 +10,7 @@ import {
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
 import { en, NS, zh, type SubagentKey } from './locales.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -27,7 +28,7 @@ export type {
 } from './SubagentReadOnlyComposer.tsx'
 
 /** Required services for conversation slots and session navigation. */
-export const inject = ['sessions', 'slots', 'locale']
+export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale']
 
 /** Claim the composer for one-shot history or an unavailable continuation owner. */
 function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null {
@@ -53,7 +54,7 @@ export function apply(ctx: ClientContext): void {
   const sessions = ctx.sessions
   const catalogActions = (_parentSessionId: SessionId): SubagentCatalogInjected => ({
     openChild(address: SubagentAddress) {
-      sessions.openSubagent(address)
+      ctx.uiWorkspace.openSession(address)
     },
     refresh(parentSessionId: SessionId) {
       void sessions.refreshSubagents(parentSessionId)

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

@@ -35,6 +35,9 @@
     {
       "path": "../ui-session"
     },
+    {
+      "path": "../ui-workspace"
+    },
     {
       "path": "../ui-slots"
     },

+ 3 - 1
packages/client/ui-workflow-run/package.json

@@ -33,7 +33,8 @@
         "@deepseek-ai/dsh-client-ui-chat",
         "@deepseek-ai/dsh-client-ui-conversation",
         "@deepseek-ai/dsh-client-ui-renderer",
-        "@deepseek-ai/dsh-client-ui-session"
+        "@deepseek-ai/dsh-client-ui-session",
+        "@deepseek-ai/dsh-client-ui-workspace"
       ],
       "platform": "web"
     }
@@ -62,6 +63,7 @@
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-tool-workflow": "workspace:^",
     "@deepseek-ai/dsh-workflow": "workspace:^",

+ 17 - 5
packages/client/ui-workflow-run/src/client/WorkflowRunPanel.tsx

@@ -7,7 +7,7 @@ import {
   type DisclosureRowProps, type StateDotState,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionListState, SessionTarget } from '@deepseek-ai/dsh-api-session-controller/client'
 import { shallowEqual } from '@deepseek-ai/dsh-client-store'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { WorkflowRunKey } from './locales.ts'
@@ -18,7 +18,7 @@ import css from './WorkflowRunPanel.module.css'
 
 /** Navigation action injected from the plugin's own Session Controller access. */
 export interface WorkflowRunInjected {
-  readonly openSession: (id: SessionId) => void
+  readonly openSession: (target: SessionTarget) => void
 }
 
 /** Complete keyed Chat renderer props. */
@@ -238,10 +238,11 @@ function RunHeader({ children, count, name, onToggle, open, status, t }: {
   )
 }
 
-function MemberRow({ member, navigable, openSession, t }: {
+function MemberRow({ member, navigable, openSession, parentSessionId, t }: {
   readonly member: WorkflowRunMemberData
   readonly navigable: boolean
   readonly openSession: WorkflowRunInjected['openSession']
+  readonly parentSessionId: SessionId
   readonly t: WorkflowRunPanelProps['t']
 }) {
   const name = readableMember(member.label, t)
@@ -268,7 +269,15 @@ function MemberRow({ member, navigable, openSession, t }: {
       tabIndex={navigable ? undefined : -1}
       onFocus={() => { setFocused(true) }}
       onBlur={() => { setFocused(false) }}
-      onClick={navigable ? () => { openSession(member.childId) } : undefined}
+      onClick={navigable
+        ? () => {
+          openSession({
+            parentSessionId,
+            childSessionId: member.childId,
+            mode: 'one-shot',
+          })
+        }
+        : undefined}
     >
       {content}
     </button>
@@ -277,7 +286,7 @@ function MemberRow({ member, navigable, openSession, t }: {
 
 function PhaseSection({
   contentRef, onContentBlur, onToggle, open, pendingCleanCollapse,
-  phase, navigable, openSession, t,
+  phase, navigable, openSession, parentSessionId, t,
 }: {
   readonly contentRef: (element: HTMLDivElement | null) => void
   readonly onContentBlur: (event: FocusEvent<HTMLDivElement>) => void
@@ -287,6 +296,7 @@ function PhaseSection({
   readonly phase: WorkflowRunPhaseData
   readonly navigable: readonly SessionId[]
   readonly openSession: WorkflowRunInjected['openSession']
+  readonly parentSessionId: SessionId
   readonly t: WorkflowRunPanelProps['t']
 }) {
   return (
@@ -320,6 +330,7 @@ function PhaseSection({
               member={member}
               navigable={navigable.includes(member.childId)}
               openSession={openSession}
+              parentSessionId={parentSessionId}
               t={t}
             />
           ))}
@@ -458,6 +469,7 @@ export function WorkflowRunPanel({ node, sessionId, useSessions, openSession, t
                   phase={phase}
                   navigable={navigable}
                   openSession={openSession}
+                  parentSessionId={sessionId}
                   t={t}
                 />
               )

+ 4 - 3
packages/client/ui-workflow-run/src/client/index.ts

@@ -1,12 +1,13 @@
 /** Browser plugin for durable workflow-run Conversation Nodes. */
 
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SessionTarget } from '@deepseek-ai/dsh-api-session-controller/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
 import { WorkflowRunPanel, type WorkflowRunInjected } from './WorkflowRunPanel.tsx'
 import { en, NS, type WorkflowRunKey, zh } from './locales.ts'
 import { workflowRunDefinition } from './workflow-definition.ts'
@@ -19,7 +20,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 }
 
 /** Required services for Definition, keyed renderer, navigation, and copy. */
-export const inject = ['uiConversation', 'slots', 'sessions', 'locale']
+export const inject = ['uiConversation', 'uiWorkspace', 'slots', 'sessions', 'locale']
 
 /** Register the workflow Definition, dictionary, and keyed Chat renderer. */
 export function apply(ctx: ClientContext): void {
@@ -30,7 +31,7 @@ export function apply(ctx: ClientContext): void {
     key: 'workflow-run',
     locale: NS,
     inject: (): WorkflowRunInjected => ({
-      openSession: (id: SessionId) => { ctx.sessions.open(id) },
+      openSession: (target: SessionTarget) => { ctx.uiWorkspace.openSession(target) },
     }),
   }, WorkflowRunPanel))
 }

+ 3 - 0
packages/client/ui-workflow-run/tsconfig.json

@@ -32,6 +32,9 @@
     {
       "path": "../ui-session"
     },
+    {
+      "path": "../ui-workspace"
+    },
     {
       "path": "../ui-primitives"
     },

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

@@ -68,6 +68,7 @@
     "@deepseek-ai/dsh-schedule": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",

+ 11 - 5
packages/client/ui-workspace/src/client/index.ts

@@ -49,6 +49,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
   }
 }
 
+declare module '@deepseek-ai/dsh-api-session-controller/client' {
+  interface SessionReferenceSourceMap {
+    workspaceOperation: unknown
+  }
+}
+
 /** Dictionary namespace owned by this plugin. */
 const NS = 'workspace'
 
@@ -107,11 +113,11 @@ export function apply(ctx: Context): void {
     searchSessions,
     searchResultLimit: sessions.searchResultLimit,
     renameSession: async (sessionId, title) => {
-      // Row → session-face hop: rename is a per-session verb (ISession), not
-      // a list-service verb; the binding resolves any listed session.
-      const session = sessions.binding(sessionId)?.session
-      if (session === undefined) throw new Error(`unknown session "${sessionId}"`)
-      const result = await session.rename(title)
+      const result = await sessions.using(
+        sessionId,
+        { source: 'workspaceOperation' },
+        reference => reference.binding.session.rename(title),
+      )
       if (!result.ok) throw new Error(result.error.message)
     },
     forkSession: (sessionId) => {

+ 98 - 22
packages/client/ui-workspace/src/client/navigation.ts

@@ -4,21 +4,30 @@ import { Service, type Context } from '@deepseek-ai/cordis'
 import type { ClientRemote, DirectoryListing, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
 import type {
   ISessions,
+  SessionReference,
+  SessionTarget,
   SessionListState,
 } from '@deepseek-ai/dsh-api-session-controller/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
 import type {
   IWorkspaces, WorkspaceId, WorkspaceView,
 } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 
+interface MainSelection {
+  readonly sessionId?: SessionId
+  readonly subagentAddress?: SubagentAddress
+}
+
 /** Workspace archive and directory operations consumed by Client UI domains. */
 export interface UiWorkspace {
   /**
    * Select a Session and show its Conversation as one UI navigation action.
-   * @param sessionId - listed or retained Session to display.
+   * @param target - known Session identity or durable direct-parent subagent address to display.
    */
-  openSession(sessionId: SessionId): void
+  openSession(target: SessionTarget): void
   /**
    * Connect a Workspace and open its Session unless a later navigation supersedes it.
    * @param workspaceId - target Workspace.
@@ -95,6 +104,10 @@ export class DirectoryBrowseError extends Error {
 class UiWorkspaceService extends Service implements UiWorkspace {
   private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
   private readonly lifetime = new AbortController()
+  private readonly selection = createSnapshotStore<MainSelection>(
+    {}, { persist: { name: 'dsh.sessions.current' } },
+  )
+  private mainReference: SessionReference | undefined
 
   /**
    * @param ctx - Client root Context.
@@ -109,7 +122,16 @@ class UiWorkspaceService extends Service implements UiWorkspace {
     private readonly sessions: ISessions,
   ) {
     super(ctx, 'uiWorkspace')
-    ctx.effect(() => this.watchNavigation(), 'ui-workspace: Workspace navigation policy')
+    ctx.effect(() => {
+      const stop = this.watchNavigation()
+      return () => {
+        stop()
+        this.lifetime.abort()
+        const reference = this.mainReference
+        this.mainReference = undefined
+        reference?.release()
+      }
+    }, 'ui-workspace: Workspace navigation policy')
   }
 
   async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
@@ -136,30 +158,27 @@ class UiWorkspaceService extends Service implements UiWorkspace {
     return attempt
   }
 
-  openSession(sessionId: SessionId): void {
-    this.sessions.open(sessionId)
-    this.ctx.layout.selectPanel(null)
+  openSession(target: SessionTarget): void {
+    this.replaceMain(target, this.lifetime.signal)
   }
 
   async openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise<void> {
     const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal])
-    const isCurrent = (): boolean => !navigation.aborted
     const sessionId = await this.connectWorkspace(workspaceId)
-    if (!isCurrent()) return
-    beforeOpen?.(sessionId)
-    if (isCurrent()) this.openSession(sessionId)
+    if (navigation.aborted) return
+    this.replaceMain(sessionId, navigation, beforeOpen)
   }
 
   async forkSession(sessionId: SessionId): Promise<void> {
     const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal])
     const childId = await this.sessions.fork({ sessionId, increaseTitle: true })
-    if (!navigation.aborted) this.openSession(childId)
+    if (!navigation.aborted) this.replaceMain(childId, navigation)
   }
 
   startSession(workspaceId?: WorkspaceId): void {
     const workspace = this.workspaces.list.getSnapshot()
     const sessions = this.sessions.list.getSnapshot()
-    const current = sessions.current
+    const current = this.mainReference?.sessionId
     const currentWorkspaceId = current === undefined
       ? undefined
       : workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId
@@ -168,8 +187,7 @@ class UiWorkspaceService extends Service implements UiWorkspace {
       : undefined
     const target = workspaceId ?? currentWorkspaceId ?? recent
     if (target === undefined) {
-      this.sessions.clear()
-      this.ctx.layout.selectPanel(null)
+      this.clearMain()
       return
     }
     void this.openWorkspace(target).catch(
@@ -179,6 +197,7 @@ class UiWorkspaceService extends Service implements UiWorkspace {
 
   async archiveSession(sessionId: SessionId): Promise<void> {
     await this.workspaces.archiveSession(sessionId)
+    if (this.mainReference?.sessionId === sessionId) this.clearMain()
   }
 
   async unarchiveSession(sessionId: SessionId): Promise<void> {
@@ -212,10 +231,29 @@ class UiWorkspaceService extends Service implements UiWorkspace {
       const workspace = this.workspaces.list.getSnapshot()
       const sessions = this.sessions.list.getSnapshot()
       if (workspace.phase !== 'ready' || sessions.phase !== 'ready') return
-      if (sessions.current !== undefined) {
+      if (this.mainReference !== undefined) {
         initial = 'done'
         return
       }
+      const saved = this.selection.getSnapshot()
+      const savedTarget = saved.subagentAddress
+        ?? (saved.sessionId !== undefined && sessions.byId[saved.sessionId] !== undefined
+          ? saved.sessionId
+          : undefined)
+      if (savedTarget !== undefined) {
+        initial = 'connecting'
+        try {
+          if (saved.subagentAddress !== undefined) {
+            void this.sessions.refreshSubagents(saved.subagentAddress.parentSessionId)
+          }
+          this.openSession(savedTarget)
+          initial = 'done'
+        } catch (reason: unknown) {
+          initial = 'waiting'
+          console.warn('initial Session restoration failed:', reason)
+        }
+        return
+      }
       const target = recentWorkspace(workspace.items, sessions.byId)
       if (target === undefined) {
         initial = 'done'
@@ -224,12 +262,10 @@ class UiWorkspaceService extends Service implements UiWorkspace {
       initial = 'connecting'
       void this.connectWorkspace(target).then(
         (sessionId) => {
-          if (this.lifetime.signal.aborted) return
-          if (this.sessions.list.getSnapshot().current === undefined) {
-            this.sessions.open(sessionId)
-          }
-          initial = 'done'
+          if (this.mainReference === undefined) this.openSession(sessionId)
         },
+      ).then(
+        () => { initial = 'done' },
         (reason: unknown) => {
           if (this.lifetime.signal.aborted) return
           initial = 'waiting'
@@ -249,13 +285,53 @@ class UiWorkspaceService extends Service implements UiWorkspace {
 
   /** @returns true when an archived current selection was cleared. */
   private clearArchivedCurrent(): boolean {
-    const current = this.sessions.list.getSnapshot().current
+    const current = this.mainReference?.sessionId
     if (current === undefined
       || !this.workspaces.list.getSnapshot().archivedSessionIds.includes(current)) return false
-    this.sessions.clear()
+    this.clearMain()
     return true
   }
 
+  private clearMain(): void {
+    const previous = this.mainReference
+    this.mainReference = undefined
+    this.selection.set({})
+    previous?.release()
+    this.ctx.layout.selectPanel(null)
+  }
+
+  private replaceMain(
+    target: SessionTarget,
+    signal: AbortSignal,
+    beforeOpen?: (sessionId: SessionId) => void,
+  ): void {
+    signal.throwIfAborted()
+    const reference = this.sessions.retain(target, { source: 'mainView' })
+    try {
+      signal.throwIfAborted()
+      beforeOpen?.(reference.sessionId)
+      if (signal.aborted) {
+        reference.release()
+        return
+      }
+      const subagentAddress = typeof target === 'string'
+        ? this.sessions.subagentAddress(reference.sessionId)
+        : target
+      this.selection.set({
+        sessionId: reference.sessionId,
+        ...(subagentAddress === undefined ? {} : { subagentAddress }),
+      })
+    } catch (error: unknown) {
+      reference.release()
+      throw error
+    }
+    const previous = this.mainReference
+    this.mainReference = reference
+    previous?.release()
+    void this.sessions.refreshSubagents(reference.sessionId)
+    this.ctx.layout.selectPanel(null)
+  }
+
 }
 
 /** Stable tie-breaking follows Host Workspace order. */

+ 34 - 24
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -169,7 +169,7 @@ function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }):
 
 type SessionTreeProps = Pick<
   WorkspaceBrowserProps,
-  'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession'
+  'useSessionStatus' | 'startSession' | 'open' | 'forkSession'
   | 'insertWorkspaceBefore' | 't' | 'usePanelInfo'
 > & {
   /** Always-mounted Session list snapshot. */
@@ -208,7 +208,7 @@ type SessionTreeProps = Pick<
 
 /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
 function SessionTree({
-  list, useSessionPendingInteraction, startSession, open, forkSession, workspaces, ungroupedSessionIds,
+  list, useSessionStatus, startSession, open, forkSession, workspaces, ungroupedSessionIds,
   archivedSessionIds,
   workspaceReady, usePanelInfo,
   onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
@@ -218,8 +218,10 @@ function SessionTree({
   revealSessionId, onSessionRevealed,
 }: SessionTreeProps) {
   const panelActive = usePanelInfo(info => info.activePanelId !== null)
-  const pendingInteractions = useSessionPendingInteraction(s => s)
-  const current = panelActive ? undefined : list.current
+  const statuses = useSessionStatus(s => s)
+  const current = panelActive
+    ? undefined
+    : Object.values(list.byId).find(session => (session.retainedBy.mainView ?? 0) > 0)?.id
   const revealGroup = revealSessionId === undefined || !workspaceReady
     ? undefined
     : owningGroupKey(workspaces, revealSessionId)
@@ -260,11 +262,11 @@ function SessionTree({
       .filter(key => groupExpansion[key] ?? ancestorKeys.has(key))
   }, [groupExpansion, parents, workspaces])
   const groups = useMemo(
-    () => deriveGroups(list, workspaces, archivedSessionIds, pendingInteractions, {
+    () => deriveGroups(list, workspaces, archivedSessionIds, statuses, {
       expandedGroups,
       ungroupedOrder: ungroupedSessionIds,
     }),
-    [list, workspaces, archivedSessionIds, pendingInteractions, expandedGroups, ungroupedSessionIds],
+    [list, workspaces, archivedSessionIds, statuses, expandedGroups, ungroupedSessionIds],
   )
   useEffect(() => {
     for (let key = revealGroup; key !== undefined; key = parents.get(key)) {
@@ -564,12 +566,12 @@ function SessionTree({
 
 /** The flat "In one list" body: every session is one draggable top-level row. */
 function FlatList({
-  list, sessionIds, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive,
+  list, sessionIds, useSessionStatus, open, forkSession, onSessionRename, onSessionArchive,
   usePanelInfo, setSessionOrder,
   revealSessionId, onSessionRevealed, t,
 }: Pick<
   SessionTreeProps,
-  | 'useSessionPendingInteraction'
+  | 'useSessionStatus'
   | 'open'
   | 'forkSession'
   | 'onSessionRename'
@@ -584,14 +586,17 @@ function FlatList({
   sessionIds: readonly SessionId[]
 }) {
   const panelActive = usePanelInfo(info => info.activePanelId !== null)
-  const pendingInteractions = useSessionPendingInteraction(s => s)
+  const statuses = useSessionStatus(s => s)
   const rows = useMemo(
-    () => deriveFlat(list, sessionIds, pendingInteractions),
-    [list, sessionIds, pendingInteractions],
+    () => deriveFlat(list, sessionIds, statuses),
+    [list, sessionIds, statuses],
   )
   const [drag, setDrag] = useState<DragState | null>(null)
   const dropCommitted = useRef(false)
   useNativeDragAcceptance(drag !== null)
+  const currentId = panelActive
+    ? undefined
+    : Object.values(list.byId).find(session => (session.retainedBy.mainView ?? 0) > 0)?.id
   const commitDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
     if (dropCommitted.current) return
     dropCommitted.current = true
@@ -624,7 +629,7 @@ function FlatList({
             <SessionNodeItem
               key={node.id}
               node={node}
-              currentId={panelActive ? undefined : list.current}
+              currentId={currentId}
               now={now}
               onOpen={open}
               onRename={onSessionRename}
@@ -675,7 +680,7 @@ interface RemoteSearchState {
 /** Flat search body: local metadata matches plus the current Host result page. */
 function SearchResults({
   useSessions,
-  useSessionPendingInteraction,
+  useSessionStatus,
   open,
   workspaces,
   archivedSessionIds,
@@ -684,7 +689,7 @@ function SearchResults({
   resultLimit,
   usePanelInfo,
   t,
-}: Pick<WorkspaceBrowserProps, 'useSessions' | 'useSessionPendingInteraction' | 'open' | 't' | 'usePanelInfo'> & {
+}: Pick<WorkspaceBrowserProps, 'useSessions' | 'useSessionStatus' | 'open' | 't' | 'usePanelInfo'> & {
   workspaces: readonly WorkspaceView[]
   archivedSessionIds: readonly SessionNode['id'][]
   query: string
@@ -693,7 +698,7 @@ function SearchResults({
 }) {
   const panelActive = usePanelInfo(info => info.activePanelId !== null)
   const list = useSessions(s => s)
-  const pendingInteractions = useSessionPendingInteraction(s => s)
+  const statuses = useSessionStatus(s => s)
   const currentRemote = remote.query === query
     ? remote
     : { query, status: 'loading' as const, items: [], hasMore: false }
@@ -703,14 +708,17 @@ function SearchResults({
       workspaces,
       query,
       archivedSessionIds,
-      pendingInteractions,
+      statuses,
       currentRemote,
       resultLimit,
     ),
-    [list, workspaces, query, archivedSessionIds, pendingInteractions, currentRemote, resultLimit],
+    [list, workspaces, query, archivedSessionIds, statuses, currentRemote, resultLimit],
   )
   const pending = currentRemote.status === 'loading'
   const failed = currentRemote.status === 'error'
+  const currentId = panelActive
+    ? undefined
+    : Object.values(list.byId).find(session => (session.retainedBy.mainView ?? 0) > 0)?.id
 
   return (
     <div className={clsx(css.treeBody, css.wide)}>
@@ -720,7 +728,7 @@ function SearchResults({
             <SearchResultItem
               key={result.id}
               result={result}
-              currentId={panelActive ? undefined : list.current}
+              currentId={currentId}
               onOpen={open}
               t={t}
             />
@@ -758,7 +766,7 @@ export function WorkspaceBrowser({
   usePanelInfo,
   expandSidebar,
   useSessions,
-  useSessionPendingInteraction,
+  useSessionStatus,
   useWorkspaces,
   useStore,
   actions,
@@ -793,8 +801,10 @@ export function WorkspaceBrowser({
   const groupExpansion = useStore(s => s.groupExpansion)
   const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount)
   const workspaceReady = workspacePhase === 'ready' && workspaceStreamState !== 'loading'
-  const currentBlank = list.current !== undefined && list.byId[list.current]?.blank === true
-    ? list.current
+  const mainSessionId = Object.values(list.byId)
+    .find(session => (session.retainedBy.mainView ?? 0) > 0)?.id
+  const currentBlank = mainSessionId !== undefined && list.byId[mainSessionId]?.blank === true
+    ? mainSessionId
     : undefined
   const ungroupedMemberIds = useMemo(() => {
     const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
@@ -1238,7 +1248,7 @@ export function WorkspaceBrowser({
             <SearchResults
               usePanelInfo={usePanelInfo}
               useSessions={useSessions}
-              useSessionPendingInteraction={useSessionPendingInteraction}
+              useSessionStatus={useSessionStatus}
               open={openSearchResult}
               workspaces={workspaces}
               archivedSessionIds={archivedSessionIds}
@@ -1254,7 +1264,7 @@ export function WorkspaceBrowser({
                 usePanelInfo={usePanelInfo}
                 list={list}
                 sessionIds={orderedFlatSessionIds}
-                useSessionPendingInteraction={useSessionPendingInteraction}
+                useSessionStatus={useSessionStatus}
                 open={open} forkSession={forkSession}
                 onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
                 setSessionOrder={saveSessionOrder}
@@ -1267,7 +1277,7 @@ export function WorkspaceBrowser({
               <SessionTree
                 usePanelInfo={usePanelInfo}
                 list={list}
-                useSessionPendingInteraction={useSessionPendingInteraction}
+                useSessionStatus={useSessionStatus}
                 onSessionRename={onSessionRename}
                 onSessionArchive={onSessionArchive}
                 forkSession={forkSession}

+ 36 - 25
packages/client/ui-workspace/src/client/tree.ts

@@ -8,7 +8,7 @@ import {
 } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type {
-  SessionPendingInteractionBase,
+  SessionStatusSnapshot,
 } from '@deepseek-ai/dsh-client-ui-session/client'
 import type {} from '@deepseek-ai/dsh-schedule/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -36,7 +36,12 @@ export function owningGroupKey(
 
 /** Pending interaction kinds with dedicated Workspace-row presentation. */
 export type SessionPendingInteractionStatus = 'approval' | 'plan-review' | 'question'
-type SessionPendingInteractions = ReadonlyMap<SessionId, SessionPendingInteractionBase>
+type SessionStatuses = SessionStatusSnapshot
+
+function mainSessionId(list: SessionListState): SessionId | undefined {
+  return Object.values(list.byId)
+    .find(session => (session.retainedBy.mainView ?? 0) > 0)?.id
+}
 
 /** One top-level session row in a group or the flat list. */
 export interface SessionNode {
@@ -261,6 +266,7 @@ function groupByWorkspace(
   archived: ReadonlySet<SessionId>,
   ungroupedOrder: readonly string[] | undefined,
 ): Group[] {
+  const current = mainSessionId(list)
   const groups: Group[] = []
   const accounted = new Set<SessionId>()
   for (const workspace of workspaces) {
@@ -269,7 +275,7 @@ function groupByWorkspace(
       const summary = list.byId[id]
       if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
       accounted.add(id)
-      if (!sessionVisible(summary, list.current, archived)) continue
+      if (!sessionVisible(summary, current, archived)) continue
       members.push(summary)
     }
     groups.push(buildGroup(
@@ -280,7 +286,7 @@ function groupByWorkspace(
   const stray = list.ids
     .map(id => list.byId[id])
     .filter((s): s is SessionSummary =>
-      s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
+      s !== undefined && !accounted.has(s.id) && sessionVisible(s, current, archived))
   if (stray.length > 0) {
     groups.push(buildGroup(
       UNGROUPED_KEY,
@@ -309,16 +315,17 @@ function visiblePendingKind(kind: string | undefined): SessionPendingInteraction
 function sessionNode(
   s: SessionSummary,
   descendants: ReadonlyMap<SessionId, SubagentDescendantSummary>,
-  pendingInteractions: SessionPendingInteractions,
+  statuses: SessionStatuses,
 ): SessionNode {
-  const pendingInteraction = visiblePendingKind(pendingInteractions.get(s.id)?.kind)
+  const status = statuses.get(s.id)
+  const pendingInteraction = visiblePendingKind(status?.pendingInteraction?.kind)
   return {
     id: s.id,
     title: sessionTitle(s),
     blank: s.blank,
-    running: s.running,
+    running: status?.running ?? s.running,
     runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0,
-    completed: s.completed === true,
+    completed: status?.completionUnread === true,
     hasActiveSchedule: hasActiveSchedule(s),
     updatedAt: s.updatedAt,
     ...(pendingInteraction === undefined ? {} : { pendingInteraction }),
@@ -333,10 +340,10 @@ function sessionNode(
  * provisional New Session row; archived sessions are excluded everywhere.
  * Content search lives outside this derivation
  * (see {@link deriveSearchResults}).
- * @param list - sessions list snapshot (`current` feeds containsCurrent).
+ * @param list - sessions list snapshot (`mainView` retention feeds containsCurrent).
  * @param workspaces - real Workspaces in Host group order with caller-projected Session order.
  * @param archivedSessionIds - registry-global archive set.
- * @param pendingInteractions - pending UI interactions by Session.
+ * @param statuses - unified UI status by Session.
  * @param view - local expansion arrays.
  * @returns group sections in render order.
  */
@@ -344,15 +351,16 @@ export function deriveGroups(
   list: SessionListState,
   workspaces: readonly WorkspaceView[],
   archivedSessionIds: readonly SessionId[],
-  pendingInteractions: SessionPendingInteractions,
+  statuses: SessionStatuses,
   view: TreeView,
 ): GroupNode[] {
   const archived = new Set(archivedSessionIds)
   const expandedGroups = new Set(view.expandedGroups)
   const descendants = indexSubagentDescendants(list.byId)
-  const currentGroup = list.current === undefined
+  const current = mainSessionId(list)
+  const currentGroup = current === undefined
     ? undefined
-    : owningGroupKey(workspaces, list.current)
+    : owningGroupKey(workspaces, current)
   const groups: GroupNode[] = []
   for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) {
     const expanded = expandedGroups.has(g.key)
@@ -366,7 +374,7 @@ export function deriveGroups(
       expanded,
       containsCurrent: g.key === currentGroup,
       sessions: expanded
-        ? g.sessions.map(session => sessionNode(session, descendants, pendingInteractions))
+        ? g.sessions.map(session => sessionNode(session, descendants, statuses))
         : [],
     })
   }
@@ -384,9 +392,10 @@ export function visibleSessionIds(
   archivedSessionIds: readonly SessionId[],
 ): SessionId[] {
   const archived = new Set(archivedSessionIds)
+  const current = mainSessionId(list)
   return list.ids.filter((id) => {
     const s = list.byId[id]
-    return s !== undefined && sessionVisible(s, list.current, archived)
+    return s !== undefined && sessionVisible(s, current, archived)
   })
 }
 
@@ -394,17 +403,17 @@ export function visibleSessionIds(
  * Derive flat rows from the browser's ordered visible Session ids.
  * @param list - sessions list snapshot used to select the ids.
  * @param sessionIds - known visible members in render order, including any pinned blank.
- * @param pendingInteractions - pending UI interactions by Session.
+ * @param statuses - unified UI status by Session.
  * @returns flat rows in the supplied order with current status indicators.
  */
 export function deriveFlat(
   list: SessionListState,
   sessionIds: readonly SessionId[],
-  pendingInteractions: SessionPendingInteractions,
+  statuses: SessionStatuses,
 ): SessionNode[] {
   const descendants = indexSubagentDescendants(list.byId)
   return sessionIds
-    .map(id => sessionNode(list.byId[id] as SessionSummary, descendants, pendingInteractions))
+    .map(id => sessionNode(list.byId[id] as SessionSummary, descendants, statuses))
 }
 
 /**
@@ -415,7 +424,7 @@ export function deriveFlat(
  * @param workspaces - Workspace membership and display labels.
  * @param query - caller text; surrounding whitespace is ignored.
  * @param archivedSessionIds - registry-global archive set (members never match).
- * @param pendingInteractions - pending UI interactions by Session.
+ * @param statuses - unified UI status by Session.
  * @param content - ranked Host content-search page.
  * @param limit - protocol-owned maximum merged row count.
  * @returns bounded deduplicated flat rows and a refine-query hint bit.
@@ -425,7 +434,7 @@ export function deriveSearchResults(
   workspaces: readonly WorkspaceView[],
   query: string,
   archivedSessionIds: readonly SessionId[],
-  pendingInteractions: SessionPendingInteractions,
+  statuses: SessionStatuses,
   content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
   limit: number,
 ): SearchResultSet {
@@ -433,6 +442,7 @@ export function deriveSearchResults(
   if (q === '') return { items: [], hasMore: false }
   const archived = new Set(archivedSessionIds)
   const descendants = indexSubagentDescendants(list.byId)
+  const current = mainSessionId(list)
 
   const workspaceBySession = new Map<SessionId, string>()
   for (const workspace of workspaces) {
@@ -452,7 +462,7 @@ export function deriveSearchResults(
     const summary = list.byId[id]
     // Blank placeholders never match a query (their canonical title displays
     // localized, so matching it would tie search to one language).
-    if (summary === undefined || summary.blank || !sessionVisible(summary, list.current, archived)) continue
+    if (summary === undefined || summary.blank || !sessionVisible(summary, current, archived)) continue
     if (
       sessionTitle(summary).toLowerCase().includes(q)
       || labelOf(summary).toLowerCase().includes(q)
@@ -474,23 +484,24 @@ export function deriveSearchResults(
   for (const summary of orderedLocal) include(summary)
   for (const item of content.items) {
     const summary = list.byId[item.sessionId]
-    if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current, archived)) include(summary)
+    if (summary !== undefined && !summary.blank && sessionVisible(summary, current, archived)) include(summary)
   }
 
   return {
     items: ordered.slice(0, limit).map((summary) => {
       const match = contentBySession.get(summary.id)
-      const pendingInteraction = visiblePendingKind(pendingInteractions.get(summary.id)?.kind)
+      const status = statuses.get(summary.id)
+      const pendingInteraction = visiblePendingKind(status?.pendingInteraction?.kind)
       return {
         id: summary.id,
         title: sessionTitle(summary),
         workspace: labelOf(summary),
-        running: summary.running,
+        running: status?.running ?? summary.running,
         runningSubagentCount: descendants.get(summary.id)?.runningCount ?? 0,
         ...(pendingInteraction === undefined
           ? {}
           : { pendingInteraction }),
-        completed: summary.completed === true,
+        completed: status?.completionUnread === true,
         hasActiveSchedule: hasActiveSchedule(summary),
         ...match === undefined ? {} : { snippet: match.snippet },
       }

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

@@ -38,6 +38,9 @@
     {
       "path": "../../core/session"
     },
+    {
+      "path": "../../subagent/subagent"
+    },
     {
       "path": "../../schedule/schedule"
     },

+ 4 - 1
packages/experimental/client-ui-agent-team/package.json

@@ -32,7 +32,8 @@
         "@deepseek-ai/dsh-api-session-controller",
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-conversation",
-        "@deepseek-ai/dsh-client-ui-primitives"
+        "@deepseek-ai/dsh-client-ui-primitives",
+        "@deepseek-ai/dsh-client-ui-workspace"
       ],
       "platform": "web"
     }
@@ -51,6 +52,7 @@
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-experimental-agent-team": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
@@ -66,6 +68,7 @@
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-experimental-agent-team": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",

+ 5 - 4
packages/experimental/client-ui-agent-team/src/client/mount.ts

@@ -12,6 +12,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
 import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
 import {
   TeamAction, type TeamActionInjected, type TeamActionResult, type TeamTaskActionResult,
@@ -26,7 +27,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 }
 
 /** Required browser services for RPC, navigation, slots, and localized copy. */
-export const inject = ['sessions', 'remote', 'slots', 'locale']
+export const inject = ['sessions', 'uiWorkspace', 'remote', 'slots', 'locale']
 
 function registerUi(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'client-ui-agent-team: dictionaries')
@@ -54,8 +55,8 @@ function registerUi(ctx: ClientContext): void {
       if (member.role !== 'teammate') return
       const parentSessionId = leadSessionId(sessionId)
       await sessions.refreshSubagents(parentSessionId)
-      if (sessions.list.getSnapshot().current !== sessionId) return
-      sessions.openSubagent({
+      if ((sessions.retainInfo(sessionId).getSnapshot().retainedBy.mainView ?? 0) === 0) return
+      ctx.uiWorkspace.openSession({
         parentSessionId,
         childSessionId: member.id,
         mode: 'continuable',
@@ -86,7 +87,7 @@ export async function mountAgentTeamUi(
   contribution: TypertRemoteContribution,
 ): Promise<() => Promise<void>> {
   const disposeRemote = await ctx.remote.$mount(contribution)
-  const ui = ctx.inject(['sessions', 'remote.agentTeams', 'slots', 'locale'], registerUi)
+  const ui = ctx.inject(['sessions', 'uiWorkspace', 'remote.agentTeams', 'slots', 'locale'], registerUi)
   try {
     await ui
   } catch (error) {

+ 1 - 0
packages/experimental/client-ui-agent-team/tsconfig.json

@@ -16,6 +16,7 @@
     { "path": "../../client/ui-renderer" },
     { "path": "../../client/ui-session" },
     { "path": "../../client/ui-slots" },
+    { "path": "../../client/ui-workspace" },
     { "path": "../../core/session" },
     { "path": "../../typert/protocol" }
   ]

+ 2 - 1
packages/extensions/ui-cordis/src/client/CordisPanel.tsx

@@ -113,7 +113,8 @@ export function CordisPanel({
   const errors = useRunErrors(snapshot => snapshot)
   const loaded = useLoaded(snapshot => snapshot)
   const renderFailures = useRenderFailures(snapshot => snapshot)
-  const current = useSessions(state => state.current)
+  const current = useSessions(state => Object.values(state.byId)
+    .find(session => (session.retainedBy.mainView ?? 0) > 0)?.id)
   const [open, setOpen] = useState(false)
   const [selected, setSelected] = useState<Record<string, CordisDynamicPackageId>>({})
   const [pending, setPending] = useState<ReadonlySet<CordisDynamicPluginId>>(new Set())

+ 33 - 0
pnpm-lock.yaml

@@ -2546,6 +2546,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-util-values':
+        specifier: workspace:^
+        version: link:../../util/values
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -2832,6 +2835,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-util-values':
+        specifier: workspace:^
+        version: link:../../util/values
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -2932,6 +2938,9 @@ importers:
       '@deepseek-ai/dsh-util-crypto':
         specifier: workspace:^
         version: link:../../util/crypto
+      '@deepseek-ai/dsh-util-values':
+        specifier: workspace:^
+        version: link:../../util/values
       '@deepseek-ai/dsh-util-workspace-path':
         specifier: workspace:^
         version: link:../../util/workspace-path
@@ -3266,6 +3275,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-util-values':
+        specifier: workspace:^
+        version: link:../../util/values
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -3464,6 +3476,9 @@ importers:
       '@deepseek-ai/dsh-typert-protocol':
         specifier: workspace:^
         version: link:../../typert/protocol
+      '@deepseek-ai/dsh-util-values':
+        specifier: workspace:^
+        version: link:../../util/values
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -3896,6 +3911,9 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-api-remotes':
+        specifier: workspace:^
+        version: link:../../api/remotes
       '@deepseek-ai/dsh-api-session-controller':
         specifier: workspace:^
         version: link:../../api/session-controller
@@ -3911,6 +3929,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-util-values':
+        specifier: workspace:^
+        version: link:../../util/values
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -4632,6 +4653,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-client-ui-workspace':
+        specifier: workspace:^
+        version: link:../ui-workspace
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
@@ -4960,6 +4984,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-client-ui-workspace':
+        specifier: workspace:^
+        version: link:../ui-workspace
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
@@ -5029,6 +5056,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-subagent':
+        specifier: workspace:^
+        version: link:../../subagent/subagent
       '@deepseek-ai/dsh-typert-protocol':
         specifier: workspace:^
         version: link:../../typert/protocol
@@ -6360,6 +6390,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../../client/ui-slots
+      '@deepseek-ai/dsh-client-ui-workspace':
+        specifier: workspace:^
+        version: link:../../client/ui-workspace
       '@deepseek-ai/dsh-experimental-agent-team':
         specifier: workspace:^
         version: link:../agent-team