Просмотр исходного кода

feat(web): session list one-list, hover card, row menus, rename, manual ordering

Sidebar session list grows the figma 239-10458 feature set and the
workspace/session browsing region moves wholesale into ui-workspace:

- Group-by menu (WorkSpace / In one list): flat mode lists every session
  top-level, strictly newest-first; the choice persists across reloads.
- Session rows get a 500ms hover detail card (title / relative time /
  status line) and a ... menu (Rename / Fork session / Delete session,
  visual-only for now); workspace headers get ... with Rename (wired) and
  Delete workspace (visual-only).
- workspace.rename RPC: trims, rejects duplicate titles on the create
  chain (workspace-name-conflict), no-op on same title; modal dialog with
  client-side duplicate pre-check.
- workspace.insertSessionBefore RPC (DOM-insertBefore semantics, omitted
  anchor appends): HTML5 drag reorder of root sessions inside a workspace
  group; order truth stays host-side, the view refreshes from the
  response/changed frame.
- Activity pinning removed: the session/event touchSession chain is gone;
  workspace accounts are manually owned (new sessions prepend, explicit
  reordering only). Contracts and tests updated, api catalog regenerated.
- ui-sidebar reduced to the column shell (brand, fold state machine, New
  Session, Settings) exposing one sidebar.workspaces hole with a two-fact
  owner share {wide, expandSidebar}; ui-workspace owns the whole region
  (header, search, grouped/flat lists, dialogs, drag) plus the picker via
  a shared WorkspaceCreateFlow. The old sidebar.workspace picker slot and
  its deferral indirection are gone.
- ui-primitives: Menu gains label entries, danger rows, and
  closeOnPointerLeave; new HoverCard (portaled, open-delay, disabled
  guard). Hover card and row menu never coexist.
imccyu 1 месяц назад
Родитель
Сommit
ea8b1178cd
48 измененных файлов с 1942 добавлено и 1127 удалено
  1. 0 11
      docs/cordis-catalog/services.md
  2. 1 1
      docs/event-producer-consumer.md
  3. 55 0
      packages/client/connection/src/client/fixture.ts
  4. 6 0
      packages/client/connection/tests/fake-api.ts
  5. 40 1
      packages/client/runtime/src/client/workspaces/manager.ts
  6. 30 1
      packages/client/runtime/src/client/workspaces/service.ts
  7. 9 0
      packages/client/runtime/tests/fake-api.ts
  8. 22 0
      packages/client/ui-primitives/src/HoverCard.module.css
  9. 109 0
      packages/client/ui-primitives/src/HoverCard.tsx
  10. 21 0
      packages/client/ui-primitives/src/Menu.module.css
  11. 29 4
      packages/client/ui-primitives/src/Menu.tsx
  12. 2 1
      packages/client/ui-primitives/src/index.ts
  13. 0 143
      packages/client/ui-sidebar/src/client/Rows.tsx
  14. 3 183
      packages/client/ui-sidebar/src/client/SidebarRoot.module.css
  15. 18 230
      packages/client/ui-sidebar/src/client/SidebarRoot.tsx
  16. 22 37
      packages/client/ui-sidebar/src/client/contract/slots.ts
  17. 7 8
      packages/client/ui-sidebar/src/client/index.ts
  18. 7 11
      packages/client/ui-sidebar/tests/apply.spec.tsx
  19. 42 220
      packages/client/ui-sidebar/tests/sidebar-root.spec.tsx
  20. 3 0
      packages/client/ui-workspace/package.json
  21. 265 0
      packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css
  22. 428 0
      packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx
  23. 58 5
      packages/client/ui-workspace/src/client/WorkspacePicker.tsx
  24. 46 17
      packages/client/ui-workspace/src/client/contract/slots.ts
  25. 63 32
      packages/client/ui-workspace/src/client/index.ts
  26. 51 2
      packages/client/ui-workspace/src/client/rows/Rows.module.css
  27. 285 0
      packages/client/ui-workspace/src/client/rows/Rows.tsx
  28. 36 0
      packages/client/ui-workspace/src/client/stores.ts
  29. 29 4
      packages/client/ui-workspace/src/client/tree.ts
  30. 41 27
      packages/client/ui-workspace/tests/apply.spec.ts
  31. 2 2
      packages/client/ui-workspace/tests/rows.spec.tsx
  32. 0 0
      packages/client/ui-workspace/tests/tree.spec.ts
  33. 1 5
      packages/cordis/tool-cordis/src/api-catalog.ts
  34. 68 1
      packages/host/apiproxy/src/api-proxy.ts
  35. 2 0
      packages/host/apiproxy/src/api/rpc-map.ts
  36. 1 0
      packages/host/apiproxy/src/api/rpc.schema.ts
  37. 1 0
      packages/host/apiproxy/src/api/rpc.ts
  38. 26 0
      packages/host/apiproxy/src/api/workspace.schema.ts
  39. 27 1
      packages/host/apiproxy/src/api/workspace.ts
  40. 8 0
      packages/host/apiproxy/src/fetch/client.ts
  41. 4 0
      packages/host/apiproxy/src/fetch/handler.ts
  42. 2 0
      packages/host/apiproxy/tests/client-handler.spec.ts
  43. 12 0
      packages/host/apiproxy/tests/fetch-carrier.spec.ts
  44. 32 24
      packages/workspace/workspace/src/entity.ts
  45. 3 34
      packages/workspace/workspace/src/index.ts
  46. 19 6
      packages/workspace/workspace/src/types.ts
  47. 2 116
      packages/workspace/workspace/tests/workspace.spec.ts
  48. 4 0
      pnpm-lock.yaml

+ 0 - 11
docs/cordis-catalog/services.md

@@ -1981,15 +1981,6 @@ get(id: WorkspaceId): Workspace | undefined
  */
 list(): Workspace[]
 
-/**
- * Move one accounted, cwd-validated session to the front of its workspace.
- * Ungrouped sessions and candidates filtered by the header check are
- * no-ops. The owning workspace's relative position never changes.
- * @param sessionId - Session whose activity was observed.
- * @returns resolution after the possible record write.
- */
-async touchSession(sessionId: SessionId): Promise<void>
-
 /**
  * Resolve by canonical directory path without creating or mutating a
  * workspace. A missing path rejects during `realpath`; an existing unowned
@@ -2000,8 +1991,6 @@ async touchSession(sessionId: SessionId): Promise<void>
 async resolveByPath(path: string): Promise<Workspace | undefined>
 ```
 
-Types: [SessionId](../core-data-structures/core.md)
-
 Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts)
 
 ## Inherited `ctx` members (cordis core + loader/hmr/timer)

+ 1 - 1
docs/event-producer-consumer.md

@@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
-| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace`](../packages/workspace/workspace), [`workspace-context`](../packages/context/workspace-context) |
+| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
 | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) |
 | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 55 - 0
packages/client/connection/src/client/fixture.ts

@@ -641,6 +641,59 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
         emitHost({ type: 'host/workspace-changed', workspace: { ...created } })
         return ok(request, { workspace: { ...created }, created: true })
       },
+      rename: (request) => {
+        const { workspaceId, title } = request.payload
+        const workspace = workspaces.find(w => w.workspaceId === workspaceId)
+        if (workspace === undefined) {
+          return err(request, {
+            code: 'workspace-not-found',
+            message: `no workspace ${workspaceId}`,
+            details: { workspaceId },
+          })
+        }
+        const trimmed = title.trim()
+        if (trimmed !== workspace.title) {
+          if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) {
+            return err(request, {
+              code: 'workspace-name-conflict',
+              message: `workspace name '${trimmed}' is already in use`,
+              details: { name: trimmed },
+            })
+          }
+          workspace.title = trimmed
+          workspace.updatedAt = new Date().toISOString()
+          emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
+        }
+        return ok(request, { workspace: { ...workspace } })
+      },
+      insertSessionBefore: (request) => {
+        const { workspaceId, sessionId, beforeSessionId } = request.payload
+        const workspace = workspaces.find(w => w.workspaceId === workspaceId)
+        if (workspace === undefined) {
+          return err(request, {
+            code: 'workspace-not-found',
+            message: `no workspace ${workspaceId}`,
+            details: { workspaceId },
+          })
+        }
+        if (!workspace.sessionIds.includes(sessionId)
+          || (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) {
+          return err(request, {
+            code: 'workspace-move-invalid',
+            message: `session or anchor is not accounted by workspace ${workspaceId}`,
+            details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } },
+          })
+        }
+        const without = workspace.sessionIds.filter(id => id !== sessionId)
+        const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId)
+        const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)]
+        if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) {
+          workspace.sessionIds = sessionIds
+          workspace.updatedAt = new Date().toISOString()
+          emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
+        }
+        return ok(request, { workspace: { ...workspace } })
+      },
     },
     events: {
       async *mux(_request, signal) {
@@ -757,6 +810,8 @@ export class FixtureApiClient extends AbstractApiClient {
       case 'host.describe': return this.api.host.describe(request)
       case 'workspace.list': return this.api.workspace.list(request)
       case 'workspace.create': return this.api.workspace.create(request)
+      case 'workspace.rename': return this.api.workspace.rename(request)
+      case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
     }
   }
 

+ 6 - 0
packages/client/connection/tests/fake-api.ts

@@ -77,6 +77,12 @@ export class FakeApiClient implements IApiClient {
       workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
       created: true,
     }))),
+    rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
+      workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
+    }))),
+    insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
+      workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
+    }))),
   }
 
   /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */

+ 40 - 1
packages/client/runtime/src/client/workspaces/manager.ts

@@ -1,7 +1,7 @@
 /** Workspace baseline, incremental-frame, and unary-action owner. */
 
 import type {
-  HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView,
+  HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
 } from '@deepseek-ai/dsh-client-connection/client'
 import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
 import { mergeOrderedBaseline } from '../ordered-baseline.ts'
@@ -143,6 +143,40 @@ export class WorkspaceManager {
     return result
   }
 
+  /**
+   * Rename a Workspace, then publish its returned snapshot without waiting
+   * for the changed frame.
+   * @param workspaceId - target workspace.
+   * @param title - new display title.
+   * @returns the wire result.
+   */
+  async rename(workspaceId: WorkspaceId, title: string): Promise<RpcResult<{ workspace: WorkspaceView }>> {
+    const { result } = await this.api.workspace.rename({ workspaceId, title })
+    if (result.ok) this.upsert(result.value.workspace)
+    return result
+  }
+
+  /**
+   * Move a session within its Workspace's manual order, then publish the
+   * returned snapshot without waiting for the changed frame.
+   * @param workspaceId - owning workspace.
+   * @param sessionId - accounted session to move.
+   * @param beforeSessionId - accounted anchor to insert before; omitted appends.
+   * @returns the wire result.
+   */
+  async insertSessionBefore(
+    workspaceId: WorkspaceId,
+    sessionId: SessionId,
+    beforeSessionId?: SessionId,
+  ): Promise<RpcResult<{ workspace: WorkspaceView }>> {
+    const { result } = await this.api.workspace.insertSessionBefore({
+      workspaceId, sessionId,
+      ...beforeSessionId === undefined ? {} : { beforeSessionId },
+    })
+    if (result.ok) this.upsert(result.value.workspace)
+    return result
+  }
+
   /**
    * Host-frame entry. Non-workspace frames are ignored so the runtime can
    * fan one host stream out to both object managers.
@@ -189,6 +223,11 @@ export class WorkspaceManager {
   private upsert(view: WorkspaceView, identity?: Workspace): void {
     this.refreshFrames?.push(view)
     const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
+    // Mutation responses and changed frames race (two carriers, no ordering):
+    // reject a snapshot strictly older than the installed projection so a
+    // late unary response cannot roll back a newer frame.
+    const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view
+    if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return
     if (identity !== undefined) {
       this.items = index === -1
         ? [identity, ...this.items]

+ 30 - 1
packages/client/runtime/src/client/workspaces/service.ts

@@ -2,7 +2,7 @@
 
 import type { Context } from 'cordis'
 import type {
-  IApiClient, RpcError, WorkspaceId, WorkspaceView,
+  IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
 } from '@deepseek-ai/dsh-client-connection/client'
 import type { SnapshotStore } from '../contract/store.ts'
 import { createSnapshotStore } from '../contract/store.ts'
@@ -100,6 +100,35 @@ export class WorkspacesService {
     return result.value.workspace
   }
 
+  /**
+   * 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.manager.rename(workspaceId, title)
+    if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`)
+    return result.value.workspace
+  }
+
+  /**
+   * 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.manager.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
+    if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`)
+    return result.value.workspace
+  }
+
   /**
    * Refresh the workspace baseline, reusing an in-flight pull.
    * @returns completion of the current or newly started workspace baseline pull.

+ 9 - 0
packages/client/runtime/tests/fake-api.ts

@@ -92,9 +92,18 @@ export class FakeApiClient implements IApiClient {
   onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
     () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
 
+  onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
+    () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
+
+  onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
+    () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
+
   readonly workspace: IApiClient['workspace'] = {
     list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
     create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
+    rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
+    insertSessionBefore: (payload: unknown) =>
+      this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
   }
 
   /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */

+ 22 - 0
packages/client/ui-primitives/src/HoverCard.module.css

@@ -0,0 +1,22 @@
+/* Block, not inline-flex: consumers wrap full-width list rows and an
+ * inline wrapper would shrink them; the card still measures this rect. */
+.root {
+  position: relative;
+  display: block;
+}
+
+/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the
+ * menu card's elevation. Surface is #2C2C2E in both themes (figma value,
+ * light/dark identical), so a component-level variable, not a theme token. */
+.card {
+  --dsw-hovercard-bg: #2C2C2E;
+  position: fixed;
+  z-index: 100;
+  box-sizing: border-box;
+  width: 244px;
+  padding: 12px 16px;
+  border-radius: 12px;
+  background: var(--dsw-hovercard-bg);
+  box-shadow: var(--dsw-shadow-lv3);
+  pointer-events: none;
+}

+ 109 - 0
packages/client/ui-primitives/src/HoverCard.tsx

@@ -0,0 +1,109 @@
+// HoverCard: delayed hover-preview card portaled to document.body.
+// Same portal mechanics as Menu: the wrapper span supplies the anchor rect,
+// the card is fixed-positioned at its right edge and repositions on
+// scroll/resize while open. Display-only — the card ignores pointer events
+// and closes the instant the pointer leaves the anchor (no close delay).
+
+import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import type { CSSProperties, ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import css from './HoverCard.module.css'
+
+/**
+ * Render an anchor with a hover-triggered preview card.
+ * @param props.anchor - the hover target (rendered in place inside a wrapper span).
+ * @param props.content - card content (display-only, no pointer interaction).
+ * @param props.openDelayMs - hover dwell before the card shows (default 500).
+ * @param props.disabled - suppress opening; turning true closes an open card.
+ * @returns anchor wrapper with the conditional portaled card.
+ */
+export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: {
+  anchor: ReactNode
+  content: ReactNode
+  openDelayMs?: number
+  disabled?: boolean
+}) {
+  const rootRef = useRef<HTMLSpanElement>(null)
+  const cardRef = useRef<HTMLDivElement>(null)
+  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const [open, setOpen] = useState(false)
+  const [pos, setPos] = useState<CSSProperties | null>(null)
+
+  const clearTimer = () => {
+    if (timerRef.current !== null) {
+      clearTimeout(timerRef.current)
+      timerRef.current = null
+    }
+  }
+
+  // Owner disabling mid-hover (menu opened, drag started) closes immediately.
+  useEffect(() => {
+    if (!disabled) return
+    clearTimer()
+    setOpen(false)
+  }, [disabled])
+
+  useEffect(() => clearTimer, [])
+
+  // Fixed-position from the anchor rect before paint; track the anchor while
+  // open (capture-phase scroll catches nested panes), as in Menu portal mode.
+  useLayoutEffect(() => {
+    if (!open) { setPos(null); return }
+    const place = () => {
+      const r = rootRef.current?.getBoundingClientRect() ?? null
+      if (r === null) return
+      const h = cardRef.current?.offsetHeight ?? 0
+      const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top
+      setPos({ left: r.right + 8, top })
+    }
+    place()
+    window.addEventListener('scroll', place, true)
+    window.addEventListener('resize', place)
+    return () => {
+      window.removeEventListener('scroll', place, true)
+      window.removeEventListener('resize', place)
+    }
+  }, [open])
+
+  // The first placement ran before the card mounted (height read 0): once the
+  // card's real height is measurable, correct the bottom-edge clamp.
+  useLayoutEffect(() => {
+    if (!open || pos === null || typeof pos.top !== 'number') return
+    const h = cardRef.current?.offsetHeight ?? 0
+    if (pos.top + h > window.innerHeight - 8) {
+      const top = window.innerHeight - h - 8
+      if (pos.top !== top) setPos({ ...pos, top })
+    }
+  }, [open, pos])
+
+  const card = open && pos !== null && (
+    <div ref={cardRef} className={css.card} style={pos}>
+      {content}
+    </div>
+  )
+
+  return (
+    <span
+      ref={rootRef}
+      className={css.root}
+      onPointerEnter={() => {
+        if (disabled) return
+        clearTimer()
+        timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs)
+      }}
+      onPointerLeave={() => {
+        clearTimer()
+        setOpen(false)
+      }}
+      // Any press inside the anchor (row click, menu trigger) dismisses the
+      // card immediately, without waiting for the owner to flip `disabled`.
+      onPointerDownCapture={() => {
+        clearTimer()
+        setOpen(false)
+      }}
+    >
+      {anchor}
+      {card !== false && createPortal(card, document.body)}
+    </span>
+  )
+}

+ 21 - 0
packages/client/ui-primitives/src/Menu.module.css

@@ -109,6 +109,27 @@
   background: transparent;
 }
 
+/* Destructive row: error text/icon, danger hover fill. */
+.danger {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.danger .itemIcon {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.danger:hover:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover-danger);
+}
+
+/* Heading row: non-interactive small grey text, padding aligned with items. */
+.label {
+  padding: 8px 10px;
+  font-size: 12px;
+  line-height: 16px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
 /* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */
 .separator {
   height: 1px;

+ 29 - 4
packages/client/ui-primitives/src/Menu.tsx

@@ -4,6 +4,7 @@
 // the anchor rect, for anchors inside overflow-clipping containers (sidebar).
 // The owner controls `open`; outside-click closing uses one document listener
 // active only while open. Submenus open on hover/focus inside the same root.
+// Entries also cover non-interactive `label` headings and `danger` rows.
 
 import { useEffect, useLayoutEffect, useRef, useState } from 'react'
 import type { CSSProperties, ReactNode } from 'react'
@@ -19,6 +20,8 @@ export interface MenuItem {
   disabled?: boolean
   /** Leading icon (figma .Menu_cell gap 8). */
   icon?: ReactNode
+  /** Destructive row: error-colored text/icon and danger hover fill. */
+  danger?: boolean
   /** Nested card opened to the right on hover/focus. */
   submenu?: readonly MenuItem[]
 }
@@ -29,13 +32,24 @@ export interface MenuSeparator {
   id: string
 }
 
-/** One primary-menu entry: a row or a separator. */
-export type MenuEntry = MenuItem | MenuSeparator
+/** Non-interactive heading row above a group of items. */
+export interface MenuLabel {
+  type: 'label'
+  id: string
+  text: string
+}
+
+/** One primary-menu entry: a row, a separator, or a heading label. */
+export type MenuEntry = MenuItem | MenuSeparator | MenuLabel
 
 function isSeparator(entry: MenuEntry): entry is MenuSeparator {
   return 'type' in entry && entry.type === 'separator'
 }
 
+function isLabel(entry: MenuEntry): entry is MenuLabel {
+  return 'type' in entry && entry.type === 'label'
+}
+
 /**
  * Render an anchored dropdown menu.
  * @param props.open - whether the list is showing (owner-controlled).
@@ -50,6 +64,8 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
  * from the anchor rect (repositions on scroll/resize while open). Use when an
  * ancestor's overflow clipping would crop the in-place list; default false
  * keeps the pure-CSS in-place behavior.
+ * @param props.closeOnPointerLeave - close the list when the pointer leaves
+ * it (default false keeps it open until outside click/Escape/selection).
  * @param props.getAnchorRect - portal mode only: supply the anchor rect
  * directly (e.g. from a host-owned trigger button) instead of measuring the
  * Menu's own wrapper span. Required when the wrapper isn't itself laid out at
@@ -58,7 +74,7 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
  * scroll/resize; return null to skip placement for that frame.
  * @returns anchor wrapper with the conditional list.
  */
-export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, getAnchorRect, className }: {
+export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
   open: boolean
   anchor: ReactNode
   items: readonly MenuEntry[]
@@ -68,6 +84,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
   align?: 'start' | 'end'
   side?: 'bottom' | 'top'
   portal?: boolean
+  closeOnPointerLeave?: boolean
   getAnchorRect?: () => DOMRect | null
   className?: string
 }) {
@@ -135,11 +152,19 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
       className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
       style={fixedPos ?? undefined}
       role="menu"
+      onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
+      // React portals bubble synthetic events through the REACT tree: without
+      // this stop, an item click re-fires the anchor row's own onClick
+      // (open/toggle) after onSelect.
+      onClick={(e) => { e.stopPropagation() }}
     >
           {items.map(entry => {
             if (isSeparator(entry)) {
               return <div key={entry.id} className={css.separator} role="separator" />
             }
+            if (isLabel(entry)) {
+              return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
+            }
             const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
             const subOpen = hasSub && openSubmenuId === entry.id
             return (
@@ -152,7 +177,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
                 <button
                   type="button"
                   role="menuitem"
-                  className={clsx(css.item, entry.id === selectedId && css.selected)}
+                  className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
                   disabled={entry.disabled}
                   aria-haspopup={hasSub ? 'menu' : undefined}
                   aria-expanded={hasSub ? subOpen : undefined}

+ 2 - 1
packages/client/ui-primitives/src/index.ts

@@ -9,7 +9,8 @@ export type { ButtonVariant } from './Button.tsx'
 export { Pill } from './Pill.tsx'
 export { Input } from './Input.tsx'
 export { Menu } from './Menu.tsx'
-export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx'
+export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
+export { HoverCard } from './HoverCard.tsx'
 export { Modal } from './Modal.tsx'
 export { ConnectionBanner } from './ConnectionBanner.tsx'
 export { FishLogo } from './FishLogo.tsx'

+ 0 - 143
packages/client/ui-sidebar/src/client/Rows.tsx

@@ -1,143 +0,0 @@
-/**
- * Sidebar tree row components (figma Cell set 14:3080): pure presentational —
- * all data and callbacks arrive via props. Hover swaps (folder->chevron,
- * time->ellipsis, action buttons) are CSS-only.
- */
-import clsx from 'clsx'
-import {
-  IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
-  IconTriangleRightFill14, StateDot,
-} from '@deepseek-ai/dsh-client-ui-primitives'
-import type { GroupNode, SessionNode } from './tree.ts'
-import { formatRelativeTime } from './tree.ts'
-import css from './Rows.module.css'
-
-/** Indent step per tree level: one 16px slot (figma session cell). */
-const INDENT_STEP = 16
-
-/**
- * Project (workspace) header row: 54px, folder + title + session count;
- * hover reveals the chevron and create button. `containsCurrent` arrives on
- * the node (derivation fact, no renderer scan).
- * @param props.group - derived group node.
- * @param props.onToggle - expand/collapse the group.
- * @param props.onCreate - start a frontend Session inside this Workspace.
- * @returns the row element.
- */
-export function ProjectRowItem({ group, onToggle, onCreate }: {
-  group: GroupNode
-  onToggle: () => void
-  onCreate: () => void
-}) {
-  const row = group
-  const active = group.expanded && group.containsCurrent
-  const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
-  return (
-    <div className={css.projectRow} role="treeitem" aria-expanded={row.expanded} onClick={onToggle}>
-      <span className={clsx(css.slot, css.folder, active && css.folderActive)}>
-        {row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
-      </span>
-      <span className={clsx(css.slot, css.chevron)}>
-        <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
-      </span>
-      <span className={css.projectText}>
-        <span className={css.title}>{row.label}</span>
-        <span className={css.meta}>{count}</span>
-      </span>
-      <span className={css.rowActions}>
-        <button
-          type="button"
-          className={css.iconButton}
-          aria-label={`New session in ${row.label}`}
-          onClick={(e) => { e.stopPropagation(); onCreate() }}
-        >
-          <IconPlusOutline16 />
-        </button>
-      </span>
-    </div>
-  )
-}
-
-/**
- * The selected "New session" row for a frontend Session Intent targeted to a
- * real Workspace. The row disappears when the Intent is replaced or connects.
- * @returns the placeholder row element.
- */
-export function IntentRowItem() {
-  return (
-    <div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}>
-      <span className={css.slot} />
-      <span className={css.slot} />
-      <span className={css.title}>New session</span>
-    </div>
-  )
-}
-
-/**
- * One session subtree: the node's own 34px row (indent by depth, expand
- * twist when it has children, running dot, relative time) plus its visible
- * children, recursively — the component tree mirrors the derived tree.
- * @param props.node - derived session node.
- * @param props.depth - 0 = directly under the group header.
- * @param props.currentId - selected session id (row highlight).
- * @param props.now - epoch ms for relative-time formatting.
- * @param props.onOpen - open a session by id.
- * @param props.onToggle - unfold/fold a subtree by id.
- * @returns the node's row followed by its children.
- */
-export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle }: {
-  node: SessionNode
-  depth: number
-  currentId: string | undefined
-  now: number
-  onOpen: (id: SessionNode['id']) => void
-  onToggle: (id: SessionNode['id']) => void
-}) {
-  const row = node
-  const selected = node.id === currentId
-  // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
-  // the title): both slots are always reserved so titles align whether or not
-  // the twist/dot is lit. Extra depth rides the left padding.
-  const ownRow = (
-    <div
-      className={clsx(css.sessionRow, selected && css.selected)}
-      role="treeitem"
-      aria-selected={selected}
-      {...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
-      style={{ paddingLeft: 8 + depth * INDENT_STEP }}
-      onClick={() => { onOpen(node.id) }}
-    >
-      {row.hasChildren
-        ? (
-            <button
-              type="button"
-              className={css.twist}
-              aria-label={row.expanded ? 'Collapse' : 'Expand'}
-              onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
-            >
-              <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
-            </button>
-          )
-        : <span className={css.slot} />}
-      <span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
-      <span className={css.title}>{row.title}</span>
-      <span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
-    </div>
-  )
-  return (
-    <>
-      {ownRow}
-      {node.children.map(child => (
-        <SessionNodeItem
-          key={child.id}
-          node={child}
-          depth={depth + 1}
-          currentId={currentId}
-          now={now}
-          onOpen={onOpen}
-          onToggle={onToggle}
-        />
-      ))}
-    </>
-  )
-}

+ 3 - 183
packages/client/ui-sidebar/src/client/SidebarRoot.module.css

@@ -48,7 +48,6 @@
    refresh straight into the collapsed state renders statically. */
 .railIn .iconButton,
 .railIn .newSession,
-.railIn .searchButton,
 .railIn .foot {
   animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards;
 }
@@ -184,133 +183,9 @@
   max-width: 0;
 }
 
-/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons;
-   the right-anchored new-workspace button is the row's rail survivor. */
-.sectionHeader {
-  flex: none;
-  display: flex;
-  align-items: center;
-  justify-content: flex-end;
-  gap: 4px;
-  height: 36px;
-  padding-left: 12px;
-  margin-bottom: 4px;
-  box-sizing: border-box;
-  border-radius: 12px;
-  overflow: hidden;
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.collapsed .sectionHeader {
-  height: 36px;
-  padding-left: 0;
-  margin-bottom: 12px;
-}
-
-.sectionLabel {
-  flex: 1;
-  min-width: 0;
-  overflow: hidden;
-  white-space: nowrap;
-  line-height: 20px;
-}
-
-/* Search input: 38px capsule (figma 133:7649); collapsed it renders as the
-   rail's search control. Upstream binds a dedicated design-system variable (light
-   #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token
-   pinned to the static scale mirrors it (ruled compliant: indirect via
-   custom property, upstream-variable equivalent). */
-.search {
-  --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
-  flex: none;
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  height: 38px;
-  margin: 0 2px 12px; /* bottom: former listArea gap 4 + own 8 (spec padB12 to the first cell) */
-  padding: 0 14px;
-  box-sizing: border-box;
-  border: 1px solid var(--dsw-alias-border-l2);
-  border-radius: 24px;
-  background: var(--dsh-search-input-fill);
-  color: var(--dsw-alias-label-caption);
-  overflow: hidden;
-}
-
-:global(body[data-ds-dark-theme]) .search {
-  --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
-}
-
-.collapsed .search {
-  height: 36px;
-  padding: 0;
-  margin: 0 0 12px;
-  gap: 0;
-  border-color: transparent;
-  background: transparent;
-}
-
-/* The capsule's leading icon, upgraded to the rail's search control. While
-   expanded it is decorative: pointer-events off so clicks reach the label
-   (native input focus); collapsed it becomes the hit target. */
-.searchButton {
-  flex: none;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  border: none;
-  border-radius: 50%;
-  padding: 0;
-  background: transparent;
-  pointer-events: none;
-  color: inherit;
-}
-
-.collapsed .searchButton {
-  width: 36px;
-  height: 36px;
-  pointer-events: auto;
-  cursor: pointer;
-  color: var(--dsw-alias-label-primary);
-}
-
-.collapsed .searchButton:hover {
-  background: var(--dsw-alias-interactive-bg-hover);
-}
-
-.searchInput {
-  flex: 1;
-  min-width: 0;
-  border: none;
-  outline: none;
-  background: transparent;
-  font-size: 14px;
-  line-height: 20px;
-  color: var(--dsw-alias-label-primary);
-}
-
-.searchInput::placeholder {
-  color: var(--dsw-alias-label-tertiary);
-}
-
-.clearButton {
-  flex: none;
-  display: inline-flex;
-  align-items: center;
-  justify-content: center;
-  width: 28px;
-  height: 28px;
-  border: none;
-  border-radius: 50%;
-  padding: 0;
-  background: transparent;
-  cursor: pointer;
-  color: var(--dsw-alias-label-secondary);
-}
-
-/* Tree seat: always mounted so the foot never moves; the tree content inside
-   is wide-only and clips while the column squeezes. */
-.listArea {
+/* Region seat: always mounted so the foot never moves; the browser inside
+   handles its own wide/rail content. */
+.regionArea {
   flex: 1;
   min-height: 0;
   display: flex;
@@ -318,60 +193,6 @@
   overflow: hidden;
 }
 
-/* Relative for the bottom fade overlay. */
-.treeBody {
-  flex: 1;
-  min-height: 0;
-  display: flex;
-  flex-direction: column;
-  position: relative;
-}
-
-/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
-   transparent -> sidebar fill so it tracks the theme. */
-.fade {
-  position: absolute;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  height: 72px;
-  background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
-  pointer-events: none;
-}
-
-/* Tree list: the only scrolling region. Block, not a flex column: as flex
-   items the 54/34 rows would shrink under content overflow (scrollHeight
-   collapses onto clientHeight and wheel scrolling dies); block children keep
-   their design heights and the 4px rhythm rides margins instead of gap. */
-.list {
-  flex: 1;
-  min-height: 0;
-  overflow-y: auto;
-  padding-bottom: 12px;
-}
-
-/* One workspace section: header row + expanded session run. Rows inside
-   keep the former flat-list 4px gap as sibling margins; the inter-group
-   breathing room (figma 133:7661 batch separator, 20px after an expanded
-   run) rides the NEXT section's top margin so the last group adds none. */
-.groupSection > * + * {
-  margin-top: 4px;
-}
-
-.groupSection + .groupSection {
-  margin-top: 4px;
-}
-
-.groupSection:has([aria-expanded='true']) + .groupSection {
-  margin-top: 20px;
-}
-
-.empty {
-  padding: 16px 12px;
-  color: var(--dsw-alias-label-tertiary);
-  font-size: 13px;
-}
-
 /* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical
    margins fold into the row so the hover pill spans the full 49px. */
 .foot {
@@ -418,7 +239,6 @@
   .fading > *,
   .railIn .iconButton,
   .railIn .newSession,
-  .railIn .searchButton,
   .railIn .foot {
     transition: none;
     animation: none;

+ 18 - 230
packages/client/ui-sidebar/src/client/SidebarRoot.tsx

@@ -1,170 +1,38 @@
 /**
- * Collapse is a slide plus crossfade: content freezes at its expanded
- * width (inline style) and fades out in place while the sliding column
- * (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
- * the wide-only content (brand, labels, input, tree) unmounts, dropping
- * the sessions subscription, and the control rows snap to the 56px rail
- * (one icon each, same top-down order) fading in as the slide ends. Rail
- * search expands and focuses the search box.
+ * Sidebar shell: column geometry only. Collapse is a slide plus crossfade:
+ * content freezes at its expanded width (inline style) and fades out in place
+ * while the sliding column (AppFrame grid tracks) clips it — nothing reflows
+ * mid-slide. At settle the wide-only content unmounts and the control rows
+ * snap to the 56px rail (one icon each, same top-down order) fading in as the
+ * slide ends. The workspace/session browsing region between the New Session
+ * button and the foot is the `sidebar.workspaces` registrant's; the shell
+ * hands it the wide flag and an expand request callback.
  */
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
 import clsx from 'clsx'
 import {
   BrandWordmark, FishLogo,
-  IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16,
-  IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
-  Menu, Tooltip,
+  IconNewChatOutline16, IconPanelLeftOutline16, IconSettingsOutline14,
+  Tooltip,
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SidebarRootComponentProps } from './contract/slots.ts'
-import { deriveGroups, UNGROUPED_KEY } from './tree.ts'
-import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './Rows.tsx'
 import css from './SidebarRoot.module.css'
 
 /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
 const COLLAPSE_SETTLE_MS = 150
 
-/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
-const EXPAND_SLIDE_MS = 300
-
-const GROUP_BY_ITEMS = [
-  { id: 'workspace', label: 'Workspace' },
-  // Only workspace grouping is implemented.
-  { id: 'update', label: 'Update', disabled: true },
-  { id: 'status', label: 'Status', disabled: true },
-]
-
-/** Immutable membership toggle for the local expansion arrays. */
-function toggled(list: readonly string[], key: string): string[] {
-  return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
-}
-
-/** Group-by strategy menu; own open state so it resets with the wide chrome. */
-function GroupByMenu() {
-  const [open, setOpen] = useState(false)
-  return (
-    <Menu
-      open={open}
-      onClose={() => { setOpen(false) }}
-      items={GROUP_BY_ITEMS}
-      selectedId="workspace"
-      onSelect={() => { setOpen(false) }}
-      align="end"
-      anchor={(
-        <button
-          type="button"
-          className={clsx(css.iconButton, css.wide)}
-          aria-label="Group by"
-          onClick={() => { setOpen((v) => !v) }}
-        >
-          <IconPersonalizationOutline16 />
-        </button>
-      )}
-    />
-  )
-}
-
-type SessionTreeProps = Pick<
-  SidebarRootComponentProps,
-  'useSessions' | 'startSession' | 'open'
-> & {
-  workspaces: readonly WorkspaceView[]
-  /** Live search filter owned by the root (the query outlives the tree). */
-  query: string
-}
-
-/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
-function SessionTree({ useSessions, startSession, open, workspaces, query }: SessionTreeProps) {
-  const list = useSessions((s) => s)
-  const current = list.current
-  const [expandedProjects, setExpandedProjects] = useState<string[]>([])
-  const [expandedSessions, setExpandedSessions] = useState<string[]>([])
-  // Re-expand when publication moves the selected intent into a real Workspace.
-  const intent = list.intent
-  const intentWorkspaceId = intent?.target.kind === 'workspace'
-    ? intent.target.workspaceId
-    : undefined
-  const currentGroup = current === undefined
-    ? undefined
-    : intent?.sessionId === current
-      ? intentWorkspaceId
-      : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
-        ?? UNGROUPED_KEY
-  useEffect(() => {
-    if (current === undefined || currentGroup === undefined) return
-    setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
-  }, [current, currentGroup])
-  const groups = useMemo(
-    () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
-    [list, workspaces, expandedProjects, expandedSessions, query],
-  )
-  const now = Date.now()
-
-  return (
-    <div className={clsx(css.treeBody, css.wide)}>
-      <div className={css.list} role="tree" aria-label="Sessions">
-        {groups.length === 0 && (
-          <div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
-        )}
-        {groups.map(group => (
-          // Group section: header row + expanded session subtree. The
-          // inter-group breathing room (former flat-list batch separator)
-          // is the section's own margin (SidebarRoot.module.css).
-          <div key={group.key} className={css.groupSection}>
-            <ProjectRowItem
-              group={group}
-              onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
-              onCreate={() => {
-                if (group.workspaceId !== undefined) startSession(group.workspaceId)
-              }}
-            />
-            {group.intentHere && <IntentRowItem />}
-            {group.sessions.map(node => (
-              <SessionNodeItem
-                key={node.id}
-                node={node}
-                depth={0}
-                currentId={current}
-                now={now}
-                onOpen={open}
-                onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
-              />
-            ))}
-          </div>
-        ))}
-      </div>
-      <span className={css.fade} />
-    </div>
-  )
-}
-
 /**
- * Render the sidebar column.
+ * Render the sidebar column shell.
  * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
  * @returns the sidebar element tree.
  */
 export function SidebarRoot({
   collapsed,
   width,
-  useSessions,
-  useWorkspaces,
   startSession,
-  open,
   toggleSidebar,
   renderSlot,
 }: SidebarRootComponentProps) {
-  const workspaces = useWorkspaces(state => state.items)
-  // The query outlives the tree and the input (both wide-only) so collapsing
-  // does not silently drop an in-progress filter.
-  const [query, setQuery] = useState('')
-  const searchInput = useRef<HTMLInputElement | null>(null)
-  // Section-header + opens the workspace picker (same popover in wide and
-  // rail states; the hole sits beside the button and opens rightward).
-  const [wsPickerOpen, setWsPickerOpen] = useState(false)
-  // Placement anchor for the picker popover: the slot span renders elsewhere
-  // in the DOM, so the picker positions off this button's rect.
-  const wsPlusRef = useRef<HTMLButtonElement>(null)
-
   // Wide content stays mounted while the collapse animates (fading via
   // .collapsed .wide), unmounts at settle, and remounts right away on expand.
   const [settled, setSettled] = useState(collapsed)
@@ -186,19 +54,6 @@ export function SidebarRoot({
   const everWide = useRef(!collapsed)
   if (!collapsed) everWide.current = true
 
-  // Rail search = expand + land in the search box: the flag arms before the
-  // expand toggle; once expanded the input is mounted and takes focus.
-  const [searchOnExpand, setSearchOnExpand] = useState(false)
-  useEffect(() => {
-    if (!collapsed && searchOnExpand) {
-      const timer = window.setTimeout(() => {
-        searchInput.current?.focus({ preventScroll: true })
-        setSearchOnExpand(false)
-      }, EXPAND_SLIDE_MS)
-      return () => { window.clearTimeout(timer) }
-    }
-  }, [collapsed, searchOnExpand])
-
   return (
     <div
       className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
@@ -238,82 +93,15 @@ export function SidebarRoot({
         </button>
       </Tooltip>
 
-      <div className={css.sectionHeader}>
-        {wide && <span className={clsx(css.sectionLabel, css.wide)}>Workspaces</span>}
-        {wide && <GroupByMenu />}
-        <Tooltip label="New Workspace" disabled={wide}>
-          <button
-            ref={wsPlusRef}
-            type="button"
-            className={css.iconButton}
-            aria-label="Create workspace"
-            onClick={() => { setWsPickerOpen(v => !v) }}
-          >
-            <IconProjectAddOutline16 size={wide ? 16 : 18} />
-          </button>
-        </Tooltip>
-        {/* Picker hole beside the + (same site in wide and rail states). */}
-        {renderSlot('sidebar.workspace', {
-          open: wsPickerOpen,
-          anchorRef: wsPlusRef,
-          onPick: (workspaceId) => {
-            setWsPickerOpen(false)
-            startSession(workspaceId)
-          },
-          onClose: () => { setWsPickerOpen(false) },
+      {/* The browsing region fills the column between the controls and the
+          foot in both states; its rail icon column rides the same slot. */}
+      <div className={css.regionArea}>
+        {renderSlot('sidebar.workspaces', {
+          wide,
+          expandSidebar: () => { if (collapsed) toggleSidebar() },
         })}
       </div>
 
-      {/* Expanded: the row is a click-to-focus field (the leading icon is
-          decorative). Collapsed: the icon is the rail's search control. */}
-      <div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
-        <Tooltip label="Search" disabled={wide}>
-          <button
-            type="button"
-            className={css.searchButton}
-            aria-label="Search sessions"
-            tabIndex={collapsed ? 0 : -1}
-            onClick={() => { if (collapsed) { setSearchOnExpand(true); toggleSidebar() } }}
-          >
-            <IconSearchOutline16 size={wide ? 14 : 18} />
-          </button>
-        </Tooltip>
-        {wide && (
-          <input
-            ref={searchInput}
-            className={clsx(css.searchInput, css.wide)}
-            type="text"
-            placeholder="Search name, keywords..."
-            value={query}
-            onChange={(e) => { setQuery(e.target.value) }}
-          />
-        )}
-        {wide && query !== '' && (
-          <button
-            type="button"
-            className={clsx(css.clearButton, css.wide)}
-            aria-label="Clear search"
-            onClick={() => { setQuery('') }}
-          >
-            <IconCloseFill14 />
-          </button>
-        )}
-      </div>
-
-      {/* Always-mounted seat: its flex slot pins the foot to the bottom in
-          both states while the tree itself is wide-only. */}
-      <div className={css.listArea}>
-        {wide && (
-          <SessionTree
-            useSessions={useSessions}
-            workspaces={workspaces}
-            startSession={startSession}
-            open={open}
-            query={query}
-          />
-        )}
-      </div>
-
       <div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
         <IconSettingsOutline14 size={wide ? 14 : 18} />
         {wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}

+ 22 - 37
packages/client/ui-sidebar/src/client/contract/slots.ts

@@ -1,69 +1,54 @@
 /**
  * Sidebar slot contract: the registrant-side props composition for the
- * layout-owned `sidebar` slot and the Workspace picker hole declared here.
- * The runtime share combines layout-owned page state and actions with the
- * global useSessions and useWorkspaces hooks; the injected share adds the
- * runtime navigation actions and sidebar toggle.
+ * layout-owned `sidebar` slot, plus the workspace-browser hole this shell
+ * declares. The shell owns column geometry (fold state machine, brand row,
+ * New Session, Settings); everything between the section header and the list
+ * bottom is the `sidebar.workspaces` registrant's (ui-workspace).
  */
-import type { RefObject } from 'react'
 import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
 // program that sees this contract, so PropsRuntime<'sidebar'> resolves.
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
-import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
     /**
-     * The workspace picker hole in the sidebar section header (anchored at
-     * the + button). Declared by this package's 'sidebar' entry (declaring
-     * is claiming); ui-workspace registers the picker.
+     * The workspace/session browsing region: section header, search, the
+     * grouped/flat session list, and every workspace dialog. Declared by this
+     * package's 'sidebar' entry (declaring is claiming); ui-workspace
+     * registers the browser.
      */
-    'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps }
+    'sidebar.workspaces': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps }
   }
 }
 
 /**
- * Owner share of the sidebar workspace hole: popover geometry plus the
- * sidebar's pick semantics. The picked Host Workspace is already real; the
- * callback starts a frontend Session Intent targeted to it.
+ * Owner share of the browser hole — the only facts crossing the shell/region
+ * seam. Business data and actions arrive through the region's own inject.
  */
-export interface SidebarWorkspaceOwnerProps {
-  /** Popover visibility (+ button toggle state, host-local). */
-  open: boolean
-  /**
-   * The + button element — the popover's placement anchor. The picker's
-   * slot span renders elsewhere in the DOM, so without this the menu
-   * positions off the zero-size placement span (order-dependent). Optional
-   * only until the host passes it; absent falls back to in-place placement.
-   */
-  anchorRef?: RefObject<HTMLElement>
-  /** Start a frontend Session in a selected or newly created real Workspace. */
-  onPick: (workspaceId: WorkspaceId) => void
-  /** Close the popover (outside click / Escape / post-pick). */
-  onClose: () => void
+export interface SidebarSectionOwnerProps {
+  /** Shell fold-state output: wide renders the full browser, rail the icon column. */
+  wide: boolean
+  /** Rail icons request expansion; the browser rides the wide flip for focus. */
+  expandSidebar: () => void
 }
 
 /**
  * Registrant-private injected share (arrives via the register inject
- * factory). Host Workspace and Session data use the global framework hooks;
- * navigation and panel actions are plain callbacks, and viewing state remains
- * component-local. A type alias supplies the implicit index signature required
- * by the registry.
+ * factory). The shell keeps only its own controls: starting a Session from
+ * the New Session button and toggling the column.
  */
 export type SidebarRootInjected = {
   /** Start or replace the current frontend Session Intent. */
   startSession: (workspaceId?: WorkspaceId, prompt?: string) => void
-  /** Open a real Session. */
-  open: (sessionId: SessionId) => void
   /** Toggle the sidebar column through the layout service. */
   toggleSidebar: () => void
 }
 
 /**
- * Full component props: layout owner state/actions plus global useSessions
- * and useWorkspaces, the declared Workspace picker render share, and this
- * package's injected callback. No store is registered.
+ * Full component props: layout owner state/actions plus the browser hole's
+ * render share and this package's injected callbacks. No store is registered.
  */
 export type SidebarRootComponentProps =
-  PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected
+  PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces'> & SidebarRootInjected

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

@@ -1,28 +1,27 @@
-/** Registers the sidebar UI into the layout-owned slot. */
+/** Registers the sidebar shell into the layout-owned slot. */
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import type { SidebarRootInjected } from './contract/slots.ts'
 import { SidebarRoot } from './SidebarRoot.tsx'
 
-export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts'
+export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps } from './contract/slots.ts'
 
 /** Services required by the sidebar plugin. */
-export const inject = ['slots', 'layout', 'sessions', 'workspaces']
+export const inject = ['slots', 'layout', 'workspaces']
 
-/** Registers the sidebar component and its service callbacks.
+/** Registers the sidebar shell and its service callbacks.
  * @param ctx - Client root context.
  */
 export function apply(ctx: ClientContext): void {
   const injectProps = (): SidebarRootInjected => ({
     startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) },
-    open: (sessionId) => { ctx.sessions.open(sessionId) },
     toggleSidebar: () => { ctx.layout.toggleSidebar() },
   })
   ctx.effect(
     () => ctx.slots.register({
       name: 'sidebar',
-      // SidebarRoot owns this picker site; ui-workspace registers the shared
-      // picker that selects a Host Workspace for a frontend Session Intent.
-      children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } },
+      // The shell owns geometry; ui-workspace registers the whole browsing
+      // region (header, search, session list, workspace dialogs) here.
+      children: { 'sidebar.workspaces': { kind: 'single', scope: 'root' } },
       inject: injectProps,
     }, SidebarRoot),
     'ui-sidebar: slot registration',

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

@@ -1,4 +1,4 @@
-/** Sidebar slot registration and its plain runtime/layout callbacks. */
+/** Sidebar shell slot registration and its plain runtime/layout callbacks. */
 import { Context } from 'cordis'
 import { describe, expect, it, vi } from 'vitest'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
@@ -9,10 +9,8 @@ async function bench(declare = true) {
   const ctx = new Context()
   await ctx.plugin(SlotsService).await()
   const layout = { toggleSidebar: vi.fn() }
-  const sessions = { open: vi.fn() }
   const workspaces = { startSession: vi.fn() }
   ctx.provide('layout', layout)
-  ctx.provide('sessions', sessions as never)
   ctx.provide('workspaces', workspaces as never)
   const slots = ctx.get('slots') as SlotsService
   if (declare) {
@@ -21,25 +19,23 @@ async function bench(declare = true) {
       () => null,
     )
   }
-  return { ctx, slots, layout, sessions, workspaces }
+  return { ctx, slots, layout, workspaces }
 }
 
 describe('ui-sidebar apply', () => {
   it('declares only the services it uses', () => {
-    expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
+    expect(inject).toEqual(['slots', 'layout', 'workspaces'])
   })
 
-  it('registers the sidebar and declares its Workspace picker hole', async () => {
+  it('registers the shell and declares the browsing-region hole', async () => {
     const b = await bench()
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     expect(b.slots.entries('sidebar')).toHaveLength(1)
-    expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' })
+    expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
     const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
-    expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar'])
+    expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
     injected.startSession('workspace' as never, 'prompt')
     expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt')
-    injected.open('session' as never)
-    expect(b.sessions.open).toHaveBeenCalledWith('session')
     injected.toggleSidebar()
     expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
   })
@@ -55,6 +51,6 @@ describe('ui-sidebar apply', () => {
     await fiber.await()
     await fiber.dispose()
     expect(b.slots.entries('sidebar')).toHaveLength(0)
-    expect(b.slots.spec('sidebar.workspace')).toBeUndefined()
+    expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
   })
 })

+ 42 - 220
packages/client/ui-sidebar/tests/sidebar-root.spec.tsx

@@ -1,79 +1,42 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
-import type {
-  SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
-} from '@deepseek-ai/dsh-client-runtime/client'
-import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts'
 import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
 
 afterEach(() => {
   cleanup()
   vi.useRealTimers()
 })
-const sid = (id: string) => id as SessionId
-const wid = (id: string) => id as WorkspaceId
-const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
-const workspace: WorkspaceView = {
-  workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')],
-  createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
-}
-const sessions: SessionListState = {
-  ids: [sid('s1')],
-  byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } },
-  current: undefined, phase: 'ready',
-  intent: undefined,
-}
-const workspaces: WorkspaceListState = {
-  items: [workspace], state: 'idle', phase: 'ready', error: null,
-  intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId,
-}
 
-function mount(sessionState: SessionListState = sessions) {
-  const startSession = vi.fn()
-  const open = vi.fn()
-  let pickerOwner: unknown
-  const view = render(
-    <SidebarRoot
-      collapsed={false} width={300}
-      useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)}
-      startSession={startSession} open={open} toggleSidebar={vi.fn()}
-      renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
-    />,
-  )
-  return { view, startSession, open, pickerOwner: () => pickerOwner }
-}
+// The shell never reads the global hooks itself, but they ride the standard
+// props share; stub them as never-called functions.
+const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
 
-function mountSidebar({
-  sessionState = sessions,
-  workspaceState = workspaces,
-  collapsed = false,
-  width = 300,
-}: {
-  sessionState?: SessionListState
-  workspaceState?: WorkspaceListState
-  collapsed?: boolean
-  width?: number
-} = {}) {
+function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) {
   const startSession = vi.fn()
-  const open = vi.fn()
   const toggleSidebar = vi.fn()
-  let pickerOwner: unknown
-  let current = { sessionState, workspaceState, collapsed, width }
+  let regionOwner: SidebarSectionOwnerProps | undefined
+  let current = { collapsed, width }
   const root = () => (
     <SidebarRoot
       collapsed={current.collapsed} width={current.width}
-      useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)}
-      startSession={startSession} open={open} toggleSidebar={toggleSidebar}
-      renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
+      useSessions={neverHook} useWorkspaces={neverHook}
+      startSession={startSession} toggleSidebar={toggleSidebar}
+      renderSlot={((_key: string, owner: SidebarSectionOwnerProps) => {
+        regionOwner = owner
+        return <div data-testid="region" data-wide={owner.wide} />
+      }) as SidebarRootComponentProps['renderSlot']}
     />
   )
   const view = render(root())
   return {
     startSession,
-    open,
     toggleSidebar,
-    pickerOwner: () => pickerOwner,
+    regionOwner: () => {
+      if (regionOwner === undefined) throw new Error('region owner not rendered')
+      return regionOwner
+    },
     rerender(next: Partial<typeof current>) {
       current = { ...current, ...next }
       view.rerender(root())
@@ -81,181 +44,40 @@ function mountSidebar({
   }
 }
 
-describe('SidebarRoot', () => {
-  it('renders real Workspaces from useWorkspaces and routes New Session', () => {
-    const b = mount()
-    expect(screen.getByText('Project')).toBeTruthy()
+describe('SidebarRoot shell', () => {
+  it('routes New Session and the column toggle', () => {
+    const b = mountShell()
     fireEvent.click(screen.getByRole('button', { name: 'New session' }))
     expect(b.startSession).toHaveBeenCalledWith()
+    fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
+    expect(b.toggleSidebar).toHaveBeenCalledOnce()
   })
 
-  it('shows a frontend Session under its real Workspace and routes its row plus', () => {
-    const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const }
-    const b = mount({
-      ...sessions,
-      current: intent.sessionId,
-      intent,
-    })
-    expect(screen.getByText('New session')).toBeTruthy()
-    expect(screen.getByText('2 sessions')).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
-    expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
-  })
-
-  it('forwards Workspace picker selection and closes the picker', () => {
-    const b = mount()
-    fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
-    const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
-    expect(owner.open).toBe(true)
-    owner.onPick(workspace.workspaceId)
-    expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
-  })
-
-  it('opens a real Session through the owner action', () => {
-    const b = mount({ ...sessions, current: sid('intent'), intent: {
-      sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready',
-    } })
-    fireEvent.click(screen.getByText('Project'))
-    fireEvent.click(screen.getByText('First session'))
-    expect(b.open).toHaveBeenCalledWith(sid('s1'))
-  })
-
-  it('opens, selects, dismisses, and toggles the group-by menu', () => {
-    mount()
-    const button = screen.getByRole('button', { name: 'Group by' })
-
-    fireEvent.click(button)
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' }))
-    expect(screen.queryByRole('menu')).toBeNull()
-
-    fireEvent.click(button)
-    fireEvent.keyDown(document, { key: 'Escape' })
-    expect(screen.queryByRole('menu')).toBeNull()
-
-    fireEvent.click(button)
-    fireEvent.click(button)
-    expect(screen.queryByRole('menu')).toBeNull()
-  })
-
-  it('routes every Workspace picker close path', () => {
-    const b = mount()
-    fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
-    const owner = b.pickerOwner() as { open: boolean; onClose(): void }
-    expect(owner.open).toBe(true)
-    act(() => { owner.onClose() })
-    expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
-  })
-
-  it('focuses, filters, and clears search while distinguishing both empty states', () => {
-    mount()
-    const input = screen.getByPlaceholderText('Search name, keywords...')
-    fireEvent.click(input.parentElement!)
-    expect(document.activeElement).toBe(input)
-    fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
-
-    fireEvent.change(input, { target: { value: 'missing' } })
-    expect(screen.getByText('No matches')).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
-    expect(screen.queryByText('No matches')).toBeNull()
-
-    cleanup()
-    const emptySessions = listState()
-    const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined }
-    mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces })
-    expect(screen.getByText('No sessions yet')).toBeTruthy()
-  })
-
-  it('toggles Workspace and nested Session expansion in both directions', () => {
-    const parent = sid('parent')
-    const child = sid('child')
-    const nestedSessions: SessionListState = {
-      ...sessions,
-      ids: [parent, child],
-      byId: {
-        [parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 },
-        [child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent },
-      },
-    }
-    const nestedWorkspace: WorkspaceListState = {
-      ...workspaces,
-      items: [{ ...workspace, sessionIds: [parent, child] }],
-    }
-    mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace })
-
-    fireEvent.click(screen.getByText('Project'))
-    fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
-    expect(screen.getByText('Child')).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
-    expect(screen.queryByText('Child')).toBeNull()
-    fireEvent.click(screen.getByText('Project'))
-    expect(screen.queryByText('Parent')).toBeNull()
-  })
-
-  it('does not start a Session from an Ungrouped row create action', () => {
-    const loose = sid('loose')
-    const looseSessions: SessionListState = {
-      ...listState(),
-      ids: [loose],
-      byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } },
-      current: loose,
-    }
-    const b = mountSidebar({
-      sessionState: looseSessions,
-      workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined },
-    })
-    fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
-    expect(b.startSession).not.toHaveBeenCalled()
-  })
-
-  it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => {
-    const b = mountSidebar()
-    fireEvent.click(screen.getByText('Project'))
-    const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] }
-    b.rerender({
-      sessionState: { ...sessions, current: sid('s1') },
-      workspaceState: { ...workspaces, items: [other, workspace] },
-    })
-    expect(screen.getByText('First session')).toBeTruthy()
-
-    b.rerender({
-      sessionState: {
-        ...sessions,
-        current: sid('draft'),
-        intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' },
-      },
-    })
-    expect(screen.getByText('Project')).toBeTruthy()
+  it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => {
+    const b = mountShell()
+    expect(b.regionOwner().wide).toBe(true)
+    // Expanded: the request is a no-op (no accidental collapse).
+    b.regionOwner().expandSidebar()
+    expect(b.toggleSidebar).not.toHaveBeenCalled()
   })
 
-  it('renders the static collapsed rail and expands rail search into focused input', () => {
+  it('keeps the region mounted through collapse and expands on its request', () => {
     vi.useFakeTimers()
-    const b = mountSidebar({ collapsed: true })
-    expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
-    expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' }))
+    const b = mountShell()
+    b.rerender({ collapsed: true })
+    // Wide content survives the crossfade window, then settles into the rail.
+    expect(b.regionOwner().wide).toBe(true)
+    vi.advanceTimersByTime(200)
+    b.rerender({})
+    expect(b.regionOwner().wide).toBe(false)
+    expect(screen.getByTestId('region')).toBeTruthy()
+    b.regionOwner().expandSidebar()
     expect(b.toggleSidebar).toHaveBeenCalledOnce()
-
-    fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
-    expect(b.toggleSidebar).toHaveBeenCalledTimes(2)
-    b.rerender({ collapsed: false })
-    const input = screen.getByPlaceholderText('Search name, keywords...')
-    act(() => { vi.advanceTimersByTime(300) })
-    expect(document.activeElement).toBe(input)
   })
 
-  it('keeps wide content during live collapse, then settles to the rail', () => {
-    vi.useFakeTimers()
-    const b = mountSidebar({ width: 320 })
-    fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
-    expect(b.toggleSidebar).toHaveBeenCalledOnce()
-    b.rerender({ collapsed: true, width: 56 })
-    expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy()
-    act(() => { vi.advanceTimersByTime(150) })
-    expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
+  it('renders statically collapsed on a cold start (no crossfade classes)', () => {
+    const b = mountShell({ collapsed: true })
+    expect(b.regionOwner().wide).toBe(false)
     expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
   })
 })
-
-function listState(): SessionListState {
-  return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined }
-}

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

@@ -35,6 +35,9 @@
     "watch": "tsdown --watch"
   },
   "license": "BSD-3-Clause",
+  "dependencies": {
+    "clsx": "^2.0.0"
+  },
   "peerDependencies": {
     "@deepseek-ai/dsh-client-runtime": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",

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

@@ -0,0 +1,265 @@
+/* Workspace browsing region (fills the sidebar shell's hole): section
+   header, search capsule, and the scrolling session list. Wide/rail
+   variants ride the shell's fold state through the `wide` owner prop —
+   rail state renders only the two 36x36 icon controls. */
+
+.root {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.iconButton {
+  flex: none;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  border: none;
+  border-radius: 50%;
+  padding: 0;
+  background: transparent;
+  cursor: pointer;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.iconButton:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* Section header: 36px, "Workspaces/Sessions" label + group-by /
+   new-workspace buttons; the right-anchored new-workspace button is the
+   row's rail survivor. */
+.sectionHeader {
+  flex: none;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 4px;
+  height: 36px;
+  padding-left: 12px;
+  margin-bottom: 4px;
+  box-sizing: border-box;
+  border-radius: 12px;
+  overflow: hidden;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.sectionLabel {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  white-space: nowrap;
+  line-height: 20px;
+}
+
+/* Search input: 38px capsule (figma 133:7649); rail state renders it as the
+   region's search control. Upstream binds a dedicated design-system variable
+   (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component
+   token pinned to the static scale mirrors it. */
+.search {
+  --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
+  flex: none;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  height: 38px;
+  margin: 0 2px 12px;
+  padding: 0 14px;
+  box-sizing: border-box;
+  border: 1px solid var(--dsw-alias-border-l2);
+  border-radius: 24px;
+  background: var(--dsh-search-input-fill);
+  color: var(--dsw-alias-label-caption);
+  overflow: hidden;
+}
+
+:global(body[data-ds-dark-theme]) .search {
+  --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
+}
+
+/* The capsule's leading icon: decorative while wide (pointer-events off so
+   clicks reach the input), the hit target in rail state. */
+.searchButton {
+  flex: none;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border: none;
+  border-radius: 50%;
+  padding: 0;
+  background: transparent;
+  pointer-events: none;
+  color: inherit;
+}
+
+.searchInput {
+  flex: 1;
+  min-width: 0;
+  border: none;
+  outline: none;
+  background: transparent;
+  font-size: 14px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.searchInput::placeholder {
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.clearButton {
+  flex: none;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  border: none;
+  border-radius: 50%;
+  padding: 0;
+  background: transparent;
+  cursor: pointer;
+  color: var(--dsw-alias-label-secondary);
+}
+
+/* Rail variant (own .rail class from the wide owner prop — the region never
+   reads the shell's class names): the two icon controls stack as 36x36
+   circles matching the shell's rail rhythm. */
+.rail .sectionHeader {
+  padding-left: 0;
+  margin-bottom: 12px;
+}
+
+.rail .iconButton {
+  width: 36px;
+  height: 36px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.rail .search {
+  height: 36px;
+  padding: 0;
+  margin: 0 0 12px;
+  gap: 0;
+  border-color: transparent;
+  background: transparent;
+}
+
+.rail .searchButton {
+  width: 36px;
+  height: 36px;
+  pointer-events: auto;
+  cursor: pointer;
+  color: var(--dsw-alias-label-primary);
+}
+
+.rail .searchButton:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* List seat: always mounted so the shell foot never moves. */
+.listArea {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+/* Relative for the bottom fade overlay. */
+.treeBody {
+  flex: 1;
+  min-height: 0;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+}
+
+/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
+   transparent -> sidebar fill so it tracks the theme. */
+.fade {
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: 72px;
+  background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
+  pointer-events: none;
+}
+
+/* Wide-only content fades back in on expand remount (mirrors the shell). */
+.wide {
+  animation: wide-in 200ms var(--ds-ease-in-out);
+}
+
+@keyframes wide-in {
+  from { opacity: 0; }
+}
+
+/* List: the only scrolling region. Block, not a flex column: as flex items
+   the 54/34 rows would shrink under content overflow; block children keep
+   their design heights and the 4px rhythm rides margins instead of gap. */
+.list {
+  flex: 1;
+  min-height: 0;
+  overflow-y: auto;
+  padding-bottom: 12px;
+}
+
+/* One workspace section: header row + expanded session run. Rows inside
+   keep the former flat-list 4px gap as sibling margins; the inter-group
+   breathing room (figma 133:7661 batch separator, 20px after an expanded
+   run) rides the NEXT section's top margin so the last group adds none. */
+.groupSection > * + * {
+  margin-top: 4px;
+}
+
+.groupSection + .groupSection {
+  margin-top: 4px;
+}
+
+.groupSection:has([aria-expanded='true']) + .groupSection {
+  margin-top: 20px;
+}
+
+.empty {
+  padding: 16px 12px;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: 13px;
+}
+
+/* Rename dialog form (same figma dialog family as the create modals). */
+.renameInput {
+  box-sizing: border-box;
+  width: 100%;
+  height: 44px;
+  padding: 7px 14px;
+  border: 1px solid var(--dsw-alias-border-l2);
+  border-radius: 22px;
+  outline: none;
+  background: transparent;
+  font-size: 14px;
+  font-weight: 400;
+  line-height: 22px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.renameInput:disabled {
+  color: var(--dsw-alias-label-dimmed);
+}
+
+.renameError {
+  margin-top: 8px;
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-state-error-primary);
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .wide {
+    animation: none;
+  }
+}

+ 428 - 0
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -0,0 +1,428 @@
+/**
+ * The workspace/session browsing region filling the sidebar shell's
+ * `sidebar.workspaces` hole: section header (title + group-by + new
+ * workspace), search, the grouped tree or flat list, and the workspace
+ * dialogs. Wide state renders the full browser; rail state renders the two
+ * region icons (search / new workspace), each requesting shell expansion
+ * through the owner share. The picker menu and create dialogs live in
+ * WorkspacePicker (same package — direct composition, no slot between them).
+ */
+import { useEffect, useMemo, useRef, useState } from 'react'
+import clsx from 'clsx'
+import {
+  Button, IconCloseFill14, IconPersonalizationOutline16,
+  IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
+import type { WorkspaceBrowserProps } from './contract/slots.ts'
+import type { SessionNode } from './tree.ts'
+import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts'
+import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
+import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
+import css from './WorkspaceBrowser.module.css'
+
+/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
+const EXPAND_SLIDE_MS = 300
+
+const GROUP_BY_ITEMS = [
+  { type: 'label' as const, id: 'group-by', text: 'Group by' },
+  { id: 'workspace', label: 'WorkSpace' },
+  { id: 'flat', label: 'In one list' },
+]
+
+/** Immutable membership toggle for the local expansion arrays. */
+function toggled(list: readonly string[], key: string): string[] {
+  return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
+}
+
+/** Group-by strategy menu; own open state so it resets with the wide chrome. */
+function GroupByMenu({ groupBy, onPick }: {
+  groupBy: 'workspace' | 'flat'
+  onPick: (mode: 'workspace' | 'flat') => void
+}) {
+  const [open, setOpen] = useState(false)
+  return (
+    <Menu
+      open={open}
+      onClose={() => { setOpen(false) }}
+      items={GROUP_BY_ITEMS}
+      selectedId={groupBy}
+      onSelect={(id) => {
+        if (id === 'workspace' || id === 'flat') onPick(id)
+        setOpen(false)
+      }}
+      align="end"
+      // Portal: the section header clips overflow, so an in-place list would
+      // be cut off at the header's bounds.
+      portal
+      anchor={(
+        <button
+          type="button"
+          className={clsx(css.iconButton, css.wide)}
+          aria-label="Group by"
+          onClick={() => { setOpen((v) => !v) }}
+        >
+          <IconPersonalizationOutline16 />
+        </button>
+      )}
+    />
+  )
+}
+
+/** In-flight root-row drag: source identity plus the current insert marker. */
+interface DragState {
+  workspaceId: WorkspaceId
+  sessionId: SessionNode['id']
+  /** Row the marker sits on and which half (insert above/below it). */
+  over: { id: SessionNode['id']; half: 'before' | 'after' } | null
+}
+
+type SessionTreeProps = Pick<
+  WorkspaceBrowserProps,
+  'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
+> & {
+  workspaces: readonly WorkspaceView[]
+  /** Live search filter owned by the browser root (the query outlives the tree). */
+  query: string
+  /** Open the browser-owned rename dialog for a real Workspace group. */
+  onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
+}
+
+/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
+function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) {
+  const list = useSessions((s) => s)
+  const current = list.current
+  const [expandedProjects, setExpandedProjects] = useState<string[]>([])
+  const [expandedSessions, setExpandedSessions] = useState<string[]>([])
+  // Transient drag viewing state (never store-bound; order truth stays Host-side).
+  const [drag, setDrag] = useState<DragState | null>(null)
+  // Re-expand when publication moves the selected intent into a real Workspace.
+  const intent = list.intent
+  const intentWorkspaceId = intent?.target.kind === 'workspace'
+    ? intent.target.workspaceId
+    : undefined
+  const currentGroup = current === undefined
+    ? undefined
+    : intent?.sessionId === current
+      ? intentWorkspaceId
+      : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
+        ?? UNGROUPED_KEY
+  useEffect(() => {
+    if (current === undefined || currentGroup === undefined) return
+    setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
+  }, [current, currentGroup])
+  const groups = useMemo(
+    () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
+    [list, workspaces, expandedProjects, expandedSessions, query],
+  )
+  const now = Date.now()
+
+  return (
+    <div className={clsx(css.treeBody, css.wide)}>
+      <div className={css.list} role="tree" aria-label="Sessions">
+        {groups.length === 0 && (
+          <div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
+        )}
+        {groups.map(group => (
+          // Group section: header row + expanded session subtree. The
+          // inter-group breathing room (former flat-list batch separator)
+          // is the section's own margin (WorkspaceBrowser.module.css).
+          <div key={group.key} className={css.groupSection}>
+            <ProjectRowItem
+              group={group}
+              onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
+              onCreate={() => {
+                if (group.workspaceId !== undefined) startSession(group.workspaceId)
+              }}
+              onRename={group.workspaceId === undefined
+                ? undefined
+                : () => {
+                    if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
+                  }}
+            />
+            {group.expanded && group.intentHere && <IntentRowItem />}
+            {group.sessions.map((node, index) => {
+              // Draggable: real-workspace group roots outside search. The drag
+              // never leaves its group — rows of other groups show no markers
+              // and reject drops (visual movement confined to this section).
+              const draggable = group.workspaceId !== undefined && query === ''
+              const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
+              const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
+                start: () => {
+                  setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null })
+                },
+                active: sameGroupDrag,
+                marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
+                hover: (half: 'before' | 'after') => {
+                  setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } }))
+                },
+                drop: (half: 'before' | 'after') => {
+                  if (drag === null) return
+                  const roots = group.sessions
+                  // Anchor = the row the insert line points at ('after' means
+                  // the next root; end-of-list omits the anchor → append).
+                  const anchor = half === 'before' ? node.id : roots[index + 1]?.id
+                  setDrag(null)
+                  if (anchor === drag.sessionId) return
+                  // No-op when the drop lands back on the source position.
+                  const sourceIndex = roots.findIndex(r => r.id === drag.sessionId)
+                  const anchorIndex = anchor === undefined ? roots.length : roots.findIndex(r => r.id === anchor)
+                  if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
+                  insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => {
+                    console.warn('session reorder rejected:', reason)
+                  })
+                },
+                end: () => { setDrag(null) },
+              }
+              return (
+                <SessionNodeItem
+                  key={node.id}
+                  node={node}
+                  depth={0}
+                  currentId={current}
+                  now={now}
+                  onOpen={open}
+                  onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
+                  drag={dragProps}
+                />
+              )
+            })}
+          </div>
+        ))}
+      </div>
+      <span className={css.fade} />
+    </div>
+  )
+}
+
+/** The flat "In one list" body: every session a top-level row, newest-first. */
+function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
+  const list = useSessions((s) => s)
+  const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
+  const now = Date.now()
+  // The intent placeholder renders outside search only; it suppresses the
+  // empty state only while actually rendered (a query hides both).
+  const intentRow = query === '' && list.intent !== undefined
+  return (
+    <div className={clsx(css.treeBody, css.wide)}>
+      <div className={css.list} role="tree" aria-label="Sessions">
+        {rows.length === 0 && !intentRow && (
+          <div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
+        )}
+        {intentRow && <IntentRowItem flat />}
+        {rows.map(node => (
+          <SessionNodeItem
+            key={node.id}
+            node={node}
+            depth={0}
+            currentId={list.current}
+            now={now}
+            onOpen={open}
+            onToggle={() => {}}
+            flat
+          />
+        ))}
+      </div>
+      <span className={css.fade} />
+    </div>
+  )
+}
+
+/**
+ * Render the browsing region.
+ * @param props - composed slot props (shell owner share + store + injected actions).
+ * @returns the region element tree.
+ */
+export function WorkspaceBrowser({
+  wide,
+  expandSidebar,
+  useSessions,
+  useWorkspaces,
+  useStore,
+  actions,
+  startSession,
+  open,
+  renameWorkspace,
+  insertSessionBefore,
+  createWorkspace,
+}: WorkspaceBrowserProps) {
+  const workspaces = useWorkspaces(state => state.items)
+  const groupBy = useStore(s => s.groupBy)
+  // The query outlives the tree and the input (both wide-only) so collapsing
+  // does not silently drop an in-progress filter.
+  const [query, setQuery] = useState('')
+  const searchInput = useRef<HTMLInputElement | null>(null)
+  // Section-header + opens the picker menu (same popover in wide and rail
+  // states; the menu anchors on this button).
+  const [wsPickerOpen, setWsPickerOpen] = useState(false)
+  const wsPlusRef = useRef<HTMLButtonElement>(null)
+
+  // Rail search = expand + land in the search box: the flag arms before the
+  // expand request; once the shell flips wide the input mounts and takes focus.
+  const [searchOnExpand, setSearchOnExpand] = useState(false)
+  useEffect(() => {
+    if (wide && searchOnExpand) {
+      const timer = window.setTimeout(() => {
+        searchInput.current?.focus({ preventScroll: true })
+        setSearchOnExpand(false)
+      }, EXPAND_SLIDE_MS)
+      return () => { window.clearTimeout(timer) }
+    }
+  }, [wide, searchOnExpand])
+
+  // Rename dialog (browser-owned so it outlives row unmounts during collapse).
+  const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null)
+  const [renameDraft, setRenameDraft] = useState('')
+  const [renaming, setRenaming] = useState(false)
+  const [renameError, setRenameError] = useState<string | null>(null)
+  const renameTrimmed = renameDraft.trim()
+  const renameDuplicate = renameTarget !== null && renameTrimmed !== '' && renameTrimmed !== renameTarget.currentTitle
+    && workspaces.some(w => w.title === renameTrimmed)
+  const renameBlocked = renaming || renameTrimmed === ''
+    || renameTarget === null || renameTrimmed === renameTarget.currentTitle || renameDuplicate
+  const closeRename = () => {
+    if (renaming) return
+    setRenameTarget(null)
+    setRenameError(null)
+  }
+  const confirmRename = () => {
+    if (renameBlocked || renameTarget === null) return
+    setRenaming(true)
+    setRenameError(null)
+    renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
+      setRenaming(false)
+      setRenameTarget(null)
+    }).catch((reason: unknown) => {
+      setRenaming(false)
+      setRenameError(reason instanceof Error ? reason.message : String(reason))
+    })
+  }
+
+  return (
+    <div className={clsx(css.root, !wide && css.rail)}>
+      <div className={css.sectionHeader}>
+        {wide && (
+          <span className={clsx(css.sectionLabel, css.wide)}>
+            {groupBy === 'flat' ? 'Sessions' : 'Workspaces'}
+          </span>
+        )}
+        {wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} />}
+        <Tooltip label="New Workspace" disabled={wide}>
+          <button
+            ref={wsPlusRef}
+            type="button"
+            className={css.iconButton}
+            aria-label="Create workspace"
+            onClick={() => {
+              if (!wide) expandSidebar()
+              setWsPickerOpen(v => !v)
+            }}
+          >
+            <IconProjectAddOutline16 size={wide ? 16 : 18} />
+          </button>
+        </Tooltip>
+        {/* Picker menu + create dialogs (same package — direct composition). */}
+        <WorkspaceCreateFlow
+          open={wsPickerOpen}
+          anchorRef={wsPlusRef}
+          useWorkspaces={useWorkspaces}
+          createWorkspace={createWorkspace}
+          onPick={(workspaceId) => {
+            setWsPickerOpen(false)
+            startSession(workspaceId)
+          }}
+          onClose={() => { setWsPickerOpen(false) }}
+        />
+      </div>
+
+      {/* Expanded: the row is a click-to-focus field (the leading icon is
+          decorative). Rail: the icon is the region's search control. */}
+      <div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}>
+        <Tooltip label="Search" disabled={wide}>
+          <button
+            type="button"
+            className={css.searchButton}
+            aria-label="Search sessions"
+            tabIndex={wide ? -1 : 0}
+            onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }}
+          >
+            <IconSearchOutline16 size={wide ? 14 : 18} />
+          </button>
+        </Tooltip>
+        {wide && (
+          <input
+            ref={searchInput}
+            className={clsx(css.searchInput, css.wide)}
+            type="text"
+            placeholder="Search name, keywords..."
+            value={query}
+            onChange={(e) => { setQuery(e.target.value) }}
+          />
+        )}
+        {wide && query !== '' && (
+          <button
+            type="button"
+            className={clsx(css.clearButton, css.wide)}
+            aria-label="Clear search"
+            onClick={() => { setQuery('') }}
+          >
+            <IconCloseFill14 />
+          </button>
+        )}
+      </div>
+
+      {/* Always-mounted seat keeps the region's flex slot while the list
+          itself is wide-only. */}
+      <div className={css.listArea}>
+        {wide && (groupBy === 'flat'
+          ? <FlatList useSessions={useSessions} open={open} query={query} />
+          : (
+              <SessionTree
+                useSessions={useSessions}
+                workspaces={workspaces}
+                startSession={startSession}
+                open={open}
+                query={query}
+                insertSessionBefore={insertSessionBefore}
+                onRenameRequest={(workspaceId, currentTitle) => {
+                  setRenameTarget({ workspaceId, currentTitle })
+                  setRenameDraft(currentTitle)
+                  setRenameError(null)
+                }}
+              />
+            ))}
+      </div>
+
+      <Modal
+        open={renameTarget !== null}
+        onClose={closeRename}
+        title="Rename workspace"
+        footer={(
+          <>
+            <Button variant="outline" disabled={renaming} onClick={closeRename}>Cancel</Button>
+            <Button variant="primary" disabled={renameBlocked} onClick={confirmRename}>Rename</Button>
+          </>
+        )}
+      >
+        <input
+          className={css.renameInput}
+          value={renameDraft}
+          aria-label="Workspace name"
+          autoFocus
+          disabled={renaming}
+          onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }}
+          onKeyDown={(e) => {
+            if (e.key === 'Enter') {
+              e.preventDefault()
+              confirmRename()
+            }
+          }}
+        />
+        {renameDuplicate && (
+          <div className={css.renameError} role="alert">A workspace named “{renameTrimmed}” already exists.</div>
+        )}
+        {renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
+      </Modal>
+    </div>
+  )
+}

+ 58 - 5
packages/client/ui-workspace/src/client/WorkspacePicker.tsx

@@ -1,9 +1,15 @@
-/** Shared Workspace picker for the sidebar and New Session hero. */
+/**
+ * Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
+ * (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
+ * package) and wrapped by WorkspacePicker for the conversation empty-state
+ * slot registration.
+ */
+import type { RefObject } from 'react'
 import { useCallback, useState } from 'react'
 import {
   Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
 import type { WorkspacePickerProps } from './contract/slots.ts'
 import css from './WorkspacePicker.module.css'
 
@@ -13,14 +19,35 @@ const CREATE_NEW = '::create-new'
 
 type ModalKind = 'path' | 'create' | null
 
-export function WorkspacePicker({
+/** Core flow props: the owner supplies popover control and pick semantics. */
+export interface WorkspaceCreateFlowProps {
+  /** Popover visibility (anchor button toggle state, owner-local). */
+  open: boolean
+  /** 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
+  /** Create or adopt a real Host Workspace. */
+  createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
+  /** A real Workspace was picked or created. */
+  onPick: (workspaceId: WorkspaceId) => void
+  /** Close the popover (outside click / Escape / post-pick). */
+  onClose: () => void
+}
+
+/**
+ * Render the pick menu plus the two create dialogs.
+ * @param props - owner-controlled flow props.
+ * @returns menu + dialog elements.
+ */
+export function WorkspaceCreateFlow({
   open,
   anchorRef,
   useWorkspaces,
+  createWorkspace,
   onPick,
   onClose,
-  createWorkspace,
-}: WorkspacePickerProps) {
+}: WorkspaceCreateFlowProps) {
   const workspaceSnapshot = useWorkspaces(state => state)
   const workspaces = workspaceSnapshot.items
   const getAnchorRect = useCallback(
@@ -194,3 +221,29 @@ export function WorkspacePicker({
     </>
   )
 }
+
+/**
+ * The conversation empty-state registration: adapts the owner share to the
+ * core flow (all state and semantics live in the flow / the owner).
+ * @param props - empty-state slot props (owner share + injected creation callback).
+ * @returns the flow element.
+ */
+export function WorkspacePicker({
+  open,
+  anchorRef,
+  useWorkspaces,
+  onPick,
+  onClose,
+  createWorkspace,
+}: WorkspacePickerProps) {
+  return (
+    <WorkspaceCreateFlow
+      open={open}
+      anchorRef={anchorRef}
+      useWorkspaces={useWorkspaces}
+      createWorkspace={createWorkspace}
+      onPick={onPick}
+      onClose={onClose}
+    />
+  )
+}

+ 46 - 17
packages/client/ui-workspace/src/client/contract/slots.ts

@@ -1,30 +1,59 @@
 /**
- * Shared Workspace picker contract for the sidebar and page-local Session Intent hero
- * slots. Each runtime share provides its owner's popover controls plus the
- * global useWorkspaces hook; this package adds the injected Host Workspace
- * creation callback.
+ * ui-workspace contracts. Two registrations share this package:
+ *
+ * - WorkspaceBrowser fills the sidebar shell's `sidebar.workspaces` hole —
+ *   the whole browsing region (section header, search, grouped/flat session
+ *   list, workspace dialogs). It registers this package's viewing store and
+ *   consumes the shell's two-fact owner share (wide / expandSidebar).
+ * - WorkspacePicker fills the conversation empty-state hole (menu +
+ *   create dialogs shared with the browser).
  */
-import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
-// Type-only: pull both owner SlotMap merges into programs that resolve the
-// picker runtime union below.
+import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
+// Type-only: pull the owner SlotMap merges into programs that resolve the
+// 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 { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
+import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
+import type { createWorkspaceViewStore } from '../stores.ts'
 
 /**
- * Registrant-private injected share. Pick semantics remain in each owner's
- * onPick callback; this callback creates only the real Host Workspace. A type
- * alias supplies the implicit index signature required by the registry.
+ * Browser-private injected share (arrives via the register inject factory).
+ * Data reads use the global framework hooks; these are the Host actions the
+ * browsing region drives.
  */
-export type WorkspacePickerInjected = {
+export type WorkspaceBrowserInjected = {
+  /** Start or replace the current frontend Session Intent. */
+  startSession: (workspaceId?: WorkspaceId, prompt?: string) => void
+  /** Open a real Session. */
+  open: (sessionId: SessionId) => void
+  /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
+  renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
+  /**
+   * Reorder a session inside its Workspace account (DOM-insertBefore
+   * semantics: omitted anchor appends to the end). The view refreshes from
+   * the Host response/changed frame; failures leave the order unchanged.
+   */
+  insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
   /** Explicitly create or adopt a real Workspace before targeting a Session. */
-  createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
+  createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
 }
 
+/** Full browser props: shell owner share + viewing store + injected actions. */
+export type WorkspaceBrowserProps =
+  PropsRuntime<'sidebar.workspaces'>
+  & PropsStore<ReturnType<typeof createWorkspaceViewStore>>
+  & WorkspaceBrowserInjected
+
 /**
- * Full picker props: either owner's runtime share, including useWorkspaces,
- * plus this package's injected creation callback.
+ * Picker-private injected share. Pick semantics remain in the owner's onPick
+ * callback; this callback creates only the real Host Workspace. A type alias
+ * supplies the implicit index signature required by the registry.
  */
+export type WorkspacePickerInjected = {
+  /** Explicitly create or adopt a real Workspace before targeting a Session. */
+  createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
+}
+
+/** Full picker props: the empty-state owner share plus the creation callback. */
 export type WorkspacePickerProps =
-  (PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>)
-  & WorkspacePickerInjected
+  PropsRuntime<'conversation.empty.workspace'> & WorkspacePickerInjected

+ 63 - 32
packages/client/ui-workspace/src/client/index.ts

@@ -1,54 +1,85 @@
 /**
- * Shared Workspace picker plugin, browser half. WorkspacePicker registers in
- * the sidebar and page-local Session Intent hero slots, reads real Host Workspaces
- * through the global useWorkspaces hook, and delegates selection semantics to
- * each owner. Its injected share creates a Workspace without creating a
- * Session. Export discipline: packages/client/AGENTS.md.
+ * Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills
+ * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
+ * and WorkspacePicker fills the conversation empty-state hole. Both read real
+ * Host Workspaces through the global useWorkspaces hook. Export discipline:
+ * packages/client/AGENTS.md.
  */
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
-import type { WorkspacePickerInjected } from './contract/slots.ts'
+import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
+import { createWorkspaceViewStore } from './stores.ts'
+import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
 import { WorkspacePicker } from './WorkspacePicker.tsx'
 
-export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts'
+export type {
+  WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
+} from './contract/slots.ts'
 
 /**
- * Required services (cordis fiber inject). The target slot is declared by
- * the ui-sidebar apply, whose activation order relative to this one is NOT
- * constrained: dshClient.inject edges are informational (loading/prefetch
- * metadata, never apply sequencing) and the sidebar provides no waitable
- * service. apply therefore registers via declaration-aware deferral instead
- * of assuming order.
+ * Required services (cordis fiber inject). The target slots are declared by
+ * the ui-sidebar / ui-conversation applies, whose activation order relative
+ * to this one is NOT constrained: dshClient.inject edges are informational
+ * (loading/prefetch metadata, never apply sequencing) and neither owner
+ * provides a waitable service. apply therefore registers via
+ * declaration-aware deferral instead of assuming order.
  */
-export const inject = ['slots', 'workspaces']
+export const inject = ['slots', 'sessions', 'workspaces']
 
 /**
- * Register WorkspacePicker in both owner slots once their declarations are on
- * the ledger. The inject factory returns a plain Workspace creation callback;
- * data reads use the framework's global useWorkspaces hook.
+ * Register the browser and picker once their slot declarations are on the
+ * ledger. Inject factories return plain callbacks; data reads use the
+ * framework's global hooks.
  * @param ctx - client root context.
  */
 export function apply(ctx: ClientContext): void {
-  const injected = (): WorkspacePickerInjected => ({
+  const browserInjected = (): WorkspaceBrowserInjected => ({
+    startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) },
+    open: (sessionId) => { ctx.sessions.open(sessionId) },
+    renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
+    insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
+      await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
+    },
     createWorkspace: input => ctx.workspaces.create(input),
   })
-  // Declaration-aware registration: the sidebar's declaring apply may
-  // activate after this one (entry activation order is unconstrained), and a
-  // register into an undeclared slot throws. Register once the declaration
-  // is on the ledger; the subscription also re-registers after an HMR
-  // collapse re-declares the slot (the cascade disposed our entry with it).
+  const pickerInjected = (): WorkspacePickerInjected => ({
+    createWorkspace: input => ctx.workspaces.create(input),
+  })
+  // Declaration-aware registration: each owner's declaring apply may activate
+  // after this one (entry activation order is unconstrained), and a register
+  // into an undeclared slot throws. Register once the declaration is on the
+  // ledger; the subscription also re-registers after an HMR collapse
+  // re-declares the slot (the cascade disposed our entry with it).
   ctx.effect(() => {
-    const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const
-    const disposers = new Map<(typeof slotNames)[number], () => void>()
-    const tryRegister = (name: (typeof slotNames)[number]): void => {
-      if (ctx.slots.spec(name) === undefined) return
-      if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return
-      disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker))
+    const registrations = [
+      {
+        name: 'sidebar.workspaces' as const,
+        component: WorkspaceBrowser,
+        register: () => ctx.slots.register(
+          { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
+          WorkspaceBrowser,
+        ),
+      },
+      {
+        name: 'conversation.empty.workspace' as const,
+        component: WorkspacePicker,
+        register: () => ctx.slots.register(
+          { name: 'conversation.empty.workspace', inject: pickerInjected },
+          WorkspacePicker,
+        ),
+      },
+    ]
+    const disposers = new Map<string, () => void>()
+    const tryRegister = (entry: (typeof registrations)[number]): void => {
+      if (ctx.slots.spec(entry.name) === undefined) return
+      if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
+      disposers.set(entry.name, entry.register())
     }
-    const unsubscribers = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) }))
-    for (const name of slotNames) tryRegister(name)
+    const unsubscribers = registrations.map(entry =>
+      ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
+    for (const entry of registrations) tryRegister(entry)
     return () => {
       for (const unsubscribe of unsubscribers) unsubscribe()
       for (const dispose of disposers.values()) dispose()
     }
-  }, 'ui-workspace: picker registrations')
+  }, 'ui-workspace: browser + picker registrations')
 }

+ 51 - 2
packages/client/ui-sidebar/src/client/Rows.module.css → packages/client/ui-workspace/src/client/rows/Rows.module.css

@@ -150,14 +150,63 @@
 }
 
 .projectRow:hover .rowActions,
-.sessionRow:hover .rowActions {
+.sessionRow:hover .rowActions,
+.projectRow.menuOpen .rowActions,
+.sessionRow.menuOpen .rowActions {
   display: inline-flex;
 }
 
-.sessionRow:hover .time {
+.sessionRow:hover .time,
+.sessionRow.menuOpen .time {
   display: none;
 }
 
+/* An open row menu pins the hover affordances (figma: the row keeps its
+   hover fill while its dropdown is up). */
+.projectRow.menuOpen,
+.sessionRow.menuOpen {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* Drag reorder insert line (workspace-group roots): 2px accent above or
+   below the hovered row, drawn with box-shadow so no layout shift. */
+.sessionRow.dropBefore {
+  box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary);
+}
+
+.sessionRow.dropAfter {
+  box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary);
+}
+
+/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */
+.hoverContent {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.hoverTitle {
+  font-size: 14px;
+  line-height: 20px;
+  color: #FFFFFF;
+  overflow-wrap: break-word;
+}
+
+.hoverTime {
+  font-size: 12px;
+  line-height: 16px;
+  color: #CFD3D6;
+}
+
+.hoverStatus {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+  line-height: 20px;
+  color: #ADB2B8;
+}
+
 .iconButton {
   flex: none;
   display: inline-flex;

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

@@ -0,0 +1,285 @@
+/**
+ * Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
+ * all data and callbacks arrive via props. Hover swaps (folder->chevron,
+ * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
+ * except workspace Rename; the session hover card is suppressed while a menu
+ * is open.
+ */
+import { useState } from 'react'
+import clsx from 'clsx'
+import {
+  HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16,
+  IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
+  IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { GroupNode, SessionNode } from '../tree.ts'
+import { formatRelativeTime } from '../tree.ts'
+import css from './Rows.module.css'
+
+/** Indent step per tree level: one 16px slot (figma session cell). */
+const INDENT_STEP = 16
+
+const SESSION_MENU_ITEMS = [
+  { id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
+  { id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> },
+  { id: 'delete', label: 'Delete session', icon: <IconTrashOutline16 />, danger: true },
+]
+
+const WORKSPACE_MENU_ITEMS = [
+  { id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
+  { id: 'delete', label: 'Delete workspace', icon: <IconTrashOutline16 />, danger: true },
+]
+
+/**
+ * Project (workspace) header row: 54px, folder + title + session count;
+ * hover reveals the chevron and create button. `containsCurrent` arrives on
+ * the node (derivation fact, no renderer scan).
+ * @param props.group - derived group node.
+ * @param props.onToggle - expand/collapse the group.
+ * @param props.onCreate - start a frontend Session inside this Workspace.
+ * @returns the row element.
+ */
+export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
+  group: GroupNode
+  onToggle: () => void
+  onCreate: () => void
+  /** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */
+  onRename?: (() => void) | undefined
+}) {
+  const row = group
+  const active = group.expanded && group.containsCurrent
+  const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
+  const [menuOpen, setMenuOpen] = useState(false)
+  return (
+    <div
+      className={clsx(css.projectRow, menuOpen && css.menuOpen)}
+      role="treeitem"
+      aria-expanded={row.expanded}
+      onClick={onToggle}
+    >
+      <span className={clsx(css.slot, css.folder, active && css.folderActive)}>
+        {row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
+      </span>
+      <span className={clsx(css.slot, css.chevron)}>
+        <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
+      </span>
+      <span className={css.projectText}>
+        <span className={css.title}>{row.label}</span>
+        <span className={css.meta}>{count}</span>
+      </span>
+      <span className={css.rowActions}>
+        {onRename !== undefined && (
+          <Menu
+            open={menuOpen}
+            onClose={() => { setMenuOpen(false) }}
+            items={WORKSPACE_MENU_ITEMS}
+            onSelect={(id) => {
+              setMenuOpen(false)
+              if (id === 'rename') onRename()
+              // Delete is visual-only for now.
+            }}
+            portal
+            closeOnPointerLeave
+            anchor={(
+              <button
+                type="button"
+                className={css.iconButton}
+                aria-label={`Workspace actions for ${row.label}`}
+                onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
+              >
+                <IconEllipsisOutline16 />
+              </button>
+            )}
+          />
+        )}
+        <button
+          type="button"
+          className={css.iconButton}
+          aria-label={`New session in ${row.label}`}
+          onClick={(e) => { e.stopPropagation(); onCreate() }}
+        >
+          <IconPlusOutline16 />
+        </button>
+      </span>
+    </div>
+  )
+}
+
+/**
+ * The selected "New session" row for a frontend Session Intent targeted to a
+ * real Workspace. The row disappears when the Intent is replaced or connects.
+ * @param props.flat - flat-list variant: no twist slot (figma flat cell), so
+ * only the status slot indents the title.
+ * @returns the placeholder row element.
+ */
+export function IntentRowItem({ flat = false }: { flat?: boolean } = {}) {
+  return (
+    <div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}>
+      {!flat && <span className={css.slot} />}
+      <span className={css.slot} />
+      <span className={css.title}>New session</span>
+    </div>
+  )
+}
+
+/**
+ * One session subtree: the node's own 34px row (indent by depth, expand
+ * twist when it has children, running dot, relative time) plus its visible
+ * children, recursively — the component tree mirrors the derived tree.
+ * @param props.node - derived session node.
+ * @param props.depth - 0 = directly under the group header.
+ * @param props.currentId - selected session id (row highlight).
+ * @param props.now - epoch ms for relative-time formatting.
+ * @param props.onOpen - open a session by id.
+ * @param props.onToggle - unfold/fold a subtree by id.
+ * @returns the node's row followed by its children.
+ */
+/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
+function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) {
+  return (
+    <div className={css.hoverContent}>
+      <div className={css.hoverTitle}>{node.title}</div>
+      <div className={css.hoverTime}>{`${formatRelativeTime(node.updatedAt, now)} ago`}</div>
+      <div className={css.hoverStatus}>
+        <StateDot state={node.running ? 'ongoing' : 'done'} />
+        <span>{node.running ? 'Running' : 'Idle'}</span>
+      </div>
+    </div>
+  )
+}
+
+/**
+ * Root-row drag wiring supplied by the group owner (workspace groups only).
+ * `drop` reports the half of the row the pointer released on: 'before'
+ * inserts above this row, 'after' below it (the owner resolves the anchor).
+ */
+export interface RowDragProps {
+  /** Start dragging this row. */
+  start: () => void
+  /** A drag from the same group is in flight (rows show insert markers). */
+  active: boolean
+  /** Current marker on this row: insert line above, below, or none. */
+  marker: 'before' | 'after' | null
+  /** Report the hovered half while a same-group drag passes over this row. */
+  hover: (half: 'before' | 'after') => void
+  drop: (half: 'before' | 'after') => void
+  end: () => void
+}
+
+/** Pointer-position half of a row (insert line above or below). */
+function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
+  const rect = e.currentTarget.getBoundingClientRect()
+  return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
+}
+
+export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: {
+  node: SessionNode
+  depth: number
+  currentId: string | undefined
+  now: number
+  onOpen: (id: SessionNode['id']) => void
+  onToggle: (id: SessionNode['id']) => void
+  /** Present only on draggable rows (workspace-group roots outside search). */
+  drag?: RowDragProps | undefined
+  /** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */
+  flat?: boolean
+}) {
+  const row = node
+  const selected = node.id === currentId
+  const [menuOpen, setMenuOpen] = useState(false)
+  // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
+  // the title): both slots are always reserved so titles align whether or not
+  // the twist/dot is lit. Extra depth rides the left padding.
+  const ownRow = (
+    <div
+      className={clsx(
+        css.sessionRow, selected && css.selected, menuOpen && css.menuOpen,
+        drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter,
+      )}
+      role="treeitem"
+      aria-selected={selected}
+      {...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
+      style={{ paddingLeft: 8 + depth * INDENT_STEP }}
+      onClick={() => { onOpen(node.id) }}
+      draggable={drag !== undefined}
+      onDragStart={drag === undefined
+        ? undefined
+        : (e) => {
+            e.dataTransfer.effectAllowed = 'move'
+            drag.start()
+          }}
+      onDragEnd={drag?.end}
+      onDragOver={drag === undefined
+        ? undefined
+        : (e) => {
+            if (!drag.active) return
+            e.preventDefault()
+            e.dataTransfer.dropEffect = 'move'
+            drag.hover(rowHalf(e))
+          }}
+      onDrop={drag === undefined
+        ? undefined
+        : (e) => {
+            if (!drag.active) return
+            e.preventDefault()
+            drag.drop(rowHalf(e))
+          }}
+    >
+      {row.hasChildren && !flat
+        ? (
+            <button
+              type="button"
+              className={css.twist}
+              aria-label={row.expanded ? 'Collapse' : 'Expand'}
+              onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
+            >
+              <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
+            </button>
+          )
+        : null}
+      <span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
+      <span className={css.title}>{row.title}</span>
+      <span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
+      <span className={css.rowActions}>
+        <Menu
+          open={menuOpen}
+          onClose={() => { setMenuOpen(false) }}
+          items={SESSION_MENU_ITEMS}
+          onSelect={() => { setMenuOpen(false) }} // Visual-only for now.
+          portal
+          closeOnPointerLeave
+          anchor={(
+            <button
+              type="button"
+              className={css.iconButton}
+              aria-label={`Session actions for ${row.title}`}
+              onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
+            >
+              <IconEllipsisOutline16 />
+            </button>
+          )}
+        />
+      </span>
+    </div>
+  )
+  return (
+    <>
+      <HoverCard
+        anchor={ownRow}
+        content={<SessionHoverContent node={node} now={now} />}
+        disabled={menuOpen || drag?.active === true}
+      />
+      {node.children.map(child => (
+        <SessionNodeItem
+          key={child.id}
+          node={child}
+          depth={depth + 1}
+          currentId={currentId}
+          now={now}
+          onOpen={onOpen}
+          onToggle={onToggle}
+        />
+      ))}
+    </>
+  )
+}

+ 36 - 0
packages/client/ui-workspace/src/client/stores.ts

@@ -0,0 +1,36 @@
+/**
+ * The workspace browser's viewing store: the session-list grouping mode,
+ * persisted across reloads. Module level exports the factory only (a
+ * module-level handle would pin the store identity across plugin reloads);
+ * 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'
+
+/** Session-list grouping mode: workspace sections or one flat recency list. */
+export type WorkspaceGroupBy = 'workspace' | 'flat'
+
+/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */
+type WorkspaceViewState = { groupBy: WorkspaceGroupBy }
+
+/**
+ * Annotation twin of the actions literal below (the export needs a declared
+ * return type); drift fails assignability at the defineStore call.
+ */
+type WorkspaceViewActions = {
+  setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
+}
+
+/**
+ * Create the workspace browser viewing store handle.
+ * @returns the store handle (spec + type + identity + factory in one).
+ */
+export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
+  return defineStore({
+    init: (): WorkspaceViewState => ({ groupBy: 'workspace' }),
+    persist: 'dsh.workspace.view',
+    actions: {
+      setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
+    },
+  })
+}

+ 29 - 4
packages/client/ui-sidebar/src/client/tree.ts → packages/client/ui-workspace/src/client/tree.ts

@@ -1,5 +1,5 @@
 /**
- * Derives the sidebar tree from Host Workspace order and membership.
+ * Derives the workspace browser tree from Host Workspace order and membership.
  * Unassigned Sessions trail under Ungrouped; only Intents targeting real Workspaces render.
  */
 import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
@@ -223,11 +223,11 @@ function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
 }
 
 /**
- * Derive the nested sidebar group structure.
+ * Derive the nested workspace browser group structure.
  *
  * Normal mode: every group shows; sessions populate under expanded groups,
  * descending only into expanded sessions. A frontend Session Intent targeting
- * a real Workspace marks that group `intentHere` and forces it expanded. Search mode (non-blank query,
+ * a real Workspace marks that group `intentHere` (rendered only while the group is expanded; expansion stays viewer-owned). Search mode (non-blank query,
  * case-insensitive display-title substring): expansion state is ignored —
  * matched sessions and their ancestor chains are forced visible, groups
  * without a display-title or label hit are dropped, a label-only hit keeps
@@ -263,7 +263,9 @@ export function deriveGroups(
       && g.workspaceId !== undefined && intentWorkspaceId === g.workspaceId
     const intentHere = q === '' && hasIntent
     if (q === '') {
-      const expanded = intentHere || expandedProjects.has(g.key)
+      // The intent never forces expansion — the viewer auto-expands the
+      // target group once (current-group effect); the toggle stays live.
+      const expanded = expandedProjects.has(g.key)
       groups.push({
         key: g.key,
         workspaceId: g.workspaceId,
@@ -294,6 +296,29 @@ export function deriveGroups(
   return groups
 }
 
+/**
+ * Derive the flat session list ("In one list" mode): every session — fork
+ * children included — as a top-level row, strictly newest-first. No grouping,
+ * no parent/child adjacency; rows reuse SessionNode with children always
+ * empty so the renderer stays branch-free. Search mode filters by
+ * case-insensitive display-title substring.
+ * @param list - sessions list snapshot.
+ * @param view - the search query (expansion state does not apply).
+ * @returns flat rows in render order.
+ */
+export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
+  const q = view.query.trim().toLowerCase()
+  const rows: SessionSummary[] = []
+  for (const id of list.ids) {
+    const s = list.byId[id]
+    if (s === undefined) continue
+    if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue
+    rows.push(s)
+  }
+  rows.sort(byRecency)
+  return rows.map(s => sessionNode(s, [], false, false))
+}
+
 /**
  * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
  * @param updatedAt - epoch ms of the session's last activity.

+ 41 - 27
packages/client/ui-workspace/tests/apply.spec.ts

@@ -2,7 +2,8 @@ import { Context } from 'cordis'
 import { describe, expect, it, vi } from 'vitest'
 import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
-import type { WorkspacePickerInjected } 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 { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
 
 async function bench() {
@@ -13,32 +14,33 @@ async function bench() {
     path: 'name' in input ? `/projects/${input.name}` : input.path,
     title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
   }))
-  ctx.provide('workspaces', { create })
-  return { ctx, slots: ctx.get('slots') as SlotsService, create }
+  const startSession = vi.fn()
+  const rename = vi.fn(async () => ({}))
+  const insertSessionBefore = vi.fn(async () => ({}))
+  const open = vi.fn()
+  ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore } as never)
+  ctx.provide('sessions', { open } as never)
+  return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open }
 }
 
-function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void {
-  return slots.register(
-    { name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never,
-    () => null,
-  )
-}
+type HoleName = 'sidebar.workspaces' | 'conversation.empty.workspace'
 
-function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected {
-  const entry = slots.entries(name)[0]!
-  return (entry.inject as () => WorkspacePickerInjected)()
+/** Declare one or both holes with a single root registration ('root' is a single slot). */
+function declare(slots: SlotsService, ...names: HoleName[]): () => void {
+  const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }]))
+  return slots.register({ name: 'root', children } as never, () => null)
 }
 
 describe('ui-workspace apply', () => {
-  it('declares the independent Workspace service', () => {
-    expect(inject).toEqual(['slots', 'workspaces'])
+  it('declares the services it drives', () => {
+    expect(inject).toEqual(['slots', 'sessions', 'workspaces'])
   })
 
-  it('registers the shared picker for declarations that arrive before or after apply', async () => {
+  it('registers browser and picker for declarations arriving before or after apply', async () => {
     const before = await bench()
-    declare(before.slots, 'sidebar.workspace')
+    declare(before.slots, 'sidebar.workspaces')
     await before.ctx.plugin({ inject: [...inject], apply }).await()
-    expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker)
+    expect(before.slots.entries('sidebar.workspaces')[0]!.component).toBe(WorkspaceBrowser)
 
     const after = await bench()
     await after.ctx.plugin({ inject: [...inject], apply }).await()
@@ -47,23 +49,35 @@ describe('ui-workspace apply', () => {
     expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker)
   })
 
-  it('routes name and path creation to WorkspacesService', async () => {
+  it('routes browser actions and picker creation to the services', async () => {
     const b = await bench()
-    declare(b.slots, 'sidebar.workspace')
+    declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
     await b.ctx.plugin({ inject: [...inject], apply }).await()
-    const injected = injectedOf(b.slots, 'sidebar.workspace')
-    await injected.createWorkspace({ name: 'project' })
-    await injected.createWorkspace({ path: '/tmp/project' })
-    expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' })
-    expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' })
+
+    const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
+    browser.startSession('ws' as never, 'prompt')
+    expect(b.startSession).toHaveBeenCalledWith('ws', 'prompt')
+    browser.open('session' as never)
+    expect(b.open).toHaveBeenCalledWith('session')
+    await browser.renameWorkspace('ws' as never, 'renamed')
+    expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
+    await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)
+    expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
+    await browser.createWorkspace({ name: 'project' })
+    expect(b.create).toHaveBeenCalledWith({ name: 'project' })
+
+    const picker = (b.slots.entries('conversation.empty.workspace')[0]!.inject as () => WorkspacePickerInjected)()
+    await picker.createWorkspace({ path: '/tmp/project' })
+    expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
   })
 
-  it('unregisters picker entries on teardown', async () => {
+  it('unregisters both entries on teardown', async () => {
     const b = await bench()
-    declare(b.slots, 'sidebar.workspace')
+    declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
     const fiber = b.ctx.plugin({ inject: [...inject], apply })
     await fiber.await()
     await fiber.dispose()
-    expect(b.slots.entries('sidebar.workspace')).toHaveLength(0)
+    expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0)
+    expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0)
   })
 })

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

@@ -2,7 +2,7 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
-import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/Rows.tsx'
+import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
 import type { GroupNode, SessionNode } from '../src/client/tree.ts'
 
 afterEach(cleanup)
@@ -10,7 +10,7 @@ afterEach(cleanup)
 const sid = (id: string) => id as SessionId
 const wid = (id: string) => id as WorkspaceId
 
-describe('sidebar rows', () => {
+describe('workspace browser rows', () => {
   it('renders an active Workspace and keeps its create action separate from toggling', () => {
     const onToggle = vi.fn()
     const onCreate = vi.fn()

+ 0 - 0
packages/client/ui-sidebar/tests/tree.spec.ts → packages/client/ui-workspace/tests/tree.spec.ts


+ 1 - 5
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -934,10 +934,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         signature: 'list(): Workspace[]',
         jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */',
       },
-      {
-        signature: 'async touchSession(sessionId: SessionId): Promise<void>',
-        jsDoc: '/**\n * Move one accounted, cwd-validated session to the front of its workspace.\n * Ungrouped sessions and candidates filtered by the header check are\n * no-ops. The owning workspace\'s relative position never changes.\n * @param sessionId - Session whose activity was observed.\n * @returns resolution after the possible record write.\n */',
-      },
       {
         signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
         jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */',
@@ -2498,7 +2494,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'Workspace',
-    declaration: 'export interface Workspace {\n    readonly id: WorkspaceId;\n    readonly path: string;\n    readonly title: string;\n    readonly createdAt: string;\n    readonly updatedAt: string;\n    readonly sessionIds: readonly SessionId[];\n    setTitle(title: string): Promise<void>;\n    attachSession(sessionId: SessionId): Promise<void>;\n    detachSession(sessionId: SessionId): Promise<void>;\n    status(): Promise<\'ok\' | \'missing-dir\'>;\n}',
+    declaration: 'export interface Workspace {\n    readonly id: WorkspaceId;\n    readonly path: string;\n    readonly title: string;\n    readonly createdAt: string;\n    readonly updatedAt: string;\n    readonly sessionIds: readonly SessionId[];\n    setTitle(title: string): Promise<void>;\n    attachSession(sessionId: SessionId): Promise<void>;\n    insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void>;\n    detachSession(sessionId: SessionId): Promise<void>;\n    status(): Promise<\'ok\' | \'missing-dir\'>;\n}',
   },
 ]
 

+ 68 - 1
packages/host/apiproxy/src/api-proxy.ts

@@ -14,7 +14,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
 import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
 import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
 import {
-  workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError,
+  workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
+  WorkspaceMoveInvalidError, WorkspaceNameConflictError,
 } from '@deepseek-ai/dsh-workspace'
 // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
 import type {} from '@deepseek-ai/dsh-tools'
@@ -680,6 +681,72 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         }
       },
 
+      async rename(request) {
+        const { payload } = request
+        const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
+        if (workspace === undefined) {
+          return err(request, {
+            code: 'workspace-not-found',
+            message: `workspace "${payload.workspaceId}" not found`,
+            details: { workspaceId: payload.workspaceId },
+          })
+        }
+        const title = payload.title.trim()
+        // Uniqueness AND the same-title no-op both ride the create chain so
+        // they observe the state left by earlier queued renames — checked
+        // up front, a queued A→A could report success while an earlier A→B
+        // still lands afterwards.
+        const operation = workspaceCreationChain.then(async () => {
+          if (title === workspace.title) return
+          if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
+            throw new WorkspaceNameConflictError(title)
+          }
+          await workspace.setTitle(title)
+        })
+        workspaceCreationChain = operation.then(() => undefined, () => undefined)
+        try {
+          await operation
+        } catch (error: unknown) {
+          if (error instanceof WorkspaceNameConflictError) {
+            return err(request, {
+              code: 'workspace-name-conflict',
+              message: error.message,
+              details: { name: error.workspaceName },
+            })
+          }
+          throw error
+        }
+        return ok(request, { workspace: workspaceView(workspace) })
+      },
+
+      async insertSessionBefore(request) {
+        const { payload } = request
+        const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
+        if (workspace === undefined) {
+          return err(request, {
+            code: 'workspace-not-found',
+            message: `workspace "${payload.workspaceId}" not found`,
+            details: { workspaceId: payload.workspaceId },
+          })
+        }
+        try {
+          await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
+        } catch (error: unknown) {
+          // Only the entity's unaccounted-id rejection is the business code;
+          // storage/durability failures propagate as internal errors.
+          if (!(error instanceof WorkspaceMoveInvalidError)) throw error
+          return err(request, {
+            code: 'workspace-move-invalid',
+            message: error.message,
+            details: {
+              workspaceId: payload.workspaceId,
+              sessionId: payload.sessionId,
+              ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
+            },
+          })
+        }
+        return ok(request, { workspace: workspaceView(workspace) })
+      },
     },
 
     host: {

+ 2 - 0
packages/host/apiproxy/src/api/rpc-map.ts

@@ -19,6 +19,8 @@ export interface RpcMethodMap {
   'host.describe': HostApi['describe']
   'workspace.list': WorkspaceApi['list']
   'workspace.create': WorkspaceApi['create']
+  'workspace.rename': WorkspaceApi['rename']
+  'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
 }
 
 /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */

+ 1 - 0
packages/host/apiproxy/src/api/rpc.schema.ts

@@ -40,6 +40,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
   z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
   z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
   z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
+  z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
   z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
   z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
 ]) as unknown as z.ZodType<RpcError>

+ 1 - 0
packages/host/apiproxy/src/api/rpc.ts

@@ -37,6 +37,7 @@ export interface RpcErrorDetailsMap {
   'workspace-not-found': { workspaceId: string }
   'workspace-invalid-path': { path: string }
   'workspace-name-conflict': { name: string }
+  'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
   'agent-busy': { reason: string }
   'internal': {}
 }

+ 26 - 0
packages/host/apiproxy/src/api/workspace.schema.ts

@@ -44,3 +44,29 @@ export const workspaceCreateValueSchema = z.object({
   workspace: workspaceViewSchema,
   created: z.boolean(),
 }) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>>
+
+/** workspace.rename request payload: the new title must be non-blank. */
+export const workspaceRenameRequestSchema = z.object({
+  workspaceId: workspaceIdSchema,
+  title: z.string(),
+}).refine(
+  payload => payload.title.trim() !== '',
+  { message: 'workspace.rename requires a non-blank title' },
+) satisfies z.ZodType<Wire<RequestPayload<'workspace.rename'>>>
+
+/** workspace.rename response value. */
+export const workspaceRenameValueSchema = z.object({
+  workspace: workspaceViewSchema,
+}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
+
+/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
+export const workspaceInsertSessionBeforeRequestSchema = z.object({
+  workspaceId: workspaceIdSchema,
+  sessionId: sessionIdSchema,
+  beforeSessionId: sessionIdSchema.optional(),
+}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertSessionBefore'>>>
+
+/** workspace.insertSessionBefore response value. */
+export const workspaceInsertSessionBeforeValueSchema = z.object({
+  workspace: workspaceViewSchema,
+}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>>

+ 27 - 1
packages/host/apiproxy/src/api/workspace.ts

@@ -24,7 +24,10 @@ export interface WorkspaceView {
   path: string
   /** Unique display title (defaults to the path basename at create). */
   title: string
-  /** Sessions accounted under this workspace, newest-first for display. */
+  /**
+   * Sessions accounted under this workspace, in manually owned order
+   * (attach prepends, insertSessionBefore reorders; activity never does).
+   */
   sessionIds: SessionId[]
   /** ISO-8601 creation instant. */
   createdAt: string
@@ -52,4 +55,27 @@ export interface WorkspaceApi {
    */
   create(request: RpcRequest<{ path?: string; name?: string }>):
   Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
+
+  /**
+   * Renames a workspace. `title` is trimmed and must be non-empty
+   * (schema-enforced). An unknown id fails with `workspace-not-found`; a
+   * title equal to another workspace's fails with `workspace-name-conflict`.
+   * Renaming to the current title is a no-op success (no durable write).
+   */
+  rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
+  Promise<RpcResponse<{ workspace: WorkspaceView }>>
+
+  /**
+   * Moves an accounted session within its workspace's manual order,
+   * DOM-insertBefore-like: with `beforeSessionId` the session is inserted
+   * before that anchor; omitted appends to the end. An unknown workspace
+   * fails with `workspace-not-found`; a session or anchor not accounted by
+   * the workspace fails with `workspace-move-invalid`. A move to the current
+   * position is a no-op success.
+   */
+  insertSessionBefore(request: RpcRequest<{
+    workspaceId: WorkspaceId
+    sessionId: SessionId
+    beforeSessionId?: SessionId
+  }>): Promise<RpcResponse<{ workspace: WorkspaceView }>>
 }

+ 8 - 0
packages/host/apiproxy/src/fetch/client.ts

@@ -23,7 +23,9 @@ import {
 } from '../api/sessions.schema.ts'
 import {
   workspaceCreateValueSchema,
+  workspaceInsertSessionBeforeValueSchema,
   workspaceListValueSchema,
+  workspaceRenameValueSchema,
 } from '../api/workspace.schema.ts'
 
 /**
@@ -55,6 +57,8 @@ export interface IApiClient {
   workspace: {
     list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
     create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
+    rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
+    insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
   }
   events: {
     mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
@@ -77,6 +81,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
   'host.describe': hostDescribeValueSchema,
   'workspace.list': workspaceListValueSchema,
   'workspace.create': workspaceCreateValueSchema,
+  'workspace.rename': workspaceRenameValueSchema,
+  'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
 }
 
 /** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
@@ -266,6 +272,8 @@ export abstract class AbstractApiClient implements IApiClient {
   readonly workspace: IApiClient['workspace'] = {
     list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
     create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
+    rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
+    insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
   }
 
   readonly events: IApiClient['events'] = {

+ 4 - 0
packages/host/apiproxy/src/fetch/handler.ts

@@ -24,7 +24,9 @@ import {
 import { hostDescribeRequestSchema } from '../api/host.schema.ts'
 import {
   workspaceCreateRequestSchema,
+  workspaceInsertSessionBeforeRequestSchema,
   workspaceListRequestSchema,
+  workspaceRenameRequestSchema,
 } from '../api/workspace.schema.ts'
 
 /**
@@ -50,6 +52,8 @@ const UNARY_ROUTES: UnaryRoutes = {
   'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
   'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
   'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
+  'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
+  'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
 }
 
 /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */

+ 2 - 0
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -37,6 +37,8 @@ function scriptedApi(overrides: {
     workspace: {
       list: r => ok(r, { items: [] }),
       create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
+      rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
+      insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
     },
     events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
     respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),

+ 12 - 0
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -52,6 +52,18 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
           result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } },
         }
       },
+      async rename(request) {
+        return {
+          rpcId: request.rpcId,
+          result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
+        }
+      },
+      async insertSessionBefore(request) {
+        return {
+          rpcId: request.rpcId,
+          result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
+        }
+      },
     },
     events: {
       mux: (_request, signal) => stream(muxFrames, signal),

+ 32 - 24
packages/workspace/workspace/src/entity.ts

@@ -15,6 +15,17 @@ import type { WorkspaceRecord } from './spec.ts'
 import type { Workspace, WorkspaceId } from './types.ts'
 import { realpathNormalize } from './paths.ts'
 
+/** An insertSessionBefore request named a session or anchor not on the account (storage failures stay plain errors). */
+export class WorkspaceMoveInvalidError extends Error {
+  /**
+   * @param message - Which id was unaccounted and where.
+   */
+  constructor(message: string) {
+    super(message)
+    this.name = 'WorkspaceMoveInvalidError'
+  }
+}
+
 /**
  * The registry-owned machinery an entity mutates through. Entities never see
  * the registry itself — only the open table, the canonical session-path
@@ -137,30 +148,27 @@ export class WorkspaceEntity implements Workspace {
       : { ...record, sessionIds: [sessionId, ...record.sessionIds] })
   }
 
-  /**
-   * Test the durable candidate account without applying header projection.
-   * @param sessionId - Candidate session id.
-   * @returns whether this workspace's stored account contains the id.
-   */
-  hasSession(sessionId: SessionId): boolean {
-    return this.record.sessionIds.includes(sessionId)
-  }
-
-  /**
-   * Move one validated accounted session to the front without touching peers.
-   * @param sessionId - Accounted session whose activity was observed.
-   */
-  async touchSession(sessionId: SessionId): Promise<void> {
-    if (
-      this.host.sessionPath(sessionId) !== this.record.path
-      || this.record.sessionIds[0] === sessionId
-    ) return
-    await this.mutate(record => !record.sessionIds.includes(sessionId) || record.sessionIds[0] === sessionId
-      ? record
-      : {
-        ...record,
-        sessionIds: [sessionId, ...record.sessionIds.filter(id => id !== sessionId)],
-      })
+  async insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void> {
+    await this.mutate((record) => {
+      if (!record.sessionIds.includes(sessionId)) {
+        throw new WorkspaceMoveInvalidError(
+          `cannot move session '${sessionId}' in workspace '${record.path}': the session is not accounted`,
+        )
+      }
+      if (beforeSessionId !== undefined && !record.sessionIds.includes(beforeSessionId)) {
+        throw new WorkspaceMoveInvalidError(
+          `cannot move session '${sessionId}' before '${beforeSessionId}' in workspace '${record.path}': `
+          + 'the anchor session is not accounted',
+        )
+      }
+      if (beforeSessionId === sessionId) return record
+      const without = record.sessionIds.filter(id => id !== sessionId)
+      const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId)
+      const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)]
+      return sessionIds.every((id, index) => id === record.sessionIds[index])
+        ? record
+        : { ...record, sessionIds }
+    })
   }
 
   async detachSession(sessionId: SessionId): Promise<void> {

+ 3 - 34
packages/workspace/workspace/src/index.ts

@@ -14,6 +14,8 @@ import type {} from '@deepseek-ai/dsh-session-persistence'
 import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain'
 import { WorkspaceEntity } from './entity.ts'
 import type { WorkspaceEntityHost } from './entity.ts'
+
+export { WorkspaceMoveInvalidError } from './entity.ts'
 import { realpathNormalize } from './paths.ts'
 import { workspaceDomainSpec } from './spec.ts'
 import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts'
@@ -47,6 +49,7 @@ export class WorkspaceNameConflictError extends Error {
   }
 }
 
+
 declare module 'cordis' {
   interface Context {
     workspace: WorkspaceRegistry
@@ -82,7 +85,6 @@ export class WorkspaceRegistry extends Service {
   private readonly headers = new Map<SessionId, SessionHeader>()
   private readonly sessionPaths = new Map<SessionId, string>()
   private readonly invalidSessionPaths = new Map<SessionId, string>()
-  private readonly pendingTouches = new Map<SessionId, Promise<void>>()
   private operationTail: Promise<void> = Promise.resolve()
 
   private readonly host: WorkspaceEntityHost = {
@@ -120,13 +122,6 @@ export class WorkspaceRegistry extends Service {
     this.validateStoredState(this.requireState())
     this.rebuildEntities()
     this.reportFilteredCandidates()
-    // Session activity is authoritative even when no RPC/SSE consumer is
-    // connected. This service-owned listener is disposed with the registry.
-    this.ctx.on('session/event', (session) => {
-      void this.touchSession(session.id).catch((error: unknown) => {
-        this.ctx.logger.warn(`workspace activity touch failed for session '${session.id}': ${String(error)}`)
-      })
-    })
   }
 
   /**
@@ -173,32 +168,6 @@ export class WorkspaceRegistry extends Service {
     })
   }
 
-  /**
-   * Move one accounted, cwd-validated session to the front of its workspace.
-   * Ungrouped sessions and candidates filtered by the header check are
-   * no-ops. The owning workspace's relative position never changes.
-   * @param sessionId - Session whose activity was observed.
-   * @returns resolution after the possible record write.
-   */
-  async touchSession(sessionId: SessionId): Promise<void> {
-    const pending = this.pendingTouches.get(sessionId)
-    if (pending !== undefined) {
-      await pending
-      return
-    }
-    for (const entity of this.entities.values()) {
-      if (!entity.hasSession(sessionId)) continue
-      const touch = entity.touchSession(sessionId)
-      this.pendingTouches.set(sessionId, touch)
-      try {
-        await touch
-      } finally {
-        this.pendingTouches.delete(sessionId)
-      }
-      return
-    }
-  }
-
   /**
    * Resolve by canonical directory path without creating or mutating a
    * workspace. A missing path rejects during `realpath`; an existing unowned

+ 19 - 6
packages/workspace/workspace/src/types.ts

@@ -41,10 +41,12 @@ export interface Workspace {
   readonly updatedAt: string
 
   /**
-   * Header-validated sessions in newest-first display order. The durable
-   * candidate account is filtered synchronously: missing headers, invalid
-   * cwd values, and canonical cwd mismatches are never returned. A subsequent
-   * workspace mutation prunes those filtered candidates durably.
+   * Header-validated sessions in manually owned order: a new session is
+   * prepended at attach, explicit reordering goes through
+   * `insertSessionBefore`, and activity never reorders. The durable candidate
+   * account is filtered synchronously: missing headers, invalid cwd values,
+   * and canonical cwd mismatches are never returned. A subsequent workspace
+   * mutation prunes those filtered candidates durably.
    */
   readonly sessionIds: readonly SessionId[]
 
@@ -57,8 +59,7 @@ export interface Workspace {
 
   /**
    * Prepend a session to this workspace's candidate account. An already
-   * accounted id resolves without writing; activity-driven reordering uses
-   * `WorkspaceRegistry.touchSession` instead. A new id's live or persisted
+   * accounted id resolves without writing. A new id's live or persisted
    * header cwd must resolve to an existing directory equal to {@link path};
    * unknown ids, missing or invalid cwd values, and mismatches reject without
    * writing.
@@ -67,6 +68,18 @@ export interface Workspace {
    */
   attachSession(sessionId: SessionId): Promise<void>
 
+  /**
+   * Move an accounted session within the manual order, DOM-insertBefore-like:
+   * with an anchor the session lands before it, without one it appends to the
+   * end. Only the moved id changes position. A session or anchor absent from
+   * the account rejects without writing; a move to the current position
+   * resolves without writing (decided on the domain write chain).
+   * @param sessionId - The accounted session to move.
+   * @param beforeSessionId - Accounted anchor to insert before; omitted appends.
+   * @returns resolution after durability.
+   */
+  insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void>
+
   /**
    * Remove a session from this workspace's account. Idempotent: an id not on
    * the account resolves without writing (decided on the domain write chain,

+ 2 - 116
packages/workspace/workspace/tests/workspace.spec.ts

@@ -10,7 +10,6 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionHeader } from '@deepseek-ai/dsh-session'
 import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
-import { WorkspaceEntity } from '../src/entity.ts'
 import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts'
 import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts'
 
@@ -434,13 +433,12 @@ describe('WorkspaceRegistry create and lookup', () => {
 })
 
 describe('Workspace session ordering', () => {
-  it('prepends new attaches, keeps repeat attach idempotent, and touches one id only', async () => {
+  it('prepends new attaches and keeps repeat attach idempotent', async () => {
     const dir = await makeDir('attach-order')
     const result = await harness()
     result.setSessions([
       header('s1', dir, 1),
       header('s2', dir, 2),
-      header('ungrouped', dir, 3),
     ])
     const workspace = await result.registry.create(dir)
     await workspace.attachSession(SessionId('s1'))
@@ -448,59 +446,7 @@ describe('Workspace session ordering', () => {
     expect(workspace.sessionIds).toEqual(['s2', 's1'])
     await workspace.attachSession(SessionId('s1'))
     expect(workspace.sessionIds).toEqual(['s2', 's1'])
-
-    const beforeTouch = result.changes.length
-    await Promise.all([
-      result.registry.touchSession(SessionId('s1')),
-      result.registry.touchSession(SessionId('s1')),
-    ])
-    expect(workspace.sessionIds).toEqual(['s1', 's2'])
-    expect(result.changes).toHaveLength(beforeTouch + 1)
-    await result.registry.touchSession(SessionId('s1'))
-    expect(result.changes).toHaveLength(beforeTouch + 1)
-    await result.registry.touchSession(SessionId('ungrouped'))
-    expect(result.changes).toHaveLength(beforeTouch + 1)
-    expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2'])
-  })
-
-  it('does not resurrect a session detached before its queued touch', async () => {
-    const dir = await makeDir('detach-touch-race')
-    const result = await harness({ sessions: [header('s1', dir), header('s2', dir)] })
-    const workspace = await result.registry.create(dir)
-    await workspace.attachSession(SessionId('s1'))
-    await workspace.attachSession(SessionId('s2'))
-    await Promise.all([
-      workspace.detachSession(SessionId('s1')),
-      result.registry.touchSession(SessionId('s1')),
-    ])
-    const written = result.changes.length
-    await workspace.detachSession(SessionId('absent'))
-    expect(result.changes).toHaveLength(written)
-    expect(workspace.sessionIds).toEqual(['s2'])
-  })
-
-  it('does not reinsert a candidate absent at the durable touch slot', async () => {
-    const dir = await makeDir('stale-touch')
-    const id = WorkspaceId('00000000-0000-4000-8000-000000000030')
-    let durable = record(dir, ['s2', 's1'])
-    const table = {
-      update: async (
-        _id: WorkspaceId,
-        update: (current: WorkspaceRecord) => WorkspaceRecord,
-      ): Promise<WorkspaceRecord> => {
-        durable = { ...durable, sessionIds: [SessionId('s2')] }
-        durable = update(durable)
-        return durable
-      },
-    }
-    const entity = new WorkspaceEntity({
-      table: () => table as never,
-      sessionPath: () => dir,
-      readSessionHeader: async () => header('s1', dir),
-      rememberSessionPath: () => {},
-    }, id, record(dir, ['s2', 's1']))
-    await entity.touchSession(SessionId('s1'))
-    expect(durable.sessionIds).toEqual(['s2'])
+    expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1'])
   })
 
   it('validates a lazy live session without requiring it in persistence.list()', async () => {
@@ -546,66 +492,6 @@ describe('Workspace session ordering', () => {
     expect(workspace.sessionIds).toEqual(['s1'])
   })
 
-  it('keeps workspace order stable while touch order survives reload', async () => {
-    const older = await makeDir('stable-older')
-    const newer = await makeDir('stable-newer')
-    const sessions = [
-      header('old-1', older, 100),
-      header('old-2', older, 200),
-      header('new-1', newer, 300),
-    ]
-    const pool = new MemoryMediaPool()
-    const first = await harness({ pool, sessions })
-    const originalWorkspaceIds = first.registry.list().map(workspace => workspace.id)
-    const oldWorkspace = first.registry.list().find(workspace => workspace.path === older)!
-    expect(oldWorkspace.sessionIds).toEqual(['old-2', 'old-1'])
-    await first.registry.touchSession(SessionId('old-1'))
-    expect(oldWorkspace.sessionIds).toEqual(['old-1', 'old-2'])
-    expect(first.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds)
-    await first.fiber.dispose()
-
-    const reloaded = await harness({ pool, sessions })
-    expect(reloaded.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds)
-    expect(reloaded.registry.list().find(workspace => workspace.path === older)!.sessionIds)
-      .toEqual(['old-1', 'old-2'])
-  })
-
-  it('persists activity order from session/event without any stream consumer', async () => {
-    const dir = await makeDir('event-touch')
-    const result = await harness({ sessionStore: true })
-    const workspace = await result.registry.create(dir)
-    const first = result.ctx.sessions.create(SessionId('event-first'), { meta: { cwd: dir } })
-    result.ctx.sessions.create(SessionId('event-second'), { meta: { cwd: dir } })
-    await workspace.attachSession(SessionId('event-first'))
-    await workspace.attachSession(SessionId('event-second'))
-    expect(workspace.sessionIds).toEqual(['event-second', 'event-first'])
-
-    first.append('turn/start', {
-      turn: 1,
-      trigger: { kind: 'message', source: { kind: 'user' } },
-    })
-    await vi.waitFor(() => { expect(workspace.sessionIds).toEqual(['event-first', 'event-second']) })
-    expect(storedRecord(result.pool, workspace.id).sessionIds)
-      .toEqual(['event-first', 'event-second'])
-  })
-
-  it('contains a background activity write failure at the service listener', async () => {
-    const dir = await makeDir('event-touch-failure')
-    const result = await harness({ sessionStore: true })
-    const workspace = await result.registry.create(dir)
-    const first = result.ctx.sessions.create(SessionId('failed-first'), { meta: { cwd: dir } })
-    result.ctx.sessions.create(SessionId('failed-second'), { meta: { cwd: dir } })
-    await workspace.attachSession(SessionId('failed-first'))
-    await workspace.attachSession(SessionId('failed-second'))
-    const warn = vi.spyOn(result.ctx.logger, 'warn')
-    result.pool.failNextWrites = 1
-    first.append('turn/start', {
-      turn: 1,
-      trigger: { kind: 'message', source: { kind: 'user' } },
-    })
-    await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('touch failed')) })
-    expect(workspace.sessionIds).toEqual(['failed-second', 'failed-first'])
-  })
 })
 
 describe('header-validated membership projection', () => {

+ 4 - 0
pnpm-lock.yaml

@@ -1014,6 +1014,10 @@ importers:
         version: 18.3.1
 
   packages/client/ui-workspace:
+    dependencies:
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
     devDependencies:
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^