Selaa lähdekoodia

refactor(ui): add Session and Workspace React adapters

imccyu 1 kuukausi sitten
vanhempi
sitoutus
d231c8777a
45 muutettua tiedostoa jossa 2566 lisäystä ja 1924 poistoa
  1. 0 94
      packages/client/runtime/src/client/contract/workspaces.ts
  2. 0 190
      packages/client/runtime/src/client/sessions/provide.ts
  3. 0 362
      packages/client/runtime/src/client/workspaces/service.ts
  4. 0 429
      packages/client/runtime/tests/workspaces-service.client.spec.ts
  5. 13 5
      packages/client/ui-agent-preset/package.json
  6. 8 3
      packages/client/ui-agent-preset/src/client/index.ts
  7. 25 25
      packages/client/ui-agent-preset/tests/apply.client.spec.ts
  8. 13 1
      packages/client/ui-agent-preset/tsconfig.json
  9. 27 24
      packages/client/ui-conversation/package.json
  10. 80 249
      packages/client/ui-conversation/src/client/apply.ts
  11. 107 316
      packages/client/ui-conversation/tests/apply-inject.client.spec.tsx
  12. 98 0
      packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx
  13. 15 35
      packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx
  14. 76 0
      packages/client/ui-session/package.json
  15. 503 0
      packages/client/ui-session/src/client/index.ts
  16. 20 0
      packages/client/ui-session/src/client/session-provider.tsx
  17. 4 0
      packages/client/ui-session/src/index.ts
  18. 21 0
      packages/client/ui-session/src/invariant.ts
  19. 469 0
      packages/client/ui-session/tests/ui-session.client.spec.ts
  20. 36 0
      packages/client/ui-session/tsconfig.json
  21. 3 0
      packages/client/ui-session/tsdown.config.ts
  22. 9 3
      packages/client/ui-sidebar/package.json
  23. 8 4
      packages/client/ui-sidebar/src/client/index.ts
  24. 9 11
      packages/client/ui-sidebar/tests/apply.client.spec.tsx
  25. 3 2
      packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx
  26. 7 1
      packages/client/ui-sidebar/tsconfig.json
  27. 21 5
      packages/client/ui-workspace/package.json
  28. 3 3
      packages/client/ui-workspace/src/client/WorkspacePicker.tsx
  29. 3 5
      packages/client/ui-workspace/src/client/contract/slots.ts
  30. 41 24
      packages/client/ui-workspace/src/client/index.ts
  31. 248 0
      packages/client/ui-workspace/src/client/navigation.ts
  32. 1 1
      packages/client/ui-workspace/src/client/rows/Rows.tsx
  33. 0 0
      packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css
  34. 35 25
      packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
  35. 1 1
      packages/client/ui-workspace/src/client/stores.ts
  36. 36 18
      packages/client/ui-workspace/src/client/tree.ts
  37. 40 16
      packages/client/ui-workspace/tests/apply.client.spec.ts
  38. 1 1
      packages/client/ui-workspace/tests/browser-styles.client.spec.ts
  39. 7 12
      packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx
  40. 2 1
      packages/client/ui-workspace/tests/rows.client.spec.tsx
  41. 44 40
      packages/client/ui-workspace/tests/tree.client.spec.ts
  42. 12 10
      packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
  43. 13 7
      packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx
  44. 488 0
      packages/client/ui-workspace/tests/workspaces-service.client.spec.ts
  45. 16 1
      packages/client/ui-workspace/tsconfig.json

+ 0 - 94
packages/client/runtime/src/client/contract/workspaces.ts

@@ -1,94 +0,0 @@
-/**
- * The outward workspaces-service face — what `ctx.workspaces` exposes to
- * feature packages and the renderer host, and therefore exactly what the
- * test runtime's workspaces double must implement. Wire-pump entry points
- * (handleHostEnvelope/handleConnected/refresh/startInitialSelection) stay on
- * the concrete class. Widening this interface is the explicit act of
- * widening what features may do to the workspaces domain.
- */
-import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
-import type { WorkspaceListState } from '../workspaces/service.ts'
-import type { ObservableSnapshot } from './store.ts'
-
-/** The workspaces-service face injected as `ctx.workspaces`. */
-export interface IWorkspaces {
-  /** The useWorkspaces standard feed (read face — writes stay inside the domain). */
-  readonly list: ObservableSnapshot<WorkspaceListState>
-  /**
-   * Connect a Workspace to its reusable or freshly created blank session.
-   * @param workspaceId - target workspace.
-   * @returns the connected session id.
-   */
-  connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
-  /**
-   * The New Session flow: connect the explicit, current-Session, or recent
-   * Workspace and open the resulting session; failures surface on the session
-   * list state.
-   * @param workspaceId - explicit target; omitted inherits the current
-   * Session's Workspace before falling back to the recency projection.
-   */
-  startSession(workspaceId?: WorkspaceId): void
-  /**
-   * Register an existing path as a Workspace.
-   * @param input - the Host create payload.
-   * @returns the created or idempotently resolved Workspace.
-   */
-  create(input: { path: string }): Promise<WorkspaceView>
-  /**
-   * Open the Host's native directory picker.
-   * @returns the selected path, or null when the user cancelled.
-   */
-  pickDirectory(): Promise<string | null>
-  /**
-   * List one directory level through the Host's `browse` capability.
-   * @param path - absolute directory to list; absent lists the Host home directory.
-   * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
-   * @returns the level's listing with breadcrumb ancestry.
-   */
-  listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
-  /**
-   * Create one child directory through the Host's `browse` capability.
-   * @param path - absolute existing parent directory.
-   * @param name - single non-blank path segment.
-   * @returns the created directory's absolute path.
-   */
-  createDirectory(path: string, name: string): Promise<string>
-  /**
-   * Open a filesystem path with the Host operating system's default application.
-   * @param path - absolute or host-resolvable path.
-   */
-  openPath(path: string): Promise<void>
-  /**
-   * Rename a Workspace.
-   * @param workspaceId - target workspace.
-   * @param title - the new display title.
-   * @returns the updated Workspace view.
-   */
-  rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView>
-  /**
-   * Delete a Workspace (its sessions fall back to the unaccounted group).
-   * @param workspaceId - target workspace.
-   */
-  delete(workspaceId: WorkspaceId): Promise<void>
-  /**
-   * Move a Workspace within the registry display order.
-   * @param workspaceId - Workspace to move.
-   * @param beforeWorkspaceId - Anchor workspace; omitted appends.
-   */
-  insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void>
-  /**
-   * Move an accounted session within/into a Workspace's ordered list.
-   * @param workspaceId - target workspace.
-   * @param sessionId - accounted session to move.
-   * @param beforeSessionId - accounted anchor to insert before; omitted appends.
-   * @returns the updated Workspace view.
-   */
-  insertSessionBefore(workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId): Promise<WorkspaceView>
-  /**
-   * Archive a session into the registry-global set (hidden from grouping
-   * surfaces; session log and accounting slot remain). Archiving the current
-   * session clears the selection into the New Session view state.
-   * @param sessionId - session to archive.
-   */
-  archiveSession(sessionId: SessionId): Promise<void>
-}

+ 0 - 190
packages/client/runtime/src/client/sessions/provide.ts

@@ -1,190 +0,0 @@
-/**
- * The session standard-props provide channel: provider roster, bundle
- * materialization (fail-loud on undeclared/missing/duplicate members), the
- * static no-session projection, and the atomic current-session projection
- * observable. One implementation — SessionRuntime drives it from wire
- * truth, the test runtime's sessions double drives it from fixtures — so
- * the materialization rules and the projection semantics cannot drift
- * between production and the test bench.
- */
-import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SessionBinding, SessionProvideDescriptor } from './service.ts'
-
-/** The owner-side hooks: how the channel reaches the owner's live bundles and current selection. */
-export interface SessionProvideChannelHost {
-  /**
-   * Re-materialize every already-materialized bundle against the new roster
-   * (call {@link SessionProvideChannel.materializeInfo} per live binding).
-   * Lazily-materialized sessions pick the new roster up on first resolve.
-   */
-  rebuildBundles(): void
-  /** Resolve the current selection's bundle (the owner's maybe-provide lookup). */
-  resolveCurrent(): SessionMaybeProvideInfo
-}
-
-/**
- * Provider roster + materialization + current projection. The channel owns
- * every rule a provider contribution must satisfy; owners keep only their
- * per-session bundle storage and the definition of "current".
- */
-export class SessionProvideChannel {
-  private readonly providers: SessionProvideDescriptor[] = []
-  private maybeInfoCache: SessionMaybeProvideInfo
-  /** Latest published current bundle (identity comparison dedupes republish). */
-  private currentSnapshot: SessionMaybeProvideInfo
-  /** Projection subscribers (plain cell: bundles hold live session sources, so no store freeze may touch them). */
-  private readonly listeners = new Set<() => void>()
-
-  /**
-   * Atomic current-session provide projection: selection changes and
-   * provider-roster changes publish through this one source, so a roster
-   * change under a stable current id republishes the bundle instead of
-   * stranding mounted entries.
-   */
-  readonly currentProvideInfo: HostObservable<SessionMaybeProvideInfo>
-
-  /**
-   * @param host - owner-side bundle storage and current-selection resolution.
-   */
-  constructor(private readonly host: SessionProvideChannelHost) {
-    // The runtime's own contribution comes first: useSession rides the same
-    // provide channel every plugin uses (no renderer special case).
-    this.providers.push({
-      hooks: ['session'],
-      resolve: binding => ({ hooks: { session: binding.session } }),
-    })
-    this.maybeInfoCache = this.materializeMaybeInfo()
-    this.currentSnapshot = this.maybeInfoCache
-    this.currentProvideInfo = {
-      getSnapshot: () => this.currentSnapshot,
-      subscribe: (fn) => {
-        this.listeners.add(fn)
-        return () => { this.listeners.delete(fn) }
-      },
-    }
-  }
-
-  /** The static no-session projection under the current roster (declared names present, values undefined). */
-  get maybeInfo(): SessionMaybeProvideInfo {
-    return this.maybeInfoCache
-  }
-
-  /**
-   * Register a per-session standard-props provider (see
-   * SessionRuntime.provide for the product contract). Live bundles rebuild
-   * immediately; misdeclared providers fail loud here, at the registration
-   * edge, and the registration rolls back — the channel never stays on a
-   * roster it cannot materialize.
-   * @param descriptor - static member roster plus per-session resolver.
-   * @returns disposer removing the provider.
-   */
-  provide(descriptor: SessionProvideDescriptor): () => void {
-    this.providers.push(descriptor)
-    try {
-      this.applyRosterChange()
-    } catch (error) {
-      this.providers.splice(this.providers.indexOf(descriptor), 1)
-      // Restore the previous (valid) roster's bundles; cannot rethrow — the
-      // pre-push roster materialized successfully before.
-      this.applyRosterChange()
-      throw error
-    }
-    return () => {
-      const at = this.providers.indexOf(descriptor)
-      if (at >= 0) this.providers.splice(at, 1)
-      this.applyRosterChange()
-    }
-  }
-
-  /**
-   * Re-derive the current selection's bundle and publish it when it changed.
-   * Bundles are identity-stable per (scope, roster) materialization, so an
-   * identity compare is exact; synchronous notify — call sites (the owner's
-   * list subscription, provide()) already sit behind their own batching or
-   * registration edges.
-   */
-  publishCurrent(): void {
-    const next = this.host.resolveCurrent()
-    if (next === this.currentSnapshot) return
-    this.currentSnapshot = next
-    for (const fn of [...this.listeners]) {
-      try {
-        fn()
-      } catch (error) {
-        // Contain subscriber failures: this notify runs inside the list
-        // notification, where a throwing render-side subscriber would starve
-        // later listeners and abort the projection pass that scheduled it.
-        console.error('sessions.currentProvideInfo subscriber failed:', error)
-      }
-    }
-  }
-
-  /**
-   * Materialize the standard-props bundle for one session (fails loud on
-   * undeclared, missing, and duplicate member names).
-   * @param binding - session assembly handle fed to every resolver.
-   * @returns the materialized bundle (identity-stable until the next materialization).
-   */
-  materializeInfo(binding: SessionBinding): SessionProvideInfo {
-    const hooks: Record<string, HostObservable<unknown>> = {}
-    const props: Record<string, unknown> = {}
-    for (const descriptor of this.providers) {
-      const contribution = descriptor.resolve(binding)
-      const contributedHooks = contribution.hooks ?? {}
-      const contributedProps = contribution.props ?? {}
-      for (const name of Object.keys(contributedHooks)) {
-        if (!(descriptor.hooks ?? []).includes(name)) {
-          throw new Error(`sessions.provide: undeclared hook "${name}"`)
-        }
-      }
-      for (const name of Object.keys(contributedProps)) {
-        if (!(descriptor.props ?? []).includes(name)) {
-          throw new Error(`sessions.provide: undeclared prop "${name}"`)
-        }
-      }
-      for (const name of descriptor.hooks ?? []) {
-        const source = contributedHooks[name]
-        if (source === undefined) throw new Error(`sessions.provide: missing hook "${name}"`)
-        if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
-        hooks[name] = source
-      }
-      for (const name of descriptor.props ?? []) {
-        if (!Object.hasOwn(contributedProps, name)) throw new Error(`sessions.provide: missing prop "${name}"`)
-        if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
-        props[name] = contributedProps[name]
-      }
-    }
-    return {
-      sessionId: binding.sessionId,
-      hooks,
-      props,
-      // The useProjection seat: key-addressed bare value faces off the
-      // session's projection store (open key space — never a static roster member).
-      projections: { faceOf: key => binding.session.projections.faceOf(key) },
-    }
-  }
-
-  /** Rebuild the static projection and the owner's live bundles, then republish the current one. */
-  private applyRosterChange(): void {
-    this.maybeInfoCache = this.materializeMaybeInfo()
-    this.host.rebuildBundles()
-    this.publishCurrent()
-  }
-
-  /** Build the static no-session kit and reject duplicate declared names. */
-  private materializeMaybeInfo(): SessionMaybeProvideInfo {
-    const hooks: Record<string, undefined> = {}
-    const props: Record<string, undefined> = {}
-    for (const descriptor of this.providers) {
-      for (const name of descriptor.hooks ?? []) {
-        if (Object.hasOwn(hooks, name)) throw new Error(`sessions.provide: duplicate hook "${name}"`)
-        hooks[name] = undefined
-      }
-      for (const name of descriptor.props ?? []) {
-        if (Object.hasOwn(props, name)) throw new Error(`sessions.provide: duplicate prop "${name}"`)
-        props[name] = undefined
-      }
-    }
-    return { sessionId: undefined, hooks, props } // no projections face: every key reads absent without a session
-  }
-}

+ 0 - 362
packages/client/runtime/src/client/workspaces/service.ts

@@ -1,362 +0,0 @@
-/** WorkspaceRuntime combines controller-owned Workspace state with Session/UI behavior. */
-
-import type { Context } from '@deepseek-ai/cordis'
-import type {
-  DirectoryListing, IApiClient, RpcError,
-  SessionId, WorkspaceId, WorkspaceView,
-} from '@deepseek-ai/dsh-api-remotes/client'
-import type {
-  ClientWorkspaceModel, WorkspaceListPhase,
-} from '@deepseek-ai/dsh-api-workspace-controller/client'
-import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
-import type { SnapshotStore } from '../contract/store.ts'
-import { createSnapshotStore } from '../contract/store.ts'
-import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
-import type { IWorkspaces } from '../contract/workspaces.ts'
-
-/** Workspace list plus the two-baseline readiness and default-target projection. */
-export interface WorkspaceListState {
-  items: readonly WorkspaceView[]
-  /**
-   * Registry-global archive set in Host order: grouping surfaces hide these
-   * sessions everywhere (workspace groups and the ungrouped bucket) while
-   * their session logs and workspace accounting slots remain. A plain array
-   * (store-engine vocabulary; immer drafts reject Sets) — membership lookups
-   * build their own transient Set.
-   */
-  archivedSessionIds: readonly SessionId[]
-  state: 'idle' | 'loading' | 'error'
-  phase: WorkspaceListPhase
-  error: RemoteFailure | null
-  /** True only after both Workspace and Session stream baselines have arrived. */
-  baselinesReady: boolean
-  /** Most recently active Workspace, derived without changing `items` order. */
-  recentWorkspaceId: WorkspaceId | undefined
-}
-
-/** Structured create failure for UI flows that distinguish Host business errors. */
-export class WorkspaceCreateError extends Error {
-  constructor(readonly rpcError: RemoteFailure) {
-    super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`)
-    this.name = 'WorkspaceCreateError'
-  }
-}
-
-/** Structured browse failure so the directory browser can branch on Host business codes. */
-export class DirectoryBrowseError extends Error {
-  constructor(readonly rpcError: RpcError) {
-    super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
-    this.name = 'DirectoryBrowseError'
-  }
-}
-
-/** Real Workspace object layer and Host actions. */
-export class WorkspaceRuntime implements IWorkspaces {
-  /** UI-facing projection derived from the controller model and Session list. */
-  readonly list: SnapshotStore<WorkspaceListState>
-  /** In-flight blank-session creates keyed by workspace (connectWorkspace coalescing). */
-  private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
-  /** Guards the runtime-owned one-shot initial-selection subscription. */
-  private initialSelectionStarted = false
-
-  /**
-   * @param ctx - client root context.
-   * @param api - shared wire client.
-   * @param model - Workspace Controller's Client state model.
-   * @param sessions - cross-domain sessions face used for recency and blank-session reuse.
-   */
-  constructor(
-    ctx: Context,
-    private readonly api: IApiClient,
-    private readonly model: ClientWorkspaceModel,
-    private readonly sessions: SessionsPort,
-  ) {
-    this.list = createSnapshotStore<WorkspaceListState>({
-      items: [], archivedSessionIds: [], state: 'loading', phase: 'pending', error: null,
-      baselinesReady: false, recentWorkspaceId: undefined,
-    })
-    this.model.subscribe(() => { this.project() })
-    this.sessions.list.subscribe(() => { this.project() })
-    ctx.reflect.provide('workspaces', this, undefined)
-  }
-
-  /**
-   * Resolve the session a New Session flow lands in once this Workspace is
-   * chosen: reuse the workspace's existing blank session when one is in the
-   * list mirror, else create a fresh one on the host (`session.create` births
-   * the full Session+Agent — the client holds no intermediate state). The
-   * caller owns navigation: take the returned id to `sessions.open`.
-   * Resolution guarantee (both arms): the returned id is already in the list
-   * store and `sessions.binding(id)` resolves synchronously — draft hand-off
-   * may write the new scope's machine before opening.
-   * @param workspaceId - chosen Workspace (must be in the workspace list).
-   * @returns the reused or newly created session id.
-   */
-  async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
-    const workspace = this.list.getSnapshot().items.find(item => item.workspaceId === workspaceId)
-    if (workspace === undefined) throw new Error(`workspaces.connectWorkspace: unknown workspace ${workspaceId}`)
-    // Coalesce concurrent connects: a create's summary lands without cwd
-    // until the host frame arrives, so a second call inside that window
-    // would miss the reuse scan and mint another hidden blank session.
-    const inflight = this.connecting.get(workspaceId)
-    if (inflight !== undefined) return inflight
-    // Reuse requires workspace membership (id in sessionIds AND same
-    // canonical cwd — the host's own membership rule), never cwd alone:
-    // a cwd match can belong to no account (sessions the CLI/TUI birthed at
-    // the host cwd, or a deleted/recreated registration) and reusing it
-    // would open a session no grouping surface shows under this workspace.
-    // An archived blank is never reused either: reuse would open a session
-    // no grouping surface can show, so New Session mints a fresh one instead.
-    const archived = this.list.getSnapshot().archivedSessionIds
-    const sessions = this.sessions.list.getSnapshot()
-    for (const id of sessions.ids) {
-      const summary = sessions.byId[id]
-      if (summary !== undefined && summary.blank && summary.cwd === workspace.path
-        && workspace.sessionIds.includes(summary.id)
-        && !archived.includes(summary.id)) return summary.id
-    }
-    const attempt = this.sessions.create({ workspaceId })
-      .finally(() => { this.connecting.delete(workspaceId) })
-    this.connecting.set(workspaceId, attempt)
-    return attempt
-  }
-
-  /**
-   * Follow the first complete Workspace/Session baseline and select a default
-   * session exactly once. A restored current session wins; otherwise the most
-   * recent Workspace is connected (reusing or creating its blank session).
-   * Later explicit clears stay cleared instead of retriggering this startup
-   * policy. A failed connect may retry on the next baseline projection.
-   * @returns disposer for the baseline subscription; late work cannot navigate after disposal.
-   */
-  startInitialSelection(): () => void {
-    if (this.initialSelectionStarted) {
-      throw new Error('workspaces.startInitialSelection: already started')
-    }
-    this.initialSelectionStarted = true
-    let state: 'waiting' | 'connecting' | 'done' = 'waiting'
-    let disposed = false
-    const reconcile = (): void => {
-      if (disposed || state !== 'waiting') return
-      const workspace = this.list.getSnapshot()
-      if (!workspace.baselinesReady) return
-      const current = this.sessions.list.getSnapshot().current
-      const target = workspace.recentWorkspaceId
-      if (current !== undefined || target === undefined) {
-        state = 'done'
-        return
-      }
-      state = 'connecting'
-      void this.connectWorkspace(target).then(
-        (sessionId) => {
-          if (disposed) return
-          if (this.sessions.list.getSnapshot().current === undefined) {
-            this.sessions.open(sessionId)
-          }
-          state = 'done'
-        },
-        (reason: unknown) => {
-          if (disposed) return
-          state = 'waiting'
-          console.warn('initial workspace selection failed:', reason)
-        },
-      )
-    }
-    const unsubscribe = this.list.subscribe(reconcile)
-    reconcile()
-    return () => {
-      disposed = true
-      unsubscribe()
-    }
-  }
-
-  /**
-   * The shared New Session action behind the shell entry points (sidebar
-   * button, workspace browser): resolve the target Workspace — explicit wins,
-   * then the current Session's Workspace, then the recent-Workspace
-   * projection — connect its blank session and navigate there; with no
-   * Workspace at all, clear the selection into the New Session view state.
-   * Connect failures are non-fatal (console diagnostics; the current view
-   * stays usable).
-   * @param workspaceId - explicit target Workspace for scoped actions.
-   */
-  startSession(workspaceId?: WorkspaceId): void {
-    const workspace = this.list.getSnapshot()
-    const current = this.sessions.list.getSnapshot().current
-    const currentWorkspaceId = current === undefined
-      ? undefined
-      : workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId
-    const target = workspaceId ?? currentWorkspaceId ?? workspace.recentWorkspaceId
-    if (target === undefined) {
-      this.sessions.clear()
-      return
-    }
-    void this.connectWorkspace(target).then(
-      (sessionId) => { this.sessions.open(sessionId) },
-      (reason: unknown) => { console.warn('new session failed:', reason) },
-    )
-  }
-
-  /**
-   * Register an existing path as a Workspace.
-   * @param input - the Host create payload.
-   * @returns the created or idempotently resolved Workspace.
-   */
-  async create(input: { path: string }): Promise<WorkspaceView> {
-    const result = await this.model.create(input)
-    if (!result.ok) throw new WorkspaceCreateError(result.error)
-    return result.value.workspace
-  }
-
-  /**
-   * Open the Host's native directory picker (the `native` capability).
-   * @returns the selected path, or null when the user cancelled.
-   */
-  async pickDirectory(): Promise<string | null> {
-    const response = await this.api.host.pickDirectory({})
-    if (!response.result.ok) {
-      throw new Error(`directory picker failed: ${response.result.error.message}`)
-    }
-    return response.result.value.path
-  }
-
-  /**
-   * List one directory level through the Host's `browse` capability.
-   * @param path - absolute directory to list; absent lists the Host home directory.
-   * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
-   * @returns the level's listing with breadcrumb ancestry.
-   */
-  async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
-    const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
-    if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
-    return response.result.value
-  }
-
-  /**
-   * Create one child directory through the Host's `browse` capability.
-   * @param path - absolute existing parent directory.
-   * @param name - single non-blank path segment.
-   * @returns the created directory's absolute path.
-   */
-  async createDirectory(path: string, name: string): Promise<string> {
-    const response = await this.api.host.createDirectory({ path, name })
-    if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
-    return response.result.value.path
-  }
-
-  /**
-   * Open a filesystem path with the Host operating system's default application.
-   * @param path - absolute or host-resolvable path.
-   */
-  async openPath(path: string): Promise<void> {
-    const response = await this.api.host.openPath({ path })
-    if (!response.result.ok) {
-      throw new Error(`path open failed: ${response.result.error.message}`)
-    }
-  }
-
-  /**
-   * Rename a Workspace.
-   * @param workspaceId - target workspace.
-   * @param title - new display title (trimmed non-empty by the Host).
-   * @returns the renamed Workspace view.
-   */
-  async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
-    const result = await this.model.rename(workspaceId, title)
-    if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`)
-    return result.value.workspace
-  }
-
-  /**
-   * Delete one Workspace registration. Sessions, session logs, and the
-   * directory remain Host-owned outside this operation.
-   * @param workspaceId - target workspace.
-   */
-  async delete(workspaceId: WorkspaceId): Promise<void> {
-    const result = await this.model.delete(workspaceId)
-    if (!result.ok) throw new Error(`workspace delete failed: ${result.error.code}: ${result.error.message}`)
-  }
-
-  /**
-   * Move a Workspace within the durable registry display order.
-   * @param workspaceId - Workspace to move.
-   * @param beforeWorkspaceId - Anchor workspace; omitted appends.
-   */
-  async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise<void> {
-    const result = await this.model.insertBefore(workspaceId, beforeWorkspaceId)
-    if (!result.ok) throw new Error(`workspace reorder failed: ${result.error.code}: ${result.error.message}`)
-  }
-
-  /**
-   * Archive a session into the registry-global set. Clearing an archived
-   * current selection is the projection sweep's job (one rule for the local
-   * echo and a remote tab's frame alike).
-   * @param sessionId - session to archive.
-   */
-  async archiveSession(sessionId: SessionId): Promise<void> {
-    const result = await this.model.archiveSession(sessionId)
-    if (!result.ok) throw new Error(`session archive failed: ${result.error.code}: ${result.error.message}`)
-  }
-
-  /**
-   * Move a session within its Workspace's manual order (DOM-insertBefore-like).
-   * @param workspaceId - owning workspace.
-   * @param sessionId - accounted session to move.
-   * @param beforeSessionId - accounted anchor to insert before; omitted appends.
-   * @returns the updated Workspace view.
-   */
-  async insertSessionBefore(
-    workspaceId: WorkspaceId,
-    sessionId: SessionId,
-    beforeSessionId?: SessionId,
-  ): Promise<WorkspaceView> {
-    const result = await this.model.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
-    if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`)
-    return result.value.workspace
-  }
-
-  private project(): void {
-    const workspace = this.model.getSnapshot()
-    const sessions = this.sessions.list.getSnapshot()
-    const baselinesReady = workspace.phase === 'ready' && sessions.phase === 'ready'
-    // An archived current selection clears into the New Session view state —
-    // a hidden row must not stay open behind the list. Sweeping here covers
-    // every install path with one rule: the local unary echo, another tab's
-    // changed frame, and a reconnect baseline restoring a persisted
-    // selection that was archived while this client was away.
-    if (sessions.current !== undefined && workspace.archivedSessionIds.includes(sessions.current)) {
-      this.sessions.clear()
-    }
-    this.list.set({
-      items: workspace.items,
-      archivedSessionIds: workspace.archivedSessionIds,
-      state: workspace.state,
-      phase: workspace.phase,
-      error: workspace.error,
-      baselinesReady,
-      recentWorkspaceId: baselinesReady ? recentWorkspace(workspace.items, sessions.byId) : undefined,
-    })
-  }
-}
-
-/** Stable tie-breaking follows Host Workspace order. */
-function recentWorkspace(
-  workspaces: readonly WorkspaceView[],
-  sessions: SessionsPortList['byId'],
-): WorkspaceId | undefined {
-  let selected: WorkspaceId | undefined
-  let selectedTime = Number.NEGATIVE_INFINITY
-  for (const workspace of workspaces) {
-    let latest = Number.NEGATIVE_INFINITY
-    for (const sessionId of workspace.sessionIds) {
-      const session = sessions[sessionId]
-      if (session !== undefined) latest = Math.max(latest, session.updatedAt)
-    }
-    if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt)
-    if (selected === undefined || latest > selectedTime) {
-      selected = workspace.workspaceId
-      selectedTime = latest
-    }
-  }
-  return selected
-}

+ 0 - 429
packages/client/runtime/tests/workspaces-service.client.spec.ts

@@ -1,429 +0,0 @@
-import { Context } from '@deepseek-ai/cordis'
-import { describe, expect, it, vi } from 'vitest'
-import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-remotes/client'
-import { ClientWorkspaceModel } from '@deepseek-ai/dsh-api-workspace-controller/client'
-import { SessionRuntime } from '../src/client/sessions/service.ts'
-import { DirectoryBrowseError, WorkspaceCreateError, WorkspaceRuntime } from '../src/client/workspaces/service.ts'
-import {
-  FakeApiClient, err, fakeRemote, ok, remoteOk, workspaceErr,
-} from './fake-api.client.ts'
-
-const sid = (id: string): SessionId => id as SessionId
-const wid = (id: string): WorkspaceId => id as WorkspaceId
-
-function workspace(id: string, sessionIds: SessionId[] = [], createdAt = '2026-01-01T00:00:00.000Z'): WorkspaceView {
-  return {
-    workspaceId: wid(id), path: `/w/${id}`, title: id, sessionIds,
-    createdAt, updatedAt: createdAt,
-  }
-}
-
-const runtimeModels = new WeakMap<WorkspaceRuntime, ClientWorkspaceModel>()
-
-function runtimeFor(
-  ctx: Context,
-  api: FakeApiClient,
-  sessions: SessionRuntime,
-): WorkspaceRuntime {
-  const model = new ClientWorkspaceModel(fakeRemote(api).workspace)
-  const runtime = new WorkspaceRuntime(ctx, api, model, sessions)
-  runtimeModels.set(runtime, model)
-  return runtime
-}
-
-function baseline(
-  target: WorkspaceRuntime,
-  items: readonly WorkspaceView[] = [],
-  archivedSessionIds: readonly SessionId[] = [],
-): void {
-  modelOf(target).replaceBaseline({ items, archivedSessionIds })
-}
-
-function modelOf(runtime: WorkspaceRuntime): ClientWorkspaceModel {
-  const model = runtimeModels.get(runtime)
-  if (model === undefined) throw new Error('WorkspaceRuntime test model missing')
-  return model
-}
-
-async function flush(): Promise<void> {
-  await Promise.resolve()
-  await Promise.resolve()
-}
-
-describe('WorkspaceRuntime', () => {
-  it('feeds readiness and recent-Workspace targeting without changing Host order', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
-    const workspaces = runtimeFor(ctx, api, sessions)
-    baseline(workspaces, [
-      workspace('stable-first', [], '2026-01-03T00:00:00.000Z'),
-      workspace('active', [sid('s-active')], '2026-01-01T00:00:00.000Z'),
-    ])
-    await flush()
-    expect(workspaces.list.getSnapshot()).toMatchObject({ baselinesReady: false, recentWorkspaceId: undefined })
-
-    api.onList = () => Promise.resolve(ok({
-      items: [{ sessionId: sid('s-active'), updatedAt: Date.parse('2026-02-01'), running: false, blank: false }] as never[],
-    }))
-    await sessions.refresh()
-    await Promise.resolve()
-    await Promise.resolve()
-    expect(workspaces.list.getSnapshot()).toMatchObject({
-      baselinesReady: true,
-      recentWorkspaceId: 'active',
-    })
-    expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['stable-first', 'active'])
-  })
-
-  it('connectWorkspace reuses the workspace-member blank session and creates otherwise', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
-    const workspaces = runtimeFor(ctx, api, sessions)
-    baseline(workspaces, [
-      workspace('alpha', [sid('s-blank')]), workspace('beta'), workspace('gamma'),
-    ])
-    api.onList = () => Promise.resolve(ok({
-      items: [
-        // Stray blank at alpha's path but NOT accounted under alpha (a CLI
-        // session birthed at the host cwd), sorted before the member blank:
-        // the scan must skip it and keep looking for a member hit.
-        { sessionId: sid('s-stray-alpha'), updatedAt: 1, running: false, blank: true, cwd: '/w/alpha' },
-        // Blank session parked in alpha (cwd == workspace path canon AND
-        // accounted under alpha): the reuse hit.
-        { sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' },
-        // Non-blank sibling in beta must never be reused.
-        { sessionId: sid('s-active'), updatedAt: 3, running: false, blank: false, cwd: '/w/beta' },
-        // Stray blank at gamma's path but NOT accounted under gamma (a CLI
-        // session birthed at the host cwd): cwd alone must not hijack it —
-        // reuse would open a session gamma cannot show, so New Session mints
-        // a fresh accounted one instead.
-        { sessionId: sid('s-stray'), updatedAt: 4, running: false, blank: true, cwd: '/w/gamma' },
-      ] as never[],
-    }))
-    await sessions.refresh()
-    await flush()
-
-    // Hit: same workspace → the parked member blank comes back (the earlier
-    // cwd-matching non-member stray is skipped), no create RPC.
-    await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
-    expect(api.callsOf('session.create')).toEqual([])
-    // Resolution guarantee: the id is binding-resolvable synchronously.
-    expect(sessions.binding(sid('s-blank'))).toBeDefined()
-
-    // Miss: beta has only a non-blank session → host create with workspaceId.
-    api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh') }))
-    await expect(workspaces.connectWorkspace(wid('beta'))).resolves.toBe('s-fresh')
-    expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }])
-    // Same guarantee on the create arm (draft hand-off writes the machine pre-open).
-    expect(sessions.binding(sid('s-fresh'))).toBeDefined()
-
-    // Miss: the stray blank matches gamma's path but is not a gamma member →
-    // never reused, a fresh accounted session is created instead.
-    api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-3') }))
-    await expect(workspaces.connectWorkspace(wid('gamma'))).resolves.toBe('s-fresh-3')
-    expect(api.callsOf('session.create')).toEqual([{ workspaceId: 'beta' }, { workspaceId: 'gamma' }])
-
-    // Unknown workspace fails loud instead of silently creating in nowhere.
-    await expect(workspaces.connectWorkspace(wid('ghost'))).rejects.toThrow(/unknown workspace ghost/)
-
-    // An archived blank is never reused: no surface can show it, so New
-    // Session mints a fresh one for alpha instead.
-    await workspaces.archiveSession(sid('s-blank'))
-    api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-fresh-2') }))
-    await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-fresh-2')
-  })
-
-  it('a rejected first prompt keeps the blank session eligible for connectWorkspace reuse', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
-    const workspaces = runtimeFor(ctx, api, sessions)
-    baseline(workspaces, [workspace('alpha', [sid('s-blank')])])
-    api.onList = () => Promise.resolve(ok({
-      items: [{ sessionId: sid('s-blank'), updatedAt: 2, running: false, blank: true, cwd: '/w/alpha' }] as never[],
-    }))
-    await sessions.refresh()
-    await flush()
-    const session = sessions.binding(sid('s-blank'))!.session
-    api.onPrompt = () => Promise.resolve(err({ code: 'internal', message: 'agent busy', details: {} }) as never)
-    await session.prompt([{ type: 'text', text: 'hi' }], 'queue')
-    await Promise.resolve()
-    // Failure leaves blank intact, so the same session is still the reuse hit.
-    await expect(workspaces.connectWorkspace(wid('alpha'))).resolves.toBe('s-blank')
-    expect(api.callsOf('session.create')).toEqual([])
-  })
-
-  it('returns created Workspaces and preserves Host business errors', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
-    const workspaces = runtimeFor(ctx, api, sessions)
-    api.onWorkspaceCreate = () => Promise.resolve(remoteOk({
-      workspace: { ...workspace('picked'), path: '/w/alpha', title: 'alpha' }, created: true,
-    }))
-    await expect(workspaces.create({ path: '/w/alpha' })).resolves.toMatchObject({ workspaceId: 'picked' })
-    expect(workspaces.list.getSnapshot().items[0]).toMatchObject({ path: '/w/alpha', title: 'alpha' })
-    expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/alpha' }])
-    api.onWorkspaceCreate = () => Promise.resolve(workspaceErr({
-      code: 'workspace-invalid-path', message: 'missing', details: { path: '/missing' },
-    }))
-    const rejected = workspaces.create({ path: '/missing' })
-    await expect(rejected).rejects.toThrow(/workspace-invalid-path: missing/)
-    await expect(rejected).rejects.toBeInstanceOf(WorkspaceCreateError)
-  })
-
-  it('passes native directory selection and cancellation through without local state', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
-    const workspaces = runtimeFor(ctx, api, sessions)
-    api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
-    await expect(workspaces.pickDirectory()).resolves.toBe('/w/alpha')
-    api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
-    await expect(workspaces.pickDirectory()).resolves.toBeNull()
-    expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
-    api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} }))
-    await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
-  })
-
-  it('passes listings and creation through the browse wire, wrapping business failures', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const workspaces = runtimeFor(ctx, api, new SessionRuntime(ctx, api, fakeRemote(api)))
-    const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
-    api.onListDirectory = () => Promise.resolve(ok(listing))
-    await expect(workspaces.listDirectory()).resolves.toEqual(listing)
-    await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing)
-    // The optional path is omitted from the payload, not sent as undefined.
-    expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
-    api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } }))
-    const listFailure = workspaces.listDirectory('/x')
-    await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
-    await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
-
-    await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new')
-    expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }])
-    api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } }))
-    await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
-  })
-
-  it('opens a filesystem path through the host without local state', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
-    const workspaces = runtimeFor(ctx, api, sessions)
-    await expect(workspaces.openPath('/w/alpha/a.ts')).resolves.toBeUndefined()
-    expect(api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/a.ts' }])
-    api.onOpenPath = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} }))
-    await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
-  })
-
-  it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
-    const workspaces = runtimeFor(ctx, api, sessions)
-    baseline(workspaces, [workspace('alpha')])
-    await flush()
-    await expect(workspaces.delete(wid('alpha'))).resolves.toBeUndefined()
-    expect(workspaces.list.getSnapshot().items).toEqual([])
-
-    api.onWorkspaceDelete = () => Promise.resolve(workspaceErr({
-      code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('ghost') },
-    }))
-    await expect(workspaces.delete(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
-  })
-
-  it('moves a Workspace through the durable order RPC and surfaces Host rejection', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const workspaces = runtimeFor(ctx, api, new SessionRuntime(ctx, api, fakeRemote(api)))
-    baseline(workspaces, [workspace('one'), workspace('two')])
-    await flush()
-    api.onWorkspaceInsertBefore = () => Promise.resolve(remoteOk({
-      workspaceIds: [wid('two'), wid('one')],
-    }))
-    await expect(workspaces.insertBefore(wid('two'), wid('one'))).resolves.toBeUndefined()
-    expect(api.callsOf('workspace.insertBefore')).toEqual([{
-      workspaceId: 'two', beforeWorkspaceId: 'one',
-    }])
-    expect(workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one'])
-
-    api.onWorkspaceInsertBefore = () => Promise.resolve(workspaceErr({
-      code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('ghost') },
-    }))
-    await expect(workspaces.insertBefore(wid('ghost'))).rejects.toThrow(/workspace-not-found: gone/)
-  })
-
-  it('targets New Session at explicit, current-session, then recent Workspaces and clears with none', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
-    const workspaces = runtimeFor(ctx, api, sessions)
-    baseline(workspaces, [
-      workspace('current-home', [sid('current')]),
-      workspace('recent-home', [sid('recent')]),
-    ])
-    api.onList = () => Promise.resolve(ok({ items: [
-      { sessionId: sid('current'), updatedAt: 1, running: false, blank: false },
-      { sessionId: sid('recent'), updatedAt: 2, running: false, blank: false },
-    ] as never[] }))
-    await sessions.refresh()
-    await flush()
-    sessions.open(sid('current'))
-    const unresolved = new Promise<SessionId>(() => {})
-    const connect = vi.spyOn(workspaces, 'connectWorkspace').mockReturnValue(unresolved)
-
-    workspaces.startSession(wid('recent-home'))
-    await Promise.resolve()
-    expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
-
-    workspaces.startSession()
-    await Promise.resolve()
-    expect(connect).toHaveBeenLastCalledWith(wid('current-home'))
-
-    sessions.clear()
-    workspaces.startSession()
-    await Promise.resolve()
-    expect(connect).toHaveBeenLastCalledWith(wid('recent-home'))
-
-    const emptyCtx = new Context()
-    const emptyApi = new FakeApiClient()
-    const emptySessions = new SessionRuntime(emptyCtx, emptyApi, fakeRemote(emptyApi))
-    const emptyWorkspaces = runtimeFor(emptyCtx, emptyApi, emptySessions)
-    const clear = vi.spyOn(emptySessions, 'clear')
-    emptyWorkspaces.startSession()
-    expect(clear).toHaveBeenCalledOnce()
-  })
-
-  it('archives a session, projects unary and stream state, and clears only the current one', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
-    const workspaces = runtimeFor(ctx, api, sessions)
-    api.onList = () => Promise.resolve(ok({
-      items: [
-        { sessionId: sid('s-open'), updatedAt: 2, running: false, blank: false },
-        { sessionId: sid('s-idle'), updatedAt: 1, running: false, blank: false },
-      ],
-    }) as never)
-    await sessions.refresh()
-    sessions.open(sid('s-open'))
-
-    // Archiving a non-current session installs the unary echo and keeps the selection.
-    await expect(workspaces.archiveSession(sid('s-idle'))).resolves.toBeUndefined()
-    expect(api.callsOf('workspace.archiveSession')).toEqual([{ sessionId: 's-idle' }])
-    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
-    expect(sessions.list.getSnapshot().current).toBe('s-open')
-
-    // Archiving the current session clears it into the New Session view state.
-    api.onWorkspaceArchiveSession = () => Promise.resolve(remoteOk({ archivedSessionIds: [sid('s-idle'), sid('s-open')] }))
-    await workspaces.archiveSession(sid('s-open'))
-    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
-    expect(sessions.list.getSnapshot().current).toBeUndefined()
-
-    // A Host failure leaves the set and the selection untouched.
-    api.onWorkspaceArchiveSession = () => Promise.resolve(workspaceErr({
-      code: 'session-not-found', message: 'no session ghost', details: { sessionId: sid('ghost') },
-    }))
-    await expect(workspaces.archiveSession(sid('ghost'))).rejects.toThrow(/session-not-found/)
-    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle', 's-open'])
-
-    modelOf(workspaces).replaceArchived([sid('s-idle')])
-    await flush()
-    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-idle'])
-    baseline(workspaces, [], [sid('s-open')])
-    await flush()
-    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
-  })
-
-  it('clears a current archived by a stream increment and accepts the next baseline as authoritative', async () => {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
-    const workspaces = runtimeFor(ctx, api, sessions)
-    api.onList = () => Promise.resolve(ok({
-      items: [{ sessionId: sid('s-open'), updatedAt: 1, running: false, blank: false }],
-    }) as never)
-    await sessions.refresh()
-    sessions.open(sid('s-open'))
-
-    modelOf(workspaces).replaceArchived([sid('s-open')])
-    await flush()
-    expect(sessions.list.getSnapshot().current).toBeUndefined()
-    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual(['s-open'])
-    baseline(workspaces)
-    await flush()
-    expect(workspaces.list.getSnapshot().archivedSessionIds).toEqual([])
-  })
-})
-
-describe('startInitialSelection', () => {
-  function bench() {
-    const ctx = new Context()
-    const api = new FakeApiClient()
-    const sessions = new SessionRuntime(ctx, api, fakeRemote(api))
-    const workspaces = runtimeFor(ctx, api, sessions)
-    return { api, sessions, workspaces }
-  }
-
-  it('connects the recent Workspace blank session once baselines are ready and opens it', async () => {
-    const b = bench()
-    const stop = b.workspaces.startInitialSelection()
-    // Nothing happens before both baselines land.
-    expect(b.api.callsOf('session.create')).toHaveLength(0)
-
-    b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-new') }))
-    baseline(b.workspaces, [workspace('recent', [], '2026-01-02T00:00:00.000Z')])
-    await b.sessions.refresh()
-    // Store notifications and the connect round trip are microtask-batched.
-    await new Promise(resolve => setTimeout(resolve, 0))
-    expect(b.api.callsOf('session.create')).toEqual([{ workspaceId: 'recent' }])
-    expect(b.sessions.list.getSnapshot().current).toBe('s-new')
-    stop()
-  })
-
-  it('stays idle when a session is already current or no recent Workspace exists', async () => {
-    const withCurrent = bench()
-    withCurrent.api.onList = () => Promise.resolve(ok({
-      items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] as never[],
-    }))
-    await withCurrent.sessions.refresh()
-    withCurrent.sessions.open(sid('s1'))
-    const stopCurrent = withCurrent.workspaces.startInitialSelection()
-    baseline(withCurrent.workspaces, [workspace('w1', [sid('s1')])])
-    await new Promise(resolve => setTimeout(resolve, 0))
-    expect(withCurrent.api.callsOf('session.create')).toHaveLength(0)
-    stopCurrent()
-
-    const noRecent = bench()
-    const stopEmpty = noRecent.workspaces.startInitialSelection()
-    baseline(noRecent.workspaces)
-    await noRecent.sessions.refresh()
-    await new Promise(resolve => setTimeout(resolve, 0))
-    expect(noRecent.api.callsOf('session.create')).toHaveLength(0)
-    expect(() => noRecent.workspaces.startInitialSelection()).toThrow(/already started/)
-    stopEmpty()
-  })
-
-  it('a failed connect returns to waiting and retries on the next list change', async () => {
-    const b = bench()
-    b.api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'attach exploded', details: {} }))
-    const stop = b.workspaces.startInitialSelection()
-    baseline(b.workspaces, [workspace('recent', [], '2026-01-02T00:00:00.000Z')])
-    await b.sessions.refresh()
-    await new Promise(resolve => setTimeout(resolve, 0))
-    expect(b.api.callsOf('session.create')).toHaveLength(1)
-    expect(b.sessions.list.getSnapshot().current).toBeUndefined()
-
-    // Recovery: the next Workspace stream change re-runs the reconcile.
-    b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('s-retry') }))
-    modelOf(b.workspaces).upsertView(workspace('recent', [], '2026-01-03T00:00:00.000Z'))
-    await new Promise(resolve => setTimeout(resolve, 0))
-    expect(b.api.callsOf('session.create')).toHaveLength(2)
-    expect(b.sessions.list.getSnapshot().current).toBe('s-retry')
-    stop()
-  })
-})

+ 13 - 5
packages/client/ui-agent-preset/package.json

@@ -32,10 +32,11 @@
   "dsh": {
     "client": {
       "inject": [
+        "@deepseek-ai/dsh-api-session-controller",
         "@deepseek-ai/dsh-client-connection",
         "@deepseek-ai/dsh-client-locale",
-        "@deepseek-ai/dsh-client-runtime",
         "@deepseek-ai/dsh-client-ui-conversation",
+        "@deepseek-ai/dsh-client-ui-session",
         "@deepseek-ai/dsh-client-ui-settings",
         "@deepseek-ai/dsh-api-remotes"
       ],
@@ -50,27 +51,34 @@
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
-    "@deepseek-ai/dsh-invariants": "workspace:^"
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",
-    "react": "^18.2.0"
+    "react": "^18.2.0",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^"
   },
   "files": [
     "lib/index.js",

+ 8 - 3
packages/client/ui-agent-preset/src/client/index.ts

@@ -12,6 +12,8 @@
  */
 
 import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
+// Type-only: pulls the Session Controller service merge (ctx.sessions).
+import type {} from '@deepseek-ai/dsh-api-session-controller/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 // Type-only: pulls the ctx.remote merge and the forwarded-event key face
@@ -19,7 +21,10 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
 // Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
 import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
-import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+// Type-only: pulls the Session UI navigation service merge (ctx.uiSession).
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import { AgentPresetLabel } from './AgentPresetLabel.tsx'
 import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx'
 import { AgentPresetRow } from './AgentPresetRow.tsx'
@@ -99,7 +104,7 @@ export function apply(ctx: ClientContext): void {
 
   // 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', 'workspaces'], (scope: ClientContext) => {
+  ctx.inject(['slots', 'conversation', 'sessions', 'uiSession'], (scope: ClientContext) => {
     const api = (scope.get('connection') as ConnectionHandle).api
     const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => {
       const state = scope.sessions.list.getSnapshot()
@@ -160,7 +165,7 @@ export function apply(ctx: ClientContext): void {
         // The introduce cue makes the chip announce the pick the user never
         // made on this screen — the stage happened back in settings.
         seat.stage('cordis', true)
-        scope.workspaces.startSession()
+        scope.uiSession.startSession()
       }
       const chip = scope.slots.register({
         name: 'conversation.hero.agentPreset',

+ 25 - 25
packages/client/ui-agent-preset/tests/apply.client.spec.ts

@@ -8,7 +8,7 @@
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
-import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
+import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime'
 import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/dsh-client-ui-settings/client'
@@ -143,8 +143,8 @@ function declareConversation(slots: SlotRegistry): () => void {
   } as never, () => null)
 }
 
-/** A workspaces double recording new-session starts. */
-function workspacesDouble() {
+/** A Session UI double recording new-session starts. */
+function uiSessionDouble() {
   const starts: unknown[] = []
   return {
     starts,
@@ -306,8 +306,8 @@ describe('ui-agent-preset apply', () => {
     const conversation = declareConversation(slots)
     ctx.provide('conversation', {} as never)
     ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
-    ctx.provide('workspaces', workspacesDouble() as never)
-    const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply })
+    ctx.provide('uiSession', uiSessionDouble() as never)
+    const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply })
     await fiber.await()
 
     const chip = slots.entries('conversation.hero.agentPreset')[0]!
@@ -328,8 +328,8 @@ describe('ui-agent-preset apply', () => {
     const conversation = declareConversation(slots)
     ctx.provide('conversation', {} as never)
     ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
-    ctx.provide('workspaces', workspacesDouble() as never)
-    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
+    ctx.provide('uiSession', uiSessionDouble() as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
 
     const chip = slots.entries('conversation.hero.agentPreset')[0]!
     const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
@@ -364,8 +364,8 @@ describe('ui-agent-preset apply', () => {
       byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
     }
     ctx.provide('sessions', sessionsDouble(state) as never)
-    ctx.provide('workspaces', workspacesDouble() as never)
-    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
+    ctx.provide('uiSession', uiSessionDouble() as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
 
     remote.emit('agent-preset/selected', ['s1', 'minimal'])
 
@@ -378,8 +378,8 @@ describe('ui-agent-preset apply', () => {
     const conversation = declareConversation(slots)
     ctx.provide('conversation', {} as never)
     ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
-    ctx.provide('workspaces', workspacesDouble() as never)
-    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
+    ctx.provide('uiSession', uiSessionDouble() as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
 
     const chip = slots.entries('conversation.hero.agentPreset')[0]!
     const seat = (chip.inject as unknown as () => AgentPresetSeatInjected)()
@@ -413,8 +413,8 @@ describe('ui-agent-preset apply', () => {
     } = { byId: {} }
     const sessions = sessionsDouble(state)
     ctx.provide('sessions', sessions as never)
-    ctx.provide('workspaces', workspacesDouble() as never)
-    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
+    ctx.provide('uiSession', uiSessionDouble() as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
     const chip = (slots.entries('conversation.hero.agentPreset')[0]!
       .inject as unknown as () => AgentPresetSeatInjected)()
 
@@ -441,8 +441,8 @@ describe('ui-agent-preset apply', () => {
       byId: { s1: { id: 's1', blank: true } },
     })
     ctx.provide('sessions', sessions as never)
-    ctx.provide('workspaces', workspacesDouble() as never)
-    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
+    ctx.provide('uiSession', uiSessionDouble() as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
     const chip = (slots.entries('conversation.hero.agentPreset')[0]!
       .inject as unknown as () => AgentPresetSeatInjected)()
 
@@ -465,8 +465,8 @@ describe('ui-agent-preset apply', () => {
     }
     const sessions = sessionsDouble(state)
     ctx.provide('sessions', sessions as never)
-    ctx.provide('workspaces', workspacesDouble() as never)
-    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
+    ctx.provide('uiSession', uiSessionDouble() as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
     const chip = (slots.entries('conversation.hero.agentPreset')[0]!
       .inject as unknown as () => AgentPresetSeatInjected)()
 
@@ -488,8 +488,8 @@ describe('ui-agent-preset apply', () => {
     declareConversation(slots)
     ctx.provide('conversation', {} as never)
     ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
-    ctx.provide('workspaces', workspacesDouble() as never)
-    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
+    ctx.provide('uiSession', uiSessionDouble() as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
     const label = (slots.entries('conversation.session.header.actions')[0]!
       .inject as unknown as () => AgentPresetLabelInjected)()
     const row = (slots.entries('settings.general.item')[0]!
@@ -509,9 +509,9 @@ describe('ui-agent-preset apply', () => {
     const conversation = declareConversation(slots)
     ctx.provide('conversation', {} as never)
     ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
-    const workspaces = workspacesDouble()
-    ctx.provide('workspaces', workspaces as never)
-    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
+    const uiSession = uiSessionDouble()
+    ctx.provide('uiSession', uiSession as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
     const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
     const seat = (slots.entries('conversation.hero.agentPreset')[0]!
       .inject as unknown as () => AgentPresetSeatInjected)()
@@ -523,7 +523,7 @@ describe('ui-agent-preset apply', () => {
     // new-session flow began.
     expect(section.startCreatorDraft).toBeDefined()
     expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
-    expect(workspaces.starts).toHaveLength(1)
+    expect(uiSession.starts).toHaveLength(1)
 
     // A cross-screen stage carries the introduce cue; the chip acknowledges
     // it once, and a repeat acknowledgement leaves the snapshot untouched.
@@ -547,8 +547,8 @@ describe('ui-agent-preset apply', () => {
     } = { byId: {} }
     const sessions = sessionsDouble(state)
     ctx.provide('sessions', sessions as never)
-    ctx.provide('workspaces', workspacesDouble() as never)
-    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'workspaces'], apply }).await()
+    ctx.provide('uiSession', uiSessionDouble() as never)
+    await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiSession'], apply }).await()
     const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)()
     const seat = (slots.entries('conversation.hero.agentPreset')[0]!
       .inject as unknown as () => AgentPresetSeatInjected)()

+ 13 - 1
packages/client/ui-agent-preset/tsconfig.json

@@ -15,7 +15,7 @@
       "path": "../../../vendor/cordis"
     },
     {
-      "path": "../runtime"
+      "path": "../store"
     },
     {
       "path": "../../test-support/client-runtime"
@@ -26,6 +26,12 @@
     {
       "path": "../ui-primitives"
     },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-session"
+    },
     {
       "path": "../ui-settings"
     },
@@ -37,6 +43,12 @@
     },
     {
       "path": "../../api/remotes/tsconfig.client.json"
+    },
+    {
+      "path": "../../api/session-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../../core/session"
     }
   ]
 }

+ 27 - 24
packages/client/ui-conversation/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-conversation",
-  "description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host",
+  "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation",
   "version": "0.1.1-rc.2",
   "publishConfig": {
     "access": "public"
@@ -31,13 +31,16 @@
   },
   "dsh": {
     "client": {
+      "external": [
+        "@deepseek-ai/dsh-api-session-controller/client"
+      ],
       "inject": [
-        "@deepseek-ai/dsh-client-connection",
+        "@deepseek-ai/dsh-api-session-controller",
         "@deepseek-ai/dsh-client-locale",
-        "@deepseek-ai/dsh-client-runtime",
-        "@deepseek-ai/dsh-client-ui-settings",
-        "@deepseek-ai/dsh-api-remotes",
-        "@deepseek-ai/dsh-client-ui-layout"
+        "@deepseek-ai/dsh-client-ui-layout",
+        "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-session",
+        "@deepseek-ai/dsh-client-ui-settings"
       ],
       "platform": "web"
     }
@@ -48,64 +51,64 @@
   },
   "license": "MIT",
   "dependencies": {
-    "@deepseek-ai/schemastery": "workspace:^",
-    "clsx": "^2.0.0"
+    "clsx": "^2.0.0",
+    "@deepseek-ai/schemastery": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
-    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
-    "@deepseek-ai/dsh-compaction": "workspace:^",
     "@deepseek-ai/dsh-goal": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-permission-presets": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
-    "@deepseek-ai/dsh-session-stats": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tool-todo": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^",
-    "@deepseek-ai/dsh-util-crypto": "workspace:^"
+    "@deepseek-ai/dsh-util-crypto": "workspace:^",
+    "@deepseek-ai/dsh-workspace": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-api-remotes": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
-    "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
-    "@deepseek-ai/dsh-compaction": "workspace:^",
     "@deepseek-ai/dsh-goal": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-permission-presets": "workspace:^",
     "@deepseek-ai/dsh-plan-mode": "workspace:^",
-    "@deepseek-ai/dsh-session-projection": "workspace:^",
-    "@deepseek-ai/dsh-session-stats": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tool-todo": "workspace:^",
-    "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-util-crypto": "workspace:^",
+    "@deepseek-ai/dsh-workspace": "workspace:^",
     "@types/react": "~18.3.1",
     "react": "^18.2.0"
   },

+ 80 - 249
packages/client/ui-conversation/src/client/apply.ts

@@ -1,67 +1,56 @@
-/** Registers the conversation components, shared store, and service callbacks. */
+/** Registers the target-neutral Conversation assembly, shell, input, and docks. */
 import type { Context } from '@deepseek-ai/cordis'
-import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
-import {
-  PendingWait, resolveWorkspacePath, type ISessions, type SessionId,
-} from '@deepseek-ai/dsh-client-runtime/client'
-// Type-only: the ctx.settingsScope Context merge. Cross-plugin collaboration
-// goes through the service, never a value import (client bundle purity gate).
-import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
-import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
-// Type-only: pulls the locale plugin's Context merge (ctx.locale).
+import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
+import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store'
+import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+// Type-only service and declaration merges used by this assembly.
 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-settings/client'
+import { UiConversation } from './conversation/assembly.ts'
 import type { ViewTab } from './contract/views.ts'
 import type {
-  ApprovalWait, ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, ComposerBarInjected,
-  ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected,
-  DetailsInjected,
+  ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected,
+  ConversationSessionInjected,
 } from './contract/slots.ts'
-import type { InputNotice } from './input/contract.ts'
-import { createChatStore } from './stores.ts'
+import type { InputNotice } from './contract/input.ts'
+import { createConversationStore } from './stores.ts'
 import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts'
 import type { IConversation } from './service.ts'
 import { ComposerBlockRegistry } from './input/blocks.ts'
-import type { ComposerBlock } from './input/blocks.ts'
+import type { ComposerBlock } from './contract/composer-blocks.ts'
 import { InputHub } from './input/hub.ts'
 import { ComposerSubmissionPolicy } from './input/submission-policy.ts'
-import { InputBar } from './skeleton/InputBar.tsx'
+import { queueDockEntry } from './queue/QueueDock.tsx'
 import { EnterBehaviorRow } from './settings/EnterBehaviorRow.tsx'
 import type { EnterBehaviorRowInjected } from './settings/EnterBehaviorRow.tsx'
-import { ChatView } from './chat/ChatView.tsx'
-import { StatsLine } from './chat/StatsLine.tsx'
-import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
-import { todoDockEntry } from './skeleton/TodoPanel.tsx'
-import { queueDockEntry } from './queue/QueueDock.tsx'
 import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
 import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
-import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
+import { InputBar } from './skeleton/InputBar.tsx'
+import { todoDockEntry } from './skeleton/TodoPanel.tsx'
 import { en, NS, zh, type ConversationKey } from './locales.ts'
-import { registerConversationNodes } from './conversation-nodes/register.ts'
-import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
 import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
-import { PendingInteractionPresenter } from './pending-interactions.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface LocaleNamespaceMap {
-    /** The conversation skeleton, chat flow, commands, details, and docks copy. */
+    /** Conversation shell, composer, queue, and dock copy. */
     conversation: ConversationKey
   }
 }
 
-/** Services required by the conversation plugin. */
+/** Services required by the Conversation plugin. */
 export const inject = [
-  'slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection', 'remote', 'settingsScope',
-  'conversationEvents', 'conversationViews',
+  'slots', 'sessions', 'uiSession', 'locale', 'settingsScope',
 ]
 
-// Static no-session sources for the composer-bar hooks compartment: module
-// constants so the render side's per-source hook cache (observableHook) keeps
-// one identity across every no-session render.
+// Stable no-session sources keep the renderer's observable-hook cache and
+// hook order unchanged across current-Session transitions.
 const ABSENT_NOTICES = {
   getSnapshot: (): InputNotice | null => null,
   subscribe: () => () => {},
 }
-/** No session, therefore nothing to block; same one-identity rule as above. */
 const ABSENT_BLOCK = {
   getSnapshot: (): ComposerBlock | undefined => undefined,
   subscribe: () => () => {},
@@ -76,61 +65,36 @@ const ABSENT_MENU_LAUNCHER = {
   subscribe: () => () => {},
 }
 
-const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
-  hooks: {
-    turnData: ({ useSession }, nodeKey) => function useTurnData(key) {
-      return useSession((snapshot) => {
-        const location = snapshot.chat.nodes.get(nodeKey)?.location
-        return location?.kind === 'turn' || location?.kind === 'step'
-          ? location.turn.data.get(key)
-          : undefined
-      })
-    },
-  },
-}
-
-/** Resolve the session-scoped conversation face (scope-addressed send/cancel), failing loud. */
+/** Resolve the session-scoped Conversation action face, failing loud. */
 function scopedConversation(sessions: ISessions, id: SessionId): IConversation {
   const scoped = sessions.scope(id)
   if (scoped === undefined) throw new Error(`ui-conversation: session "${id}" resolved no scope`)
   const conversation = scoped.get('conversation')
-  if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable through the session scope')
+  if (conversation === undefined) {
+    throw new Error('ui-conversation: conversation service unavailable through the session scope')
+  }
   return conversation
 }
 
-/** Resolve package-internal attachment operations from the public service registration. */
+/** Resolve package-internal attachment operations from the public service. */
 function concreteConversation(ctx: Context): ConversationController {
   const conversation = ctx.get('conversation') as ConversationController | undefined
   if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable')
   return conversation
 }
 
-/** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */
-function selectApproval({ pendingInteraction }: ComposerChainProps): ApprovalWait | null {
-  return pendingInteraction?.kind === 'approval' ? pendingInteraction : null
-}
-
-/** Mounts the conversation plugin.
+/**
+ * Mount the Conversation core and target-neutral presentation.
  * @param ctx - Client root context.
  */
 export function apply(ctx: Context): void {
   const sessions = ctx.sessions
-  const workspaces = ctx.workspaces
-  const layout = ctx.layout
   const slots = ctx.slots
-
-  registerConversationNodes(ctx)
-  registerChatNodeRenderers(ctx)
+  const uiConversation = new UiConversation(ctx, sessions)
 
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-conversation: dictionaries')
-
-  // Registration-time text (the view tab label) reads through the bound
-  // translate as a thunk, so it follows the active locale without
-  // re-registration; components read the standard `t` seat instead.
   const t = ctx.locale.bind(NS)
-
-  // Apply-time construction keeps store identity bound to this fiber.
-  const chatStore = createChatStore()
+  const conversationStore = createConversationStore()
   const submissionPolicy = new ComposerSubmissionPolicy(
     ctx.settingsScope.bind<ConversationSettings>({ namespace: CONVERSATION_SETTINGS_NAMESPACE }),
   )
@@ -146,55 +110,58 @@ export function apply(ctx: Context): void {
     }),
   }, EnterBehaviorRow))
 
-  // Chat semantic reader positions by session, surviving view switches and
-  // width reflow when the tab ring remounts the view. Deliberately not
-  // persisted: a fresh page load keeps the open-jump-to-bottom default.
-  const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
-
   const viewTabs = (): ViewTab[] => {
     const tabs: ViewTab[] = []
     for (const entry of slots.entries('conversation.view')) {
-      /* v8 ignore next -- unreachable: list registration validates id at load. */
+      /* v8 ignore next -- list registration validates id at load. */
       if (entry.options.id === undefined) continue
-      tabs.push({ id: entry.options.id, label: resolveSlotLabel(entry.options.label) ?? entry.options.id })
+      tabs.push({
+        id: entry.options.id,
+        label: resolveSlotLabel(entry.options.label) ?? entry.options.id,
+      })
     }
     return tabs
   }
-  const views = {
-    list: viewTabs,
-    subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
-    version: () => slots.getVersion('conversation.view'),
+  const conversationViews = createSnapshotStore<readonly ViewTab[]>(viewTabs())
+  const refreshViews = (): void => {
+    const current = conversationViews.getSnapshot()
+    const next = viewTabs()
+    if (current.length === next.length
+      && current.every((tab, index) => {
+        const candidate = next.at(index)
+        return candidate !== undefined && tab.id === candidate.id && tab.label === candidate.label
+      })) return
+    conversationViews.set(next)
   }
+  ctx.effect(() => {
+    const disposeViews = slots.subscribe('conversation.view', refreshViews)
+    const disposeLocale = ctx.locale.subscribe(refreshViews)
+    return () => {
+      disposeLocale()
+      disposeViews()
+    }
+  }, 'ui-conversation: View roster')
 
-  // The per-session input machine registry (SessionInputResolver face; published as
-  // ctx.conversation.input by the service below sharing this one instance).
   const inputHub = new InputHub(ctx, t)
-
-  // The composer-block registry: a plugin that knows a session cannot send —
-  // ui-model-selection, when no adapter serves the session's route — raises a block
-  // here, and the bar reads its own session's store. It cannot flow the other
-  // way: this package must not import the plugins that would know.
   const composerBlocks = new ComposerBlockRegistry()
-  const pendingInteractions = new PendingInteractionPresenter()
 
-  // The input machine feeds every session-scope slot
-  // component through the standard provide channel — the 'input' hook plus
-  // the two public actions. Materialization is the shell creation trigger
-  // (per-session lazy; scope disposer tears down).
-  ctx.effect(() => sessions.provide({
-    hooks: ['input'],
+  // Conversation assembly and input share the Session binding lifecycle. The
+  // source roster is installed before any consuming Slot entry.
+  ctx.uiSession.provide({
+    hooks: ['conversation', 'input'],
     props: ['inputActions'],
     resolve: (binding) => {
       const shell = inputHub.shellFor(binding)
       return {
-        hooks: { input: shell.state },
+        hooks: {
+          conversation: uiConversation.binding(binding).snapshot,
+          input: shell.state,
+        },
         props: { inputActions: shell.actions },
       }
     },
-  }), 'ui-conversation: input standard-kit provider')
+  })
 
-  // Resident current-session-optional shell. It owns the stable Hero/composer
-  // frame while strict session slots fill only their session-bound regions.
   slots.register({
     name: 'conversation',
     locale: NS,
@@ -215,10 +182,9 @@ export function apply(ctx: Context): void {
     inject: (sessionId: SessionId | undefined): ConversationInjected => ({
       hooks: {
         composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId),
-        sessionPendingInteraction: pendingInteractions.forSession(sessionId),
       },
       selectWorkspace: async (workspaceId) => {
-        const nextId = await workspaces.connectWorkspace(workspaceId)
+        const nextId = await ctx.uiSession.connectWorkspace(workspaceId)
         if (sessionId !== undefined && nextId !== sessionId) {
           const from = inputHub.shell(sessionId)
           const draft = from.snapshot.draft
@@ -239,27 +205,18 @@ export function apply(ctx: Context): void {
     }),
   }, ConversationRoot)
 
-  // The strict session body fills the resident scrollport without owning it;
-  // the Hero/composer path therefore stays fixed while the first blank
-  // session appears after a Workspace pick.
   slots.register({
     name: 'conversation.session',
     children: {
       'conversation.view': { kind: 'list', scope: 'session' },
     },
-    store: chatStore,
-    inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => {
-      const conversation = concreteConversation(ctx)
-      return {
-        views,
-        releaseSessionImages: (id) => { conversation.releaseSessionImages(id) },
-        bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
-      }
-    },
+    store: conversationStore,
+    inject: (sessionId: SessionId, _actions: BoundActions<typeof conversationStore>): ConversationSessionInjected => ({
+      hooks: { conversationViews },
+      bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
+    }),
   }, ConversationSession)
 
-  // Header chrome sits above the resident scrollport but shares the same
-  // per-session chat store (active view) as its body and view entries.
   slots.register({
     name: 'conversation.session.header',
     locale: NS,
@@ -268,26 +225,16 @@ export function apply(ctx: Context): void {
       'conversation.session.header.actions': { kind: 'list', scope: 'session' },
       'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
     },
-    store: chatStore,
+    store: conversationStore,
     inject: (): ConversationSessionHeaderInjected => ({
-      views,
+      hooks: { conversationViews },
       open: (id) => { sessions.open(id) },
     }),
   }, ConversationSessionHeader)
 
-  // The default composer body: its own single slot inside the composer
-  // chain's fallback. Public machine surface arrives via the
-  // provide channel above; the keyboard command face and the stop/retry
-  // verbs ride this inject (package-internal — hub and bar are one plugin).
-  // Session-maybe: with no current session the machine faces are absent and
-  // the hooks compartment binds static empty sources (module constants, so
-  // observableHook caching and hook order stay stable across transitions).
   slots.register({
     name: 'conversation.composer.bar',
     locale: NS,
-    // The two named control seats in the bar's tool row (plan beside the
-    // access control, model right); empty until their owning plugins
-    // register.
     children: {
       'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' },
       'conversation.input.plan': { kind: 'single', scope: 'session' },
@@ -305,7 +252,11 @@ export function apply(ctx: Context): void {
           toggleCommandMenu: undefined,
           stop: undefined,
           command: undefined,
-          hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER },
+          hooks: {
+            notices: ABSENT_NOTICES,
+            lexicon: ABSENT_LEXICON,
+            menuLauncher: ABSENT_MENU_LAUNCHER,
+          },
         }
       }
       const conversation = concreteConversation(ctx)
@@ -321,11 +272,7 @@ export function apply(ctx: Context): void {
             }
             return null
           } catch (error: unknown) {
-            if (error instanceof UnsupportedImageMediaTypeError) {
-              // Positive copy: the supported list is fixed in imageMediaType,
-              // and naming it beats echoing the rejected MIME type back.
-              return t('image.unsupportedType')
-            }
+            if (error instanceof UnsupportedImageMediaTypeError) return t('image.unsupportedType')
             return error instanceof Error ? error.message : String(error)
           }
         },
@@ -351,7 +298,7 @@ export function apply(ctx: Context): void {
           },
         stop: () => {
           scopedConversation(sessions, sessionId).cancel().catch(() => {
-            // Stop failure surfaces via snapshot.promptError; nothing to restore.
+            // Stop failure is published through Session promptError.
           })
         },
         command: async (line) => {
@@ -369,123 +316,7 @@ export function apply(ctx: Context): void {
     },
   }, InputBar)
 
-  // The approval takeover: a selector-routed entry of the chain this package
-  // just declared (the ui-user-questions registration pattern; the entry lives here
-  // because approval answering is core conversation UX, not an optional tool).
-  // Zero business face — data and verbs both ride the matched carrier.
-  // priority 1: question takeovers (default 0) win when both kinds are
-  // pending — a question is a conversation the model is waiting on, while an
-  // approval only blocks one tool call; answering the question first cannot
-  // strand the approval (it re-elects the moment the question resolves).
-  slots.register({ name: 'conversation.composer', select: selectApproval, priority: 1, locale: NS }, ApprovalPanel)
-
-  // The chat view: first entry of the ring this package just declared.
-  // ChatView owns only the stable ordered Node list. Business renderers are
-  // independently keyed behind its one Node seat.
-  slots.register({
-    name: 'conversation.view',
-    id: 'chat',
-    order: 0,
-    label: () => t('view.chat'),
-    locale: NS,
-    children: {
-      'conversation.chat.node': { kind: 'keyed', scope: 'session', inject: CHAT_NODE_INJECT },
-      'conversation.message.images': { kind: 'single', scope: 'session' },
-    },
-    store: chatStore,
-    inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => {
-      const conversation = concreteConversation(ctx)
-      const scoped = scopedConversation(sessions, sessionId)
-      return {
-        openDetails: (target) => {
-          actions.select(target)
-          layout.openDetails()
-        },
-        fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner),
-        openFile: (path) => {
-          const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
-          return workspaces.openPath(resolveWorkspacePath(cwd, path))
-        },
-        loadOlder: () => { void scoped.loadOlder() },
-        loadImage: attachment => conversation.resolveImage(sessionId, attachment),
-        // Unregistered 'trajectory' id is safe: the tab ring falls back to
-        // the first view, and the untouched inspect target stays inert.
-        inspectCall: (callId) => {
-          actions.setInspect({ callId })
-          actions.setView('trajectory')
-        },
-        chatScroll: {
-          save: (position) => {
-            if (position === null) chatScrollPositions.delete(sessionId)
-            else chatScrollPositions.set(sessionId, position)
-          },
-          read: () => chatScrollPositions.get(sessionId) ?? null,
-        },
-        forkAt: (seq) => {
-          sessions.fork({ sessionId, atSeq: seq, increaseTitle: true })
-            .then((childId) => { sessions.open(childId) })
-            .catch(() => {
-              // Fork or child-rename failure keeps the source view untouched.
-            })
-        },
-      }
-    },
-  }, ChatView)
-
-  // Session stats stick with the composer (composer.dock = stats-line family).
-  slots.register({ name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS }, StatsLine)
-
-  // Class-plugin mount (packages/AGENTS.md service form): the service
-  // registers itself as `conversation` and lives on its own child fiber.
-  // Presentation registrants depend directly on their slot declarations;
-  // this service remains only where conversation actions are required.
-  ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks, pendingInteractions })
-
-  let nextApprovalKey = 0
-  ctx.remote.$on('approval/request', function (request, next) {
-    const sessionId = sessions.scopeOf(this)
-    if (sessionId === undefined) return next()
-    nextApprovalKey += 1
-    const interactionId = `remote-${String(nextApprovalKey)}`
-    const completion = Promise.withResolvers<Awaited<ReturnType<typeof next>>>()
-    const wait = new PendingWait('approval', interactionId, sessionId, {
-      approvalId: interactionId,
-      toolName: request.toolName,
-      ...(request.callId === undefined ? {} : { callId: request.callId }),
-      ...(request.reason === undefined ? {} : { reason: request.reason }),
-    }, (response) => {
-      if (response.result.ok) completion.resolve(response.result.value.outcome)
-      return Promise.resolve({ ok: true, value: { accepted: true } })
-    })
-    const remove = pendingInteractions.present(wait, 'approval', 0)
-    const abort = (): void => {
-      completion.reject(request.signal?.reason ?? new Error('approval request was aborted'))
-    }
-    request.signal?.addEventListener('abort', abort, { once: true })
-    if (request.signal?.aborted === true) abort()
-    return completion.promise.finally(() => {
-      request.signal?.removeEventListener('abort', abort)
-      remove()
-    })
-  })
-
-  // The plan strip rides the input dock above the queue rows (same posture).
+  ctx.plugin(ConversationController, { input: inputHub, blocks: composerBlocks })
   ctx.plugin(todoDockEntry)
-
-  // The read-only queue dock entry rides the same
-  // registration path into the input dock declared above.
   ctx.plugin(queueDockEntry)
-
-  slots.register({
-    name: 'details',
-    locale: NS,
-    children: {
-      'conversation.details.tool': { kind: 'single', scope: 'session' },
-    },
-    store: chatStore,
-    inject: (): DetailsInjected => ({
-      closeDetails: () => { layout.closeDetails() },
-    }),
-  }, DetailsPanel)
-
 }

+ 107 - 316
packages/client/ui-conversation/tests/apply-inject.client.spec.tsx

@@ -1,37 +1,29 @@
 // @vitest-environment jsdom
-
 import { describe, expect, it, vi } from 'vitest'
-import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
-import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
+import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
-import type { ClientContext, ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
-import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import type {
-  ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected,
-  ConversationSessionInjected, DetailsInjected,
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import {
+  SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
+} from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
+import {
+  apply, inject, type ComposerBarInjected, type ConversationInjected,
+  type ConversationSessionInjected, type ViewTab,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import { PendingApproval } from '../src/client/contract/slots.ts'
-import type { createChatStore } from '../src/client/stores.ts'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
+import { createConversationStore } from '../src/client/stores.ts'
 
-// The service reads its initial locale from the browser; these specs assert
-// the shipped Chinese copy, so they state the browser they assume.
 usePinnedBrowserLanguages('zh-CN')
 
 const ROOT = 'root-1' as SessionId
 
-type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
-type ChatActions = ChatInstance['actions']
-type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable'
-type ApprovalListener = (
-  this: ClientContext,
-  request: { toolName: string; callId?: string; reason?: string; signal?: AbortSignal },
-  next: () => Promise<ApprovalOutcome>,
-) => Promise<ApprovalOutcome>
+type ConversationInstance = ReturnType<ReturnType<typeof createConversationStore>['create']>
+type ConversationActions = ConversationInstance['actions']
 
-/** ISession verb mocks, typed against the production face (['prompt'] etc. keep vitest mock ergonomics). */
 function sessionFakeFor() {
   return {
-    open: vi.fn(() => Promise.resolve()),
     loadOlder: vi.fn<ISession['loadOlder']>(() => Promise.resolve()),
     prompt: vi.fn<ISession['prompt']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
     cancel: vi.fn<ISession['cancel']>(() => Promise.resolve({ ok: true, value: { accepted: true } })),
@@ -40,54 +32,32 @@ function sessionFakeFor() {
 
 async function bench() {
   const runtime = await SlotTestRuntime.create()
-  runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
-  let approvalListener: ApprovalListener | undefined
-  const remoteOn = vi.fn((event: string, listener: ApprovalListener) => {
-    expect(event).toBe('approval/request')
-    approvalListener = listener
-    return () => { approvalListener = undefined }
-  })
-  runtime.provide('remote', { $on: remoteOn } as never)
-  runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
+  runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
+  const connectWorkspace = vi.spyOn(runtime.ctx.uiSession, 'connectWorkspace').mockResolvedValue(ROOT)
   const sessionFake = sessionFakeFor()
   await runtime.sessions.add({
     id: ROOT,
     summary: { title: 'R', displayTitle: 'R', cwd: '/proj' },
     session: sessionFake,
-  })
-  const layoutFake = { openDetails: vi.fn(), closeDetails: vi.fn() }
-  runtime.provide('layout', layoutFake)
+  }, { current: false })
   const locale = new LocaleRuntime(runtime.ctx)
-  runtime.provide('locale', locale)
+  runtime.ctx.provide('locale', locale)
   runtime.slots.installLocale(locale)
-
-  // The AppFrame role: the conversation-package slots must be declared by a
-  // live entry before apply can contribute into them.
   await runtime.root.declare({
     'conversation': { kind: 'single', scope: 'session-maybe' },
-    'details': { kind: 'single', scope: 'session' },
-  }, (_p: { renderSlot?: unknown }) => null)
+  }, (_props: { renderSlot?: unknown }) => null)
 
   const feature = await runtime.mount({ inject: [...inject], apply })
-
-  // The host face (store resolution) exists only inside the installed
-  // renderer, so materialize it the way the shell does.
   runtime.renderRoot()
-  const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
+  const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar') =>
     runtime.slots.entries(key)[0]!
-  /** Resolve store instance + call the inject the way the outlet would. */
   const conversationApi = (id: SessionId) => {
     const entry = entryOf('conversation.session')
-    const instance = runtime.storeOf('conversation.session', id) as ChatInstance
-    const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
-      id, instance.actions)
-    return { instance, injected }
-  }
-  const conversationHeaderApi = (id: SessionId) => {
-    const entry = entryOf('conversation.session.header')
-    const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance
-    const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)(
-      id, instance.actions)
+    const instance = runtime.storeOf('conversation.session', id) as ConversationInstance
+    const injected = (entry.inject as unknown as (
+      sessionId: SessionId,
+      actions: ConversationActions,
+    ) => ConversationSessionInjected)(id, instance.actions)
     return { instance, injected }
   }
   const residentApi = (id: SessionId | undefined) => {
@@ -98,330 +68,151 @@ async function bench() {
     const entry = entryOf('conversation.composer.bar')
     return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
   }
-  /** Same resolution for the chat entry riding the view ring. */
-  const chatViewApi = (id: SessionId) => {
-    const entry = entryOf('conversation.view')
-    const instance = runtime.storeOf('conversation.view', id) as ChatInstance
-    const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
-      id, instance.actions)
-    return { instance, injected }
-  }
-  /** Materialize the input provide contribution the way the runtime does. */
   const inputApi = (id: SessionId) => {
-    const info = runtime.sessions.provideInfo(id)!
-    const state = info.hooks['input'] as {
-      getSnapshot: () => { draft: string }
-      subscribe: (fn: () => void) => () => void
-    }
-    const actions = info.props['inputActions'] as {
-      setDraft: (text: string) => void
-      submit: () => void
-    }
-    return { state, actions }
+    const input = runtime.ctx.conversation.input.for(runtime.sessions.scope(id)!)
+    return { state: input.state, actions: input }
   }
+  const viewSource = (id: SessionId): ObservableSnapshot<readonly ViewTab[]> =>
+    conversationApi(id).injected.hooks.conversationViews
   return {
-    runtime, feature, slots: runtime.slots, entryOf,
-    conversationApi, conversationHeaderApi, residentApi, composerApi, chatViewApi, inputApi,
-    sessionFake, layoutFake, remoteOn,
-    invokeApproval(
-      owner: ClientContext,
-      request: Parameters<ApprovalListener>[0],
-      next: Parameters<ApprovalListener>[1],
-    ): Promise<ApprovalOutcome> {
-      if (approvalListener === undefined) throw new Error('approval listener was not installed')
-      return approvalListener.call(owner, request, next)
-    },
+    runtime, feature, slots: runtime.slots, entryOf, conversationApi, residentApi, composerApi,
+    inputApi, viewSource, sessionFake, connectWorkspace,
   }
 }
 
-describe('conversation slot inject API', () => {
-  it('presents a scoped approval until its Remote Event waterfall resolves', async () => {
-    const b = await bench()
-    const scope = b.runtime.sessions.scope(ROOT)
-    if (scope === undefined) throw new Error('Session scope was not created')
-    const next = vi.fn(() => Promise.resolve<ApprovalOutcome>('unavailable'))
-    const result = b.invokeApproval(scope, {
-      toolName: 'bash', callId: 'call-1', reason: 'needs access',
-    }, next)
-    const source = b.residentApi(ROOT).hooks.sessionPendingInteraction
-    const wait = source.getSnapshot()[0]
-    if (wait === undefined || wait.kind !== 'approval') {
-      throw new Error('approval wait was not presented')
-    }
-
-    expect(b.remoteOn).toHaveBeenCalledOnce()
-    expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot().get(ROOT))
-      .toBe('approval')
-    await new PendingApproval(wait).answer('allowed-once')
-    await expect(result).resolves.toBe('allowed-once')
-    expect(next).not.toHaveBeenCalled()
-    expect(source.getSnapshot()).toEqual([])
-    expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot()).toEqual(new Map())
-    await b.runtime.dispose()
-  })
-
-  it('removes a scoped approval when its Remote Event lifetime aborts', async () => {
-    const b = await bench()
-    const scope = b.runtime.sessions.scope(ROOT)
-    if (scope === undefined) throw new Error('Session scope was not created')
-    const controller = new AbortController()
-    const removeEventListener = vi.spyOn(controller.signal, 'removeEventListener')
-    const reason = new DOMException('aborted by Host', 'AbortError')
-    const result = b.invokeApproval(scope, {
-      toolName: 'bash', signal: controller.signal,
-    }, () => Promise.resolve('unavailable'))
-    const source = b.residentApi(ROOT).hooks.sessionPendingInteraction
-    expect(source.getSnapshot()).toHaveLength(1)
-
-    controller.abort(reason)
-
-    await expect(result).rejects.toBe(reason)
-    expect(removeEventListener).toHaveBeenCalledWith('abort', expect.any(Function))
-    expect(source.getSnapshot()).toEqual([])
-    await b.runtime.dispose()
-  })
-
-  it('delegates an approval without a Session-scoped Client Context', async () => {
-    const b = await bench()
-    const next = vi.fn(() => Promise.resolve<ApprovalOutcome>('unavailable'))
-
-    await expect(b.invokeApproval(b.runtime.ctx, { toolName: 'bash' }, next))
-      .resolves.toBe('unavailable')
-
-    expect(next).toHaveBeenCalledOnce()
-    expect(b.runtime.ctx.conversation.pendingInteractions.statuses.getSnapshot()).toEqual(new Map())
-    await b.runtime.dispose()
-  })
-
-  it('assembles the thin API side-effect-free', async () => {
+describe('Conversation inject API', () => {
+  it('assembles the target-neutral read face without Session side effects', async () => {
     const b = await bench()
     const { injected } = b.conversationApi(ROOT)
-    // Assembly has no session side effects: opening the event window belongs
-    // to the runtime watch path, not the inject factory.
-    expect(b.sessionFake.open).not.toHaveBeenCalled()
-    expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
-
-    const chatView = b.chatViewApi(ROOT)
-    chatView.injected.loadOlder()
-    expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
-    chatView.injected.forkAt(17)
-    await vi.waitFor(() => {
-      expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
-    })
-    expect(b.runtime.sessions.calls).toContainEqual({
-      method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }],
-    })
+    expect(b.sessionFake.loadOlder).not.toHaveBeenCalled()
+    expect(Object.keys(injected)).toEqual(['hooks', 'bindDraftMirror'])
+    expect(b.viewSource(ROOT).getSnapshot()).toEqual([])
     await b.runtime.dispose()
   })
 
-  it('the provide-channel input face submits through the machine sink: trim, transactional clear, failure retains the draft', async () => {
+  it('submits through the provided input machine and mirrors accepted draft edits', async () => {
     const b = await bench()
     const { injected } = b.conversationApi(ROOT)
     const { state, actions } = b.inputApi(ROOT)
-    // Whitespace-only: the machine treats it as empty — no prompt, draft kept.
     actions.setDraft('   ')
     actions.submit()
     expect(b.sessionFake.prompt).not.toHaveBeenCalled()
     expect(state.getSnapshot().draft).toBe('   ')
-    // Success: the draft clears only after the sink settles.
+
     actions.setDraft('hello')
     actions.submit()
-    await vi.waitFor(() => {
-      expect(state.getSnapshot().draft).toBe('')
+    await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') })
+    expect(b.sessionFake.prompt).toHaveBeenCalledWith(
+      [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal),
+    )
+
+    b.sessionFake.prompt.mockResolvedValueOnce({
+      ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } },
     })
-    expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal))
-    // Failure: the draft is retained through the round-trip.
-    b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
     actions.setDraft('retry me')
     actions.submit()
-    await vi.waitFor(() => {
-      expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2)
-    })
-    await new Promise(r => setTimeout(r, 0))
+    await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledTimes(2) })
+    await Promise.resolve()
     expect(state.getSnapshot().draft).toBe('retry me')
-    // Failure landing after new typing: no clobber (the interleaved edit wins).
-    b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
-    actions.submit()
-    actions.setDraft('typed during flight')
-    await new Promise(r => setTimeout(r, 0))
-    expect(state.getSnapshot().draft).toBe('typed during flight')
-    // The provide contribution is idempotent per session: one shell identity.
-    expect(b.inputApi(ROOT).state).toBe(state)
-    // The draft mirror rides the conversation inject face.
+
     const mirrored: string[] = []
     const unbind = injected.bindDraftMirror(text => mirrored.push(text))
     actions.setDraft('mirrored text')
     expect(mirrored).toEqual(['mirrored text'])
     unbind()
-    // Stop failure is swallowed (promptError owns the display).
-    b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
+    expect(b.inputApi(ROOT).state).toBe(state)
+
+    b.sessionFake.cancel.mockResolvedValueOnce({
+      ok: false, error: { code: 'internal', message: 'stop failed', details: {} },
+    })
     b.composerApi(ROOT).stop!()
-    await new Promise(r => setTimeout(r, 0))
-    expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
+    await vi.waitFor(() => { expect(b.sessionFake.cancel).toHaveBeenCalledOnce() })
     await b.runtime.dispose()
   })
 
-  it('inject fails loud when the session resolves no binding or the scope lacks the service', async () => {
+  it('fails loud for an unknown binding or an unloaded scoped service', async () => {
     const b = await bench()
     const entry = b.entryOf('conversation.composer.bar')
-    const injectFn = entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected
-    // Unknown session: the keyboard face's binding resolution answers nothing.
-    expect(() => { injectFn('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
-    // No session (session-maybe absent side): machine faces absent, static
-    // hooks compartment still present so the render side's hook order holds.
-    const absent = injectFn(undefined)
+    const injectBar = entry.inject as unknown as (
+      sessionId: SessionId | undefined,
+    ) => ComposerBarInjected
+    expect(() => { injectBar('ghost' as SessionId).stop!() }).toThrow(/resolved no binding/)
+
+    const absent = injectBar(undefined)
     expect(absent.keyboard).toBeUndefined()
     expect(absent.toggleCommandMenu).toBeUndefined()
     expect(absent.stop).toBeUndefined()
     expect(absent.hooks.notices.getSnapshot()).toBeNull()
     expect(absent.hooks.lexicon.getSnapshot().size).toBe(0)
     expect(absent.hooks.menuLauncher.getSnapshot()).toBeNull()
-    // A scope whose service tree lost 'conversation' (the feature fiber
-    // unloaded while a retained inject closure re-runs): fails loud too.
-    const stop = injectFn(ROOT).stop!
+
+    const stop = injectBar(ROOT).stop!
     await b.feature.dispose()
     expect(() => { stop() }).toThrow(/unavailable through the session scope/)
     await b.runtime.dispose()
   })
 
-  it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
-    const b = await bench()
-    const { instance, injected } = b.chatViewApi(ROOT)
-    injected.openDetails({ turnSeq: 2, callId: 'c1' })
-    expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
-    expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
-    // The chat view shares the conversation entry's store instance: selection
-    // writes land where the skeleton and details read.
-    const conv = b.conversationApi(ROOT)
-    expect(conv.instance).toBe(instance)
-    await b.runtime.dispose()
-  })
-
-  it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
-    const b = await bench()
-    const { injected } = b.chatViewApi(ROOT)
-    await injected.openFile('src/a.ts')
-    await vi.waitFor(() => {
-      expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
-    })
-    await b.runtime.dispose()
-  })
-
-  it('openFile rejects when the Host cannot open the path', async () => {
-    const b = await bench()
-    b.runtime.workspaces.stub('openPath', () => Promise.reject(new Error('xdg-open is not available')))
-    const { injected } = b.chatViewApi(ROOT)
-    await expect(injected.openFile('src/a.ts')).rejects.toThrow('xdg-open is not available')
-    await b.runtime.dispose()
-  })
-
-  it('routes workspace switching through the runtime owner, carrying the draft', async () => {
+  it('moves a draft only when Workspace navigation changes Session', async () => {
     const b = await bench()
     const resident = b.residentApi(ROOT)
-    // Same-session connect (the picked workspace resolves to this session):
-    // no draft movement, plain re-open.
-    b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
     const { state, actions } = b.inputApi(ROOT)
     actions.setDraft('carry me')
-    void resident.selectWorkspace('workspace-1' as never)
-    await vi.waitFor(() => {
-      expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(1)
-    })
-    expect(b.runtime.workspaces.calls).toContainEqual({ method: 'connectWorkspace', args: ['workspace-1'] })
-    expect(state.getSnapshot().draft).toBe('carry me')
-    // Cross-session connect: the draft MOVES — the old machine empties, the
-    // new session's machine receives the text, then navigation lands there.
-    const OTHER = 'other-1' as SessionId
-    await b.runtime.sessions.add({ id: OTHER }, { current: false })
-    b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
-    void resident.selectWorkspace('workspace-2' as never)
-    await vi.waitFor(() => {
-      expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
-    })
-    expect(state.getSnapshot().draft).toBe('')
-    expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('carry me')
-    await b.runtime.dispose()
-  })
 
-  it('selectWorkspace edge arms: no-session resident, empty-draft move, connect failure retryable', async () => {
-    const b = await bench()
-    // No-session resident (hero before any session): connect resolves and
-    // navigation proceeds without any draft choreography.
-    const noSession = b.residentApi(undefined)
-    b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
-    void noSession.selectWorkspace('workspace-0' as never)
-    await vi.waitFor(() => {
-      expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
-    })
+    b.connectWorkspace.mockResolvedValueOnce(ROOT)
+    await resident.selectWorkspace('workspace-1' as WorkspaceId)
+    expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
+    expect(state.getSnapshot().draft).toBe('carry me')
 
-    // Cross-session connect with an EMPTY draft: no move, no clearing.
-    const OTHER = 'b9-other' as SessionId
-    await b.runtime.sessions.add({ id: OTHER }, { current: false })
-    const resident = b.residentApi(ROOT)
-    const { state } = b.inputApi(ROOT)
+    const other = 'other-1' as SessionId
+    await b.runtime.sessions.add({ id: other }, { current: false })
+    b.connectWorkspace.mockResolvedValueOnce(other)
+    await resident.selectWorkspace('workspace-2' as WorkspaceId)
+    expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [other] })
     expect(state.getSnapshot().draft).toBe('')
-    b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
-    void resident.selectWorkspace('workspace-3' as never)
-    await vi.waitFor(() => {
-      expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
-    })
-    expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('')
-
-    // Connect failure: the rejection propagates to the caller (the view owns
-    // the rollback) and no further navigation happens.
-    const opens = b.runtime.sessions.calls.filter(c => c.method === 'open').length
-    b.runtime.workspaces.stub('connectWorkspace', () => Promise.reject(new Error('offline')))
-    await expect(resident.selectWorkspace('workspace-4' as never)).rejects.toThrow('offline')
-    expect(b.runtime.sessions.calls.filter(c => c.method === 'open')).toHaveLength(opens)
+    expect(b.inputApi(other).state.getSnapshot().draft).toBe('carry me')
     await b.runtime.dispose()
   })
 
-  it('scopedConversation fails loud when the session resolves no scope', async () => {
+  it('supports no-Session navigation and propagates Workspace connection failure', async () => {
     const b = await bench()
-    // The chat-view inject resolves the scoped conversation service at inject
-    // time: an unlisted session hits the scope() === undefined throw directly.
-    const entry = b.entryOf('conversation.view')
-    const injectFn = entry.inject as unknown as (sessionId: SessionId, actions: unknown) => unknown
-    expect(() => injectFn('never-listed' as SessionId, {})).toThrow(/resolved no scope/)
+    b.connectWorkspace.mockResolvedValueOnce(ROOT)
+    await b.residentApi(undefined).selectWorkspace('workspace-0' as WorkspaceId)
+    expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] })
+
+    const opens = b.runtime.sessions.calls.filter(call => call.method === 'open').length
+    b.connectWorkspace.mockRejectedValueOnce(new Error('offline'))
+    await expect(b.residentApi(ROOT).selectWorkspace('workspace-4' as WorkspaceId))
+      .rejects.toThrow('offline')
+    expect(b.runtime.sessions.calls.filter(call => call.method === 'open')).toHaveLength(opens)
     await b.runtime.dispose()
   })
 
-  it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
+  it('projects the dynamic View registration ledger', async () => {
     const b = await bench()
-    const { injected } = b.conversationApi(ROOT)
-    const before = injected.views.version()
+    const source = b.viewSource(ROOT)
+    const before = source.getSnapshot()
     const listener = vi.fn()
-    const unsub = injected.views.subscribe(listener)
-    // A second ring rider (what ui-trajectory does in production).
-    const off = b.slots.register(
-      { name: 'conversation.view', id: 'chat2', order: 5, label: 'X' } as never, (() => null) as never)
-    await Promise.resolve() // ledger notifications batch per microtask
-    expect(listener).toHaveBeenCalled()
-    expect(injected.views.version()).toBeGreaterThan(before)
-    expect(injected.views.list().map(v => v.id)).toEqual(['chat', 'chat2'])
-    // Label falls back to the id when a rider declares none.
-    const off2 = b.slots.register(
-      { name: 'conversation.view', id: 'bare', order: 6 } as never, (() => null) as never)
-    expect(injected.views.list().map(v => v.label)).toEqual(['对话', 'X', 'bare'])
-    off()
-    off2()
-    unsub()
-    await b.runtime.dispose()
-  })
-})
+    const unsubscribe = source.subscribe(listener)
+    const removeNamed = b.slots.register(
+      { name: 'conversation.view', id: 'trajectory', order: 5, label: 'Trajectory' },
+      (() => null) as never,
+    )
+    await vi.waitFor(() => {
+      expect(source.getSnapshot()).toEqual([{ id: 'trajectory', label: 'Trajectory' }])
+    })
+    expect(listener).toHaveBeenCalledOnce()
+    expect(source.getSnapshot()).not.toBe(before)
 
-describe('details inject API', () => {
-  it('details injects the one layout callback; selection rides the shared store instead', async () => {
-    const b = await bench()
-    const entry = b.entryOf('details')
-    const injected = (entry.inject as unknown as () => DetailsInjected)()
-    expect(Object.keys(injected)).toEqual(['closeDetails'])
-    injected.closeDetails()
-    expect(b.layoutFake.closeDetails).toHaveBeenCalledTimes(1)
-    // The shared handle: details resolves the SAME instance conversation writes.
-    const conv = b.runtime.storeOf('conversation.session', ROOT)
-    const details = b.runtime.storeOf('details', ROOT)
-    expect(details).toBe(conv)
+    const removeBare = b.slots.register(
+      { name: 'conversation.view', id: 'bare', order: 6 },
+      (() => null) as never,
+    )
+    await vi.waitFor(() => {
+      expect(source.getSnapshot().map(view => view.label)).toEqual(['Trajectory', 'bare'])
+    })
+    removeNamed()
+    removeBare()
+    unsubscribe()
     await b.runtime.dispose()
   })
 })

+ 98 - 0
packages/client/ui-conversation/tests/apply-wiring.client.spec.tsx

@@ -0,0 +1,98 @@
+// @vitest-environment jsdom
+import { describe, expect, it, vi } from 'vitest'
+import {
+  SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages,
+} from '@deepseek-ai/dsh-client-test-runtime'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { apply, inject, type ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
+
+usePinnedBrowserLanguages('zh-CN')
+
+const SID = 'session-1' as SessionId
+
+async function bench() {
+  const runtime = await SlotTestRuntime.create()
+  runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
+  const locale = new LocaleRuntime(runtime.ctx)
+  runtime.ctx.provide('locale', locale)
+  runtime.slots.installLocale(locale)
+  await runtime.root.declare({
+    'conversation': { kind: 'single', scope: 'session-maybe' },
+    'settings.general.item': { kind: 'list', scope: 'root' },
+  }, (_props: { renderSlot?: unknown }) => null)
+  const feature = await runtime.mount({ inject: [...inject], apply })
+  return { runtime, feature }
+}
+
+function entry(
+  runtime: SlotTestRuntime,
+  key: 'conversation' | 'conversation.session' | 'conversation.session.header',
+) {
+  return runtime.slots.entries(key)[0] as { store?: unknown } | undefined
+}
+
+describe('target-neutral Conversation apply wiring', () => {
+  it('provides both action and assembly services without installing Chat', async () => {
+    const b = await bench()
+    expect(b.runtime.ctx.get('conversation')).toBeDefined()
+    expect(b.runtime.ctx.get('uiConversation')).toBeDefined()
+    expect(b.runtime.slots.entries('conversation.view')).toHaveLength(0)
+    await b.runtime.dispose()
+  })
+
+  it('owns shell slots and shares only the Conversation store', async () => {
+    const b = await bench()
+    const session = entry(b.runtime, 'conversation.session')
+    const header = entry(b.runtime, 'conversation.session.header')
+    expect(entry(b.runtime, 'conversation')?.store).toBeUndefined()
+    expect(session?.store).toBeDefined()
+    expect(header?.store).toBe(session?.store)
+    expect(b.runtime.slots.spec('conversation.composer'))
+      .toEqual({ kind: 'chain', scope: 'session' })
+    expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id))
+      .toEqual(['composer-enter'])
+    await b.runtime.dispose()
+  })
+
+  it('binds a cached locale-aware View roster only to its shell entries', async () => {
+    const b = await bench()
+    await b.runtime.sessions.add({ id: SID }, { current: false })
+    expect(b.runtime.ctx.uiSession.adapter.resolve(SID)?.hooks.conversationViews).toBeUndefined()
+    const header = b.runtime.slots.entries('conversation.session.header')[0]
+    const source = (header?.inject?.() as {
+      hooks: { conversationViews: ObservableSnapshot<readonly ViewTab[]> }
+    } | undefined)?.hooks.conversationViews
+    expect(source).toBeDefined()
+    expect(source?.getSnapshot()).toBe(source?.getSnapshot())
+
+    const disposeView = b.runtime.slots.register({
+      name: 'conversation.view',
+      id: 'probe',
+      label: () => b.runtime.ctx.locale.getSnapshot().active,
+    }, (() => null) as never)
+    await vi.waitFor(() => {
+      expect(source?.getSnapshot()).toEqual([{ id: 'probe', label: 'zh' }])
+    })
+    const chinese = source?.getSnapshot()
+
+    b.runtime.ctx.locale.setLocale('en')
+    expect(source?.getSnapshot()).toEqual([{ id: 'probe', label: 'en' }])
+    expect(source?.getSnapshot()).not.toBe(chinese)
+
+    disposeView()
+    await vi.waitFor(() => { expect(source?.getSnapshot()).toEqual([]) })
+    await b.runtime.dispose()
+  })
+
+  it('removes services, entries, and declarations with the plugin fiber', async () => {
+    const b = await bench()
+    await b.feature.dispose()
+    expect(b.runtime.ctx.get('conversation')).toBeUndefined()
+    expect(b.runtime.ctx.get('uiConversation')).toBeUndefined()
+    expect(b.runtime.slots.entries('conversation')).toHaveLength(0)
+    expect(b.runtime.slots.spec('conversation.view')).toBeUndefined()
+    await b.runtime.dispose()
+  })
+})

+ 15 - 35
packages/client/ui-conversation/tests/assembly-surfaces.client.spec.tsx

@@ -4,10 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
 import { useState } from 'react'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
-import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
 import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
 usePinnedBrowserLanguages('zh-CN')
 
@@ -29,14 +30,13 @@ beforeEach(() => {
   vi.stubGlobal('ResizeObserver', ResizeObserverStub)
 })
 
-type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
+type AppRootProps = PropsRenderSlots<'conversation'>
 function AppRoot({ renderSlot }: AppRootProps) {
   return <>{renderSlot('conversation', {})}</>
 }
 
 const LAYOUT_CHILDREN = {
   'conversation': { kind: 'single', scope: 'session-maybe' },
-  'details': { kind: 'single', scope: 'session' },
 } as const
 
 function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
@@ -50,21 +50,14 @@ function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
 
 async function bench(opts?: { blank?: boolean }) {
   const runtime = await SlotTestRuntime.create()
-  runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
-  // The plugin injects both; these specs exercise no settings path.
-  runtime.provide('remote', { $on: () => () => {} })
-  runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
-  runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
+  runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   const locale = new LocaleRuntime(runtime.ctx)
-  runtime.provide('locale', locale)
+  runtime.ctx.provide('locale', locale)
   runtime.slots.installLocale(locale)
   await runtime.sessions.add({
     id: SID,
     summary: { title: 'S', displayTitle: 'S', cwd: '/proj' },
-    snapshot: {
-      nodes: [],
-      ...(opts?.blank === true ? { blank: true, composerPhase: 'blank' as const } : {}),
-    },
+    ...(opts?.blank === true ? { snapshot: { blank: true } } : {}),
     session: {
       loadOlder: vi.fn<ISession['loadOlder']>(),
       prompt: vi.fn<ISession['prompt']>(async () => ({ ok: true, value: { accepted: true } })),
@@ -78,13 +71,9 @@ async function bench(opts?: { blank?: boolean }) {
 describe('resident composer', () => {
   it('renders the locked view state while no session exists at all', async () => {
     const runtime = await SlotTestRuntime.create()
-    runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
-    // The plugin injects both; these specs exercise no settings path.
-    runtime.provide('remote', { $on: () => () => {} })
-    runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
-    runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
+    runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
     const locale = new LocaleRuntime(runtime.ctx)
-    runtime.provide('locale', locale)
+    runtime.ctx.provide('locale', locale)
     runtime.slots.installLocale(locale)
     await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
     await runtime.mount({ inject: [...inject], apply })
@@ -108,13 +97,9 @@ describe('resident composer', () => {
 
   it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
     const runtime = await SlotTestRuntime.create()
-    runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
-    // The plugin injects both; these specs exercise no settings path.
-    runtime.provide('remote', { $on: () => () => {} })
-    runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
-    runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
+    runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
     const locale = new LocaleRuntime(runtime.ctx)
-    runtime.provide('locale', locale)
+    runtime.ctx.provide('locale', locale)
     runtime.slots.installLocale(locale)
     await runtime.workspaces.update((draft) => {
       draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
@@ -140,7 +125,7 @@ describe('resident composer', () => {
     await runtime.sessions.add({
       id: SID,
       summary: { title: 'S', displayTitle: 'S', cwd: '/proj', blank: true },
-      snapshot: { blank: true, composerPhase: 'blank' },
+      snapshot: { blank: true },
     })
 
     expect(view.container.querySelector('[data-phase="hero"]')).toBe(root)
@@ -165,9 +150,8 @@ describe('resident composer', () => {
     expect(hero).not.toBeNull()
     expect(hero!.disabled).toBe(false)
 
-    await runtime.sessions.updateSnapshot(SID, (draft) => {
+    await runtime.sessions.updateSessionSnapshot(SID, (draft) => {
       draft.blank = false
-      draft.composerPhase = 'active'
     })
     expect(view.container.querySelector('textarea')).toBe(hero)
     await runtime.dispose()
@@ -177,13 +161,9 @@ describe('resident composer', () => {
 describe('prompt rejection through the assembled composer', () => {
   it('renders the promptError alert strip and keeps the draft in the machine', async () => {
     const runtime = await SlotTestRuntime.create()
-    runtime.provide('connection', { api: { settings: {} }, isLoopback: false })
-    // The plugin injects both; these specs exercise no settings path.
-    runtime.provide('remote', { $on: () => () => {} })
-    runtime.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
-    runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
+    runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
     const locale = new LocaleRuntime(runtime.ctx)
-    runtime.provide('locale', locale)
+    runtime.ctx.provide('locale', locale)
     runtime.slots.installLocale(locale)
     const prompt = vi.fn<ISession['prompt']>(async () => ({
       ok: false, error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },
@@ -202,7 +182,7 @@ describe('prompt rejection through the assembled composer', () => {
     fireEvent.keyDown(composer, { key: 'Enter' })
     await waitFor(() => { expect(prompt).toHaveBeenCalledOnce() })
 
-    await runtime.sessions.updateSnapshot(SID, (draft) => {
+    await runtime.sessions.updateSessionSnapshot(SID, (draft) => {
       draft.promptError = {
         op: 'send',
         error: { code: 'agent-busy', message: 'prompt rejected before acceptance', details: { reason: 'busy' } },

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

@@ -0,0 +1,76 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-session",
+  "description": "Session Controller adapter for React and session-scoped slots",
+  "version": "0.1.1-rc.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/ui-session"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "external": [
+        "@deepseek-ai/dsh-api-workspace-controller/client"
+      ],
+      "inject": [
+        "@deepseek-ai/dsh-api-session-controller",
+        "@deepseek-ai/dsh-api-workspace-controller",
+        "@deepseek-ai/dsh-client-ui-renderer"
+      ],
+      "platform": "web"
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-api-workspace-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-invariants": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@types/react": "~18.3.1",
+    "react": "^18.2.0"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 503 - 0
packages/client/ui-session/src/client/index.ts

@@ -0,0 +1,503 @@
+/** Session Controller adapter for React selector hooks and Slot scope data. */
+import { Service, type Context } from '@deepseek-ai/cordis'
+import type {
+  ISessions,
+  SessionBinding,
+  SessionListState,
+  SessionSnapshot,
+  UseProjection,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
+import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots'
+import type {
+  HostObservable,
+  KeyedStandardSource,
+  MaybeSnapshotSelectorHook,
+  RootStandardSourceContribution,
+  ScopedStandardSourceBinding,
+  SlotScopeAdapter,
+  SnapshotSelectorHook,
+  StandardSourceBinding,
+} from '@deepseek-ai/dsh-client-ui-slots'
+// Type-only service merge for ctx.slots.
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { renderSessionArea } from './session-provider.tsx'
+
+/** Selector hook over the Session Controller list and current selection. */
+export type UseSessions = SnapshotSelectorHook<SessionListState>
+/** Selector hook over one Session's lifecycle and control state. */
+export type SessionSnapshotSelector = SnapshotSelectorHook<SessionSnapshot>
+/** Public name for the Session lifecycle selector hook. */
+export type UseSession = SessionSnapshotSelector
+
+/** Common identity carried by every Session-scoped pending interaction. */
+export interface SessionPendingInteractionBase {
+  /** Opaque request identity; a replacement request must use a new key. */
+  readonly key: string
+  /** Domain-owned presentation discriminator. */
+  readonly kind: string
+  /** Session whose UI can answer this interaction. */
+  readonly sessionId: SessionId
+}
+
+/** Declaration-merged roster of domain-owned pending interaction values. */
+export interface SessionPendingInteractionMap {}
+
+/** Every pending interaction 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>
+
+class PendingInteractionDomain<T extends SessionPendingInteractionBase> {
+  private readonly values = new Map<string, T>()
+
+  constructor(
+    readonly precedence: (interaction: T) => number,
+    private readonly changed: () => void,
+  ) {}
+
+  valuesSnapshot(): readonly T[] {
+    return [...this.values.values()]
+  }
+
+  publish(interaction: T): () => void {
+    if (this.values.has(interaction.key)) {
+      throw new Error(`ui-session: duplicate pending interaction key '${interaction.key}'`)
+    }
+    this.values.set(interaction.key, interaction)
+    this.changed()
+    let active = true
+    return () => {
+      if (!active) return
+      active = false
+      if (this.values.get(interaction.key) !== interaction) return
+      this.values.delete(interaction.key)
+      this.changed()
+    }
+  }
+}
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface GlobalStandardProps {
+    /** Session list and current selection. */
+    useSessions: UseSessions
+    /** Pending user interaction presented by a Session-scoped UI consumer. */
+    useSessionPendingInteraction: UseSessionPendingInteraction
+  }
+
+  interface SessionStandardProps {
+    /** Current Session lifecycle and control state. */
+    useSession: SessionSnapshotSelector
+    /** Current Session identity. */
+    sessionId: SessionId
+    /** Host-computed projection values addressed by projection key. */
+    useProjection: UseProjection
+  }
+
+  interface SessionMaybeStandardProps {
+    /** Current Session state, absent while no Session is selected. */
+    useSession: MaybeSnapshotSelectorHook<SessionSnapshot>
+    /** Current Session identity, absent while no Session is selected. */
+    sessionId: SessionId | undefined
+    /** Host-computed projection values; every key is absent without a Session. */
+    useProjection: UseProjection
+  }
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Session Controller adapter and session-scoped source registry. */
+    uiSession: UiSession
+  }
+}
+
+type SessionSourceRoster = readonly string[] | undefined
+type StandardMemberKind = 'hook' | 'keyed hook' | 'prop'
+
+type SessionSourceRecord<Roster extends SessionSourceRoster, Value> =
+  Roster extends readonly string[] ? Readonly<Record<Roster[number], Value>> : never
+
+/** Bare values produced by one Session-scoped source contribution. */
+export interface SessionSourceContribution<
+  Hooks extends SessionSourceRoster = SessionSourceRoster,
+  KeyedHooks extends SessionSourceRoster = SessionSourceRoster,
+  Props extends SessionSourceRoster = SessionSourceRoster,
+> {
+  readonly hooks?: SessionSourceRecord<Hooks, HostObservable<unknown>>
+  readonly keyedHooks?: SessionSourceRecord<KeyedHooks, KeyedStandardSource>
+  readonly props?: SessionSourceRecord<Props, unknown>
+}
+
+/** Static roster and per-Session resolver for one standard-props contribution. */
+export interface SessionSourceDescriptor<
+  Hooks extends SessionSourceRoster = SessionSourceRoster,
+  KeyedHooks extends SessionSourceRoster = SessionSourceRoster,
+  Props extends SessionSourceRoster = SessionSourceRoster,
+> {
+  readonly hooks?: Hooks
+  readonly keyedHooks?: KeyedHooks
+  readonly props?: Props
+  /**
+   * Resolve every declared member for one Session binding.
+   * @param binding - Controller-owned Session binding.
+   * @returns all declared bare sources and stable props.
+   */
+  resolve(binding: SessionBinding): SessionSourceContribution<
+    NoInfer<Hooks>,
+    NoInfer<KeyedHooks>,
+    NoInfer<Props>
+  >
+}
+
+interface RuntimeSessionSourceContribution {
+  readonly hooks?: Readonly<Record<string, HostObservable<unknown>>>
+  readonly keyedHooks?: Readonly<Record<string, KeyedStandardSource>>
+  readonly props?: Readonly<Record<string, unknown>>
+}
+
+interface RuntimeSessionSourceDescriptor {
+  readonly hooks?: readonly string[]
+  readonly keyedHooks?: readonly string[]
+  readonly props?: readonly string[]
+  resolve(binding: SessionBinding): RuntimeSessionSourceContribution
+}
+
+type RuntimePendingDomain = PendingInteractionDomain<SessionPendingInteractionBase>
+
+interface MaterializedBinding {
+  readonly owner: SessionBinding
+  readonly value: ScopedStandardSourceBinding
+  readonly release: () => void
+}
+
+const BUILTIN_SOURCE = {
+  hooks: ['session'],
+  keyedHooks: ['projection'],
+  props: ['sessionId'],
+  resolve: binding => ({
+    hooks: { session: binding.session },
+    keyedHooks: { projection: key => binding.session.projections.faceOf(key) },
+    props: { sessionId: binding.sessionId },
+  }),
+} satisfies SessionSourceDescriptor<
+  readonly ['session'],
+  readonly ['projection'],
+  readonly ['sessionId']
+>
+
+/** Session-scoped source roster and renderer adapter. */
+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 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 as SessionPendingInteractionSnapshot,
+    subscribe: (listener) => {
+      this.pendingListeners.add(listener)
+      return () => { this.pendingListeners.delete(listener) }
+    },
+  }
+  /** Renderer-facing adapter for `session` and `session-maybe` scopes. */
+  readonly adapter: SlotScopeAdapter
+
+  /**
+   * @param ctx - Client root context.
+   * @param sessions - Controller-owned Session object layer.
+   */
+  constructor(
+    ctx: Context,
+    private readonly sessions: ISessions,
+  ) {
+    super(ctx, 'uiSession')
+    this.absent = this.materializeAbsent()
+    this.currentBinding = this.resolveCurrent()
+    this.adapter = {
+      current: {
+        getSnapshot: () => this.currentBinding,
+        subscribe: (listener) => {
+          this.currentListeners.add(listener)
+          return () => { this.currentListeners.delete(listener) }
+        },
+      },
+      resolve: key => this.resolve(key as SessionId),
+      renderArea: renderSessionArea,
+    }
+
+    ctx.effect(() => {
+      const disposeList = sessions.list.subscribe(() => { this.publishCurrent() })
+      return () => {
+        disposeList()
+        const records = [...this.bindings.values()]
+        this.bindings.clear()
+        for (const record of records) record.release()
+      }
+    }, 'ui-session: Session binding projection')
+  }
+
+  /**
+   * Register one Session-scoped standard-source contribution.
+   * @param descriptor - static member roster and per-binding resolver.
+   * @returns disposer owned by the caller's Cordis fiber.
+   */
+  provide<
+    const Hooks extends SessionSourceRoster = undefined,
+    const KeyedHooks extends SessionSourceRoster = undefined,
+    const Props extends SessionSourceRoster = undefined,
+  >(descriptor: SessionSourceDescriptor<Hooks, KeyedHooks, Props>): () => void {
+    const runtimeDescriptor = descriptor as unknown as RuntimeSessionSourceDescriptor
+    const dispose = this.ctx.effect(() => {
+      this.descriptors.push(runtimeDescriptor)
+      try {
+        this.rebuildBindings()
+      } catch (error) {
+        this.descriptors.pop()
+        throw error
+      }
+      return () => {
+        const index = this.descriptors.indexOf(runtimeDescriptor)
+        this.descriptors.splice(index, 1)
+        this.rebuildBindings()
+      }
+    }, 'uiSession.provide()')
+    return () => { void dispose() }
+  }
+
+  /**
+   * Register one pending-interaction domain and return its publication function.
+   * @param precedence - deterministic cross-domain precedence; larger values win.
+   * @returns a function that publishes one exact interaction until its disposer runs.
+   */
+  attend<T extends SessionPendingInteractionBase>(
+    precedence: (interaction: T) => number,
+  ): (interaction: T) => () => void {
+    const domain = new PendingInteractionDomain(precedence, () => {
+      this.publishPendingInteractions()
+    })
+    const runtimeDomain = domain as unknown as RuntimePendingDomain
+    this.ctx.effect(() => {
+      this.pendingDomains.push(runtimeDomain)
+      this.publishPendingInteractions()
+      return () => {
+        const index = this.pendingDomains.indexOf(runtimeDomain)
+        if (index !== -1) this.pendingDomains.splice(index, 1)
+        this.publishPendingInteractions()
+      }
+    }, 'uiSession.attend()')
+    return interaction => domain.publish(interaction)
+  }
+
+  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 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
+    const record = this.createMaterializedBinding(owner)
+    this.bindings.set(key, record)
+    cached?.release()
+    return record.value
+  }
+
+  private resolveCurrent(): StandardSourceBinding {
+    const current = this.sessions.list.getSnapshot().current
+    return current === undefined ? this.absent : this.resolve(current) ?? this.absent
+  }
+
+  private publishCurrent(): void {
+    const next = this.resolveCurrent()
+    if (next === this.currentBinding) return
+    this.currentBinding = next
+    notifySubscribers(this.currentListeners, '[ui-session] current binding')
+  }
+
+  private publishPendingInteractions(): void {
+    const next = new Map<SessionId, {
+      interaction: SessionPendingInteractionBase
+      precedence: number
+    }>()
+    for (const domain of this.pendingDomains) {
+      for (const interaction of domain.valuesSnapshot()) {
+        const precedence = domain.precedence(interaction)
+        const previous = next.get(interaction.sessionId)
+        if (previous === undefined || precedence >= previous.precedence) {
+          next.set(interaction.sessionId, { interaction, precedence })
+        }
+      }
+    }
+    const projected = new Map(
+      [...next].map(([sessionId, value]) => [sessionId, value.interaction] as const),
+    )
+    if (samePendingInteractions(this.pendingSnapshot, projected)) return
+    this.pendingSnapshot = projected
+    notifySubscribers(this.pendingListeners, '[ui-session] pending interactions')
+  }
+
+  private createMaterializedBinding(owner: SessionBinding): MaterializedBinding {
+    const value = this.materialize(owner)
+    let releaseEffect: () => void | Promise<void> = () => {}
+    const record: MaterializedBinding = {
+      owner,
+      value,
+      release: () => { void releaseEffect() },
+    }
+    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')
+    }, `ui-session: binding ${owner.sessionId}`)
+    return record
+  }
+
+  private materialize(binding: SessionBinding): ScopedStandardSourceBinding {
+    const hooks: Record<string, HostObservable<unknown>> = {}
+    const keyedHooks: Record<string, KeyedStandardSource> = {}
+    const props: Record<string, unknown> = {}
+    const finalProps = new Set<string>()
+    for (const descriptor of this.descriptors) {
+      const contribution = descriptor.resolve(binding)
+      validateContribution(descriptor, contribution)
+      copyDeclared('hook', hooks, descriptor.hooks, contribution.hooks, finalProps)
+      copyDeclared('keyed hook', keyedHooks, descriptor.keyedHooks, contribution.keyedHooks, finalProps)
+      copyDeclared('prop', props, descriptor.props, contribution.props, finalProps)
+    }
+    return {
+      key: binding.sessionId,
+      ctx: binding.ctx,
+      hooks,
+      keyedHooks,
+      props,
+    }
+  }
+
+  private materializeAbsent(): StandardSourceBinding {
+    const hooks: Record<string, undefined> = {}
+    const keyedHooks: Record<string, undefined> = {}
+    const props: Record<string, undefined> = {}
+    const finalProps = new Set<string>()
+    for (const descriptor of this.descriptors) {
+      declareAbsent('hook', hooks, descriptor.hooks, finalProps)
+      declareAbsent('keyed hook', keyedHooks, descriptor.keyedHooks, finalProps)
+      declareAbsent('prop', props, descriptor.props, finalProps)
+    }
+    return { key: undefined, hooks, keyedHooks, props }
+  }
+}
+
+function validateContribution(
+  descriptor: RuntimeSessionSourceDescriptor,
+  contribution: RuntimeSessionSourceContribution,
+): void {
+  rejectUndeclared('hook', descriptor.hooks, contribution.hooks)
+  rejectUndeclared('keyed hook', descriptor.keyedHooks, contribution.keyedHooks)
+  rejectUndeclared('prop', descriptor.props, contribution.props)
+}
+
+function rejectUndeclared(
+  kind: string,
+  declared: readonly string[] | undefined,
+  values: Readonly<Record<string, unknown>> | undefined,
+): void {
+  for (const name of Object.keys(values ?? {})) {
+    if (!(declared ?? []).includes(name)) {
+      throw new Error(`uiSession.provide: undeclared ${kind} '${name}'`)
+    }
+  }
+}
+
+function copyDeclared<T>(
+  kind: StandardMemberKind,
+  target: Record<string, T>,
+  declared: readonly string[] | undefined,
+  values: Readonly<Record<string, T>> | undefined,
+  finalProps: Set<string>,
+): void {
+  for (const name of declared ?? []) {
+    claimStandardProp(kind, name, finalProps)
+    const value = values?.[name]
+    if (value === undefined) throw new Error(`uiSession.provide: missing ${kind} '${name}'`)
+    target[name] = value
+  }
+}
+
+function declareAbsent(
+  kind: StandardMemberKind,
+  target: Record<string, undefined>,
+  declared: readonly string[] | undefined,
+  finalProps: Set<string>,
+): void {
+  for (const name of declared ?? []) {
+    claimStandardProp(kind, name, finalProps)
+    target[name] = undefined
+  }
+}
+
+function claimStandardProp(kind: StandardMemberKind, name: string, finalProps: Set<string>): void {
+  const propName = kind === 'prop' ? name : standardHookPropName(name)
+  if (finalProps.has(propName)) {
+    throw new Error(`uiSession.provide: duplicate ${kind} '${name}' at prop '${propName}'`)
+  }
+  finalProps.add(propName)
+}
+
+/** Required Controller and renderer services. */
+export const inject = ['sessions', 'slots']
+
+/**
+ * Install the Session root source and scoped adapter.
+ * @param ctx - Client Cordis context.
+ */
+export function apply(ctx: Context): void {
+  const service = new UiSession(ctx, ctx.sessions)
+  ctx.slots.provideRoot({
+    hooks: {
+      sessions: ctx.sessions.list,
+      sessionPendingInteraction: service.pendingInteractions,
+    },
+  } satisfies RootStandardSourceContribution)
+  ctx.slots.installScope('session', service.adapter)
+}
+
+function samePendingInteractions(
+  left: ReadonlyMap<SessionId, SessionPendingInteractionBase>,
+  right: ReadonlyMap<SessionId, SessionPendingInteractionBase>,
+): boolean {
+  if (left.size !== right.size) return false
+  for (const [sessionId, interaction] of left) {
+    if (right.get(sessionId) !== interaction) return false
+  }
+  return true
+}

+ 20 - 0
packages/client/ui-session/src/client/session-provider.tsx

@@ -0,0 +1,20 @@
+/** Session-owned rendering semantics for the standard SessionProvider seat. */
+import { Fragment, type ReactNode } from 'react'
+import type {
+  SessionAreaProps, StandardSourceBinding,
+} from '@deepseek-ai/dsh-client-ui-slots'
+
+/**
+ * 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.
+ */
+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>
+}

+ 4 - 0
packages/client/ui-session/src/index.ts

@@ -0,0 +1,4 @@
+/** Host loader entry for the browser-only Session UI adapter. */
+
+/** Provides no Host-side behavior. */
+export function apply(): void {}

+ 21 - 0
packages/client/ui-session/src/invariant.ts

@@ -0,0 +1,21 @@
+/** Package-owned invariant companion for the Session UI adapter. */
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-session'
+
+/** Cordis companion plugin name. */
+export const name = 'client-ui-session-invariant'
+/** Service required before the companion reserves package ownership. */
+export const inject = ['invariants']
+
+/** No runtime invariant: the adapter materialization path enforces Session binding consistency. */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

+ 469 - 0
packages/client/ui-session/tests/ui-session.client.spec.ts

@@ -0,0 +1,469 @@
+import { Context } from '@deepseek-ai/cordis'
+import InvariantRegistry from '@deepseek-ai/dsh-invariants'
+import type {
+  AgentContext,
+  ISessions,
+  SessionBinding,
+  SessionListState,
+  SessionSnapshot,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { Fragment } from 'react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+  apply,
+  type SessionPendingInteractionBase,
+  UiSession,
+} from '../src/client/index.ts'
+import { apply as nodeApply } from '../src/index.ts'
+import * as SessionInvariant from '../src/invariant.ts'
+
+interface SessionsBench {
+  readonly sessions: ISessions
+  readonly list: ReturnType<typeof createSnapshotStore<SessionListState>>
+  readonly resolveBinding: ReturnType<typeof vi.fn<(id: SessionId) => SessionBinding | undefined>>
+  readonly createSession: ReturnType<typeof vi.fn<ISessions['create']>>
+  readonly openSession: ReturnType<typeof vi.fn<(id: SessionId) => void>>
+  readonly clearSession: ReturnType<typeof vi.fn<() => void>>
+  binding(id: SessionId): SessionBinding
+  select(id: SessionId | undefined): void
+  release(id: SessionId): Promise<void>
+}
+
+const sessionId = (value: string): SessionId => value as SessionId
+
+function createSessionsBench(_ctx: Context): SessionsBench {
+  const list = createSnapshotStore<SessionListState>({
+    ids: [],
+    byId: {},
+    current: undefined,
+    phase: 'ready',
+    subagentsByParent: {},
+    jobsBySession: {},
+    currentAddress: undefined,
+  })
+  const bindings = new Map<SessionId, SessionBinding>()
+  const scopes = new Map<SessionId, Context>()
+  const resolveBinding = vi.fn((id: SessionId) => bindings.get(id))
+  const createSession = vi.fn<ISessions['create']>(async options =>
+    options?.sessionId ?? sessionId(`created-${String(options?.workspaceId ?? 'none')}`))
+  const openSession = vi.fn((id: SessionId) => {
+    list.update((draft) => { draft.current = id })
+  })
+  const clearSession = vi.fn(() => {
+    list.update((draft) => { draft.current = undefined })
+  })
+  const sessions = {
+    list,
+    create: createSession,
+    open: openSession,
+    clear: clearSession,
+    binding: resolveBinding,
+  } as unknown as ISessions
+
+  return {
+    sessions,
+    list,
+    resolveBinding,
+    createSession,
+    openSession,
+    clearSession,
+    binding(id) {
+      const scopeCtx = new Context()
+      const snapshot = createSnapshotStore<SessionSnapshot>({
+        sessionId: id,
+        queue: [],
+        running: false,
+        subagent: null,
+        removed: false,
+        openState: 'open',
+        openError: null,
+        hasMore: false,
+        loadingOlder: false,
+        promptError: null,
+        blank: false,
+        lastAgentError: null,
+        promptAttempted: false,
+        awaitingFirstTurn: false,
+      })
+      const projections = new Map<string, HostObservable<unknown>>()
+      const session = {
+        sessionId: id,
+        projections: {
+          faceOf(key: string) {
+            let source = projections.get(key)
+            if (source === undefined) {
+              source = createSnapshotStore<unknown>(undefined)
+              projections.set(key, source)
+            }
+            return source
+          },
+        },
+        getSnapshot: () => snapshot.getSnapshot(),
+        subscribe: (listener: () => void) => snapshot.subscribe(listener),
+      } as unknown as SessionBinding['session']
+      const binding: SessionBinding = {
+        sessionId: id,
+        session,
+        eventSource: new MutableSessionEventSource(),
+        ctx: scopeCtx as AgentContext,
+      }
+      bindings.set(id, binding)
+      scopes.set(id, scopeCtx)
+      list.update((draft) => {
+        if (!draft.ids.includes(id)) draft.ids.push(id)
+        draft.byId[id] = {
+          id,
+          displayTitle: id,
+          running: false,
+          blank: false,
+          updatedAt: 1,
+        }
+      })
+      return binding
+    },
+    select(id) {
+      list.update((draft) => { draft.current = id })
+    },
+    async release(id) {
+      bindings.delete(id)
+      const scopeCtx = scopes.get(id)
+      scopes.delete(id)
+      await scopeCtx?.fiber.dispose()
+    },
+  }
+}
+
+function createUiSession(ctx: Context, bench: SessionsBench): UiSession {
+  return new UiSession(ctx, bench.sessions)
+}
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
+
+describe('UiSession bindings', () => {
+  it('materializes built-in sources, caches a binding, and publishes selection and release', async () => {
+    const ctx = new Context()
+    const bench = createSessionsBench(ctx)
+    const service = createUiSession(ctx, bench)
+    const id = sessionId('s1')
+    const binding = bench.binding(id)
+    const current = vi.fn()
+    const offCurrent = service.adapter.current.subscribe(current)
+
+    expect(service.adapter.current.getSnapshot()).toEqual({
+      key: undefined,
+      hooks: { session: undefined },
+      keyedHooks: { projection: undefined },
+      props: { sessionId: undefined },
+    })
+    expect(service.adapter.resolve('missing')).toBeUndefined()
+
+    const first = service.adapter.resolve(id)!
+    expect(service.adapter.resolve(id)).toBe(first)
+    expect(first.key).toBe(id)
+    expect(first.hooks.session).toBe(binding.session)
+    expect(first.props.sessionId).toBe(id)
+    expect(first.keyedHooks.projection?.('status'))
+      .toBe(binding.session.projections.faceOf('status'))
+
+    bench.select(id)
+    expect(current).toHaveBeenCalledTimes(1)
+    expect(service.adapter.current.getSnapshot()).toBe(first)
+    bench.select(id)
+    expect(current).toHaveBeenCalledTimes(1)
+
+    bench.resolveBinding.mockClear()
+    await bench.release(id)
+    expect(bench.resolveBinding).not.toHaveBeenCalled()
+    expect(current).toHaveBeenCalledTimes(2)
+    expect(service.adapter.current.getSnapshot().key).toBeUndefined()
+
+    const other = sessionId('s2')
+    bench.binding(other)
+    service.adapter.resolve(other)
+    bench.resolveBinding.mockClear()
+    await bench.release(other)
+    expect(bench.resolveBinding).not.toHaveBeenCalled()
+    expect(current).toHaveBeenCalledTimes(2)
+
+    offCurrent()
+    await ctx.fiber.dispose()
+  })
+
+  it('renders the empty area and a Session-keyed selected area', () => {
+    const ctx = new Context()
+    const bench = createSessionsBench(ctx)
+    const service = createUiSession(ctx, bench)
+    const empty = vi.fn(() => 'empty')
+    const children = 'session body'
+    if (service.adapter.renderArea === undefined) throw new Error('Session area renderer was not installed')
+
+    const emptyArea = service.adapter.renderArea(
+      service.adapter.current.getSnapshot(),
+      { empty, children },
+    )
+    expect(emptyArea).toMatchObject({
+      type: Fragment,
+      key: null,
+      props: { children: 'empty' },
+    })
+    expect(empty).toHaveBeenCalledOnce()
+
+    const defaultEmptyArea = service.adapter.renderArea(
+      service.adapter.current.getSnapshot(),
+      { children },
+    )
+    expect(defaultEmptyArea).toMatchObject({
+      type: Fragment,
+      key: null,
+      props: { children: null },
+    })
+
+    const id = sessionId('s1')
+    bench.binding(id)
+    bench.select(id)
+    const selectedArea = service.adapter.renderArea(
+      service.adapter.current.getSnapshot(),
+      { empty, children },
+    )
+    expect(selectedArea).toMatchObject({
+      type: Fragment,
+      key: id,
+      props: { children },
+    })
+    expect(empty).toHaveBeenCalledOnce()
+  })
+
+  it('contains a failing current-binding subscriber and continues dispatch', () => {
+    const ctx = new Context()
+    const bench = createSessionsBench(ctx)
+    const service = createUiSession(ctx, bench)
+    const id = sessionId('s1')
+    bench.binding(id)
+    const failure = new Error('subscriber failed')
+    const report = vi.spyOn(console, 'error').mockImplementation(() => {})
+    service.adapter.current.subscribe(() => { throw failure })
+    const after = vi.fn()
+    service.adapter.current.subscribe(after)
+
+    bench.select(id)
+
+    expect(after).toHaveBeenCalledOnce()
+    expect(report).toHaveBeenCalledWith(
+      '[ui-session] current binding subscriber failed:',
+      failure,
+    )
+  })
+
+  it('rebuilds live bindings and removes only the disposed source contribution', () => {
+    const ctx = new Context()
+    const bench = createSessionsBench(ctx)
+    const service = createUiSession(ctx, bench)
+    const id = sessionId('s1')
+    bench.binding(id)
+    bench.select(id)
+    const custom = createSnapshotStore({ value: 1 })
+    const keyed = (key: string): HostObservable<unknown> => createSnapshotStore(key)
+
+    const dispose = service.provide({
+      hooks: ['custom'],
+      keyedHooks: ['customKeyed'],
+      props: ['customProp'],
+      resolve: () => ({
+        hooks: { custom },
+        keyedHooks: { customKeyed: keyed },
+        props: { customProp: 'value' },
+      }),
+    })
+    const disposeNeighbor = service.provide({
+      props: ['neighborProp'],
+      resolve: () => ({ props: { neighborProp: 'neighbor' } }),
+    })
+
+    const contributed = service.adapter.current.getSnapshot()
+    expect(contributed.hooks.custom).toBe(custom)
+    expect(contributed.keyedHooks.customKeyed).toBe(keyed)
+    expect(contributed.props.customProp).toBe('value')
+    expect(contributed.props.neighborProp).toBe('neighbor')
+
+    dispose()
+    const restored = service.adapter.current.getSnapshot()
+    expect(restored.hooks.session).toBeDefined()
+    expect(typeof restored.keyedHooks.projection).toBe('function')
+    expect(restored.props.sessionId).toBe(id)
+    expect(restored.hooks).not.toHaveProperty('custom')
+    expect(restored.props.neighborProp).toBe('neighbor')
+    dispose()
+    expect(service.adapter.current.getSnapshot().props.neighborProp).toBe('neighbor')
+
+    disposeNeighbor()
+    expect(service.adapter.current.getSnapshot().props).not.toHaveProperty('neighborProp')
+  })
+
+  it.each([
+    ['hook', { resolve: () => ({ hooks: { surprise: createSnapshotStore(1) } }) }],
+    ['keyed hook', { resolve: () => ({ keyedHooks: { surprise: () => createSnapshotStore(1) } }) }],
+    ['prop', { resolve: () => ({ props: { surprise: 1 } }) }],
+  ] as const)('rejects an undeclared %s returned by a contribution', (kind, descriptor) => {
+    const ctx = new Context()
+    const bench = createSessionsBench(ctx)
+    const service = createUiSession(ctx, bench)
+    service.adapter.resolve(bench.binding(sessionId('s1')).sessionId)
+
+    expect(() => { service.provide(descriptor as never) })
+      .toThrow(`uiSession.provide: undeclared ${kind} 'surprise'`)
+  })
+
+  it.each([
+    ['hook', { hooks: ['missing'], resolve: () => ({}) }],
+    ['keyed hook', { keyedHooks: ['missing'], resolve: () => ({}) }],
+    ['prop', { props: ['missing'], resolve: () => ({}) }],
+  ] as const)('rejects a missing declared %s', (kind, descriptor) => {
+    const ctx = new Context()
+    const bench = createSessionsBench(ctx)
+    const service = createUiSession(ctx, bench)
+    service.adapter.resolve(bench.binding(sessionId('s1')).sessionId)
+
+    expect(() => { service.provide(descriptor) })
+      .toThrow(`uiSession.provide: missing ${kind} 'missing'`)
+  })
+
+  it.each([
+    ['hook', { hooks: ['session'], resolve: () => ({ hooks: { session: createSnapshotStore(1) } }) }],
+    ['keyed hook', {
+      keyedHooks: ['projection'],
+      resolve: () => ({ keyedHooks: { projection: () => createSnapshotStore(1) } }),
+    }],
+    ['prop', { props: ['sessionId'], resolve: () => ({ props: { sessionId: 'other' } }) }],
+  ] as const)('rejects a duplicate declared %s', (kind, descriptor) => {
+    const ctx = new Context()
+    const bench = createSessionsBench(ctx)
+    const service = createUiSession(ctx, bench)
+
+    expect(() => { service.provide(descriptor) })
+      .toThrow(`uiSession.provide: duplicate ${kind}`)
+  })
+
+  it('rejects cross-compartment collisions at the final standard prop name', () => {
+    const ctx = new Context()
+    const bench = createSessionsBench(ctx)
+    const service = createUiSession(ctx, bench)
+    const source = createSnapshotStore(1)
+    service.provide({
+      hooks: ['feature'],
+      resolve: () => ({ hooks: { feature: source } }),
+    })
+    const before = service.adapter.current.getSnapshot()
+
+    expect(() => service.provide({
+      keyedHooks: ['feature'],
+      resolve: () => ({ keyedHooks: { feature: () => source } }),
+    })).toThrow("uiSession.provide: duplicate keyed hook 'feature' at prop 'useFeature'")
+    expect(() => service.provide({
+      props: ['useFeature'],
+      resolve: () => ({ props: { useFeature: true } }),
+    })).toThrow("uiSession.provide: duplicate prop 'useFeature' at prop 'useFeature'")
+    expect(service.adapter.current.getSnapshot()).toBe(before)
+  })
+})
+
+describe('UiSession pending interactions', () => {
+  it('publishes the highest-precedence exact object and removes each source independently', () => {
+    const ctx = new Context()
+    const bench = createSessionsBench(ctx)
+    const service = createUiSession(ctx, bench)
+    const id = sessionId('s1')
+    const listener = vi.fn()
+    const off = service.pendingInteractions.subscribe(listener)
+    const attendApproval = service.attend<SessionPendingInteractionBase>(() => 0)
+    const attendQuestion = service.attend<SessionPendingInteractionBase>(
+      interaction => interaction.kind === 'plan-review' ? 2 : 1,
+    )
+    listener.mockClear()
+
+    const approval = { key: 'approval:1', kind: 'approval', sessionId: id }
+    const duplicate = { key: 'approval:2', kind: 'approval', sessionId: id }
+    const question = { key: 'question:1', kind: 'question', sessionId: id }
+    const plan = { key: 'question:2', kind: 'plan-review', sessionId: id }
+    const removeApproval = attendApproval(approval)
+    expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval)
+    const removeDuplicate = attendApproval(duplicate)
+    expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate)
+    const removeQuestion = attendQuestion(question)
+    expect(service.pendingInteractions.getSnapshot().get(id)).toBe(question)
+    const removePlan = attendQuestion(plan)
+    expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
+
+    removeQuestion()
+    expect(service.pendingInteractions.getSnapshot().get(id)).toBe(plan)
+    removePlan()
+    expect(service.pendingInteractions.getSnapshot().get(id)).toBe(duplicate)
+    removeDuplicate()
+    expect(service.pendingInteractions.getSnapshot().get(id)).toBe(approval)
+    removeApproval()
+    expect(service.pendingInteractions.getSnapshot().has(id)).toBe(false)
+    off()
+  })
+
+  it('rejects duplicate keys and contains a failing aggregate subscriber', () => {
+    const ctx = new Context()
+    const bench = createSessionsBench(ctx)
+    const service = createUiSession(ctx, bench)
+    const attend = service.attend<SessionPendingInteractionBase>(() => 1)
+    const interaction = { key: 'question:1', kind: 'question', sessionId: sessionId('s1') }
+    const remove = attend(interaction)
+    expect(() => { attend(interaction) })
+      .toThrow("ui-session: duplicate pending interaction key 'question:1'")
+
+    const failure = new Error('pending subscriber failed')
+    const report = vi.spyOn(console, 'error').mockImplementation(() => {})
+    service.pendingInteractions.subscribe(() => { throw failure })
+    const after = vi.fn()
+    service.pendingInteractions.subscribe(after)
+
+    remove()
+
+    expect(after).toHaveBeenCalledOnce()
+    expect(report).toHaveBeenCalledWith(
+      '[ui-session] pending interactions subscriber failed:',
+      failure,
+    )
+  })
+})
+
+describe('ui-session apply', () => {
+  it('provides the root sources and installs the Session scope adapter', () => {
+    const ctx = new Context()
+    const bench = createSessionsBench(ctx)
+    const slots = {
+      provideRoot: vi.fn(),
+      installScope: vi.fn(),
+    }
+    ctx.provide('sessions', bench.sessions)
+    ctx.provide('slots', slots as never)
+
+    apply(ctx)
+
+    expect(ctx.uiSession).toBeInstanceOf(UiSession)
+    expect(slots.provideRoot).toHaveBeenCalledWith({
+      hooks: {
+        sessions: bench.sessions.list,
+        sessionPendingInteraction: ctx.uiSession.pendingInteractions,
+      },
+    })
+    expect(slots.installScope).toHaveBeenCalledWith('session', ctx.uiSession.adapter)
+  })
+
+  it('keeps the Host loader half inert and registers the invariant companion', async () => {
+    expect(() => { nodeApply() }).not.toThrow()
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry, { enabled: true })
+
+    await expect(ctx.plugin(SessionInvariant).await()).resolves.toBeDefined()
+  })
+})

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

@@ -0,0 +1,36 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../api/session-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../../api/workspace-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../store"
+    },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../../runtime-diagnostics/invariants"
+    }
+  ]
+}

+ 3 - 0
packages/client/ui-session/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-session', ['lib/types/index.js', 'lib/types/invariant.js'])

+ 9 - 3
packages/client/ui-sidebar/package.json

@@ -32,8 +32,10 @@
   "dsh": {
     "client": {
       "inject": [
-        "@deepseek-ai/dsh-client-runtime",
+        "@deepseek-ai/dsh-api-workspace-controller",
+        "@deepseek-ai/dsh-client-ui-renderer",
         "@deepseek-ai/dsh-client-ui-layout",
+        "@deepseek-ai/dsh-client-ui-session",
         "@deepseek-ai/dsh-client-locale"
       ],
       "platform": "web"
@@ -48,19 +50,23 @@
     "clsx": "^2.0.0"
   },
   "peerDependencies": {
+    "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",

+ 8 - 4
packages/client/ui-sidebar/src/client/index.ts

@@ -1,7 +1,11 @@
 /** Registers the sidebar shell into the layout-owned slot. */
-import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
+// Type-only: pulls the SlotRegistry service merge (ctx.slots).
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+// Type-only: pulls the Session UI navigation service merge (ctx.uiSession).
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type { SidebarRootInjected } from './contract/slots.ts'
 import { SidebarRoot } from './SidebarRoot.tsx'
 import { en, zh, type SidebarKey } from './locales.ts'
@@ -23,7 +27,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 const NS = 'sidebar'
 
 /** Services required by the sidebar plugin. */
-export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
+export const inject = ['slots', 'layout', 'uiSession', 'locale']
 
 /** Registers the sidebar shell and its service callbacks.
  * @param ctx - Client root context.
@@ -32,9 +36,9 @@ export function apply(ctx: ClientContext): void {
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar: dictionaries')
 
   const injectProps = (): SidebarRootInjected => ({
-    // The shell's New Session button rides the runtime's shared action
+    // The shell's New Session button rides the Session UI's shared action
     // (current Session Workspace, then recent Workspace).
-    startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
+    startSession: (workspaceId) => { ctx.uiSession.startSession(workspaceId) },
     toggleSidebar: () => { ctx.layout.toggleSidebar() },
   })
   ctx.effect(

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

@@ -1,7 +1,7 @@
-/** Sidebar shell slot registration and its plain runtime/layout callbacks. */
+/** Sidebar shell slot registration and its Session/layout callbacks. */
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
-import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
+import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -10,11 +10,9 @@ async function bench(declare = true) {
   const ctx = new Context()
   await ctx.plugin(SlotRegistry).await()
   const layout = { toggleSidebar: vi.fn() }
-  const workspaces = { startSession: vi.fn() }
-  const sessions = { open: vi.fn(), clear: vi.fn() }
+  const uiSession = { startSession: vi.fn() }
   ctx.provide('layout', layout)
-  ctx.provide('sessions', sessions as never)
-  ctx.provide('workspaces', workspaces as never)
+  ctx.provide('uiSession', uiSession as never)
   ctx.provide('locale', new LocaleRuntime(ctx))
   const slots = ctx.get('slots') as SlotRegistry
   if (declare) {
@@ -23,12 +21,12 @@ async function bench(declare = true) {
       () => null,
     )
   }
-  return { ctx, slots, layout, workspaces, sessions }
+  return { ctx, slots, layout, uiSession }
 }
 
 describe('ui-sidebar apply', () => {
   it('declares only the services it uses', () => {
-    expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale'])
+    expect(inject).toEqual(['slots', 'layout', 'uiSession', 'locale'])
   })
 
   it('registers the shell and declares its child seats', async () => {
@@ -44,11 +42,11 @@ describe('ui-sidebar apply', () => {
     expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
     const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
     expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
-    // Both arms delegate to the runtime's shared New Session action.
+    // Both arms delegate to the Session UI's shared New Session action.
     injected.startSession('workspace' as never)
-    expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace')
+    expect(b.uiSession.startSession).toHaveBeenCalledWith('workspace')
     injected.startSession()
-    expect(b.workspaces.startSession).toHaveBeenLastCalledWith(undefined)
+    expect(b.uiSession.startSession).toHaveBeenLastCalledWith(undefined)
     injected.toggleSidebar()
     expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
   })

+ 3 - 2
packages/client/ui-sidebar/tests/sidebar-snapshot.client.spec.tsx

@@ -33,10 +33,11 @@ afterEach(() => {
  */
 async function bench(options: { locale?: 'en' } = {}) {
   const runtime = await SlotTestRuntime.create()
-  runtime.provide('layout', { toggleSidebar: vi.fn() })
+  runtime.ctx.provide('layout', { toggleSidebar: vi.fn() })
+  vi.spyOn(runtime.ctx.uiSession, 'startSession').mockImplementation(() => undefined)
   const locale = new LocaleRuntime(runtime.ctx)
   if (options.locale === 'en') locale.setLocale('en')
-  runtime.provide('locale', locale)
+  runtime.ctx.provide('locale', locale)
   runtime.slots.installLocale(locale)
   await runtime.declare({ 'sidebar': { kind: 'single', scope: 'root' } })
   await runtime.mount({ inject: [...inject], apply })

+ 7 - 1
packages/client/ui-sidebar/tsconfig.json

@@ -8,6 +8,9 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../../api/workspace-controller/tsconfig.client.json"
+    },
     {
       "path": "../../../vendor/cordis"
     },
@@ -18,7 +21,10 @@
       "path": "../ui-primitives"
     },
     {
-      "path": "../runtime"
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-session"
     },
     {
       "path": "../ui-layout"

+ 21 - 5
packages/client/ui-workspace/package.json

@@ -31,11 +31,18 @@
   },
   "dsh": {
     "client": {
+      "external": [
+        "@deepseek-ai/dsh-api-session-controller/client",
+        "@deepseek-ai/dsh-api-workspace-controller/client"
+      ],
       "inject": [
+        "@deepseek-ai/dsh-api-session-controller",
+        "@deepseek-ai/dsh-api-workspace-controller",
         "@deepseek-ai/dsh-client-connection",
         "@deepseek-ai/dsh-client-locale",
-        "@deepseek-ai/dsh-client-runtime",
         "@deepseek-ai/dsh-client-ui-conversation",
+        "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-session",
         "@deepseek-ai/dsh-client-ui-sidebar"
       ],
       "platform": "web"
@@ -50,24 +57,33 @@
     "clsx": "^2.0.0"
   },
   "peerDependencies": {
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
-    "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^"
+    "@deepseek-ai/dsh-session": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-client-connection": "workspace:^",
     "@deepseek-ai/dsh-client-locale": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^",
     "react": "^18.2.0"

+ 3 - 3
packages/client/ui-workspace/src/client/WorkspacePicker.tsx

@@ -14,8 +14,8 @@ import {
   Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {
-  WorkspaceId, WorkspaceListState, WorkspaceView,
-} from '@deepseek-ai/dsh-client-runtime/client'
+  WorkspaceId, WorkspaceSnapshot, WorkspaceView,
+} from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
 import css from './WorkspacePicker.module.css'
@@ -31,7 +31,7 @@ export interface WorkspacePickFlowProps {
   /** The anchor button element — the popover's placement anchor. */
   anchorRef?: RefObject<HTMLElement | null> | undefined
   /** Selector hook over the workspace list (framework standard hook). */
-  useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
+  useWorkspaces: <S>(selector: (state: WorkspaceSnapshot) => S) => S
   /** Adopt a picked host directory as a real Workspace. */
   createWorkspace: (input: { path: string }) => Promise<WorkspaceView>
   /** Bound occupancy selector hook for this surface's directory-flow hole (empty leaves the surface with no add action). */

+ 3 - 5
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -28,9 +28,9 @@ import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRu
 // runtime shares below.
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
-import type {
-  PendingInteractionStatus, SessionId, SessionSearchResultItem, WorkspaceId, WorkspaceView,
-} from '@deepseek-ai/dsh-client-runtime/client'
+import type { SessionSearchResultItem } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { createWorkspaceViewStore } from '../stores.ts'
 
 /**
@@ -91,8 +91,6 @@ export type WorkspaceBrowserInjected = {
   hooks: DirectoryPickingInjected['hooks'] & {
     /** Current generation's Host description, bound by the slot renderer. */
     hostDescription: HostDescriptionSource
-    /** Effective pending Remote Event interaction by Session. */
-    pendingInteractions: HostObservable<ReadonlyMap<SessionId, PendingInteractionStatus>>
   }
   /**
    * Start a New Session in a Workspace: reuse-or-create its blank session and

+ 41 - 24
packages/client/ui-workspace/src/client/index.ts

@@ -8,17 +8,29 @@
  * client half (see the contract module doc). Export discipline:
  * packages/client/AGENTS.md.
  */
+import type { Context } from '@deepseek-ai/cordis'
+import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
-import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
-import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import type { IWorkspaces, WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
+// Type-only: pulls the Controller service merges.
+import type {} from '@deepseek-ai/dsh-api-session-controller/client'
+import type {} from '@deepseek-ai/dsh-api-workspace-controller/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
+// Type-only: pulls the SlotRegistry service merge (ctx.slots).
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+// Type-only: pulls the Session root standard-hook merge.
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
 import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
+import { UiWorkspaceService } from './navigation.ts'
 import { createWorkspaceViewStore } from './stores.ts'
-import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
+import { WorkspaceBrowser } from './rows/WorkspaceBrowser.tsx'
 import { WorkspacePicker } from './WorkspacePicker.tsx'
 import { en, zh, type WorkspaceKey } from './locales.ts'
 
+export { DirectoryBrowseError } from './navigation.ts'
+export type { UiWorkspace } from './navigation.ts'
 export type {
   DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected,
   WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
@@ -26,6 +38,11 @@ export type {
 export type { WorkspaceKey } from './locales.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface GlobalStandardProps {
+    /** Selector hook over the pure Workspace Controller snapshot. */
+    useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>
+  }
+
   interface LocaleNamespaceMap {
     /** The workspace browsing region and pick/create flow copy. */
     workspace: WorkspaceKey
@@ -43,7 +60,7 @@ const NS = 'workspace'
  * provides a waitable service. apply therefore depends on each slot
  * declaration through `slots.inject()` instead of assuming order.
  */
-export const inject = ['slots', 'sessions', 'workspaces', 'conversation', 'locale', 'connection']
+export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'connection']
 
 /**
  * Register the browser and picker once their slot declarations are on the
@@ -51,13 +68,17 @@ export const inject = ['slots', 'sessions', 'workspaces', 'conversation', 'local
  * framework's global hooks.
  * @param ctx - client root context.
  */
-export function apply(ctx: ClientContext): void {
+export function apply(ctx: Context): void {
   const connection = ctx.get('connection') as ConnectionHandle
+  const sessions = ctx.get('sessions') as ISessions
+  const workspaces = ctx.get('workspaces') as IWorkspaces
   const hostDescription = connection.hostDescription
+  const uiWorkspace = new UiWorkspaceService(ctx, connection.api, workspaces, sessions)
+  ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } })
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries')
 
   const searchSessions: WorkspaceBrowserInjected['searchSessions'] = async (query, signal) => {
-    const result = await ctx.sessions.search(query, signal)
+    const result = await sessions.search(query, signal)
     if (!result.ok) throw new Error(result.error.message)
     return result.value
   }
@@ -73,43 +94,39 @@ export function apply(ctx: ClientContext): void {
   const browserInjected = (): WorkspaceBrowserInjected => ({
     // Explicit group actions keep their target; unscoped New Session inherits
     // the current Session Workspace before the recent-Workspace fallback.
-    startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
-    open: (sessionId) => { ctx.sessions.open(sessionId) },
+    startSession: (workspaceId) => { uiWorkspace.startSession(workspaceId) },
+    open: (sessionId) => { sessions.open(sessionId) },
     searchSessions,
-    searchResultLimit: ctx.sessions.searchResultLimit,
+    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 = ctx.sessions.binding(sessionId)?.session
+      const session = sessions.binding(sessionId)?.session
       if (session === undefined) throw new Error(`unknown session "${sessionId}"`)
       const result = await session.rename(title)
       if (!result.ok) throw new Error(result.error.message)
     },
     forkSession: (sessionId) => {
-      ctx.sessions.fork({ sessionId, increaseTitle: true })
-        .then((childId) => { ctx.sessions.open(childId) })
+      sessions.fork({ sessionId, increaseTitle: true })
+        .then((childId) => { sessions.open(childId) })
         .catch(() => {
           // Fork or child-rename failure keeps the current selection.
         })
     },
-    renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
-    deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
+    renameWorkspace: async (workspaceId, title) => { await workspaces.rename(workspaceId, title) },
+    deleteWorkspace: async (workspaceId) => { await workspaces.delete(workspaceId) },
     insertWorkspaceBefore: async (workspaceId, beforeWorkspaceId) => {
-      await ctx.workspaces.insertBefore(workspaceId, beforeWorkspaceId)
+      await workspaces.insertBefore(workspaceId, beforeWorkspaceId)
     },
-    archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) },
+    archiveSession: async (sessionId) => { await uiWorkspace.archiveSession(sessionId) },
     insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
-      await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
-    },
-    createWorkspace: input => ctx.workspaces.create(input),
-    hooks: {
-      directoryFlow: browserFlowSource,
-      hostDescription,
-      pendingInteractions: ctx.conversation.pendingInteractions.statuses,
+      await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
     },
+    createWorkspace: input => workspaces.create(input),
+    hooks: { directoryFlow: browserFlowSource, hostDescription },
   })
   const pickerInjected = (): WorkspacePickerInjected => ({
-    createWorkspace: input => ctx.workspaces.create(input),
+    createWorkspace: input => workspaces.create(input),
     hooks: { directoryFlow: pickerFlowSource },
   })
   // Each registration declares its directory-flow child in the same call;

+ 248 - 0
packages/client/ui-workspace/src/client/navigation.ts

@@ -0,0 +1,248 @@
+/** Workspace archive and directory UI capability. */
+
+import { Service, type Context } from '@deepseek-ai/cordis'
+import type {
+  DirectoryListing, IApiClient, RpcError,
+} from '@deepseek-ai/dsh-client-connection/client'
+import type {
+  ISessions,
+  SessionListState,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import type {
+  IWorkspaces, WorkspaceId, WorkspaceView,
+} from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+
+/** Workspace archive and directory operations consumed by Client UI domains. */
+export interface UiWorkspace {
+  /**
+   * Resolve the reusable or newly created blank Session for a Workspace.
+   * @param workspaceId - target Workspace.
+   * @returns a Session already addressable through the Session Controller.
+   */
+  connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>
+  /**
+   * Start a New Session flow and navigate to its Session.
+   * @param workspaceId - explicit target; absent inherits the current or most recent Workspace.
+   */
+  startSession(workspaceId?: WorkspaceId): void
+  /**
+   * Archive a Session and clear it when it is the current selection.
+   * @param sessionId - Session to archive.
+   */
+  archiveSession(sessionId: SessionId): Promise<void>
+  /** @returns the Host-native picked directory, or null when cancelled. */
+  pickDirectory(): Promise<string | null>
+  /**
+   * List one Host directory level.
+   * @param path - directory path; absent selects the Host home.
+   * @param signal - cancellation for a superseded scan.
+   * @returns directory entries and breadcrumb ancestry.
+   */
+  listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
+  /**
+   * Create a child directory.
+   * @param path - existing parent directory.
+   * @param name - child directory name.
+   * @returns created absolute path.
+   */
+  createDirectory(path: string, name: string): Promise<string>
+  /**
+   * Open a path with the Host operating system.
+   * @param path - absolute or Host-resolvable path.
+   */
+  openPath(path: string): Promise<void>
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Cross-Controller Workspace navigation and directory UI capability. */
+    uiWorkspace: UiWorkspace
+  }
+}
+
+/** Structured directory failure exposed to directory UI consumers. */
+export class DirectoryBrowseError extends Error {
+  override readonly name = 'DirectoryBrowseError'
+
+  /** @param rpcError - Host directory business failure. */
+  constructor(readonly rpcError: RpcError) {
+    super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
+  }
+}
+
+/** Implements Workspace archive and directory UI operations. */
+class UiWorkspaceService extends Service implements UiWorkspace {
+  private readonly connecting = new Map<WorkspaceId, Promise<SessionId>>()
+
+  /**
+   * @param ctx - Client root Context.
+   * @param api - shared Host API carrier.
+   * @param workspaces - pure Workspace Controller.
+   * @param sessions - pure Session Controller.
+   */
+  constructor(
+    ctx: Context,
+    private readonly api: IApiClient,
+    private readonly workspaces: IWorkspaces,
+    private readonly sessions: ISessions,
+  ) {
+    super(ctx, 'uiWorkspace')
+    ctx.effect(() => this.watchNavigation(), 'ui-workspace: Workspace navigation policy')
+  }
+
+  async connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId> {
+    const workspace = this.workspaces.list.getSnapshot().items
+      .find(item => item.workspaceId === workspaceId)
+    if (workspace === undefined) {
+      throw new Error(`uiWorkspace.connectWorkspace: unknown workspace ${workspaceId}`)
+    }
+    const inflight = this.connecting.get(workspaceId)
+    if (inflight !== undefined) return inflight
+
+    const archived = this.workspaces.list.getSnapshot().archivedSessionIds
+    const sessions = this.sessions.list.getSnapshot()
+    for (const id of sessions.ids) {
+      const summary = sessions.byId[id]
+      if (summary !== undefined && summary.blank && summary.cwd === workspace.path
+        && workspace.sessionIds.includes(summary.id)
+        && !archived.includes(summary.id)) return summary.id
+    }
+
+    const attempt = this.sessions.create({ workspaceId })
+      .finally(() => { this.connecting.delete(workspaceId) })
+    this.connecting.set(workspaceId, attempt)
+    return attempt
+  }
+
+  startSession(workspaceId?: WorkspaceId): void {
+    const workspace = this.workspaces.list.getSnapshot()
+    const sessions = this.sessions.list.getSnapshot()
+    const current = sessions.current
+    const currentWorkspaceId = current === undefined
+      ? undefined
+      : workspace.items.find(item => item.sessionIds.includes(current))?.workspaceId
+    const recent = workspace.phase === 'ready' && sessions.phase === 'ready'
+      ? recentWorkspace(workspace.items, sessions.byId)
+      : undefined
+    const target = workspaceId ?? currentWorkspaceId ?? recent
+    if (target === undefined) {
+      this.sessions.clear()
+      return
+    }
+    void this.connectWorkspace(target).then(
+      (sessionId) => { this.sessions.open(sessionId) },
+      (reason: unknown) => { console.warn('new session failed:', reason) },
+    )
+  }
+
+  async archiveSession(sessionId: SessionId): Promise<void> {
+    await this.workspaces.archiveSession(sessionId)
+  }
+
+  async pickDirectory(): Promise<string | null> {
+    const response = await this.api.host.pickDirectory({})
+    if (!response.result.ok) {
+      throw new Error(`directory picker failed: ${response.result.error.message}`)
+    }
+    return response.result.value.path
+  }
+
+  async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
+    const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
+    if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
+    return response.result.value
+  }
+
+  async createDirectory(path: string, name: string): Promise<string> {
+    const response = await this.api.host.createDirectory({ path, name })
+    if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
+    return response.result.value.path
+  }
+
+  async openPath(path: string): Promise<void> {
+    const response = await this.api.host.openPath({ path })
+    if (!response.result.ok) {
+      throw new Error(`path open failed: ${response.result.error.message}`)
+    }
+  }
+
+  private watchNavigation(): () => void {
+    let initial: 'waiting' | 'connecting' | 'done' = 'waiting'
+    let disposed = false
+    const reconcile = (): void => {
+      if (disposed) return
+      if (this.clearArchivedCurrent()) return
+      if (initial !== 'waiting') return
+      const workspace = this.workspaces.list.getSnapshot()
+      const sessions = this.sessions.list.getSnapshot()
+      if (workspace.phase !== 'ready' || sessions.phase !== 'ready') return
+      if (sessions.current !== undefined) {
+        initial = 'done'
+        return
+      }
+      const target = recentWorkspace(workspace.items, sessions.byId)
+      if (target === undefined) {
+        initial = 'done'
+        return
+      }
+      initial = 'connecting'
+      void this.connectWorkspace(target).then(
+        (sessionId) => {
+          if (disposed) return
+          if (this.sessions.list.getSnapshot().current === undefined) {
+            this.sessions.open(sessionId)
+          }
+          initial = 'done'
+        },
+        (reason: unknown) => {
+          if (disposed) return
+          initial = 'waiting'
+          console.warn('initial workspace selection failed:', reason)
+        },
+      )
+    }
+    const disposeWorkspaces = this.workspaces.list.subscribe(reconcile)
+    const disposeSessions = this.sessions.list.subscribe(reconcile)
+    reconcile()
+    return () => {
+      disposed = true
+      disposeSessions()
+      disposeWorkspaces()
+    }
+  }
+
+  /** @returns true when an archived current selection was cleared. */
+  private clearArchivedCurrent(): boolean {
+    const current = this.sessions.list.getSnapshot().current
+    if (current === undefined
+      || !this.workspaces.list.getSnapshot().archivedSessionIds.includes(current)) return false
+    this.sessions.clear()
+    return true
+  }
+
+}
+
+/** Stable tie-breaking follows Host Workspace order. */
+function recentWorkspace(
+  workspaces: readonly WorkspaceView[],
+  sessions: SessionListState['byId'],
+): WorkspaceId | undefined {
+  let selected: WorkspaceId | undefined
+  let selectedTime = Number.NEGATIVE_INFINITY
+  for (const workspace of workspaces) {
+    let latest = Number.NEGATIVE_INFINITY
+    for (const sessionId of workspace.sessionIds) {
+      const session = sessions[sessionId]
+      if (session !== undefined) latest = Math.max(latest, session.updatedAt)
+    }
+    if (latest === Number.NEGATIVE_INFINITY) latest = Date.parse(workspace.createdAt)
+    if (selected === undefined || latest > selectedTime) {
+      selected = workspace.workspaceId
+      selectedTime = latest
+    }
+  }
+  return selected
+}
+
+export { UiWorkspaceService }

+ 1 - 1
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -13,7 +13,7 @@ import {
   IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
-import { abbreviateHomePath } from '@deepseek-ai/dsh-client-runtime/client'
+import { abbreviateHomePath } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { WorkspaceBrowserProps } from '../contract/slots.ts'
 import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
 import { relativeTime } from '../tree.ts'

+ 0 - 0
packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css → packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css


+ 35 - 25
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx → packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -16,14 +16,16 @@ import {
   IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {
-  SessionId, SessionListState, SessionSearchResultItem, WorkspaceId, WorkspaceView,
-} from '@deepseek-ai/dsh-client-runtime/client'
-import type { WorkspaceBrowserProps } from './contract/slots.ts'
-import type { SessionNode, SessionOrderBy } from './tree.ts'
-import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
-import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
-import { FLAT_SESSION_ORDER_KEY } from './stores.ts'
-import { WorkspacePickFlow } from './WorkspacePicker.tsx'
+  SessionListState, SessionSearchResultItem,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceBrowserProps } from '../contract/slots.ts'
+import type { SessionNode, SessionOrderBy } from '../tree.ts'
+import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from '../tree.ts'
+import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx'
+import { FLAT_SESSION_ORDER_KEY } from '../stores.ts'
+import { WorkspacePickFlow } from '../WorkspacePicker.tsx'
 import css from './WorkspaceBrowser.module.css'
 
 /**
@@ -215,7 +217,7 @@ function workspaceGroupHalf(e: { clientY: number; currentTarget: HTMLElement }):
 
 type SessionTreeProps = Pick<
   WorkspaceBrowserProps,
-  'useSessions' | 'usePendingInteractions' | 'startSession' | 'open' | 'forkSession'
+  'useSessions' | 'useSessionPendingInteraction' | 'startSession' | 'open' | 'forkSession'
   | 'insertWorkspaceBefore' | 'insertSessionBefore' | 't'
 > & {
   /** Host account home for POSIX hover-path abbreviation. */
@@ -249,14 +251,14 @@ type SessionTreeProps = Pick<
 
 /** The scrolling session tree; unmounting drops the sessions subscription and expand-all state. */
 function SessionTree({
-  useSessions, usePendingInteractions, startSession, open, forkSession, workspaces, archivedSessionIds,
+  useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds,
   onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive,
   insertWorkspaceBefore, insertSessionBefore, orderBy,
   groupExpansion, setGroupExpanded,
   sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t,
 }: SessionTreeProps) {
   const list = useSessions(s => s)
-  const pendingInteractions = usePendingInteractions(s => s)
+  const pendingInteractions = useSessionPendingInteraction(s => s)
   const current = list.current
   const [expandedSessionGroups, setExpandedSessionGroups] = useState<string[]>([])
   // Transient drag marker state; the selected mode owns the resulting order.
@@ -281,7 +283,7 @@ function SessionTree({
   )
   const ungroupedSessionIds = useMemo(() => {
     const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
-    return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id))
+    return list.ids.filter((id: SessionId) => list.byId[id] !== undefined && !accounted.has(id))
   }, [list, workspaces])
   useEffect(() => {
     if (list.phase !== 'ready') return
@@ -548,12 +550,13 @@ function SessionTree({
 
 /** The flat "In one list" body: every session is one draggable top-level row. */
 function FlatList({
-  useSessions, usePendingInteractions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds,
+  useSessions, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive,
+  archivedSessionIds,
   orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, t,
 }: Pick<
   SessionTreeProps,
   | 'useSessions'
-  | 'usePendingInteractions'
+  | 'useSessionPendingInteraction'
   | 'open'
   | 'forkSession'
   | 'onSessionRename'
@@ -567,7 +570,7 @@ function FlatList({
   | 't'
 >) {
   const list = useSessions(s => s)
-  const pendingInteractions = usePendingInteractions(s => s)
+  const pendingInteractions = useSessionPendingInteraction(s => s)
   const baseRows = useMemo(
     () => deriveFlat(list, archivedSessionIds, pendingInteractions),
     [list, archivedSessionIds, pendingInteractions],
@@ -678,7 +681,7 @@ interface RemoteSearchState {
 /** Flat search body: local metadata matches plus the current Host result page. */
 function SearchResults({
   useSessions,
-  usePendingInteractions,
+  useSessionPendingInteraction,
   open,
   workspaces,
   archivedSessionIds,
@@ -686,7 +689,7 @@ function SearchResults({
   remote,
   resultLimit,
   t,
-}: Pick<SessionTreeProps, 'useSessions' | 'usePendingInteractions' | 'open' | 't'> & {
+}: Pick<SessionTreeProps, 'useSessions' | 'useSessionPendingInteraction' | 'open' | 't'> & {
   workspaces: readonly WorkspaceView[]
   archivedSessionIds: readonly SessionNode['id'][]
   query: string
@@ -694,13 +697,19 @@ function SearchResults({
   resultLimit: number
 }) {
   const list = useSessions(s => s)
-  const pendingInteractions = usePendingInteractions(s => s)
+  const pendingInteractions = useSessionPendingInteraction(s => s)
   const currentRemote = remote.query === query
     ? remote
     : { query, status: 'loading' as const, items: [], hasMore: false }
   const results = useMemo(
     () => deriveSearchResults(
-      list, workspaces, query, archivedSessionIds, pendingInteractions, currentRemote, resultLimit,
+      list,
+      workspaces,
+      query,
+      archivedSessionIds,
+      pendingInteractions,
+      currentRemote,
+      resultLimit,
     ),
     [list, workspaces, query, archivedSessionIds, pendingInteractions, currentRemote, resultLimit],
   )
@@ -752,7 +761,7 @@ export function WorkspaceBrowser({
   wide,
   expandSidebar,
   useSessions,
-  usePendingInteractions,
+  useSessionPendingInteraction,
   useWorkspaces,
   useStore,
   actions,
@@ -799,8 +808,9 @@ export function WorkspaceBrowser({
       promotedBlank.current = undefined
       return
     }
-    if (promotedBlank.current?.sessionId === currentBlankSessionId
-      && promotedBlank.current.accountKey === currentBlankAccount) return
+    const promoted = promotedBlank.current
+    if (promoted !== undefined && promoted.sessionId === currentBlankSessionId
+      && promoted.accountKey === currentBlankAccount) return
     promotedBlank.current = { sessionId: currentBlankSessionId, accountKey: currentBlankAccount }
     for (const accountKey of new Set([currentBlankAccount, FLAT_SESSION_ORDER_KEY])) {
       const previous = sessionOrderByAccount[accountKey] ?? []
@@ -1153,7 +1163,7 @@ export function WorkspaceBrowser({
           ? (
             <SearchResults
               useSessions={useSessions}
-              usePendingInteractions={usePendingInteractions}
+              useSessionPendingInteraction={useSessionPendingInteraction}
               open={open}
               workspaces={workspaces}
               archivedSessionIds={archivedSessionIds}
@@ -1166,7 +1176,7 @@ export function WorkspaceBrowser({
           : groupBy === 'flat'
             ? (
               <FlatList
-                useSessions={useSessions} usePendingInteractions={usePendingInteractions}
+                useSessions={useSessions} useSessionPendingInteraction={useSessionPendingInteraction}
                 open={open} forkSession={forkSession}
                 onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
                 archivedSessionIds={archivedSessionIds}
@@ -1181,7 +1191,7 @@ export function WorkspaceBrowser({
             : (
               <SessionTree
                 useSessions={useSessions}
-                usePendingInteractions={usePendingInteractions}
+                useSessionPendingInteraction={useSessionPendingInteraction}
                 onSessionRename={onSessionRename}
                 onSessionArchive={onSessionArchive}
                 forkSession={forkSession}

+ 1 - 1
packages/client/ui-workspace/src/client/stores.ts

@@ -5,7 +5,7 @@
  * register() receives the factory and the browser derives its PropsStore
  * share from the return type.
  */
-import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
+import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
 
 /** Browser-local order account for the hierarchy-free flat Session list. */
 export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__'

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

@@ -4,12 +4,14 @@
  * remains visible.
  */
 import {
-  indexSubagentDescendants, type PendingInteractionStatus, type SessionId, type SessionListState,
+  indexSubagentDescendants, type SessionListState,
   type SessionSearchResultItem, type SessionSummary, type SubagentDescendantSummary,
-  type WorkspaceId, type WorkspaceView,
-} from '@deepseek-ai/dsh-client-runtime/client'
-
-type PendingInteractions = ReadonlyMap<SessionId, PendingInteractionStatus>
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type {
+  SessionPendingInteractionBase,
+} from '@deepseek-ai/dsh-client-ui-session/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
 /** Group key for Sessions outside every Workspace. */
 export const UNGROUPED_KEY = ''
@@ -17,6 +19,10 @@ export const UNGROUPED_KEY = ''
 /** Display label for the ungrouped bucket row. */
 export const UNGROUPED_LABEL = 'Ungrouped'
 
+/** Pending interaction kinds with dedicated Workspace-row presentation. */
+export type SessionPendingInteractionStatus = 'approval' | 'plan-review' | 'question'
+type SessionPendingInteractions = ReadonlyMap<SessionId, SessionPendingInteractionBase>
+
 /** One top-level session row in a group or the flat list. */
 export interface SessionNode {
   id: SessionId
@@ -24,8 +30,8 @@ export interface SessionNode {
   title: string
   /** The provisional blank session (renderer shows the localized New Session title). */
   blank: boolean
-  /** A Remote Event interaction awaiting this user. */
-  pendingInteraction?: PendingInteractionStatus
+  /** A Session-scoped UI consumer is awaiting this user. */
+  pendingInteraction?: SessionPendingInteractionStatus
   running: boolean
   /** Running descendants connected through uninterrupted subagent-origin lineage. */
   runningSubagentCount: number
@@ -61,8 +67,8 @@ export interface SearchResultNode {
   id: SessionId
   title: string
   workspace: string
-  /** A Remote Event interaction awaiting this user. */
-  pendingInteraction?: PendingInteractionStatus
+  /** A Session-scoped UI consumer is awaiting this user. */
+  pendingInteraction?: SessionPendingInteractionStatus
   running: boolean
   /** Running descendants connected through uninterrupted subagent-origin lineage. */
   runningSubagentCount: number
@@ -213,12 +219,24 @@ function groupByWorkspace(
   return groups
 }
 
+/** Keep navigation presentation independent from domain-owned interaction objects. */
+function visiblePendingKind(kind: string | undefined): SessionPendingInteractionStatus | undefined {
+  switch (kind) {
+    case 'approval':
+    case 'plan-review':
+    case 'question':
+      return kind
+    default:
+      return undefined
+  }
+}
+
 function sessionNode(
   s: SessionSummary,
   descendants: ReadonlyMap<SessionId, SubagentDescendantSummary>,
-  pendingInteractions: PendingInteractions,
+  pendingInteractions: SessionPendingInteractions,
 ): SessionNode {
-  const pendingInteraction = pendingInteractions.get(s.id)
+  const pendingInteraction = visiblePendingKind(pendingInteractions.get(s.id)?.kind)
   return {
     id: s.id,
     title: sessionTitle(s),
@@ -242,7 +260,7 @@ function sessionNode(
  * @param list - sessions list snapshot (`current` feeds containsCurrent).
  * @param workspaces - real workspaces in stable Host order.
  * @param archivedSessionIds - registry-global archive set.
- * @param pendingInteractions - pending Remote Event presentation by Session.
+ * @param pendingInteractions - pending UI interactions by Session.
  * @param view - local expansion arrays.
  * @returns group sections in render order.
  */
@@ -250,7 +268,7 @@ export function deriveGroups(
   list: SessionListState,
   workspaces: readonly WorkspaceView[],
   archivedSessionIds: readonly SessionId[],
-  pendingInteractions: PendingInteractions,
+  pendingInteractions: SessionPendingInteractions,
   view: TreeView,
 ): GroupNode[] {
   const archived = new Set(archivedSessionIds)
@@ -287,13 +305,13 @@ export function deriveGroups(
  * (see {@link deriveSearchResults}).
  * @param list - sessions list snapshot.
  * @param archivedSessionIds - registry-global archive set.
- * @param pendingInteractions - pending Remote Event presentation by Session.
+ * @param pendingInteractions - pending UI interactions by Session.
  * @returns flat rows in render order.
  */
 export function deriveFlat(
   list: SessionListState,
   archivedSessionIds: readonly SessionId[],
-  pendingInteractions: PendingInteractions,
+  pendingInteractions: SessionPendingInteractions,
 ): SessionNode[] {
   const archived = new Set(archivedSessionIds)
   const descendants = indexSubagentDescendants(list.byId)
@@ -324,7 +342,7 @@ export interface RelativeTime {
  * @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 Remote Event presentation by Session.
+ * @param pendingInteractions - pending UI interactions 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.
@@ -334,7 +352,7 @@ export function deriveSearchResults(
   workspaces: readonly WorkspaceView[],
   query: string,
   archivedSessionIds: readonly SessionId[],
-  pendingInteractions: PendingInteractions,
+  pendingInteractions: SessionPendingInteractions,
   content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
   limit: number,
 ): SearchResultSet {
@@ -387,7 +405,7 @@ export function deriveSearchResults(
   return {
     items: ordered.slice(0, limit).map((summary) => {
       const match = contentBySession.get(summary.id)
-      const pendingInteraction = pendingInteractions.get(summary.id)
+      const pendingInteraction = visiblePendingKind(pendingInteractions.get(summary.id)?.kind)
       return {
         id: summary.id,
         title: sessionTitle(summary),

+ 40 - 16
packages/client/ui-workspace/tests/apply.client.spec.ts

@@ -1,10 +1,10 @@
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
-import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
+import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
 import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
-import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
+import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
 import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
 
 async function bench() {
@@ -15,7 +15,6 @@ async function bench() {
     path: 'name' in input ? `/projects/${input.name}` : input.path,
     title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
   }))
-  const startSession = vi.fn()
   const rename = vi.fn(async () => ({}))
   const insertSessionBefore = vi.fn(async () => ({}))
   const open = vi.fn()
@@ -27,18 +26,40 @@ async function bench() {
   const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } }))
   const binding = vi.fn(() => ({ session: { rename: renameSession } }))
   const fork = vi.fn(async () => 'forked' as never)
+  const subscribe = () => () => {}
   ctx.provide('workspaces', {
-    create, startSession, rename, insertSessionBefore,
+    list: {
+      getSnapshot: () => ({
+        items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
+      }),
+      subscribe,
+    },
+    create,
+    rename,
+    delete: vi.fn(async () => undefined),
+    insertBefore: vi.fn(async () => undefined),
+    archiveSession: vi.fn(async () => undefined),
+    insertSessionBefore,
+  } as never)
+  ctx.provide('sessions', {
+    list: {
+      getSnapshot: () => ({
+        ids: [], byId: {}, current: undefined, phase: 'ready',
+        subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
+      }),
+      subscribe,
+    },
+    create: vi.fn(async () => 'created' as never),
+    open,
+    clear,
+    search,
+    searchResultLimit: 20,
+    binding,
+    fork,
   } as never)
-  ctx.provide('sessions', { open, clear, search, searchResultLimit: 20, binding, fork } as never)
   ctx.provide('connection', {
     hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
   } as never)
-  ctx.provide('conversation', {
-    pendingInteractions: {
-      statuses: { getSnapshot: () => new Map(), subscribe: () => () => {} },
-    },
-  } as never)
   const locale = new LocaleRuntime(ctx)
   // These specs assert the shipped Chinese copy. There is no jsdom `window`
   // in this lane, so browser-language detection never runs and the locale
@@ -46,7 +67,7 @@ async function bench() {
   locale.setLocale('zh')
   ctx.provide('locale', locale)
   return {
-    ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, startSession, rename,
+    ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename,
     insertSessionBefore, open, clear, search, renameSession, binding, fork,
   }
 }
@@ -61,7 +82,9 @@ function declare(slots: SlotRegistry, ...names: HoleName[]): () => void {
 
 describe('ui-workspace apply', () => {
   it('declares the services it drives', () => {
-    expect(inject).toEqual(['slots', 'sessions', 'workspaces', 'conversation', 'locale', 'connection'])
+    expect(inject).toEqual([
+      'slots', 'sessions', 'workspaces', 'locale', 'connection',
+    ])
   })
 
   it('registers browser and pickers for declarations arriving before or after apply', async () => {
@@ -86,13 +109,14 @@ describe('ui-workspace apply', () => {
     const b = await bench()
     declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace')
     await b.ctx.plugin({ inject: [...inject], apply }).await()
+    const startSession = vi.spyOn(b.ctx.uiWorkspace, 'startSession').mockImplementation(() => undefined)
 
     const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
-    // Both arms delegate to the runtime's shared New Session action.
+    // Both arms delegate to the shared Session navigation action.
     browser.startSession('ws' as never)
-    expect(b.startSession).toHaveBeenCalledWith('ws')
+    expect(startSession).toHaveBeenCalledWith('ws')
     browser.startSession()
-    expect(b.startSession).toHaveBeenLastCalledWith(undefined)
+    expect(startSession).toHaveBeenLastCalledWith(undefined)
     browser.open('session' as never)
     expect(b.open).toHaveBeenCalledWith('session')
     const signal = new AbortController().signal
@@ -148,7 +172,7 @@ describe('ui-workspace apply', () => {
     unsubscribe()
   })
 
-  it('rejects the browser search callback on a runtime business error', async () => {
+  it('rejects the browser search callback on a Session Controller business error', async () => {
     const b = await bench()
     b.search.mockImplementationOnce(async () => ({
       ok: false,

+ 1 - 1
packages/client/ui-workspace/tests/browser-styles.client.spec.ts

@@ -7,7 +7,7 @@ import { readFileSync } from 'node:fs'
 import { fileURLToPath } from 'node:url'
 import { describe, expect, it } from 'vitest'
 
-const css = readFileSync(fileURLToPath(new URL('../src/client/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
+const css = readFileSync(fileURLToPath(new URL('../src/client/rows/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
 const rowsCss = readFileSync(fileURLToPath(new URL('../src/client/rows/Rows.module.css', import.meta.url)), 'utf8')
 
 /**

+ 7 - 12
packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx

@@ -7,13 +7,15 @@
  * the list state — no push-frame wait. Coverage split: the assembled-app
  * snapshot (apps/web/tests/session-actions.snapshot.ts) pins the full-app
  * transcript; the
- * verb's wire behavior stays with the runtime package
+ * verb's wire behavior stays with the Session Controller client package
  * (session.spec.ts#rename), the dialog's own arms with rows.spec /
  * workspace-browser.spec.
  */
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
-import type { ISession, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
@@ -31,19 +33,12 @@ beforeEach(() => { localStorage.clear() })
 /** Runtime with the locale face installed (the browser entry declares `locale:` — zh default backs the t seat). */
 async function createRuntime(): Promise<SlotTestRuntime> {
   const runtime = await SlotTestRuntime.create()
-  const noPendingInteractions = new Map()
-  runtime.provide('connection', {
+  runtime.releaseWorkspaceSource()
+  runtime.ctx.provide('connection', {
     hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} },
   })
-  runtime.provide('conversation', {
-    pendingInteractions: {
-      statuses: { getSnapshot: () => noPendingInteractions, subscribe: () => () => {} },
-      forSession: () => ({ getSnapshot: () => [], subscribe: () => () => {} }),
-      present: () => () => {},
-    },
-  })
   const locale = new LocaleRuntime(runtime.ctx)
-  runtime.provide('locale', locale)
+  runtime.ctx.provide('locale', locale)
   runtime.slots.installLocale(locale)
   return runtime
 }

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

@@ -1,7 +1,8 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
-import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type { RowDragProps } from '../src/client/rows/Rows.tsx'

+ 44 - 40
packages/client/ui-workspace/tests/tree.client.spec.ts

@@ -1,7 +1,8 @@
 import { describe, expect, it } from 'vitest'
-import type {
-  PendingInteractionStatus, SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
-} from '@deepseek-ai/dsh-client-runtime/client'
+import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import {
   deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, relativeTime,
   UNGROUPED_KEY, UNGROUPED_LABEL,
@@ -29,14 +30,14 @@ const view = (expandedGroups: readonly string[] = [], ungroupedOrder?: readonly
   ...(ungroupedOrder === undefined ? {} : { ungroupedOrder }),
 })
 const noArchive: readonly SessionId[] = []
-const noPending: ReadonlyMap<SessionId, PendingInteractionStatus> = new Map()
+const noAttention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map()
 const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
 
 describe('deriveGroups', () => {
   it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
     const sessions = list(summary('newer', 20), summary('older', 10))
     const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
-    const groups = deriveGroups(sessions, workspaces, noArchive, noPending, view(['first']))
+    const groups = deriveGroups(sessions, workspaces, noArchive, noAttention, view(['first']))
     expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
     expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
   })
@@ -44,20 +45,22 @@ describe('deriveGroups', () => {
   it('projects pending-interaction state into grouped and flat rows', () => {
     const awaiting = { ...summary('awaiting', 10), running: true }
     const sessions = list(awaiting)
-    const pending = new Map([[awaiting.id, 'plan-review' as const]])
+    const attention: ReadonlyMap<SessionId, SessionPendingInteractionBase> = new Map([[
+      awaiting.id,
+      { key: 'question:1', kind: 'plan-review', sessionId: awaiting.id },
+    ]])
     const grouped = deriveGroups(
-      sessions, [workspace('project', ['awaiting'])], noArchive, pending, view(['project']),
+      sessions, [workspace('project', ['awaiting'])], noArchive, attention, view(['project']),
     )
     expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
-    expect(deriveFlat(sessions, noArchive, pending)[0]).toMatchObject({
-      pendingInteraction: 'plan-review', running: true,
-    })
+    expect(deriveFlat(sessions, noArchive, attention)[0])
+      .toMatchObject({ pendingInteraction: 'plan-review', running: true })
   })
 
   it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
     const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
     const groups = deriveGroups(
-      sessions, [workspace('first', ['owned'])], noArchive, noPending, view([UNGROUPED_KEY]),
+      sessions, [workspace('first', ['owned'])], noArchive, noAttention, view([UNGROUPED_KEY]),
     )
     expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
     expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
@@ -69,7 +72,7 @@ describe('deriveGroups', () => {
       sessions,
       [],
       noArchive,
-      noPending,
+      noAttention,
       view([UNGROUPED_KEY], ['two', 'stale', 'two']),
     )
     expect(groups[0]!.sessions.map(session => session.id)).toEqual([
@@ -87,7 +90,7 @@ describe('deriveGroups', () => {
     }
     const groups = deriveGroups(
       sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])],
-      noArchive, noPending, view(['first']),
+      noArchive, noAttention, view(['first']),
     )
     expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
     const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
@@ -99,8 +102,8 @@ describe('deriveGroups', () => {
     expect(groups[0]!.sessionCount).toBe(2)
     // A non-current blank stray never surfaces an Ungrouped bucket either.
     const strayGroups = deriveGroups(
-      list({ ...summary('stray', 2), blank: true }), [workspace('first', [])],
-      noArchive, noPending, view(),
+      list({ ...summary('stray', 2), blank: true }),
+      [workspace('first', [])], noArchive, noAttention, view(),
     )
     expect(strayGroups.map(group => group.key)).toEqual(['first'])
   })
@@ -110,16 +113,16 @@ describe('deriveGroups', () => {
     const plain = summary('plain', 2)
     const sessions = list(done, plain)
     const groups = deriveGroups(
-      sessions, [workspace('first', ['done', 'plain'])], noArchive, noPending, view(['first']),
+      sessions, [workspace('first', ['done', 'plain'])], noArchive, noAttention, view(['first']),
     )
     const doneNode = groups[0]!.sessions.find(session => session.id === done.id)!
     const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
     expect(doneNode.completed).toBe(true)
     expect(plainNode.completed).toBe(false)
-    expect(deriveFlat(sessions, noArchive, noPending).find(node => node.id === done.id)!.completed).toBe(true)
+    expect(deriveFlat(sessions, noArchive, noAttention).find(node => node.id === done.id)!.completed).toBe(true)
     const search = deriveSearchResults(
       sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive,
-      noPending, { items: [], hasMore: false }, 10,
+      noAttention, { items: [], hasMore: false }, 10,
     )
     expect(search.items[0]?.completed).toBe(true)
   })
@@ -141,7 +144,7 @@ describe('deriveGroups', () => {
       sessions,
       [workspace('first', ['parent', 'fork', 'subagent', 'grandchild', 'fork-child'])],
       noArchive,
-      noPending,
+      noAttention,
       view(['first']),
     )
 
@@ -149,12 +152,12 @@ describe('deriveGroups', () => {
     expect(groups[0]!.sessionCount).toBe(2)
     expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 })
     expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 })
-    expect(deriveFlat(sessions, noArchive, noPending).map(node => [node.id, node.runningSubagentCount])).toEqual([
+    expect(deriveFlat(sessions, noArchive, noAttention).map(node => [node.id, node.runningSubagentCount])).toEqual([
       [fork.id, 1], [parent.id, 2],
     ])
     expect(deriveSearchResults(
-      sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive, noPending,
-      { items: [], hasMore: false }, 10,
+      sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive,
+      noAttention, { items: [], hasMore: false }, 10,
     ).items[0]).toMatchObject({ id: parent.id, runningSubagentCount: 2 })
   })
 
@@ -172,7 +175,7 @@ describe('deriveGroups', () => {
       list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
       [],
       noArchive,
-      noPending,
+      noAttention,
       { expandedGroups: [UNGROUPED_KEY] },
     )
 
@@ -184,7 +187,7 @@ describe('deriveGroups', () => {
 
     // Equal timestamps use ids as a deterministic tiebreak in either input order.
     expect(deriveGroups(
-      list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, noPending, view([UNGROUPED_KEY]),
+      list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, noAttention, view([UNGROUPED_KEY]),
     )[0]!
       .sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
   })
@@ -196,7 +199,7 @@ describe('deriveGroups', () => {
       byId: { [sid('present')]: summary('present', 1) },
     }
     const groups = deriveGroups(
-      partial, [workspace('project', ['missing', 'present'])], noArchive, noPending, view(['project']),
+      partial, [workspace('project', ['missing', 'present'])], noArchive, noAttention, view(['project']),
     )
     expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
   })
@@ -208,7 +211,7 @@ describe('deriveGroups', () => {
     const sessions = list(kept, gone, looseGone)
     const groups = deriveGroups(
       sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'),
-      noPending, view(['first', UNGROUPED_KEY]),
+      noAttention, view(['first', UNGROUPED_KEY]),
     )
     // The archived member drops from its group AND the archived stray never
     // surfaces an Ungrouped bucket; counts follow the visible rows.
@@ -222,11 +225,11 @@ describe('deriveGroups', () => {
     const loose = summary('loose', 2)
     const ws = workspace('project', ['owned'])
     const ownedGroups = deriveGroups(
-      { ...list(owned, loose), current: owned.id }, [ws], noArchive, noPending, view(),
+      { ...list(owned, loose), current: owned.id }, [ws], noArchive, noAttention, view(),
     )
     expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
     const looseGroups = deriveGroups(
-      { ...list(owned, loose), current: loose.id }, [ws], noArchive, noPending, view(),
+      { ...list(owned, loose), current: loose.id }, [ws], noArchive, noAttention, view(),
     )
     expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
   })
@@ -238,7 +241,7 @@ describe('deriveFlat', () => {
     const child = { ...summary('child', 30), parentId: parent.id }
     const tieB = summary('tie-b', 20)
     const tieA = summary('tie-a', 20)
-    const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive, noPending)
+    const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive, noAttention)
     expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
   })
 
@@ -249,14 +252,14 @@ describe('deriveFlat', () => {
     const rows = deriveFlat(
       { ...list(parent, fork, subagent), current: subagent.id },
       noArchive,
-      noPending,
+      noAttention,
     )
     expect(rows.map(row => row.id)).toEqual([fork.id, parent.id])
   })
 
   it('tolerates ids whose summary has not landed yet', () => {
     const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
-    expect(deriveFlat(partial, noArchive, noPending).map(row => row.id)).toEqual([sid('present')])
+    expect(deriveFlat(partial, noArchive, noAttention).map(row => row.id)).toEqual([sid('present')])
   })
 
   it('shows only the current blank session and excludes blanks from search', () => {
@@ -266,7 +269,7 @@ describe('deriveFlat', () => {
       ...list(summary('real', 1), currentBlank, staleBlank),
       current: currentBlank.id,
     }
-    const rows = deriveFlat(sessions, noArchive, noPending)
+    const rows = deriveFlat(sessions, noArchive, noAttention)
     expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
     expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
     expect(rows.map(row => row.blank)).toEqual([true, false])
@@ -275,7 +278,7 @@ describe('deriveFlat', () => {
   it('hides archived sessions in flat mode', () => {
     const kept = summary('kept', 1)
     const gone = summary('gone', 2)
-    expect(deriveFlat(list(kept, gone), archived('gone'), noPending).map(row => row.id)).toEqual([kept.id])
+    expect(deriveFlat(list(kept, gone), archived('gone'), noAttention).map(row => row.id)).toEqual([kept.id])
   })
 })
 
@@ -290,7 +293,7 @@ describe('deriveSearchResults archive filtering', () => {
       [],
       'needle',
       archived('gone'),
-      noPending,
+      noAttention,
       { items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false },
       10,
     )
@@ -302,7 +305,6 @@ describe('deriveSearchResults', () => {
   it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
     const titleHit = summary('title-hit', 30, '/projects/a')
     titleHit.displayTitle = 'Needle title'
-    const pending = new Map([[titleHit.id, 'plan-review' as const]])
     const workspaceHit = summary('workspace-hit', 20, '/projects/b')
     workspaceHit.displayTitle = 'Ordinary title'
     const contentHit = summary('content-hit', 10, '/projects/c')
@@ -316,7 +318,9 @@ describe('deriveSearchResults', () => {
       ],
       ' NEEDLE ',
       noArchive,
-      pending,
+      new Map([[titleHit.id, {
+        key: 'question:1', kind: 'plan-review', sessionId: titleHit.id,
+      }]]),
       {
         items: [
           { sessionId: contentHit.id, snippet: 'body needle excerpt' },
@@ -377,7 +381,7 @@ describe('deriveSearchResults', () => {
       [workspace('first', ['opaque-current', 'new session stale'])],
       'new session',
       noArchive,
-      noPending,
+      noAttention,
       {
         items: [
           { sessionId: staleBlank.id, snippet: 'stale body' },
@@ -401,7 +405,7 @@ describe('deriveSearchResults', () => {
       [],
       'needle',
       noArchive,
-      noPending,
+      noAttention,
       { items: [], hasMore: false },
       3,
     )
@@ -413,13 +417,13 @@ describe('deriveSearchResults', () => {
       [],
       'needle',
       noArchive,
-      noPending,
+      noAttention,
       { items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
       3,
     )
     expect(backendMore.items).toHaveLength(1)
     expect(backendMore.hasMore).toBe(true)
-    expect(deriveSearchResults(list(), [], '  ', noArchive, noPending, { items: [], hasMore: true }, 3))
+    expect(deriveSearchResults(list(), [], '  ', noArchive, noAttention, { items: [], hasMore: true }, 3))
       .toEqual({ items: [], hasMore: false })
   })
 })

+ 12 - 10
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -2,16 +2,18 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
 import type {
-  PendingInteractionStatus, SessionId, SessionListState, SessionSummary, WorkspaceId,
-  WorkspaceListState, WorkspaceView,
-} from '@deepseek-ai/dsh-client-runtime/client'
+  WorkspaceId, WorkspaceSnapshot, WorkspaceView,
+} from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
 import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts'
 import { UNGROUPED_KEY } from '../src/client/tree.ts'
-import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
+import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
 import { zh } from '../src/client/locales.ts'
 
 afterEach(cleanup)
@@ -39,10 +41,11 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
   workspaceId: wid(id), path: `/projects/${id}`, title,
   sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
 })
-const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: readonly SessionId[] = []): WorkspaceListState => ({
-  items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
-  recentWorkspaceId: items[0]?.workspaceId,
-})
+const workspaceState = (
+  items: readonly WorkspaceView[],
+  archivedSessionIds: readonly SessionId[] = [],
+): WorkspaceSnapshot => ({ items, archivedSessionIds, state: 'idle', phase: 'ready', error: null })
+const noPendingInteraction: SessionPendingInteractionSnapshot = new Map()
 function hook<T>(snapshot: T) {
   return function select<S>(selector: (state: T) => S): S { return selector(snapshot) }
 }
@@ -65,7 +68,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     wide: true,
     expandSidebar: vi.fn(),
     useSessions: hook(sessionState([])),
-    usePendingInteractions: hook(new Map<SessionId, PendingInteractionStatus>()),
+    useSessionPendingInteraction: hook(noPendingInteraction),
     useWorkspaces: hook(workspaceState([])),
     useStore: bindSnapshotSelector(store),
     actions: store.actions,
@@ -124,7 +127,6 @@ describe('WorkspaceBrowser', () => {
       ...workspaceState([]),
       phase: 'pending' as const,
       state: 'loading' as const,
-      baselinesReady: false,
     }
     const b = mount({ useWorkspaces: hook(pending) })
     act(() => {

+ 13 - 7
packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx

@@ -1,11 +1,14 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
 import type {
-  SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
-} from '@deepseek-ai/dsh-client-runtime/client'
+  WorkspaceId, WorkspaceSnapshot, WorkspaceView,
+} from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
 import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
@@ -30,9 +33,9 @@ function hook<T>(snapshot: T) {
 const sessions: SessionListState = {
   ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
 }
-const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
-  items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true,
-  recentWorkspaceId: items[0]?.workspaceId,
+const noPendingInteraction: SessionPendingInteractionSnapshot = new Map()
+const workspaceState = (items: readonly WorkspaceView[]): WorkspaceSnapshot => ({
+  items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
 })
 function anchor(): { current: HTMLElement } {
   const element = document.createElement('button')
@@ -91,6 +94,7 @@ function mount(
       open
       anchorRef={anchorRef}
       useSessions={hook(sessions)}
+      useSessionPendingInteraction={hook(noPendingInteraction)}
       useWorkspaces={hook(workspaceState(nextItems))}
       onPick={onPick}
       onClose={onClose}
@@ -211,6 +215,7 @@ describe('WorkspacePicker', () => {
     render(
       <WorkspacePicker
         open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([workspace('alpha', 'Alpha')]))}
+        useSessionPendingInteraction={hook(noPendingInteraction)}
         onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
         useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
       />,
@@ -219,13 +224,14 @@ describe('WorkspacePicker', () => {
   })
 
   it('keeps the menu up while the list baseline is still in flight', () => {
-    const state: WorkspaceListState = {
-      ...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
+    const state: WorkspaceSnapshot = {
+      ...workspaceState([]), phase: 'pending', state: 'loading',
     }
     const { renderSlot } = flowProbe()
     render(
       <WorkspacePicker
         open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
+        useSessionPendingInteraction={hook(noPendingInteraction)}
         onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
         useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
       />,

+ 488 - 0
packages/client/ui-workspace/tests/workspaces-service.client.spec.ts

@@ -0,0 +1,488 @@
+import { Context } from '@deepseek-ai/cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type {
+  ISessions, SessionListState, SessionSummary,
+} from '@deepseek-ai/dsh-api-session-controller/client'
+import type {
+  IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
+} from '@deepseek-ai/dsh-api-workspace-controller/client'
+import {
+  RpcId,
+  type DirectoryListing,
+  type IApiClient,
+  type RpcError,
+  type RpcResponse,
+} from '@deepseek-ai/dsh-client-connection/client'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
+import { DirectoryBrowseError } from '../src/client/index.ts'
+import { UiWorkspaceService } from '../src/client/navigation.ts'
+
+const sid = (id: string): SessionId => SessionId(id)
+const wid = (id: string): WorkspaceId => id as WorkspaceId
+
+afterEach(() => {
+  vi.restoreAllMocks()
+})
+
+function workspace(
+  id: string,
+  sessionIds: readonly SessionId[] = [],
+  createdAt = '2026-01-01T00:00:00.000Z',
+): WorkspaceView {
+  return {
+    workspaceId: wid(id),
+    path: `/w/${id}`,
+    title: id,
+    sessionIds,
+    createdAt,
+    updatedAt: createdAt,
+  }
+}
+
+function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
+  return {
+    id: sid(id),
+    displayTitle: id,
+    running: false,
+    blank: false,
+    updatedAt: 0,
+    ...overrides,
+  }
+}
+
+function sessionState(
+  summaries: readonly SessionSummary[] = [],
+  current?: SessionId,
+  phase: SessionListState['phase'] = 'ready',
+): SessionListState {
+  return {
+    ids: summaries.map(item => item.id),
+    byId: Object.fromEntries(summaries.map(item => [item.id, item])),
+    current,
+    phase,
+    subagentsByParent: {},
+    jobsBySession: {},
+    currentAddress: undefined,
+  }
+}
+
+function workspaceState(
+  items: WorkspaceSnapshot['items'] = [],
+  archivedSessionIds: readonly SessionId[] = [],
+  phase: WorkspaceSnapshot['phase'] = 'ready',
+): WorkspaceSnapshot {
+  return {
+    items,
+    archivedSessionIds,
+    phase,
+    state: phase === 'ready' ? 'idle' : 'loading',
+    error: null,
+  }
+}
+
+class MutableSource<T> {
+  private readonly listeners = new Set<() => void>()
+
+  constructor(private value: T) {}
+
+  getSnapshot(): T {
+    return this.value
+  }
+
+  subscribe(listener: () => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  set(value: T): void {
+    this.value = value
+    for (const listener of [...this.listeners]) listener()
+  }
+
+  update(update: (value: T) => T): void {
+    this.set(update(this.value))
+  }
+
+  listenersSnapshot(): readonly (() => void)[] {
+    return [...this.listeners]
+  }
+}
+
+class FakeSessions {
+  readonly list: MutableSource<SessionListState>
+  readonly create: ReturnType<typeof vi.fn<ISessions['create']>>
+  readonly open: ReturnType<typeof vi.fn<(id: SessionId) => void>>
+  readonly clear: ReturnType<typeof vi.fn<() => void>>
+
+  constructor(initial: SessionListState) {
+    this.list = new MutableSource(initial)
+    this.create = vi.fn<ISessions['create']>(async options =>
+      options?.sessionId ?? sid(`created-${String(options?.workspaceId ?? 'none')}`))
+    this.open = vi.fn((id: SessionId) => {
+      this.list.update(state => ({ ...state, current: id }))
+    })
+    this.clear = vi.fn(() => {
+      this.list.update(state => ({ ...state, current: undefined }))
+    })
+  }
+}
+
+class FakeWorkspaces implements IWorkspaces {
+  readonly list: MutableSource<WorkspaceSnapshot>
+  readonly archiveCalls: SessionId[] = []
+  onArchive: IWorkspaces['archiveSession'] = async (sessionId) => {
+    this.list.update(state => ({
+      ...state,
+      archivedSessionIds: [...state.archivedSessionIds, sessionId],
+    }))
+  }
+
+  declare readonly create: IWorkspaces['create']
+  declare readonly rename: IWorkspaces['rename']
+  declare readonly delete: IWorkspaces['delete']
+  declare readonly insertBefore: IWorkspaces['insertBefore']
+  declare readonly insertSessionBefore: IWorkspaces['insertSessionBefore']
+
+  constructor(initial: WorkspaceSnapshot) {
+    this.list = new MutableSource(initial)
+  }
+
+  archiveSession(sessionId: SessionId): Promise<void> {
+    this.archiveCalls.push(sessionId)
+    return this.onArchive(sessionId)
+  }
+}
+
+let nextRpcId = 0
+
+function ok<T>(value: T): RpcResponse<T> {
+  return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: true, value } }
+}
+
+function failed<T>(error: RpcError): RpcResponse<T> {
+  return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: false, error } }
+}
+
+const listing: DirectoryListing = {
+  path: '/home/u',
+  home: '/home/u',
+  crumbs: [{ name: '/', path: '/', hidden: false }],
+  entries: [{ name: 'project', path: '/home/u/project', hidden: false }],
+  truncated: false,
+}
+
+class FakeApiClient implements IApiClient {
+  readonly calls: Array<{ readonly method: string; readonly payload: unknown }> = []
+
+  onDescribe: IApiClient['host']['describe'] = () => Promise.resolve(ok({
+    version: 'test',
+    cwd: '/home/u',
+    attachedSessions: 0,
+    home: '/home/u',
+    canOpenPath: true,
+  }))
+  onPickDirectory: IApiClient['host']['pickDirectory'] = () => Promise.resolve(ok({ path: null }))
+  onListDirectory: IApiClient['host']['listDirectory'] = () => Promise.resolve(ok(listing))
+  onCreateDirectory: IApiClient['host']['createDirectory'] = () => Promise.resolve(ok({ path: '/home/u/new' }))
+  onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true }))
+
+  declare readonly subagents: IApiClient['subagents']
+  declare readonly skills: IApiClient['skills']
+  declare readonly agentPresets: IApiClient['agentPresets']
+  declare readonly goals: IApiClient['goals']
+  declare readonly settings: IApiClient['settings']
+  declare readonly credentials: IApiClient['credentials']
+  declare readonly llm: IApiClient['llm']
+
+  readonly host: IApiClient['host'] = {
+    describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)),
+    pickDirectory: (payload, signal) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload, signal)),
+    listDirectory: (payload, signal) => this.record('host.listDirectory', payload, this.onListDirectory(payload, signal)),
+    createDirectory: (payload, signal) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload, signal)),
+    openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)),
+  }
+
+  callsOf(method: string): unknown[] {
+    return this.calls.filter(call => call.method === method).map(call => call.payload)
+  }
+
+  private record<T>(method: string, payload: unknown, response: Promise<T>): Promise<T> {
+    this.calls.push({ method, payload })
+    return response
+  }
+}
+
+interface BenchOptions {
+  readonly workspaces?: WorkspaceSnapshot
+  readonly sessions?: SessionListState
+}
+
+function bench(options: BenchOptions = {}) {
+  const ctx = new Context()
+  const api = new FakeApiClient()
+  const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending'))
+  const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending'))
+  const uiWorkspace = new UiWorkspaceService(
+    ctx,
+    api,
+    workspaces,
+    sessions as unknown as ISessions,
+  )
+  return { api, ctx, sessions, uiWorkspace, workspaces }
+}
+
+async function flush(): Promise<void> {
+  await Promise.resolve()
+  await Promise.resolve()
+}
+
+describe('UiWorkspaceService', () => {
+  it('reuses only an unarchived member blank and coalesces concurrent creation', async () => {
+    const b = bench()
+    const memberBlank = sid('member-blank')
+    const archivedBlank = sid('archived-blank')
+    const summaries: readonly SessionSummary[] = [
+      summary('stray', { blank: true, cwd: '/w/alpha' }),
+      summary('member-blank', { blank: true, cwd: '/w/alpha' }),
+      summary('active', { cwd: '/w/beta' }),
+      summary('archived-blank', { blank: true, cwd: '/w/gamma' }),
+    ]
+    b.workspaces.list.set(workspaceState([
+      workspace('alpha', [memberBlank]),
+      workspace('beta', [sid('active')]),
+      workspace('gamma', [archivedBlank]),
+    ], [archivedBlank]))
+    b.sessions.list.set({
+      ...sessionState(summaries, memberBlank),
+      ids: [sid('missing'), ...summaries.map(item => item.id)],
+    })
+
+    await expect(Promise.all([
+      b.uiWorkspace.connectWorkspace(wid('alpha')),
+      b.uiWorkspace.connectWorkspace(wid('alpha')),
+    ])).resolves.toEqual([memberBlank, memberBlank])
+    expect(b.sessions.create).not.toHaveBeenCalled()
+
+    const creation = Promise.withResolvers<SessionId>()
+    b.sessions.create.mockImplementation(() => creation.promise)
+    const first = b.uiWorkspace.connectWorkspace(wid('beta'))
+    const second = b.uiWorkspace.connectWorkspace(wid('beta'))
+    expect(b.sessions.create).toHaveBeenCalledTimes(1)
+    creation.resolve(sid('fresh-beta'))
+    await expect(Promise.all([first, second])).resolves.toEqual([sid('fresh-beta'), sid('fresh-beta')])
+
+    b.sessions.create.mockImplementation(async options => sid(`fresh-${String(options?.workspaceId)}`))
+    await expect(b.uiWorkspace.connectWorkspace(wid('gamma'))).resolves.toBe(sid('fresh-gamma'))
+    expect(b.sessions.create).toHaveBeenLastCalledWith({ workspaceId: wid('gamma') })
+    await expect(b.uiWorkspace.connectWorkspace(wid('ghost')))
+      .rejects.toThrow('uiWorkspace.connectWorkspace: unknown workspace ghost')
+  })
+
+  it('targets an explicit, current-session, then recent Workspace and reports failed starts', async () => {
+    const current = summary('current', { cwd: '/w/current-home', updatedAt: 1 })
+    const recent = summary('recent', { cwd: '/w/recent-home', updatedAt: 2 })
+    const b = bench({
+      sessions: sessionState([current, recent], current.id),
+      workspaces: workspaceState([
+        workspace('current-home', [current.id]),
+        workspace('recent-home', [recent.id]),
+      ]),
+    })
+    b.sessions.create.mockImplementation(async options => sid(`opened-${String(options?.workspaceId)}`))
+
+    b.uiWorkspace.startSession(wid('recent-home'))
+    await vi.waitFor(() => {
+      expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
+    })
+
+    b.sessions.open(current.id)
+    b.uiWorkspace.startSession()
+    await vi.waitFor(() => {
+      expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-current-home'))
+    })
+
+    b.sessions.clear()
+    b.uiWorkspace.startSession()
+    await vi.waitFor(() => {
+      expect(b.sessions.open).toHaveBeenLastCalledWith(sid('opened-recent-home'))
+    })
+
+    const empty = bench()
+    empty.uiWorkspace.startSession()
+    expect(empty.sessions.clear).toHaveBeenCalledOnce()
+
+    const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    b.sessions.create.mockRejectedValueOnce(new Error('create failed'))
+    b.uiWorkspace.startSession(wid('recent-home'))
+    await vi.waitFor(() => {
+      expect(warning).toHaveBeenCalledWith('new session failed:', expect.any(Error))
+    })
+  })
+
+  it('opens the recent Workspace after both baselines arrive', async () => {
+    const b = bench()
+    b.sessions.create.mockResolvedValue(sid('initial'))
+
+    const stableFirst = workspace('stable-first', [], '2026-01-01T00:00:00.000Z')
+    const recent = workspace('recent', [], '2026-01-02T00:00:00.000Z')
+    b.workspaces.list.set(workspaceState([stableFirst, recent]))
+    expect(b.sessions.create).not.toHaveBeenCalled()
+    b.sessions.list.set(sessionState())
+
+    await vi.waitFor(() => {
+      expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
+    })
+    expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('recent') })
+    expect(b.workspaces.list.getSnapshot().items.map(item => item.workspaceId)).toEqual([
+      wid('stable-first'), wid('recent'),
+    ])
+  })
+
+  it('uses Workspace creation time when members are absent and preserves Host tie order', async () => {
+    const b = bench()
+    b.sessions.create.mockResolvedValue(sid('initial'))
+
+    b.workspaces.list.set(workspaceState([
+      workspace('newest', [sid('missing')], '2026-03-01T00:00:00.000Z'),
+      workspace('same-time', [], '2026-03-01T00:00:00.000Z'),
+      workspace('older', [], '2026-01-01T00:00:00.000Z'),
+    ]))
+    b.sessions.list.set(sessionState())
+
+    await vi.waitFor(() => {
+      expect(b.sessions.open).toHaveBeenCalledWith(sid('initial'))
+    })
+    expect(b.sessions.create).toHaveBeenCalledWith({ workspaceId: wid('newest') })
+  })
+
+  it('retries failed initial selection and never overwrites a later selection', async () => {
+    const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    const b = bench()
+    let attempts = 0
+    b.sessions.create.mockImplementation(() => ++attempts === 1
+      ? Promise.reject(new Error('attach exploded'))
+      : Promise.resolve(sid('retry')))
+    b.workspaces.list.set(workspaceState([workspace('recent')]))
+    b.sessions.list.set(sessionState())
+    await vi.waitFor(() => {
+      expect(warning).toHaveBeenCalledWith('initial workspace selection failed:', expect.any(Error))
+    })
+    b.workspaces.list.update(state => ({ ...state, items: [...state.items] }))
+    await vi.waitFor(() => {
+      expect(b.sessions.open).toHaveBeenCalledWith(sid('retry'))
+    })
+    expect(attempts).toBe(2)
+
+    const changed = bench()
+    const pending = Promise.withResolvers<SessionId>()
+    changed.sessions.create.mockImplementation(() => pending.promise)
+    changed.workspaces.list.set(workspaceState([workspace('recent')]))
+    changed.sessions.list.set(sessionState())
+    await vi.waitFor(() => { expect(changed.sessions.create).toHaveBeenCalledOnce() })
+    changed.sessions.open(sid('manual'))
+    pending.resolve(sid('automatic'))
+    await flush()
+    expect(changed.sessions.open).toHaveBeenCalledTimes(1)
+    expect(changed.sessions.open).toHaveBeenCalledWith(sid('manual'))
+  })
+
+  it('stops initial navigation when its Cordis lifetime is disposed', async () => {
+    const success = bench()
+    const resolved = Promise.withResolvers<SessionId>()
+    success.sessions.create.mockImplementation(() => resolved.promise)
+    success.workspaces.list.set(workspaceState([workspace('recent')]))
+    success.sessions.list.set(sessionState())
+    await vi.waitFor(() => { expect(success.sessions.create).toHaveBeenCalledOnce() })
+    await success.ctx.fiber.dispose()
+    resolved.resolve(sid('late'))
+    await flush()
+    expect(success.sessions.open).not.toHaveBeenCalled()
+    success.workspaces.list.set(workspaceState([workspace('ignored')]))
+    expect(success.sessions.create).toHaveBeenCalledOnce()
+
+    const failure = bench()
+    const rejected = Promise.withResolvers<SessionId>()
+    failure.sessions.create.mockImplementation(() => rejected.promise)
+    failure.workspaces.list.set(workspaceState([workspace('recent')]))
+    failure.sessions.list.set(sessionState())
+    await vi.waitFor(() => { expect(failure.sessions.create).toHaveBeenCalledOnce() })
+    const staleReconciles = failure.workspaces.list.listenersSnapshot()
+    const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+    await failure.ctx.fiber.dispose()
+    rejected.reject(new Error('late failure'))
+    await flush()
+    for (const reconcile of staleReconciles) reconcile()
+    expect(warning).not.toHaveBeenCalled()
+    expect(failure.sessions.create).toHaveBeenCalledOnce()
+  })
+
+  it('clears a current Session only after it enters the archive baseline', () => {
+    const current = summary('current')
+    const idle = summary('idle')
+    const b = bench({
+      sessions: sessionState([current, idle], current.id),
+      workspaces: workspaceState([workspace('one', [current.id, idle.id])]),
+    })
+
+    b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
+    expect(b.sessions.clear).not.toHaveBeenCalled()
+    b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [current.id] }))
+    expect(b.sessions.clear).toHaveBeenCalledOnce()
+
+    b.sessions.open(idle.id)
+    b.workspaces.list.update(state => ({ ...state, archivedSessionIds: [idle.id] }))
+    expect(b.sessions.clear).toHaveBeenCalledTimes(2)
+
+    const archived = bench({
+      sessions: sessionState([current], current.id),
+      workspaces: workspaceState([workspace('one', [current.id])], [current.id]),
+    })
+    expect(archived.sessions.clear).toHaveBeenCalledOnce()
+  })
+
+  it('forwards archive commands and preserves failures', async () => {
+    const idle = sid('idle')
+    const b = bench()
+
+    await b.uiWorkspace.archiveSession(idle)
+    expect(b.workspaces.archiveCalls).toEqual([idle])
+
+    b.workspaces.onArchive = () => Promise.reject(new Error('archive rejected'))
+    await expect(b.uiWorkspace.archiveSession(idle)).rejects.toThrow('archive rejected')
+    expect(b.workspaces.archiveCalls).toEqual([idle, idle])
+  })
+
+  it('passes directory operations to the Host and preserves structured browse failures', async () => {
+    const b = bench()
+    b.api.onPickDirectory = () => Promise.resolve(ok({ path: '/w/alpha' }))
+    await expect(b.uiWorkspace.pickDirectory()).resolves.toBe('/w/alpha')
+    b.api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
+    await expect(b.uiWorkspace.pickDirectory()).resolves.toBeNull()
+    expect(b.api.callsOf('host.pickDirectory')).toEqual([{}, {}])
+
+    await expect(b.uiWorkspace.listDirectory()).resolves.toEqual(listing)
+    await expect(b.uiWorkspace.listDirectory('/home/u')).resolves.toEqual(listing)
+    expect(b.api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
+    await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new')
+    expect(b.api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'new' }])
+    await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined()
+    expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }])
+
+    b.api.onPickDirectory = () => Promise.resolve(failed({ code: 'internal', message: 'no chooser', details: {} }))
+    await expect(b.uiWorkspace.pickDirectory()).rejects.toThrow('directory picker failed: no chooser')
+    b.api.onListDirectory = () => Promise.resolve(failed({
+      code: 'directory-unreadable', message: 'denied', details: { path: '/private' },
+    }))
+    const listFailure = b.uiWorkspace.listDirectory('/private')
+    await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
+    await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
+    b.api.onCreateDirectory = () => Promise.resolve(failed({
+      code: 'directory-exists', message: 'taken', details: { path: '/home/u/new' },
+    }))
+    await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({
+      rpcError: { code: 'directory-exists' },
+    })
+    b.api.onOpenPath = () => Promise.resolve(failed({ code: 'internal', message: 'boom', details: {} }))
+    await expect(b.uiWorkspace.openPath('/missing')).rejects.toThrow('path open failed: boom')
+  })
+})

+ 16 - 1
packages/client/ui-workspace/tsconfig.json

@@ -8,6 +8,12 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../../api/session-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../../api/workspace-controller/tsconfig.client.json"
+    },
     {
       "path": "../locale"
     },
@@ -24,7 +30,10 @@
       "path": "../connection/tsconfig.client.json"
     },
     {
-      "path": "../runtime"
+      "path": "../store"
+    },
+    {
+      "path": "../../core/session"
     },
     {
       "path": "../ui-sidebar"
@@ -32,6 +41,12 @@
     {
       "path": "../ui-conversation"
     },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-session"
+    },
     {
       "path": "../../runtime-diagnostics/invariants"
     }