فهرست منبع

test: cover filesystem resources and Sidebar domain behavior

imccyu 2 هفته پیش
والد
کامیت
bd4199f48c
100فایلهای تغییر یافته به همراه10590 افزوده شده و 1647 حذف شده
  1. 61 0
      packages/api/workspace-files/tests/apply.client.spec.ts
  2. 232 0
      packages/api/workspace-files/tests/change-feed.client.spec.ts
  3. 281 0
      packages/api/workspace-files/tests/changes.spec.ts
  4. 178 0
      packages/api/workspace-files/tests/fake-remote.client.ts
  5. 94 0
      packages/api/workspace-files/tests/harness.ts
  6. 260 0
      packages/api/workspace-files/tests/host-path.client.spec.ts
  7. 119 0
      packages/api/workspace-files/tests/list.spec.ts
  8. 363 0
      packages/api/workspace-files/tests/provider.client.spec.ts
  9. 146 0
      packages/api/workspace-files/tests/read-bytes.spec.ts
  10. 264 0
      packages/api/workspace-files/tests/read.spec.ts
  11. 79 0
      packages/api/workspace-files/tests/stat.spec.ts
  12. 196 0
      packages/client/connection/src/client/fixture.ts
  13. 5 0
      packages/client/locale/tests/language-row.client.spec.tsx
  14. 98 0
      packages/client/resources/tests/apply.client.spec.ts
  15. 359 0
      packages/client/resources/tests/resources.client.spec.ts
  16. 5 0
      packages/client/ui-attachment/tests/message-image.client.spec.tsx
  17. 39 32
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  18. 4 6
      packages/client/ui-chat/tests/chat-apply.client.spec.tsx
  19. 0 22
      packages/client/ui-chat/tests/chat-store.client.spec.ts
  20. 10 30
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  21. 0 164
      packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx
  22. 0 79
      packages/client/ui-chat/tests/selection-survival.client.spec.tsx
  23. 5 0
      packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
  24. 2 4
      packages/client/ui-chat/tests/views-type-chain.client.spec.tsx
  25. 5 0
      packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx
  26. 5 0
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  27. 5 0
      packages/client/ui-conversation/tests/input-matrix.client.spec.tsx
  28. 5 0
      packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx
  29. 6 0
      packages/client/ui-conversation/tests/queue-dock.client.spec.tsx
  30. 10 0
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  31. 21 86
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  32. 629 0
      packages/client/ui-dockkit/tests/apply.client.spec.ts
  33. 1127 0
      packages/client/ui-dockkit/tests/components.client.spec.tsx
  34. 131 0
      packages/client/ui-dockkit/tests/constraints.client.spec.ts
  35. 360 0
      packages/client/ui-dockkit/tests/controller.client.spec.ts
  36. 68 0
      packages/client/ui-dockkit/tests/fixtures.client.ts
  37. 144 0
      packages/client/ui-dockkit/tests/geometry.client.spec.ts
  38. 122 0
      packages/client/ui-dockkit/tests/measure.client.spec.ts
  39. 385 0
      packages/client/ui-dockkit/tests/planner.client.spec.ts
  40. 203 0
      packages/client/ui-dockkit/tests/sequence.client.spec.ts
  41. 379 305
      packages/client/ui-layout/tests/app-frame.client.spec.tsx
  42. 0 1
      packages/client/ui-layout/tests/apply.client.spec.ts
  43. 28 70
      packages/client/ui-layout/tests/columns.client.spec.ts
  44. 113 60
      packages/client/ui-layout/tests/layout-store.client.spec.ts
  45. 24 13
      packages/client/ui-layout/tests/service.client.spec.ts
  46. 5 0
      packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
  47. 5 1
      packages/client/ui-settings-general/tests/components.client.spec.tsx
  48. 5 0
      packages/client/ui-settings-general/tests/settings-root.client.spec.tsx
  49. 5 0
      packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx
  50. 5 0
      packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx
  51. 89 0
      packages/client/ui-sidebar-files/tests/apply.client.spec.ts
  52. 44 0
      packages/client/ui-sidebar-files/tests/definition.client.spec.ts
  53. 143 0
      packages/client/ui-sidebar-files/tests/face.client.spec.ts
  54. 177 0
      packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx
  55. 100 0
      packages/client/ui-sidebar-files/tests/mount.client.tsx
  56. 51 0
      packages/client/ui-sidebar-files/tests/scripted-list.client.ts
  57. 95 0
      packages/client/ui-sidebar-files/tests/store.client.spec.ts
  58. 204 0
      packages/client/ui-sidebar-right/tests/apply.client.spec.ts
  59. 68 0
      packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx
  60. 110 0
      packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx
  61. 375 0
      packages/client/ui-sidebar-right/tests/seat.client.spec.tsx
  62. 580 0
      packages/client/ui-sidebar-right/tests/service.client.spec.ts
  63. 241 0
      packages/client/ui-sidebar-right/tests/stores.client.spec.ts
  64. 210 0
      packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts
  65. 286 0
      packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts
  66. 84 0
      packages/client/ui-sidebar-textpreview/tests/apply.client.spec.ts
  67. 88 0
      packages/client/ui-sidebar-textpreview/tests/definition.client.spec.ts
  68. 157 0
      packages/client/ui-sidebar-textpreview/tests/face.client.spec.ts
  69. 36 0
      packages/client/ui-sidebar-textpreview/tests/failure-line.client.spec.ts
  70. 146 0
      packages/client/ui-sidebar-textpreview/tests/fixtures.client.ts
  71. 43 0
      packages/client/ui-sidebar-textpreview/tests/lines.client.spec.ts
  72. 40 0
      packages/client/ui-sidebar-textpreview/tests/rpc.client.spec.ts
  73. 84 0
      packages/client/ui-sidebar-textpreview/tests/store.client.spec.ts
  74. 298 0
      packages/client/ui-sidebar-textpreview/tests/text-preview.client.spec.tsx
  75. 6 1
      packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx
  76. 12 4
      packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx
  77. 5 0
      packages/client/ui-theme/tests/appearance-row.client.spec.tsx
  78. 5 0
      packages/client/ui-theme/tests/font-size-row.client.spec.tsx
  79. 3 3
      packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx
  80. 9 7
      packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx
  81. 2 95
      packages/client/ui-tool/tests/diff-card.client.spec.tsx
  82. 30 117
      packages/client/ui-tool/tests/read-card.client.spec.tsx
  83. 1 99
      packages/client/ui-tool/tests/search-card.client.spec.tsx
  84. 2 269
      packages/client/ui-tool/tests/terminal-card.client.spec.tsx
  85. 1 32
      packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx
  86. 2 42
      packages/client/ui-tool/tests/tool-fixtures.client.ts
  87. 13 10
      packages/client/ui-tool/tests/toolview-slot.client.spec.tsx
  88. 1 95
      packages/client/ui-tool/tests/web-card.client.spec.tsx
  89. 7 0
      packages/client/ui-trajectory/tests/views.client.spec.tsx
  90. 6 0
      packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx
  91. 6 0
      packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx
  92. 6 0
      packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx
  93. 5 0
      packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
  94. 7 0
      packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx
  95. 4 0
      packages/context/agent-instructions/tests/agent-instructions.spec.ts
  96. 53 0
      packages/e2b/fs-e2b/tests/filesystem.spec.ts
  97. 39 0
      packages/fs/fs-local/tests/filesystem.spec.ts
  98. 14 0
      packages/fs/fs/tests/service.spec.ts
  99. 3 0
      packages/fs/tool-fs/tests/tools.spec.ts
  100. 4 0
      packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts

+ 61 - 0
packages/api/workspace-files/tests/apply.client.spec.ts

@@ -0,0 +1,61 @@
+/**
+ * The plugin body: one `file` provider registered into `ctx.resources` for the
+ * fiber's lifetime, reading through `ctx.remote` and resolving addresses
+ * authorizing absolute addresses through the current `ctx.sessions` entry.
+ */
+import { Context } from '@deepseek-ai/cordis'
+import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client'
+import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
+import { describe, expect, it, onTestFinished } from 'vitest'
+import { apply, inject } from '../src/client/index.ts'
+import { FakeRemote, settle } from './fake-remote.client.ts'
+
+describe('workspace-files client apply', () => {
+  it('registers the file provider over ctx.remote and ctx.sessions, and unregisters it with the fiber', async () => {
+    const ctx = new Context()
+    const remote = new FakeRemote()
+    const controller = new AbortController()
+    const pulls: Array<Promise<unknown>> = []
+    ctx.provide('remote', remote as never)
+    ctx.provide('remote.workspaceFiles', remote.workspaceFiles as never)
+    // No Session summary or cwd is available; only absolute addresses need current.
+    ctx.provide('sessions', {
+      list: { getSnapshot: () => ({ byId: {}, current: 's1' }) },
+    } as never)
+    const registered: Array<ResourceProvider<'file'>> = []
+    let released = 0
+    ctx.provide('resources', {
+      register: (provider: ResourceProvider<'file'>) => {
+        registered.push(provider)
+        return () => {
+          released++
+          controller.abort()
+          for (const request of remote.stats) {
+            request.resolve({ ok: true, value: { absolutePath: '/host/late-stat', version: 'v0' } })
+          }
+        }
+      },
+    } as never)
+    const fiber = ctx.plugin({ inject: [...inject], apply })
+    onTestFinished(async () => {
+      await fiber.dispose()
+      await Promise.all(pulls)
+    })
+    await fiber.await()
+
+    expect(registered.map(provider => provider.protocol)).toEqual(['file'])
+    const signal = controller.signal
+    // A session address reaches the Host as its relative path; an absolute one
+    // is read through the current Session with its absolute path.
+    pulls.push(registered[0]!.open(sessionFileAddress('s1', 'a.txt'), { signal })[Symbol.asyncIterator]().next())
+    pulls.push(registered[0]!.open(absoluteFileAddress('/etc/hosts'), { signal })[Symbol.asyncIterator]().next())
+    await settle()
+    expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([['s1', 'a.txt'], ['s1', '/etc/hosts']])
+    expect(remote.opened).toHaveLength(1)
+
+    await fiber.dispose()
+    await Promise.all(pulls)
+    expect(released).toBe(1)
+    expect(remote.opened[0]!.source.aborted).toBe(true)
+  })
+})

+ 232 - 0
packages/api/workspace-files/tests/change-feed.client.spec.ts

@@ -0,0 +1,232 @@
+/**
+ * The change feed's promises: one Host stream per session, delivery by absolute
+ * path, local stat requests, and a follower's life bounded by its signal or by
+ * the stream's end.
+ */
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { describe, expect, it } from 'vitest'
+import { ChangeFeed } from '../src/client/change-feed.ts'
+import { FakeRemote, peek, settle } from './fake-remote.client.ts'
+
+const S1 = 's1' as SessionId
+const S2 = 's2' as SessionId
+
+function harness() {
+  const remote = new FakeRemote()
+  const feed = new ChangeFeed(remote)
+  const follow = (sessionId: SessionId, path: string, controller = new AbortController()) => {
+    const follower = feed.follow(sessionId, path, controller.signal)
+    follower.bind(path)
+    return { it: follower[Symbol.asyncIterator](), controller }
+  }
+  return { remote, feed, follow }
+}
+
+describe('ChangeFeed — one Host stream per session', () => {
+  it('shares one stream among the followers of a session and opens another per session', async () => {
+    const { remote, follow } = harness()
+    follow(S1, '/w/a.txt')
+    follow(S1, '/w/b.txt')
+    await settle()
+    expect(remote.opened.map(o => o.sessionId)).toEqual([S1])
+    follow(S2, '/w/a.txt')
+    await settle()
+    expect(remote.opened.map(o => o.sessionId)).toEqual([S1, S2])
+  })
+
+  it('disposes the session stream when its last follower leaves and reopens for the next', async () => {
+    const { remote, follow } = harness()
+    const a = follow(S1, '/w/a.txt')
+    const b = follow(S1, '/w/b.txt')
+    await settle()
+    a.controller.abort()
+    await settle()
+    expect(remote.disposed).toEqual([])
+    b.controller.abort()
+    await settle()
+    expect(remote.disposed).toEqual(['workspace file changes of s1'])
+    expect(remote.opened[0]!.source.aborted).toBe(true)
+    follow(S1, '/w/c.txt')
+    await settle()
+    expect(remote.opened).toHaveLength(2)
+  })
+
+  it('opens the next stream of a session only after the previous dispose settled, and settle() waits for it', async () => {
+    const { remote, feed, follow } = harness()
+    let release!: () => void
+    remote.disposeGate = new Promise<void>((resolve) => { release = resolve })
+    const a = follow(S1, '/w/a.txt')
+    await settle()
+    a.controller.abort()
+    await settle()
+    expect(remote.disposed).toEqual(['workspace file changes of s1'])
+    // The next follower registers at once, but its Host stream waits for the close.
+    follow(S1, '/w/b.txt')
+    await settle()
+    expect(remote.opened).toHaveLength(1)
+    let settled = false
+    void feed.settle().then(() => { settled = true })
+    await settle()
+    expect(settled).toBe(false)
+    release()
+    await settle()
+    expect(remote.opened).toHaveLength(2)
+    expect(settled).toBe(true)
+    // Nothing left closing: settle() resolves at once.
+    await feed.settle()
+  })
+
+  it('keeps waiting for the newest close when two closes of one session overlap', async () => {
+    const { remote, feed, follow } = harness()
+    let release!: () => void
+    remote.disposeGate = new Promise<void>((resolve) => { release = resolve })
+    const a = follow(S1, '/w/a.txt')
+    await settle()
+    a.controller.abort()
+    await settle()
+    // The second feed waits for the first close, then its only follower leaves too.
+    const b = follow(S1, '/w/b.txt')
+    await settle()
+    b.controller.abort()
+    await settle()
+    let settled = false
+    void feed.settle().then(() => { settled = true })
+    release()
+    await settle()
+    await settle()
+    expect(settled).toBe(true)
+    expect(remote.disposed).toHaveLength(2)
+    follow(S1, '/w/c.txt')
+    await settle()
+    expect(remote.opened).toHaveLength(3)
+  })
+
+  it('treats a dispose that rejects as settled, so the next stream still opens', async () => {
+    const { remote, feed, follow } = harness()
+    // Rejected only once dispose() has taken the gate, so the rejection always has a handler.
+    let fail!: (error: Error) => void
+    remote.disposeGate = new Promise<void>((_resolve, reject) => { fail = reject })
+    const a = follow(S1, '/w/a.txt')
+    await settle()
+    a.controller.abort()
+    await settle()
+    follow(S1, '/w/b.txt')
+    await settle()
+    expect(remote.opened).toHaveLength(1)
+    fail(new Error('carrier gone'))
+    await settle()
+    expect(remote.opened).toHaveLength(2)
+    await feed.settle()
+  })
+})
+
+describe('ChangeFeed — delivery', () => {
+  it('routes a frame to the followers of its path, whichever separator the Host spells', async () => {
+    const { remote, follow } = harness()
+    // A stat and a change frame may spell the same Host path with different separators.
+    const mine = follow(S1, 'C:/w/a b.txt')
+    const twin = follow(S1, 'C:/w/a b.txt')
+    const other = follow(S1, 'C:/w/other.txt')
+    await settle()
+    const source = remote.opened[0]!.source
+    source.push({ kind: 'change', change: { absolutePath: 'C:/w/a b.txt', version: 'v1' } })
+    source.push({ kind: 'change', change: { absolutePath: 'C:\\w\\a b.txt', absent: true } })
+    await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
+    await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'absent' } })
+    await expect(twin.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
+    await expect(peek(other.it)).resolves.toBe('silent')
+    // One follower of a path leaving does not silence the other.
+    mine.controller.abort()
+    await settle()
+    source.push({ kind: 'change', change: { absolutePath: 'C:/w/a b.txt', version: 'v2' } })
+    await expect(twin.it.next()).resolves.toEqual({ done: false, value: { kind: 'absent' } })
+    await expect(twin.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v2' } })
+  })
+
+  it('queues frames reported before the consumer starts pulling', async () => {
+    const { remote, follow } = harness()
+    const mine = follow(S1, '/w/a.txt')
+    await settle()
+    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v1' } })
+    await settle()
+    await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
+  })
+
+  it('keeps sessions apart', async () => {
+    const { remote, follow } = harness()
+    const one = follow(S1, '/w/a.txt')
+    const two = follow(S2, '/w/a.txt')
+    await settle()
+    remote.opened[1]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v2' } })
+    await expect(two.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v2' } })
+    await expect(peek(one.it)).resolves.toBe('silent')
+  })
+
+  it('hands a stat request to the followers of that path only', async () => {
+    const { feed, follow } = harness()
+    const mine = follow(S1, '/w/a.txt')
+    const other = follow(S1, '/w/b.txt')
+    await settle()
+    feed.requestRestat(S1, '/w/a.txt')
+    feed.requestRestat(S2, '/w/a.txt')
+    feed.requestRestat(S1, '/w/nobody.txt')
+    await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'restat' } })
+    await expect(peek(other.it)).resolves.toBe('silent')
+  })
+})
+
+describe('ChangeFeed — a follower ends', () => {
+  it('ends on its signal and drops nothing queued before it', async () => {
+    const { remote, follow } = harness()
+    const mine = follow(S1, '/w/a.txt')
+    await settle()
+    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v1' } })
+    await settle()
+    mine.controller.abort()
+    await expect(mine.it.next()).resolves.toEqual({ done: false, value: { kind: 'changed', version: 'v1' } })
+    await expect(mine.it.next()).resolves.toEqual({ done: true, value: undefined })
+  })
+
+  it('is empty when the signal is already aborted, without opening a stream', async () => {
+    const { remote, feed } = harness()
+    const controller = new AbortController()
+    controller.abort()
+    const it = feed.follow(S1, '/w/a.txt', controller.signal)[Symbol.asyncIterator]()
+    await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
+    await settle()
+    expect(remote.opened).toEqual([])
+  })
+
+  it('unregisters when the consumer breaks out after a notice', async () => {
+    const { remote, follow } = harness()
+    const mine = follow(S1, '/w/a.txt')
+    await settle()
+    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: '/w/a.txt', version: 'v1' } })
+    await mine.it.next()
+    await mine.it.return?.()
+    await settle()
+    expect(remote.disposed).toEqual(['workspace file changes of s1'])
+  })
+
+  it('ends every follower when the Host closes the session stream', async () => {
+    const { remote, follow } = harness()
+    const a = follow(S1, '/w/a.txt')
+    const b = follow(S1, '/w/b.txt')
+    await settle()
+    remote.opened[0]!.source.end()
+    await expect(a.it.next()).resolves.toEqual({ done: true, value: undefined })
+    await expect(b.it.next()).resolves.toEqual({ done: true, value: undefined })
+    // The next follower starts a fresh stream rather than joining the dead one.
+    follow(S1, '/w/c.txt')
+    await settle()
+    expect(remote.opened).toHaveLength(2)
+  })
+
+  it('ends every follower when the session stream fails', async () => {
+    const { remote, follow } = harness()
+    const a = follow(S1, '/w/a.txt')
+    await settle()
+    remote.opened[0]!.source.fail(new Error('carrier gone for good'))
+    await expect(a.it.next()).resolves.toEqual({ done: true, value: undefined })
+  })
+})

+ 281 - 0
packages/api/workspace-files/tests/changes.spec.ts

@@ -0,0 +1,281 @@
+/** The `changes` stream: driven by `fs/observed`, filtered by the workspace root, ended by its signal. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { join } from 'node:path'
+import type { Context } from '@deepseek-ai/cordis'
+import type { FsObservation } from '@deepseek-ai/dsh-fs'
+import { FsVersion } from '@deepseek-ai/dsh-fs'
+import { WorkspaceFiles } from '../src/index.ts'
+import type { WorkspaceFileWatchFrame } from '../src/types.ts'
+import { agent, openWorkspace, type Harness } from './harness.ts'
+
+let harness: Harness
+const closeStreams: Array<() => Promise<unknown>> = []
+
+beforeEach(async () => {
+  harness = await openWorkspace('dsh-workspace-files-changes-')
+})
+
+afterEach(async () => {
+  try {
+    for (const close of closeStreams.splice(0)) await close()
+    await harness.dispose()
+  } finally {
+    vi.restoreAllMocks()
+  }
+})
+
+/** Emit one observation for `path` the way a tool does after touching it. */
+async function observe(path: string, observation: FsObservation): Promise<string> {
+  const target = await harness.ctx.fs.resolve(path)
+  harness.ctx.emit('fs/observed', target, observation, undefined)
+  return harness.ctx.fs.processPath(target)
+}
+
+const present = (version: string): FsObservation => ({ kind: 'present', version: FsVersion(version) })
+
+/** Own one generation through abort and iterator completion, including failed assertions. */
+function open(
+  service: WorkspaceFiles,
+  controller = new AbortController(),
+): { next(): Promise<IteratorResult<WorkspaceFileWatchFrame>>; controller: AbortController } {
+  const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]()
+  closeStreams.push(async () => {
+    controller.abort()
+    await iterator.return?.()
+  })
+  return { next: () => iterator.next(), controller }
+}
+
+describe('workspaceFiles.changes — frames', () => {
+  it('acknowledges the resolved root before draining observations queued during root resolution', async () => {
+    const service = harness.endpoint()
+    const fs = harness.ctx.fs
+    const original = fs.resolve.bind(fs)
+    const root = await original(harness.workspace)
+    const entered = Promise.withResolvers<AbortSignal | undefined>()
+    const release = Promise.withResolvers<undefined>()
+    vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => {
+      if (path !== harness.workspace) return original(path, opts)
+      entered.resolve(opts?.signal)
+      await release.promise
+      opts?.signal?.throwIfAborted()
+      return root
+    })
+    const stream = open(service)
+    const first = stream.next()
+    let acknowledged = false
+    void first.then(() => { acknowledged = true })
+    try {
+      expect(await entered.promise).toBe(stream.controller.signal)
+      const a = await observe(join(harness.workspace, 'early-a.txt'), present('a1'))
+      await observe(join(harness.outside, 'secret.txt'), present('outside'))
+      const b = await observe(join(harness.workspace, 'early-b.txt'), { kind: 'absent' })
+      expect(acknowledged).toBe(false)
+      release.resolve(undefined)
+      await expect(first).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+      await expect(stream.next()).resolves.toEqual({
+        done: false, value: { kind: 'change', change: { absolutePath: a, version: 'a1' } },
+      })
+      await expect(stream.next()).resolves.toEqual({
+        done: false, value: { kind: 'change', change: { absolutePath: b, absent: true } },
+      })
+    } finally {
+      release.resolve(undefined)
+    }
+  })
+
+  it('reports a present observation inside the workspace as its absolute path and version', async () => {
+    const stream = open(harness.endpoint())
+    await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    const pending = stream.next()
+    const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1'))
+    expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } })
+    expect(absolutePath).toBe(harness.ctx.fs.processPath(await harness.ctx.fs.resolve(join(harness.workspace, 'a.txt'))))
+  })
+
+  it('reports an absent observation as absent', async () => {
+    const stream = open(harness.endpoint())
+    await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    const pending = stream.next()
+    const absolutePath = await observe(join(harness.workspace, 'gone.txt'), { kind: 'absent' })
+    expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, absent: true } } })
+  })
+
+  it('drops observations outside the workspace root', async () => {
+    const stream = open(harness.endpoint())
+    await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    const pending = stream.next()
+    await observe(join(harness.outside, 'secret.txt'), present('v1'))
+    const absolutePath = await observe(join(harness.workspace, 'seen.txt'), present('v2'))
+    expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v2' } } })
+  })
+
+  it('queues observations made faster than they are pulled, in emission order', async () => {
+    const stream = open(harness.endpoint())
+    await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    const first = stream.next()
+    const a = await observe(join(harness.workspace, 'a.txt'), present('a1'))
+    const b = await observe(join(harness.workspace, 'b.txt'), present('b1'))
+    expect(await first).toEqual({ done: false, value: { kind: 'change', change: { absolutePath: a, version: 'a1' } } })
+    expect(await stream.next()).toEqual({ done: false, value: { kind: 'change', change: { absolutePath: b, version: 'b1' } } })
+  })
+
+  it('serves every open generation independently', async () => {
+    const service = harness.endpoint()
+    const one = open(service)
+    const two = open(service)
+    await expect(one.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    await expect(two.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    const firstOfOne = one.next()
+    const firstOfTwo = two.next()
+    const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1'))
+    expect(await firstOfOne).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } })
+    expect(await firstOfTwo).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } })
+  })
+})
+
+describe('workspaceFiles.changes — ending', () => {
+  it('does not acknowledge a subscription disposed while its workspace root is resolving', async () => {
+    const fs = harness.ctx.fs
+    const original = fs.resolve.bind(fs)
+    const root = await original(harness.workspace)
+    const entered = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => {
+      if (path !== harness.workspace) return original(path, opts)
+      entered.resolve(undefined)
+      await release.promise
+      return root
+    })
+    let service: WorkspaceFiles | undefined
+    const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => {
+      service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 })
+    }, { inject: ['fs', 'sandboxPolicy'] }))
+    try {
+      if (service === undefined) throw new Error('plugin body did not run')
+      const stream = open(service)
+      const first = stream.next()
+      await entered.promise
+      await fiber.dispose()
+      release.resolve(undefined)
+      await expect(first).resolves.toEqual({ done: true, value: undefined })
+    } finally {
+      release.resolve(undefined)
+      await fiber.dispose()
+    }
+  })
+
+  it('refuses an already-aborted signal', async () => {
+    const controller = new AbortController()
+    controller.abort()
+    const stream = open(harness.endpoint(), controller)
+    await expect(stream.next()).rejects.toThrow()
+  })
+
+  it('ends when its signal aborts while idle', async () => {
+    const stream = open(harness.endpoint())
+    await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    const first = stream.next()
+    // A delivered frame proves the generation is past setup and waiting idle.
+    await observe(join(harness.workspace, 'a.txt'), present('v1'))
+    expect((await first).done).toBe(false)
+    const pending = stream.next()
+    stream.controller.abort()
+    expect(await pending).toEqual({ done: true, value: undefined })
+  })
+
+  it('ends when its signal aborts during setup, without delivering anything', async () => {
+    const stream = open(harness.endpoint())
+    const pending = stream.next()
+    stream.controller.abort()
+    await observe(join(harness.workspace, 'a.txt'), present('v1'))
+    expect(await pending).toEqual({ done: true, value: undefined })
+  })
+
+  it('ends when its signal aborts while the root resolves, resolving under that signal', async () => {
+    const fs = harness.ctx.fs
+    const original = fs.resolve.bind(fs)
+    let release: () => void = () => {}
+    const gate = new Promise<void>((resolve) => { release = resolve })
+    // A backend that checks the signal after its round-trip, as a remote one does.
+    const spy = vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => {
+      await gate
+      opts?.signal?.throwIfAborted()
+      return original(path, opts)
+    })
+    const stream = open(harness.endpoint())
+    const pending = stream.next()
+    stream.controller.abort()
+    release()
+    expect(await pending).toEqual({ done: true, value: undefined })
+    expect(spy).toHaveBeenCalledWith(expect.any(String), { signal: stream.controller.signal })
+    spy.mockRestore()
+  })
+
+  it('ends when its signal aborts between the root resolving and the first pull', async () => {
+    const fs = harness.ctx.fs
+    const original = fs.resolve.bind(fs)
+    const controller = new AbortController()
+    // The abort lands after the backend answered and before the drain installs its listener.
+    const spy = vi.spyOn(fs, 'resolve').mockImplementation(async (path, opts) => {
+      const target = await original(path, opts)
+      controller.abort()
+      return target
+    })
+    const stream = open(harness.endpoint(), controller)
+    expect(await stream.next()).toEqual({ done: true, value: undefined })
+    spy.mockRestore()
+  })
+
+  it('surfaces a root that fails to resolve', async () => {
+    const spy = vi.spyOn(harness.ctx.fs, 'resolve').mockRejectedValue(new Error('no such root'))
+    const stream = open(harness.endpoint())
+    await expect(stream.next()).rejects.toThrow('no such root')
+    spy.mockRestore()
+  })
+
+  it('stops delivering to a generation the consumer returned from', async () => {
+    const service = harness.endpoint()
+    const controller = new AbortController()
+    const iterator = service.changes(agent, controller.signal)[Symbol.asyncIterator]()
+    closeStreams.push(async () => {
+      controller.abort()
+      await iterator.return?.()
+    })
+    await expect(iterator.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    const first = iterator.next()
+    const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1'))
+    expect(await first).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } })
+    expect(await iterator.return?.(undefined)).toEqual({ done: true, value: undefined })
+    // A later observation reaches no follower: the set is empty again, so the
+    // second generation opened here is the only one that sees it.
+    const stream = open(service)
+    await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    const pending = stream.next()
+    const later = await observe(join(harness.workspace, 'b.txt'), present('v2'))
+    expect(await pending).toEqual({ done: false, value: { kind: 'change', change: { absolutePath: later, version: 'v2' } } })
+  })
+
+  it('ends every open generation when the owning fiber is disposed', async () => {
+    let service: WorkspaceFiles | undefined
+    const fiber = await harness.ctx.plugin(Object.assign((ctx: Context) => {
+      service = new WorkspaceFiles(ctx, { maxBytes: 1, maxLines: 1, maxEntries: 1 })
+    }, { inject: ['fs', 'sandboxPolicy'] }))
+    if (service === undefined) throw new Error('plugin body did not run')
+    const stream = open(service)
+    await expect(stream.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    const first = stream.next()
+    await observe(join(harness.workspace, 'a.txt'), present('v0'))
+    expect((await first).done).toBe(false)
+    const pending = stream.next()
+    await fiber.dispose()
+    expect(await pending).toEqual({ done: true, value: undefined })
+    // The listener left with the fiber: a fresh generation on a live service
+    // proves the root context still observes while the disposed one is silent.
+    const live = open(harness.endpoint())
+    await expect(live.next()).resolves.toEqual({ done: false, value: { kind: 'ready' } })
+    const next = live.next()
+    const absolutePath = await observe(join(harness.workspace, 'a.txt'), present('v1'))
+    expect(await next).toEqual({ done: false, value: { kind: 'change', change: { absolutePath, version: 'v1' } } })
+  })
+})

+ 178 - 0
packages/api/workspace-files/tests/fake-remote.client.ts

@@ -0,0 +1,178 @@
+/**
+ * The Remote slice, scripted: stats answered by the spec, one push source per
+ * opened `changes` generation, and a supervisor that runs one generation and
+ * classifies its end the way the real one does.
+ */
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceFileWatchFrame, WorkspaceFileStat } from '../src/types.ts'
+import type { SupervisedStream, SupervisedStreamOptions, WorkspaceFilesRemote } from '../src/client/remote.ts'
+
+/** One scripted Host `changes` generation: frames pushed by the spec, ended by abort. */
+export class Source<T> implements AsyncIterable<T> {
+  private readonly queue: Array<
+    { kind: 'value'; value: T; delivered?: () => void } | { kind: 'end' } | { kind: 'fail'; error: unknown }
+  > = []
+  private wake: (() => void) | undefined
+  aborted = false
+
+  constructor(signal: AbortSignal) {
+    this.aborted = signal.aborted
+    signal.addEventListener('abort', () => {
+      this.aborted = true
+      this.wake?.()
+    }, { once: true })
+  }
+
+  push(value: T): void {
+    this.queue.push({ kind: 'value', value })
+    this.wake?.()
+  }
+
+  /** Resolve after the consumer processes this frame and asks for the next one. */
+  deliver(value: T): Promise<void> {
+    return new Promise((resolve) => {
+      this.queue.push({ kind: 'value', value, delivered: resolve })
+      this.wake?.()
+    })
+  }
+
+  end(): void {
+    this.queue.push({ kind: 'end' })
+    this.wake?.()
+  }
+
+  fail(error: unknown): void {
+    this.queue.push({ kind: 'fail', error })
+    this.wake?.()
+  }
+
+  async *[Symbol.asyncIterator](): AsyncIterator<T> {
+    while (true) {
+      if (this.aborted) return
+      const next = this.queue.shift()
+      if (next === undefined) {
+        await new Promise<void>((resolve) => { this.wake = resolve })
+        this.wake = undefined
+        continue
+      }
+      if (next.kind === 'value') {
+        yield next.value
+        next.delivered?.()
+        continue
+      }
+      if (next.kind === 'end') return
+      throw next.error
+    }
+  }
+}
+
+/** One `stat` call awaiting the spec's answer. */
+export interface PendingStat {
+  readonly sessionId: SessionId
+  readonly path: string
+  readonly signal: AbortSignal | undefined
+  resolve(result: RemoteResult<WorkspaceFileStat>): void
+}
+
+/** One opened Host watch whose acknowledgement and changes the spec controls. */
+interface OpenedWatch {
+  readonly sessionId: SessionId
+  readonly source: Source<WorkspaceFileWatchFrame>
+}
+
+/** The scripted Remote: every stat waits for the spec, every session stream is a {@link Source}. */
+export class FakeRemote implements WorkspaceFilesRemote {
+  readonly calls: Array<'changes' | 'accept' | 'stat'> = []
+  readonly opened: OpenedWatch[] = []
+  readonly disposed: string[] = []
+  readonly stats: PendingStat[] = []
+  private readonly statWaiters = new Map<number, Array<(stat: PendingStat) => void>>()
+  private readonly watchWaiters = new Map<number, Array<(watch: OpenedWatch) => void>>()
+  /** False lets a spec keep the Host subscription unacknowledged. */
+  autoReady = true
+  /** When set, every stream dispose waits for it before settling. */
+  disposeGate: Promise<void> | undefined
+
+  /** Wait for an indexed stat request without advancing or assuming scheduler timing. */
+  waitForStat(index: number): Promise<PendingStat> {
+    const stat = this.stats[index]
+    if (stat !== undefined) return Promise.resolve(stat)
+    return new Promise((resolve) => {
+      const waiters = this.statWaiters.get(index) ?? []
+      waiters.push(resolve)
+      this.statWaiters.set(index, waiters)
+    })
+  }
+
+  /** Wait until the Client calls changes, independently of the Host acknowledgement. */
+  waitForChanges(index: number): Promise<OpenedWatch> {
+    const watch = this.opened[index]
+    if (watch !== undefined) return Promise.resolve(watch)
+    return new Promise((resolve) => {
+      const waiters = this.watchWaiters.get(index) ?? []
+      waiters.push(resolve)
+      this.watchWaiters.set(index, waiters)
+    })
+  }
+
+  $stream<Item>(options: SupervisedStreamOptions<Item>): SupervisedStream<Item> {
+    const controller = new AbortController()
+    const disposed = this.disposed
+    const calls = this.calls
+    const done = Promise.withResolvers<undefined>()
+    return {
+      async *[Symbol.asyncIterator]() {
+        try {
+          let accepted = false
+          for await (const value of options.open(controller.signal)) {
+            if (controller.signal.aborted) return
+            yield { value, accept: () => { accepted = true; calls.push('accept') } }
+          }
+          if (controller.signal.aborted) return
+          throw options.ended(accepted)
+        } finally {
+          done.resolve(undefined)
+        }
+      },
+      dispose: async () => {
+        disposed.push(options.name)
+        controller.abort(new Error('disposed'))
+        await this.disposeGate
+        await done.promise
+      },
+    }
+  }
+
+  readonly workspaceFiles = {
+    stat: (sessionId: SessionId, path: string, signal?: AbortSignal): Promise<RemoteResult<WorkspaceFileStat>> =>
+      new Promise((resolve) => {
+        this.calls.push('stat')
+        const index = this.stats.length
+        const stat = { sessionId, path, signal, resolve }
+        this.stats.push(stat)
+        for (const waiter of this.statWaiters.get(index) ?? []) waiter(stat)
+        this.statWaiters.delete(index)
+      }),
+    changes: (sessionId: SessionId, signal?: AbortSignal): AsyncIterable<WorkspaceFileWatchFrame> => {
+      this.calls.push('changes')
+      if (signal === undefined) throw new Error('the feed must hand its signal to the Host stream')
+      const source = new Source<WorkspaceFileWatchFrame>(signal)
+      const watch = { sessionId, source }
+      const index = this.opened.length
+      this.opened.push(watch)
+      for (const waiter of this.watchWaiters.get(index) ?? []) waiter(watch)
+      this.watchWaiters.delete(index)
+      if (this.autoReady) source.push({ kind: 'ready' })
+      return source
+    },
+  }
+}
+
+/** Let queued microtasks and background pumps settle. */
+export const settle = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 0) })
+
+/** The next item, or `'silent'` when none arrives within a tick. */
+export async function peek<T>(it: AsyncIterator<T>): Promise<IteratorResult<T> | 'silent'> {
+  return Promise.race([it.next(), settle().then(() => 'silent' as const)])
+}

+ 94 - 0
packages/api/workspace-files/tests/harness.ts

@@ -0,0 +1,94 @@
+/**
+ * Shared fixture: a real local backend over a temp workspace beside a sibling
+ * directory outside it, and a sandbox policy whose only job is naming the root.
+ *
+ * The real backend, not a mocked `ctx.fs`, because the gates under test are
+ * only meaningful against a real filesystem: a symlink that leaves the
+ * workspace, a file whose byte size exceeds the cap, and bytes that are not
+ * text. A fake provider would let a string-prefix containment check pass this
+ * file, which is exactly the defect the gate exists to prevent.
+ */
+import { mkdir, mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import type { Agent } from '@deepseek-ai/dsh-agent'
+import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
+import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
+import { WorkspaceFiles, type Config } from '../src/index.ts'
+
+/** The Agent shape the service reads: only its session reaches the policy. */
+export const agent = { id: 'a-test', session: { id: 's-test' } } as unknown as Agent
+
+export const signal = (): AbortSignal => new AbortController().signal
+
+/** One temp workspace and the context serving it. */
+export interface Harness {
+  readonly workspace: string
+  readonly outside: string
+  readonly ctx: Context
+  /**
+   * The service under test, at the given caps. One per test: the service key is
+   * global to the Context, so a second call with caps is a defect in the test.
+   */
+  endpoint(caps?: Partial<Config>): WorkspaceFiles
+  dispose(): Promise<void>
+}
+
+/**
+ * Create the workspace, its outside sibling, and a context with the local
+ * backend rooted at the workspace.
+ * @param prefix - temp directory prefix naming the suite.
+ * @returns the harness; dispose it in `afterEach`.
+ */
+export async function openWorkspace(prefix: string): Promise<Harness> {
+  const root = await mkdtemp(join(tmpdir(), prefix))
+  const workspace = join(root, 'workspace')
+  const outside = join(root, 'outside')
+  await mkdir(workspace, { recursive: true })
+  await mkdir(outside, { recursive: true })
+  const ctx = new Context()
+  const fiber = await ctx.plugin(LocalFileSystem, { cwd: workspace })
+  // The policy is the service's only source for the workspace root, so the
+  // fake supplies exactly that and nothing else.
+  ctx.provide('sandboxPolicy', { resolve: () => ({ mode: 'workspace-write', workspaceRoot: workspace }) } as never)
+  let service: WorkspaceFiles | undefined
+  return {
+    workspace,
+    outside,
+    ctx,
+    endpoint: (caps) => {
+      if (service !== undefined) {
+        if (caps !== undefined) throw new Error('the harness serves one WorkspaceFiles per test; hoist the endpoint')
+        return service
+      }
+      service = new WorkspaceFiles(ctx, {
+        maxBytes: caps?.maxBytes ?? 1024 * 1024,
+        maxLines: caps?.maxLines ?? 5000,
+        maxEntries: caps?.maxEntries ?? 2000,
+      })
+      return service
+    },
+    dispose: async () => {
+      await fiber.dispose()
+      await rm(root, { recursive: true, force: true })
+    },
+  }
+}
+
+/**
+ * Await an operation expected to fail with a Remote error.
+ * @param operation - the call under test.
+ * @returns the Remote failure's code and details.
+ */
+export async function failureOf(operation: Promise<unknown>): Promise<{ code: string; details: unknown }> {
+  try {
+    await operation
+  } catch (error: unknown) {
+    const failure = remoteErrorOf(error)
+    // A non-Remote throw is a defect in the service, not an expected outcome.
+    if (failure === undefined) throw error
+    return { code: failure.code, details: failure.details }
+  }
+  throw new Error('expected the operation to fail')
+}

+ 260 - 0
packages/api/workspace-files/tests/host-path.client.spec.ts

@@ -0,0 +1,260 @@
+/** Host-resolved file identities across pending stats, retries, reloads, and disposal. */
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
+import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
+import { describe, expect, it, onTestFinished } from 'vitest'
+import { ChangeFeed } from '../src/client/change-feed.ts'
+import { createFileResourceProvider, type SessionLookup } from '../src/client/provider.ts'
+import { FakeRemote, settle } from './fake-remote.client.ts'
+
+const SESSION = 'host-only' as SessionId
+const RELATIVE = 'linked/a b.txt'
+const ADDRESS = sessionFileAddress(SESSION, RELATIVE)
+const CANONICAL = '/host/canonical/a b.txt'
+
+function harness(sessions: SessionLookup = { current: () => SESSION }) {
+  const remote = new FakeRemote()
+  const changes = new ChangeFeed(remote)
+  const provider = createFileResourceProvider(remote, changes, sessions)
+  const open = (address = ADDRESS) => {
+    const controller = new AbortController()
+    const iterator = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]()
+    onTestFinished(async () => {
+      controller.abort()
+      for (const request of remote.stats) {
+        request.resolve({ ok: false, error: new RemoteError('gateway/internal', 'test ended', {}) })
+      }
+      await iterator.return?.()
+      await changes.settle()
+    })
+    return { iterator, controller, reload: () => { provider.reload!(address) } }
+  }
+  return { remote, changes, open }
+}
+
+describe('Host-resolved file paths', () => {
+  it('accepts Host ready before submitting the unmodified relative path without a Client Session summary', async () => {
+    const { remote, open } = harness({ current: () => { throw new Error('session addresses do not read current') } })
+    const { iterator } = open()
+    const first = iterator.next()
+    const request = await remote.waitForStat(0)
+    expect(remote.calls).toEqual(['changes', 'accept', 'stat'])
+    expect(request).toMatchObject({ sessionId: SESSION, path: RELATIVE })
+    request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v0', bytes: 3, changed: false } } })
+  })
+
+  it('does not stat an opened changes stream until the Host acknowledges its subscription', async () => {
+    const { remote, open } = harness()
+    remote.autoReady = false
+    const { iterator } = open()
+    const first = iterator.next()
+    const { source } = await remote.waitForChanges(0)
+    await settle()
+    expect(remote.calls).toEqual(['changes'])
+    expect(remote.stats).toEqual([])
+
+    await source.deliver({ kind: 'ready' })
+    const request = await remote.waitForStat(0)
+    expect(remote.calls).toEqual(['changes', 'accept', 'stat'])
+    request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v0', changed: false } } })
+  })
+
+  it.each(['abort', 'end', 'failure'] as const)('settles %s before Host ready without sending a stat or leaving a stream', async (ending) => {
+    const { remote, changes, open } = harness()
+    remote.autoReady = false
+    const { iterator, controller } = open()
+    const first = iterator.next()
+    const { source } = await remote.waitForChanges(0)
+    switch (ending) {
+      case 'abort': controller.abort(); break
+      case 'end': source.end(); break
+      case 'failure': source.fail(new Error('workspace root unavailable')); break
+      default: throw new Error(`Unexpected stream ending: ${ending satisfies never}`)
+    }
+    await expect(first).resolves.toEqual({ done: true, value: undefined })
+    await changes.settle()
+    expect(remote.calls).toEqual(['changes'])
+    expect(remote.stats).toEqual([])
+    expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`])
+    expect(source.aborted).toBe(true)
+  })
+
+  it('accepts another generation ready without resetting metadata or re-statting existing files', async () => {
+    const { remote, open } = harness()
+    const { iterator } = open()
+    const first = iterator.next()
+    const request = await remote.waitForStat(0)
+    request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } })
+    await first
+    const next = iterator.next()
+    const source = remote.opened[0]!.source
+    await source.deliver({ kind: 'ready' })
+    await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
+    expect(remote.stats).toHaveLength(1)
+    expect(remote.calls).toEqual(['changes', 'accept', 'stat', 'accept'])
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 3, changed: true } } })
+  })
+
+  it.each([
+    ['session', ADDRESS, RELATIVE],
+    ['absolute', absoluteFileAddress('/shortcut/a.txt'), '/shortcut/a.txt'],
+  ])('filters queued and live %s changes using the Host canonical path, not the input path', async (_, address, path) => {
+    const { remote, open } = harness()
+    const { iterator } = open(address)
+    const first = iterator.next()
+    const request = await remote.waitForStat(0)
+    expect(request).toMatchObject({ sessionId: SESSION, path })
+    const source = remote.opened[0]!.source
+    // Delivery is acknowledged while stat is still unresolved, before a key can be bound.
+    await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-before-stat' } })
+    await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
+    request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0', bytes: 3 } })
+    await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0', changed: false } } })
+    await expect(iterator.next()).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 3, changed: true } } })
+
+    await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other-after-stat' } })
+    await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } })
+    await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2', changed: true } } })
+    expect(remote.stats).toHaveLength(1)
+  })
+
+  it.each(['reload', 'write'] as const)('recovers an initial failed stat through %s and filters the retry backlog after binding', async (trigger) => {
+    const { remote, open } = harness()
+    const { iterator, reload } = open()
+    const first = iterator.next()
+    const request = await remote.waitForStat(0)
+    const error = new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE })
+    request.resolve({ ok: false, error })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } })
+    const source = remote.opened[0]!.source
+    const retried = iterator.next()
+    if (trigger === 'reload') reload()
+    else await source.deliver({ kind: 'change', change: { absolutePath: '/unknown-key-trigger.txt', version: 'trigger' } })
+    const retry = await remote.waitForStat(1)
+    expect(retry).toMatchObject({ sessionId: SESSION, path: RELATIVE })
+    await source.deliver({ kind: 'change', change: { absolutePath: '/other/file.txt', version: 'other' } })
+    await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v3' } })
+    retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v2', bytes: 5 } })
+    await expect(retried).resolves.toEqual({
+      done: false, value: { ok: true, value: { version: 'v2', bytes: 5, changed: trigger === 'write' } },
+    })
+    await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v3', changed: true } } })
+    expect(remote.stats).toHaveLength(2)
+  })
+
+  it('reloads session and absolute addresses bound to the same Host key without re-statting other files', async () => {
+    const { remote, open } = harness()
+    const session = open()
+    const absolute = open(absoluteFileAddress('/shortcut/a.txt'))
+    const other = open(sessionFileAddress(SESSION, 'other.txt'))
+    const firsts = Promise.all([session.iterator.next(), absolute.iterator.next(), other.iterator.next()])
+    await remote.waitForStat(2)
+    for (const request of remote.stats) {
+      request.resolve({ ok: true, value: {
+        absolutePath: request.path === 'other.txt' ? '/host/other.txt' : CANONICAL,
+        version: 'v0', bytes: 3,
+      } })
+    }
+    await firsts
+    const reloaded = Promise.all([session.iterator.next(), absolute.iterator.next()])
+    const otherChange = other.iterator.next()
+    session.reload()
+    await remote.waitForStat(4)
+    expect(remote.stats.slice(3).map(request => request.path)).toEqual([RELATIVE, '/shortcut/a.txt'])
+    for (const request of remote.stats.slice(3)) {
+      request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1', bytes: 4 } })
+    }
+    const value = { done: false, value: { ok: true, value: { version: 'v1', bytes: 4, changed: false } } }
+    await expect(reloaded).resolves.toEqual([value, value])
+    await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: '/host/other.txt', version: 'other-v1' } })
+    await expect(otherChange).resolves.toMatchObject({ value: { ok: true, value: { version: 'other-v1', changed: true } } })
+    expect(remote.stats).toHaveLength(5)
+    expect(remote.opened).toHaveLength(1)
+  })
+
+  it('binds a changed canonical path on reload before filtering writes received during that stat', async () => {
+    const { remote, open } = harness()
+    const { iterator, reload } = open()
+    const first = iterator.next()
+    const request = await remote.waitForStat(0)
+    request.resolve({ ok: true, value: { absolutePath: '/host/old-target.txt', version: 'v0' } })
+    await first
+    reload()
+    const reloaded = iterator.next()
+    const retry = await remote.waitForStat(1)
+    const source = remote.opened[0]!.source
+    await source.deliver({ kind: 'change', change: { absolutePath: '/host/old-target.txt', version: 'old-target-write' } })
+    await source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v2' } })
+    retry.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v1' } })
+    await expect(reloaded).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: false } } })
+    await expect(iterator.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v2', changed: true } } })
+    expect(remote.stats).toHaveLength(2)
+  })
+
+  it('drops a late stat after abort and waits for the session stream disposal', async () => {
+    const { remote, changes, open } = harness()
+    const { iterator, controller } = open()
+    const first = iterator.next()
+    const request = await remote.waitForStat(0)
+    await remote.opened[0]!.source.deliver({ kind: 'change', change: { absolutePath: CANONICAL, version: 'v1' } })
+    const gate = Promise.withResolvers<undefined>()
+    remote.disposeGate = gate.promise
+    try {
+      controller.abort()
+      request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } })
+      await expect(first).resolves.toEqual({ done: true, value: undefined })
+      expect(remote.opened[0]!.source.aborted).toBe(true)
+      let settled = false
+      const closing = changes.settle().then(() => { settled = true })
+      await settle()
+      expect(settled).toBe(false)
+      gate.resolve(undefined)
+      await closing
+      expect(settled).toBe(true)
+      expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`])
+    } finally {
+      gate.resolve(undefined)
+    }
+  })
+
+  it('aborts while waiting for a predecessor to close without sending a stat', async () => {
+    const { remote, changes, open } = harness()
+    const previous = open()
+    const first = previous.iterator.next()
+    const request = await remote.waitForStat(0)
+    request.resolve({ ok: true, value: { absolutePath: CANONICAL, version: 'v0' } })
+    await first
+    const gate = Promise.withResolvers<undefined>()
+    remote.disposeGate = gate.promise
+    try {
+      previous.controller.abort()
+      const next = open()
+      const pending = next.iterator.next()
+      next.controller.abort()
+      await expect(pending).resolves.toEqual({ done: true, value: undefined })
+      expect(remote.stats).toHaveLength(1)
+      gate.resolve(undefined)
+      await changes.settle()
+      expect(remote.disposed).toHaveLength(2)
+    } finally {
+      gate.resolve(undefined)
+    }
+  })
+
+  it.each([true, false])('releases an unconsumed notification follower after the first stat (success: %s)', async (ok) => {
+    const { remote, changes, open } = harness()
+    const { iterator } = open()
+    const first = iterator.next()
+    const request = await remote.waitForStat(0)
+    request.resolve(ok
+      ? { ok: true, value: { absolutePath: CANONICAL, version: 'v0' } }
+      : { ok: false, error: new RemoteError('workspace-file/not-found', 'missing', { path: RELATIVE }) })
+    await first
+    await iterator.return?.()
+    await changes.settle()
+    expect(remote.opened[0]!.source.aborted).toBe(true)
+    expect(remote.disposed).toEqual([`workspace file changes of ${SESSION}`])
+  })
+})

+ 119 - 0
packages/api/workspace-files/tests/list.spec.ts

@@ -0,0 +1,119 @@
+/** The `list` endpoint: the same containment gates as `read`, plus the entry cap. */
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mkdir, symlink, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+
+let harness: Harness
+let workspace: string
+let outside: string
+
+beforeEach(async () => {
+  harness = await openWorkspace('dsh-workspace-files-list-')
+  workspace = harness.workspace
+  outside = harness.outside
+})
+
+afterEach(async () => {
+  await harness.dispose()
+})
+
+const endpoint = (caps?: { maxEntries?: number }): ReturnType<Harness['endpoint']> => harness.endpoint(caps)
+
+describe('workspaceFiles.list — the happy path', () => {
+  it('lists the root as the empty workspace path, with types and file sizes', async () => {
+    await mkdir(join(workspace, 'src'))
+    await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8')
+    await writeFile(join(workspace, '.hidden'), '', 'utf8')
+    const listing = await endpoint().list(agent, '.', signal())
+    expect(listing.path).toBe('')
+    expect(listing.truncated).toBe(false)
+    expect(listing.entries).toEqual([
+      { name: '.hidden', type: 'file', size: 0 },
+      { name: 'notes.txt', type: 'file', size: 5 },
+      { name: 'src', type: 'directory' },
+    ])
+  })
+
+  it('accepts the absolute workspace root and reports the same empty path', async () => {
+    const listing = await endpoint().list(agent, workspace, signal())
+    expect(listing.path).toBe('')
+    expect(listing.entries).toEqual([])
+  })
+
+  it('reports a nested directory as its `/`-joined path relative to the root, decoded', async () => {
+    await mkdir(join(workspace, 'src', 'my dir', '子目录'), { recursive: true })
+    await writeFile(join(workspace, 'src', 'my dir', '子目录', 'a.ts'), '', 'utf8')
+    const listing = await endpoint().list(agent, 'src/my dir/子目录', signal())
+    expect(listing.path).toBe('src/my dir/子目录')
+    expect(listing.entries.map(entry => entry.name)).toEqual(['a.ts'])
+  })
+
+  it('reports a symlink child as what it points to, and a dangling one as other', async () => {
+    await writeFile(join(workspace, 'real.txt'), 'x', 'utf8')
+    await mkdir(join(workspace, 'dir'))
+    await symlink(join(workspace, 'real.txt'), join(workspace, 'to-file'))
+    await symlink(join(workspace, 'dir'), join(workspace, 'to-dir'))
+    await symlink(join(workspace, 'missing'), join(workspace, 'dangling'))
+    const listing = await endpoint().list(agent, '.', signal())
+    expect(listing.entries).toEqual([
+      { name: 'dangling', type: 'other' },
+      { name: 'dir', type: 'directory' },
+      { name: 'real.txt', type: 'file', size: 1 },
+      { name: 'to-dir', type: 'directory' },
+      { name: 'to-file', type: 'file', size: 1 },
+    ])
+  })
+})
+
+describe('workspaceFiles.list — the entry cap', () => {
+  it('cuts at the cap in name order and says so', async () => {
+    for (const name of ['a', 'b', 'c', 'd', 'e']) await writeFile(join(workspace, name), '', 'utf8')
+    const listing = await endpoint({ maxEntries: 2 }).list(agent, '.', signal())
+    expect(listing.entries.map(entry => entry.name)).toEqual(['a', 'b'])
+    expect(listing.truncated).toBe(true)
+  })
+
+  it('does not report a cut at exactly the cap', async () => {
+    for (const name of ['a', 'b']) await writeFile(join(workspace, name), '', 'utf8')
+    const listing = await endpoint({ maxEntries: 2 }).list(agent, '.', signal())
+    expect(listing.entries).toHaveLength(2)
+    expect(listing.truncated).toBe(false)
+  })
+})
+
+describe('workspaceFiles.list — gates', () => {
+  it('rejects an absolute directory outside the workspace', async () => {
+    const failure = await failureOf(endpoint().list(agent, outside, signal()))
+    expect(failure.code).toBe('workspace-file/outside-workspace')
+  })
+
+  it('rejects a traversal that climbs out of the workspace', async () => {
+    const failure = await failureOf(endpoint().list(agent, '..', signal()))
+    expect(failure.code).toBe('workspace-file/outside-workspace')
+  })
+
+  it('rejects a symlinked directory before following it, wherever it points', async () => {
+    await symlink(outside, join(workspace, 'escape'))
+    const failure = await failureOf(endpoint().list(agent, 'escape', signal()))
+    expect(failure.code).toBe('workspace-file/not-directory')
+    expect(failure.details).toMatchObject({ kind: 'symlink' })
+  })
+
+  it('rejects a file, which has no children to list', async () => {
+    await writeFile(join(workspace, 'notes.txt'), 'hello', 'utf8')
+    const failure = await failureOf(endpoint().list(agent, 'notes.txt', signal()))
+    expect(failure.code).toBe('workspace-file/not-directory')
+    expect(failure.details).toMatchObject({ path: 'notes.txt', kind: 'file' })
+  })
+
+  it('reports a missing path as not found', async () => {
+    const failure = await failureOf(endpoint().list(agent, 'nope', signal()))
+    expect(failure.code).toBe('workspace-file/not-found')
+  })
+
+  it('refuses an empty path as a bad request', async () => {
+    const failure = await failureOf(endpoint().list(agent, '', signal()))
+    expect(failure.code).toBe('gateway/bad-request')
+  })
+})

+ 363 - 0
packages/api/workspace-files/tests/provider.client.spec.ts

@@ -0,0 +1,363 @@
+/**
+ * The `file` provider's frame stream: how the two address scopes resolve to a
+ * Host call and a change-feed key, the opening stat, the write flag that
+ * carries no content, the reload and disappearance that stat again, failures
+ * as frames, and the life bounded by the signal.
+ */
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
+import { absoluteFileAddress, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
+import type { WorkspaceFileStat } from '../src/types.ts'
+import { describe, expect, it, onTestFinished } from 'vitest'
+import { ChangeFeed } from '../src/client/change-feed.ts'
+import { createFileResourceProvider } from '../src/client/provider.ts'
+import type { SessionLookup } from '../src/client/provider.ts'
+import { FakeRemote, peek, settle } from './fake-remote.client.ts'
+
+const S1 = 's1' as SessionId
+const S2 = 's2' as SessionId
+/** The relative path the address carries, and the absolute path the Host's frames spell for it under S1's root. */
+const REL_PATH = 'a b.txt'
+const HOST_PATH = '/w/a b.txt'
+const ADDRESS = sessionFileAddress(S1, REL_PATH)
+/** A file outside every workspace root, addressed absolutely. */
+const ABS_PATH = '/etc/hosts'
+const ABS_ADDRESS = absoluteFileAddress(ABS_PATH)
+
+/** Only the current Session is available to the provider; there are no Client roots. */
+function sessionsWith(current: SessionId | undefined): SessionLookup {
+  return { current: () => current }
+}
+
+const stat = (version: string, bytes: number): WorkspaceFileStat => ({ absolutePath: HOST_PATH, version, bytes })
+const notFound = (): RemoteFailure => new RemoteError('workspace-file/not-found', 'no such file', { path: REL_PATH })
+
+function opened(address = ADDRESS, sessions = sessionsWith(S1)) {
+  const remote = new FakeRemote()
+  const changes = new ChangeFeed(remote)
+  const provider = createFileResourceProvider(remote, changes, sessions)
+  const controller = new AbortController()
+  const it = provider.open(address, { signal: controller.signal })[Symbol.asyncIterator]()
+  const reload = (): void => { provider.reload!(address) }
+  onTestFinished(async () => {
+    controller.abort()
+    for (const request of remote.stats) {
+      request.resolve({ ok: false, error: new RemoteError('gateway/internal', 'test ended', {}) })
+    }
+    await it.return?.()
+    await changes.settle()
+  })
+  return { remote, provider, changes, controller, it, reload }
+}
+
+/** Open, answer the opening stat, and hand back the bench once the first frame is out. */
+async function live(version = 'v0', bytes = 3) {
+  const bench = opened()
+  const first = bench.it.next()
+  await settle()
+  bench.remote.stats[0]!.resolve({ ok: true, value: stat(version, bytes) })
+  await first
+  return bench
+}
+
+describe('file provider — the address', () => {
+  it.each([
+    ['another scope', 'dsh-resource://file/shared/x/w/a.txt'],
+    ['no path', 'dsh-resource://file/session/s1'],
+    ['an absolute address with no path', 'dsh-resource://file/absolute/'],
+    ['another resource type', 'dsh-resource://terminal/session/s1/1'],
+    ['the retired file:// grammar', 'file://sessions/s1/w/a.txt'],
+    ['a bare file URL', 'file:///w/a.txt'],
+    ['another protocol', 'sidebar:guide'],
+  ])('yields one unsupported-address failure and ends for %s, touching no Remote', async (_, address) => {
+    const { remote, it } = opened(address)
+    const first = await it.next()
+    expect(first.done).toBe(false)
+    expect(first.value).toMatchObject({ ok: false, error: { code: 'workspace-file/unsupported-address', details: { address } } })
+    await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
+    expect(remote.stats).toEqual([])
+    expect(remote.opened).toEqual([])
+  })
+
+  it('rejects an absolute address with no current Session without touching the Remote', async () => {
+    const address = ABS_ADDRESS
+    const { remote, it } = opened(address, sessionsWith(undefined))
+    const first = await it.next()
+    expect(first.done).toBe(false)
+    expect(first.value).toMatchObject({ ok: false, error: { code: 'workspace-file/unknown-workspace', details: { address } } })
+    await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
+    expect(remote.stats).toEqual([])
+    expect(remote.opened).toEqual([])
+  })
+
+  it('reloads nothing for an address it does not resolve', async () => {
+    for (const bench of [opened('dsh-resource://file/shared/x/w/a.txt'), opened(ABS_ADDRESS, sessionsWith(undefined))]) {
+      bench.reload()
+      await settle()
+      expect(bench.remote.stats).toEqual([])
+    }
+  })
+
+  it('hands the Host a session address\'s relative path and follows the stat absolute path', async () => {
+    const { remote, it } = opened()
+    const first = it.next()
+    await settle()
+    expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: REL_PATH })
+    remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
+    await first
+    // The Host's frame names the file absolutely; the follower keyed by the resolved path receives it.
+    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
+    await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
+  })
+
+  it('reads an absolute address through the Session on screen, with the absolute path as both Host path and follow key', async () => {
+    const { remote, it, reload } = opened(ABS_ADDRESS)
+    const first = it.next()
+    await settle()
+    expect(remote.opened.map(o => o.sessionId)).toEqual([S1])
+    expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: ABS_PATH })
+    remote.stats[0]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v0', bytes: 3 } })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v0', bytes: 3, changed: false } } })
+    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: ABS_PATH, version: 'v1' } })
+    await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
+    // A reload stats the same absolute path again.
+    reload()
+    const next = it.next()
+    await settle()
+    expect(remote.stats[1]).toMatchObject({ sessionId: S1, path: ABS_PATH })
+    remote.stats[1]!.resolve({ ok: true, value: { absolutePath: ABS_PATH, version: 'v1', bytes: 4 } })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 4, changed: false } } })
+  })
+
+  it('opens one change stream per session named by the addresses', async () => {
+    const remote = new FakeRemote()
+    const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1))
+    const signal = new AbortController().signal
+    void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
+    void provider.open(sessionFileAddress(S2, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
+    await settle()
+    expect(remote.opened.map(o => o.sessionId)).toEqual([S1, S2])
+    expect(remote.stats.map(pending => pending.sessionId)).toEqual([S1, S2])
+  })
+})
+
+describe('file provider — the opening stat', () => {
+  it('stats the decoded relative path in the session the address names and yields its metadata unflagged', async () => {
+    const { remote, it, controller } = opened()
+    const first = it.next()
+    await settle()
+    expect(remote.stats).toHaveLength(1)
+    expect(remote.stats[0]).toMatchObject({ sessionId: S1, path: REL_PATH, signal: controller.signal })
+    remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v0', bytes: 3, changed: false } } })
+  })
+
+  it('omits bytes when the backend reports none', async () => {
+    const { remote, it } = opened()
+    const first = it.next()
+    await settle()
+    remote.stats[0]!.resolve({ ok: true, value: { absolutePath: HOST_PATH, version: 'v0' } })
+    await expect(first).resolves.toStrictEqual({ done: false, value: { ok: true, value: { version: 'v0', changed: false } } })
+  })
+
+  it('yields the Host failure as a frame and keeps following the address', async () => {
+    const { remote, it } = opened()
+    const first = it.next()
+    await settle()
+    const error = notFound()
+    remote.stats[0]!.resolve({ ok: false, error })
+    await expect(first).resolves.toEqual({ done: false, value: { ok: false, error } })
+    const source = remote.opened[0]!.source
+    // Still gone: no stat, no frame; the pull stays open for what comes next.
+    source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
+    const pending = it.next()
+    await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent')
+    expect(remote.stats).toHaveLength(1)
+    // The agent creates the file: the write stats again and the value goes live, flagged as changed.
+    source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
+    await settle()
+    remote.stats[1]!.resolve({ ok: true, value: stat('v1', 5) })
+    await expect(pending).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 5, changed: true } } })
+  })
+
+  it('lets a reload retry a failed opening stat', async () => {
+    const { remote, it, reload } = opened()
+    const first = it.next()
+    await settle()
+    remote.stats[0]!.resolve({ ok: false, error: notFound() })
+    await first
+    reload()
+    const next = it.next()
+    await settle()
+    remote.stats[1]!.resolve({ ok: true, value: stat('v0', 3) })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v0', bytes: 3, changed: false } } })
+  })
+
+  it('ends without a frame when aborted during the stat', async () => {
+    const { remote, it, controller } = opened()
+    const first = it.next()
+    await settle()
+    controller.abort()
+    remote.stats[0]!.resolve({ ok: false, error: new RemoteError('gateway/internal', 'aborted', {}) })
+    await expect(first).resolves.toEqual({ done: true, value: undefined })
+  })
+
+  it('shares one change stream between two files of a session', async () => {
+    const remote = new FakeRemote()
+    const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1))
+    const signal = new AbortController().signal
+    void provider.open(sessionFileAddress(S1, 'a.txt'), { signal })[Symbol.asyncIterator]().next()
+    void provider.open(sessionFileAddress(S1, 'b.txt'), { signal })[Symbol.asyncIterator]().next()
+    await settle()
+    expect(remote.opened).toHaveLength(1)
+    expect(remote.stats).toHaveLength(2)
+  })
+})
+
+describe('file provider — Host writes', () => {
+  it('flags a write with its version and keeps the byte count', async () => {
+    const { remote, it } = await live()
+    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
+    await expect(it.next()).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 3, changed: true } } })
+    expect(remote.stats).toHaveLength(1)
+  })
+
+  it('ignores a frame carrying the version it already holds', async () => {
+    const { remote, it } = await live('v0')
+    const source = remote.opened[0]!.source
+    source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v0' } })
+    // The pull outlives the silent tick: the frame that finally answers it is v1.
+    const pending = it.next()
+    await expect(Promise.race([pending, settle().then(() => 'silent' as const)])).resolves.toBe('silent')
+    source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
+    await expect(pending).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
+  })
+
+  it('does not lose a write reported during the opening stat', async () => {
+    const { remote, it } = opened()
+    const first = it.next()
+    await settle()
+    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
+    await settle()
+    remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
+    await expect(first).resolves.toMatchObject({ value: { ok: true, value: { version: 'v0', changed: false } } })
+    await expect(it.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
+  })
+})
+
+describe('file provider — a reported disappearance', () => {
+  it('stats again and, when the file is still there, yields its fresh metadata flagged', async () => {
+    const { remote, it } = await live('v0', 3)
+    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
+    const next = it.next()
+    await settle()
+    expect(remote.stats).toHaveLength(2)
+    remote.stats[1]!.resolve({ ok: true, value: stat('v2', 9) })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v2', bytes: 9, changed: true } } })
+  })
+
+  it('yields the not-found frame and keeps following, so a later write stats again and brings the file back flagged', async () => {
+    const { remote, it } = await live('v0', 3)
+    const source = remote.opened[0]!.source
+    source.push({ kind: 'change', change: { absolutePath: HOST_PATH, absent: true } })
+    const next = it.next()
+    await settle()
+    const error = notFound()
+    remote.stats[1]!.resolve({ ok: false, error })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: false, error } })
+    source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v3' } })
+    const back = it.next()
+    await settle()
+    remote.stats[2]!.resolve({ ok: true, value: stat('v3', 8) })
+    await expect(back).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v3', bytes: 8, changed: true } } })
+  })
+})
+
+describe('file provider — reload', () => {
+  it('stats again and clears the flag', async () => {
+    const { remote, it, reload } = await live('v0', 3)
+    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
+    await it.next()
+    reload()
+    const next = it.next()
+    await settle()
+    expect(remote.stats).toHaveLength(2)
+    expect(remote.stats[1]).toMatchObject({ sessionId: S1, path: REL_PATH })
+    remote.stats[1]!.resolve({ ok: true, value: stat('v1', 7) })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: true, value: { version: 'v1', bytes: 7, changed: false } } })
+  })
+
+  it('yields a failed stat as a frame and keeps the stream open', async () => {
+    const { remote, it, reload } = await live()
+    reload()
+    const next = it.next()
+    await settle()
+    const error = notFound()
+    remote.stats[1]!.resolve({ ok: false, error })
+    await expect(next).resolves.toEqual({ done: false, value: { ok: false, error } })
+    await expect(peek(it)).resolves.toBe('silent')
+  })
+
+  it('ends quietly when aborted during the stat', async () => {
+    const { remote, it, reload, controller } = await live()
+    reload()
+    const next = it.next()
+    await settle()
+    controller.abort()
+    remote.stats[1]!.resolve({ ok: false, error: new RemoteError('gateway/internal', 'aborted', {}) })
+    await expect(next).resolves.toEqual({ done: true, value: undefined })
+  })
+
+  it('re-stats every record of the path on one record\'s reload: a session record and an absolute record of one file share it', async () => {
+    const remote = new FakeRemote()
+    const provider = createFileResourceProvider(remote, new ChangeFeed(remote), sessionsWith(S1))
+    const signal = new AbortController().signal
+    const session = provider.open(ADDRESS, { signal })[Symbol.asyncIterator]()
+    const absolute = provider.open(absoluteFileAddress(HOST_PATH), { signal })[Symbol.asyncIterator]()
+    const firsts = Promise.all([session.next(), absolute.next()])
+    await settle()
+    expect(remote.stats.map(pending => [pending.sessionId, pending.path])).toEqual([[S1, REL_PATH], [S1, HOST_PATH]])
+    remote.stats[0]!.resolve({ ok: true, value: stat('v0', 3) })
+    remote.stats[1]!.resolve({ ok: true, value: stat('v0', 3) })
+    await firsts
+    expect(remote.opened).toHaveLength(1)
+    // One Host write flags both records: they follow one path.
+    remote.opened[0]!.source.push({ kind: 'change', change: { absolutePath: HOST_PATH, version: 'v1' } })
+    await expect(session.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
+    await expect(absolute.next()).resolves.toMatchObject({ value: { ok: true, value: { version: 'v1', changed: true } } })
+    // A reload on the session record re-stats both and clears both flags:
+    // delivery is per path, not per record.
+    provider.reload!(ADDRESS)
+    const nexts = Promise.all([session.next(), absolute.next()])
+    await settle()
+    expect(remote.stats.slice(2).map(pending => pending.path)).toEqual([REL_PATH, HOST_PATH])
+    remote.stats[2]!.resolve({ ok: true, value: stat('v1', 3) })
+    remote.stats[3]!.resolve({ ok: true, value: stat('v1', 3) })
+    const cleared = { done: false, value: { ok: true, value: { version: 'v1', bytes: 3, changed: false } } }
+    await expect(nexts).resolves.toEqual([cleared, cleared])
+  })
+
+  it('is a no-op for a file nobody has open', async () => {
+    const { remote, reload } = opened()
+    reload()
+    await settle()
+    expect(remote.stats).toHaveLength(0)
+  })
+})
+
+describe('file provider — the end', () => {
+  it('ends when its signal aborts and releases the session stream', async () => {
+    const { remote, it, controller } = await live()
+    controller.abort()
+    await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
+    await settle()
+    expect(remote.disposed).toEqual(['workspace file changes of s1'])
+  })
+
+  it('ends when the Host closes the session stream', async () => {
+    const { remote, it } = await live()
+    remote.opened[0]!.source.end()
+    await expect(it.next()).resolves.toEqual({ done: true, value: undefined })
+  })
+})

+ 146 - 0
packages/api/workspace-files/tests/read-bytes.spec.ts

@@ -0,0 +1,146 @@
+/** The `readBytes` endpoint: the byte window it cuts, its defaults and cap, and the gates it shares with `read`. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdir, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { FsVersion } from '@deepseek-ai/dsh-fs'
+import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+
+let harness: Harness
+let workspace: string
+
+beforeEach(async () => {
+  harness = await openWorkspace('dsh-workspace-files-read-bytes-')
+  workspace = harness.workspace
+})
+
+afterEach(async () => {
+  await harness.dispose()
+})
+
+const endpoint = (caps?: { maxBytes?: number }): ReturnType<Harness['endpoint']> => harness.endpoint(caps)
+
+/** 256 bytes, each equal to its offset: the window's content names its position. */
+const RAMP = Buffer.from(Array.from({ length: 256 }, (_, i) => i))
+
+const decode = (data: string): Buffer => Buffer.from(data, 'base64')
+
+describe('workspaceFiles.readBytes — the window', () => {
+  it('returns the whole file as one window by default, with its absolute path, version, and size', async () => {
+    await writeFile(join(workspace, 'ramp.bin'), RAMP)
+    const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal())
+    expect(decode(result.data).equals(RAMP)).toBe(true)
+    expect(result).toMatchObject({ offset: 0, eof: true, bytes: 256 })
+    expect(result.absolutePath.endsWith('ramp.bin')).toBe(true)
+    expect(result.version.length).toBeGreaterThan(0)
+  })
+
+  it('cuts the requested window and reports that more follows', async () => {
+    await writeFile(join(workspace, 'ramp.bin'), RAMP)
+    const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 16, length: 8 }, signal())
+    expect([...decode(result.data)]).toEqual([16, 17, 18, 19, 20, 21, 22, 23])
+    expect(result).toMatchObject({ offset: 16, eof: false, bytes: 256 })
+  })
+
+  it('reads a window of a file far above the byte cap', async () => {
+    await writeFile(join(workspace, 'huge.bin'), Buffer.alloc(200_000, 7))
+    const result = await endpoint({ maxBytes: 1024 }).readBytes(agent, 'huge.bin', { offset: 199_000, length: 1024 }, signal())
+    expect(decode(result.data)).toHaveLength(1000)
+    expect(result).toMatchObject({ eof: true, bytes: 200_000 })
+  })
+
+  it('infers eof from a short window when the backend reports no size', async () => {
+    await writeFile(join(workspace, 'ramp.bin'), RAMP)
+    vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' })
+    const full = await endpoint().readBytes(agent, 'ramp.bin', { offset: 0, length: 256 }, signal())
+    expect(full.eof).toBe(false)
+    const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 10 }, signal())
+    expect(short).toMatchObject({ eof: true })
+    expect(short.bytes).toBeUndefined()
+  })
+
+  it('reports eof on the window that holds the last byte, whether or not the length is reached', async () => {
+    await writeFile(join(workspace, 'ramp.bin'), RAMP)
+    const exact = await endpoint().readBytes(agent, 'ramp.bin', { offset: 248, length: 8 }, signal())
+    expect(exact.eof).toBe(true)
+    expect(decode(exact.data)).toHaveLength(8)
+    const short = await endpoint().readBytes(agent, 'ramp.bin', { offset: 250, length: 100 }, signal())
+    expect(short.eof).toBe(true)
+    expect([...decode(short.data)]).toEqual([250, 251, 252, 253, 254, 255])
+  })
+
+  it('returns an empty eof window for an offset at or past the end', async () => {
+    await writeFile(join(workspace, 'ramp.bin'), RAMP)
+    const result = await endpoint().readBytes(agent, 'ramp.bin', { offset: 300, length: 8 }, signal())
+    expect(result).toMatchObject({ data: '', offset: 300, eof: true, bytes: 256 })
+  })
+
+  it('returns an empty eof window for an empty file', async () => {
+    await writeFile(join(workspace, 'empty.bin'), Buffer.alloc(0))
+    const result = await endpoint().readBytes(agent, 'empty.bin', {}, signal())
+    expect(result).toMatchObject({ data: '', offset: 0, eof: true, bytes: 0 })
+  })
+
+  it('carries bytes a text read would refuse: NUL and invalid UTF-8 round-trip through base64', async () => {
+    const raw = Buffer.from([0, 0xff, 0xfe, 0x80, 0x41, 0])
+    await writeFile(join(workspace, 'blob.bin'), raw)
+    const result = await endpoint().readBytes(agent, 'blob.bin', {}, signal())
+    expect(decode(result.data).equals(raw)).toBe(true)
+  })
+
+  it('names the version a stat of the same file reports', async () => {
+    await writeFile(join(workspace, 'ramp.bin'), RAMP)
+    const stat = await endpoint().stat(agent, 'ramp.bin', signal())
+    const result = await endpoint().readBytes(agent, 'ramp.bin', {}, signal())
+    expect(result.version).toBe(stat.version)
+  })
+})
+
+describe('workspaceFiles.readBytes — defaults and cap', () => {
+  it('defaults the length to the configured byte cap', async () => {
+    await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64))
+    const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', {}, signal())
+    expect(decode(result.data)).toHaveLength(64)
+    expect(result.eof).toBe(true)
+  })
+
+  it('refuses a window longer than the cap as too-large rather than shortening it', async () => {
+    await writeFile(join(workspace, 'ramp.bin'), RAMP)
+    const failure = await failureOf(endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 65 }, signal()))
+    expect(failure.code).toBe('workspace-file/too-large')
+    expect(failure.details).toMatchObject({ limit: 64 })
+  })
+
+  it('accepts a window exactly at the cap', async () => {
+    await writeFile(join(workspace, 'ramp.bin'), RAMP.subarray(0, 64))
+    const result = await endpoint({ maxBytes: 64 }).readBytes(agent, 'ramp.bin', { length: 64 }, signal())
+    expect(decode(result.data)).toHaveLength(64)
+  })
+
+  it('refuses a negative or fractional offset and a non-positive length as bad requests', async () => {
+    await writeFile(join(workspace, 'ramp.bin'), RAMP)
+    // Beyond-safe integers and a window whose end overflows are refused too: they cannot index a file.
+    const ranges = [
+      { offset: -1 }, { offset: 1.5 }, { length: 0 }, { length: 2.5 },
+      { offset: 2 ** 53 }, { offset: Number.MAX_SAFE_INTEGER, length: 2 },
+    ]
+    for (const range of ranges) {
+      const failure = await failureOf(endpoint().readBytes(agent, 'ramp.bin', range, signal()))
+      expect(failure.code).toBe('gateway/bad-request')
+    }
+  })
+})
+
+describe('workspaceFiles.readBytes — the gates it shares with read', () => {
+  it('rejects a directory, a missing path, and an empty path', async () => {
+    await mkdir(join(workspace, 'dir'))
+    expect((await failureOf(endpoint().readBytes(agent, 'dir', {}, signal()))).code).toBe('workspace-file/not-regular-file')
+    expect((await failureOf(endpoint().readBytes(agent, 'missing.bin', {}, signal()))).code).toBe('workspace-file/not-found')
+    expect((await failureOf(endpoint().readBytes(agent, '', {}, signal()))).code).toBe('gateway/bad-request')
+  })
+
+  it('rejects an absolute path outside the workspace', async () => {
+    await writeFile(join(harness.outside, 'secret.bin'), RAMP)
+    const failure = await failureOf(endpoint().readBytes(agent, join(harness.outside, 'secret.bin'), {}, signal()))
+    expect(failure.code).toBe('workspace-file/outside-workspace')
+  })
+})

+ 264 - 0
packages/api/workspace-files/tests/read.spec.ts

@@ -0,0 +1,264 @@
+/** The `read` endpoint: its four gates and the line window it cuts. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { FsError } from '@deepseek-ai/dsh-fs'
+import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+
+let harness: Harness
+let workspace: string
+let outside: string
+
+beforeEach(async () => {
+  harness = await openWorkspace('dsh-workspace-files-read-')
+  workspace = harness.workspace
+  outside = harness.outside
+})
+
+afterEach(async () => {
+  await harness.dispose()
+})
+
+const endpoint = (caps?: { maxBytes?: number; maxLines?: number }): ReturnType<Harness['endpoint']> =>
+  harness.endpoint(caps)
+
+/** Twenty lines, `line 1` through `line 20`, terminated by a final newline. */
+async function twentyLines(): Promise<void> {
+  await writeFile(join(workspace, 'long.txt'), `${Array.from({ length: 20 }, (_, i) => `line ${i + 1}`).join('\n')}\n`, 'utf8')
+}
+
+/** A file whose second line carries a NUL byte past the backend's 8 KiB binary sample. */
+async function lateNul(): Promise<void> {
+  await writeFile(join(workspace, 'late-nul.txt'), Buffer.concat([
+    Buffer.from(`${'a'.repeat(9000)}\nb`, 'utf8'),
+    Buffer.from([0]),
+    Buffer.from('c\n', 'utf8'),
+  ]))
+}
+
+describe('workspaceFiles.read — the happy path', () => {
+  it('returns the whole file as one page with its absolute path, version, and byte size', async () => {
+    await writeFile(join(workspace, 'notes.txt'), 'hello\nworld\n', 'utf8')
+    const result = await endpoint().read(agent, 'notes.txt', {}, signal())
+    expect(result.text).toBe('hello\nworld')
+    expect(result.offset).toBe(1)
+    expect(result.lines).toBe(2)
+    expect(result.eof).toBe(true)
+    expect(result.bytes).toBe(12)
+    expect(result.version.length).toBeGreaterThan(0)
+    expect(result.absolutePath.endsWith('notes.txt')).toBe(true)
+  })
+
+  it('reads a nested path relative to the workspace root, not to any backend cwd', async () => {
+    await mkdir(join(workspace, 'src', 'deep'), { recursive: true })
+    await writeFile(join(workspace, 'src', 'deep', 'a.ts'), 'export {}\n', 'utf8')
+    const result = await endpoint().read(agent, 'src/deep/a.ts', {}, signal())
+    expect(result.text).toBe('export {}')
+  })
+
+  it('returns an empty page for an empty file', async () => {
+    await writeFile(join(workspace, 'empty.txt'), '', 'utf8')
+    const result = await endpoint().read(agent, 'empty.txt', {}, signal())
+    expect(result).toMatchObject({ text: '', lines: 0, eof: true, bytes: 0 })
+  })
+
+  it('accepts multi-byte UTF-8 and counts the file bytes, not its characters', async () => {
+    await writeFile(join(workspace, 'zh.txt'), '侧栏', 'utf8')
+    const result = await endpoint().read(agent, 'zh.txt', {}, signal())
+    expect(result.text).toBe('侧栏')
+    expect(result.bytes).toBe(6)
+  })
+})
+
+describe('workspaceFiles.read — the line window', () => {
+  it('cuts the requested lines and reports that more follow', async () => {
+    await twentyLines()
+    const result = await endpoint().read(agent, 'long.txt', { offset: 6, limit: 3 }, signal())
+    expect(result).toMatchObject({ offset: 6, text: 'line 6\nline 7\nline 8', lines: 3, eof: false })
+  })
+
+  it('reports eof on the page that holds the last line, whether or not the limit is reached', async () => {
+    await twentyLines()
+    const service = endpoint()
+    const exact = await service.read(agent, 'long.txt', { offset: 16, limit: 5 }, signal())
+    expect(exact).toMatchObject({ text: 'line 16\nline 17\nline 18\nline 19\nline 20', lines: 5, eof: true })
+    const beyond = await service.read(agent, 'long.txt', { offset: 19, limit: 10 }, signal())
+    expect(beyond).toMatchObject({ text: 'line 19\nline 20', lines: 2, eof: true })
+  })
+
+  it('treats a final newline as the last line terminator, not as an empty line after it', async () => {
+    await writeFile(join(workspace, 'two.txt'), 'a\nb\n', 'utf8')
+    await writeFile(join(workspace, 'three.txt'), 'a\nb\n\n', 'utf8')
+    const service = endpoint()
+    expect(await service.read(agent, 'two.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: true })
+    expect(await service.read(agent, 'three.txt', { limit: 2 }, signal())).toMatchObject({ text: 'a\nb', lines: 2, eof: false })
+    // The third line is empty, not absent: `lines` tells it from a page past the end.
+    expect(await service.read(agent, 'three.txt', { offset: 3 }, signal())).toMatchObject({ text: '', lines: 1, eof: true })
+  })
+
+  it('returns an empty eof page for an offset past the last line', async () => {
+    await twentyLines()
+    const result = await endpoint().read(agent, 'long.txt', { offset: 21 }, signal())
+    expect(result).toMatchObject({ offset: 21, text: '', lines: 0, eof: true })
+  })
+
+  it('defaults the limit to the configured page size', async () => {
+    await twentyLines()
+    const result = await endpoint({ maxLines: 5 }).read(agent, 'long.txt', {}, signal())
+    expect(result.text.split('\n')).toHaveLength(5)
+    expect(result.eof).toBe(false)
+  })
+
+  it('refuses a limit above the configured page size and a non-positive-integer window', async () => {
+    await twentyLines()
+    const service = endpoint({ maxLines: 5 })
+    for (const range of [{ limit: 6 }, { offset: 0 }, { limit: 1.5 }, { offset: -3 }]) {
+      const failure = await failureOf(service.read(agent, 'long.txt', range, signal()))
+      expect(failure.code).toBe('gateway/bad-request')
+    }
+  })
+
+  it('keeps carriage returns: the page is the file text, not a rendering of it', async () => {
+    await writeFile(join(workspace, 'crlf.txt'), 'a\r\nb\r\n', 'utf8')
+    const result = await endpoint().read(agent, 'crlf.txt', {}, signal())
+    expect(result.text).toBe('a\r\nb\r')
+  })
+})
+
+describe('workspaceFiles.read — gate 1 and 2: authorization by containment', () => {
+  it('rejects an absolute path outside the workspace', async () => {
+    await writeFile(join(outside, 'secret.txt'), 'no', 'utf8')
+    const failure = await failureOf(endpoint().read(agent, join(outside, 'secret.txt'), {}, signal()))
+    expect(failure.code).toBe('workspace-file/outside-workspace')
+  })
+
+  it('rejects a traversal that climbs out of the workspace', async () => {
+    await writeFile(join(outside, 'secret.txt'), 'no', 'utf8')
+    const failure = await failureOf(endpoint().read(agent, '../outside/secret.txt', {}, signal()))
+    expect(failure.code).toBe('workspace-file/outside-workspace')
+  })
+
+  it('rejects a symlink that points out of the workspace — the case a prefix test cannot see', async () => {
+    await writeFile(join(outside, 'secret.txt'), 'no', 'utf8')
+    // The path itself is inside the workspace and would pass any string
+    // comparison; only lstat (before the follow) or realpath containment catches it.
+    await symlink(join(outside, 'secret.txt'), join(workspace, 'link.txt'))
+    const failure = await failureOf(endpoint().read(agent, 'link.txt', {}, signal()))
+    expect(failure.code).toBe('workspace-file/not-regular-file')
+    expect(failure.details).toMatchObject({ kind: 'symlink' })
+  })
+
+  it('rejects a symlink even when it points back inside the workspace', async () => {
+    await writeFile(join(workspace, 'real.txt'), 'fine', 'utf8')
+    await symlink(join(workspace, 'real.txt'), join(workspace, 'alias.txt'))
+    const failure = await failureOf(endpoint().read(agent, 'alias.txt', {}, signal()))
+    expect(failure.code).toBe('workspace-file/not-regular-file')
+  })
+
+  it('rejects a directory, which has no text to return', async () => {
+    await mkdir(join(workspace, 'src'), { recursive: true })
+    const failure = await failureOf(endpoint().read(agent, 'src', {}, signal()))
+    expect(failure.code).toBe('workspace-file/not-regular-file')
+    expect(failure.details).toMatchObject({ kind: 'directory' })
+  })
+
+  it('reports a missing path as not found', async () => {
+    const failure = await failureOf(endpoint().read(agent, 'nope.txt', {}, signal()))
+    expect(failure.code).toBe('workspace-file/not-found')
+  })
+
+  it('refuses an empty path as a bad request', async () => {
+    const failure = await failureOf(endpoint().read(agent, '', {}, signal()))
+    expect(failure.code).toBe('gateway/bad-request')
+  })
+})
+
+describe('workspaceFiles.read — gate 3: the page byte cap', () => {
+  it('fails a page above the cap rather than returning it shortened', async () => {
+    await writeFile(join(workspace, 'big.txt'), 'x'.repeat(4096), 'utf8')
+    const failure = await failureOf(endpoint({ maxBytes: 1024 }).read(agent, 'big.txt', {}, signal()))
+    expect(failure.code).toBe('workspace-file/too-large')
+    expect(failure.details).toMatchObject({ limit: 1024 })
+  })
+
+  it('accepts a page exactly at the cap, because the cap is inclusive', async () => {
+    await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8')
+    const result = await endpoint({ maxBytes: 64 }).read(agent, 'exact.txt', {}, signal())
+    expect(result.text).toHaveLength(64)
+  })
+
+  it('counts the newlines between the page lines against the cap', async () => {
+    await writeFile(join(workspace, 'exact.txt'), `${'x'.repeat(31)}\n${'y'.repeat(32)}\n`, 'utf8')
+    const failure = await failureOf(endpoint({ maxBytes: 63 }).read(agent, 'exact.txt', {}, signal()))
+    expect(failure.code).toBe('workspace-file/too-large')
+  })
+
+  it('caps the page, not the file: a small window of a file far above the cap reads', async () => {
+    await writeFile(join(workspace, 'huge.txt'), Array.from({ length: 2000 }, (_, i) => `row ${i} ${'z'.repeat(100)}`).join('\n'), 'utf8')
+    const result = await endpoint({ maxBytes: 1024 }).read(agent, 'huge.txt', { offset: 1990, limit: 3 }, signal())
+    expect(result.text.split('\n')).toHaveLength(3)
+    expect(result.eof).toBe(false)
+    expect(result.bytes).toBeGreaterThan(200_000)
+  })
+})
+
+describe('workspaceFiles.read — gate 4: text only', () => {
+  it('rejects bytes that are not valid UTF-8', async () => {
+    await writeFile(join(workspace, 'bin.dat'), Buffer.from([0xff, 0xfe, 0xfd]))
+    const failure = await failureOf(endpoint().read(agent, 'bin.dat', {}, signal()))
+    expect(failure.code).toBe('workspace-file/not-text')
+  })
+
+  it('rejects a page that carries NUL bytes, wherever in the file the page lies', async () => {
+    await writeFile(join(workspace, 'nul.dat'), Buffer.from([0x61, 0x00, 0x62]))
+    const service = endpoint()
+    expect((await failureOf(service.read(agent, 'nul.dat', {}, signal()))).code).toBe('workspace-file/not-text')
+    // Past the backend's own binary sample, so only the page scan can see it.
+    await lateNul()
+    expect((await failureOf(service.read(agent, 'late-nul.txt', { offset: 2 }, signal()))).code).toBe('workspace-file/not-text')
+  })
+
+  it('reads a page that ends before a NUL byte, because detection is per page', async () => {
+    await lateNul()
+    const result = await endpoint().read(agent, 'late-nul.txt', { limit: 1 }, signal())
+    expect(result.text).toHaveLength(9000)
+    expect(result.eof).toBe(false)
+  })
+})
+
+describe('workspaceFiles.read — the file changing under its gate', () => {
+  /** Run `mutate` after the path gate has looked, so what follows sees a different filesystem. */
+  function afterGate(mutate: () => Promise<void>): void {
+    const fs = harness.ctx.fs
+    const lstat = fs.lstat.bind(fs)
+    vi.spyOn(fs, 'lstat').mockImplementation(async (path, opts, signal) => {
+      const entry = await lstat(path, opts, signal)
+      await mutate()
+      return entry
+    })
+  }
+
+  it('reports a file deleted after the gate as not found, not as an internal failure', async () => {
+    await writeFile(join(workspace, 'fleeting.txt'), 'x', 'utf8')
+    afterGate(() => rm(join(workspace, 'fleeting.txt')))
+    const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal()))
+    expect(failure.code).toBe('workspace-file/not-found')
+  })
+
+  it('reports a file replaced by a directory after the gate as not a regular file', async () => {
+    await writeFile(join(workspace, 'fleeting.txt'), 'x', 'utf8')
+    afterGate(async () => {
+      await rm(join(workspace, 'fleeting.txt'))
+      await mkdir(join(workspace, 'fleeting.txt'))
+    })
+    const failure = await failureOf(endpoint().read(agent, 'fleeting.txt', {}, signal()))
+    expect(failure.code).toBe('workspace-file/not-regular-file')
+    expect(failure.details).toMatchObject({ kind: 'directory' })
+  })
+
+  it('passes any other backend failure through unchanged', async () => {
+    await writeFile(join(workspace, 'notes.txt'), 'x', 'utf8')
+    vi.spyOn(harness.ctx.fs, 'streamText').mockRejectedValue(new FsError('disk unreadable', 'FS_IO_ERROR'))
+    await expect(endpoint().read(agent, 'notes.txt', {}, signal())).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
+  })
+})

+ 79 - 0
packages/api/workspace-files/tests/stat.spec.ts

@@ -0,0 +1,79 @@
+/** The `stat` endpoint: the same gates as `read`, answering identity and freshness without content. */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { mkdir, symlink, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { FsVersion } from '@deepseek-ai/dsh-fs'
+import { agent, failureOf, openWorkspace, signal, type Harness } from './harness.ts'
+
+let harness: Harness
+
+beforeEach(async () => {
+  harness = await openWorkspace('dsh-workspace-files-stat-')
+})
+
+afterEach(async () => {
+  await harness.dispose()
+})
+
+describe('workspaceFiles.stat', () => {
+  it('returns the absolute path, a version, and the byte size', async () => {
+    await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8')
+    const result = await harness.endpoint().stat(agent, 'notes.txt', signal())
+    expect(result.absolutePath).toBe(harness.ctx.fs.processPath(await harness.ctx.fs.resolve(join(harness.workspace, 'notes.txt'))))
+    expect(result.version.length).toBeGreaterThan(0)
+    expect(result.bytes).toBe(6)
+  })
+
+  it('answers with the version a read of the same file reports, and a new one after a write', async () => {
+    const path = join(harness.workspace, 'notes.txt')
+    await writeFile(path, 'one\n', 'utf8')
+    const endpoint = harness.endpoint()
+    const before = await endpoint.stat(agent, 'notes.txt', signal())
+    const page = await endpoint.read(agent, 'notes.txt', {}, signal())
+    expect(page.version).toBe(before.version)
+    await writeFile(path, 'one\ntwo\n', 'utf8')
+    const after = await endpoint.stat(agent, 'notes.txt', signal())
+    expect(after.version).not.toBe(before.version)
+    expect(after.bytes).toBe(8)
+  })
+
+  it('rejects under a signal the caller already aborted, before any path resolves', async () => {
+    const controller = new AbortController()
+    controller.abort()
+    await expect(harness.endpoint().stat(agent, 'notes.txt', controller.signal)).rejects.toThrow()
+  })
+
+  it('resolves the workspace root and then the confined target under the caller\'s signal', async () => {
+    await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8')
+    const fs = harness.ctx.fs
+    const original = fs.resolve.bind(fs)
+    const spy = vi.spyOn(fs, 'resolve').mockImplementation((path, opts) => original(path, opts))
+    const controller = new AbortController()
+    await harness.endpoint().stat(agent, 'notes.txt', controller.signal)
+    expect(spy.mock.calls.map(([, opts]) => opts?.signal)).toEqual([controller.signal, controller.signal])
+    spy.mockRestore()
+  })
+
+  it('omits bytes when the backend reports no size', async () => {
+    await writeFile(join(harness.workspace, 'notes.txt'), 'hello\n', 'utf8')
+    vi.spyOn(harness.ctx.fs, 'stat').mockResolvedValue({ version: FsVersion('v-sizeless'), type: 'file' })
+    const result = await harness.endpoint().stat(agent, 'notes.txt', signal())
+    expect(result).toEqual({ absolutePath: result.absolutePath, version: 'v-sizeless' })
+  })
+
+  it('applies the read gates: symlink, directory, outside, missing, empty', async () => {
+    await writeFile(join(harness.outside, 'secret.txt'), 'no', 'utf8')
+    await symlink(join(harness.outside, 'secret.txt'), join(harness.workspace, 'link.txt'))
+    await mkdir(join(harness.workspace, 'src'))
+    const endpoint = harness.endpoint()
+    expect(await failureOf(endpoint.stat(agent, 'link.txt', signal()))).toMatchObject({
+      code: 'workspace-file/not-regular-file',
+      details: { kind: 'symlink' },
+    })
+    expect((await failureOf(endpoint.stat(agent, 'src', signal()))).details).toMatchObject({ kind: 'directory' })
+    expect((await failureOf(endpoint.stat(agent, join(harness.outside, 'secret.txt'), signal()))).code)
+      .toBe('workspace-file/outside-workspace')
+    expect((await failureOf(endpoint.stat(agent, 'nope.txt', signal()))).code).toBe('workspace-file/not-found')
+    expect((await failureOf(endpoint.stat(agent, '', signal()))).code).toBe('gateway/bad-request')
+  })
+})

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

@@ -1750,8 +1750,20 @@ export interface FixtureOptions {
   dropSessionCreateResponse?: boolean
   /** Order of the two successful create frames. */
   createFrameOrder?: 'session-first' | 'workspace-first'
+  /** Announce one Agent write to `notes/demo.txt` shortly after a `workspaceFiles/changes` stream opens. */
+  fileChanges?: boolean
 }
 
+/** File observation payload, mirrored so this Client fixture names no Host package. */
+type FixtureWorkspaceFileChange =
+  | { readonly absolutePath: string; readonly version: string }
+  | { readonly absolutePath: string; readonly absent: true }
+
+/** Host subscription acknowledgement followed by file observations. */
+type FixtureWorkspaceFileWatchFrame =
+  | { readonly kind: 'ready' }
+  | { readonly kind: 'change'; readonly change: FixtureWorkspaceFileChange }
+
 /** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung
  *  outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and
  *  piles up for the stream's lifetime). breakNow force-ends the stream without the
@@ -2367,6 +2379,157 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     },
   }
 
+  /**
+   * Workspace text reads under `?fixture`.
+   *
+   * The sample content is deliberately more than one shape: the panel's states
+   * (text, oversized, unreadable) are only demonstrable if the fixture can
+   * produce each of them, and a preview that can only ever succeed hides its
+   * own failure rendering.
+   */
+  // The Session workspace the replayed conversation writes into: `list` walks a
+  // fixed tree under it so the file-tree tab has directories, files, one entry
+  // of neither kind, and one cut listing to draw.
+  const WORKSPACE_FILES_ROOT = '/tmp/fixture'
+  type FixtureWorkspaceEntry = { name: string; type: 'file' | 'directory' | 'other'; size?: number }
+  const workspaceFileTree = new Map<string, FixtureWorkspaceEntry[]>([
+    ['', [
+      { name: '.gitignore', type: 'file', size: 24 },
+      { name: 'dev.sock', type: 'other' },
+      { name: 'notes', type: 'directory' },
+      { name: 'package.json', type: 'file', size: 512 },
+      { name: 'README.md', type: 'file', size: 640 },
+      { name: 'src', type: 'directory' },
+    ]],
+    ['notes', [
+      { name: 'demo.txt', type: 'file', size: 14 },
+      { name: 'new-demo.txt', type: 'file', size: 14 },
+    ]],
+    ['src', [
+      { name: 'config.ts', type: 'file', size: 211 },
+      { name: 'index.ts', type: 'file', size: 88 },
+      { name: 'lib', type: 'directory' },
+    ]],
+    ['src/lib', Array.from({ length: 24 }, (_, index) => ({
+      name: `module-${String(index + 1).padStart(2, '0')}.ts`,
+      type: 'file' as const,
+      size: 96 + index,
+    }))],
+  ])
+  /** Resolve a `list` argument to its workspace-relative path, or undefined when it leaves the root. */
+  const workspaceFilePath = (path: string): string | undefined => {
+    const segments: string[] = []
+    for (const segment of (path.startsWith('/') ? path : `${WORKSPACE_FILES_ROOT}/${path}`).split('/')) {
+      if (segment === '' || segment === '.') continue
+      if (segment === '..') {
+        segments.pop()
+        continue
+      }
+      segments.push(segment)
+    }
+    const absolute = `/${segments.join('/')}`
+    if (absolute !== WORKSPACE_FILES_ROOT && !absolute.startsWith(`${WORKSPACE_FILES_ROOT}/`)) return undefined
+    return absolute.slice(WORKSPACE_FILES_ROOT.length + 1)
+  }
+  // Page cap mirrored from the Host default so an over-limit request fails here too.
+  const WORKSPACE_FILE_PAGE_LINES = 5000
+  /**
+   * Sample text for any readable path: a heading plus two lines of copy. The
+   * replayed conversation's `demo` files, and any `huge` path, run past two
+   * default pages so paging can be exercised without a real workspace.
+   */
+  const workspaceFileLines = (path: string): string[] => {
+    const name = path.slice(path.lastIndexOf('/') + 1)
+    const head = [`# ${name}`, '', 'fixture 模式下的示例文本,用于验收侧栏的文本预览。', '真实构建从工作区读取同名文件。']
+    return path.includes('demo') || path.includes('huge')
+      ? [...head, ...Array.from({ length: 12_000 }, (_, index) => `第 ${index + 5} 行:用于验收分页与滚动的长文本样本。`)]
+      : head
+  }
+  const workspaceFileRemotes = {
+    list(path: string): ConnectionRpcResult<{
+      path: string
+      entries: readonly FixtureWorkspaceEntry[]
+      truncated: boolean
+    }> {
+      if (path.length === 0) {
+        return { ok: false, error: { code: 'gateway/bad-request', message: 'path is required', details: {} } }
+      }
+      const relative = workspaceFilePath(path)
+      if (relative === undefined) {
+        return {
+          ok: false,
+          error: { code: 'workspace-file/outside-workspace', message: `${path} is outside the workspace`, details: { path } },
+        }
+      }
+      const entries = workspaceFileTree.get(relative)
+      if (entries === undefined) {
+        const cut = relative.lastIndexOf('/')
+        const name = relative.slice(cut + 1)
+        const sibling = workspaceFileTree.get(cut === -1 ? '' : relative.slice(0, cut))?.find(entry => entry.name === name)
+        if (sibling === undefined) {
+          return { ok: false, error: { code: 'workspace-file/not-found', message: `no entry at ${path}`, details: { path } } }
+        }
+        return {
+          ok: false,
+          error: {
+            code: 'workspace-file/not-directory',
+            message: `${path} is a ${sibling.type}`,
+            details: { path, kind: sibling.type === 'file' ? 'file' : 'other' },
+          },
+        }
+      }
+      return { ok: true, value: { path: relative, entries, truncated: relative === 'src/lib' } }
+    },
+    read(path: string, range: { offset?: number; limit?: number }): ConnectionRpcResult<{
+      absolutePath: string
+      version: string
+      bytes: number
+      offset: number
+      text: string
+      lines: number
+      eof: boolean
+    }> {
+      const located = workspaceFileRemotes.stat(path)
+      if (!located.ok) return located
+      const offset = range.offset ?? 1
+      const limit = range.limit ?? WORKSPACE_FILE_PAGE_LINES
+      if (!Number.isInteger(offset) || offset < 1 || !Number.isInteger(limit) || limit < 1 || limit > WORKSPACE_FILE_PAGE_LINES) {
+        return { ok: false, error: { code: 'gateway/bad-request', message: 'offset and limit must be positive integers within the page cap', details: {} } }
+      }
+      const lines = workspaceFileLines(path)
+      const page = lines.slice(offset - 1, offset - 1 + limit)
+      return {
+        ok: true,
+        value: {
+          ...located.value,
+          offset,
+          text: page.join('\n'),
+          lines: page.length,
+          eof: offset - 1 + limit >= lines.length,
+        },
+      }
+    },
+    stat(path: string): ConnectionRpcResult<{ absolutePath: string; version: string; bytes: number }> {
+      if (path.length === 0) {
+        return { ok: false, error: { code: 'gateway/bad-request', message: 'path is required', details: {} } }
+      }
+      if (path.endsWith('.png') || path.endsWith('.bin')) {
+        return {
+          ok: false,
+          error: { code: 'workspace-file/not-text', message: `${path} is not UTF-8 text`, details: { path } },
+        }
+      }
+      return {
+        ok: true,
+        value: {
+          absolutePath: path.startsWith('/') ? path : `/${path}`,
+          version: 'fx-v1',
+          bytes: new TextEncoder().encode(workspaceFileLines(path).join('\n')).byteLength,
+        },
+      }
+    },
+  }
+
   /**
    * Canonical fixture implementation of the generated Directory Picker Remote
    * contract. The pick is deterministic — the keyless lanes drive the full
@@ -3274,6 +3437,27 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
     }
   }
 
+  async function* openWorkspaceFileChanges(signal: AbortSignal): AsyncGenerator<FixtureWorkspaceFileWatchFrame> {
+    signal.throwIfAborted()
+    const conn = new FxInbox<FixtureWorkspaceFileWatchFrame>()
+    const breakNow = (): void => { conn.breakNow() }
+    streamBreakers.add(breakNow)
+    // Opt-in only: an unprompted frame would age every preview of demo.txt
+    // into its changed state on a timer the assembled snapshots cannot see.
+    const announce = options.fileChanges
+      ? setTimeout(() => {
+        conn.push({ kind: 'change', change: { absolutePath: `${WORKSPACE_FILES_ROOT}/notes/demo.txt`, version: 'fx-demo-v2' } })
+      }, 1000)
+      : undefined
+    try {
+      yield { kind: 'ready' }
+      yield* conn.drain(signal)
+    } finally {
+      if (announce !== undefined) clearTimeout(announce)
+      streamBreakers.delete(breakNow)
+    }
+  }
+
   async function* openRemoteEvents(
     signal: AbortSignal,
   ): AsyncGenerator<FixtureRemoteEventReadyFrame | FixtureRemoteEventFrame> {
@@ -3557,6 +3741,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
           line?: string
           query?: string
           path?: string
+          range?: { offset?: number; limit?: number }
           name?: string
           images?: readonly unknown[]
           // A goal ref and a credential reference name share this wire field name.
@@ -3638,6 +3823,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         case 'session/openWorkspacePath': {
           return sessionOk({ opened: true as const })
         }
+        case 'workspaceFiles/read': {
+          return Promise.resolve(workspaceFileRemotes.read(args.path ?? '', args.range ?? {}))
+        }
+        case 'workspaceFiles/stat': {
+          return Promise.resolve(workspaceFileRemotes.stat(args.path ?? ''))
+        }
+        case 'workspaceFiles/list': {
+          return Promise.resolve(workspaceFileRemotes.list(args.path ?? ''))
+        }
         case 'session/canOpenWorkspacePath': return Promise.resolve({ ok: true, value: true })
         case 'session/modelCatalog': return Promise.resolve({
           ok: true,
@@ -3740,6 +3934,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
         case 'session/control': return openControl(signal)
         case 'session/follow': return openFollow(args.request as FixtureFollowRequest, signal)
         case 'workspace/follow': return openWorkspace(signal)
+        case 'workspaceFiles/changes': return openWorkspaceFileChanges(signal)
         default:
           throw new Error(`fixture connection stream endpoint ${JSON.stringify(endpoint)} is unavailable`)
       }
@@ -3766,5 +3961,6 @@ function fixtureOptionsFromLocation(): FixtureOptions {
     failWorkspaceAttach: query.get('fixtureAttach') === 'fail',
     dropSessionCreateResponse: query.get('fixtureSessionCreate') === 'drop-response',
     createFrameOrder: query.get('fixtureFrames') === 'workspace-first' ? 'workspace-first' : 'session-first',
+    fileChanges: query.get('fixtureFileChanges') === 'demo',
   }
 }

+ 5 - 0
packages/client/locale/tests/language-row.client.spec.tsx

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -9,6 +10,9 @@ import { LanguageRow } from '../src/client/LanguageRow.tsx'
 import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx'
 import { createLanguageRowStore } from '../src/client/settings-store.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(cleanup)
 
 const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }]
@@ -37,6 +41,7 @@ function mount(active = 'en') {
   const props: LanguageRowComponentProps = {
     useSessions: emptySessions(),
     useSessionPendingInteraction,
+    useResource,
     useWorkspaces: emptyWorkspaces(),
     useStore: bindSnapshotSelector(store),
     actions: store.actions,

+ 98 - 0
packages/client/resources/tests/apply.client.spec.ts

@@ -0,0 +1,98 @@
+// @vitest-environment jsdom
+/**
+ * The plugin's wiring: `ctx.resources` is provided, the `resource` root keyed
+ * hook reaches every slot component as `useResource`, and both leave with the
+ * fiber so a reload of the plugin registers cleanly again.
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+import { act } from '@testing-library/react'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import { apply, inject, type ResourceSnapshot, type UseResource } from '../src/client/index.ts'
+import { apply as hostApply } from '../src/index.ts'
+import { ResourceRegistry } from '../src/client/resources.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface SlotMap {
+    'resources.probe': { kind: 'single'; scope: 'root' }
+  }
+  interface ResourceProtocolMap {
+    feed: string
+  }
+}
+
+const A = 'dsh-resource://feed/one'
+let runtime: SlotTestRuntime | undefined
+
+afterEach(async () => {
+  await runtime?.dispose()
+  runtime = undefined
+})
+
+const settle = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 0) })
+
+async function boot(): Promise<SlotTestRuntime> {
+  const rt = await SlotTestRuntime.create()
+  await rt.declare({ 'resources.probe': { kind: 'single', scope: 'root' } })
+  return rt
+}
+
+describe('client-resources apply', () => {
+  it('keeps the host Loader entry inert', () => {
+    expect(hostApply).not.toThrow()
+  })
+
+  it('provides ctx.resources and hands every slot component useResource over the shared source', async () => {
+    runtime = await boot()
+    await runtime.mount({ inject: [...inject], apply })
+    expect(runtime.ctx.resources).toBeInstanceOf(ResourceRegistry)
+
+    // A root-scope component reading one address through the standard hook.
+    let seen: ResourceSnapshot<string> | undefined
+    runtime.slots.register({ name: 'resources.probe' }, ({ useResource }: { useResource: UseResource }) => {
+      seen = useResource<'feed'>(A)
+      return null
+    })
+    runtime.renderSlot('resources.probe', {})
+    expect(seen).toMatchObject({ status: 'none', value: undefined })
+
+    let push: ((value: string) => void) | undefined
+    await act(async () => {
+      runtime!.ctx.effect(() => runtime!.ctx.resources.register<'feed'>({
+        protocol: 'feed',
+        open: () => ({
+          [Symbol.asyncIterator]: () => ({
+            next: () => new Promise<IteratorResult<RemoteResult<string>>>((resolve) => {
+              push = (value) => { resolve({ done: false, value: { ok: true, value } }) }
+            }),
+          }),
+        }),
+      }), 'spec: feed provider')
+    })
+    expect(seen).toMatchObject({ status: 'loading' })
+    // Rendering the hook is what holds the address: the provider's stream is open.
+    expect(push).toBeDefined()
+    await act(async () => { push!('v1'); await settle() })
+    expect(seen).toMatchObject({ status: 'live', value: 'v1' })
+    expect(runtime.ctx.resources.source(A).getSnapshot()).toBe(seen)
+  })
+
+  it('withdraws both on dispose, so a remount registers again without a duplicate', async () => {
+    runtime = await boot()
+    const handle = await runtime.mount({ inject: [...inject], apply })
+    await handle.dispose()
+    expect(runtime.ctx.get('resources')).toBeUndefined()
+
+    // Records only whether the standard hook is present on a root-scope component's props.
+    let hook: UseResource | undefined
+    runtime.slots.register({ name: 'resources.probe' }, (props: { useResource?: UseResource }) => {
+      hook = props.useResource
+      return null
+    })
+    runtime.renderSlot('resources.probe', {})
+    expect(hook).toBeUndefined()
+
+    await runtime.mount({ inject: [...inject], apply })
+    expect(hook).toBeTypeOf('function')
+  })
+})

+ 359 - 0
packages/client/resources/tests/resources.client.spec.ts

@@ -0,0 +1,359 @@
+/**
+ * The resource lifecycle: one address opens when its first holder arrives,
+ * stays open across holder changes, and closes when the last one leaves.
+ * Providers are scripted feeds so every transition is driven by the spec,
+ * never by timing.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import { protocolOf, RESOURCE_SCHEME, ResourceRegistry } from '../src/client/resources.ts'
+import type { ResourceOpenContext, ResourceProvider } from '../src/client/contract.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface ResourceProtocolMap {
+    feed: string
+  }
+}
+
+const A = `${RESOURCE_SCHEME}://feed/one`
+
+/** One scripted stream: the spec pushes value frames, failure frames, or ends it. */
+interface Feed {
+  readonly ctx: ResourceOpenContext
+  push(value: string): void
+  fail(error: RemoteFailure): void
+  end(): void
+  /** Whether the consumer returned the iterator (its `finally` ran). */
+  readonly returned: boolean
+}
+
+type Step = { readonly kind: 'frame'; readonly frame: RemoteResult<string> } | { readonly kind: 'end' }
+
+function createFeed(ctx: ResourceOpenContext): { feed: Feed; stream: AsyncIterable<RemoteResult<string>> } {
+  const steps: Step[] = []
+  let wake: (() => void) | undefined
+  let returned = false
+  const notify = (): void => { wake?.(); wake = undefined }
+  async function* stream(): AsyncGenerator<RemoteResult<string>> {
+    try {
+      for (;;) {
+        if (steps.length === 0) await new Promise<void>((resolve) => { wake = resolve })
+        const step = steps.shift()
+        if (step === undefined) continue
+        if (step.kind === 'end') return
+        yield step.frame
+      }
+    } finally {
+      returned = true
+    }
+  }
+  const feed: Feed = {
+    ctx,
+    push: (value) => { steps.push({ kind: 'frame', frame: { ok: true, value } }); notify() },
+    fail: (error) => { steps.push({ kind: 'frame', frame: { ok: false, error } }); notify() },
+    end: () => { steps.push({ kind: 'end' }); notify() },
+    get returned() { return returned },
+  }
+  return { feed, stream: stream() }
+}
+
+/** A `feed` provider whose every `open` is recorded and spec-driven. */
+function scriptedProvider() {
+  const opens: Feed[] = []
+  const provider = {
+    protocol: 'feed' as const,
+    open: vi.fn((_address: string, ctx: ResourceOpenContext) => {
+      const { feed, stream } = createFeed(ctx)
+      opens.push(feed)
+      return stream
+    }),
+    reload: vi.fn(),
+  } satisfies ResourceProvider<'feed'>
+  return { provider, opens, last: () => opens[opens.length - 1]! }
+}
+
+const settle = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 0) })
+
+function bench() {
+  const ctx = new Context()
+  const registry = new ResourceRegistry(ctx)
+  const scripted = scriptedProvider()
+  const snapshot = (address = A) => registry.source(address).getSnapshot()
+  return { ctx, registry, ...scripted, snapshot }
+}
+
+describe('protocolOf', () => {
+  it('reads the dsh-resource host, lower-cased, and reports none for any other address', () => {
+    expect(protocolOf('dsh-resource://file/session/s1/home/ys/b.txt')).toBe('file')
+    expect(protocolOf('DSH-RESOURCE://File/session/s1/a')).toBe('file')
+    expect(protocolOf('dsh-resource://chat/node/1')).toBe('chat')
+    // A navigation address is not a resource.
+    expect(protocolOf('sidebar://guide')).toBeUndefined()
+    expect(protocolOf('file://sessions/s1/a.txt')).toBeUndefined()
+    expect(protocolOf('dsh-resource:///no-host')).toBeUndefined()
+    expect(protocolOf('/a/b.txt')).toBeUndefined()
+    expect(protocolOf('')).toBeUndefined()
+  })
+})
+
+describe('ResourceRegistry providers', () => {
+  it('owns a protocol by exactly one provider, and frees it on dispose', () => {
+    const b = bench()
+    const dispose = b.registry.register(b.provider)
+    expect(() => b.registry.register(scriptedProvider().provider)).toThrow('protocol "feed" already has a provider')
+    dispose()
+    dispose()
+    expect(() => b.registry.register(scriptedProvider().provider)).not.toThrow()
+  })
+
+  it('reports none for an address whose protocol has no provider, and for a navigation address', () => {
+    const b = bench()
+    expect(b.snapshot()).toMatchObject({ status: 'none', value: undefined, failure: undefined })
+    expect(b.snapshot('sidebar://guide')).toMatchObject({ status: 'none' })
+    const unsubscribe = b.registry.source(A).subscribe(() => {})
+    expect(b.snapshot().status).toBe('none')
+    unsubscribe()
+  })
+
+  it('opens a held address when its provider arrives, and closes it when the provider leaves', async () => {
+    const b = bench()
+    const seen = vi.fn()
+    b.registry.source(A).subscribe(seen)
+    expect(b.snapshot().status).toBe('none')
+
+    const dispose = b.registry.register(b.provider)
+    expect(b.snapshot().status).toBe('loading')
+    expect(b.provider.open).toHaveBeenCalledWith(A, { signal: expect.any(AbortSignal) as AbortSignal })
+    b.last().push('v1')
+    await vi.waitFor(() => { expect(b.snapshot()).toMatchObject({ status: 'live', value: 'v1' }) })
+
+    dispose()
+    expect(b.last().ctx.signal.aborted).toBe(true)
+    expect(b.snapshot()).toMatchObject({ status: 'none', value: undefined })
+    expect(seen).toHaveBeenCalled()
+  })
+
+  it('opens the arriving protocol\'s held addresses only, leaving another protocol\'s records as they were', () => {
+    const b = bench()
+    const other = `${RESOURCE_SCHEME}://other/one`
+    b.registry.source(other).subscribe(() => {})
+    b.registry.source(A).subscribe(() => {})
+    b.registry.register(b.provider)
+    expect(b.snapshot().status).toBe('loading')
+    expect(b.snapshot(other).status).toBe('none')
+    expect(b.provider.open).toHaveBeenCalledExactlyOnceWith(A, { signal: expect.any(AbortSignal) as AbortSignal })
+  })
+
+  it('turns an idle, unheld address to loading when its provider arrives, without opening it', () => {
+    const b = bench()
+    expect(b.snapshot().status).toBe('none')
+    b.registry.register(b.provider)
+    expect(b.snapshot().status).toBe('loading')
+    expect(b.provider.open).not.toHaveBeenCalled()
+  })
+
+  it('drops a registration when the registering fiber is disposed', async () => {
+    const b = bench()
+    const fiber = b.ctx.plugin({
+      apply: (child: Context) => { child.effect(() => b.registry.register(b.provider), 'spec: feed provider') },
+    })
+    await fiber.await()
+    expect(b.snapshot().status).toBe('loading')
+    await fiber.dispose()
+    expect(b.snapshot().status).toBe('none')
+    expect(() => b.registry.register(scriptedProvider().provider)).not.toThrow()
+  })
+
+  it('drops every registration when the registry\'s own fiber is disposed', async () => {
+    const root = new Context()
+    let registry: ResourceRegistry | undefined
+    const fiber = root.plugin({ apply: (child: Context) => { registry = new ResourceRegistry(child) } })
+    await fiber.await()
+    const { provider } = scriptedProvider()
+    registry!.register(provider)
+    registry!.source(A).subscribe(() => {})
+    expect(provider.open).toHaveBeenCalledTimes(1)
+    await fiber.dispose()
+    expect(registry!.source(A).getSnapshot().status).toBe('none')
+  })
+})
+
+describe('ResourceRegistry holders', () => {
+  it('opens on the first subscriber only, and closes after the last one leaves', async () => {
+    const b = bench()
+    b.registry.register(b.provider)
+    const source = b.registry.source(A)
+    expect(b.provider.open).not.toHaveBeenCalled()
+
+    const first = source.subscribe(() => {})
+    const second = source.subscribe(() => {})
+    expect(b.provider.open).toHaveBeenCalledTimes(1)
+    b.last().push('v1')
+    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') })
+
+    first()
+    first()
+    expect(b.last().ctx.signal.aborted).toBe(false)
+    expect(source.getSnapshot().value).toBe('v1')
+
+    second()
+    expect(b.last().ctx.signal.aborted).toBe(true)
+    expect(source.getSnapshot()).toMatchObject({ status: 'loading', value: undefined })
+  })
+
+  it('keeps one source per address and separates addresses', () => {
+    const b = bench()
+    expect(b.registry.source(A)).toBe(b.registry.source(A))
+    expect(b.registry.source(A)).not.toBe(b.registry.source(`${RESOURCE_SCHEME}://feed/two`))
+  })
+
+  it('pins hold the address open until the signal aborts; an aborted signal pins nothing', () => {
+    const b = bench()
+    b.registry.register(b.provider)
+    const controller = new AbortController()
+    b.registry.pin(A, controller.signal)
+    expect(b.provider.open).toHaveBeenCalledTimes(1)
+    controller.abort()
+    expect(b.last().ctx.signal.aborted).toBe(true)
+
+    const aborted = new AbortController()
+    aborted.abort()
+    b.registry.pin(A, aborted.signal)
+    expect(b.provider.open).toHaveBeenCalledTimes(1)
+  })
+
+  it('hands a remounting subscriber the latest value without reopening while a pin holds it', async () => {
+    const b = bench()
+    b.registry.register(b.provider)
+    const pin = new AbortController()
+    b.registry.pin(A, pin.signal)
+    const source = b.registry.source(A)
+
+    const unsubscribe = source.subscribe(() => {})
+    b.last().push('v1')
+    b.last().push('v2')
+    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v2') })
+    unsubscribe()
+    b.last().push('v3')
+    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v3') })
+
+    const seen = vi.fn()
+    source.subscribe(seen)
+    expect(source.getSnapshot()).toMatchObject({ status: 'live', value: 'v3' })
+    expect(b.provider.open).toHaveBeenCalledTimes(1)
+    expect(seen).not.toHaveBeenCalled()
+  })
+
+  it('reopens after the last holder left, as a fresh stream', async () => {
+    const b = bench()
+    b.registry.register(b.provider)
+    const source = b.registry.source(A)
+    const first = source.subscribe(() => {})
+    b.last().push('v1')
+    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') })
+    first()
+
+    const second = source.subscribe(() => {})
+    expect(b.provider.open).toHaveBeenCalledTimes(2)
+    expect(source.getSnapshot()).toMatchObject({ status: 'loading', value: undefined })
+    b.last().push('v2')
+    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v2') })
+    second()
+  })
+})
+
+describe('ResourceRegistry streams', () => {
+  it('ignores what a released stream still yields, and returns its iterator', async () => {
+    const b = bench()
+    b.registry.register(b.provider)
+    const source = b.registry.source(A)
+    const unsubscribe = source.subscribe(() => {})
+    const feed = b.last()
+    unsubscribe()
+    expect(feed.ctx.signal.aborted).toBe(true)
+
+    feed.push('late')
+    await settle()
+    await settle()
+    expect(source.getSnapshot()).toMatchObject({ status: 'loading', value: undefined })
+    expect(feed.returned).toBe(true)
+  })
+
+  it('keeps the last value live when the stream ends on its own', async () => {
+    const b = bench()
+    b.registry.register(b.provider)
+    const source = b.registry.source(A)
+    source.subscribe(() => {})
+    b.last().push('v1')
+    b.last().end()
+    await settle()
+    await settle()
+    expect(source.getSnapshot()).toMatchObject({ status: 'live', value: 'v1' })
+  })
+
+  it('reports a failure frame beside the last value, and the next ok frame clears it', async () => {
+    const b = bench()
+    b.registry.register(b.provider)
+    const source = b.registry.source(A)
+    source.subscribe(() => {})
+    b.last().push('v1')
+    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') })
+    const failure = new RemoteError('gateway/bad-request', 'refused', {})
+    b.last().fail(failure)
+    await vi.waitFor(() => { expect(source.getSnapshot().status).toBe('failed') })
+    expect(source.getSnapshot()).toMatchObject({ value: 'v1', failure })
+    b.last().push('v2')
+    await vi.waitFor(() => { expect(source.getSnapshot().status).toBe('live') })
+    expect(source.getSnapshot()).toMatchObject({ value: 'v2', failure: undefined })
+  })
+
+  it('reports a failure frame that arrives first with no value', async () => {
+    const b = bench()
+    b.registry.register(b.provider)
+    const source = b.registry.source(A)
+    source.subscribe(() => {})
+    b.last().fail(new RemoteError('gateway/bad-request', 'refused', {}))
+    await vi.waitFor(() => { expect(source.getSnapshot().status).toBe('failed') })
+    expect(source.getSnapshot()).toMatchObject({ value: undefined, failure: { code: 'gateway/bad-request' } })
+  })
+
+  it('drops a failure frame that follows the release that aborted the stream', async () => {
+    const b = bench()
+    b.registry.register(b.provider)
+    const source = b.registry.source(A)
+    const unsubscribe = source.subscribe(() => {})
+    const feed = b.last()
+    unsubscribe()
+    feed.fail(new RemoteError('gateway/internal', 'after abort', {}))
+    await settle()
+    await settle()
+    expect(source.getSnapshot()).toMatchObject({ status: 'loading', failure: undefined })
+  })
+})
+
+describe('ResourceRegistry reload', () => {
+  it('forwards reload to the protocol\'s provider, and stays a no-op without one', () => {
+    const b = bench()
+    b.snapshot().reload()
+    b.registry.register(b.provider)
+    b.snapshot().reload()
+    expect(b.provider.reload).toHaveBeenCalledWith(A)
+
+    const bare = bench()
+    bare.registry.register({ protocol: 'feed', open: bare.provider.open })
+    expect(() => { bare.snapshot().reload() }).not.toThrow()
+  })
+
+  it('keeps one reload function per address across state changes', async () => {
+    const b = bench()
+    b.registry.register(b.provider)
+    const source = b.registry.source(A)
+    const { reload } = source.getSnapshot()
+    source.subscribe(() => {})
+    b.last().push('v1')
+    await vi.waitFor(() => { expect(source.getSnapshot().value).toBe('v1') })
+    expect(source.getSnapshot().reload).toBe(reload)
+  })
+})

+ 5 - 0
packages/client/ui-attachment/tests/message-image.client.spec.tsx

@@ -1,5 +1,6 @@
 // @vitest-environment jsdom
 
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { AttachmentId } from '@deepseek-ai/dsh-attachment'
@@ -9,6 +10,9 @@ import { ImageGallery, MessageImage } from '../src/MessageImage.tsx'
 import type { MessageImageLabels } from '../src/MessageImage.tsx'
 import { MessageImages } from '../src/client/MessageImages.tsx'
 
+// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(cleanup)
 
 const labels: MessageImageLabels = {
@@ -268,6 +272,7 @@ describe('ImageGallery', () => {
       sessionId: 'message-images-test' as MessageImagesProps['sessionId'],
       useSession,
       useSessions,
+      useResource,
       useSessionPendingInteraction,
       useWorkspaces,
       useProjection: () => undefined,

+ 39 - 32
packages/client/ui-chat/tests/apply-inject.client.spec.tsx

@@ -5,7 +5,7 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import {
-  RemoteError, SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages,
+  SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages,
 } from '@deepseek-ai/dsh-client-test-runtime'
 import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime'
 import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client'
@@ -13,7 +13,7 @@ import {
   apply as applyConversation, inject as injectConversation,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import {
-  apply as applyChat, inject as injectChat, type ChatViewInjected, type DetailsInjected,
+  apply as applyChat, inject as injectChat, type ChatViewInjected,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
 import { createChatStore } from '../src/client/stores.ts'
@@ -48,8 +48,10 @@ function sessionFakeFor() {
 async function bench() {
   const runtime = await SlotTestRuntime.create()
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
-  const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
+  const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() }
   runtime.ctx.provide('layout', layout as never)
+  const sidebarRight = { openResource: vi.fn<(address: string) => void>() }
+  runtime.ctx.provide('sidebarRight', sidebarRight as never)
   const openWorkspacePath = vi.fn<ClientRemote['session']['openWorkspacePath']>(
     () => Promise.resolve({ ok: true, value: { opened: true } }),
   )
@@ -68,7 +70,6 @@ async function bench() {
   runtime.slots.installLocale(locale)
   await runtime.root.declare({
     'conversation': { kind: 'single', scope: 'session-maybe' },
-    'details': { kind: 'single', scope: 'session' },
   }, (_props: { renderSlot?: unknown }) => null)
   await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
   await runtime.mount({ inject: [...injectChat], apply: applyChat })
@@ -83,7 +84,7 @@ async function bench() {
     ) => ChatViewInjected)(id, instance.actions)
     return { instance, injected }
   }
-  return { runtime, layout, openWorkspacePath, session, chatViewApi }
+  return { runtime, layout, openWorkspacePath, sidebarRight, session, chatViewApi }
 }
 
 describe('Chat inject API', () => {
@@ -112,28 +113,45 @@ describe('Chat inject API', () => {
     await b.runtime.dispose()
   })
 
-  it('writes Chat selection before opening details', async () => {
+  it('addresses file paths under the Session\'s scope and opens them in the right Sidebar', async () => {
     const b = await bench()
-    const { instance, injected } = b.chatViewApi(ROOT)
-    injected.openDetails({ turnSeq: 2, callId: 'c1' })
-    expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
-    expect(b.layout.openDetails).toHaveBeenCalledOnce()
-    expect(b.runtime.storeOf('details', ROOT)).toBe(instance)
-    expect(b.runtime.storeOf('conversation.session', ROOT)).not.toBe(instance)
+    const { injected } = b.chatViewApi(ROOT)
+    await injected.openFile('src/a.ts')
+    // Files stay in the product: a relative path is handed to the Sidebar as an
+    // address under this session's scope, not to a desktop opener.
+    expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/root-1/src/a.ts')
+    expect(b.openWorkspacePath).not.toHaveBeenCalled()
+
+    // An absolute path inside the session's workspace is the same session-relative address.
+    await injected.openFile('/proj/src/a.ts')
+    expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a.ts')
+
+    // A name a URL would otherwise mangle survives the round trip.
+    await injected.openFile('src/a b#c.ts')
+    expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a%20b%23c.ts')
+
+    // A line travels as the `file` type's navigation parameter, not in the address.
+    await injected.openFile('src/a.ts', { line: 7 })
+    expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/session/root-1/src/a.ts', { params: { line: 7 } })
     await b.runtime.dispose()
   })
 
-  it('resolves file paths against the Session cwd and preserves failures', async () => {
+  it('keeps a relative path under the Session without a cwd, and addresses a path outside the workspace absolutely', async () => {
     const b = await bench()
-    const { injected } = b.chatViewApi(ROOT)
+    const NO_CWD = 'root-2' as SessionId
+    await b.runtime.sessions.add({
+      id: NO_CWD,
+      summary: { title: 'N', displayTitle: 'N' },
+      session: sessionFakeFor(),
+    }, { current: false })
+    const { injected } = b.chatViewApi(NO_CWD)
+    // The Host resolves the relative path against the root it holds for the
+    // Session; the Client need not know it.
     await injected.openFile('src/a.ts')
-    expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: '/proj/src/a.ts' })
-
-    b.openWorkspacePath.mockResolvedValueOnce({
-      ok: false,
-      error: new RemoteError('gateway/internal', 'xdg-open is not available', {}),
-    })
-    await expect(injected.openFile('src/b.ts')).rejects.toThrow('path open failed: xdg-open is not available')
+    expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/root-2/src/a.ts')
+    // An absolute path outside every known root carries no Session in its address.
+    await injected.openFile('/abs/a.ts')
+    expect(b.sidebarRight.openResource).toHaveBeenLastCalledWith('dsh-resource://file/absolute/abs/a.ts')
     await b.runtime.dispose()
   })
 
@@ -149,17 +167,6 @@ describe('Chat inject API', () => {
     await b.runtime.dispose()
   })
 
-  it('closes details while sharing selection through the Chat store', async () => {
-    const b = await bench()
-    const entry = b.runtime.slots.entries('details')[0]!
-    const injected = (entry.inject as unknown as () => DetailsInjected)()
-    expect(Object.keys(injected)).toEqual(['closeDetails'])
-    injected.closeDetails()
-    expect(b.layout.closeDetails).toHaveBeenCalledOnce()
-    expect(b.runtime.storeOf('details', ROOT)).toBe(b.runtime.storeOf('conversation.view', ROOT))
-    await b.runtime.dispose()
-  })
-
   it('owns image loading, scroll memory, and optional closing-file mentions', async () => {
     const b = await bench()
     const { injected } = b.chatViewApi(ROOT)

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

@@ -41,7 +41,8 @@ async function bench() {
       ? chatSettings.scope
       : stubSettingsScope().scope,
   } as never)
-  runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() } as never)
+  runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
+  runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
   runtime.ctx.provide('uiWorkspace', {
     connectWorkspace: vi.fn(async () => SID),
   } as never)
@@ -53,7 +54,6 @@ async function bench() {
   runtime.slots.installLocale(locale)
   await runtime.root.declare({
     'conversation': { kind: 'single', scope: 'session-maybe' },
-    'details': { kind: 'single', scope: 'session' },
     'conversation.approval.detail': { kind: 'single', scope: 'session' },
     'settings.general.item': { kind: 'list', scope: 'root' },
   }, (_props: { renderSlot?: unknown }) => null)
@@ -68,12 +68,12 @@ async function bench() {
   return { runtime, conversation, chat, chatSettings, sourceDescriptor }
 }
 
-function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') {
+function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conversation.session.header' | 'conversation.view') {
   return (runtime.slots.entries(key)[0] as { store?: unknown } | undefined)?.store
 }
 
 describe('Chat apply wiring', () => {
-  it('contributes Chat View, node renderers, stats, and details', async () => {
+  it('contributes Chat View, node renderers, and stats', async () => {
     const b = await bench()
     const views = b.runtime.slots.entries('conversation.view')
     expect(views.map(row => row.options.id)).toEqual(['chat'])
@@ -84,7 +84,6 @@ describe('Chat apply wiring', () => {
       .toEqual(['stats'])
     expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id))
       .toEqual(['transcript-view', 'composer-enter'])
-    expect(b.runtime.slots.entries('details')).toHaveLength(1)
     await b.runtime.dispose()
   })
 
@@ -111,7 +110,6 @@ describe('Chat apply wiring', () => {
     const conversationStore = storeOf(b.runtime, 'conversation.session')
     const chatStore = storeOf(b.runtime, 'conversation.view')
     expect(storeOf(b.runtime, 'conversation.session.header')).toBe(conversationStore)
-    expect(storeOf(b.runtime, 'details')).toBe(chatStore)
     expect(chatStore).toBeDefined()
     expect(chatStore).not.toBe(conversationStore)
     await b.runtime.dispose()

+ 0 - 22
packages/client/ui-chat/tests/chat-store.client.spec.ts

@@ -2,28 +2,6 @@ import { describe, expect, it } from 'vitest'
 import { createChatStore } from '../src/client/stores.ts'
 
 describe('createChatStore', () => {
-  it('starts without a selected Chat target', () => {
-    const store = createChatStore().create()
-    expect(store.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] })
-  })
-
-  it('selects and clears one Chat details target', () => {
-    const store = createChatStore().create()
-    store.actions.select({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
-    expect(store.store.getSnapshot().selection)
-      .toEqual({ turnSeq: 3, callId: 'c1', toolName: 'bash' })
-    store.actions.select(null)
-    expect(store.store.getSnapshot().selection).toBeNull()
-  })
-
-  it('creates independent instances', () => {
-    const handle = createChatStore()
-    const first = handle.create()
-    const second = handle.create()
-    first.actions.select({ turnSeq: 1 })
-    expect(second.store.getSnapshot().selection).toBeNull()
-  })
-
   it('stores only manually expanded Turn-process answers', () => {
     const store = createChatStore().create()
     store.actions.setTurnProcessOpen(2, 3, true)

+ 10 - 30
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -1,12 +1,13 @@
 // @vitest-environment jsdom
 
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
 import { useEffect } from 'react'
 import type {
   AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot,
   ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
-  LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget, SteeringMessageNode,
+  LegacyConversationSlice, ModelRetryNode, RunningToolCall, SteeringMessageNode,
   ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UseChatNodeTurnData,
   TranscriptViewMode, UserMessageNode,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
@@ -43,6 +44,9 @@ import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snaps
 import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts'
 import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
 
+// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(() => {
   cleanup()
   vi.unstubAllGlobals()
@@ -245,7 +249,6 @@ function makeHarness(
   const useChatNodeProcess = bindKeyedSnapshotSelector(
     key => chatSource.source.getSnapshot().nodes.processSource(key),
   )
-  const openDetails = vi.fn<(t: SelectionTarget) => void>()
   const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
   const loadOlder = vi.fn()
   const loadThrough = vi.fn<(seq: number) => Promise<void>>().mockResolvedValue(undefined)
@@ -267,7 +270,6 @@ function makeHarness(
     callId: string
     toolName: string
     block: ToolCallBlock
-    selectedCallId: string | undefined
     openFile: ChatNodeOwnerProps['openFile']
     inspectCall: ChatNodeOwnerProps['inspectCall']
   }> = []
@@ -339,7 +341,6 @@ function makeHarness(
           callId: block.callId,
           toolName,
           block,
-          selectedCallId: nodeOwner.selectedCallId,
           openFile: nodeOwner.openFile,
           inspectCall: nodeOwner.inspectCall,
         }
@@ -371,6 +372,7 @@ function makeHarness(
     useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)),
     useTrajectory: (() => { throw new Error('unused') }),
     useSessions: emptySessions(),
+    useResource,
     useSessionPendingInteraction: bindSnapshotSelector(
       createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
     ),
@@ -392,7 +394,6 @@ function makeHarness(
     viewRequest: null,
     openView,
     completeViewRequest: () => {},
-    openDetails,
     openFile,
     loadOlder,
     loadThrough,
@@ -421,12 +422,11 @@ function makeHarness(
     }
     session.set(sessionUpdate)
   }
-  const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
   return {
     set, setSession: session.set, setChat: chatSource.set, ChatView, props,
-    openDetails, openFile, loadOlder, loadThrough, openView,
+    openFile, loadOlder, loadThrough, openView,
     setOutline: (value: unknown) => { outlineValue = value },
-    chatScroll, forkAt, setSelection, toolOwners,
+    chatScroll, forkAt, toolOwners,
     setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) },
     setNodeRenderer: (renderer: React.ComponentProps<typeof ChatNodeSeat>['renderSlot']) => {
       nodeSlotOverride = renderer
@@ -564,7 +564,7 @@ describe('ChatView', () => {
     const afterMount = railRenders
     expect(afterMount).toBeGreaterThan(0)
 
-    act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
+    act(() => { h.setTranscriptView('compact') })
 
     expect(railRenders).toBe(afterMount)
   })
@@ -2083,14 +2083,6 @@ describe('ChatView', () => {
     expect(rowRenders).toBe(afterMount)
   })
 
-  it('updates the selected call id handed to the Tool seat', () => {
-    const h = makeHarness({ nodes: [toolResult(3, 'a')] })
-    render(<h.ChatView {...h.props} />)
-    expect(h.toolOwners.at(-1)?.selectedCallId).toBeUndefined()
-    act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) })
-    expect(h.toolOwners.at(-1)?.selectedCallId).toBe('a')
-  })
-
   it('hands running calls to a live Tool group', () => {
     const h = makeHarness({ runningCalls: [runningCall('r1')] }, { running: true })
     const view = render(<h.ChatView {...h.props} />)
@@ -2183,7 +2175,7 @@ describe('ChatView', () => {
     expect(calls).toHaveLength(1)
     expect(calls[0]).toMatchObject({
       key: 'conversation.chat.node',
-      owner: { node: { kind: 'tool-call' }, selectedCallId: undefined },
+      owner: { node: { kind: 'tool-call' } },
       entryKey: 'tool-call',
     })
     const owner = calls[0]?.owner as RoutedChatNodeOwner
@@ -2243,18 +2235,6 @@ describe('ChatView', () => {
     })
   })
 
-  it('names a workspace-folder Host refusal as a folder', async () => {
-    const openFile = vi.fn<(path: string) => Promise<void>>()
-      .mockRejectedValueOnce(new Error(''))
-    const h = makeHarness({ nodes: [toolResult(3, 'a')] })
-    h.props.openFile = openFile
-    render(<h.ChatView {...h.props} />)
-    await act(async () => { h.toolOwners[0]!.openFile('.') })
-    await waitFor(() => {
-      expect(screen.getByRole('dialog', { name: '无法打开文件夹' }).textContent).toContain('无法打开此文件夹')
-    })
-  })
-
   it('ignores a Host refusal that settles after the dialog is dismissed', async () => {
     let rejectRetry!: (error: unknown) => void
     const openFile = vi.fn<(path: string) => Promise<void>>()

+ 0 - 164
packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx

@@ -3,23 +3,9 @@
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
 import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import type {
-  SessionListState, SessionSnapshot,
-} from '@deepseek-ai/dsh-api-session-controller/client'
-import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
-import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client'
-import type {
-  DetailsSlotProps, DetailsToolOwnerProps, RunningToolCall, SelectionTarget,
-} from '@deepseek-ai/dsh-client-ui-chat/client'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import { createChatStore } from '../src/client/stores.ts'
 import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
 import { StatsLine } from '../src/client/chat/StatsLine.tsx'
-import { DetailsPanel } from '../src/client/details/DetailsPanel.tsx'
 import { zh } from '../src/client/locale.ts'
 import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
 
@@ -39,44 +25,6 @@ afterEach(() => {
   vi.unstubAllGlobals()
 })
 
-const SID = 's1' as SessionId
-
-/** Minimal framework seat for direct DetailsPanel host tests. */
-const SessionProviderStub: SessionProviderComponent = ({ children }) => children
-
-/** Observe the owner currency without importing the Tool details renderer. */
-function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotProps['renderSlot'] {
-  return (_key, owner) => {
-    owners?.push(owner as unknown as DetailsToolOwnerProps)
-    return <div data-testid="tool-details-seat" />
-  }
-}
-
-function sessionSnapshot(): SessionSnapshot {
-  return {
-    sessionId: SID,
-    queue: [],
-    pendingSubmissions: [],
-    running: false,
-    removed: false,
-    openState: 'open',
-    openError: null,
-    hasMore: false,
-    loadingOlder: false,
-    promptError: null,
-    blank: false,
-    subagent: null,
-    lastAgentError: null,
-    promptAttempted: true,
-    awaitingFirstTurn: false,
-  }
-}
-
-function emptyWorkspaces() {
-  return createSnapshotStore<WorkspaceSnapshot>({
-    items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
-  })
-}
 
 describe('render branch tails', () => {
   it('AssistantMarkdown reasoning row is ok-state when not the streaming tail', () => {
@@ -126,116 +74,4 @@ describe('render branch tails', () => {
     expect(view.container.querySelector('[data-state="running"]')).not.toBeNull()
   })
 
-  it('DetailsPanel title falls to 详情 when the selection has no toolName and no material', () => {
-    localStorage.clear()
-    const session = sessionSnapshot()
-    const chatSnapshot = chatSnapshotFixture()
-    const chat = createChatStore().create()
-    chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget)
-    const emptyList = createSnapshotStore<SessionListState>(
-      { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
-    const workspaces = emptyWorkspaces()
-    const view = render(
-      <DetailsPanel
-        SessionProvider={SessionProviderStub}
-        renderSlot={renderToolDetailsProbe()}
-        sessionId={SID}
-        useSession={bindSnapshotSelector(createSnapshotStore(session))}
-        useChat={bindSnapshotSelector(createSnapshotStore(chatSnapshot))}
-        useConversation={bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT))}
-        useTrajectory={(() => { throw new Error('unused') })}
-        useSessions={bindSnapshotSelector(emptyList)}
-        useSessionPendingInteraction={bindSnapshotSelector(
-          createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
-        )}
-        useWorkspaces={bindSnapshotSelector(workspaces)}
-        useProjection={(() => undefined)}
-        useInput={(() => { throw new Error('unused') })}
-        inputActions={{
-          setDraft: () => {},
-          addAttachments: () => true,
-          removeAttachment: () => {},
-          pruneAttachments: () => {},
-          submit: () => {},
-        }}
-        useStore={bindSnapshotSelector(chat)}
-        actions={chat.actions}
-        closeDetails={vi.fn()}
-        t={t}
-      />,
-    )
-    expect(view.getByText('详情')).toBeTruthy()
-    expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
-  })
-
-  it('DetailsPanel passes the existing parentCallId through to the Tool details seat', () => {
-    localStorage.clear()
-    const session = sessionSnapshot()
-    const longText = 'x'.repeat(1_000)
-    const runningCalls: readonly RunningToolCall[] = [{
-      callId: 'p1', name: 'run_code', argsRaw: '{}', turn: 1, step: 1,
-      time: 7_000, subCalls: [{
-        kind: 'tool-result', seq: 8, time: 8_000, callId: 'p1:code:1',
-        parentCallId: 'p1',
-        call: { name: 'run_code', argsRaw: '{"code":"return 1"}' },
-        callTime: 8_000,
-        content: [], isError: false,
-        subCalls: [{
-          kind: 'tool-result', seq: 9, time: 9_000, callId: 'p1:code:1:code:1',
-          parentCallId: 'p1:code:1',
-          call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
-          callTime: 8_500,
-          content: [{ type: 'text', text: longText }], isError: false,
-          subCalls: [],
-        }],
-      }],
-    }]
-    const chatSnapshot = chatSnapshotFixture({ runningCalls })
-    const chat = createChatStore().create()
-    chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget)
-    const emptyList = createSnapshotStore<SessionListState>(
-      { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined })
-    const workspaces = emptyWorkspaces()
-    const owners: DetailsToolOwnerProps[] = []
-    const view = render(
-      <DetailsPanel
-        SessionProvider={SessionProviderStub}
-        renderSlot={renderToolDetailsProbe(owners)}
-        sessionId={SID}
-        useSession={bindSnapshotSelector(createSnapshotStore(session))}
-        useChat={bindSnapshotSelector(createSnapshotStore(chatSnapshot))}
-        useConversation={bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT))}
-        useTrajectory={(() => { throw new Error('unused') })}
-        useSessions={bindSnapshotSelector(emptyList)}
-        useSessionPendingInteraction={bindSnapshotSelector(
-          createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
-        )}
-        useWorkspaces={bindSnapshotSelector(workspaces)}
-        useProjection={(() => undefined)}
-        useInput={(() => { throw new Error('unused') })}
-        inputActions={{
-          setDraft: () => {},
-          addAttachments: () => true,
-          removeAttachment: () => {},
-          pruneAttachments: () => {},
-          submit: () => {},
-        }}
-        useStore={bindSnapshotSelector(chat)}
-        actions={chat.actions}
-        closeDetails={vi.fn()}
-        t={t}
-      />,
-    )
-    // Chat resolves the selected sub-call and keeps its Code Dispatch parent
-    // identity on the block handed to the Tool-owned details seat.
-    expect(view.getByText('read')).toBeTruthy()
-    expect(view.getByTestId('tool-details-seat')).toBeTruthy()
-    expect(owners).toHaveLength(1)
-    expect(owners[0]?.block).toMatchObject({
-      callId: 'p1:code:1:code:1',
-      parentCallId: 'p1:code:1',
-      call: { name: 'read', argsRaw: '{"path":"notes/demo.txt"}' },
-      content: [{ type: 'text', text: longText }],
-    })
-  })
 })

+ 0 - 79
packages/client/ui-chat/tests/selection-survival.client.spec.tsx

@@ -1,79 +0,0 @@
-// @vitest-environment jsdom
-/** Exercises Chat selection through the real SlotRegistry store axis. */
-import { describe, expect, it } from 'vitest'
-import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
-import { createChatStore } from '../src/client/stores.ts'
-
-const sid = (value: string): SessionId => value as SessionId
-
-type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
-
-async function createBench() {
-  const runtime = await SlotTestRuntime.create()
-  const chat = createChatStore()
-  await runtime.root.declare({
-    'conversation.view': { kind: 'list', scope: 'session' },
-    'details': { kind: 'single', scope: 'session' },
-  }, (_props: PropsRenderSlots<'conversation.view' | 'details'>) => null)
-  runtime.slots.register({ name: 'conversation.view', id: 'chat', store: chat }, () => null)
-  runtime.slots.register({ name: 'details', store: chat }, () => null)
-  runtime.renderRoot()
-  return { runtime }
-}
-
-function storeFor(
-  current: Awaited<ReturnType<typeof createBench>>,
-  slot: 'conversation.view' | 'details',
-  sessionId: SessionId,
-): ChatInstance {
-  return current.runtime.storeOf(slot, sessionId) as ChatInstance
-}
-
-describe('Chat selection survives on its store seat', () => {
-  it('shares one instance between the Chat View and details panel', async () => {
-    const b = await createBench()
-    await b.runtime.sessions.add({ id: 's1' })
-    const chat = storeFor(b, 'conversation.view', sid('s1'))
-    const details = storeFor(b, 'details', sid('s1'))
-    chat.actions.select({ turnSeq: 3, callId: 'c1' })
-
-    expect(details).toBe(chat)
-    expect(details.store.getSnapshot().selection).toEqual({ turnSeq: 3, callId: 'c1' })
-    await b.runtime.dispose()
-  })
-
-  it('isolates Session instances and preserves identity across list projection updates', async () => {
-    const b = await createBench()
-    const oneId = sid('s1')
-    await b.runtime.sessions.add({ id: 's1' })
-    await b.runtime.sessions.add({ id: 's2' })
-    const one = storeFor(b, 'conversation.view', oneId)
-    const two = storeFor(b, 'conversation.view', sid('s2'))
-    one.actions.select({ turnSeq: 1, callId: 'a' })
-    two.actions.select({ turnSeq: 9, callId: 'z' })
-
-    await b.runtime.sessions.updateSummary(oneId, { displayTitle: 'projected' })
-
-    expect(storeFor(b, 'conversation.view', oneId)).toBe(one)
-    expect(one.store.getSnapshot().selection).toEqual({ turnSeq: 1, callId: 'a' })
-    expect(two.store.getSnapshot().selection).toEqual({ turnSeq: 9, callId: 'z' })
-    await b.runtime.dispose()
-  })
-
-  it('buries selection with the Session scope', async () => {
-    const b = await createBench()
-    await b.runtime.sessions.add({ id: 's1' })
-    const doomed = storeFor(b, 'conversation.view', sid('s1'))
-    doomed.actions.select({ turnSeq: 1 })
-
-    await b.runtime.sessions.remove('s1')
-
-    await b.runtime.sessions.add({ id: 's1' })
-    const reborn = storeFor(b, 'conversation.view', sid('s1'))
-    expect(reborn).not.toBe(doomed)
-    expect(reborn.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] })
-    await b.runtime.dispose()
-  })
-})

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

@@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx'
@@ -27,6 +28,9 @@ function noPendingInteraction() {
   return bindSnapshotSelector(createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()))
 }
 
+// The resource hook the resources plugin merges into GlobalStandardProps; this row reads no address.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 function mount(mode: 'normal' | 'compact' = 'compact') {
   const source = createSnapshotStore(mode)
   const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) })
@@ -34,6 +38,7 @@ function mount(mode: 'normal' | 'compact' = 'compact') {
     useSessions: emptySessions(),
     useSessionPendingInteraction: noPendingInteraction(),
     useWorkspaces: emptyWorkspaces(),
+    useResource,
     useTranscriptView: bindSnapshotSelector(source),
     setTranscriptView,
     t: makeTranslate(en),

+ 2 - 4
packages/client/ui-chat/tests/views-type-chain.client.spec.tsx

@@ -9,10 +9,8 @@ describe('Chat View type chain', () => {
       base: ConvViewProps,
       chat: ChatViewSlotProps,
     ): ReactNode => {
-      // @ts-expect-error openDetails belongs to the Chat inject face.
-      void base.openDetails
-      // @ts-expect-error openDetails accepts a SelectionTarget.
-      chat.openDetails('nope')
+      // @ts-expect-error openFile belongs to the Chat inject face.
+      void base.openFile
       // @ts-expect-error openFile accepts a path.
       void chat.openFile({ turnSeq: 1, callId: 'c' })
       return null

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

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -11,6 +12,9 @@ import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehavior
 import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.ts'
 import { en } from '../src/client/locales.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(() => {
   cleanup()
   localStorage.clear()
@@ -38,6 +42,7 @@ function mount() {
   const props: EnterBehaviorRowProps = {
     useSessions: emptySessions(),
     useSessionPendingInteraction: noPendingInteraction(),
+    useResource,
     useWorkspaces: emptyWorkspaces(),
     useBusyEnter: bindSnapshotSelector(policy.busyEnter),
     setBusyEnter,

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

@@ -8,6 +8,7 @@
 // root listener routes them through the keymap commands); draft writes drive
 // the shell (jsdom's beforeinput lacks the ranges Lexical needs).
 
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import { $getRoot, $isTextNode } from 'lexical'
@@ -31,6 +32,9 @@ import { InputBar } from '../src/client/skeleton/InputBar.tsx'
 import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import { zh } from '../src/client/locales.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(cleanup)
 
 // jsdom implements no Range geometry at all — `Range.prototype.getBoundingClientRect`
@@ -162,6 +166,7 @@ function bench(over?: BenchOptions) {
     useSession: bindSnapshotSelector(session),
     useConversation: bindSnapshotSelector(createSnapshotStore(conversationFixture())),
     useSessionPendingInteraction: bindSnapshotSelector(createSnapshotStore(new Map())),
+    useResource,
     useSessions: bindSnapshotSelector(createSnapshotStore<SessionListState>({
       ids: [], byId: {}, current: undefined, phase: 'ready',
       subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,

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

@@ -5,6 +5,7 @@
  * hint / pending), edit freedom, and the published currency's claim seat.
  * React over jsdom per the client testing discipline; the machine is real.
  */
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import type { Context } from '@deepseek-ai/cordis'
@@ -24,6 +25,9 @@ import { InputBar } from '../src/client/skeleton/InputBar.tsx'
 import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import { zh } from '../src/client/locales.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 // jsdom implements no Range geometry (Lexical's scroll-into-view measures the
 // caret with one once the surface is genuinely contenteditable).
 Range.prototype.getBoundingClientRect = () => ({
@@ -54,6 +58,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
     useSessionPendingInteraction: bindSnapshotSelector(
       createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
     ),
+    useResource,
     useWorkspaces: bindSnapshotSelector(createSnapshotStore({
       items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
       baselinesReady: true, recentWorkspaceId: undefined,

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

@@ -8,6 +8,7 @@
  * itself is not a dependency of this package; the source below is the
  * decision-table contract at the `InputTriggerSource` boundary.
  */
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -31,6 +32,9 @@ import { InputBar } from '../src/client/skeleton/InputBar.tsx'
 import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import { zh } from '../src/client/locales.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 // jsdom implements no Range geometry (Lexical's scroll-into-view measures the
 // caret with one once the surface is genuinely contenteditable).
 Range.prototype.getBoundingClientRect = () => ({
@@ -143,6 +147,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
     useSessionPendingInteraction: bindSnapshotSelector(
       createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
     ),
+    useResource,
     useWorkspaces: bindSnapshotSelector(createSnapshotStore({
       items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
       baselinesReady: true, recentWorkspaceId: undefined,

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

@@ -3,6 +3,7 @@
  * QueueDock rendering and operations: authoritative rows, inline editing,
  * collapse state, removal, QueueDock Steer, failure notices, and live retirement.
  */
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { useSyncExternalStore } from 'react'
@@ -22,8 +23,12 @@ import type { InputState } from '../src/client/contract/input.ts'
 import { zh } from '../src/client/locales.ts'
 import { QueueDock, queueDockEntry, type QueueDockInjected, type QueueDockProps } from '../src/client/queue/QueueDock.tsx'
 
+// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(cleanup)
 
+
 const SID = 's1' as SessionId
 const iid = (id: string): QueueItemId => id as QueueItemId
 
@@ -74,6 +79,7 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial<QueueDockInjected>
     sessionId: SID,
     t,
     useSessions: (() => { throw new Error('unused') }) as unknown as SnapshotSelectorHook<SessionListState>,
+    useResource,
     useSessionPendingInteraction: bindSnapshotSelector(
       createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
     ),

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

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import type { ComponentProps, ReactNode } from 'react'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
@@ -32,12 +33,16 @@ import type {
 } from '../src/client/contract/slots.ts'
 import type { ViewTab } from '../src/client/contract/views.ts'
 
+// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 // jsdom implements no Range geometry (Lexical's scroll-into-view measures the
 // caret with one once the surface is genuinely contenteditable).
 Range.prototype.getBoundingClientRect = () => ({
   top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}),
 })
 
+
 function fakeWiring() {
   const sink = vi.fn(() => Promise.resolve({ kind: 'success' as const }))
   const shell = new SessionInputShell({ actx: {} as Context, defaultSink: sink, commandAttachments: { serialize: () => Promise.resolve([]), release: () => {}, unsupportedNotice: (token: string) => `${token.trim()} attachments-unsupported` } })
@@ -194,6 +199,7 @@ function mount(
           useChat={useChat}
           useTrajectory={useTrajectory}
           useSessions={props.useSessions}
+          useResource={useResource}
           useSessionPendingInteraction={useSessionPendingInteraction}
           useWorkspaces={props.useWorkspaces}
           useProjection={(() => undefined)}
@@ -219,6 +225,7 @@ function mount(
           useChat={useChat}
           useTrajectory={useTrajectory}
           useSessions={props.useSessions}
+          useResource={useResource}
           useSessionPendingInteraction={useSessionPendingInteraction}
           useWorkspaces={props.useWorkspaces}
           useProjection={(() => undefined)}
@@ -240,6 +247,7 @@ function mount(
         <InputBar
           sessionId={SID}
           SessionProvider={({ children }) => children}
+          useResource={useResource}
           useSession={useSession}
           useConversation={useConversation}
           useSessions={props.useSessions}
@@ -293,6 +301,7 @@ function mount(
     useConversation,
     useSessions: bindSnapshotSelector(sessions),
     useSessionPendingInteraction,
+    useResource,
     useWorkspaces: bindSnapshotSelector(workspaces),
     useProjection: (() => undefined),
     useComposerBlock: select => select(options.composerBlock),
@@ -437,6 +446,7 @@ describe('ConversationRoot resident composer', () => {
     expect(b.slotCalls).toContain('conversation.session.header.lineage')
     expect(b.slotCalls).toContain('conversation.session.header.actions')
     expect(b.slotCalls).toContain('conversation.session.header.utilities')
+    expect(b.slotCalls).toContain('conversation.session.header.corner')
   })
 
   it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => {

+ 21 - 86
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

@@ -20,8 +20,8 @@ import type {
 import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
 import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
-import { makeTranslate, RemoteError, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
-import { ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps } from '../src/client/ProducedFiles.tsx'
+import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
+import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
 import {
   basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
   type DeliverablesTurnData,
@@ -405,24 +405,12 @@ describe('produced-file Turn data', () => {
 
 describe('ProducedFiles row', () => {
   const t = makeTranslate(zh)
-  const capability = (
-    canOpenPath: boolean | undefined,
-    isLoopback = true,
-  ): Pick<ProducedFilesProps, 'isLoopback' | 'ensureWorkspacePathOpen' | 'useWorkspacePathOpen'> => {
-    return {
-      isLoopback,
-      ensureWorkspacePathOpen: () => {},
-      useWorkspacePathOpen: selector => selector(canOpenPath),
-    }
-  }
 
-  it('renders the bounded CSS candidates and opens a file or the workspace folder', () => {
+  it('renders the bounded chips and opens the file it was clicked for', () => {
     const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts', 'h.ts']
     const openFile = vi.fn<(path: string) => void>()
 
-    const view = render(
-      <ProducedFiles matched={paths} openFile={openFile} {...capability(true)} t={t} />,
-    )
+    const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
     expect(view.getByText('产物')).toBeTruthy()
     const row = view.container.querySelector('[data-produced-files-row]')
     if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
@@ -433,24 +421,27 @@ describe('ProducedFiles row', () => {
     expect(chip.getAttribute('title')).toBe('deep/a.html')
     expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull()
     fireEvent.click(chip)
+    // The row hands over the path it was given; where it opens is the
+    // Sidebar's decision, not this row's.
     expect(openFile).toHaveBeenCalledWith('deep/a.html')
+  })
 
-    const showFolder = view.getByRole('button', { name: '在文件夹中显示' })
-    fireEvent.click(showFolder)
-    expect(openFile).toHaveBeenLastCalledWith('.')
+  it('renders a remainder counter after every chip but the last when every file fits', () => {
+    const view = render(<ProducedFiles matched={['a.md', 'b.md', 'c.md']} openFile={() => {}} t={t} />)
+    const row = view.container.querySelector('[data-produced-files-row]')
+    if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
+    expect(within(row).getAllByRole('button')).toHaveLength(3)
+    // One counter per chip that could be the last visible one; the final chip hides nothing.
+    expect([...row.querySelectorAll('[data-shown]')].map(node => node.getAttribute('data-shown'))).toEqual(['1', '2'])
   })
 
-  it('keeps the folder action absent without overflow or a local native opener', () => {
+  it('offers no folder action, because a directory has no preview to open', () => {
     const openFile = vi.fn<(path: string) => void>()
-    const view = render(
-      <ProducedFiles matched={['a.md']} openFile={openFile} {...capability(true)} t={t} />,
-    )
     const overflowing = ['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']
+    const view = render(<ProducedFiles matched={overflowing} openFile={openFile} t={t} />)
     expect(view.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
-    for (const unavailable of [capability(false), capability(true, false), capability(undefined)]) {
-      view.rerender(<ProducedFiles matched={overflowing} openFile={openFile} {...unavailable} t={t} />)
-      expect(view.queryByRole('button', { name: '在文件夹中显示' })).toBeNull()
-    }
+    // Nothing in the row reaches the local machine any more.
+    expect(openFile).not.toHaveBeenCalled()
   })
 
   it('uses singular English copy when exactly one file is hidden', () => {
@@ -458,7 +449,6 @@ describe('ProducedFiles row', () => {
       <ProducedFiles
         matched={['a.md', 'b.md', 'c.md', 'd.md', 'e.md', 'f.md', 'g.md']}
         openFile={() => {}}
-        {...capability(false)}
         t={makeTranslate(en)}
       />,
     )
@@ -523,16 +513,9 @@ describe('plugin registration', () => {
     await fiber.await()
     const [entry] = ctx.slots.entries('conversation.chat.turnTail')
     expect(entry).toBeDefined()
-    const injected = entry?.inject?.() as unknown as ProducedFilesInjected
-    expect(injected.isLoopback).toBe(false)
-    expect(typeof injected.ensureWorkspacePathOpen).toBe('function')
-    expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined()
-    ctx.emit('connection/reset')
-    injected.ensureWorkspacePathOpen()
-    await vi.waitFor(() => {
-      expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true)
-    })
-    injected.ensureWorkspacePathOpen()
+    // The row needs no injected Host capability: it hands a path to its owner
+    // and nothing in it reaches the local machine.
+    expect(entry?.inject).toBeUndefined()
 
     // The prose face is live while the plugin is: a produced turn yields a
     // resolver whose matches open through the owner-supplied opener.
@@ -554,52 +537,4 @@ describe('plugin registration', () => {
     // Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
     expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
   })
-
-  it('queries the workspace opener lazily and replaces stale results after reconnect', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SlotRegistry).await()
-    new UiConversation(ctx, { binding: () => undefined } as never)
-    ctx.slots.register({
-      name: 'root',
-      children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
-    } as never, () => null)
-    const first = Promise.withResolvers<{ ok: true; value: boolean }>()
-    const second = Promise.withResolvers<{ ok: true; value: boolean }>()
-    const staleFailure = Promise.withResolvers<{ ok: false; error: RemoteError }>()
-    const capability = vi.fn()
-      .mockReturnValueOnce(first.promise)
-      .mockReturnValueOnce(second.promise)
-      .mockReturnValueOnce(staleFailure.promise)
-      .mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'offline', {}) })
-    const session = { canOpenWorkspacePath: capability }
-    ctx.provide('remote', {
-      $on: () => () => {},
-      $host: { home: undefined, isLoopback: true },
-      session,
-    } as never)
-    ctx.provide('remote.session', session as never)
-    ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
-    await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
-    const fiber = ctx.plugin({ inject: [...inject], apply })
-    await fiber.await()
-    const entry = ctx.slots.entries('conversation.chat.turnTail')[0]
-    const injected = entry?.inject?.() as unknown as ProducedFilesInjected
-
-    injected.ensureWorkspacePathOpen()
-    injected.ensureWorkspacePathOpen()
-    expect(capability).toHaveBeenCalledOnce()
-    ctx.emit('connection/reset')
-    expect(capability).toHaveBeenCalledTimes(2)
-    first.resolve({ ok: true, value: false })
-    await Promise.resolve()
-    expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined()
-    second.resolve({ ok: true, value: true })
-    await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) })
-
-    ctx.emit('connection/reset')
-    ctx.emit('connection/reset')
-    staleFailure.resolve({ ok: false, error: new RemoteError('gateway/internal', 'stale offline', {}) })
-    await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(false) })
-    await fiber.dispose()
-  })
 })

+ 629 - 0
packages/client/ui-dockkit/tests/apply.client.spec.ts

@@ -0,0 +1,629 @@
+/**
+ * Model and operation-engine behavior: what each operation does to the tree, and
+ * that applying its inverse returns the exact state it started from.
+ */
+import { describe, expect, it } from 'vitest'
+import { applyOp, replay } from '../src/engine/operations.ts'
+import { createIdMinter, createInitialState } from '../src/engine/initial.ts'
+import { asPane, asSplit, asTab, childAt, fileTab, firstTab, seedTab } from './fixtures.client.ts'
+import {
+  assertNever, dockPaneIds, findParent, findTabPane, firstDockPaneId, floatIndex, floatRect, getNode, getPane, getSplit,
+  getTab, normalizeSizes, onlyTabId, replaceInParent, topRightPaneId,
+} from '../src/engine/tree.ts'
+import type { IdMinter } from '../src/engine/initial.ts'
+import type { LayoutOp, LayoutState, PaneId, PaneNode, TabId } from '../src/contract/types.ts'
+
+interface Fixture {
+  readonly state: LayoutState
+  readonly minter: IdMinter
+  readonly paneId: PaneId
+  readonly guideTabId: TabId
+}
+
+function fixture(): Fixture {
+  const minter = createIdMinter()
+  const state = createInitialState(minter, seedTab)
+  const paneId = getPane(state, state.rootId).id
+  const guideTabId = getPane(state, paneId).tabs[0]
+  if (guideTabId === undefined) throw new Error('fixture: initial pane has no guide tab')
+  return { state, minter, paneId, guideTabId }
+}
+
+/** Apply `op`, then its inverse, and require the original state back. */
+function expectRoundTrip(state: LayoutState, op: LayoutOp): LayoutState {
+  const result = applyOp(state, op)
+  const back = result.inverse.reduce((current, inverse) => applyOp(current, inverse).state, result.state)
+  expect(back).toEqual(state)
+  return result.state
+}
+
+/** Split `paneId` to the right, returning the state and the new pane id. */
+function split(
+  state: LayoutState,
+  minter: IdMinter,
+  paneId: PaneId,
+  axis: 'row' | 'column' = 'row',
+  direction: 'before' | 'after' = 'after',
+): { state: LayoutState; newPaneId: PaneId } {
+  const newPaneId = minter.next('pane')
+  const op: LayoutOp = { type: 'split', paneId, axis, direction, newPaneId, newSplitId: minter.next('split') }
+  return { state: expectRoundTrip(state, op), newPaneId }
+}
+
+describe('initial state', () => {
+  it('starts collapsed with one docked pane holding an active guide tab', () => {
+    const { state, paneId, guideTabId } = fixture()
+    expect(state.expanded).toBe(false)
+    expect(dockPaneIds(state)).toEqual([paneId])
+    expect(state.floats).toEqual([])
+    expect(state.activePaneId).toBe(paneId)
+    const pane = getPane(state, paneId)
+    expect(pane.tabs).toEqual([guideTabId])
+    expect(pane.activeTabId).toBe(guideTabId)
+    expect(state.tabs[guideTabId]?.kind).toBe('seed')
+  })
+})
+
+describe('split', () => {
+  it('wraps the reference pane in a new split and adds an empty sibling', () => {
+    const { state, minter, paneId } = fixture()
+    const after = split(state, minter, paneId).state
+    const root = getSplit(after, after.rootId)
+    expect(root.axis).toBe('row')
+    expect(root.children).toHaveLength(2)
+    expect(root.children[0]).toBe(paneId)
+    expect(root.sizes).toEqual([0.5, 0.5])
+    expect(dockPaneIds(after)).toHaveLength(2)
+    const sibling = getPane(after, childAt(root, 1))
+    expect(sibling.tabs).toEqual([])
+    expect(sibling.activeTabId).toBeUndefined()
+  })
+
+  it('puts the new pane before the reference pane when asked', () => {
+    const { state, minter, paneId } = fixture()
+    const after = split(state, minter, paneId, 'row', 'before').state
+    expect(getSplit(after, after.rootId).children[1]).toBe(paneId)
+  })
+
+  it('joins an existing split of the same axis instead of nesting', () => {
+    const { state, minter, paneId } = fixture()
+    const first = split(state, minter, paneId)
+    const second = split(first.state, minter, paneId)
+    const root = getSplit(second.state, second.state.rootId)
+    expect(root.children).toHaveLength(3)
+    expect(root.sizes).toEqual([0.25, 0.25, 0.5])
+    expect(dockPaneIds(second.state)).toHaveLength(3)
+  })
+
+  it('joins an existing split before the reference pane when asked', () => {
+    const { state, minter, paneId } = fixture()
+    const first = split(state, minter, paneId)
+    const second = split(first.state, minter, paneId, 'row', 'before')
+    const root = getSplit(second.state, second.state.rootId)
+    expect(root.children).toEqual([second.newPaneId, paneId, first.newPaneId])
+    expect(root.sizes).toEqual([0.25, 0.25, 0.5])
+  })
+
+  it('nests when the parent split runs on the other axis', () => {
+    const { state, minter, paneId } = fixture()
+    const first = split(state, minter, paneId)
+    const second = split(first.state, minter, first.newPaneId, 'column')
+    const root = getSplit(second.state, second.state.rootId)
+    expect(root.axis).toBe('row')
+    expect(root.children).toHaveLength(2)
+    const nested = getSplit(second.state, childAt(root, 1))
+    expect(nested.axis).toBe('column')
+    expect(nested.children).toEqual([first.newPaneId, second.newPaneId])
+  })
+
+  it('refuses an unknown pane and a floating pane', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    expect(() => applyOp(state, {
+      type: 'split', paneId: asPane('nope'), axis: 'row', direction: 'after', newPaneId: asPane('p'), newSplitId: asSplit('s'),
+    })).toThrow(/unknown node/)
+    const floatId = minter.next('float')
+    const floated = applyOp(state, {
+      type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
+    }).state
+    expect(() => applyOp(floated, {
+      type: 'split', paneId: floatId, axis: 'row', direction: 'after', newPaneId: asPane('p'), newSplitId: asSplit('s'),
+    })).toThrow(/docked pane/)
+    expect(paneId).toBeDefined()
+  })
+})
+
+describe('merge', () => {
+  it('collapses a two-child split back into its surviving pane', () => {
+    const { state, minter, paneId } = fixture()
+    const after = split(state, minter, paneId)
+    const merged = expectRoundTrip(after.state, { type: 'merge', paneId: after.newPaneId })
+    expect(merged.rootId).toBe(paneId)
+    expect(dockPaneIds(merged)).toEqual([paneId])
+    expect(merged.nodes[after.newPaneId]).toBeUndefined()
+  })
+
+  it('drops one child of a wider split and renormalizes the rest', () => {
+    const { state, minter, paneId } = fixture()
+    const first = split(state, minter, paneId)
+    const second = split(first.state, minter, paneId)
+    const merged = expectRoundTrip(second.state, { type: 'merge', paneId: second.newPaneId })
+    const root = getSplit(merged, merged.rootId)
+    expect(root.children).toHaveLength(2)
+    expect(root.sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1)
+  })
+
+  it('refuses a non-empty pane and the docked root pane', () => {
+    const { state, minter, paneId } = fixture()
+    expect(() => applyOp(state, { type: 'merge', paneId })).toThrow(/empty pane/)
+    const after = split(state, minter, paneId)
+    expect(() => applyOp(after.state, { type: 'merge', paneId: asPane(after.newPaneId + 'x') })).toThrow(/unknown node/)
+    const emptied = applyOp(after.state, { type: 'closeTab', tabId: firstTab(getPane(after.state, paneId)) }).state
+    const collapsed = applyOp(emptied, { type: 'merge', paneId }).state
+    expect(() => applyOp(collapsed, { type: 'merge', paneId: after.newPaneId })).toThrow(/root pane/)
+  })
+
+  it('reseats focus when the merged pane held it', () => {
+    const { state, minter, paneId } = fixture()
+    const after = split(state, minter, paneId)
+    const focused = applyOp(after.state, { type: 'focusPane', paneId: after.newPaneId }).state
+    expect(focused.activePaneId).toBe(after.newPaneId)
+    const merged = expectRoundTrip(focused, { type: 'merge', paneId: after.newPaneId })
+    expect(merged.activePaneId).toBe(paneId)
+  })
+})
+
+describe('tabs', () => {
+  it('opens a tab into a pane and focuses it', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    const after = expectRoundTrip(state, { type: 'openTab', paneId, tab, index: 0 })
+    const pane = getPane(after, paneId)
+    expect(pane.tabs).toEqual([tab.id, guideTabId])
+    expect(pane.activeTabId).toBe(tab.id)
+    expect(after.activePaneId).toBe(paneId)
+  })
+
+  it('refuses to reopen a live tab id', () => {
+    const { state, paneId, guideTabId } = fixture()
+    const clash = { id: guideTabId, kind: 'guide' as const, contentId: 'x', title: 'x' }
+    expect(() => applyOp(state, { type: 'openTab', paneId, tab: clash, index: 0 })).toThrow(/already exists/)
+  })
+
+  it('closes the active tab onto its previous neighbour', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const first = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    const second = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/b.txt', 'b.txt')
+    let current = applyOp(state, { type: 'openTab', paneId, tab: first, index: 1 }).state
+    current = applyOp(current, { type: 'openTab', paneId, tab: second, index: 2 }).state
+    expect(getPane(current, paneId).tabs).toEqual([guideTabId, first.id, second.id])
+    const after = expectRoundTrip(current, { type: 'closeTab', tabId: second.id })
+    const pane = getPane(after, paneId)
+    expect(pane.tabs).toEqual([guideTabId, first.id])
+    expect(pane.activeTabId).toBe(first.id)
+    expect(after.tabs[second.id]).toBeUndefined()
+  })
+
+  it('closes a tab that is not the active one without moving focus', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
+    const after = expectRoundTrip(opened, { type: 'closeTab', tabId: guideTabId })
+    expect(getPane(after, paneId).tabs).toEqual([tab.id])
+    expect(getPane(after, paneId).activeTabId).toBe(tab.id)
+  })
+
+  it('leaves an empty pane with no active tab', () => {
+    const { state, paneId, guideTabId } = fixture()
+    const after = expectRoundTrip(state, { type: 'closeTab', tabId: guideTabId })
+    const pane = getPane(after, paneId)
+    expect(pane.tabs).toEqual([])
+    expect(pane.activeTabId).toBeUndefined()
+  })
+
+  it('moves a tab across panes and focuses the destination', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const after = split(state, minter, paneId)
+    const moved = expectRoundTrip(after.state, { type: 'moveTab', tabId: guideTabId, toPaneId: after.newPaneId, index: 0 })
+    expect(getPane(moved, paneId).tabs).toEqual([])
+    expect(getPane(moved, after.newPaneId).tabs).toEqual([guideTabId])
+    expect(moved.activePaneId).toBe(after.newPaneId)
+  })
+
+  it('moves a tab that is not the active one, leaving the source focus alone', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
+    const after = split(opened, minter, paneId)
+    const moved = expectRoundTrip(after.state, { type: 'moveTab', tabId: guideTabId, toPaneId: after.newPaneId, index: 0 })
+    expect(getPane(moved, paneId).tabs).toEqual([tab.id])
+    expect(getPane(moved, paneId).activeTabId).toBe(tab.id)
+    expect(getPane(moved, after.newPaneId).activeTabId).toBe(guideTabId)
+  })
+
+  it('refuses a cross-pane move onto the tab own pane', () => {
+    const { state, paneId, guideTabId } = fixture()
+    expect(() => applyOp(state, { type: 'moveTab', tabId: guideTabId, toPaneId: paneId, index: 0 }))
+      .toThrow(/reorderTab/)
+  })
+
+  it('reorders inside one pane without changing the active tab', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
+    const after = expectRoundTrip(opened, { type: 'reorderTab', tabId: tab.id, index: 0 })
+    expect(getPane(after, paneId).tabs).toEqual([tab.id, guideTabId])
+    expect(getPane(after, paneId).activeTabId).toBe(tab.id)
+  })
+})
+
+describe('focus', () => {
+  it('focuses a tab and its pane', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    const opened = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
+    const after = expectRoundTrip(opened, { type: 'focusTab', tabId: guideTabId })
+    expect(getPane(after, paneId).activeTabId).toBe(guideTabId)
+  })
+
+  it('raises a floating pane when its tab takes focus', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    let current = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
+    const firstFloat = minter.next('float')
+    const secondFloat = minter.next('float')
+    current = applyOp(current, {
+      type: 'float', tabId: guideTabId, newPaneId: firstFloat, rect: { x: 0, y: 0, width: 100, height: 100 },
+    }).state
+    current = applyOp(current, {
+      type: 'float', tabId: tab.id, newPaneId: secondFloat, rect: { x: 10, y: 10, width: 100, height: 100 },
+    }).state
+    const after = expectRoundTrip(current, { type: 'focusTab', tabId: guideTabId })
+    expect(after.floats).toEqual([secondFloat, firstFloat])
+    expect(after.activePaneId).toBe(firstFloat)
+  })
+
+  it('raises a floating pane when it takes focus', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    let current = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
+    const firstFloat = minter.next('float')
+    const secondFloat = minter.next('float')
+    current = applyOp(current, {
+      type: 'float', tabId: guideTabId, newPaneId: firstFloat, rect: { x: 0, y: 0, width: 100, height: 100 },
+    }).state
+    current = applyOp(current, {
+      type: 'float', tabId: tab.id, newPaneId: secondFloat, rect: { x: 10, y: 10, width: 100, height: 100 },
+    }).state
+    expect(current.floats).toEqual([firstFloat, secondFloat])
+    const after = expectRoundTrip(current, { type: 'focusPane', paneId: firstFloat })
+    expect(after.floats).toEqual([secondFloat, firstFloat])
+    expect(after.activePaneId).toBe(firstFloat)
+  })
+})
+
+describe('resize', () => {
+  it('replaces divider sizes', () => {
+    const { state, minter, paneId } = fixture()
+    const after = split(state, minter, paneId)
+    const resized = expectRoundTrip(after.state, { type: 'resize', splitId: getSplit(after.state, after.state.rootId).id, sizes: [0.3, 0.7] })
+    expect(getSplit(resized, resized.rootId).sizes).toEqual([0.3, 0.7])
+  })
+
+  it('rejects the wrong count and non-positive sizes', () => {
+    const { state, minter, paneId } = fixture()
+    const after = split(state, minter, paneId)
+    const splitId = getSplit(after.state, after.state.rootId).id
+    expect(() => applyOp(after.state, { type: 'resize', splitId, sizes: [1] })).toThrow(/do not match/)
+    expect(() => applyOp(after.state, { type: 'resize', splitId, sizes: [0, 1] })).toThrow(/above zero/)
+  })
+})
+
+describe('floating panes', () => {
+  it('takes a tab out of the docked tree onto the top of the z order', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const floatId = minter.next('float')
+    const rect = { x: 40, y: 50, width: 300, height: 200 }
+    const after = expectRoundTrip(state, { type: 'float', tabId: guideTabId, newPaneId: floatId, rect })
+    expect(getPane(after, paneId).tabs).toEqual([])
+    expect(after.floats).toEqual([floatId])
+    expect(after.activePaneId).toBe(floatId)
+    const pane = getPane(after, floatId)
+    expect(pane.host).toBe('float')
+    expect(pane.tabs).toEqual([guideTabId])
+    expect(pane.rect).toEqual(rect)
+    expect(findParent(after, floatId)).toBeUndefined()
+  })
+
+  it('returns a floating tab to a docked pane and drops the floating pane', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const floatId = minter.next('float')
+    const floated = applyOp(state, {
+      type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
+    }).state
+    const docked = expectRoundTrip(floated, { type: 'unfloat', paneId: floatId, toPaneId: paneId, index: 0 })
+    expect(docked.floats).toEqual([])
+    expect(docked.nodes[floatId]).toBeUndefined()
+    expect(getPane(docked, paneId).tabs).toEqual([guideTabId])
+    expect(docked.activePaneId).toBe(paneId)
+  })
+
+  it('destroys the floating pane together with its only tab', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const floatId = minter.next('float')
+    const floated = applyOp(state, {
+      type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
+    }).state
+    const closed = expectRoundTrip(floated, { type: 'closeTab', tabId: guideTabId })
+    expect(closed.floats).toEqual([])
+    expect(closed.nodes[floatId]).toBeUndefined()
+    expect(closed.tabs[guideTabId]).toBeUndefined()
+    expect(closed.activePaneId).toBe(paneId)
+  })
+
+  it('records net drag and resize results, focusing and raising the pane they reshape', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    const floatId = minter.next('float')
+    const topId = minter.next('float')
+    let current = applyOp(state, { type: 'openTab', paneId, tab, index: 1 }).state
+    current = applyOp(current, {
+      type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
+    }).state
+    current = applyOp(current, {
+      type: 'float', tabId: tab.id, newPaneId: topId, rect: { x: 10, y: 10, width: 100, height: 100 },
+    }).state
+    current = applyOp(current, { type: 'focusPane', paneId }).state
+    expect(current.floats).toEqual([floatId, topId])
+    expect(current.activePaneId).toBe(paneId)
+
+    // The lower, unfocused panel is dragged: it ends on top and focused; undo puts both facts back.
+    const moved = expectRoundTrip(current, { type: 'moveFloat', paneId: floatId, x: 25, y: 35 })
+    expect(getPane(moved, floatId).rect).toEqual({ x: 25, y: 35, width: 100, height: 100 })
+    expect(moved.floats).toEqual([topId, floatId])
+    expect(moved.activePaneId).toBe(floatId)
+
+    const lowered = applyOp(moved, { type: 'focusPane', paneId: topId }).state
+    const resized = expectRoundTrip(lowered, {
+      type: 'resizeFloat', paneId: floatId, rect: { x: 25, y: 35, width: 420, height: 260 },
+    })
+    expect(getPane(resized, floatId).rect).toEqual({ x: 25, y: 35, width: 420, height: 260 })
+    expect(resized.floats).toEqual([topId, floatId])
+    expect(resized.activePaneId).toBe(floatId)
+    expect(() => applyOp(moved, {
+      type: 'resizeFloat', paneId: floatId, rect: { x: 0, y: 0, width: 0, height: 10 },
+    })).toThrow(/above zero/)
+  })
+
+  it('refuses to float a tab that already floats', () => {
+    const { state, minter, guideTabId } = fixture()
+    const floatId = minter.next('float')
+    const floated = applyOp(state, {
+      type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
+    }).state
+    expect(() => applyOp(floated, {
+      type: 'float', tabId: guideTabId, newPaneId: minter.next('float'), rect: { x: 0, y: 0, width: 10, height: 10 },
+    })).toThrow(/docked tab/)
+  })
+})
+
+describe('presentation flags', () => {
+  it('round-trips the collapsed flag', () => {
+    const { state } = fixture()
+    const after = expectRoundTrip(state, { type: 'setExpanded', expanded: true })
+    expect(after.expanded).toBe(true)
+  })
+
+  it('round-trips the presentation mode', () => {
+    const { state } = fixture()
+    expect(state.mode).toBe('push')
+    const after = expectRoundTrip(state, { type: 'setMode', mode: 'fullscreen' })
+    expect(after.mode).toBe('fullscreen')
+    // The two flags are independent: switching one leaves the other alone.
+    expect(after.expanded).toBe(state.expanded)
+  })
+
+  it('starts in the presentation the embedder seeded', () => {
+    const minter = createIdMinter()
+    expect(createInitialState(minter, seedTab, 'fullscreen').mode).toBe('fullscreen')
+  })
+})
+
+describe('tree readers', () => {
+  /** The seeded pane floated out, leaving the root pane empty. */
+  function floated(): { state: LayoutState; paneId: PaneId; floatId: PaneId; guideTabId: TabId } {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const floatId = minter.next('float')
+    const next = applyOp(state, {
+      type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
+    }).state
+    return { state: next, paneId, floatId, guideTabId }
+  }
+
+  it('throw on a dangling id or a node of the other kind', () => {
+    const { state, minter, paneId } = fixture()
+    const after = split(state, minter, paneId).state
+    expect(() => getNode(after, asPane('nope'))).toThrow(/unknown node nope/)
+    expect(() => getPane(after, after.rootId)).toThrow(/is not a pane/)
+    expect(() => getSplit(after, paneId)).toThrow(/is not a split/)
+    expect(() => getTab(after, asTab('nope'))).toThrow(/unknown tab nope/)
+    expect(() => findTabPane(after, asTab('nope'))).toThrow(/has no pane/)
+    expect(() => replaceInParent(after, asPane('nope'), paneId)).toThrow(/neither rooted nor parented/)
+  })
+
+  it('read a floating pane\'s rectangle, z position, and only tab, and refuse a docked one', () => {
+    const { state, paneId, floatId, guideTabId } = floated()
+    const pane = getPane(state, floatId)
+    expect(floatRect(pane)).toEqual({ x: 0, y: 0, width: 100, height: 100 })
+    expect(floatIndex(state, floatId)).toBe(0)
+    expect(onlyTabId(pane)).toBe(guideTabId)
+    const docked = getPane(state, paneId)
+    expect(() => floatRect(docked)).toThrow(/is not floating/)
+    expect(() => floatIndex(state, paneId)).toThrow(/not in the z order/)
+    expect(() => onlyTabId(docked)).toThrow(/exactly one tab/)
+    const two: PaneNode = { ...pane, tabs: [guideTabId, asTab('other')] }
+    expect(() => onlyTabId(two)).toThrow(/exactly one tab/)
+  })
+
+  it('name the first and the top-right docked pane however the tree is divided', () => {
+    const { state, minter, paneId } = fixture()
+    expect(firstDockPaneId(state)).toBe(paneId)
+    expect(topRightPaneId(state)).toBe(paneId)
+    const right = split(state, minter, paneId)
+    const below = split(right.state, minter, right.newPaneId, 'column')
+    expect(firstDockPaneId(below.state)).toBe(paneId)
+    expect(topRightPaneId(below.state)).toBe(right.newPaneId)
+    const above = split(below.state, minter, paneId, 'column', 'before')
+    expect(firstDockPaneId(above.state)).toBe(above.newPaneId)
+  })
+
+  it('normalize sizes without drifting an exact sum, and refuse a zero total', () => {
+    const exact = [0.25, 0.75]
+    expect(normalizeSizes(exact)).toEqual(exact)
+    expect(normalizeSizes(exact)).not.toBe(exact)
+    expect(normalizeSizes([1, 3])).toEqual([0.25, 0.75])
+    expect(() => normalizeSizes([0, 0])).toThrow(/sum above zero/)
+  })
+
+  it('assertNever names the value a closed switch failed to handle', () => {
+    expect(() => { assertNever('nope' as never, 'layout: thing') }).toThrow('layout: thing: unhandled "nope"')
+  })
+})
+
+describe('operations refuse', () => {
+  /** A second docked pane plus a floating one holding a content tab. */
+  function mixed(): {
+    state: LayoutState
+    minter: IdMinter
+    paneId: PaneId
+    secondPaneId: PaneId
+    floatId: PaneId
+    guideTabId: TabId
+    floatTabId: TabId
+  } {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const second = split(state, minter, paneId)
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    const opened = applyOp(second.state, { type: 'openTab', paneId, tab, index: 1 }).state
+    const floatId = minter.next('float')
+    const floated = applyOp(opened, {
+      type: 'float', tabId: tab.id, newPaneId: floatId, rect: { x: 0, y: 0, width: 100, height: 100 },
+    }).state
+    return { state: floated, minter, paneId, secondPaneId: second.newPaneId, floatId, guideTabId, floatTabId: tab.id }
+  }
+
+  it('opening or inserting a tab into a floating pane', () => {
+    const { state, minter, floatId } = mixed()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/b.txt', 'b.txt')
+    expect(() => applyOp(state, { type: 'openTab', paneId: floatId, tab, index: 0 })).toThrow(/docked pane/)
+    expect(() => applyOp(state, { type: 'insertTab', paneId: floatId, tab, index: 0 })).toThrow(/docked pane/)
+  })
+
+  it('moving a tab out of or into a floating pane, which is what unfloat and float are for', () => {
+    const { state, paneId, floatId, guideTabId, floatTabId } = mixed()
+    expect(() => applyOp(state, { type: 'moveTab', tabId: floatTabId, toPaneId: paneId, index: 0 })).toThrow(/use unfloat/)
+    expect(() => applyOp(state, { type: 'moveTab', tabId: guideTabId, toPaneId: floatId, index: 0 })).toThrow(/target must be docked/)
+  })
+
+  it('returning a floating tab anywhere but a docked pane, and treating a docked pane as floating', () => {
+    const { state, minter, paneId, floatId, guideTabId } = mixed()
+    const otherFloat = minter.next('float')
+    const twoFloats = applyOp(state, {
+      type: 'float', tabId: guideTabId, newPaneId: otherFloat, rect: { x: 5, y: 5, width: 100, height: 100 },
+    }).state
+    expect(() => applyOp(twoFloats, { type: 'unfloat', paneId: floatId, toPaneId: otherFloat, index: 0 }))
+      .toThrow(/target must be docked/)
+    expect(() => applyOp(state, { type: 'unfloat', paneId, toPaneId: paneId, index: 0 })).toThrow(/is not floating/)
+    expect(() => applyOp(state, { type: 'moveFloat', paneId, x: 1, y: 1 })).toThrow(/is not floating/)
+    expect(() => applyOp(state, { type: 'resizeFloat', paneId, rect: { x: 0, y: 0, width: 10, height: 10 } }))
+      .toThrow(/is not floating/)
+  })
+
+  it('creating a node under an id that is already taken', () => {
+    const { state, minter, paneId, secondPaneId } = mixed()
+    expect(() => applyOp(state, {
+      type: 'split', paneId, axis: 'column', direction: 'after', newPaneId: secondPaneId, newSplitId: minter.next('split'),
+    })).toThrow(/already exists/)
+    expect(() => applyOp(state, {
+      type: 'split', paneId, axis: 'column', direction: 'after', newPaneId: minter.next('pane'), newSplitId: asSplit(state.rootId),
+    })).toThrow(/already exists/)
+  })
+
+  it('putting a pane back with records that do not match the pane or the split', () => {
+    const { state, minter, paneId, secondPaneId, floatId } = mixed()
+    const pane = getPane(state, secondPaneId)
+    const stray = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/c.txt', 'c.txt')
+    const fresh: PaneNode = { ...pane, id: minter.next('pane') }
+    const parent = getSplit(state, state.rootId)
+    expect(() => applyOp(state, {
+      type: 'insertPane', pane, tabs: [], attach: { mode: 'child', parentId: parent.id, index: 1, sizes: parent.sizes },
+    })).toThrow(/already exists/)
+    expect(() => applyOp(state, {
+      type: 'insertPane', pane: fresh, tabs: [stray], attach: { mode: 'child', parentId: parent.id, index: 1, sizes: parent.sizes },
+    })).toThrow(/do not match the pane/)
+    expect(() => applyOp(state, {
+      type: 'insertPane', pane: fresh, tabs: [], attach: { mode: 'child', parentId: parent.id, index: 1, sizes: parent.sizes },
+    })).toThrow(/sizes do not match the split/)
+    expect(() => applyOp(state, {
+      type: 'insertPane', pane: fresh, tabs: [], attach: { mode: 'wrap', targetId: paneId, split: parent },
+    })).toThrow(/does not list the pane/)
+    expect(() => applyOp(state, {
+      type: 'insertPane', pane: fresh, tabs: [], attach: { mode: 'float', index: 0 },
+    })).toThrow(/requires a floating pane/)
+    expect(floatId).toBeDefined()
+  })
+
+  it('restoring focus facts that name a docked pane as floating or an unknown pane', () => {
+    const { state, paneId } = mixed()
+    expect(() => applyOp(state, { type: 'restoreFocus', activePaneId: paneId, floats: [paneId], paneActiveTabs: {} }))
+      .toThrow(/as floating/)
+    expect(() => applyOp(state, { type: 'restoreFocus', activePaneId: paneId, floats: [], paneActiveTabs: { [asPane('nope')]: undefined } }))
+      .toThrow(/unknown node/)
+  })
+})
+
+describe('inverses the engine itself never records', () => {
+  it('refuse to return a docked pane with tab records: closeTab records those through insertTab', () => {
+    const { state, minter, paneId } = fixture()
+    const after = split(state, minter, paneId)
+    const parent = getSplit(after.state, after.state.rootId)
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    const pane: PaneNode = { kind: 'pane', id: minter.next('pane'), host: 'dock', tabs: [tab.id], activeTabId: tab.id, rect: undefined }
+    expect(() => applyOp(after.state, {
+      type: 'insertPane', pane, tabs: [tab], attach: { mode: 'child', parentId: parent.id, index: 2, sizes: [0.25, 0.25, 0.5] },
+    })).toThrow(/returns a docked pane empty/)
+  })
+
+  it('merge away an empty floating pane and restore it at its z position', () => {
+    const { state, minter } = fixture()
+    const pane: PaneNode = {
+      kind: 'pane', id: minter.next('float'), host: 'float', tabs: [], activeTabId: undefined, rect: { x: 1, y: 2, width: 50, height: 40 },
+    }
+    const inserted = expectRoundTrip(state, { type: 'insertPane', pane, tabs: [], attach: { mode: 'float', index: 0 } })
+    expect(inserted.floats).toEqual([pane.id])
+    const merged = expectRoundTrip(inserted, { type: 'merge', paneId: pane.id })
+    expect(merged.floats).toEqual([])
+    expect(merged.nodes[pane.id]).toBeUndefined()
+  })
+})
+
+describe('replay', () => {
+  it('rebuilds the same tree from the recorded operations', () => {
+    const { state, minter, paneId, guideTabId } = fixture()
+    const preview = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    const secondPaneId = minter.next('pane')
+    const floatId = minter.next('float')
+    const ops: LayoutOp[] = [
+      { type: 'setExpanded', expanded: true },
+      { type: 'openTab', paneId, tab: preview, index: 1 },
+      { type: 'split', paneId, axis: 'row', direction: 'after', newPaneId: secondPaneId, newSplitId: minter.next('split') },
+      { type: 'moveTab', tabId: preview.id, toPaneId: secondPaneId, index: 0 },
+      { type: 'focusTab', tabId: guideTabId },
+      { type: 'float', tabId: preview.id, newPaneId: floatId, rect: { x: 10, y: 20, width: 200, height: 150 } },
+      { type: 'moveFloat', paneId: floatId, x: 60, y: 70 },
+    ]
+    const direct = ops.reduce((current, op) => applyOp(current, op).state, state)
+    expect(replay(state, ops)).toEqual(direct)
+    expect(replay(state, ops)).toEqual(replay(state, ops))
+    expect(findTabPane(direct, preview.id).id).toBe(floatId)
+  })
+})

+ 1127 - 0
packages/client/ui-dockkit/tests/components.client.spec.tsx

@@ -0,0 +1,1127 @@
+// @vitest-environment jsdom
+/**
+ * Component-level behaviour of the kit's React surface, driven by props alone:
+ * no cordis, no slot registry, no scaffold. These assert what a user sees and
+ * which settled intent each gesture reports — the layout maths itself is covered
+ * by the engine suites.
+ *
+ * jsdom lays nothing out, so the gesture specs hand the surface a layout: panes
+ * of one width side by side, each 600px tall with a 36px strip and 100px chips.
+ */
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { DockIntents } from '../src/contract/adapter.ts'
+import type { PaneId, TabId } from '../src/contract/types.ts'
+import { DockController } from '../src/engine/controller.ts'
+import { applyOp } from '../src/engine/operations.ts'
+import { FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE } from '../src/engine/constraints.ts'
+import { floatRectAt } from '../src/engine/geometry.ts'
+import { DockSurface, type DockSurfaceProps } from '../src/components/DockSurface.tsx'
+import type { TabMenuExtras } from '../src/contract/adapter.ts'
+import { FloatLayer } from '../src/components/FloatLayer.tsx'
+import { dockPaneIds, getPane } from '../src/engine/tree.ts'
+import { TEST_LABELS, asPane, asTab, fileTab, seededController } from './fixtures.client.ts'
+
+afterEach(() => {
+  cleanup()
+  vi.restoreAllMocks()
+  vi.unstubAllGlobals()
+})
+
+/** Every intent as a spy, so a spec can assert exactly which one fired. */
+function spyIntents(): DockIntents & Record<keyof DockIntents, ReturnType<typeof vi.fn>> {
+  return {
+    focusTab: vi.fn(),
+    focusPane: vi.fn(),
+    splitPane: vi.fn(),
+    addTab: vi.fn(),
+    closeTab: vi.fn(),
+    duplicateTab: vi.fn(),
+    floatTab: vi.fn(),
+    unfloatPane: vi.fn(),
+    placeTab: vi.fn(),
+    dropTab: vi.fn(),
+    moveFloat: vi.fn(),
+    resizeFloat: vi.fn(),
+    resizeSplit: vi.fn(),
+  } as DockIntents & Record<keyof DockIntents, ReturnType<typeof vi.fn>>
+}
+
+/** Render the docked surface over a controller's current layout. */
+function renderSurface(
+  controller: DockController,
+  intents: DockIntents,
+  canSplit = true,
+  renderTabMenuItems?: TabMenuExtras,
+  options: Pick<DockSurfaceProps, 'dropZones' | 'minPaneFraction'> = {},
+) {
+  const snapshot = controller.getSnapshot()
+  return render(
+    <DockSurface
+      {...options}
+      state={snapshot.state}
+      canSplit={canSplit}
+      intents={intents}
+      labels={TEST_LABELS}
+      renderTab={tab => <p data-testid="body">{tab.contentId}</p>}
+      {...renderTabMenuItems === undefined ? {} : { renderTabMenuItems }}
+    />,
+  )
+}
+
+const box = (x: number, y: number, width: number, height: number): DOMRect =>
+  ({ x, y, width, height, top: y, left: x, right: x + width, bottom: y + height, toJSON: () => ({}) })
+
+/** Height every laid-out pane takes. */
+const PANE_HEIGHT = 600
+
+/** Width of one laid-out chip. */
+const CHIP_WIDTH = 100
+
+/**
+ * Lay the surface out: `panes` in visual order, each `paneWidth` wide, side by
+ * side from the origin. Every pane's strip is 36px tall inside a 1px border,
+ * its chips 100px wide from the strip's left edge, its fixed controls 104px.
+ * Dividers are 4px wide; the surface and every split container span every
+ * pane, except the split containers `splits` gives their own box.
+ */
+function layOut(panes: readonly PaneId[], paneWidth: number, splits: Readonly<Record<string, DOMRect>> = {}): void {
+  vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+    if (this.hasAttribute('data-dockkit-split')) {
+      return splits[this.getAttribute('data-dockkit-split') ?? ''] ?? box(0, 0, panes.length * paneWidth, PANE_HEIGHT)
+    }
+    if (this.hasAttribute('data-dockkit-surface')) return box(0, 0, panes.length * paneWidth, PANE_HEIGHT)
+    if (this.hasAttribute('data-dockkit-divider')) return box(0, 0, 4, PANE_HEIGHT)
+    const pane = this.closest<HTMLElement>('[data-dockkit-pane]')
+    const index = pane === null ? -1 : panes.indexOf(asPane(pane.dataset.dockkitPane ?? ''))
+    if (pane === null || index < 0) return box(0, 0, 0, 0)
+    const left = index * paneWidth
+    if (this === pane) return box(left, 0, paneWidth, PANE_HEIGHT)
+    const chips = [...pane.querySelectorAll('[data-dockkit-tab]')]
+    if (this.hasAttribute('data-dockkit-strip')) return box(left + 1, 1, paneWidth - 2, 36)
+    if (this.hasAttribute('data-dockkit-strip-tabs')) return box(left + 1, 1, chips.length * CHIP_WIDTH, 34)
+    if (this.hasAttribute('data-dockkit-strip-fill')) {
+      return box(left + 1 + chips.length * CHIP_WIDTH + 32, 1, Math.max(0, paneWidth - 2 - chips.length * CHIP_WIDTH - 104), 34)
+    }
+    if (this.hasAttribute('data-dockkit-tab')) return box(left + 1 + chips.indexOf(this) * CHIP_WIDTH, 1, CHIP_WIDTH, 34)
+    return box(0, 0, 0, 0)
+  })
+}
+
+/** Press `element` at a point and move to another, releasing there unless told not to. */
+function drag(element: Element, from: readonly [number, number], to: readonly [number, number], release = true): void {
+  fireEvent.pointerDown(element, { clientX: from[0], clientY: from[1], pointerId: 7, button: 0 })
+  fireEvent.pointerMove(window, { pointerId: 7, clientX: to[0], clientY: to[1] })
+  if (release) fireEvent.pointerUp(window, { pointerId: 7, clientX: to[0], clientY: to[1] })
+}
+
+/** Two seeded docked panes, the first also holding a content tab, with the layout state on a spied intent set. */
+function twoPanes(
+  paneWidth = 420,
+  canSplit = true,
+  options: Pick<DockSurfaceProps, 'dropZones' | 'minPaneFraction'> = {},
+): {
+  intents: ReturnType<typeof spyIntents>
+  first: PaneId
+  second: PaneId
+  seedTabId: TabId
+  fileTabId: TabId
+  chip: (tabId: TabId) => HTMLElement
+  unmount: () => void
+} {
+  const controller = seededController()
+  controller.setExpanded(true)
+  controller.splitPane()
+  const [first, second] = dockPaneIds(controller.getSnapshot().state)
+  if (first === undefined || second === undefined) throw new Error('expected two docked panes')
+  const fileTabId = controller.openContent({
+    contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId: first,
+  })
+  const seedTabId = getPane(controller.getSnapshot().state, first).tabs[0]
+  if (seedTabId === undefined) throw new Error('expected the seeded tab')
+  layOut([first, second], paneWidth)
+  const intents = spyIntents()
+  const { unmount } = renderSurface(controller, intents, canSplit, undefined, options)
+  const chip = (tabId: TabId): HTMLElement => {
+    const element = document.querySelector<HTMLElement>(`[data-dockkit-tab="${tabId}"]`)
+    if (element === null) throw new Error(`no chip for ${tabId}`)
+    return element
+  }
+  return { intents, first, second, seedTabId, fileTabId, chip, unmount }
+}
+
+/** The chip the layout puts second in the first pane: pressed at its centre. */
+const FILE_CHIP: readonly [number, number] = [151, 18]
+
+/** Which gesture intents fired, in order. */
+function gestureIntents(intents: ReturnType<typeof spyIntents>): string[] {
+  return (['placeTab', 'dropTab', 'floatTab', 'resizeSplit'] as const).filter(name => intents[name].mock.calls.length > 0)
+}
+
+describe('DockSurface', () => {
+  it('renders each pane with its tabs and the active tab body', () => {
+    const controller = seededController()
+    controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    renderSurface(controller, spyIntents())
+
+    // The chip's text is its title alone: the close control is an icon.
+    expect(screen.getAllByRole('tab').map(tab => tab.textContent)).toEqual(['Start', 'a.txt'])
+    expect(screen.getByTestId('body').textContent).toBe('dsh-resource://file/session/s/a.txt')
+    expect(screen.getByRole('tab', { name: /a\.txt/u }).getAttribute('aria-selected')).toBe('true')
+  })
+
+  it('shows the empty-pane label when a pane holds nothing', () => {
+    const controller = new DockController()
+    renderSurface(controller, spyIntents())
+    expect(screen.getByText(TEST_LABELS.emptyPane)).toBeDefined()
+  })
+
+  it('reports one focus intent when a tab is clicked, not when it is pressed, and none for the pane', () => {
+    const controller = seededController()
+    controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const intents = spyIntents()
+    renderSurface(controller, intents)
+    const tab = screen.getByRole('tab', { name: /Start/u })
+
+    fireEvent.pointerDown(tab)
+    expect(intents.focusTab).not.toHaveBeenCalled()
+    fireEvent.click(tab)
+    const seeded = Object.values(controller.getSnapshot().state.tabs)[0]
+    expect(intents.focusTab).toHaveBeenCalledTimes(1)
+    expect(intents.focusTab).toHaveBeenCalledWith(seeded?.id)
+    // The tab's own operation decides the active pane; the pane records nothing of its own.
+    expect(intents.focusPane).not.toHaveBeenCalled()
+  })
+
+  it('records nothing for a click on the active pane\'s selected chip, but selects that chip in another pane', () => {
+    const controller = seededController()
+    controller.setExpanded(true)
+    controller.splitPane()
+    const state = controller.getSnapshot().state
+    const [first, second] = dockPaneIds(state)
+    if (first === undefined || second === undefined) throw new Error('expected two docked panes')
+    expect(state.activePaneId).toBe(second)
+    const intents = spyIntents()
+    renderSurface(controller, intents)
+    const selectedChip = (paneId: PaneId): Element => {
+      const chip = document.querySelector(`[data-dockkit-pane="${paneId}"] [data-dockkit-tab][aria-selected="true"]`)
+      if (chip === null) throw new Error(`expected a selected chip in ${paneId}`)
+      return chip
+    }
+    fireEvent.click(selectedChip(second))
+    expect(intents.focusTab).not.toHaveBeenCalled()
+    fireEvent.click(selectedChip(first))
+    expect(intents.focusTab).toHaveBeenCalledWith(getPane(state, first).activeTabId)
+    expect(intents.focusPane).not.toHaveBeenCalled()
+  })
+
+  it('focuses a pane when its body or strip is clicked, unless it is the active pane already', () => {
+    const controller = seededController()
+    controller.setExpanded(true)
+    controller.splitPane()
+    const state = controller.getSnapshot().state
+    const [first, second] = dockPaneIds(state)
+    if (first === undefined || second === undefined) throw new Error('expected two docked panes')
+    expect(state.activePaneId).toBe(second)
+    const intents = spyIntents()
+    renderSurface(controller, intents)
+    const body = (paneId: PaneId): Element => {
+      const element = document.querySelector(`[data-dockkit-pane="${paneId}"] [data-testid="body"]`)
+      if (element === null) throw new Error(`expected a body in ${paneId}`)
+      return element
+    }
+    // The active pane: nothing would change, so nothing is recorded.
+    fireEvent.click(body(second))
+    fireEvent.click(document.querySelector(`[data-dockkit-strip="${second}"]`) ?? body(second))
+    expect(intents.focusPane).not.toHaveBeenCalled()
+    // The other pane: its body and its strip's blank area both focus it.
+    fireEvent.click(body(first))
+    fireEvent.click(document.querySelector(`[data-dockkit-strip="${first}"]`) ?? body(first))
+    expect(intents.focusPane).toHaveBeenCalledTimes(2)
+    expect(intents.focusPane).toHaveBeenCalledWith(first)
+    expect(intents.focusTab).not.toHaveBeenCalled()
+  })
+
+  it('reports a split intent from the pane control, and disables it when the budget is spent', () => {
+    const controller = seededController()
+    const intents = spyIntents()
+    const { unmount } = renderSurface(controller, intents)
+    fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane }))
+    expect(intents.splitPane).toHaveBeenCalledWith(controller.getSnapshot().state.rootId)
+    expect(intents.focusPane).not.toHaveBeenCalled()
+    unmount()
+
+    renderSurface(controller, intents, false)
+    const disabled = screen.getByRole('button', { name: TEST_LABELS.splitPane })
+    expect(disabled.hasAttribute('disabled')).toBe(true)
+    expect(disabled.getAttribute('title')).toBe(TEST_LABELS.splitPaneDisabled)
+    expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget')
+  })
+
+  // jsdom lays nothing out, so the room rule reads the rectangles this spec
+  // hands it: two panes, one wide enough for two halves and one not. The
+  // strip's fixed part is 104px in both (the chrome pane's controls), the chip
+  // minimum falls back to the stylesheet's 59px.
+  it('disables the split control, with its own title, in a pane too narrow for two halves', () => {
+    const controller = seededController()
+    controller.setExpanded(true)
+    controller.splitPane()
+    const snapshot = controller.getSnapshot()
+    const [wide, narrow] = dockPaneIds(snapshot.state)
+    if (wide === undefined || narrow === undefined) throw new Error('expected two docked panes')
+    const widths: Record<string, number> = { [wide]: 420, [narrow]: 208 }
+    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      const paneWidth = widths[this.closest<HTMLElement>('[data-dockkit-pane]')?.dataset.dockkitPane ?? ''] ?? 0
+      if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
+      if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
+      if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24)
+      if (this.hasAttribute('data-dockkit-strip-fill')) return box(0, 0, Math.max(0, paneWidth - 2 - 60 - 104), 24)
+      return box(0, 0, 0, 0)
+    })
+    render(
+      <DockSurface
+        state={snapshot.state}
+        canSplit
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+      />,
+    )
+    const wideButton = document.querySelector(`[data-dockkit-split-button="${wide}"]`)
+    const narrowButton = document.querySelector(`[data-dockkit-split-button="${narrow}"]`)
+    expect(wideButton?.hasAttribute('disabled')).toBe(false)
+    expect(wideButton?.getAttribute('title')).toBe(TEST_LABELS.splitPane)
+    expect(narrowButton?.hasAttribute('disabled')).toBe(true)
+    expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow)
+    expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width')
+  })
+
+  it('reports the room readings through onRoom, and re-reads them when the surface resizes', () => {
+    class FakeResizeObserver implements ResizeObserver {
+      static latest: FakeResizeObserver | undefined
+      readonly observe = vi.fn()
+      readonly unobserve = vi.fn()
+      readonly disconnect = vi.fn()
+      constructor(private readonly callback: ResizeObserverCallback) {
+        FakeResizeObserver.latest = this
+      }
+
+      /** What the platform does when the observed element's size changes. */
+      fire(): void {
+        this.callback([], this)
+      }
+    }
+    vi.stubGlobal('ResizeObserver', FakeResizeObserver)
+    const controller = seededController()
+    controller.setExpanded(true)
+    controller.splitPane()
+    const snapshot = controller.getSnapshot()
+    const panes = dockPaneIds(snapshot.state)
+    let paneWidth = 420
+    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
+      if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
+      if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24)
+      if (this.hasAttribute('data-dockkit-strip-fill')) return box(0, 0, Math.max(0, paneWidth - 2 - 60 - 104), 24)
+      return box(0, 0, 0, 0)
+    })
+    const onRoom = vi.fn()
+    const { unmount } = render(
+      <DockSurface
+        state={snapshot.state}
+        canSplit
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+        onRoom={onRoom}
+      />,
+    )
+    const observer = FakeResizeObserver.latest
+    if (observer === undefined) throw new Error('expected the surface to observe its own size')
+    expect(observer.observe).toHaveBeenCalledWith(document.querySelector('[data-dockkit-surface]'))
+    expect(onRoom).toHaveBeenLastCalledWith(new Map(panes.map(id => [id, { row: true, column: true }])))
+    const readings = onRoom.mock.calls.length
+
+    // The same reading again renders nothing new.
+    act(() => { observer.fire() })
+    expect(onRoom).toHaveBeenCalledTimes(readings)
+
+    // The column narrowed: every pane loses the room for a row split.
+    paneWidth = 208
+    act(() => { observer.fire() })
+    expect(onRoom).toHaveBeenLastCalledWith(new Map(panes.map(id => [id, { row: false, column: true }])))
+    unmount()
+    expect(observer.disconnect).toHaveBeenCalledTimes(1)
+  })
+
+  it('asks for the seeded tab from the strip\'s add control, naming the pane and nothing else', () => {
+    const controller = seededController()
+    const intents = spyIntents()
+    renderSurface(controller, intents)
+    fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.addTab }))
+    expect(intents.addTab).toHaveBeenCalledWith(controller.getSnapshot().state.rootId)
+    expect(intents.focusPane).not.toHaveBeenCalled()
+  })
+
+  it('draws the add control only where the embedder\'s canAddTab allows, pane by pane', () => {
+    const controller = seededController()
+    controller.setExpanded(true)
+    controller.splitPane()
+    const snapshot = controller.getSnapshot()
+    const [first, second] = dockPaneIds(snapshot.state)
+    if (first === undefined || second === undefined) throw new Error('expected two docked panes')
+    render(
+      <DockSurface
+        state={snapshot.state}
+        canSplit
+        canAddTab={paneId => paneId === second}
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+        chrome={<button type="button">chrome</button>}
+      />,
+    )
+    expect(document.querySelector(`[data-dockkit-pane="${first}"] [data-dockkit-add-tab]`)).toBeNull()
+    expect(document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-add-tab]`)).not.toBeNull()
+    // The strip's end controls stay: only the add control is missing.
+    const strip = document.querySelector(`[data-dockkit-pane="${first}"] [data-dockkit-strip]`)
+    expect(strip?.querySelector('[data-dockkit-split-button]')).not.toBeNull()
+    expect(strip?.querySelector('[data-dockkit-strip-tabs]')).not.toBeNull()
+  })
+
+  it('lets the embedder render a chip\'s title, and shows the record\'s text when it does not', () => {
+    const controller = seededController()
+    controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const snapshot = controller.getSnapshot()
+    render(
+      <DockSurface
+        state={snapshot.state}
+        canSplit
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+        renderTabTitle={tab => tab.kind === 'file' ? <em data-testid="rich-title">{tab.title.toUpperCase()}</em> : undefined}
+      />,
+    )
+    const titles = [...document.querySelectorAll('[data-dockkit-tab-title]')].map(el => el.textContent)
+    expect(titles).toEqual(['Start', 'A.TXT'])
+    expect(screen.getByTestId('rich-title').tagName).toBe('EM')
+  })
+
+  it('closes a tab from the chip\'s own control, without starting a drag', () => {
+    const controller = seededController()
+    const intents = spyIntents()
+    renderSurface(controller, intents)
+    const close = screen.getByRole('button', { name: TEST_LABELS.closeTab })
+    // A press on the nested control must not reach the chip's gesture handler;
+    // if it did, the pointer would be captured and this click would never land.
+    fireEvent.pointerDown(close)
+    fireEvent.click(close)
+    expect(intents.closeTab).toHaveBeenCalledTimes(1)
+    expect(intents.focusTab).not.toHaveBeenCalled()
+  })
+
+  it('opens the context menu on a secondary press, offering close and closing after acting', () => {
+    const controller = seededController()
+    const intents = spyIntents()
+    renderSurface(controller, intents)
+    expect(screen.queryByRole('menu')).toBeNull()
+    fireEvent.contextMenu(screen.getByRole('tab'))
+    expect(screen.getByRole('menu')).toBeDefined()
+    fireEvent.click(screen.getByRole('menuitem', { name: TEST_LABELS.closeTab }))
+    expect(intents.closeTab).toHaveBeenCalledTimes(1)
+    expect(screen.queryByRole('menu')).toBeNull()
+  })
+
+  it('toggles the menu closed on a second secondary press, and dismisses it on a press anywhere else', () => {
+    const controller = seededController()
+    renderSurface(controller, spyIntents())
+    const tab = screen.getByRole('tab')
+    fireEvent.contextMenu(tab)
+    fireEvent.contextMenu(tab)
+    expect(screen.queryByRole('menu')).toBeNull()
+
+    fireEvent.contextMenu(tab)
+    expect(screen.getByRole('menu')).toBeDefined()
+    fireEvent.pointerDown(document.body)
+    expect(screen.queryByRole('menu')).toBeNull()
+
+    // A press with no element target, dispatched to the window itself, counts as outside.
+    fireEvent.contextMenu(tab)
+    fireEvent.pointerDown(window)
+    expect(screen.queryByRole('menu')).toBeNull()
+  })
+
+  it('hangs the menu below the chip\'s left edge, or from its right edge when it would run off the viewport', () => {
+    const controller = seededController()
+    let left = 10
+    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      return this.hasAttribute('data-dockkit-tab') ? box(left, 4, 60, 26) : box(0, 0, 0, 0)
+    })
+    const { unmount } = renderSurface(controller, spyIntents())
+    fireEvent.contextMenu(screen.getByRole('tab'))
+    expect(screen.getByRole('menu').style.top).toBe('34px')
+    expect(screen.getByRole('menu').style.left).toBe('10px')
+    unmount()
+
+    left = window.innerWidth + 100
+    renderSurface(controller, spyIntents())
+    fireEvent.contextMenu(screen.getByRole('tab'))
+    expect(screen.getByRole('menu').style.left).toBe(`${left + 60}px`)
+  })
+
+  it('offers no copy or float item: those gestures are the embedder\'s API and the drag', () => {
+    const controller = seededController()
+    renderSurface(controller, spyIntents())
+    fireEvent.contextMenu(screen.getByRole('tab'))
+    expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab])
+    expect(document.querySelector('[data-dockkit-tab-more]')).toBeNull()
+  })
+
+  it('appends embedder menu items after its own, and hands them the tab and a dismiss', () => {
+    const controller = seededController()
+    const acted = vi.fn<(contentId: string) => void>()
+    const extras: TabMenuExtras = (tab, dismiss) => (
+      <button
+        type="button"
+        role="menuitem"
+        data-testid="extra"
+        onClick={() => { acted(tab.contentId); dismiss() }}
+      >
+        embedder item
+      </button>
+    )
+    renderSurface(controller, spyIntents(), true, extras)
+    fireEvent.contextMenu(screen.getByRole('tab'))
+
+    // Order is contract: the kit's own item stays in the same place in every
+    // menu, so an embedder item cannot displace it.
+    expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
+      TEST_LABELS.closeTab, 'embedder item',
+    ])
+
+    fireEvent.click(screen.getByTestId('extra'))
+    expect(acted).toHaveBeenCalledWith('seed:start')
+    expect(screen.queryByRole('menu')).toBeNull()
+  })
+
+  it('keeps a menu press from starting a tab drag', () => {
+    const controller = seededController()
+    const intents = spyIntents()
+    renderSurface(controller, intents)
+    fireEvent.contextMenu(screen.getByRole('tab'))
+    const item = screen.getByRole('menuitem', { name: TEST_LABELS.closeTab })
+
+    // A press inside the menu must not reach the chip's gesture handler; if it
+    // did, the pointer would be captured and this click would never land.
+    fireEvent.pointerDown(item)
+    fireEvent.click(item)
+    expect(intents.closeTab).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('tab drags', () => {
+  it('previews a caret at the slot under the pointer and reports that slot on release', () => {
+    const { intents, first, fileTabId, chip } = twoPanes()
+    // Left of the first chip's midpoint: slot 0.
+    drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
+    expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0')
+    // Past both chips: the slot after the last one, drawn at the strip's end.
+    fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 })
+    expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('2')
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 })
+    // The slot counts the dragged chip itself; the planner turns it into a reorder.
+    expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 2)
+    expect(gestureIntents(intents)).toEqual(['placeTab'])
+    expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
+  })
+
+  it('reports a slot in another pane\'s strip as a placement there', () => {
+    const { intents, second, fileTabId, chip } = twoPanes()
+    drag(chip(fileTabId), FILE_CHIP, [420 + 30, 18])
+    expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, second, 0)
+  })
+
+  it('shows the dock hint on the pane under the pointer and reports the zone on release', () => {
+    const { intents, second, fileTabId, chip } = twoPanes()
+    drag(chip(fileTabId), FILE_CHIP, [420 + 210, 300], false)
+    const hint = document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-dock-zone]`)
+    expect(hint?.getAttribute('data-dockkit-dock-zone')).toBe('center')
+    fireEvent.pointerMove(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 })
+    expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('right')
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 })
+    expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'right')
+    expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
+  })
+
+  it('offers no edge zone once the pane budget is spent: a release there moves nothing', () => {
+    const { intents, fileTabId, chip } = twoPanes(420, false)
+    drag(chip(fileTabId), FILE_CHIP, [420 + 410, 300], false)
+    expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 })
+    expect(gestureIntents(intents)).toEqual([])
+  })
+
+  // 208px panes: room for a column split (600px tall) but not for a row split.
+  it('offers an edge zone only on the axis the pane has room to split on', () => {
+    const { intents, second, fileTabId, chip } = twoPanes(208)
+    drag(chip(fileTabId), FILE_CHIP, [208 + 200, 300], false)
+    expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
+    fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 })
+    expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('top')
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 })
+    expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'top')
+  })
+
+  it('floats a tab released clear of the surface, with the panel under the drop point', () => {
+    const { intents, fileTabId, chip } = twoPanes()
+    drag(chip(fileTabId), FILE_CHIP, [2000, 900])
+    expect(intents.floatTab).toHaveBeenCalledWith(fileTabId, floatRectAt(2000, 900, FLOAT_DEFAULT_SIZE))
+    expect(gestureIntents(intents)).toEqual(['floatTab'])
+  })
+
+  it('treats a press that never travels the threshold as no drag at all', () => {
+    const { intents, fileTabId, chip } = twoPanes()
+    drag(chip(fileTabId), FILE_CHIP, [FILE_CHIP[0] + 2, FILE_CHIP[1] + 1], false)
+    expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 2000, clientY: 900 })
+    expect(gestureIntents(intents)).toEqual([])
+  })
+
+  it('ignores a secondary press: that is the menu, never a drag', () => {
+    const { intents, fileTabId, chip } = twoPanes()
+    fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 2 })
+    fireEvent.pointerMove(window, { pointerId: 7, clientX: 2000, clientY: 900 })
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 2000, clientY: 900 })
+    expect(gestureIntents(intents)).toEqual([])
+  })
+
+  it('drops the preview and reports nothing when the platform cancels the pointer', () => {
+    const { intents, fileTabId, chip } = twoPanes()
+    drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
+    expect(document.querySelector('[data-dockkit-caret]')).not.toBeNull()
+    fireEvent.pointerCancel(window, { pointerId: 7 })
+    expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 })
+    expect(gestureIntents(intents)).toEqual([])
+  })
+
+  it('lets a new press take over from a gesture still in flight', () => {
+    const { intents, first, seedTabId, fileTabId, chip } = twoPanes()
+    drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
+    expect(document.querySelector('[data-dockkit-caret]')).not.toBeNull()
+    // The second press ends the first gesture: its preview clears and its release never reports.
+    fireEvent.pointerDown(chip(seedTabId), { clientX: 51, clientY: 18, pointerId: 8, button: 0 })
+    expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
+    fireEvent.pointerMove(window, { pointerId: 8, clientX: 300, clientY: 18 })
+    fireEvent.pointerUp(window, { pointerId: 8, clientX: 300, clientY: 18 })
+    expect(intents.placeTab).toHaveBeenCalledTimes(1)
+    expect(intents.placeTab).toHaveBeenCalledWith(seedTabId, first, 2)
+  })
+
+  it('stops following the pointer when unmounted mid-gesture', () => {
+    const { intents, fileTabId, chip, unmount } = twoPanes()
+    drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
+    unmount()
+    fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 })
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 })
+    expect(gestureIntents(intents)).toEqual([])
+  })
+
+  it('follows only its own pointer: another pointer neither moves, releases, nor cancels the gesture', () => {
+    const { intents, first, fileTabId, chip } = twoPanes()
+    fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 0 })
+    fireEvent.pointerMove(window, { pointerId: 9, clientX: 30, clientY: 18 })
+    expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
+    fireEvent.pointerUp(window, { pointerId: 9, clientX: 30, clientY: 18 })
+    fireEvent.pointerCancel(window, { pointerId: 9 })
+    expect(gestureIntents(intents)).toEqual([])
+    // The gesture is still alive for its own pointer.
+    fireEvent.pointerMove(window, { pointerId: 7, clientX: 30, clientY: 18 })
+    expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0')
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 })
+    expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 0)
+    expect(intents.placeTab).toHaveBeenCalledTimes(1)
+  })
+
+  it('captures the pointer on the pressed chip where the platform offers capture', () => {
+    const capture = vi.fn<(pointerId: number) => void>()
+    Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { value: capture, configurable: true, writable: true })
+    try {
+      const { fileTabId, chip } = twoPanes()
+      fireEvent.pointerDown(chip(fileTabId), { clientX: FILE_CHIP[0], clientY: FILE_CHIP[1], pointerId: 7, button: 0 })
+      expect(capture).toHaveBeenCalledWith(7)
+    } finally {
+      Reflect.deleteProperty(HTMLElement.prototype, 'setPointerCapture')
+    }
+  })
+})
+
+describe('horizontal workbench drops', () => {
+  it.each([
+    { x: 450, y: 300, zone: 'left' },
+    { x: 810, y: 590, zone: 'right' },
+  ] as const)('offers both halves and targets $zone at ($x, $y)', ({ x, y, zone }) => {
+    const { intents, second, fileTabId, chip } = twoPanes(420, true, { dropZones: 'horizontal' })
+    drag(chip(fileTabId), FILE_CHIP, [x, y], false)
+    const hints = document.querySelectorAll('[data-dockkit-dock-zone]')
+    expect([...hints].map(hint => hint.getAttribute('data-dockkit-dock-zone'))).toEqual(['left', 'right'])
+    expect(document.querySelector('[data-dockkit-drop-active]')?.getAttribute('data-dockkit-dock-zone')).toBe(zone)
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: x, clientY: y })
+    expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, zone)
+  })
+
+  it('uses the entire other pane for moving once the split budget is spent', () => {
+    const { intents, second, fileTabId, chip } = twoPanes(420, false, { dropZones: 'horizontal' })
+    drag(chip(fileTabId), FILE_CHIP, [425, 590])
+    expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, 'center')
+  })
+
+  it('does not offer a new split when a pane cannot fit two halves', () => {
+    const { intents, second, fileTabId, chip } = twoPanes(300, true, { dropZones: 'horizontal' })
+    drag(chip(fileTabId), FILE_CHIP, [450, 300], false)
+    expect(document.querySelectorAll('[data-dockkit-dock-zone]')).toHaveLength(1)
+    expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('center')
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 450, clientY: 300 })
+    expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(fileTabId, second, 'center')
+  })
+
+  it('clamps the live divider preview and committed sizes to the requested minimum', () => {
+    const { intents } = twoPanes(420, false, { minPaneFraction: 0.2 })
+    const divider = document.querySelector('[data-dockkit-divider]')
+    if (divider === null) throw new Error('expected a divider')
+    drag(divider, [420, 300], [0, 300], false)
+    const cells = document.querySelectorAll<HTMLElement>('[data-dockkit-cell]')
+    expect(Number(cells[0]?.style.flexGrow)).toBeCloseTo(0.2)
+    expect(Number(cells[1]?.style.flexGrow)).toBeCloseTo(0.8)
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 0, clientY: 300 })
+    expect(intents.resizeSplit.mock.calls[0]?.[1]).toEqual([0.2, 0.8])
+  })
+})
+
+describe('keyboard tabs', () => {
+  /** One pane holding the seed and two files, the last opened selected, with the chips by title. */
+  function threeChips(): { intents: ReturnType<typeof spyIntents>; chip: (title: string) => HTMLElement; ids: Record<string, TabId> } {
+    const controller = seededController()
+    const a = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const b = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
+    const seed = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]
+    if (seed === undefined) throw new Error('expected the seeded tab')
+    const intents = spyIntents()
+    renderSurface(controller, intents)
+    const chip = (title: string): HTMLElement => screen.getByRole('tab', { name: new RegExp(title.replace('.', '\\.'), 'u') })
+    return { intents, chip, ids: { Start: seed, 'a.txt': a, 'b.txt': b } }
+  }
+
+  it('puts only the selected chip in the tab order', () => {
+    const { chip } = threeChips()
+    expect(chip('b.txt').tabIndex).toBe(0)
+    expect(chip('a.txt').tabIndex).toBe(-1)
+    expect(chip('Start').tabIndex).toBe(-1)
+  })
+
+  it('moves focus with Left and Right, wrapping, and with Home and End, without selecting', () => {
+    const { intents, chip } = threeChips()
+    chip('b.txt').focus()
+    fireEvent.keyDown(chip('b.txt'), { key: 'ArrowRight' })
+    expect(document.activeElement).toBe(chip('Start'))
+    fireEvent.keyDown(chip('Start'), { key: 'ArrowLeft' })
+    expect(document.activeElement).toBe(chip('b.txt'))
+    fireEvent.keyDown(chip('b.txt'), { key: 'ArrowLeft' })
+    expect(document.activeElement).toBe(chip('a.txt'))
+    fireEvent.keyDown(chip('a.txt'), { key: 'Home' })
+    expect(document.activeElement).toBe(chip('Start'))
+    fireEvent.keyDown(chip('Start'), { key: 'End' })
+    expect(document.activeElement).toBe(chip('b.txt'))
+    expect(intents.focusTab).not.toHaveBeenCalled()
+  })
+
+  it('selects the focused chip with Enter or Space, once each, and ignores other keys', () => {
+    const { intents, chip, ids } = threeChips()
+    chip('b.txt').focus()
+    fireEvent.keyDown(chip('b.txt'), { key: 'ArrowLeft' })
+    fireEvent.keyDown(chip('a.txt'), { key: 'Enter' })
+    expect(intents.focusTab).toHaveBeenCalledWith(ids['a.txt'])
+    fireEvent.keyDown(chip('a.txt'), { key: 'Home' })
+    fireEvent.keyDown(chip('Start'), { key: ' ' })
+    expect(intents.focusTab).toHaveBeenCalledWith(ids.Start)
+    fireEvent.keyDown(chip('Start'), { key: 'a' })
+    fireEvent.keyDown(chip('Start'), { key: 'Escape' })
+    expect(intents.focusTab).toHaveBeenCalledTimes(2)
+  })
+
+  it('records nothing for Enter on the active pane\'s selected chip, and leaves keys on the close control alone', () => {
+    const { intents, chip } = threeChips()
+    fireEvent.keyDown(chip('b.txt'), { key: 'Enter' })
+    fireEvent.keyDown(chip('b.txt'), { key: ' ' })
+    const close = chip('a.txt').querySelector('[data-dockkit-tab-close]')
+    if (close === null) throw new Error('expected the close control')
+    fireEvent.keyDown(close, { key: 'Enter' })
+    fireEvent.keyDown(close, { key: 'ArrowLeft' })
+    expect(intents.focusTab).not.toHaveBeenCalled()
+    expect(intents.closeTab).not.toHaveBeenCalled()
+  })
+})
+
+describe('divider drags', () => {
+  /** A row split whose second pane is itself split into a column: the column split's id and a spied intent set. */
+  function withColumn(columnBox: DOMRect): { intents: ReturnType<typeof spyIntents>; columnId: string } {
+    const controller = seededController()
+    controller.setExpanded(true)
+    controller.splitPane()
+    const [first, second] = dockPaneIds(controller.getSnapshot().state)
+    if (first === undefined || second === undefined) throw new Error('expected two docked panes')
+    const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId: second })
+    controller.dropTab(opened, second, 'bottom')
+    const state = controller.getSnapshot().state
+    const root = state.nodes[state.rootId]
+    const columnId = root?.kind === 'split' ? root.children[1] : undefined
+    if (columnId === undefined || state.nodes[columnId]?.kind !== 'split') throw new Error('expected a column split')
+    layOut(dockPaneIds(state), 420, { [columnId]: columnBox })
+    const intents = spyIntents()
+    renderSurface(controller, intents)
+    return { intents, columnId }
+  }
+
+  it('moves a column split\'s divider along the vertical axis', () => {
+    const { intents, columnId } = withColumn(box(420, 0, 420, 600))
+    const divider = document.querySelector(`[data-dockkit-divider="${columnId}:0"]`)
+    if (divider === null) throw new Error('expected the column divider')
+    drag(divider, [630, 300], [630, 360])
+    const [reportedSplit, sizes] = intents.resizeSplit.mock.calls[0] as [string, readonly number[]]
+    expect(reportedSplit).toBe(columnId)
+    expect(sizes[0]).toBeCloseTo(0.6)
+    expect(sizes[1]).toBeCloseTo(0.4)
+  })
+
+  it('reports nothing for a release that left the fractions where they were', () => {
+    const { intents } = twoPanes()
+    const divider = document.querySelector('[data-dockkit-divider]')
+    if (divider === null) throw new Error('expected a divider between the two panes')
+    // A click on the divider.
+    drag(divider, [420, 300], [420, 300])
+    // A drag returned to where it began.
+    drag(divider, [420, 300], [504, 300], false)
+    fireEvent.pointerMove(window, { pointerId: 7, clientX: 420, clientY: 300 })
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 420, clientY: 300 })
+    expect(intents.resizeSplit).not.toHaveBeenCalled()
+    // A real move still reports, once.
+    drag(divider, [420, 300], [504, 300])
+    expect(intents.resizeSplit).toHaveBeenCalledTimes(1)
+  })
+
+  it('reports nothing along a split that has no measured extent', () => {
+    const { intents, columnId } = withColumn(box(420, 0, 420, 0))
+    const divider = document.querySelector(`[data-dockkit-divider="${columnId}:0"]`)
+    if (divider === null) throw new Error('expected the column divider')
+    drag(divider, [630, 300], [630, 360])
+    expect(intents.resizeSplit).not.toHaveBeenCalled()
+  })
+
+  it('previews the fractions while the divider moves and reports the net result once', () => {
+    const { intents, first, second } = twoPanes()
+    const divider = document.querySelector('[data-dockkit-divider]')
+    if (divider === null) throw new Error('expected a divider between the two panes')
+    const splitId = divider.getAttribute('data-dockkit-divider')?.split(':')[0]
+    // The split spans 840px, so 84px of travel is a tenth of it.
+    drag(divider, [420, 300], [504, 300], false)
+    const cell = (index: number): number =>
+      Number(document.querySelector<HTMLElement>(`[data-dockkit-cell="${splitId}:${index}"]`)?.style.flexGrow)
+    expect(cell(0)).toBeCloseTo(0.6)
+    expect(cell(1)).toBeCloseTo(0.4)
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 504, clientY: 300 })
+    expect(intents.resizeSplit).toHaveBeenCalledTimes(1)
+    const [reportedSplit, sizes] = intents.resizeSplit.mock.calls[0] as [string, readonly number[]]
+    expect(reportedSplit).toBe(splitId)
+    expect(sizes[0]).toBeCloseTo(0.6)
+    expect(sizes[1]).toBeCloseTo(0.4)
+    // The recorded fractions come back through the state; the preview is gone.
+    expect(cell(0)).toBeCloseTo(0.5)
+    expect(intents.placeTab).not.toHaveBeenCalled()
+    expect([first, second]).toHaveLength(2)
+  })
+})
+
+describe('FloatLayer', () => {
+  /** Two floating panels, `lower` under `upper`, with `upper` active. */
+  function twoFloats(): {
+    intents: ReturnType<typeof spyIntents>
+    lower: PaneId
+    upper: PaneId
+    panel: (paneId: PaneId) => HTMLElement
+    part: (attribute: string, paneId: PaneId) => Element
+  } {
+    const controller = seededController()
+    const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
+    const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 })
+    const upper = controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 })
+    const intents = spyIntents()
+    render(<FloatLayer state={controller.getSnapshot().state} intents={intents} labels={TEST_LABELS} renderTab={() => null} />)
+    const part = (attribute: string, paneId: PaneId): Element => {
+      const element = document.querySelector(`[${attribute}="${paneId}"]`)
+      if (element === null) throw new Error(`expected ${attribute} of ${paneId}`)
+      return element
+    }
+    const panel = (paneId: PaneId): HTMLElement => {
+      const element = document.querySelector<HTMLElement>(`[data-dockkit-float="${paneId}"]`)
+      if (element === null) throw new Error(`expected the panel ${paneId}`)
+      return element
+    }
+    return { intents, lower, upper, panel, part }
+  }
+
+  /** One floating panel over a spied intent set. */
+  function floating(): { intents: ReturnType<typeof spyIntents>; paneId: PaneId; tabId: TabId; panel: HTMLElement } {
+    const controller = seededController()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const paneId = controller.floatTab(tabId, { x: 100, y: 80, width: 300, height: 200 })
+    const intents = spyIntents()
+    render(
+      <FloatLayer
+        state={controller.getSnapshot().state}
+        intents={intents}
+        labels={TEST_LABELS}
+        renderTab={tab => <p data-testid="float-body">{tab.title}</p>}
+      />,
+    )
+    const panel = document.querySelector<HTMLElement>(`[data-dockkit-float="${paneId}"]`)
+    if (panel === null) throw new Error('expected a floating panel')
+    return { intents, paneId, tabId, panel }
+  }
+
+  it('draws one panel per floating pane, without a tab strip', () => {
+    const { intents } = floating()
+    expect(screen.getByTestId('float-body').textContent).toBe('a.txt')
+    expect(screen.queryByRole('tablist')).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.dockFloat }))
+    expect(intents.unfloatPane).toHaveBeenCalledTimes(1)
+  })
+
+  it('closes a floating panel through its own control', () => {
+    const { intents, tabId } = floating()
+    fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.closeFloat }))
+    expect(intents.closeTab).toHaveBeenCalledWith(tabId)
+  })
+
+  it('raises a panel on a press on its body, but not from a press on its controls, grip, or corner', () => {
+    const { intents, lower, panel, part } = twoFloats()
+    fireEvent.pointerDown(panel(lower))
+    expect(intents.focusPane).toHaveBeenCalledWith(lower)
+    fireEvent.pointerDown(part('data-dockkit-float-dock', lower))
+    fireEvent.pointerDown(part('data-dockkit-float-close', lower))
+    fireEvent.pointerDown(part('data-dockkit-float-grip', lower), { clientX: 150, clientY: 90, pointerId: 7, button: 0 })
+    fireEvent.pointerDown(part('data-dockkit-float-resize', lower), { clientX: 400, clientY: 280, pointerId: 8, button: 0 })
+    expect(intents.focusPane).toHaveBeenCalledTimes(1)
+  })
+
+  it('records nothing for a press on the panel that is active and on top already', () => {
+    const { intents, upper, panel, part } = twoFloats()
+    fireEvent.pointerDown(panel(upper))
+    // A grip or corner press released in place is a click on the same panel.
+    drag(part('data-dockkit-float-grip', upper), [550, 90], [550, 90])
+    drag(part('data-dockkit-float-resize', upper), [800, 280], [800, 280])
+    expect(intents.focusPane).not.toHaveBeenCalled()
+    expect(intents.moveFloat).not.toHaveBeenCalled()
+    expect(intents.resizeFloat).not.toHaveBeenCalled()
+  })
+
+  it('raises the active panel when another has been drawn over it', () => {
+    const controller = seededController()
+    const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
+    const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 })
+    const upper = controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 })
+    // Focus on the lower panel while the upper stays on top: only a recorded
+    // focus snapshot puts the layout there, as an undo does.
+    const buried = applyOp(controller.getSnapshot().state, {
+      type: 'restoreFocus', activePaneId: lower, floats: [lower, upper], paneActiveTabs: {},
+    }).state
+    const intents = spyIntents()
+    render(<FloatLayer state={buried} intents={intents} labels={TEST_LABELS} renderTab={() => null} />)
+    const panel = document.querySelector(`[data-dockkit-float="${lower}"]`)
+    if (panel === null) throw new Error('expected the lower panel')
+    fireEvent.pointerDown(panel)
+    expect(intents.focusPane).toHaveBeenCalledWith(lower)
+  })
+
+  it('moves a panel by its grip as one intent, previewing the position and the panel on top', () => {
+    const controller = seededController()
+    const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
+    const lower = controller.floatTab(first, { x: 100, y: 80, width: 300, height: 200 })
+    controller.floatTab(second, { x: 500, y: 80, width: 300, height: 200 })
+    const intents = spyIntents()
+    render(<FloatLayer state={controller.getSnapshot().state} intents={intents} labels={TEST_LABELS} renderTab={() => null} />)
+    const panel = document.querySelector<HTMLElement>(`[data-dockkit-float="${lower}"]`)
+    const grip = document.querySelector(`[data-dockkit-float-grip="${lower}"]`)
+    if (panel === null || grip === null) throw new Error('expected the lower panel')
+    expect(panel.style.zIndex).toBe('1')
+
+    drag(grip, [150, 90], [180, 110], false)
+    expect(panel.style.left).toBe('130px')
+    expect(panel.style.top).toBe('100px')
+    // Mid-gesture the panel draws on top, as the move it records will leave it.
+    expect(panel.style.zIndex).toBe('3')
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 180, clientY: 110 })
+    expect(intents.moveFloat).toHaveBeenCalledWith(lower, 130, 100)
+    expect(intents.moveFloat).toHaveBeenCalledTimes(1)
+    expect(intents.focusPane).not.toHaveBeenCalled()
+    // The recorded rectangle and depth are drawn again once the preview clears.
+    expect(panel.style.left).toBe('100px')
+    expect(panel.style.zIndex).toBe('1')
+  })
+
+  it('treats a press on the grip or corner released where it began as a click that raises the panel', () => {
+    const { intents, lower, part } = twoFloats()
+    drag(part('data-dockkit-float-grip', lower), [150, 90], [150, 90])
+    drag(part('data-dockkit-float-resize', lower), [400, 280], [400, 280])
+    expect(intents.focusPane).toHaveBeenCalledTimes(2)
+    expect(intents.focusPane).toHaveBeenCalledWith(lower)
+    expect(intents.moveFloat).not.toHaveBeenCalled()
+    expect(intents.resizeFloat).not.toHaveBeenCalled()
+  })
+
+  it('resizes from the corner as one intent, holding the minimum size', () => {
+    const { intents, paneId, panel } = floating()
+    const corner = document.querySelector(`[data-dockkit-float-resize="${paneId}"]`)
+    if (corner === null) throw new Error('expected a resize corner')
+    drag(corner, [400, 280], [440, 310], false)
+    expect(panel.style.width).toBe('340px')
+    expect(panel.style.height).toBe('230px')
+    fireEvent.pointerMove(window, { pointerId: 7, clientX: -1000, clientY: -1000 })
+    expect(panel.style.width).toBe(`${FLOAT_MIN_SIZE.width}px`)
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: -1000, clientY: -1000 })
+    expect(intents.resizeFloat).toHaveBeenCalledWith(paneId, { x: 100, y: 80, ...FLOAT_MIN_SIZE })
+    expect(intents.resizeFloat).toHaveBeenCalledTimes(1)
+    expect(intents.focusPane).not.toHaveBeenCalled()
+  })
+
+  it('stacks panels in the model z order', () => {
+    const controller = seededController()
+    const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
+    controller.floatTab(first)
+    controller.floatTab(second)
+    render(
+      <FloatLayer
+        state={controller.getSnapshot().state}
+        intents={spyIntents()}
+        labels={TEST_LABELS}
+        renderTab={() => null}
+      />,
+    )
+    const zIndexes = screen.getAllByRole('banner').map(header => header.parentElement?.style.zIndex)
+    expect(zIndexes).toEqual(['1', '2'])
+  })
+})
+
+describe('surface chrome', () => {
+  it('renders the embedder\'s controls once, at the end of the top-right pane\'s strip', () => {
+    const controller = seededController()
+    controller.setExpanded(true)
+    // A row split puts the new pane to the right; a bottom drop under it puts a
+    // pane below that. The top-right pane is the right column's upper one.
+    controller.splitPane()
+    const right = controller.getSnapshot().state
+    const root = right.nodes[right.rootId]
+    const rightPaneId = root?.kind === 'split' ? root.children[1] : undefined
+    if (rightPaneId === undefined) throw new Error('expected a row split')
+    const seeded = controller.getSnapshot().state.nodes[rightPaneId]
+    if (seeded === undefined || seeded.kind !== 'pane' || seeded.tabs[0] === undefined) throw new Error('expected a seeded pane')
+    const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file', paneId: seeded.id })
+    controller.dropTab(opened, seeded.id, 'bottom')
+
+    const snapshot = controller.getSnapshot()
+    render(
+      <DockSurface
+        state={snapshot.state}
+        canSplit
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+        chrome={<button type="button">chrome</button>}
+      />,
+    )
+    const hosts = screen.getAllByRole('button', { name: 'chrome' })
+    expect(hosts).toHaveLength(1)
+    const pane = hosts[0]?.closest('[data-dockkit-pane]')
+    // The upper of the two right-hand panes, not the lower one the drop created.
+    expect(pane?.getAttribute('data-dockkit-pane')).toBe(rightPaneId)
+    // At the strip's end: after the split control.
+    const strip = pane?.querySelector('[data-dockkit-strip]')
+    const children = [...(strip?.children ?? [])]
+    expect(children.at(-1)?.hasAttribute('data-dockkit-strip-chrome')).toBe(true)
+  })
+
+  it('keeps a click on the chrome from focusing the pane: the embedder\'s controls report their own intents', () => {
+    const controller = seededController()
+    const intents = spyIntents()
+    render(
+      <DockSurface
+        state={controller.getSnapshot().state}
+        canSplit
+        intents={intents}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+        chrome={<button type="button">chrome</button>}
+      />,
+    )
+    fireEvent.click(screen.getByRole('button', { name: 'chrome' }))
+    expect(intents.focusPane).not.toHaveBeenCalled()
+  })
+
+  it('renders no chrome host when the embedder supplies none', () => {
+    const controller = seededController()
+    renderSurface(controller, spyIntents())
+    expect(document.querySelector('[data-dockkit-strip-chrome]')).toBeNull()
+  })
+
+  // Layout itself is the stylesheet's; what the DOM promises is that every
+  // chip and drop caret sits inside the strip's one shrinking box, and that the
+  // controls after it are the strip's own children, in this order.
+  it('keeps the chips in their own box ahead of the add, split, and chrome controls', () => {
+    const controller = seededController()
+    controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const snapshot = controller.getSnapshot()
+    render(
+      <DockSurface
+        state={snapshot.state}
+        canSplit
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+        chrome={<button type="button">chrome</button>}
+      />,
+    )
+    const strip = document.querySelector('[data-dockkit-strip]')
+    const role = (child: Element): string => {
+      if (child.hasAttribute('data-dockkit-strip-tabs')) return 'tabs'
+      if (child.hasAttribute('data-dockkit-add-tab')) return 'add'
+      if (child.hasAttribute('data-dockkit-split-button')) return 'split'
+      if (child.hasAttribute('data-dockkit-strip-chrome')) return 'chrome'
+      return 'fill'
+    }
+    expect([...(strip?.children ?? [])].map(role)).toEqual(['tabs', 'add', 'fill', 'split', 'chrome'])
+    expect(strip?.querySelectorAll('[data-dockkit-strip-tabs] [data-dockkit-tab]')).toHaveLength(2)
+    expect(strip?.querySelectorAll(':scope > [data-dockkit-tab]')).toHaveLength(0)
+  })
+})
+
+describe('a controller satisfies the intent contract', () => {
+  it('accepts a DockController wherever DockIntents is required', () => {
+    const controller = seededController()
+    const intents: DockIntents = controller
+    renderSurface(controller, intents)
+    fireEvent.click(screen.getByRole('button', { name: TEST_LABELS.splitPane }))
+    // One click, one entry: the split seeded its new pane, and that seating is
+    // what decides the active pane — the control's click reports nothing else.
+    expect(controller.ops.map(op => op.type)).toEqual(['split', 'openTab'])
+  })
+})
+
+describe('fileTab', () => {
+  it('builds a content tab the kit treats as opaque', () => {
+    expect(fileTab(asTab('t1'), 'dsh-resource://file/session/s/x', 'x').kind).toBe('file')
+  })
+})

+ 131 - 0
packages/client/ui-dockkit/tests/constraints.client.spec.ts

@@ -0,0 +1,131 @@
+/**
+ * Interaction limits: the four-pane cap, the five dock regions, and divider
+ * clamping. The model itself stays unbounded; these rules gate dispatch.
+ */
+import { describe, expect, it } from 'vitest'
+import { applyOp } from '../src/engine/operations.ts'
+import {
+  canSplit, clampSizes, DOCK_ZONES, dockPaneCount, MAX_DOCK_PANES, MIN_PANE_FRACTION, zoneAt, zoneSplit,
+} from '../src/engine/constraints.ts'
+import { createIdMinter, createInitialState } from '../src/engine/initial.ts'
+import { seedTab } from './fixtures.client.ts'
+import { getPane } from '../src/engine/tree.ts'
+import type { LayoutState } from '../src/contract/types.ts'
+
+/** Split the root pane repeatedly until the docked grid holds `count` panes. */
+function grid(count: number): LayoutState {
+  const minter = createIdMinter()
+  let state = createInitialState(minter, seedTab)
+  while (dockPaneCount(state) < count) {
+    state = applyOp(state, {
+      type: 'split',
+      paneId: state.activePaneId,
+      axis: 'row',
+      direction: 'after',
+      newPaneId: minter.next('pane'),
+      newSplitId: minter.next('split'),
+    }).state
+  }
+  return state
+}
+
+describe('pane cap', () => {
+  it('allows splitting up to four docked panes and no further', () => {
+    expect(MAX_DOCK_PANES).toBe(4)
+    expect(canSplit(createInitialState(createIdMinter(), seedTab))).toBe(true)
+    expect(dockPaneCount(grid(4))).toBe(4)
+    expect(canSplit(grid(4))).toBe(false)
+    expect(canSplit(grid(3))).toBe(true)
+  })
+
+  it('ignores floating panes when counting the grid', () => {
+    const minter = createIdMinter()
+    const state = createInitialState(minter, seedTab)
+    const guideTabId = getPane(state, state.rootId).tabs[0]
+    if (guideTabId === undefined) throw new Error('fixture: no guide tab')
+    const floated = applyOp(state, {
+      type: 'float', tabId: guideTabId, newPaneId: minter.next('float'), rect: { x: 0, y: 0, width: 10, height: 10 },
+    }).state
+    expect(floated.floats).toHaveLength(1)
+    expect(dockPaneCount(floated)).toBe(1)
+    expect(canSplit(floated)).toBe(true)
+  })
+})
+
+describe('dock regions', () => {
+  it('offers five regions', () => {
+    expect([...DOCK_ZONES].sort()).toEqual(['bottom', 'center', 'left', 'right', 'top'])
+  })
+
+  it('reads the centre of a pane as a move, not a split', () => {
+    expect(zoneAt(0.5, 0.5)).toBe('center')
+    expect(zoneSplit('center')).toBeUndefined()
+  })
+
+  it('reads each edge band as its own region', () => {
+    expect(zoneAt(0.05, 0.5)).toBe('left')
+    expect(zoneAt(0.95, 0.5)).toBe('right')
+    expect(zoneAt(0.5, 0.05)).toBe('top')
+    expect(zoneAt(0.5, 0.95)).toBe('bottom')
+  })
+
+  it('takes the closest edge in a corner', () => {
+    expect(zoneAt(0.02, 0.10)).toBe('left')
+    expect(zoneAt(0.10, 0.02)).toBe('top')
+  })
+
+  it('maps regions to the split they create', () => {
+    expect(zoneSplit('left')).toEqual({ axis: 'row', direction: 'before' })
+    expect(zoneSplit('right')).toEqual({ axis: 'row', direction: 'after' })
+    expect(zoneSplit('top')).toEqual({ axis: 'column', direction: 'before' })
+    expect(zoneSplit('bottom')).toEqual({ axis: 'column', direction: 'after' })
+  })
+})
+
+describe('divider clamping', () => {
+  it('honours an embedder\'s twenty-percent minimum', () => {
+    expect(clampSizes([0.01, 0.99], 0.2)).toEqual([0.2, 0.8])
+    expect(clampSizes([0.99, 0.01], 0.2)).toEqual([0.8, 0.2])
+    expect(clampSizes([0.5, 0.5], 0.2)).toEqual([0.5, 0.5])
+  })
+
+  it('keeps sizes summing to one', () => {
+    const sizes = clampSizes([0.6, 0.4])
+    expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1)
+    expect(sizes[0]).toBeCloseTo(0.6)
+  })
+
+  it('lifts a pane dragged under the minimum', () => {
+    const sizes = clampSizes([0.01, 0.99])
+    expect(sizes[0]).toBeGreaterThanOrEqual(MIN_PANE_FRACTION * 0.9)
+    expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1)
+  })
+
+  it('accepts unnormalized pixel-like input', () => {
+    const sizes = clampSizes([300, 100])
+    expect(sizes[0]).toBeCloseTo(0.75)
+    expect(sizes[1]).toBeCloseTo(0.25)
+  })
+
+  it('treats a negative size as zero and shares an all-zero input equally', () => {
+    expect(clampSizes([])).toEqual([])
+    const lifted = clampSizes([-1, 1])
+    expect(lifted[0]).toBeCloseTo(MIN_PANE_FRACTION)
+    expect(lifted[1]).toBeCloseTo(1 - MIN_PANE_FRACTION)
+    expect(clampSizes([0, 0])).toEqual([0.5, 0.5])
+  })
+
+  it('pins a share that drops under the minimum only after the first pass', () => {
+    // 0.13 clears the floor until the first pin takes its 0.11 from everyone.
+    const sizes = clampSizes([0.01, 0.13, 0.86])
+    expect(sizes[0]).toBeCloseTo(MIN_PANE_FRACTION)
+    expect(sizes[1]).toBeCloseTo(MIN_PANE_FRACTION)
+    expect(sizes[2]).toBeCloseTo(1 - 2 * MIN_PANE_FRACTION)
+  })
+
+  it('never lifts a pane above an equal share when the minimum would not fit them all', () => {
+    const sizes = clampSizes(Array.from({ length: 10 }, (_, index) => (index === 0 ? 0.001 : 1)))
+    expect(sizes[0]).toBeCloseTo(0.1)
+    expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1)
+  })
+})

+ 360 - 0
packages/client/ui-dockkit/tests/controller.client.spec.ts

@@ -0,0 +1,360 @@
+/**
+ * Controller behavior: the interaction vocabulary the UI calls, including tab
+ * identity (focus an existing tab, or open an explicit copy), the guide tab a
+ * split seats, drop resolution, floating, and the notification contract.
+ */
+import { describe, expect, it } from 'vitest'
+import { DockController } from '../src/engine/controller.ts'
+import { dockPaneCount, FLOAT_DEFAULT_SIZE } from '../src/engine/constraints.ts'
+import { SEED_CONTENT_ID, childAt, firstTab, seededController } from './fixtures.client.ts'
+import { dockPaneIds, findTabPane, getPane, getSplit } from '../src/engine/tree.ts'
+import type { LayoutState, PaneId } from '../src/contract/types.ts'
+
+/** Tab titles of one pane, in strip order. */
+function titles(state: LayoutState, paneId: PaneId): string[] {
+  return getPane(state, paneId).tabs.map(id => state.tabs[id]?.title ?? '?')
+}
+
+/** A controller expanded once, with its first pane id. */
+function expanded(): { controller: DockController; paneId: PaneId } {
+  const controller = seededController()
+  controller.setExpanded(true)
+  const { state } = controller.getSnapshot()
+  return { controller, paneId: getPane(state, state.rootId).id }
+}
+
+describe('expand', () => {
+  it('starts collapsed with a guide tab already seated', () => {
+    const controller = seededController()
+    const snapshot = controller.getSnapshot()
+    expect(snapshot.state.expanded).toBe(false)
+    expect(snapshot.canUndo).toBe(false)
+    expect(snapshot.canSplit).toBe(true)
+    expect(titles(snapshot.state, getPane(snapshot.state, snapshot.state.rootId).id)).toHaveLength(1)
+    const guide = Object.values(snapshot.state.tabs)[0]
+    expect(guide?.contentId).toBe(SEED_CONTENT_ID)
+  })
+
+  it('records a toggle and ignores a redundant set', () => {
+    const controller = seededController()
+    controller.toggleExpanded()
+    expect(controller.getSnapshot().state.expanded).toBe(true)
+    expect(controller.ops).toHaveLength(1)
+    controller.setExpanded(true)
+    expect(controller.ops).toHaveLength(1)
+    controller.toggleExpanded()
+    expect(controller.getSnapshot().state.expanded).toBe(false)
+    expect(controller.ops).toHaveLength(2)
+  })
+})
+
+describe('presentation', () => {
+  it('records a mode switch once and ignores a redundant one', () => {
+    const controller = seededController()
+    controller.setMode('fullscreen')
+    expect(controller.getSnapshot().state.mode).toBe('fullscreen')
+    controller.setMode('fullscreen')
+    expect(controller.ops.map(op => op.type)).toEqual(['setMode'])
+  })
+})
+
+describe('splitting', () => {
+  it('splits the active pane to the right and seats a guide tab there', () => {
+    const { controller, paneId } = expanded()
+    expect(controller.splitPane()).toBe(true)
+    const state = controller.getSnapshot().state
+    const root = getSplit(state, state.rootId)
+    expect(root.axis).toBe('row')
+    const newPaneId = getPane(state, childAt(root, 1)).id
+    expect(newPaneId).not.toBe(paneId)
+    expect(titles(state, newPaneId)).toHaveLength(1)
+    expect(state.tabs[firstTab(getPane(state, newPaneId))]?.contentId).toBe(SEED_CONTENT_ID)
+    expect(controller.ops.map(op => op.type)).toEqual(['setExpanded', 'split', 'openTab'])
+  })
+
+  it('seats the pane factory\'s tab at the end of a strip from the add control, and nothing without one', () => {
+    const { controller, paneId } = expanded()
+    expect(controller.addTab(paneId)).toBe(true)
+    expect(titles(controller.getSnapshot().state, paneId)).toEqual(['Start', 'Start'])
+    const bare = new DockController({ makeInitialTab: id => ({ id, kind: 'seed', contentId: SEED_CONTENT_ID, title: 'Start' }) })
+    expect(bare.addTab(getPane(bare.getSnapshot().state, bare.getSnapshot().state.rootId).id)).toBe(false)
+  })
+
+  it('names the pane a new tab lands in: the active pane, or the first docked one while a panel floats', () => {
+    const { controller, paneId } = expanded()
+    expect(controller.activeDockPaneId()).toBe(paneId)
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const floatId = controller.floatTab(tabId)
+    expect(controller.getSnapshot().state.activePaneId).toBe(floatId)
+    expect(controller.activeDockPaneId()).toBe(paneId)
+  })
+
+  it('stops at four panes without recording anything', () => {
+    const { controller } = expanded()
+    expect(controller.splitPane()).toBe(true)
+    expect(controller.splitPane()).toBe(true)
+    expect(controller.splitPane()).toBe(true)
+    expect(dockPaneCount(controller.getSnapshot().state)).toBe(4)
+    const before = controller.ops.length
+    expect(controller.splitPane()).toBe(false)
+    expect(controller.ops).toHaveLength(before)
+    expect(controller.getSnapshot().canSplit).toBe(false)
+  })
+})
+
+describe('tab identity', () => {
+  it('focuses the existing tab when the same content is opened twice', () => {
+    const { controller, paneId } = expanded()
+    const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    controller.splitPane()
+    const again = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    expect(again).toBe(first)
+    const state = controller.getSnapshot().state
+    expect(getPane(state, paneId).activeTabId).toBe(first)
+    expect(state.activePaneId).toBe(paneId)
+    expect(Object.values(state.tabs).filter(tab => tab.contentId === 'dsh-resource://file/session/s/a.txt')).toHaveLength(1)
+  })
+
+  it('opens an explicit second copy beside the original', () => {
+    const { controller, paneId } = expanded()
+    const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const copy = controller.duplicateTab(first)
+    expect(copy).not.toBe(first)
+    const state = controller.getSnapshot().state
+    expect(state.tabs[copy]?.contentId).toBe('dsh-resource://file/session/s/a.txt')
+    expect(getPane(state, paneId).tabs.indexOf(copy)).toBe(getPane(state, paneId).tabs.indexOf(first) + 1)
+  })
+
+  it('closes a tab and leaves the pane in place', () => {
+    const { controller, paneId } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    controller.closeTab(tabId)
+    const state = controller.getSnapshot().state
+    expect(state.tabs[tabId]).toBeUndefined()
+    expect(dockPaneIds(state)).toEqual([paneId])
+  })
+})
+
+describe('drops', () => {
+  it('moves a tab into the pane under the pointer', () => {
+    const { controller, paneId } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    controller.splitPane()
+    const target = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).children[1]
+    if (target === undefined) throw new Error('expected a second pane')
+    expect(controller.dropTab(tabId, getPane(controller.getSnapshot().state, target).id, 'center')).toBe(true)
+    const state = controller.getSnapshot().state
+    expect(getPane(state, target).tabs).toContain(tabId)
+    expect(getPane(state, paneId).tabs).not.toContain(tabId)
+    expect(state.activePaneId).toBe(target)
+  })
+
+  it('rejects a centre drop on the tab own pane', () => {
+    const { controller, paneId } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const before = controller.ops.length
+    expect(controller.dropTab(tabId, paneId, 'center')).toBe(false)
+    expect(controller.ops).toHaveLength(before)
+  })
+
+  it('splits on an edge drop and lands the tab in the new pane', () => {
+    const { controller, paneId } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    expect(controller.dropTab(tabId, paneId, 'bottom')).toBe(true)
+    const state = controller.getSnapshot().state
+    const root = getSplit(state, state.rootId)
+    expect(root.axis).toBe('column')
+    const created = root.children[1]
+    if (created === undefined) throw new Error('expected a split child')
+    expect(getPane(state, created).tabs).toEqual([tabId])
+    expect(dockPaneCount(state)).toBe(2)
+  })
+
+  it('refuses an edge drop once the grid is full', () => {
+    const { controller, paneId } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    controller.splitPane()
+    controller.splitPane()
+    controller.splitPane()
+    const before = controller.ops.length
+    expect(controller.dropTab(tabId, paneId, 'right')).toBe(false)
+    expect(controller.ops).toHaveLength(before)
+  })
+
+  it('reorders inside one pane', () => {
+    const { controller, paneId } = expanded()
+    const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    controller.reorderTab(first, 0)
+    expect(getPane(controller.getSnapshot().state, paneId).tabs[0]).toBe(first)
+  })
+})
+
+describe('explicit tab placement', () => {
+  it('reorders when the slot is in the tab own pane', () => {
+    const { controller, paneId } = expanded()
+    const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    expect(controller.placeTab(first, paneId, 0)).toBe(true)
+    expect(getPane(controller.getSnapshot().state, paneId).tabs[0]).toBe(first)
+    expect(controller.ops.at(-1)?.type).toBe('reorderTab')
+  })
+
+  it('reports no change when the slot is where the tab already sits', () => {
+    const { controller, paneId } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const index = getPane(controller.getSnapshot().state, paneId).tabs.indexOf(tabId)
+    const before = controller.ops.length
+    expect(controller.placeTab(tabId, paneId, index)).toBe(false)
+    expect(controller.ops).toHaveLength(before)
+  })
+
+  it('moves across panes into the requested slot', () => {
+    const { controller, paneId } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    controller.splitPane()
+    const target = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).children[1]
+    if (target === undefined) throw new Error('expected a second pane')
+    expect(controller.placeTab(tabId, getPane(controller.getSnapshot().state, target).id, 0)).toBe(true)
+    const state = controller.getSnapshot().state
+    expect(getPane(state, target).tabs[0]).toBe(tabId)
+    expect(getPane(state, paneId).tabs).not.toContain(tabId)
+  })
+
+  it('returns a floating tab when placed into a docked strip', () => {
+    const { controller, paneId } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const floatId = controller.floatTab(tabId)
+    expect(controller.placeTab(tabId, paneId, 0)).toBe(true)
+    const state = controller.getSnapshot().state
+    expect(state.floats).toEqual([])
+    expect(state.nodes[floatId]).toBeUndefined()
+    expect(getPane(state, paneId).tabs[0]).toBe(tabId)
+  })
+
+  it('refuses a floating pane as the destination', () => {
+    const { controller } = expanded()
+    const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' })
+    const floatId = controller.floatTab(first)
+    const before = controller.ops.length
+    expect(controller.placeTab(second, floatId, 0)).toBe(false)
+    expect(controller.ops).toHaveLength(before)
+  })
+})
+
+describe('floating', () => {
+  it('takes a tab out and cascades each new panel', () => {
+    const { controller, paneId } = expanded()
+    const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' })
+    const firstFloat = controller.floatTab(first)
+    const secondFloat = controller.floatTab(second)
+    const state = controller.getSnapshot().state
+    expect(state.floats).toEqual([firstFloat, secondFloat])
+    const one = getPane(state, firstFloat)
+    const two = getPane(state, secondFloat)
+    expect(one.rect?.width).toBe(FLOAT_DEFAULT_SIZE.width)
+    expect(two.rect?.x).toBeGreaterThan(one.rect?.x ?? 0)
+    expect(getPane(state, paneId).tabs).toHaveLength(1)
+  })
+
+  it('is unaffected by collapsing the column', () => {
+    const { controller } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const floatId = controller.floatTab(tabId)
+    controller.setExpanded(false)
+    const state = controller.getSnapshot().state
+    expect(state.expanded).toBe(false)
+    expect(state.floats).toEqual([floatId])
+    expect(getPane(state, floatId).tabs).toEqual([tabId])
+  })
+
+  it('sends a floating panel back into the grid', () => {
+    const { controller, paneId } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const floatId = controller.floatTab(tabId)
+    controller.unfloatPane(floatId)
+    const state = controller.getSnapshot().state
+    expect(state.floats).toEqual([])
+    expect(findTabPane(state, tabId).id).toBe(paneId)
+  })
+
+  it('treats a centre drop from a floating panel as a return', () => {
+    const { controller, paneId } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const floatId = controller.floatTab(tabId)
+    expect(controller.dropTab(tabId, paneId, 'center')).toBe(true)
+    const state = controller.getSnapshot().state
+    expect(state.floats).toEqual([])
+    expect(state.nodes[floatId]).toBeUndefined()
+    expect(findTabPane(state, tabId).id).toBe(paneId)
+  })
+
+  it('records net drag and resize results', () => {
+    const { controller } = expanded()
+    const tabId = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const floatId = controller.floatTab(tabId)
+    controller.moveFloat(floatId, 300, 210)
+    controller.resizeFloat(floatId, { x: 300, y: 210, width: 500, height: 400 })
+    expect(getPane(controller.getSnapshot().state, floatId).rect)
+      .toEqual({ x: 300, y: 210, width: 500, height: 400 })
+    expect(controller.ops.map(op => op.type).slice(-2)).toEqual(['moveFloat', 'resizeFloat'])
+  })
+
+  it('raises a panel to the top when focused', () => {
+    const { controller } = expanded()
+    const first = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'text-preview' })
+    const second = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'text-preview' })
+    const firstFloat = controller.floatTab(first)
+    const secondFloat = controller.floatTab(second)
+    controller.focusPane(firstFloat)
+    expect(controller.getSnapshot().state.floats).toEqual([secondFloat, firstFloat])
+  })
+})
+
+describe('dividers', () => {
+  it('clamps a drag that would starve a pane', () => {
+    const { controller } = expanded()
+    controller.splitPane()
+    const splitId = getSplit(controller.getSnapshot().state, controller.getSnapshot().state.rootId).id
+    controller.resizeSplit(splitId, [0.01, 0.99])
+    const sizes = getSplit(controller.getSnapshot().state, splitId).sizes
+    expect(sizes[0]).toBeGreaterThan(0.05)
+    expect(sizes.reduce((sum, size) => sum + size, 0)).toBeCloseTo(1)
+  })
+})
+
+describe('notification', () => {
+  it('notifies subscribers and keeps the snapshot stable between changes', () => {
+    const controller = seededController()
+    let notifications = 0
+    const dispose = controller.subscribe(() => { notifications += 1 })
+    const before = controller.getSnapshot()
+    expect(controller.getSnapshot()).toBe(before)
+
+    controller.setExpanded(true)
+    expect(notifications).toBe(1)
+    expect(controller.getSnapshot()).not.toBe(before)
+
+    controller.setExpanded(true)
+    expect(notifications).toBe(1)
+
+    dispose()
+    controller.setExpanded(false)
+    expect(notifications).toBe(1)
+  })
+
+  it('steps one compound command as one entry, however many operations it recorded', () => {
+    expect(seededController().undo()).toBe(false)
+    const { controller } = expanded()
+    const before = controller.ops.length
+    controller.splitPane()
+    // The split and the seeded tab are two operations of one intent.
+    expect(controller.ops.length - before).toBe(2)
+    expect(controller.getSnapshot().canUndo).toBe(true)
+    expect(controller.undo()).toBe(true)
+    expect(dockPaneCount(controller.getSnapshot().state)).toBe(1)
+    expect(controller.redo()).toBe(true)
+    expect(dockPaneCount(controller.getSnapshot().state)).toBe(2)
+    expect(controller.redo()).toBe(false)
+  })
+})

+ 68 - 0
packages/client/ui-dockkit/tests/fixtures.client.ts

@@ -0,0 +1,68 @@
+/**
+ * Shared spec fixtures. The kit has no notion of a guide or a preview tab, so
+ * the suites supply their own content families — exactly as an embedder does.
+ */
+import type { NodeId, PaneId, PaneNode, SplitId, SplitNode, TabId, TabRecord } from '../src/contract/types.ts'
+import { createIdMinter, createInitialState, DockController, type IdMinter } from '../src/index.ts'
+import type { DockLabels } from '../src/contract/adapter.ts'
+import type { LayoutState } from '../src/contract/types.ts'
+
+/** Consistency id of the seeded tab these suites use. */
+export const SEED_CONTENT_ID = 'seed:start'
+
+/** Title of the seeded tab. */
+export const SEED_TITLE = 'Start'
+
+/** The seeded tab an embedder would put in a fresh pane. */
+export function seedTab(id: TabId): TabRecord {
+  return { id, kind: 'seed', contentId: SEED_CONTENT_ID, title: SEED_TITLE }
+}
+
+/** A content tab addressed by a file URL, the shape the first embedder uses. */
+export function fileTab(id: TabId, contentId: string, title: string): TabRecord {
+  return { id, kind: 'file', contentId, title }
+}
+
+/** A controller seeded on both the initial pane and every pane a split creates. */
+export function seededController(): DockController {
+  return new DockController({ makeInitialTab: seedTab, makePaneTab: seedTab })
+}
+
+/** An initial state holding one seeded tab, plus the minter that produced it. */
+export function seededState(): { state: LayoutState; minter: IdMinter } {
+  const minter = createIdMinter()
+  return { state: createInitialState(minter, seedTab), minter }
+}
+
+/** Labels a spec can pass without caring what they say. */
+export const TEST_LABELS: DockLabels = {
+  emptyPane: 'empty pane',
+  splitPane: 'split right',
+  splitPaneDisabled: 'pane budget spent',
+  splitPaneNarrow: 'too narrow to split',
+  closeTab: 'close',
+  addTab: 'new tab',
+  dockFloat: 'dock',
+  closeFloat: 'close panel',
+}
+
+/** Brand a literal a spec spells out: an id the kit would have minted. */
+export const asPane = (id: string): PaneId => id as PaneId
+/** See {@link asPane}. */
+export const asSplit = (id: string): SplitId => id as SplitId
+/** See {@link asPane}. */
+export const asTab = (id: string): TabId => id as TabId
+
+/** The first tab of a pane the spec knows is not empty. */
+export function firstTab(pane: PaneNode): TabId {
+  const id = pane.tabs[0]
+  if (id === undefined) throw new Error(`fixture: pane ${pane.id} holds no tab`)
+  return id
+}
+
+/** A split's child at an index the spec knows exists. */
+export function childAt(split: SplitNode, index: number): NodeId {
+  const id = split.children[index]
+  if (id === undefined) throw new Error(`fixture: split ${split.id} has no child ${String(index)}`)
+  return id
+}

+ 144 - 0
packages/client/ui-dockkit/tests/geometry.client.spec.ts

@@ -0,0 +1,144 @@
+/**
+ * Drag geometry: point tests, dock-zone resolution against a real rectangle,
+ * tab-strip insertion slots, the drag threshold, and divider arithmetic.
+ */
+import { describe, expect, it } from 'vitest'
+import {
+  containsPoint, dividerSizes, DRAG_THRESHOLD, floatRectAt, insertionIndex, movedRect,
+  passedThreshold, resizedRect, zoneInRect,
+} from '../src/engine/geometry.ts'
+import type { PaneMeasure, Rect } from '../src/engine/geometry.ts'
+import { halvesFit, SPLIT_MINIMUMS } from '../src/engine/geometry.ts'
+import { FLOAT_DEFAULT_SIZE, FLOAT_MIN_SIZE } from '../src/index.ts'
+
+const PANE: Rect = { x: 100, y: 200, width: 400, height: 300 }
+
+describe('containsPoint', () => {
+  it('includes the edges and excludes anything outside', () => {
+    expect(containsPoint(PANE, 100, 200)).toBe(true)
+    expect(containsPoint(PANE, 500, 500)).toBe(true)
+    expect(containsPoint(PANE, 300, 350)).toBe(true)
+    expect(containsPoint(PANE, 99, 350)).toBe(false)
+    expect(containsPoint(PANE, 300, 501)).toBe(false)
+  })
+})
+
+describe('zoneInRect', () => {
+  it('reads the middle of a pane as its centre', () => {
+    expect(zoneInRect(PANE, 300, 350)).toBe('center')
+  })
+
+  it('reads each edge band as its own region', () => {
+    expect(zoneInRect(PANE, 110, 350)).toBe('left')
+    expect(zoneInRect(PANE, 490, 350)).toBe('right')
+    expect(zoneInRect(PANE, 300, 210)).toBe('top')
+    expect(zoneInRect(PANE, 300, 490)).toBe('bottom')
+  })
+
+  it('treats a collapsed rectangle as all centre', () => {
+    expect(zoneInRect({ x: 0, y: 0, width: 0, height: 0 }, 0, 0)).toBe('center')
+  })
+})
+
+describe('insertionIndex', () => {
+  const tabs: Rect[] = [
+    { x: 0, y: 0, width: 100, height: 30 },
+    { x: 100, y: 0, width: 100, height: 30 },
+    { x: 200, y: 0, width: 100, height: 30 },
+  ]
+
+  it('places before a tab while left of its midpoint', () => {
+    expect(insertionIndex(tabs, 10)).toBe(0)
+    expect(insertionIndex(tabs, 49)).toBe(0)
+  })
+
+  it('places after a tab once past its midpoint', () => {
+    expect(insertionIndex(tabs, 51)).toBe(1)
+    expect(insertionIndex(tabs, 151)).toBe(2)
+    expect(insertionIndex(tabs, 400)).toBe(3)
+  })
+
+  it('places at the start of an empty strip', () => {
+    expect(insertionIndex([], 400)).toBe(0)
+  })
+})
+
+describe('passedThreshold', () => {
+  it('ignores travel under the threshold on both axes', () => {
+    expect(passedThreshold(10, 10, 10, 10)).toBe(false)
+    expect(passedThreshold(10, 10, 10 + DRAG_THRESHOLD - 1, 10)).toBe(false)
+    expect(passedThreshold(10, 10, 10 + DRAG_THRESHOLD, 10)).toBe(true)
+    expect(passedThreshold(10, 10, 10, 10 - DRAG_THRESHOLD)).toBe(true)
+  })
+})
+
+describe('dividerSizes', () => {
+  it('moves the change between the two neighbours only', () => {
+    expect(dividerSizes([0.5, 0.5], 0, 0.1)).toEqual([0.6, 0.4])
+    expect(dividerSizes([0.25, 0.25, 0.5], 1, -0.05)).toEqual([0.25, 0.2, 0.55])
+  })
+
+  it('leaves the fractions alone at a boundary that has no pair', () => {
+    expect(dividerSizes([0.5, 0.5], 1, 0.1)).toEqual([0.5, 0.5])
+  })
+})
+
+describe('floating rectangles', () => {
+  const rect = { x: 100, y: 80, width: 300, height: 200 }
+
+  it('moves without changing the size', () => {
+    expect(movedRect(rect, 25, -15)).toEqual({ x: 125, y: 65, width: 300, height: 200 })
+  })
+
+  it('resizes from the origin and honours the minimum', () => {
+    expect(resizedRect(rect, 40, 30, FLOAT_MIN_SIZE))
+      .toEqual({ x: 100, y: 80, width: 340, height: 230 })
+    expect(resizedRect(rect, -1000, -1000, FLOAT_MIN_SIZE))
+      .toEqual({ x: 100, y: 80, ...FLOAT_MIN_SIZE })
+  })
+
+  it('places a new panel with its header under the drop point', () => {
+    const dropped = floatRectAt(400, 300, FLOAT_DEFAULT_SIZE)
+    expect(dropped.width).toBe(FLOAT_DEFAULT_SIZE.width)
+    expect(dropped.x).toBeLessThan(400)
+    expect(dropped.y).toBeLessThan(300)
+    expect(floatRectAt(5, 5, FLOAT_DEFAULT_SIZE)).toMatchObject({ x: 0, y: 0 })
+  })
+})
+
+describe('halvesFit — the room rule', () => {
+  /** A pane whose strip shows `fixed` px of controls and one chip box of 60px; the fill takes the rest. */
+  const measure = (width: number, height: number, fixed: number): PaneMeasure => {
+    const strip = { x: 1, y: 1, width: width - 2, height: 36 }
+    return { pane: { x: 0, y: 0, width, height }, strip, chipsWidth: 60, fillWidth: Math.max(0, strip.width - 60 - fixed) }
+  }
+
+  it('lets an unmeasured pane split: the rule only blocks on a positive reading', () => {
+    expect(halvesFit(measure(0, 0, 104))).toEqual({ row: true, column: true })
+  })
+
+  it('needs each half to hold the strip\'s fixed controls plus one minimum chip', () => {
+    // 420px: halves of 206px inside the borders, against 104 + 59.
+    expect(halvesFit(measure(420, 600, 104)).row).toBe(true)
+    // 208px: halves of 100px, short of 163.
+    expect(halvesFit(measure(208, 600, 104)).row).toBe(false)
+    // The boundary is inclusive: 2 * (163 + 2 borders) + 4 divider = 334.
+    expect(halvesFit(measure(334, 600, 104)).row).toBe(true)
+    expect(halvesFit(measure(333, 600, 104)).row).toBe(false)
+    // A strip with fewer controls needs less.
+    expect(halvesFit(measure(208, 600, 44)).row).toBe(false)
+    expect(halvesFit(measure(214, 600, 44)).row).toBe(true)
+  })
+
+  it('needs each half to hold the strip plus a minimum body for a column split', () => {
+    // Halves of (h - 4) / 2 - 2 against 36 + 48 = 84.
+    expect(halvesFit(measure(420, 176, 104)).column).toBe(true)
+    expect(halvesFit(measure(420, 175, 104)).column).toBe(false)
+  })
+
+  it('takes the minimums it is given, and the stylesheet\'s by default', () => {
+    expect(SPLIT_MINIMUMS).toEqual({ divider: 4, chip: 59, body: 48 })
+    expect(halvesFit(measure(208, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(false)
+    expect(halvesFit(measure(220, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(true)
+  })
+})

+ 122 - 0
packages/client/ui-dockkit/tests/measure.client.spec.ts

@@ -0,0 +1,122 @@
+// @vitest-environment jsdom
+/**
+ * The DOM side of the room rule, over a hand-built pane tree: which rectangles
+ * and computed styles feed `halvesFit`, and what an unmeasured pane reads as.
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+import { fitOf, measurePaneFits, paneElements, sameFits } from '../src/components/measure.ts'
+import type { HalvesFit, Rect } from '../src/engine/geometry.ts'
+import { asPane } from './fixtures.client.ts'
+
+/** Give one element a fixed rectangle, as a browser's layout would. */
+function lay(element: HTMLElement, rect: Rect): void {
+  element.getBoundingClientRect = (): DOMRect => ({
+    ...rect, top: rect.y, left: rect.x, right: rect.x + rect.width, bottom: rect.y + rect.height, toJSON: () => ({}),
+  })
+}
+
+/** A pane element with the parts the measurement reads; `width` is the pane's, `fixed` the strip's controls. */
+function pane(id: string, width: number, fixed = 104): HTMLElement {
+  const element = document.createElement('section')
+  element.dataset.dockkitPane = id
+  lay(element, { x: 0, y: 0, width, height: 600 })
+  const strip = document.createElement('div')
+  strip.dataset.dockkitStrip = id
+  lay(strip, { x: 1, y: 1, width: width - 2, height: 36 })
+  const chips = document.createElement('div')
+  chips.dataset.dockkitStripTabs = id
+  lay(chips, { x: 1, y: 1, width: 60, height: 34 })
+  const fill = document.createElement('div')
+  fill.dataset.dockkitStripFill = ''
+  lay(fill, { x: 61, y: 1, width: Math.max(0, width - 2 - 60 - fixed), height: 34 })
+  strip.append(chips, fill)
+  element.append(strip)
+  return element
+}
+
+/** A chip with the given inline box styles, appended to `strip`'s chip box. */
+function chip(host: HTMLElement, style: Partial<CSSStyleDeclaration>): HTMLElement {
+  const element = document.createElement('div')
+  element.dataset.dockkitTab = 't'
+  Object.assign(element.style, style)
+  host.querySelector('[data-dockkit-strip-tabs]')?.append(element)
+  return element
+}
+
+/** A surface root in the document, so computed styles resolve. */
+function surface(): HTMLElement {
+  const root = document.createElement('div')
+  document.body.append(root)
+  return root
+}
+
+afterEach(() => { document.body.replaceChildren() })
+
+describe('measurePaneFits', () => {
+  it('keys every pane by the id it carries, and reads an unlaid pane as fitting', () => {
+    const root = surface()
+    const bare = document.createElement('section')
+    bare.dataset.dockkitPane = 'bare'
+    root.append(pane('wide', 420), bare)
+    expect(paneElements(root).map(([id]) => id)).toEqual(['wide', 'bare'])
+    const fits = measurePaneFits(root)
+    expect(fits.get(asPane('wide'))).toEqual({ row: true, column: true })
+    expect(fits.get(asPane('bare'))).toEqual({ row: true, column: true })
+  })
+
+  // 308px: halves of 150px inside the borders, against 104px of controls plus one chip.
+  it('reads the chip minimum from a rendered chip\'s computed style, padding included for a content box', () => {
+    const root = surface()
+    const narrow = pane('p', 308)
+    root.append(narrow)
+    // No chip rendered: the stylesheet's 59px, so 163 > 150.
+    expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
+    // 44px of content plus 4px + 4px of padding is 52: 156 > 150.
+    const rendered = chip(narrow, { minWidth: '44px', paddingLeft: '4px', paddingRight: '4px', boxSizing: 'content-box' })
+    expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
+    // The same declaration as a border box is the whole footprint: 148 fits.
+    rendered.style.boxSizing = 'border-box'
+    expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(true)
+    // Unstyled or zero minimum: back to the stylesheet's figure.
+    rendered.style.minWidth = '0px'
+    expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
+    rendered.style.minWidth = ''
+    expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
+  })
+
+  // 336px: halves of 164px against 163 with the stylesheet's 4px divider, 162 with an 8px one.
+  it('reads the divider\'s thickness from a rendered divider, and the stylesheet\'s before one exists', () => {
+    const root = surface()
+    root.append(pane('p', 336))
+    expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(true)
+    const divider = document.createElement('div')
+    divider.dataset.dockkitDivider = 's:0'
+    root.append(divider)
+    lay(divider, { x: 0, y: 0, width: 8, height: 600 })
+    expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
+    // A divider without layout yet falls back too.
+    lay(divider, { x: 0, y: 0, width: 0, height: 0 })
+    expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(true)
+  })
+})
+
+describe('fitOf', () => {
+  it('answers from the map, and takes an unmeasured pane to fit', () => {
+    const blocked: HalvesFit = { row: false, column: true }
+    const fits = new Map([[asPane('p'), blocked]])
+    expect(fitOf(fits, asPane('p'))).toBe(blocked)
+    expect(fitOf(fits, asPane('q'))).toEqual({ row: true, column: true })
+  })
+})
+
+describe('sameFits', () => {
+  const fit: HalvesFit = { row: true, column: false }
+
+  it('agrees only when both name the same panes with the same readings', () => {
+    expect(sameFits(new Map([[asPane('a'), fit]]), new Map([[asPane('a'), { ...fit }]]))).toBe(true)
+    expect(sameFits(new Map([[asPane('a'), fit]]), new Map())).toBe(false)
+    expect(sameFits(new Map([[asPane('a'), fit]]), new Map([[asPane('b'), fit]]))).toBe(false)
+    expect(sameFits(new Map([[asPane('a'), fit]]), new Map([[asPane('a'), { row: false, column: false }]]))).toBe(false)
+    expect(sameFits(new Map([[asPane('a'), fit]]), new Map([[asPane('a'), { row: true, column: true }]]))).toBe(false)
+  })
+})

+ 385 - 0
packages/client/ui-dockkit/tests/planner.client.spec.ts

@@ -0,0 +1,385 @@
+/**
+ * The two properties the intent layer must not lose in refactoring.
+ *
+ * Determinism: a planner reads only the state and the minter it is handed, so the
+ * same inputs plan the same operations and replay stays exact. Nothing here may
+ * reach a clock or a random source.
+ *
+ * One undo behaviour: focus-run merging and redo-branch discarding live in the
+ * pure history functions, so the stateful `Sequencer` embedding and an embedder
+ * driving `record`/`stepBack` directly cannot drift apart.
+ */
+import { describe, expect, it } from 'vitest'
+import { applyOp } from '../src/engine/operations.ts'
+import { DockController } from '../src/engine/controller.ts'
+import { createIdMinter, createInitialState } from '../src/engine/initial.ts'
+import {
+  activeDockPaneId, findContentTab, findPaneContentTab, planDropTab, planDuplicateTab, planFloatTab, planOpenContent,
+  planPlaceTab, planSetExpanded, planSetMode, planSettle, planSplitPane, planUnfloatPane, planAddTab,
+} from '../src/engine/planner.ts'
+import {
+  EMPTY_HISTORY, record, recordedOps, stepBack, stepForward, type History,
+} from '../src/engine/sequence.ts'
+import { dockPaneIds, findTabPane, getPane, getSplit } from '../src/engine/tree.ts'
+import type { LayoutOp, LayoutState, PaneId, TabId } from '../src/contract/types.ts'
+import type { Mint } from '../src/engine/planner.ts'
+import { asTab, fileTab, firstTab, seedTab, seededState } from './fixtures.client.ts'
+
+/** A deep snapshot, for proving a planner left its input alone. */
+function frozen(state: LayoutState): string {
+  return JSON.stringify(state)
+}
+
+/** Apply a plan the way any embedder does, and return the resulting state. */
+function applyAll(state: LayoutState, ops: readonly LayoutOp[]): LayoutState {
+  return ops.reduce((current, op) => applyOp(current, op).state, state)
+}
+
+describe('planner determinism', () => {
+  it('plans the same operations from the same state and a fresh minter', () => {
+    const first = seededState()
+    const second = seededState()
+    const planOne = planSplitPane(first.state, first.minter.next, undefined, seedTab)
+    const planTwo = planSplitPane(second.state, second.minter.next, undefined, seedTab)
+    expect(planOne).toEqual(planTwo)
+    expect(planOne.length).toBeGreaterThan(0)
+  })
+
+  it('leaves the state it was handed untouched', () => {
+    const { state, minter } = seededState()
+    const before = frozen(state)
+    const mint = minter.next
+
+    planSetExpanded(state, true)
+    planSplitPane(state, mint, undefined, seedTab)
+    planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    planFloatTab(state, mint, firstTab(getPane(state, state.rootId)))
+    expect(frozen(state)).toBe(before)
+  })
+
+  it('plans nothing for an intent that changes nothing', () => {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const tabId = getPane(state, state.rootId).tabs[0]
+    if (tabId === undefined) throw new Error('fixture: seeded tab missing')
+
+    // Already collapsed; already in that mode; already in that slot; centre
+    // release on its own pane.
+    expect(planSetExpanded(state, false)).toEqual([])
+    expect(planSetMode(state, 'push')).toEqual([])
+    expect(planSetMode(state, 'fullscreen')).toEqual([{ type: 'setMode', mode: 'fullscreen' }])
+    expect(planPlaceTab(state, tabId, getPane(state, state.rootId).id, 0)).toEqual([])
+    expect(planDropTab(state, mint, tabId, getPane(state, state.rootId).id, 'center')).toEqual([])
+  })
+
+  it('refuses to plan past the pane budget', () => {
+    const seeded = seededState()
+    let { state } = seeded
+    const { minter } = seeded
+    const mint = minter.next
+    for (let index = 0; index < 3; index += 1) {
+      state = applyAll(state, planSplitPane(state, mint, state.activePaneId, seedTab))
+    }
+    expect(planSplitPane(state, mint, state.activePaneId, seedTab)).toEqual([])
+    // The root is a split once panes exist, so address a real pane.
+    const firstPane = dockPaneIds(state)[0]
+    if (firstPane === undefined) throw new Error('fixture: no docked pane')
+    const tabId = getPane(state, firstPane).tabs[0]
+    if (tabId === undefined) throw new Error('fixture: seeded tab missing')
+    expect(planDropTab(state, mint, tabId, firstPane, 'right')).toEqual([])
+  })
+})
+
+describe('planPlaceTab', () => {
+  /** One docked pane holding three tabs, in strip order. */
+  function threeTabs(): { state: LayoutState; paneId: PaneId; tabs: readonly [TabId, TabId, TabId] } {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const paneId = getPane(state, state.rootId).id
+    const b = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/b.txt', title: 'b', kind: 'file' })
+    const withB = applyAll(state, b.ops)
+    const c = planOpenContent(withB, mint, { contentId: 'dsh-resource://file/session/s/c.txt', title: 'c', kind: 'file' })
+    const withC = applyAll(withB, c.ops)
+    const a = getPane(withC, paneId).tabs[0]
+    if (a === undefined) throw new Error('fixture: seeded tab missing')
+    return { state: withC, paneId, tabs: [a, b.tabId, c.tabId] }
+  }
+
+  /** Strip order after placing `tabId` at caret slot `index` in its own pane. */
+  function after(tabId: TabId, index: number): readonly TabId[] {
+    const { state, paneId } = threeTabs()
+    return getPane(applyAll(state, planPlaceTab(state, tabId, paneId, index)), paneId).tabs
+  }
+
+  it('reads the slot as the caret over the strip as drawn, the dragged chip included', () => {
+    const { tabs: [a, b, c] } = threeTabs()
+    // Rightward: the caret between b and c is slot 2, and a lands between them.
+    expect(after(a, 2)).toEqual([b, a, c])
+    expect(after(a, 3)).toEqual([b, c, a])
+    // Leftward: the chip sits after the caret, so the slot is the final index.
+    expect(after(c, 0)).toEqual([c, a, b])
+    expect(after(c, 1)).toEqual([a, c, b])
+  })
+
+  it('plans nothing for the slot on either side of the dragged chip', () => {
+    const { state, paneId, tabs: [a, b, c] } = threeTabs()
+    expect(planPlaceTab(state, a, paneId, 0)).toEqual([])
+    expect(planPlaceTab(state, a, paneId, 1)).toEqual([])
+    expect(planPlaceTab(state, b, paneId, 1)).toEqual([])
+    expect(planPlaceTab(state, b, paneId, 2)).toEqual([])
+    expect(planPlaceTab(state, c, paneId, 2)).toEqual([])
+    expect(planPlaceTab(state, c, paneId, 3)).toEqual([])
+  })
+})
+
+describe('planDropTab on the tab\'s own pane', () => {
+  it('plans nothing for a pane\'s only tab released on any of its own edges', () => {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const tabId = getPane(state, state.rootId).tabs[0]
+    if (tabId === undefined) throw new Error('fixture: seeded tab missing')
+    for (const zone of ['left', 'right', 'top', 'bottom'] as const) {
+      expect(planDropTab(state, mint, tabId, getPane(state, state.rootId).id, zone)).toEqual([])
+    }
+  })
+
+  it('splits the pane when it keeps another tab', () => {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const two = applyAll(state, opened.ops)
+    const ops = planDropTab(two, mint, opened.tabId, getPane(two, two.rootId).id, 'right')
+    expect(ops.map(op => op.type)).toEqual(['split', 'moveTab'])
+    const split = applyAll(two, ops)
+    expect(dockPaneIds(split)).toHaveLength(2)
+    expect(getPane(split, two.rootId).tabs).toHaveLength(1)
+  })
+})
+
+describe('floating panes as planner arguments', () => {
+  /** A docked pane holding the seed, plus one content tab floated out of it. */
+  function withFloat(): { state: LayoutState; mint: Mint; floatId: PaneId; tabId: TabId } {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const docked = applyAll(state, opened.ops)
+    const floated = planFloatTab(docked, mint, opened.tabId)
+    return { state: applyAll(docked, floated.ops), mint, floatId: floated.paneId, tabId: opened.tabId }
+  }
+
+  it('plans nothing into a floating pane: no split, no seeded tab, no drop', () => {
+    const { state, mint, floatId } = withFloat()
+    expect(planSplitPane(state, mint, floatId, seedTab)).toEqual([])
+    expect(planAddTab(state, mint, floatId, seedTab)).toEqual([])
+    const seeded = getPane(state, state.rootId).tabs[0]
+    if (seeded === undefined) throw new Error('fixture: seeded tab missing')
+    expect(planDropTab(state, mint, seeded, floatId, 'center')).toEqual([])
+  })
+
+  it('copies a floating tab into the active docked pane, at its end', () => {
+    const { state, mint, tabId } = withFloat()
+    expect(state.activePaneId).not.toBe(state.rootId)
+    expect(activeDockPaneId(state)).toBe(state.rootId)
+    const copy = planDuplicateTab(state, mint, tabId)
+    expect(copy.ops[0]).toMatchObject({ type: 'openTab', paneId: state.rootId, index: 1 })
+    expect(getPane(applyAll(state, copy.ops), state.rootId).tabs.at(-1)).toBe(copy.tabId)
+  })
+})
+
+describe('planSettle', () => {
+  /** Two seeded docked panes side by side. */
+  function twoPanes(): { state: LayoutState; mint: Mint; left: PaneId; right: PaneId } {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const split = applyAll(state, planSplitPane(state, mint, undefined, seedTab))
+    const [left, right] = dockPaneIds(split)
+    if (left === undefined || right === undefined) throw new Error('fixture: expected two panes')
+    return { state: split, mint, left, right }
+  }
+
+  it('plans nothing while every docked pane holds a tab', () => {
+    const { state, mint } = twoPanes()
+    expect(planSettle(state, mint, seedTab)).toEqual([])
+  })
+
+  it('merges away each pane an intent emptied, one after another', () => {
+    const { state, mint, left, right } = twoPanes()
+    const third = applyAll(state, planSplitPane(state, mint, right, seedTab))
+    const emptied = dockPaneIds(third).flatMap(id => getPane(third, id).tabs)
+      .filter(tabId => findTabPane(third, tabId).id !== left)
+      .reduce((current, tabId) => applyAll(current, [{ type: 'closeTab', tabId }]), third)
+    const ops = planSettle(emptied, mint, seedTab)
+    expect(ops.map(op => op.type)).toEqual(['merge', 'merge'])
+    expect(dockPaneIds(applyAll(emptied, ops))).toEqual([left])
+  })
+
+  it('reseeds an emptied root pane through the factory, and leaves it empty without one', () => {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const tabId = getPane(state, state.rootId).tabs[0]
+    if (tabId === undefined) throw new Error('fixture: seeded tab missing')
+    const emptied = applyAll(state, [{ type: 'closeTab', tabId }])
+    const reseeded = planSettle(emptied, mint, seedTab)
+    expect(reseeded.map(op => op.type)).toEqual(['openTab'])
+    expect(getPane(applyAll(emptied, reseeded), emptied.rootId).tabs).toHaveLength(1)
+    expect(planSettle(emptied, mint)).toEqual([])
+  })
+})
+
+describe('planOpenContent placement and identity', () => {
+  it('identifies content by (kind, contentId): the same address under another kind opens another tab', () => {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const first = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const opened = applyAll(state, first.ops)
+    expect(findContentTab(opened, 'dsh-resource://file/session/s/a.txt')).toBe(first.tabId)
+    expect(findContentTab(opened, 'dsh-resource://file/session/s/a.txt', 'file')).toBe(first.tabId)
+    expect(findContentTab(opened, 'dsh-resource://file/session/s/a.txt', 'hex')).toBeUndefined()
+    // The pane-level lookup answers for one pane only.
+    const [pane] = dockPaneIds(opened)
+    if (pane === undefined) throw new Error('expected a docked pane')
+    expect(findPaneContentTab(opened, pane, 'dsh-resource://file/session/s/a.txt')).toBe(first.tabId)
+    expect(findPaneContentTab(opened, pane, 'dsh-resource://file/session/s/a.txt', 'hex')).toBeUndefined()
+    expect(findPaneContentTab(opened, pane, 'dsh-resource://file/session/s/nowhere.txt')).toBeUndefined()
+    const again = planOpenContent(opened, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    expect(again.ops.map(op => op.type)).toEqual(['focusTab'])
+    const other = planOpenContent(opened, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'hex' })
+    expect(other.ops.map(op => op.type)).toEqual(['openTab'])
+  })
+
+  it('opens another tab when told not to reveal the existing one', () => {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const opened = applyAll(state, planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' }).ops)
+    const copy = planOpenContent(opened, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', revealIfOpened: false })
+    expect(copy.ops.map(op => op.type)).toEqual(['openTab'])
+    const both = applyAll(opened, copy.ops)
+    expect(Object.values(both.tabs).filter(tab => tab.contentId === 'dsh-resource://file/session/s/a.txt')).toHaveLength(2)
+  })
+
+  it('seats a new tab at an explicit strip slot', () => {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const paneId = getPane(state, state.rootId).id
+    const planned = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file', paneId, index: 0 })
+    expect(planned.ops[0]).toMatchObject({ type: 'openTab', paneId, index: 0 })
+    expect(getPane(applyAll(state, planned.ops), paneId).tabs[0]).toBe(planned.tabId)
+  })
+})
+
+describe('planAddTab', () => {
+  it('seats the factory\'s tab at the end of the pane, and plans nothing without a factory', () => {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const paneId = getPane(state, state.rootId).id
+    const ops = planAddTab(state, mint, paneId, seedTab)
+    expect(ops.map(op => op.type)).toEqual(['openTab'])
+    expect(ops[0]).toMatchObject({ paneId, index: getPane(state, paneId).tabs.length })
+    expect(planAddTab(state, mint, paneId)).toEqual([])
+  })
+})
+
+describe('one undo behaviour across both embeddings', () => {
+  /** Drive a script of intents through the pure history functions. */
+  function driveByHand(): { history: History; state: LayoutState } {
+    const minter = createIdMinter()
+    const mint = minter.next
+    let state = createInitialState(minter, seedTab)
+    let history = EMPTY_HISTORY
+    const run = (ops: readonly LayoutOp[]): void => {
+      const stepped = record(history, state, ops)
+      history = stepped.history
+      state = stepped.state
+    }
+    run(planSetExpanded(state, true))
+    run(planSplitPane(state, mint, undefined, seedTab))
+    const opened = planOpenContent(state, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    run(opened.ops)
+    const copy = planDuplicateTab(state, mint, opened.tabId)
+    run(copy.ops)
+    run([{ type: 'focusTab', tabId: opened.tabId }])
+    run([{ type: 'focusTab', tabId: copy.tabId }])
+    run([{ type: 'focusTab', tabId: opened.tabId }])
+    return { history, state }
+  }
+
+  /** The same script through the stateful embedding. */
+  function driveByController(): DockController {
+    const controller = new DockController({ makeInitialTab: seedTab, makePaneTab: seedTab })
+    controller.setExpanded(true)
+    controller.splitPane()
+    const opened = controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const copy = controller.duplicateTab(opened)
+    controller.focusTab(opened)
+    controller.focusTab(copy)
+    controller.focusTab(opened)
+    return controller
+  }
+
+  it('records the same sequence either way', () => {
+    const byHand = driveByHand()
+    const controller = driveByController()
+    expect(recordedOps(byHand.history)).toEqual(controller.ops)
+    expect(byHand.state).toEqual(controller.getSnapshot().state)
+  })
+
+  it('merges a focus run into one step in both embeddings', () => {
+    const byHand = driveByHand()
+    const controller = driveByController()
+
+    const stepped = stepBack(byHand.history, byHand.state)
+    if (stepped === undefined) throw new Error('expected a step back')
+    expect(controller.undo()).toBe(true)
+    // Three consecutive focus moves collapse to one step on both paths.
+    expect(byHand.history.cursor - stepped.history.cursor).toBe(3)
+    expect(stepped.history.cursor).toBe(controller.getSnapshot().cursor)
+    expect(stepped.state).toEqual(controller.getSnapshot().state)
+
+    const forward = stepForward(stepped.history, stepped.state)
+    if (forward === undefined) throw new Error('expected a step forward')
+    expect(controller.redo()).toBe(true)
+    expect(forward.history.cursor).toBe(controller.getSnapshot().cursor)
+    expect(forward.state).toEqual(controller.getSnapshot().state)
+  })
+
+  it('discards the redo branch on both paths when a new operation lands', () => {
+    const byHand = driveByHand()
+    const controller = driveByController()
+    const stepped = stepBack(byHand.history, byHand.state)
+    if (stepped === undefined) throw new Error('expected a step back')
+    expect(controller.undo()).toBe(true)
+
+    const reopened = record(stepped.history, stepped.state, [{ type: 'setExpanded', expanded: false }])
+    controller.setExpanded(false)
+    expect(recordedOps(reopened.history)).toEqual(controller.ops)
+    expect(stepForward(reopened.history, reopened.state)).toBeUndefined()
+    expect(controller.getSnapshot().canRedo).toBe(false)
+  })
+})
+
+describe('planned intents survive replay', () => {
+  it('rebuilds the same tree from the operations a planner produced', () => {
+    const { state, minter } = seededState()
+    const mint = minter.next
+    const ops: LayoutOp[] = []
+    let current = state
+
+    const push = (planned: readonly LayoutOp[]): void => {
+      ops.push(...planned)
+      current = applyAll(current, planned)
+    }
+    push(planSetExpanded(current, true))
+    push(planSplitPane(current, mint, undefined, seedTab))
+    const opened = planOpenContent(current, mint, { contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    push(opened.ops)
+    const second = getSplit(current, current.rootId).children[1]
+    if (second === undefined) throw new Error('expected a second pane')
+    push(planPlaceTab(current, opened.tabId, getPane(current, second).id, 0))
+    const floated = planFloatTab(current, mint, opened.tabId)
+    push(floated.ops)
+    push(planUnfloatPane(current, floated.paneId))
+
+    expect(applyAll(state, ops)).toEqual(current)
+    expect(fileTab(asTab('t'), 'dsh-resource://file/session/s/a.txt', 'a.txt').kind).toBe('file')
+  })
+})

+ 203 - 0
packages/client/ui-dockkit/tests/sequence.client.spec.ts

@@ -0,0 +1,203 @@
+/**
+ * Sequence behavior: linear history, exact inverses, the focus-run undo step, and
+ * the redo branch a new operation discards.
+ */
+import { describe, expect, it } from 'vitest'
+import { replay } from '../src/engine/operations.ts'
+import { createIdMinter, createInitialState } from '../src/engine/initial.ts'
+import { asPane, asTab, fileTab, seedTab } from './fixtures.client.ts'
+import { EMPTY_HISTORY, isFocusOp, record, Sequencer } from '../src/engine/sequence.ts'
+import { dockPaneIds, getPane, getSplit } from '../src/engine/tree.ts'
+import type { LayoutState, PaneId, TabId } from '../src/contract/types.ts'
+
+interface Fixture {
+  readonly initial: LayoutState
+  readonly sequencer: Sequencer
+  readonly minter: ReturnType<typeof createIdMinter>
+  readonly paneId: PaneId
+  readonly guideTabId: TabId
+}
+
+function fixture(): Fixture {
+  const minter = createIdMinter()
+  const initial = createInitialState(minter, seedTab)
+  const guideTabId = getPane(initial, initial.rootId).tabs[0]
+  if (guideTabId === undefined) throw new Error('fixture: initial pane has no guide tab')
+  return { initial, sequencer: new Sequencer(initial), minter, paneId: getPane(initial, initial.rootId).id, guideTabId }
+}
+
+describe('isFocusOp', () => {
+  it('names the operations that only move focus', () => {
+    expect(isFocusOp({ type: 'focusPane', paneId: asPane('p') })).toBe(true)
+    expect(isFocusOp({ type: 'focusTab', tabId: asTab('t') })).toBe(true)
+    expect(isFocusOp({ type: 'restoreFocus', activePaneId: asPane('p'), floats: [], paneActiveTabs: {} })).toBe(true)
+    expect(isFocusOp({ type: 'setExpanded', expanded: true })).toBe(false)
+  })
+})
+
+describe('recording', () => {
+  it('starts with nothing to step through', () => {
+    const { sequencer, initial } = fixture()
+    expect(sequencer.state).toBe(initial)
+    expect(sequencer.canUndo).toBe(false)
+    expect(sequencer.canRedo).toBe(false)
+    expect(sequencer.undo()).toBe(false)
+    expect(sequencer.redo()).toBe(false)
+  })
+
+  it('records every operation, focus moves included', () => {
+    const { sequencer, minter, paneId, guideTabId } = fixture()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 })
+    sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
+    sequencer.dispatch({ type: 'focusTab', tabId: tab.id })
+    expect(sequencer.ops.map(op => op.type)).toEqual(['openTab', 'focusTab', 'focusTab'])
+    expect(sequencer.cursor).toBe(3)
+  })
+
+  it('exposes the recorded sequence as plain history, and records nothing for an empty intent', () => {
+    const { sequencer, initial, paneId } = fixture()
+    expect(sequencer.history).toBe(EMPTY_HISTORY)
+    sequencer.dispatch({ type: 'focusPane', paneId })
+    expect(sequencer.history.entries).toHaveLength(1)
+    expect(sequencer.history.cursor).toBe(1)
+    const unchanged = record(EMPTY_HISTORY, initial, [])
+    expect(unchanged.history).toBe(EMPTY_HISTORY)
+    expect(unchanged.state).toBe(initial)
+    expect(sequencer.dispatchAll([])).toBe(sequencer.state)
+    expect(sequencer.history.entries).toHaveLength(1)
+  })
+
+  it('leaves the sequence untouched when an operation is invalid', () => {
+    const { sequencer } = fixture()
+    expect(() => sequencer.dispatch({ type: 'focusPane', paneId: asPane('nope') })).toThrow(/unknown node/)
+    expect(sequencer.ops).toHaveLength(0)
+    expect(sequencer.canUndo).toBe(false)
+  })
+})
+
+describe('stepping back and forward', () => {
+  it('returns to the exact previous state', () => {
+    const { sequencer, initial, minter, paneId } = fixture()
+    const newPaneId = minter.next('pane')
+    sequencer.dispatch({
+      type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'),
+    })
+    expect(dockPaneIds(sequencer.state)).toHaveLength(2)
+    expect(sequencer.undo()).toBe(true)
+    expect(sequencer.state).toEqual(initial)
+    expect(sequencer.canUndo).toBe(false)
+    expect(sequencer.canRedo).toBe(true)
+    expect(sequencer.redo()).toBe(true)
+    expect(dockPaneIds(sequencer.state)).toHaveLength(2)
+  })
+
+  it('collapses a run of consecutive focus moves into one step', () => {
+    const { sequencer, minter, paneId, guideTabId } = fixture()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 })
+    const afterOpen = sequencer.state
+    sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
+    sequencer.dispatch({ type: 'focusTab', tabId: tab.id })
+    sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
+    expect(sequencer.cursor).toBe(4)
+
+    expect(sequencer.undo()).toBe(true)
+    expect(sequencer.cursor).toBe(1)
+    expect(sequencer.state).toEqual(afterOpen)
+
+    expect(sequencer.redo()).toBe(true)
+    expect(sequencer.cursor).toBe(4)
+    expect(getPane(sequencer.state, paneId).activeTabId).toBe(guideTabId)
+  })
+
+  it('steps one structural operation even when focus moves precede it', () => {
+    const { sequencer, paneId, guideTabId } = fixture()
+    sequencer.dispatch({ type: 'focusPane', paneId })
+    sequencer.dispatch({ type: 'focusTab', tabId: guideTabId })
+    sequencer.dispatch({ type: 'setExpanded', expanded: true })
+    expect(sequencer.undo()).toBe(true)
+    expect(sequencer.cursor).toBe(2)
+    expect(sequencer.state.expanded).toBe(false)
+    expect(sequencer.undo()).toBe(true)
+    expect(sequencer.cursor).toBe(0)
+  })
+
+  it('undoes a whole session back to the initial state', () => {
+    const { sequencer, initial, minter, paneId, guideTabId } = fixture()
+    const newPaneId = minter.next('pane')
+    const floatId = minter.next('float')
+    sequencer.dispatch({ type: 'setExpanded', expanded: true })
+    sequencer.dispatch({
+      type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'),
+    })
+    sequencer.dispatch({ type: 'moveTab', tabId: guideTabId, toPaneId: newPaneId, index: 0 })
+    sequencer.dispatch({
+      type: 'float', tabId: guideTabId, newPaneId: floatId, rect: { x: 5, y: 6, width: 200, height: 100 },
+    })
+    sequencer.dispatch({ type: 'moveFloat', paneId: floatId, x: 50, y: 60 })
+    while (sequencer.undo()) { /* step back to the beginning */ }
+    expect(sequencer.state).toEqual(initial)
+    expect(sequencer.cursor).toBe(0)
+  })
+})
+
+describe('a floating panel\'s drag as one step', () => {
+  it('raises and focuses the panel with the move, and steps both back and forward together', () => {
+    const { sequencer, minter, paneId, guideTabId } = fixture()
+    const tab = fileTab(minter.next('tab'), 'dsh-resource://file/session/s/a.txt', 'a.txt')
+    const lower = minter.next('float')
+    const upper = minter.next('float')
+    sequencer.dispatch({ type: 'openTab', paneId, tab, index: 1 })
+    sequencer.dispatch({ type: 'float', tabId: guideTabId, newPaneId: lower, rect: { x: 0, y: 0, width: 200, height: 100 } })
+    sequencer.dispatch({ type: 'float', tabId: tab.id, newPaneId: upper, rect: { x: 20, y: 20, width: 200, height: 100 } })
+    sequencer.dispatch({ type: 'focusPane', paneId })
+    const before = sequencer.state
+    expect(before.floats).toEqual([lower, upper])
+
+    sequencer.dispatch({ type: 'moveFloat', paneId: lower, x: 50, y: 60 })
+    expect(sequencer.state.floats).toEqual([upper, lower])
+    expect(sequencer.state.activePaneId).toBe(lower)
+
+    // One step back restores the rectangle, the z order, and the focus; the
+    // preceding focus-only entry is its own step.
+    expect(sequencer.undo()).toBe(true)
+    expect(sequencer.state).toEqual(before)
+    expect(sequencer.redo()).toBe(true)
+    expect(sequencer.state.floats).toEqual([upper, lower])
+    expect(getPane(sequencer.state, lower).rect).toEqual({ x: 50, y: 60, width: 200, height: 100 })
+    expect(sequencer.state.activePaneId).toBe(lower)
+  })
+})
+
+describe('linear history', () => {
+  it('drops the redo branch when a new operation lands after an undo', () => {
+    const { sequencer, minter, paneId } = fixture()
+    sequencer.dispatch({ type: 'setExpanded', expanded: true })
+    sequencer.dispatch({
+      type: 'split', paneId, axis: 'row', direction: 'after', newPaneId: minter.next('pane'), newSplitId: minter.next('split'),
+    })
+    expect(sequencer.undo()).toBe(true)
+    expect(sequencer.canRedo).toBe(true)
+    sequencer.dispatch({ type: 'focusPane', paneId })
+    expect(sequencer.canRedo).toBe(false)
+    expect(sequencer.ops.map(op => op.type)).toEqual(['setExpanded', 'focusPane'])
+    expect(dockPaneIds(sequencer.state)).toHaveLength(1)
+  })
+
+  it('keeps the applied prefix replayable at any cursor', () => {
+    const { sequencer, initial, minter, paneId, guideTabId } = fixture()
+    const newPaneId = minter.next('pane')
+    sequencer.dispatch({ type: 'setExpanded', expanded: true })
+    sequencer.dispatch({
+      type: 'split', paneId, axis: 'row', direction: 'after', newPaneId, newSplitId: minter.next('split'),
+    })
+    sequencer.dispatch({ type: 'moveTab', tabId: guideTabId, toPaneId: newPaneId, index: 0 })
+    sequencer.dispatch({ type: 'resize', splitId: getSplit(sequencer.state, sequencer.state.rootId).id, sizes: [0.7, 0.3] })
+    sequencer.undo()
+    sequencer.undo()
+    expect(replay(initial, sequencer.ops.slice(0, sequencer.cursor))).toEqual(sequencer.state)
+    sequencer.redo()
+    expect(replay(initial, sequencer.ops.slice(0, sequencer.cursor))).toEqual(sequencer.state)
+  })
+})

+ 379 - 305
packages/client/ui-layout/tests/app-frame.client.spec.tsx

@@ -1,436 +1,510 @@
 // @vitest-environment jsdom
-/**
- * AppFrame interaction spec under the four-share props form: real layout
- * store instance (createLayoutStore().create() — the test-sanctioned engine
- * path), a recording renderSlot stub, and a SessionProvider component stub
- * (the real one is framework-wired to the renderer host; its own behavior is
- * ui-renderer's spec territory). Drag sequences (pointer capture + rAF flush),
- * concession response to viewport change, and details staying mounted at
- * zero width are the preserved behavior assertions. jsdom has no layout
- * engine, so the frame width comes from a mocked getBoundingClientRect and
- * resizes are driven through the ResizeObserver stub.
- */
+/** Frame interactions with a real store and explicitly driven browser measurements. */
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, render } from '@testing-library/react'
 import { useSyncExternalStore } from 'react'
-import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
-import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
-import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
-import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
+import { AppFrame } from '../src/client/AppFrame.tsx'
+import type { AppFrameProps } from '../src/client/AppFrame.tsx'
+import type { RightbarOwnerProps, SidebarOwnerProps } from '../src/client/index.ts'
+import { createLayoutStore } from '../src/client/stores.ts'
 import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 
-// Session selection controls for the SessionProvider and useSessions stubs.
-const selectedSession = { current: 's-test' as SessionId | undefined }
-const selectedSessionBlank = { current: false }
-const selectedSessionTitle = { current: undefined as string | undefined }
-const workspacesReady = { current: true }
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+let selectedSession: SessionId | undefined
+let selectedSessionTitle: string | undefined
+let workspacesReady = true
 type AttentionSnapshot = Parameters<Parameters<AppFrameProps['useSessionPendingInteraction']>[0]>[0]
 const noAttention: AttentionSnapshot = new Map()
 const useSessionPendingInteraction: AppFrameProps['useSessionPendingInteraction'] = selector => selector(noAttention)
-
-// Provider contract stub fed through the standard seat prop (the renderer
-// injects the real one in production): session mode renders children and
-// empty mode runs the empty branch.
 const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
-  selectedSession.current === undefined ? <>{empty?.() ?? null}</> : <>{children}</>
-
+  selectedSession === undefined ? <>{empty?.() ?? null}</> : <>{children}</>
 
-/** Observer stub: captures the callback so tests can fire resizes manually. */
-let fireResize: (() => void) | null = null
+let observers: ResizeObserverStub[]
 class ResizeObserverStub {
-  #cb: ResizeObserverCallback
-  constructor(cb: ResizeObserverCallback) { this.#cb = cb }
-  observe(): void { fireResize = () => { this.#cb([], this) } }
+  disconnected = false
+  constructor(private callback: ResizeObserverCallback) { observers.push(this) }
+  observe(): void {}
   unobserve(): void {}
-  disconnect(): void { fireResize = null }
+  disconnect(): void { this.disconnected = true }
+  fire(): void { this.callback([], this) }
+}
+
+let frameWidth: number
+let animationFrames: Map<number, FrameRequestCallback>
+let nextFrame: number
+let originalTitle: string
+const restoreProperties: (() => void)[] = []
+
+function replaceProperty<T extends object, K extends keyof T>(target: T, key: K, value: T[K]): void {
+  const descriptor = Object.getOwnPropertyDescriptor(target, key)
+  restoreProperties.push(() => {
+    if (descriptor === undefined) Reflect.deleteProperty(target, key)
+    else Object.defineProperty(target, key, descriptor)
+  })
+  Object.defineProperty(target, key, { configurable: true, writable: true, value })
 }
 
-let frameWidth = 1920
+/** Flush one browser frame without depending on the worker's timer cadence. */
+function flushFrames(): void {
+  for (const [id, callback] of [...animationFrames]) {
+    if (!animationFrames.delete(id)) continue
+    callback(0)
+  }
+}
+
+function resize(width: number): void {
+  frameWidth = width
+  act(() => {
+    for (const observer of observers) if (!observer.disconnected) observer.fire()
+    flushFrames()
+  })
+}
 
-/** Test-local selector hook over a framework-neutral store instance. */
 function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
   return function useSelector<S>(sel: (s: T) => S): S { return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot)) }
 }
 
-function mountFrame() {
-  window.innerWidth = frameWidth // first-render viewport source before the observer fires
+function mountFrame(windowWidth = frameWidth) {
+  vi.stubGlobal('innerWidth', windowWidth)
   const instance = createLayoutStore().create()
-  const slotCalls: { key: string; props: unknown }[] = []
+  const slotCalls: { key: string; props: object }[] = []
   const renderSlot = ((key: string, owner: object) => {
     slotCalls.push({ key, props: owner })
-    if (key === 'sidebar') return <div data-testid="sidebar-content" />
-    if (key === 'conversation') return <div data-testid="center-content" />
-    if (key === 'details') return <div data-testid="details-content" />
-    if (key === 'conversation.empty') return <div data-testid="empty-content" />
-    return <div data-testid="other-content" />
+    return <div data-testid={`${key}-content`} />
   }) as AppFrameProps['renderSlot']
-  const useSessions = ((sel: (s: SessionListState) => unknown) => {
-    const current = selectedSession.current
-    const sessionState = {
-      ids: current === undefined ? [] : [current],
-      byId: current === undefined
-        ? {}
-        : {
-          [current]: {
-            id: current,
-            displayTitle: 'Test',
-            running: false,
-            blank: selectedSessionBlank.current,
-            updatedAt: 1,
-            ...(selectedSessionTitle.current === undefined ? {} : { title: selectedSessionTitle.current }),
-          },
-        },
-      current,
-      phase: 'ready',
-    } as SessionListState
-    return sel(sessionState)
-  }) as never
+  const useSessions = ((sel: (s: SessionListState) => unknown) => sel({
+    ids: selectedSession === undefined ? [] : [selectedSession],
+    byId: selectedSession === undefined ? {} : {
+      [selectedSession]: {
+        id: selectedSession, displayTitle: 'Test', running: false, blank: false, updatedAt: 1,
+        ...(selectedSessionTitle === undefined ? {} : { title: selectedSessionTitle }),
+      },
+    },
+    current: selectedSession,
+    phase: 'ready',
+  } as SessionListState)) as AppFrameProps['useSessions']
   const workspaceState: WorkspaceSnapshot = {
     items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
-    ...(workspacesReady.current ? {} : { state: 'loading' as const, phase: 'pending' as const }),
+    ...(workspacesReady ? {} : { state: 'loading' as const, phase: 'pending' as const }),
   }
+  const useStore = hookOf(instance)
   const element = () => (
     <AppFrame
-      useStore={hookOf(instance)}
+      useStore={useStore}
       actions={instance.actions}
       renderSlot={renderSlot}
       useSessions={useSessions}
       useSessionPendingInteraction={useSessionPendingInteraction}
-      useWorkspaces={((sel: (s: WorkspaceSnapshot) => unknown) => sel(workspaceState)) as never}
+      useResource={useResource}
+      useWorkspaces={((sel: (s: WorkspaceSnapshot) => unknown) => sel(workspaceState)) as AppFrameProps['useWorkspaces']}
       SessionProvider={SessionProviderStub}
       t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key}
     />
   )
   const utils = render(element())
   const frame = utils.container.firstElementChild as HTMLElement
-  return { instance, frame, slotCalls, rerenderFrame: () => { utils.rerender(element()) }, ...utils }
+  return {
+    ...utils, instance, frame, slotCalls,
+    rerenderFrame: () => { utils.rerender(element()) },
+    rightOwner: () => slotCalls.findLast(c => c.key === 'rightbar')!.props as RightbarOwnerProps,
+    sidebarOwner: () => slotCalls.findLast(c => c.key === 'sidebar')!.props as SidebarOwnerProps,
+  }
 }
 
 function tracks(frame: HTMLElement): number[] {
-  const m = /^(\d+)px minmax\(0, 1fr\) (\d+)px$/.exec(frame.style.gridTemplateColumns)
-  if (m === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`)
-  return [Number(m[1]), Number(m[2])]
+  const match = /^([\d.]+)px minmax\(0, 1fr\) ([\d.]+)px$/.exec(frame.style.gridTemplateColumns)
+  if (match === null) throw new Error(`unexpected template: ${frame.style.gridTemplateColumns}`)
+  return [Number(match[1]), Number(match[2])]
+}
+
+function handleFor(frame: HTMLElement, side: 'sidebar' | 'rightbar'): HTMLElement {
+  const handle = frame.querySelector<HTMLElement>(`[data-side="${side}"]`)
+  if (handle === null) throw new Error(`missing ${side} resize handle`)
+  return handle
+}
+
+function pointer(handle: Element, type: string, clientX: number, pointerId = 1, button = 0): void {
+  act(() => { handle.dispatchEvent(new PointerEvent(type, { pointerId, clientX, button, bubbles: true })) })
 }
 
 function drag(handle: Element, fromX: number, toX: number): void {
-  const down = new PointerEvent('pointerdown', { pointerId: 1, clientX: fromX, bubbles: true })
-  const move = new PointerEvent('pointermove', { pointerId: 1, clientX: toX, bubbles: true })
-  const up = new PointerEvent('pointerup', { pointerId: 1, clientX: toX, bubbles: true })
-  act(() => { handle.dispatchEvent(down) })
-  act(() => { handle.dispatchEvent(move); vi.advanceTimersByTime(20) })
-  act(() => { handle.dispatchEvent(up) })
+  pointer(handle, 'pointerdown', fromX)
+  pointer(handle, 'pointermove', toX)
+  act(flushFrames)
+  pointer(handle, 'pointerup', toX)
 }
 
 beforeEach(() => {
+  originalTitle = document.title
   frameWidth = 1920
-  selectedSession.current = 's-test' as SessionId
-  selectedSessionBlank.current = false
-  selectedSessionTitle.current = undefined
-  workspacesReady.current = true
-  vi.useFakeTimers()
+  selectedSession = 's-test' as SessionId
+  selectedSessionTitle = undefined
+  workspacesReady = true
+  observers = []
+  animationFrames = new Map()
+  nextFrame = 1
+  vi.stubEnv('DSH_CLIENT_TITLE', undefined)
   vi.stubGlobal('ResizeObserver', ResizeObserverStub)
-  vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
-  vi.stubGlobal('cancelAnimationFrame', (h: number) => { clearTimeout(h) })
-  window.innerWidth = frameWidth
-  Element.prototype.getBoundingClientRect = function () {
-    return { width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080, x: 0, y: 0, toJSON: () => ({}) }
-  }
-  // jsdom lacks pointer capture: emulate per-element so hasPointerCapture gates pass.
-  const captured = new WeakSet<Element>()
-  Element.prototype.setPointerCapture = function () { captured.add(this) }
-  Element.prototype.releasePointerCapture = function () { captured.delete(this) }
-  Element.prototype.hasPointerCapture = function () { return captured.has(this) }
+  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
+    const id = nextFrame++
+    animationFrames.set(id, callback)
+    return id
+  })
+  vi.stubGlobal('cancelAnimationFrame', (id: number) => { animationFrames.delete(id) })
+  vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(() => ({
+    width: frameWidth, height: 1080, top: 0, left: 0, right: frameWidth, bottom: 1080,
+    x: 0, y: 0, toJSON: () => ({}),
+  }))
+  // jsdom has no pointer capture. Each element retains the actual pointer id,
+  // and teardown restores absent methods as well as existing descriptors.
+  const captured = new WeakMap<Element, number>()
+  replaceProperty(Element.prototype, 'setPointerCapture', function (this: Element, id: number) { captured.set(this, id) })
+  replaceProperty(Element.prototype, 'releasePointerCapture', function (this: Element) { captured.delete(this) })
+  replaceProperty(Element.prototype, 'hasPointerCapture', function (this: Element, id: number) { return captured.get(this) === id })
 })
 
 afterEach(() => {
-  cleanup()
-  document.title = ''
-  vi.useRealTimers()
-  vi.unstubAllGlobals()
-  vi.unstubAllEnvs()
+  try {
+    cleanup()
+  } finally {
+    for (const restore of restoreProperties.splice(0).reverse()) restore()
+    document.title = originalTitle
+    vi.restoreAllMocks()
+    vi.unstubAllGlobals()
+    vi.unstubAllEnvs()
+  }
 })
 
 describe('AppFrame', () => {
-  it('localizes the product title when the build does not supply one', () => {
+  it('localizes the product title without a configured build title', () => {
     mountFrame()
     expect(document.title).toBe('DSH Local Build')
   })
 
-  it('projects the selected durable Session title', () => {
+  it('follows the selected durable Session title', () => {
     vi.stubEnv('DSH_CLIENT_TITLE', 'Product')
-    selectedSessionTitle.current = 'First'
+    selectedSessionTitle = 'First'
     const { rerenderFrame } = mountFrame()
     expect(document.title).toBe('First — Product')
-
-    selectedSessionTitle.current = 'Revised'
-    act(() => { rerenderFrame() })
+    selectedSessionTitle = 'Revised'
+    rerenderFrame()
     expect(document.title).toBe('Revised — Product')
-
-    selectedSession.current = undefined
-    act(() => { rerenderFrame() })
+    selectedSession = undefined
+    rerenderFrame()
     expect(document.title).toBe('Product')
   })
 
-  it('renders three tracks from store state', () => {
-    const { frame } = mountFrame()
+  it('renders owner props for the default sidebar and prospective right panel', () => {
+    const { frame, rightOwner, sidebarOwner, slotCalls } = mountFrame()
     expect(tracks(frame)).toEqual([280, 0])
-  })
-
-  it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
-    const { slotCalls, getByTestId } = mountFrame()
-    expect(getByTestId('center-content')).toBeTruthy()
-    expect(getByTestId('details-content')).toBeTruthy()
-    const keys = slotCalls.map(c => c.key)
-    expect(keys).toContain('conversation')
-    expect(keys).toContain('details')
-    expect(keys).not.toContain('conversation.empty')
+    expect(sidebarOwner()).toEqual({ collapsed: false, width: 280 })
+    expect(rightOwner()).toEqual({ width: 864, viewportWidth: 1920, canShow: true })
     expect(slotCalls.find(c => c.key === 'conversation')!.props).toEqual({})
-    expect(slotCalls.find(c => c.key === 'details')!.props).toEqual({})
   })
 
-  it('keeps the conversation slot mounted while no session is current', () => {
-    // No current session: the session-maybe conversation shell owns the New
-    // Session view itself — the center column renders it unconditionally.
-    selectedSession.current = undefined
-    const { slotCalls, getByTestId, queryByTestId } = mountFrame()
-    expect(getByTestId('center-content')).toBeTruthy()
-    expect(slotCalls.map(c => c.key)).toContain('conversation')
-    expect(queryByTestId('details-content')).toBeNull()
-    expect(slotCalls.map(c => c.key)).toContain('details')
+  it('retains conversation and sidebar content without a current Session', () => {
+    selectedSession = undefined
+    const { frame, getByTestId, queryByTestId } = mountFrame()
+    expect(getByTestId('conversation-content')).toBeTruthy()
+    expect(getByTestId('sidebar-content')).toBeTruthy()
+    expect(queryByTestId('rightbar-content')).toBeNull()
+    expect(tracks(frame)).toEqual([280, 0])
   })
 
-  it('renders both column occupants before baselines settle (no loading gate)', () => {
-    // No loading gate: a bare loading status reads worse than the shell's own
-    // pending rendering — both occupants mount from first paint.
-    workspacesReady.current = false
-    const { slotCalls } = mountFrame()
-    expect(slotCalls.map(c => c.key)).toContain('conversation')
-    expect(slotCalls.map(c => c.key)).toContain('details')
+  it('renders both occupants before workspace baselines settle', () => {
+    workspacesReady = false
+    const { getByTestId } = mountFrame()
+    expect(getByTestId('conversation-content')).toBeTruthy()
+    expect(getByTestId('rightbar-content')).toBeTruthy()
   })
 
-  it('ignores unselected states and closes only when the Session id changes', () => {
-    const { frame, instance, rerenderFrame } = mountFrame()
-    expect(tracks(frame)).toEqual([280, 0])
-
-    act(() => { instance.actions.openDetails() })
-    expect(tracks(frame)).toEqual([280, 360])
-
-    selectedSession.current = 's-next' as SessionId
-    act(() => { rerenderFrame() })
-    expect(tracks(frame)).toEqual([280, 0])
-
-    act(() => { instance.actions.openDetails() })
-    selectedSession.current = 's-blank' as SessionId
-    selectedSessionBlank.current = true
-    act(() => { rerenderFrame() })
-    expect(tracks(frame)).toEqual([280, 0])
-    expect(instance.getSnapshot().details).toBe(360)
-
-    selectedSession.current = 's-next' as SessionId
-    selectedSessionBlank.current = false
-    act(() => { rerenderFrame() })
-    expect(tracks(frame)).toEqual([280, 360])
+  it('keeps the closed sidebar mounted at its 56px rail without a handle', () => {
+    const { frame, instance, sidebarOwner, getByTestId } = mountFrame()
+    act(() => { instance.actions.toggleSidebar() })
+    expect(tracks(frame)).toEqual([56, 0])
+    expect(sidebarOwner()).toEqual({ collapsed: true, width: 56 })
+    expect(getByTestId('sidebar-content')).toBeTruthy()
+    expect(frame.querySelector('[data-side="sidebar"]')).toBeNull()
+  })
+})
 
-    selectedSession.current = undefined
-    act(() => { rerenderFrame() })
-    expect(tracks(frame)).toEqual([280, 0])
-    selectedSession.current = 's-test' as SessionId
-    act(() => { rerenderFrame() })
-    expect(tracks(frame)).toEqual([280, 0])
+describe('AppFrame normal width concessions', () => {
+  it('measures the frame, not the window, before choosing the first-open preference', () => {
+    frameWidth = 1000
+    const { instance, rightOwner } = mountFrame(1920)
+    expect(instance.getSnapshot().viewportWidth).toBe(1000)
+    expect(rightOwner()).toEqual({ width: 450, viewportWidth: 1000, canShow: true })
+    act(() => { instance.actions.openRightbar(true, false) })
+    expect(instance.getSnapshot().rightbar).toBe(450)
+    resize(1920)
+    expect(rightOwner().width).toBe(450)
   })
 
-  it('keeps details closed when the first Session materializes', () => {
-    selectedSession.current = undefined
-    const { frame, instance, rerenderFrame } = mountFrame()
-    expect(tracks(frame)).toEqual([280, 0])
-    expect(instance.getSnapshot().details).toBe(0)
+  it('shrinks the right panel to 300px, drops its track, and only then squeezes center', () => {
+    const { frame, instance, rightOwner } = mountFrame()
+    act(() => { instance.actions.setSidebar(420); instance.actions.openRightbar(true, false) })
+    resize(1200)
+    expect(tracks(frame)).toEqual([420, 380])
+    expect(rightOwner()).toEqual({ width: 380, viewportWidth: 1200, canShow: true })
+    resize(1120)
+    expect(tracks(frame)).toEqual([420, 300])
+    resize(1119)
+    expect(tracks(frame)).toEqual([420, 0])
+    expect(rightOwner()).toEqual({ width: 0, viewportWidth: 1119, canShow: false })
+    expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
+    expect(instance.getSnapshot()).toMatchObject({ rightbarShown: true, rightbar: 864 })
+    act(() => { instance.actions.closeRightbar() })
+    resize(455)
+    expect(tracks(frame)).toEqual([56, 0])
+    resize(1920)
+    expect(tracks(frame)).toEqual([420, 0])
+  })
 
-    selectedSession.current = 's-first' as SessionId
-    act(() => { rerenderFrame() })
+  it('uses the post-collapse left rail to permit a narrow first opening', () => {
+    frameWidth = 800
+    const { frame, instance, rightOwner } = mountFrame()
+    act(() => { instance.actions.toggleSidebar() })
     expect(tracks(frame)).toEqual([280, 0])
+    expect(rightOwner()).toEqual({ width: 344, viewportWidth: 800, canShow: true })
+    act(() => { instance.actions.openRightbar(true, false) })
+    expect(tracks(frame)).toEqual([56, 344])
+    expect(instance.getSnapshot()).toMatchObject({ narrowExpanded: false, rightbar: 360 })
+    expect(rightOwner().canShow).toBe(true)
   })
 
-  it('sidebar slot receives live concession output as owner props', () => {
-    const { slotCalls } = mountFrame()
-    expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
+  it.each([[756, 300, true], [755, 0, false]] as const)('reports eligibility at %ipx', (width, rightbar, canShow) => {
+    frameWidth = width
+    const { instance, rightOwner } = mountFrame()
+    act(() => { instance.actions.toggleSidebar() })
+    expect(rightOwner()).toEqual({ width: rightbar, viewportWidth: width, canShow })
   })
 
-  it('sidebar drag widens through rAF-batched pointer moves', () => {
-    const { frame } = mountFrame()
-    const handles = frame.querySelectorAll('[class*="handle"]')
-    drag(handles[0]!, 280, 350)
-    expect(tracks(frame)[0]).toBe(350)
+  it('does not anticipate another left collapse after the right panel is already shown', () => {
+    frameWidth = 800
+    const { instance, rightOwner } = mountFrame()
+    act(() => { instance.actions.openRightbar(true, false); instance.actions.toggleSidebar() })
+    expect(rightOwner().canShow).toBe(false)
   })
 
-  it('details drag widens leftward (negative dx grows the panel)', () => {
+  it('auto-collapses only below 1024px and preserves the wide sidebar preference', () => {
     const { frame, instance } = mountFrame()
-    act(() => { instance.actions.openDetails() })
-    const handles = frame.querySelectorAll('[class*="handle"]')
-    drag(handles[1]!, 1560, 1500)
-    expect(tracks(frame)[1]).toBe(420)
+    act(() => { instance.actions.setSidebar(400) })
+    resize(1024)
+    expect(tracks(frame)[0]).toBe(400)
+    resize(1023)
+    expect(tracks(frame)[0]).toBe(56)
+    act(() => { instance.actions.toggleSidebar() })
+    expect(tracks(frame)[0]).toBe(400)
+    resize(980)
+    expect(tracks(frame)[0]).toBe(400)
+    act(() => { instance.actions.toggleSidebar() })
+    expect(tracks(frame)[0]).toBe(56)
+    resize(1920)
+    expect(tracks(frame)[0]).toBe(400)
   })
 
-  it('drag base is the rendered (concession-clamped) width, not the preference', () => {
-    frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360
+  it('re-expands a wide-closed sidebar at the default width while narrow', () => {
     const { frame, instance } = mountFrame()
-    act(() => { instance.actions.openDetails() })
-    expect(tracks(frame)).toEqual([280, 330])
-    const handles = frame.querySelectorAll('[class*="handle"]')
-    drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width
-    expect(instance.getSnapshot().details).toBe(320)
+    act(() => { instance.actions.toggleSidebar() })
+    resize(980)
+    act(() => { instance.actions.toggleSidebar() })
+    expect(tracks(frame)[0]).toBe(280)
+    expect(instance.getSnapshot().sidebar).toBe(0)
   })
+})
 
-  it('details column stays mounted at zero width', () => {
-    const { frame, getByTestId } = mountFrame()
+describe('AppFrame right panel presentation', () => {
+  it('preserves normal tracks through fullscreen and hides the outer resize handle', () => {
+    const { frame, instance, rightOwner } = mountFrame()
+    act(() => { instance.actions.openRightbar(true, false) })
+    expect(tracks(frame)).toEqual([280, 864])
+    expect(handleFor(frame, 'rightbar').style.left).toBe('1056px')
+    act(() => { instance.actions.openRightbar(true, true) })
+    expect(tracks(frame)).toEqual([280, 864])
+    expect(rightOwner().width).toBe(864)
+    expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
+    act(() => { instance.actions.openRightbar(true, false) })
+    expect(tracks(frame)).toEqual([280, 864])
+    expect(handleFor(frame, 'rightbar').style.left).toBe('1056px')
+    act(() => { instance.actions.closeRightbar() })
     expect(tracks(frame)).toEqual([280, 0])
-    expect(getByTestId('details-content')).toBeTruthy()
-    expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
+    expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
   })
 
-  it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => {
-    const { frame, instance, slotCalls, getByTestId } = mountFrame()
-    act(() => { instance.actions.toggleSidebar() })
-    expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
-    expect(getByTestId('sidebar-content')).toBeTruthy()
-    expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
-    const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)!
-    expect(lastSidebarCall.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
+  it('retains fullscreen without a track when normal columns cannot fit', () => {
+    frameWidth = 700
+    const { frame, instance, rightOwner } = mountFrame()
+    act(() => { instance.actions.openRightbar(false, true) })
+    expect(tracks(frame)).toEqual([56, 0])
+    expect(rightOwner()).toEqual({ width: 0, viewportWidth: 700, canShow: false })
+    expect(instance.getSnapshot().rightbarShown).toBe(true)
+    expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
   })
 
-  it('viewport shrink triggers the concession chain via ResizeObserver', () => {
-    const { frame, instance } = mountFrame()
-    act(() => { instance.actions.openDetails() })
-    frameWidth = 1250
-    act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
-    expect(tracks(frame)).toEqual([280, 330])
-    frameWidth = 1920
-    act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
-    expect(tracks(frame)).toEqual([280, 360])
+  it('keeps resolved panel width independent of the requested track', () => {
+    const { frame, instance, rightOwner } = mountFrame()
+    act(() => { instance.actions.openRightbar(false, false) })
+    resize(1100)
+    expect(tracks(frame)).toEqual([280, 0])
+    expect(rightOwner().width).toBe(420)
+    drag(handleFor(frame, 'rightbar'), 680, 690)
+    expect(instance.getSnapshot().rightbar).toBe(410)
+    expect(rightOwner().width).toBe(410)
+    expect(tracks(frame)[1]).toBe(0)
   })
+})
 
-  it('drag handles disappear for collapsed columns', () => {
+describe('AppFrame pointer resizing', () => {
+  it('updates columns during the gesture and freezes the drag-start width', () => {
     const { frame, instance } = mountFrame()
-    expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
-    act(() => { instance.actions.openDetails() })
-    expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
-    act(() => { instance.actions.closeDetails() })
-    expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
-    act(() => { instance.actions.toggleSidebar() })
-    expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
+    const handle = handleFor(frame, 'sidebar')
+    pointer(handle, 'pointerdown', 280)
+    expect(frame.dataset.dragging).toBe('true')
+    pointer(handle, 'pointermove', 320)
+    pointer(handle, 'pointermove', 340)
+    expect(animationFrames.size).toBe(1)
+    act(flushFrames)
+    expect(tracks(frame)[0]).toBe(340)
+    pointer(handle, 'pointermove', 360)
+    act(flushFrames)
+    expect(tracks(frame)[0]).toBe(360)
+    pointer(handle, 'pointerup', 360)
+    expect(instance.getSnapshot().sidebar).toBe(360)
+    expect(frame.dataset.dragging).toBeUndefined()
+    expect(handle.hasPointerCapture(1)).toBe(false)
   })
-})
 
-describe('AppFrame — narrow-viewport auto-collapse', () => {
-  it('mounts collapsed below the breakpoint with no sidebar handle', () => {
-    frameWidth = 980
-    const { frame, slotCalls } = mountFrame()
-    expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
-    expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
-    expect(slotCalls.filter(c => c.key === 'sidebar').at(-1)!.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
-    expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
+  it('starts a conceded right drag at its actual width, shared by panel and track', () => {
+    const { frame, instance, rightOwner } = mountFrame()
+    act(() => { instance.actions.openRightbar(true, false) })
+    resize(1100)
+    const handle = handleFor(frame, 'rightbar')
+    expect(rightOwner().width).toBe(420)
+    expect(tracks(frame)[1]).toBe(420)
+    expect(handle.style.left).toBe('680px')
+    drag(handle, 680, 690)
+    expect(instance.getSnapshot().rightbar).toBe(410)
+    expect(rightOwner().width).toBe(410)
+    expect(tracks(frame)[1]).toBe(410)
+    expect(handle.style.left).toBe('690px')
   })
 
-  it('narrow toggle re-expands over the squeezed center and back', () => {
-    frameWidth = 980
-    const { frame, instance } = mountFrame()
-    act(() => { instance.actions.toggleSidebar() })
-    expect(tracks(frame)).toEqual([280, 0])
-    expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(false)
-    expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
-    act(() => { instance.actions.toggleSidebar() })
-    expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
+  it('widens to the 70% limit and shrinks to 300px through pointer input', () => {
+    frameWidth = 3000
+    const { frame, instance, rightOwner } = mountFrame()
+    act(() => { instance.actions.toggleSidebar(); instance.actions.openRightbar(true, false) })
+    drag(handleFor(frame, 'rightbar'), 1650, 0)
+    expect(rightOwner().width).toBe(2100)
+    expect(tracks(frame)[1]).toBe(2100)
+    drag(handleFor(frame, 'rightbar'), 900, 3000)
+    expect(rightOwner().width).toBe(300)
+    expect(tracks(frame)[1]).toBe(300)
   })
 
-  it('a wide-closed preference re-expands at the contract default while narrow', () => {
-    frameWidth = 1920
+  it('commits the pointerup coordinate and cancels its pending animation frame', () => {
     const { frame, instance } = mountFrame()
-    act(() => { instance.actions.toggleSidebar() }) // close while wide: preference 0
-    frameWidth = 980
-    act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
-    act(() => { instance.actions.toggleSidebar() })
-    expect(tracks(frame)).toEqual([280, 0])
-    expect(instance.getSnapshot().sidebar).toBe(0) // preference untouched
+    const handle = handleFor(frame, 'sidebar')
+    pointer(handle, 'pointerdown', 280)
+    pointer(handle, 'pointermove', 320)
+    pointer(handle, 'pointerup', 360)
+    expect(instance.getSnapshot().sidebar).toBe(360)
+    expect(animationFrames.size).toBe(0)
+    act(flushFrames)
+    expect(instance.getSnapshot().sidebar).toBe(360)
   })
 
-  it('shrinking across the breakpoint auto-collapses; re-widening restores the drag width', () => {
+  it('ignores uncaptured motion, secondary buttons, and a second pointer', () => {
     const { frame, instance } = mountFrame()
-    act(() => { instance.actions.setSidebar(400) })
-    frameWidth = 980
-    act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
-    expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
-    frameWidth = 1920
-    act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
-    expect(tracks(frame)).toEqual([400, 0])
+    const handle = handleFor(frame, 'sidebar')
+    pointer(handle, 'pointermove', 500, 9)
+    pointer(handle, 'pointerup', 500, 9)
+    pointer(handle, 'pointercancel', 500, 9)
+    pointer(handle, 'pointerdown', 500, 9, 2)
+    expect(frame.dataset.dragging).toBeUndefined()
+    pointer(handle, 'pointerdown', 280)
+    pointer(handle, 'pointerdown', 500, 9)
+    pointer(handle, 'pointermove', 500, 9)
+    pointer(handle, 'pointerup', 500, 9)
+    expect(animationFrames.size).toBe(0)
+    expect(instance.getSnapshot().sidebar).toBe(280)
+    pointer(handle, 'pointerup', 300)
+    expect(instance.getSnapshot().sidebar).toBe(300)
   })
-})
 
-describe('AppFrame — guard branches', () => {
-  it('pointer moves without capture are ignored (no width write)', () => {
+  it.each(['pointercancel', 'lostpointercapture'])('ends %s without committing queued motion', (event) => {
     const { frame, instance } = mountFrame()
-    const handle = frame.querySelectorAll('[class*="handle"]')[0]!
-    const before = instance.getSnapshot().sidebar
-    // Move + up without a preceding pointerdown: hasPointerCapture is false.
-    act(() => {
-      handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 9, clientX: 500, bubbles: true }))
-      vi.advanceTimersByTime(20)
-      handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 9, clientX: 500, bubbles: true }))
-    })
-    expect(instance.getSnapshot().sidebar).toBe(before)
+    const handle = handleFor(frame, 'sidebar')
+    pointer(handle, 'pointerdown', 280)
+    pointer(handle, 'pointermove', 340)
+    if (event === 'lostpointercapture') handle.releasePointerCapture(1)
+    pointer(handle, event, 340)
+    act(flushFrames)
+    expect(instance.getSnapshot().sidebar).toBe(280)
+    expect(animationFrames.size).toBe(0)
+    expect(frame.dataset.dragging).toBeUndefined()
+    expect(handle.hasPointerCapture(1)).toBe(false)
   })
 
-  it('two moves inside one frame coalesce through the pending rAF', () => {
-    const { frame, instance } = mountFrame()
-    const handle = frame.querySelectorAll('[class*="handle"]')[0]!
-    act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
+  it.each(['fullscreen', 'close', 'unmount'])('cancels a pending drag on %s', (change) => {
+    const { frame, instance, unmount } = mountFrame()
+    act(() => { instance.actions.openRightbar(true, false) })
+    const handle = handleFor(frame, 'rightbar')
+    pointer(handle, 'pointerdown', 1056)
+    pointer(handle, 'pointermove', 1000)
     act(() => {
-      // Two moves before the frame flushes: the second must ride the pending
-      // rAF (frame.current ??= guard), and the flush sees the latest x.
-      handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 320, bubbles: true }))
-      handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 340, bubbles: true }))
-      vi.advanceTimersByTime(20)
+      if (change === 'fullscreen') instance.actions.openRightbar(true, true)
+      else if (change === 'close') instance.actions.closeRightbar()
+      else unmount()
     })
-    act(() => { handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 340, bubbles: true })) })
-    expect(instance.getSnapshot().sidebar).toBe(340)
+    const settled = instance.getSnapshot()
+    act(flushFrames)
+    expect(instance.getSnapshot()).toBe(settled)
+    expect(instance.getSnapshot().rightbar).toBe(864)
+    expect(animationFrames.size).toBe(0)
+    expect(handle.hasPointerCapture(1)).toBe(false)
+    if (change !== 'unmount') expect(frame.dataset.dragging).toBeUndefined()
   })
+})
 
-  it('pointerup with a pending rAF cancels it and commits the final position', () => {
-    const { frame, instance } = mountFrame()
-    const handle = frame.querySelectorAll('[class*="handle"]')[0]!
-    act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) })
+describe('AppFrame frame measurement lifecycle', () => {
+  it('coalesces observer reports and publishes the latest frame measurement', () => {
+    const { instance, rightOwner } = mountFrame()
+    const observer = observers.at(-1)!
     act(() => {
-      handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true }))
-      // No timer advance: the rAF is still pending when pointerup arrives.
-      handle.dispatchEvent(new PointerEvent('pointerup', { pointerId: 1, clientX: 360, bubbles: true }))
+      frameWidth = 900
+      observer.fire()
+      frameWidth = 1200
+      observer.fire()
     })
-    expect(instance.getSnapshot().sidebar).toBe(360)
+    expect(animationFrames.size).toBe(1)
+    expect(instance.getSnapshot().viewportWidth).toBe(1920)
+    act(flushFrames)
+    expect(instance.getSnapshot().viewportWidth).toBe(1200)
+    expect(rightOwner().viewportWidth).toBe(1200)
+    expect(instance.getSnapshot().rightbar).toBeNull()
   })
 
-  it('zero-width resize reports are ignored (display:none window)', () => {
-    const { frame } = mountFrame()
-    frameWidth = 0
-    act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
-    // Track template still reflects the last non-zero viewport.
-    expect(tracks(frame)).toEqual([280, 0])
+  it('retains the last positive measurement while the frame is hidden', () => {
+    const { instance, rightOwner } = mountFrame()
+    resize(0)
+    expect(instance.getSnapshot().viewportWidth).toBe(1920)
+    expect(rightOwner().viewportWidth).toBe(1920)
   })
-})
 
-describe('AppFrame — unmount with an in-flight resize frame', () => {
-  it('cancels the pending rAF on unmount (no post-unmount setState)', () => {
-    const { unmount } = mountFrame()
+  it('disconnects the observer and prevents queued or late reports after unmount', () => {
+    const { instance, unmount } = mountFrame()
+    const observer = observers.at(-1)!
     frameWidth = 800
-    act(() => { fireResize?.() }) // rAF scheduled, NOT flushed
+    act(() => { observer.fire() })
+    expect(animationFrames.size).toBe(1)
     unmount()
-    // Flushing after unmount must be a no-op (the frame was cancelled).
-    expect(() => { vi.advanceTimersByTime(20) }).not.toThrow()
-  })
-
-  it('double resize inside one frame rides the pending rAF (??= guard)', () => {
-    const { frame, instance } = mountFrame()
-    act(() => { instance.actions.openDetails() })
-    frameWidth = 1250
-    act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
-    expect(tracks(frame)).toEqual([280, 330])
+    expect(observer.disconnected).toBe(true)
+    expect(animationFrames.size).toBe(0)
+    act(() => { observer.fire(); flushFrames() })
+    expect(instance.getSnapshot().viewportWidth).toBe(1920)
+    expect(animationFrames.size).toBe(0)
   })
 })

+ 0 - 1
packages/client/ui-layout/tests/apply.client.spec.ts

@@ -43,7 +43,6 @@ describe('ui-layout client apply', () => {
     // …and declared the three children in the ledger.
     expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' })
     expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session-maybe' })
-    expect(slots.spec('details')).toEqual({ kind: 'single', scope: 'session' })
   })
 
   it('injects no business face and attaches the layout actions', async () => {

+ 28 - 70
packages/client/ui-layout/tests/columns.client.spec.ts

@@ -1,12 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import {
-  CENTER_MIN, clampWidth, computeColumns,
-  DETAILS_DEFAULT, DETAILS_MIN, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT, SIDEBAR_MIN,
-} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
-
-// Numeric preference form (0 = closed); helpers keep the scenario names readable.
-const open = (width: number) => width
-const closed = (_width: number) => 0
+import { clampWidth, computeColumns } from '../src/client/columns.ts'
 
 describe('clampWidth', () => {
   it('clamps into the range and rounds', () => {
@@ -17,79 +10,44 @@ describe('clampWidth', () => {
 })
 
 describe('computeColumns', () => {
-  it('step 1: everything fits at preferred widths', () => {
-    const cols = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
-    expect(cols).toEqual({ sidebar: 280, center: 1920 - 280 - 360, details: 360 })
-  })
-
-  it('closed sidebar keeps its compact rail while closed details contribute zero width', () => {
-    expect(computeColumns(1920, closed(300), closed(360)))
-      .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 1920 - SIDEBAR_COLLAPSED, details: 0 })
-  })
-
-  it('preferences beyond the clamp range are clamped before solving', () => {
-    const cols = computeColumns(1920, open(9999), open(1))
-    expect(cols.sidebar).toBe(420)
-    expect(cols.details).toBe(300)
-    expect(computeColumns(1920, open(1), open(DETAILS_DEFAULT)).sidebar).toBe(SIDEBAR_MIN)
+  it('gives each edge column its preference when the center has enough room', () => {
+    expect(computeColumns(1920, 280, 864)).toEqual({ sidebar: 280, center: 776, rightbar: 864 })
   })
 
-  it('step 2: details shrinks first, center pinned at min', () => {
-    // 280 + 360 + 640 = 1280 > 1250; details concedes to 1250-280-640 = 330.
-    const cols = computeColumns(1250, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
-    expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: 330 })
+  it('keeps only the left rail when both panels are closed', () => {
+    expect(computeColumns(1920, 0, 0)).toEqual({ sidebar: 56, center: 1864, rightbar: 0 })
   })
 
-  it('boundary: exactly at the step-1/step-2 seam', () => {
-    const cols = computeColumns(300 + 360 + CENTER_MIN, open(300), open(360))
-    expect(cols).toEqual({ sidebar: 300, center: CENTER_MIN, details: 360 })
-    const one = computeColumns(300 + 360 + CENTER_MIN - 1, open(300), open(360))
-    expect(one).toEqual({ sidebar: 300, center: CENTER_MIN, details: 359 })
+  it('clamps sidebar preferences and limits the right panel to 70% of the frame', () => {
+    expect(computeColumns(3000, 9999, 9999)).toEqual({ sidebar: 420, center: 480, rightbar: 2100 })
+    expect(computeColumns(1920, 1, 1)).toEqual({ sidebar: 264, center: 1356, rightbar: 300 })
   })
 
-  it('step 3: details auto-closes when its min still starves center — sidebar holds its preference', () => {
-    // 280 + 300 + 640 = 1220 > 1210 → details 0; sidebar untouched: center = 1210-280 = 930.
-    const cols = computeColumns(1210, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
-    expect(cols).toEqual({ sidebar: 280, center: 930, details: 0 })
+  it.each([
+    [1300, 280, 620, 400],
+    [1100, 280, 420, 400],
+    [1120, 420, 300, 400],
+    [1119, 420, 0, 699],
+    [1024, 420, 0, 604],
+    [756, 0, 300, 400],
+    [755, 0, 0, 699],
+    [455, 0, 0, 399],
+    [20, 0, 0, 0],
+  ])('solves frame %i and sidebar %i to right %i and center %i', (viewport, sidebar, rightbar, center) => {
+    expect(computeColumns(viewport, sidebar, 864)).toEqual({ sidebar: sidebar || 56, center, rightbar })
   })
 
-  it('the sidebar never concedes: center absorbs the deficit below CENTER_MIN', () => {
-    // 700 < 280+640: sidebar keeps 280, center takes 420 < CENTER_MIN.
-    const cols = computeColumns(700, open(SIDEBAR_DEFAULT), closed(DETAILS_DEFAULT))
-    expect(cols).toEqual({ sidebar: SIDEBAR_DEFAULT, center: 420, details: 0 })
+  it('does not reduce the wide sidebar to keep a normal right panel open', () => {
+    expect(computeColumns(1024, 420, 500)).toEqual({ sidebar: 420, center: 604, rightbar: 0 })
   })
 
-  it('sidebar-closed narrow window: details concedes then auto-closes', () => {
-    const fits = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN, closed(300), open(DETAILS_DEFAULT))
-    expect(fits).toEqual({ sidebar: SIDEBAR_COLLAPSED, center: CENTER_MIN, details: DETAILS_MIN })
-    const starved = computeColumns(SIDEBAR_COLLAPSED + DETAILS_MIN + CENTER_MIN - 1, closed(300), open(DETAILS_DEFAULT))
-    expect(starved).toEqual({
-      sidebar: SIDEBAR_COLLAPSED,
-      center: DETAILS_MIN + CENTER_MIN - 1,
-      details: 0,
-    })
+  it('restores a still-open preference when the frame widens', () => {
+    expect(computeColumns(1100, 280, 864).rightbar).toBe(420)
+    expect(computeColumns(1920, 280, 864).rightbar).toBe(864)
   })
 
-  it('tiny viewport: details closes, sidebar holds, center takes the remainder', () => {
-    const cols = computeColumns(400, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
-    expect(cols.details).toBe(0)
-    expect(cols.sidebar).toBe(SIDEBAR_DEFAULT)
-    expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_DEFAULT))
-  })
-
-  it('recovery is pure: re-widening restores preferred widths untouched', () => {
-    const squeezed = computeColumns(1100, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
-    expect(squeezed.details).toBe(0)
-    const restored = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT))
-    expect(restored.details).toBe(DETAILS_DEFAULT)
-    expect(restored.sidebar).toBe(SIDEBAR_DEFAULT)
-  })
-})
-
-describe('computeColumns — degenerate viewports', () => {
-  it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes the rest', () => {
-    // Reaches step 3's auto-close with the compact rail sidebar.
-    expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT)))
-      .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 })
+  it('leaves a closed right track closed when the frame widens', () => {
+    expect(computeColumns(755, 0, 0).rightbar).toBe(0)
+    expect(computeColumns(1920, 0, 0).rightbar).toBe(0)
   })
 })

+ 113 - 60
packages/client/ui-layout/tests/layout-store.client.spec.ts

@@ -1,103 +1,156 @@
 // @vitest-environment jsdom
-/**
- * createLayoutStore unit account: init shape, the action write set (clamp
- * inside actions), and the absence of browser persistence. Uses the
- * test-sanctioned path: factory self-call + .create() gives the
- * real engine instance (same create path as production).
- */
-import { beforeEach, describe, expect, it } from 'vitest'
-import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
-import {
-  DETAILS_DEFAULT, DETAILS_MAX, DETAILS_MIN,
-  SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
-} from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
 
-const PERSIST_KEY = 'dsh.layout.panels'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createLayoutStore } from '../src/client/stores.ts'
 
-beforeEach(() => { localStorage.clear() })
+beforeEach(() => { vi.stubGlobal('innerWidth', 1920) })
+afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
 
 describe('createLayoutStore', () => {
-  it('initializes the sidebar at its default width, details closed, wide viewport assumed', () => {
+  it('starts with the default sidebar and no right panel preference', () => {
     const { store } = createLayoutStore().create()
-    expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0, narrow: false, narrowExpanded: false })
+    expect(store.getSnapshot()).toEqual({
+      sidebar: 280,
+      viewportWidth: 1920,
+      narrowExpanded: false,
+      rightbar: null,
+      rightbarShown: false,
+      rightbarTrack: false,
+      rightbarFullscreen: false,
+    })
   })
 
-  it('each create() is an independent instance (factory is not a singleton)', () => {
+  it('creates independent instances without browser persistence', () => {
+    const write = vi.spyOn(Storage.prototype, 'setItem')
     const a = createLayoutStore().create()
     const b = createLayoutStore().create()
     a.actions.setSidebar(400)
-    expect(b.store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
+    a.actions.openRightbar(true, false)
+    expect(b.store.getSnapshot().sidebar).toBe(280)
+    expect(b.store.getSnapshot().rightbar).toBeNull()
+    expect(write).not.toHaveBeenCalled()
   })
 
-  it('setSidebar/setDetails clamp into the contract ranges', () => {
+  it('clamps the sidebar to 264–420px', () => {
     const { store, actions } = createLayoutStore().create()
     actions.setSidebar(1)
-    expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MIN)
+    expect(store.getSnapshot().sidebar).toBe(264)
     actions.setSidebar(9999)
-    expect(store.getSnapshot().sidebar).toBe(SIDEBAR_MAX)
-    actions.setDetails(1)
-    expect(store.getSnapshot().details).toBe(DETAILS_MIN)
-    actions.setDetails(9999)
-    expect(store.getSnapshot().details).toBe(DETAILS_MAX)
+    expect(store.getSnapshot().sidebar).toBe(420)
   })
 
-  it('toggleSidebar flips closed <-> contract default (drag width forgotten)', () => {
+  it('toggles the wide sidebar between closed and default width', () => {
     const { store, actions } = createLayoutStore().create()
     actions.setSidebar(400)
     actions.toggleSidebar()
     expect(store.getSnapshot().sidebar).toBe(0)
     actions.toggleSidebar()
-    expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
+    expect(store.getSnapshot().sidebar).toBe(280)
   })
 
-  it('narrow toggleSidebar flips only the re-expand override; the width preference survives', () => {
+  it('keeps the sidebar preference while toggling its narrow override', () => {
     const { store, actions } = createLayoutStore().create()
     actions.setSidebar(400)
-    actions.setNarrow(true)
+    actions.setViewportWidth(980)
     actions.toggleSidebar()
-    expect(store.getSnapshot()).toEqual({ sidebar: 400, details: 0, narrow: true, narrowExpanded: true })
+    expect(store.getSnapshot()).toMatchObject({ sidebar: 400, viewportWidth: 980, narrowExpanded: true })
     actions.toggleSidebar()
-    expect(store.getSnapshot().narrowExpanded).toBe(false)
-    expect(store.getSnapshot().sidebar).toBe(400)
+    expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false })
   })
 
-  it('crossing the breakpoint drops the override; a same-value setNarrow keeps it', () => {
+  it('clears the manual override only when crossing 1024px', () => {
     const { store, actions } = createLayoutStore().create()
-    actions.setNarrow(true)
+    actions.setViewportWidth(980)
     actions.toggleSidebar()
+    actions.setViewportWidth(980)
+    actions.setViewportWidth(1023)
     expect(store.getSnapshot().narrowExpanded).toBe(true)
-    actions.setNarrow(true)
-    expect(store.getSnapshot().narrowExpanded).toBe(true)
-    actions.setNarrow(false)
-    expect(store.getSnapshot()).toMatchObject({ narrow: false, narrowExpanded: false })
-    actions.setNarrow(true)
+    actions.setViewportWidth(1024)
     expect(store.getSnapshot().narrowExpanded).toBe(false)
+    actions.setViewportWidth(980)
+    expect(store.getSnapshot().narrowExpanded).toBe(false)
+  })
+})
+
+describe('right panel', () => {
+  it('initializes at 45% of the latest frame only on first opening', () => {
+    const { store, actions } = createLayoutStore().create()
+    actions.setViewportWidth(1000)
+    expect(store.getSnapshot().rightbar).toBeNull()
+    actions.openRightbar(true, false)
+    expect(store.getSnapshot().rightbar).toBe(450)
+    actions.setViewportWidth(2000)
+    actions.openRightbar(true, true)
+    expect(store.getSnapshot().rightbar).toBe(450)
+    actions.closeRightbar()
+    actions.openRightbar(true, false)
+    expect(store.getSnapshot().rightbar).toBe(450)
   })
 
-  it('openDetails uses the contract default, preserves an open width, and closeDetails zeroes', () => {
+  it('keeps track and fullscreen reports independent and clears both on close', () => {
     const { store, actions } = createLayoutStore().create()
-    actions.openDetails()
-    expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT)
-    actions.setDetails(500)
-    actions.openDetails()
-    expect(store.getSnapshot().details).toBe(500)
-    actions.closeDetails()
-    expect(store.getSnapshot().details).toBe(0)
+    actions.openRightbar(true, false)
+    expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false })
+    actions.openRightbar(true, true)
+    expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: true, rightbarFullscreen: true })
+    actions.openRightbar(false, true)
+    expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarTrack: false, rightbarFullscreen: true })
+    actions.closeRightbar()
+    expect(store.getSnapshot()).toMatchObject({ rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false })
   })
 
-  it('does not persist panel geometry', () => {
-    const first = createLayoutStore().create()
-    first.actions.setSidebar(400)
-    first.actions.openDetails()
-    first.actions.setDetails(500)
-    expect(localStorage.getItem(PERSIST_KEY)).toBeNull()
+  it('keeps dragged px preferences across resize, close, and reopen', () => {
+    const { store, actions } = createLayoutStore().create()
+    actions.openRightbar(true, false)
+    actions.setRightbar(1100)
+    actions.setViewportWidth(800)
+    expect(store.getSnapshot().rightbar).toBe(1100)
+    actions.closeRightbar()
+    actions.openRightbar(false, true)
+    expect(store.getSnapshot().rightbar).toBe(1100)
+  })
 
-    const second = createLayoutStore().create()
-    expect(second.store.getSnapshot()).toEqual({
-      sidebar: SIDEBAR_DEFAULT,
-      details: 0,
-      narrow: false,
-      narrowExpanded: false,
-    })
+  it('clamps drag preferences to 300px and 70% of the current frame', () => {
+    const { store, actions } = createLayoutStore().create()
+    actions.setViewportWidth(1600)
+    actions.setRightbar(9999)
+    expect(store.getSnapshot().rightbar).toBe(1120)
+    actions.setViewportWidth(1000)
+    actions.setRightbar(9999)
+    expect(store.getSnapshot().rightbar).toBe(700)
+    actions.setRightbar(1)
+    expect(store.getSnapshot().rightbar).toBe(300)
+  })
+
+  it('retains a minimum normal preference when first opened fullscreen on a phone', () => {
+    const { store, actions } = createLayoutStore().create()
+    actions.setViewportWidth(320)
+    actions.openRightbar(false, true)
+    expect(store.getSnapshot().rightbar).toBe(300)
+  })
+
+  it('collapses a manually expanded narrow sidebar on opening, not presentation reports', () => {
+    const { store, actions } = createLayoutStore().create()
+    actions.setSidebar(400)
+    actions.setViewportWidth(800)
+    actions.toggleSidebar()
+    actions.openRightbar(true, false)
+    expect(store.getSnapshot()).toMatchObject({ sidebar: 400, narrowExpanded: false })
+    actions.toggleSidebar()
+    actions.openRightbar(true, true)
+    expect(store.getSnapshot().narrowExpanded).toBe(true)
+    actions.closeRightbar()
+    actions.openRightbar(true, false)
+    expect(store.getSnapshot().narrowExpanded).toBe(false)
+  })
+
+  it('keeps the wide sidebar preference and never opens a closed right panel on resize', () => {
+    const { store, actions } = createLayoutStore().create()
+    actions.setSidebar(420)
+    actions.openRightbar(true, false)
+    expect(store.getSnapshot().sidebar).toBe(420)
+    actions.closeRightbar()
+    actions.setViewportWidth(3000)
+    expect(store.getSnapshot()).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false })
   })
 })

+ 24 - 13
packages/client/ui-layout/tests/service.client.spec.ts

@@ -1,40 +1,51 @@
 import { describe, expect, it, vi } from 'vitest'
-import { LayoutController } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
-import type { PanelActions } from '@deepseek-ai/dsh-client-ui-layout/src/client/service.ts'
+import { LayoutController } from '../src/client/service.ts'
+import type { PanelActions } from '../src/client/service.ts'
 
 function fakePanels(): PanelActions {
   return {
     setSidebar: vi.fn(),
-    setDetails: vi.fn(),
     toggleSidebar: vi.fn(),
-    setNarrow: vi.fn(),
-    openDetails: vi.fn(),
-    closeDetails: vi.fn(),
+    setViewportWidth: vi.fn(),
+    setRightbar: vi.fn(),
+    openRightbar: vi.fn(),
+    closeRightbar: vi.fn(),
   }
 }
 
 describe('LayoutController', () => {
+  it('forwards the right column transitions to the attached set', () => {
+    const service = new LayoutController()
+    const panels = fakePanels()
+    service.attachPanels(panels)
+
+    service.openRightbar(true, false)
+    service.openRightbar(true, true)
+    service.openRightbar(false, true)
+    service.closeRightbar()
+
+    expect(panels.openRightbar).toHaveBeenNthCalledWith(1, true, false)
+    expect(panels.openRightbar).toHaveBeenNthCalledWith(2, true, true)
+    expect(panels.openRightbar).toHaveBeenNthCalledWith(3, false, true)
+    expect(panels.closeRightbar).toHaveBeenCalledTimes(1)
+    // The drag width stays the frame's own business, never the caller's.
+    expect(panels.setRightbar).not.toHaveBeenCalled()
+  })
+
   it('forwards the three panel actions to the attached set', () => {
     const service = new LayoutController()
     const panels = fakePanels()
     service.attachPanels(panels)
 
     service.toggleSidebar()
-    service.openDetails()
-    service.closeDetails()
 
     expect(panels.toggleSidebar).toHaveBeenCalledTimes(1)
-    expect(panels.openDetails).toHaveBeenCalledTimes(1)
-    expect(panels.closeDetails).toHaveBeenCalledTimes(1)
     expect(panels.setSidebar).not.toHaveBeenCalled()
-    expect(panels.setDetails).not.toHaveBeenCalled()
   })
 
   it('fails loud before the root entry wired its actions', () => {
     const service = new LayoutController()
     expect(() => { service.toggleSidebar() }).toThrow(/panel actions not wired/)
-    expect(() => { service.openDetails() }).toThrow(/panel actions not wired/)
-    expect(() => { service.closeDetails() }).toThrow(/panel actions not wired/)
   })
 
   it('re-attach overwrites the stale action set (entry re-register)', () => {

+ 5 - 0
packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { Context } from '@deepseek-ai/cordis'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
@@ -10,6 +11,9 @@ import { zh } from '../src/client/locales.ts'
 import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
 import { PermissionPresetSettingsController } from '../src/client/settings-store.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 const schema = new SettingsSchemaService(new Context())
 
 /** Controller over a real mirror derived from the same scripted context. */
@@ -56,6 +60,7 @@ const useSessionPendingInteraction: PermissionRowProps['useSessionPendingInterac
 const runtime = {
   useSessions: (() => { throw new Error('unused') }) as never,
   useSessionPendingInteraction,
+  useResource,
   useWorkspaces: (() => { throw new Error('unused') }) as never,
 }
 

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

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
@@ -10,6 +11,9 @@ import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx
 import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
 import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 /** Store over a real mirror derived from the same scripted context. */
 function derivedDocumentStore(remote: object) {
   const ctx = { remote } as never
@@ -28,7 +32,7 @@ const unusedHook = (() => { throw new Error('unused by settings-general componen
 type AttentionSnapshot = Parameters<Parameters<TriggerContentProps['useSessionPendingInteraction']>[0]>[0]
 const noAttention: AttentionSnapshot = new Map()
 const useSessionPendingInteraction: TriggerContentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
-const kit = { useSessions: unusedHook, useSessionPendingInteraction, useWorkspaces: unusedHook }
+const kit = { useSessions: unusedHook, useSessionPendingInteraction, useResource, useWorkspaces: unusedHook }
 
 describe('chrome content', () => {
   it('TriggerContent renders the icon with the label in the wide column', () => {

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

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { useEffect, useState } from 'react'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
@@ -7,6 +8,9 @@ import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts
 import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
 import { en } from '../src/client/locales.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(() => {
   cleanup()
   vi.useRealTimers()
@@ -72,6 +76,7 @@ function mount({
   const props: SettingsRootComponentProps = {
     useSessions,
     useSessionPendingInteraction,
+    useResource,
     useWorkspaces: unusedHook,
     wide,
     reconnect,

+ 5 - 0
packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx

@@ -1,5 +1,6 @@
 // @vitest-environment jsdom
 /** First-run DeepSeek prompt behavior over the shared Models join. */
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import Schema from '@deepseek-ai/schemastery'
@@ -14,6 +15,9 @@ import { createModelsOperations } from '../src/client/operations.ts'
 import { en } from '../src/client/locales.ts'
 import { settingsSchema } from './settings-schema.client.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(() => {
   cleanup()
   document.getElementById('root')?.remove()
@@ -143,6 +147,7 @@ function harness(options: {
     openSection,
     useSessions: unusedHook,
     useSessionPendingInteraction,
+    useResource,
     useWorkspaces: unusedHook,
     controller,
     useModels: bindSnapshotSelector(controller.store),

+ 5 - 0
packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
@@ -7,6 +8,9 @@ import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/c
 import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
 import { SettingsScopeController } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 /** Stateless schema service for scope construction in this jsdom fixture. */
 const schemaService = new SettingsSchemaService(new Context())
 import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
@@ -89,6 +93,7 @@ function mount(
     openSection: vi.fn(),
     useSessions: unusedHook,
     useSessionPendingInteraction,
+    useResource,
     useWorkspaces: unusedHook,
     controller,
     useWelcome: bindSnapshotSelector(controller.store),

+ 89 - 0
packages/client/ui-sidebar-files/tests/apply.client.spec.ts

@@ -0,0 +1,89 @@
+/**
+ * The plugin's registrations, and their removal when the plugin goes.
+ *
+ * The registry is real, because "registered" means what it says a type is; the
+ * slot, locale, and Remote faces are recorders, because what matters here is
+ * what was handed to them — one body seat under the type's id with its store
+ * and face — and that every registration is gone after dispose, which is what
+ * makes a reload safe.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
+import { FILES_ID, FILES_KIND } from '../src/client/definition.ts'
+import { apply, inject } from '../src/client/index.ts'
+import { apply as hostApply } from '../src/index.ts'
+import { FilesBody } from '../src/client/FilesBody.tsx'
+import { en, zh } from '../src/client/locales.ts'
+
+interface Recorded {
+  name: string
+  key: string
+  locale: string
+  store: unknown
+  inject: unknown
+  component: unknown
+}
+
+async function boot() {
+  const ctx = new Context()
+  const tabs = new SidebarRightTabRegistry(ctx)
+  const registered: Recorded[] = []
+  const slots = {
+    inject: vi.fn((_name: string, register: () => () => void) => register()),
+    register: vi.fn((options: Omit<Recorded, 'component'>, component: unknown) => {
+      const entry: Recorded = { ...options, component }
+      registered.push(entry)
+      return () => { registered.splice(registered.indexOf(entry), 1) }
+    }),
+  }
+  const dictionaries = new Map<string, unknown>()
+  const locale = {
+    // Copy is the dictionary's contract; the key stands in for the translation.
+    bind: vi.fn(() => (key: string) => key),
+    register: vi.fn((ns: string, dicts: unknown) => {
+      dictionaries.set(ns, dicts)
+      return () => { dictionaries.delete(ns) }
+    }),
+  }
+  const workspaceFiles = { list: vi.fn() }
+  ctx.provide('sidebarRightTabs', tabs as never)
+  ctx.provide('slots', slots as never)
+  ctx.provide('locale', locale as never)
+  ctx.provide('remote', { workspaceFiles } as never)
+  ctx.provide('remote.workspaceFiles', workspaceFiles as never)
+  const fiber = ctx.plugin({ inject: [...inject], apply })
+  await fiber.await()
+  return { tabs, registered, dictionaries, fiber }
+}
+
+describe('ui-sidebar-files apply', () => {
+  it('keeps the host Loader entry inert', () => {
+    expect(hostApply).not.toThrow()
+  })
+
+  it('registers the type, its dictionaries, and the body seat under the type\'s id with a store and a face', async () => {
+    const { tabs, registered, dictionaries } = await boot()
+    const definition = tabs.get(FILES_KIND)
+    expect(definition?.id).toBe(FILES_ID)
+    expect(definition?.priority).toBe('builtin')
+    expect(definition?.title('sidebar://files')).toBe('type.label')
+    expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description()])).toEqual([[10, 'guide.title', 'guide.description']])
+    expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en })
+    // The seat key is the implementation's id, not the kind: an extension may
+    // take the kind over, and the seat must still find this body.
+    expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
+      ['sidebar.right.pane.tab', FILES_ID, 'sidebarFiles', FilesBody],
+    ])
+    expect(registered[0]?.store).toBeDefined()
+    expect(typeof registered[0]?.inject).toBe('function')
+  })
+
+  it('takes every registration back when the plugin is disposed', async () => {
+    const { tabs, registered, dictionaries, fiber } = await boot()
+    await fiber.dispose()
+    expect(tabs.get(FILES_KIND)).toBeUndefined()
+    expect(registered).toEqual([])
+    expect(dictionaries.size).toBe(0)
+  })
+})

+ 44 - 0
packages/client/ui-sidebar-files/tests/definition.client.spec.ts

@@ -0,0 +1,44 @@
+/**
+ * Stage one, as the registry sees it: the type is a page that claims no
+ * address, sits in the builtin band, and offers the guide page one entry that
+ * opens its kind.
+ */
+import { describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
+import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
+import {
+  FILES_ID, FILES_KIND, filesDefinition,
+} from '../src/client/definition.ts'
+import { zh } from '../src/client/locales.ts'
+
+const t = makeTranslate(zh)
+
+describe('filesDefinition', () => {
+  it('registers under its kind and id and claims no address', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(filesDefinition(t))
+    expect(registry.get(FILES_KIND)?.id).toBe(FILES_ID)
+    expect(registry.candidates(sessionFileAddress('s-1', '/work/app/a.ts'))).toEqual([])
+  })
+
+  it('offers the guide page one entry at order 10 that opens the files kind', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(filesDefinition(t))
+    const [entry, ...rest] = registry.guide()
+    expect(rest).toEqual([])
+    expect(entry?.order).toBe(10)
+    expect(entry?.kind).toBe(FILES_KIND)
+    expect(entry?.title()).toBe(zh['guide.title'])
+    expect(entry?.description()).toBe(zh['guide.description'])
+    expect(entry?.icon).toBeDefined()
+  })
+
+  it('sits in the builtin band and titles itself from the dictionary', () => {
+    const definition = filesDefinition(t)
+    expect(definition.priority).toBe('builtin')
+    expect(definition.patterns).toBeUndefined()
+    expect(definition.title('')).toBe(zh['type.label'])
+  })
+})

+ 143 - 0
packages/client/ui-sidebar-files/tests/face.client.spec.ts

@@ -0,0 +1,143 @@
+/**
+ * The tree's asynchronous half against a scripted listing, and the Remote
+ * adapter under it.
+ *
+ * The face's contract is what reaches the store and when: a level is `loading`
+ * before the listing settles, `ready` or `failed` after, never written once the
+ * owner's signal aborted or a newer listing of the level was asked for, and a
+ * tab whose record is gone leaves no bucket behind. The adapter's is what it
+ * keeps and what it drops: entries and the
+ * truncation flag reach the store, the endpoint's workspace-relative path does
+ * not, and a failure passes through untouched.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceDirectoryListing } from '@deepseek-ai/dsh-api-workspace-files/types'
+import { childPath, createList, filesFace } from '../src/client/face.ts'
+import type { WorkspaceFilesListRemote } from '../src/client/face.ts'
+import { createFilesStore } from '../src/client/store.ts'
+import type { DirLevel } from '../src/client/store.ts'
+import { scriptedList } from './scripted-list.client.ts'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+
+const SESSION = 's-1' as SessionId
+const ROOT = '/work/app'
+const TAB = 'tab-1' as TabId
+
+const LEVEL: DirLevel = { entries: [{ name: 'src', type: 'directory' }], truncated: false }
+
+function mount() {
+  const instance = createFilesStore().create()
+  const script = scriptedList()
+  const face = filesFace(script.list)(SESSION, instance.actions)
+  return { ...script, face, snapshot: () => instance.getSnapshot().byTab[TAB] }
+}
+
+describe('filesFace', () => {
+  it('start seeds the tab and lists the root with the session and the absolute root path', async () => {
+    const { face, list, settle, snapshot } = mount()
+    const controller = new AbortController()
+    face.start(TAB, ROOT, controller.signal)
+    expect(list).toHaveBeenCalledWith(SESSION, ROOT, controller.signal)
+    expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'loading' })
+    await settle({ ok: true, value: LEVEL })
+    expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
+  })
+
+  it('records a failed listing under its level', async () => {
+    const { face, settle, snapshot } = mount()
+    face.start(TAB, ROOT, new AbortController().signal)
+    const error = new RemoteError('workspace-file/not-directory', 'not a directory', { path: ROOT, kind: 'file' })
+    await settle({ ok: false, error })
+    expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'failed', failure: error })
+  })
+
+  it('toggle expands and lists a directory the first time, and only toggles afterwards', async () => {
+    const { face, list, settle, snapshot } = mount()
+    const signal = new AbortController().signal
+    const child = `${ROOT}/src`
+    face.start(TAB, ROOT, signal)
+    await settle({ ok: true, value: LEVEL })
+    face.toggle(TAB, child, false, signal)
+    expect(list).toHaveBeenLastCalledWith(SESSION, child, signal)
+    expect(snapshot()!.expanded).toEqual([ROOT, child])
+    await settle({ ok: true, value: LEVEL })
+    face.toggle(TAB, child, true, signal)
+    expect(snapshot()!.expanded).toEqual([ROOT])
+    expect(list).toHaveBeenCalledTimes(2)
+  })
+
+  it('abort forgets the bucket and a late settlement writes nothing', async () => {
+    const { face, settle, snapshot } = mount()
+    const controller = new AbortController()
+    face.start(TAB, ROOT, controller.signal)
+    controller.abort()
+    expect(snapshot()).toBeUndefined()
+    await settle({ ok: true, value: LEVEL })
+    expect(snapshot()).toBeUndefined()
+  })
+
+  it('makes no request for a record that already ended', () => {
+    const { face, list } = mount()
+    const controller = new AbortController()
+    controller.abort()
+    face.load(TAB, ROOT, controller.signal)
+    expect(list).not.toHaveBeenCalled()
+  })
+
+  it('lets the latest listing of a level win, whichever settles first', async () => {
+    const { face, list, settle, settleLatest, snapshot, outstanding } = mount()
+    const signal = new AbortController().signal
+    const older: DirLevel = { entries: [{ name: 'old.txt', type: 'file' }], truncated: false }
+    face.start(TAB, ROOT, signal)
+    // The reload gesture asks for the root again while the first listing is still out.
+    face.load(TAB, ROOT, signal)
+    expect(list).toHaveBeenCalledTimes(2)
+    expect(outstanding()).toEqual([ROOT, ROOT])
+    await settleLatest({ ok: true, value: LEVEL })
+    expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
+    // The retired listing lands afterwards and changes nothing.
+    await settle({ ok: true, value: older })
+    expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
+    // A retired failure is dropped the same way.
+    face.load(TAB, ROOT, signal)
+    face.load(TAB, ROOT, signal)
+    await settleLatest({ ok: true, value: LEVEL })
+    await settle({ ok: false, error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }) })
+    expect(snapshot()!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
+  })
+})
+
+describe('createList', () => {
+  it('passes the session, the absolute path, and the signal through, and keeps entries and truncation', async () => {
+    const listing: WorkspaceDirectoryListing = {
+      path: 'src',
+      entries: [{ name: 'a.ts', type: 'file', size: 3 }],
+      truncated: true,
+    }
+    const list = vi.fn<WorkspaceFilesListRemote['workspaceFiles']['list']>()
+      .mockResolvedValue({ ok: true, value: listing })
+    const signal = new AbortController().signal
+    const result = await createList({ workspaceFiles: { list } })(SESSION, `${ROOT}/src`, signal)
+    expect(list).toHaveBeenCalledWith(SESSION, `${ROOT}/src`, signal)
+    expect(result).toEqual({ ok: true, value: { entries: listing.entries, truncated: true } })
+  })
+
+  it('returns a failure as the endpoint reported it', async () => {
+    const error = new RemoteError('workspace-file/not-directory', 'file', { path: 'x', kind: 'file' })
+    const list = vi.fn<WorkspaceFilesListRemote['workspaceFiles']['list']>()
+      .mockResolvedValue({ ok: false, error })
+    const result = await createList({ workspaceFiles: { list } })(SESSION, `${ROOT}/x`, new AbortController().signal)
+    expect(result).toEqual({ ok: false, error })
+  })
+})
+
+describe('childPath', () => {
+  it('joins with one slash whatever the parent ends in', () => {
+    expect(childPath('/work/app', 'src')).toBe('/work/app/src')
+    expect(childPath('/work/app/', 'src')).toBe('/work/app/src')
+    expect(childPath('/', 'etc')).toBe('/etc')
+    expect(childPath('C:\\work\\', 'src')).toBe('C:\\work/src')
+  })
+})

+ 177 - 0
packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx

@@ -0,0 +1,177 @@
+// @vitest-environment jsdom
+/**
+ * The body against a scripted listing.
+ *
+ * What is asserted is the reader's contract: the root lists itself on mount,
+ * rows come out directories-first, a directory click asks for exactly that
+ * level, a file click opens exactly that session-scoped `file:` address through
+ * the owner, an `other` entry is shown but not clickable, the tree says when it
+ * was cut or could not be read, and reload asks again for the expanded levels
+ * only. The two pure helpers the rows are built from are checked on their own.
+ */
+import { afterEach, describe, expect, it } from 'vitest'
+import { act, cleanup, fireEvent } from '@testing-library/react'
+import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
+import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
+import { failureLine, orderEntries } from '../src/client/FilesBody.tsx'
+import type { DirLevel } from '../src/client/store.ts'
+import { zh } from '../src/client/locales.ts'
+import { mountBody, ROOT, SESSION, TAB } from './mount.client.tsx'
+
+const ROOT_LEVEL: DirLevel = {
+  entries: [
+    { name: 'README.md', type: 'file', size: 12 },
+    { name: 'src', type: 'directory' },
+    { name: '.env', type: 'file', size: 2 },
+    { name: 'pipe', type: 'other' },
+  ],
+  truncated: false,
+}
+
+afterEach(() => { cleanup() })
+
+/** Row labels in document order. */
+function names(root: HTMLElement): string[] {
+  return [...root.querySelectorAll('[data-files-entry]')].map(li => li.getAttribute('data-files-path')!)
+}
+
+describe('FilesBody', () => {
+  it('says so when the session has no workspace directory, and asks for nothing', () => {
+    const { view, script } = mountBody(null)
+    expect(view.container.querySelector('[data-files-state="no-workspace"]')?.textContent).toBe(zh.noWorkspace)
+    expect(script.list).not.toHaveBeenCalled()
+  })
+
+  it('lists the root on mount, names it by its basename, and draws directories first with dotfiles kept', async () => {
+    const { view, script } = mountBody()
+    expect(script.list).toHaveBeenCalledWith(SESSION, ROOT, expect.any(AbortSignal))
+    expect(view.container.querySelector('[data-files-row="loading"]')).not.toBeNull()
+    await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
+    expect(view.container.querySelector('[data-files-state="tree"]')?.getAttribute('data-files-root')).toBe(ROOT)
+    expect(view.container.querySelector('[data-files-state="tree"] > div')?.textContent).toBe('app')
+    expect(names(view.container)).toEqual([`${ROOT}/src`, `${ROOT}/.env`, `${ROOT}/pipe`, `${ROOT}/README.md`])
+  })
+
+  it('labels a separator-only root by the root itself, since it has no final segment', async () => {
+    const { view, script } = mountBody('/')
+    await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
+    expect(view.container.querySelector('[data-files-state="tree"] > div')?.textContent).toBe('/')
+    expect(names(view.container)).toEqual(['/src', '/.env', '/pipe', '/README.md'])
+  })
+
+  it('a directory click lists that level once and marks it expanded; a second click collapses without asking again', async () => {
+    const { view, script } = mountBody()
+    await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
+    const dir = view.container.querySelector(`[data-files-path="${ROOT}/src"] > button`)!
+    act(() => { fireEvent.click(dir) })
+    expect(script.list).toHaveBeenLastCalledWith(SESSION, `${ROOT}/src`, expect.any(AbortSignal))
+    expect(dir.getAttribute('aria-expanded')).toBe('true')
+    await act(() => script.settle({ ok: true, value: { entries: [{ name: 'a.ts', type: 'file' }], truncated: false } }))
+    expect(names(view.container)).toContain(`${ROOT}/src/a.ts`)
+    act(() => { fireEvent.click(dir) })
+    expect(dir.getAttribute('aria-expanded')).toBe('false')
+    expect(names(view.container)).not.toContain(`${ROOT}/src/a.ts`)
+    act(() => { fireEvent.click(dir) })
+    expect(names(view.container)).toContain(`${ROOT}/src/a.ts`)
+    expect(script.list).toHaveBeenCalledTimes(2)
+  })
+
+  it('a file click opens its session-scoped file: address through the owner; an other entry offers no button', async () => {
+    const { view, script, tabActions } = mountBody()
+    await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
+    fireEvent.click(view.container.querySelector(`[data-files-path="${ROOT}/README.md"] > button`)!)
+    // Every row sits under the tree's root, so the address is the path relative to it.
+    expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, ROOT, `${ROOT}/README.md`))
+    expect(tabActions.openResource).toHaveBeenCalledWith('dsh-resource://file/session/s-test/README.md')
+    const other = view.container.querySelector(`[data-files-path="${ROOT}/pipe"]`)!
+    expect(other.querySelector('button')).toBeNull()
+    expect(other.querySelector('[aria-disabled="true"]')?.getAttribute('title')).toBe(zh['entry.other'])
+  })
+
+  it('marks a cut listing and an empty one', async () => {
+    const { view, script } = mountBody()
+    await act(() => script.settle({ ok: true, value: { entries: [{ name: 'd', type: 'directory' }], truncated: true } }))
+    expect(view.container.querySelector('[data-files-row="truncated"]')?.textContent).toBe(zh.truncated)
+    act(() => { fireEvent.click(view.container.querySelector(`[data-files-path="${ROOT}/d"] > button`)!) })
+    await act(() => script.settle({ ok: true, value: { entries: [], truncated: false } }))
+    expect(view.container.querySelector('[data-files-row="empty"]')?.textContent).toBe(zh.empty)
+  })
+
+  it('shows a failed level under its directory with the failure code', async () => {
+    const { view, script } = mountBody()
+    await act(() => script.settle({
+      ok: false,
+      error: new RemoteError('workspace-file/not-found', 'gone', { path: ROOT }),
+    }))
+    const failed = view.container.querySelector('[data-files-row="failed"]')
+    expect(failed?.getAttribute('data-files-code')).toBe('workspace-file/not-found')
+    expect(failed?.textContent).toBe(zh['error.notFound'])
+  })
+
+  it('reload resets every level and lists the expanded ones again', async () => {
+    const { view, script, controller, instance } = mountBody()
+    const child = `${ROOT}/src`
+    const collapsed = `${ROOT}/docs`
+    await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
+    act(() => { fireEvent.click(view.container.querySelector(`[data-files-path="${child}"] > button`)!) })
+    await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
+    // A level listed earlier and since collapsed is dropped, not re-fetched.
+    act(() => { instance.actions.loaded(TAB, collapsed, ROOT_LEVEL) })
+    script.list.mockClear()
+
+    act(() => { fireEvent.click(view.container.querySelector('[data-files-reload]')!) })
+    expect(script.list.mock.calls.map(call => call[1])).toEqual([ROOT, child])
+    expect(script.list).toHaveBeenCalledWith(SESSION, ROOT, controller.signal)
+    const state = instance.getSnapshot().byTab[TAB]!
+    expect(state.expanded).toEqual([ROOT, child])
+    expect(state.levels).toEqual({ [ROOT]: { kind: 'loading' }, [child]: { kind: 'loading' } })
+    expect(view.container.querySelector('[data-files-reload]')?.getAttribute('aria-label')).toBe(zh.reload)
+  })
+
+  it('an aborted record is forgotten and not seeded again while the body is still mounted', async () => {
+    const { view, script, controller, instance } = mountBody()
+    await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
+    act(() => { controller.abort() })
+    expect(instance.getSnapshot().byTab[TAB]).toBeUndefined()
+    expect(view.container.querySelector('[data-files-state="tree"]')).toBeNull()
+    expect(script.list).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('orderEntries', () => {
+  it('puts directories first and orders each group by name, numbers included', () => {
+    const ordered = orderEntries([
+      { name: 'file10.txt', type: 'file' },
+      { name: 'zeta', type: 'directory' },
+      { name: 'file2.txt', type: 'file' },
+      { name: '.env', type: 'file' },
+      { name: 'Alpha', type: 'directory' },
+      { name: 'sock', type: 'other' },
+    ])
+    expect(ordered.map(entry => entry.name)).toEqual(['Alpha', 'zeta', '.env', 'file2.txt', 'file10.txt', 'sock'])
+  })
+
+  it('leaves the endpoint\'s array untouched', () => {
+    const entries = [{ name: 'b', type: 'file' as const }, { name: 'a', type: 'file' as const }]
+    orderEntries(entries)
+    expect(entries.map(entry => entry.name)).toEqual(['b', 'a'])
+  })
+})
+
+describe('failureLine', () => {
+  const t = makeTranslate(zh)
+
+  it('names each directory failure', () => {
+    expect(failureLine(t, new RemoteError('workspace-file/not-found', 'x', { path: 'p' }))).toBe(zh['error.notFound'])
+    expect(failureLine(t, new RemoteError('workspace-file/outside-workspace', 'x', { path: 'p' })))
+      .toBe(zh['error.outsideWorkspace'])
+    expect(failureLine(t, new RemoteError('workspace-file/not-directory', 'x', { path: 'p', kind: 'file' })))
+      .toBe(zh['error.notDirectory'])
+  })
+
+  it('carries an unclassified failure\'s own message', () => {
+    const failure = { code: 'remote/transport', message: 'socket closed' } as unknown as RemoteFailure
+    expect(failureLine(t, failure)).toBe('读取失败:socket closed')
+  })
+})

+ 100 - 0
packages/client/ui-sidebar-files/tests/mount.client.tsx

@@ -0,0 +1,100 @@
+/**
+ * Mount the body over a real store instance and a scripted listing.
+ *
+ * The component reads a handful of its props; the rest of the standard kit is
+ * framework-injected and never touched here, so one documented cast keeps the
+ * harness to what is actually exercised.
+ */
+import { useSyncExternalStore } from 'react'
+import { render } from '@testing-library/react'
+import type { RenderResult } from '@testing-library/react'
+import { vi } from 'vitest'
+import type { Mock } from 'vitest'
+import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SidebarRightTabActions } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import { filesFace } from '../src/client/face.ts'
+import type { FilesInjected } from '../src/client/face.ts'
+import { FilesBody } from '../src/client/FilesBody.tsx'
+import type { FilesBodyProps } from '../src/client/FilesBody.tsx'
+import { zh } from '../src/client/locales.ts'
+import { createFilesStore } from '../src/client/store.ts'
+import { scriptedList } from './scripted-list.client.ts'
+import type { ScriptedList } from './scripted-list.client.ts'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+
+export const SESSION = 's-test' as SessionId
+export const ROOT = '/work/app'
+export const TAB = 'tab-1' as TabId
+
+/** Test-local selector hook over a framework-neutral store instance. */
+function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
+  return function useSelector<S>(sel: (s: T) => S): S {
+    return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
+  }
+}
+
+/** A live instance of the tree's store, as the framework would mint one per session. */
+type FilesStoreInstance = ReturnType<ReturnType<typeof createFilesStore>['create']>
+
+/** The owner's tab actions as recording mocks. */
+interface MockedTabActions {
+  readonly openResource: Mock<SidebarRightTabActions['openResource']>
+  readonly openTab: Mock<SidebarRightTabActions['openTab']>
+  readonly close: Mock<SidebarRightTabActions['close']>
+}
+
+/** What a spec holds after mounting: the rendered view and every hand on the tree. */
+export interface Mounted {
+  readonly view: RenderResult
+  readonly instance: FilesStoreInstance
+  readonly script: ScriptedList
+  readonly face: FilesInjected
+  readonly controller: AbortController
+  readonly tabActions: MockedTabActions
+}
+
+/** One store instance, one face, one owner share. */
+function harness(cwd: string | null) {
+  const instance = createFilesStore().create()
+  const script = scriptedList()
+  const face = filesFace(script.list)(SESSION, instance.actions)
+  const controller = new AbortController()
+  const tabActions: MockedTabActions = {
+    openResource: vi.fn<SidebarRightTabActions['openResource']>(),
+    openTab: vi.fn<SidebarRightTabActions['openTab']>(),
+    close: vi.fn<SidebarRightTabActions['close']>(),
+  }
+  const sessions = { byId: cwd === null ? {} : { [SESSION]: { cwd } } } as unknown as SessionListState
+  const shared = {
+    // A page tab's address is the shell's to mint; the body never reads it.
+    useTabInfo: () => ({
+      sidebar: { expanded: true, fullscreen: false },
+      panel: { id: 'pane-1' },
+      tab: {
+        id: TAB, kind: 'files', contentId: 'files', title: zh['type.label'], visible: true,
+        navigation: { address: 'files', params: undefined, revision: 1 },
+        signal: controller.signal,
+        actions: tabActions,
+      },
+    }),
+    sessionId: SESSION,
+    useSessions: <S,>(sel: (s: SessionListState) => S) => sel(sessions),
+    useStore: hookOf(instance),
+    actions: instance.actions,
+    ...face,
+    t: makeTranslate(zh),
+  }
+  return { instance, script, face, controller, tabActions, shared }
+}
+
+/**
+ * Mount the body.
+ * @param cwd - the session's working directory as `useSessions` reports it; `null` for a session without one.
+ */
+export function mountBody(cwd: string | null = ROOT): Mounted {
+  const { shared, ...hands } = harness(cwd)
+  const view = render(<FilesBody {...shared as unknown as FilesBodyProps} />)
+  return { ...hands, view }
+}

+ 51 - 0
packages/client/ui-sidebar-files/tests/scripted-list.client.ts

@@ -0,0 +1,51 @@
+/** A directory listing the spec settles by hand, one deferred result per call. */
+import { vi } from 'vitest'
+import type { Mock } from 'vitest'
+import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
+import type { ListWorkspaceDirectory } from '../src/client/face.ts'
+import type { DirLevel } from '../src/client/store.ts'
+
+/** The scripted listing: the mock the face receives, and the hand that settles it. */
+export interface ScriptedList {
+  readonly list: Mock<ListWorkspaceDirectory>
+  /**
+   * Settle the oldest outstanding call and let its store write land.
+   * @param result - what the endpoint answers.
+   */
+  readonly settle: (result: RemoteResult<DirLevel>) => Promise<void>
+  /**
+   * Settle the newest outstanding call first, so an older one can arrive after it.
+   * @param result - what the endpoint answers.
+   */
+  readonly settleLatest: (result: RemoteResult<DirLevel>) => Promise<void>
+  /** Paths of calls not yet settled, oldest first. */
+  readonly outstanding: () => readonly string[]
+}
+
+/**
+ * Build a listing whose every call stays pending until the spec settles it.
+ * @returns the scripted listing.
+ */
+/** One listing awaiting the spec's answer. */
+interface PendingList {
+  readonly path: string
+  resolve(result: RemoteResult<DirLevel>): void
+}
+
+export function scriptedList(): ScriptedList {
+  const pending: PendingList[] = []
+  const list = vi.fn<ListWorkspaceDirectory>((_sessionId, path) =>
+    new Promise((resolve) => { pending.push({ path, resolve }) }))
+  const land = async (call: PendingList | undefined, result: RemoteResult<DirLevel>): Promise<void> => {
+    if (call === undefined) throw new Error('no outstanding listing to settle')
+    call.resolve(result)
+    await Promise.resolve()
+    await Promise.resolve()
+  }
+  return {
+    list,
+    settle: result => land(pending.shift(), result),
+    settleLatest: result => land(pending.pop(), result),
+    outstanding: () => pending.map(call => call.path),
+  }
+}

+ 95 - 0
packages/client/ui-sidebar-files/tests/store.client.spec.ts

@@ -0,0 +1,95 @@
+/**
+ * The tree's write set, one tab at a time.
+ *
+ * Two facts here are load-bearing for the body: a collapsed level keeps what it
+ * loaded (reopening draws at once), and `reset` clears levels while keeping the
+ * expanded set, which is what lets the reload gesture know which levels to ask
+ * for again.
+ */
+import { describe, expect, it } from 'vitest'
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import { createFilesStore } from '../src/client/store.ts'
+import type { DirLevel } from '../src/client/store.ts'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+
+const ROOT = '/work/app'
+const TAB = 'tab-1' as TabId
+
+const LEVEL: DirLevel = {
+  entries: [{ name: 'src', type: 'directory' }, { name: 'README.md', type: 'file', size: 12 }],
+  truncated: false,
+}
+
+describe('createFilesStore', () => {
+  it('mints an independent instance per call', () => {
+    const first = createFilesStore().create()
+    const second = createFilesStore().create()
+    first.actions.start(TAB, ROOT)
+    expect(second.getSnapshot().byTab[TAB]).toBeUndefined()
+  })
+
+  it('seeds a tab at its root with the root expanded and nothing loaded', () => {
+    const store = createFilesStore().create()
+    const { actions } = store
+    const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
+    actions.start(TAB, ROOT)
+    expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT] })
+  })
+
+  it('walks one level through loading, ready, and failed', () => {
+    const store = createFilesStore().create()
+    const { actions } = store
+    const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
+    actions.start(TAB, ROOT)
+    actions.loading(TAB, ROOT)
+    expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'loading' })
+    actions.loaded(TAB, ROOT, LEVEL)
+    expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'ready', level: LEVEL })
+    const failure = new RemoteError('workspace-file/not-found', 'gone', { path: ROOT })
+    actions.failed(TAB, ROOT, failure)
+    expect(getSnapshot().byTab[TAB]!.levels[ROOT]).toEqual({ kind: 'failed', failure })
+  })
+
+  it('toggles a directory in and out of the expanded set without touching its level', () => {
+    const store = createFilesStore().create()
+    const { actions } = store
+    const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
+    const child = `${ROOT}/src`
+    actions.start(TAB, ROOT)
+    actions.loaded(TAB, child, LEVEL)
+    actions.toggled(TAB, child)
+    expect(getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT, child])
+    actions.toggled(TAB, child)
+    expect(getSnapshot().byTab[TAB]!.expanded).toEqual([ROOT])
+    // Collapsing keeps the listing, so reopening draws without another fetch.
+    expect(getSnapshot().byTab[TAB]!.levels[child]).toEqual({ kind: 'ready', level: LEVEL })
+  })
+
+  it('reset drops every level and keeps the expanded set', () => {
+    const store = createFilesStore().create()
+    const { actions } = store
+    const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
+    const child = `${ROOT}/src`
+    actions.start(TAB, ROOT)
+    actions.loaded(TAB, ROOT, LEVEL)
+    actions.toggled(TAB, child)
+    actions.loaded(TAB, child, LEVEL)
+    actions.reset(TAB)
+    expect(getSnapshot().byTab[TAB]).toEqual({ root: ROOT, levels: {}, expanded: [ROOT, child] })
+  })
+
+  it('refuses to write a level for a tab that was never started', () => {
+    const { actions } = createFilesStore().create()
+    expect(() => { actions.loading('tab-nowhere' as TabId, ROOT) }).toThrow('no tree for tab "tab-nowhere"')
+  })
+
+  it('forget removes exactly the tab that went away', () => {
+    const store = createFilesStore().create()
+    const { actions } = store
+    const getSnapshot = (): ReturnType<typeof store.getSnapshot> => store.getSnapshot()
+    actions.start(TAB, ROOT)
+    actions.start('tab-2' as TabId, ROOT)
+    actions.forget(TAB)
+    expect(Object.keys(getSnapshot().byTab)).toEqual(['tab-2'])
+  })
+})

+ 204 - 0
packages/client/ui-sidebar-right/tests/apply.client.spec.ts

@@ -0,0 +1,204 @@
+/**
+ * The plugin's wiring, and its removal when the plugin goes.
+ *
+ * The registry and the navigation controller are real, because "provided"
+ * means what those faces do; the slot, locale, frame, and resource faces are
+ * recorders, because what matters here is what was handed to them — two seats
+ * over one store, the guide's body under its own id, the frame reports, the
+ * service binding — and that every registration is gone after dispose, which
+ * is what makes a reload safe. The seats' components have their own specs.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { apply, inject } from '../src/client/index.ts'
+import type { GuideInjected, SidebarRightInjected } from '../src/client/index.ts'
+import { apply as hostApply } from '../src/index.ts'
+import { SidebarRightController } from '../src/client/service.ts'
+import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts'
+import type { createSidebarRightStore } from '../src/client/stores.ts'
+import { RightbarSeat } from '../src/client/shell/SidebarRight.tsx'
+import { ExpandButton } from '../src/client/shell/ExpandButton.tsx'
+import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx'
+import { GUIDE_ID } from '../src/client/tabs/guide/definition.ts'
+import { en, zh } from '../src/client/locales.ts'
+
+const SESSION = 's-test' as SessionId
+
+interface Recorded {
+  name: string
+  key?: string
+  locale: string
+  store?: unknown
+  children?: unknown
+  inject?: (sessionId: SessionId) => unknown
+  component: unknown
+}
+
+async function boot() {
+  const ctx = new Context()
+  const registered: Recorded[] = []
+  const slots = {
+    inject: vi.fn((_name: string, register: () => () => void) => register()),
+    register: vi.fn((options: Omit<Recorded, 'component'>, component: unknown) => {
+      const entry: Recorded = { ...options, component }
+      registered.push(entry)
+      return () => { registered.splice(registered.indexOf(entry), 1) }
+    }),
+  }
+  const dictionaries = new Map<string, unknown>()
+  const locale = {
+    // Copy is the dictionary's contract; the key stands in for the translation.
+    bind: vi.fn(() => (key: string) => key),
+    register: vi.fn((ns: string, dicts: unknown) => {
+      dictionaries.set(ns, dicts)
+      return () => { dictionaries.delete(ns) }
+    }),
+  }
+  const layout = { openRightbar: vi.fn(), closeRightbar: vi.fn() }
+  const resources = { pin: vi.fn<(address: string, signal: AbortSignal) => void>() }
+  ctx.provide('slots', slots as never)
+  ctx.provide('locale', locale as never)
+  ctx.provide('layout', layout as never)
+  ctx.provide('resources', resources as never)
+  const fiber = ctx.plugin({ inject: [...inject], apply })
+  await fiber.await()
+  const seat = (name: string): Recorded => {
+    const entry = registered.find(candidate => candidate.name === name)
+    if (entry === undefined) throw new Error(`expected a registration into ${name}`)
+    return entry
+  }
+  const injectedOf = (entry: Recorded): unknown => {
+    if (entry.inject === undefined) throw new Error(`expected ${entry.name} to inject`)
+    return entry.inject(SESSION)
+  }
+  return { ctx, registered, dictionaries, layout, resources, fiber, seat, injectedOf }
+}
+
+describe('ui-sidebar-right apply', () => {
+  it('keeps the host Loader entry inert', () => {
+    expect(hostApply).not.toThrow()
+  })
+
+  it('provides both faces, and registers the guide through the same two-stage path as any other type', async () => {
+    const { ctx, registered, dictionaries, seat } = await boot()
+    expect(ctx.sidebarRightTabs).toBeInstanceOf(SidebarRightTabRegistry)
+    expect(ctx.sidebarRight).toBeInstanceOf(SidebarRightController)
+    expect('adopt' in ctx.sidebarRight).toBe(false)
+    expect(dictionaries.get('sidebarRight')).toEqual({ zh, en })
+    const guide = ctx.sidebarRightTabs.get('guide')
+    expect(guide?.id).toBe(GUIDE_ID)
+    expect(guide?.priority).toBe('builtin')
+    expect(guide?.title('sidebar://guide')).toBe('tab.guide.title')
+    // Three registrations: the panel seat, the header's corner seat, and the
+    // guide body under the guide implementation's id.
+    expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
+      ['rightbar', undefined, 'sidebarRight', RightbarSeat],
+      ['conversation.session.header.corner', undefined, 'sidebarRight', ExpandButton],
+      ['sidebar.right.pane.tab', GUIDE_ID, 'sidebarRight', GuideBody],
+    ])
+    // The panel declares the extension seats; the guide declares its chain child.
+    expect(Object.keys(seat('rightbar').children as object)).toEqual([
+      'sidebar.right.pane.tab', 'sidebar.right.pane.tab.title', 'sidebar.right.tab.menu.item',
+    ])
+    expect(seat('sidebar.right.pane.tab').children).toMatchObject({ 'sidebar.right.tab.guide': { kind: 'chain', scope: 'session' } })
+    // Both seats read one store: the button only needs to know whether the panel is expanded.
+    expect(seat('rightbar').store).toBeDefined()
+    expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar').store)
+  })
+
+  it('hands the panel seat the frame report, the service binding, the opens, the observable registry, and the Tab domain', async () => {
+    const { ctx, layout, resources, seat, injectedOf } = await boot()
+    const injected = injectedOf(seat('rightbar')) as SidebarRightInjected
+    // The frame learns the composition of expanded and presentation, nothing else.
+    injected.syncPresentation({ shown: true, track: true, fullscreen: false })
+    expect(layout.openRightbar).toHaveBeenLastCalledWith(true, false)
+    injected.syncPresentation({ shown: true, track: true, fullscreen: true })
+    expect(layout.openRightbar).toHaveBeenLastCalledWith(true, true)
+    injected.syncPresentation({ shown: true, track: false, fullscreen: true })
+    expect(layout.openRightbar).toHaveBeenLastCalledWith(false, true)
+    injected.syncPresentation({ shown: false, track: false, fullscreen: false })
+    expect(layout.closeRightbar).toHaveBeenCalledOnce()
+    // The registry, observable: what the seat dispatches a kind to.
+    expect(injected.hooks.tabTypes.getSnapshot().find(type => type.kind === 'guide')?.id).toBe(GUIDE_ID)
+    const seen = vi.fn()
+    const unsubscribe = injected.hooks.tabTypes.subscribe(seen)
+    ctx.sidebarRightTabs.register({ id: 'spec/text', kind: 'text', patterns: ['dsh-resource://file/**'], title: () => 'text' })
+    expect(seen).toHaveBeenCalledOnce()
+    unsubscribe()
+    // The binding makes the service act on this seat's session; the seat's
+    // store instance is minted here from the handle the registration declared.
+    const handle = seat('rightbar').store as ReturnType<typeof createSidebarRightStore>
+    const instance = handle.create()
+    const release = injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true })
+    injected.openTab('guide', { revealIfOpened: false })
+    const surface = instance.getSnapshot().bySession[SESSION]
+    expect(surface?.layout.expanded).toBe(true)
+    expect(Object.values(surface?.layout.tabs ?? {}).map(tab => tab.kind)).toEqual(['guide'])
+    // Holding a record pins its address through the resource model.
+    if (surface === undefined) throw new Error('expected a surface')
+    ctx.sidebarRight.tabDomain.sync(SESSION, surface.layout)
+    expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal))
+    release()
+    expect(() => { ctx.sidebarRight.toggleExpanded() }).toThrow('no session surface is mounted')
+  })
+
+  it('adopts each session\'s store instance as the runtime mints it, so a tab\'s own actions land with no seat bound', async () => {
+    const { ctx, resources, seat } = await boot()
+    const handle = seat('rightbar').store as ReturnType<typeof createSidebarRightStore>
+    // Both seats declare the same wrapped handle, so either minting adopts.
+    expect(seat('conversation.session.header.corner').store).toBe(handle)
+    const instance = handle.create(SESSION)
+    instance.actions.open(SESSION)
+    const guide = Object.values(instance.getSnapshot().bySession[SESSION]?.layout.tabs ?? {})[0]
+    if (guide === undefined) throw new Error('expected the seeded guide')
+    // Held and pinned from the store's own commit: no seat synced anything.
+    const occurrence = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide)
+    expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', occurrence.signal)
+    occurrence.tabActions.close()
+    expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined()
+    expect(occurrence.signal.aborted).toBe(true)
+  })
+
+  it('hands the guide body the registry\'s entry boxes, observable', async () => {
+    const { ctx, seat, injectedOf } = await boot()
+    const { hooks: { guideEntries } } = injectedOf(seat('sidebar.right.pane.tab')) as GuideInjected
+    expect(guideEntries.getSnapshot()).toEqual([])
+    const seen = vi.fn()
+    guideEntries.subscribe(seen)
+    ctx.sidebarRightTabs.register({
+      id: 'spec/files',
+      kind: 'files',
+      title: () => 'Files',
+      guide: [{ order: 10, title: () => 'Files', description: () => 'The workspace tree' }],
+    })
+    expect(seen).toHaveBeenCalledOnce()
+    expect(guideEntries.getSnapshot().map(entry => entry.kind)).toEqual(['files'])
+  })
+
+  it('takes every registration and both faces back when disposed, aborting the open records, so a reload registers again', async () => {
+    const { ctx, registered, dictionaries, fiber, seat, injectedOf } = await boot()
+    const injected = injectedOf(seat('rightbar')) as SidebarRightInjected
+    const handle = seat('rightbar').store as ReturnType<typeof createSidebarRightStore>
+    // Minted under the session key, so the instance is adopted and the teardown releases it.
+    const instance = handle.create(SESSION)
+    injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true })
+    injected.openTab('guide')
+    const surface = instance.getSnapshot().bySession[SESSION]
+    const guide = Object.values(surface?.layout.tabs ?? {})[0]
+    if (guide === undefined) throw new Error('expected the guide tab')
+    const { signal, tabActions } = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide)
+    await fiber.dispose()
+    expect(signal.aborted).toBe(true)
+    // The adoption went with the plugin: a late action from the dead occurrence changes nothing.
+    tabActions.close()
+    expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeDefined()
+    expect(ctx.get('sidebarRight')).toBeUndefined()
+    expect(ctx.get('sidebarRightTabs')).toBeUndefined()
+    expect(registered).toEqual([])
+    expect(dictionaries.size).toBe(0)
+    await ctx.plugin({ inject: [...inject], apply }).await()
+    expect(ctx.sidebarRightTabs.get('guide')?.id).toBe(GUIDE_ID)
+    expect(registered).toHaveLength(3)
+  })
+})

+ 68 - 0
packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx

@@ -0,0 +1,68 @@
+// @vitest-environment jsdom
+/**
+ * The way back into a hidden panel: the header's corner button exists exactly
+ * while the panel is collapsed, asks for it to expand, and leaves a same-size
+ * footprint while the panel is shown so the header row never moves.
+ */
+import { describe, expect, it } from 'vitest'
+import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { useSyncExternalStore } from 'react'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { ExpandButton } from '../src/client/shell/ExpandButton.tsx'
+import type { ExpandButtonProps } from '../src/client/shell/ExpandButton.tsx'
+import { createSidebarRightStore } from '../src/client/stores.ts'
+
+const SESSION = 's-test' as SessionId
+
+/** Test-local selector hook over a framework-neutral store instance. */
+function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
+  return function useSelector<S>(sel: (s: T) => S): S {
+    return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
+  }
+}
+
+/**
+ * Mount the button over a real store instance. It reads four of its props; the
+ * rest of the standard kit is framework-injected and never touched here, so one
+ * documented cast keeps the harness to what is actually exercised.
+ */
+function mountButton() {
+  const instance = createSidebarRightStore(() => 'Start').create()
+  const props = {
+    sessionId: SESSION,
+    useStore: hookOf(instance),
+    actions: instance.actions,
+    // Copy is the dictionary's contract; the key stands in for the translation.
+    t: (key: string) => key,
+  } as unknown as ExpandButtonProps
+  const view = render(<ExpandButton {...props} />)
+  const control = (): HTMLElement | null => view.container.querySelector('[data-sidebar-right-expand]')
+  const placeholder = (): HTMLElement | null => view.container.querySelector('[data-sidebar-right-expand-placeholder]')
+  return { instance, view, control, placeholder }
+}
+
+describe('ExpandButton', () => {
+  it('offers the way in while the session has no surface yet, and asks the panel to expand', () => {
+    const { instance, control, placeholder } = mountButton()
+    const button = control()
+    if (button === null) throw new Error('expected the expand control')
+    expect(button.getAttribute('aria-label')).toBe('chrome.expand')
+    expect(placeholder()).toBeNull()
+    fireEvent.click(button)
+    expect(instance.getSnapshot().bySession[SESSION]?.layout.expanded).toBe(true)
+    // Shown: the control gives way to its footprint, so the seat keeps its width.
+    expect(control()).toBeNull()
+    expect(placeholder()).not.toBeNull()
+    cleanup()
+  })
+
+  it('comes back when the panel collapses again', () => {
+    const { instance, control, placeholder } = mountButton()
+    act(() => { instance.actions.setExpanded(SESSION, true) })
+    expect(control()).toBeNull()
+    act(() => { instance.actions.setExpanded(SESSION, false) })
+    expect(control()).not.toBeNull()
+    expect(placeholder()).toBeNull()
+    cleanup()
+  })
+})

+ 110 - 0
packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx

@@ -0,0 +1,110 @@
+// @vitest-environment jsdom
+/**
+ * The guide tab's body: the chain seam, and the shipped guide behind it.
+ *
+ * The contract a type relies on is the entry box: one per guide entry every
+ * registered type contributed, in the registry's order, and picking one opens
+ * that type as a page in the guide's own tab. The chain is asserted through
+ * what the body hands it — the tab and the shipped guide as the fallback.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import type { ReactNode } from 'react'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
+import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx'
+import type { GuideBodyProps } from '../src/client/tabs/guide/GuideBody.tsx'
+import type { SidebarRightGuideBox } from '../src/client/tab-registry.ts'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
+
+afterEach(cleanup)
+
+const TAB = { id: 'tab-1', kind: 'guide', contentId: 'sidebar://guide', title: 'Start' }
+
+/** A glyph that marks its box, so a spec can tell an entry with an icon from one without. */
+function Glyph({ size }: IconProps): ReactNode {
+  return <span data-guide-glyph={size} />
+}
+
+/** One entry box as the registry lists it. */
+function box(kind: string, order: number, icon?: SidebarRightGuideBox['icon']): SidebarRightGuideBox {
+  return {
+    kind,
+    order,
+    title: () => `${kind} title`,
+    description: () => `${kind} description`,
+    ...icon === undefined ? {} : { icon },
+  }
+}
+
+/**
+ * Mount the body with the entries observable and a chain that renders its
+ * fallback, which is what the chain does with no registrant.
+ */
+function mountGuide(entries: readonly SidebarRightGuideBox[]) {
+  const guideEntries = createSnapshotStore<readonly SidebarRightGuideBox[]>(entries)
+  const openTab = vi.fn()
+  const renderSlot = vi.fn((_seat: string, _owner: unknown, options: { fallback: ReactNode }) => options.fallback)
+  const props = {
+    useTabInfo: () => ({ tab: { ...TAB, actions: { openResource: vi.fn(), openTab, close: vi.fn() } } }),
+    useGuideEntries: bindSnapshotSelector(guideEntries),
+    renderSlotChain: renderSlot,
+    // Copy is the dictionary's contract; the key stands in for the translation.
+    t: (key: string) => key,
+  } as unknown as GuideBodyProps
+  const view = render(<GuideBody {...props} />)
+  const boxes = (): string[] =>
+    [...view.container.querySelectorAll('[data-sidebar-right-guide-entry]')].map(node => node.getAttribute('data-sidebar-right-guide-entry') ?? '')
+  return { view, guideEntries, openTab, renderSlot, boxes, useTabInfo: props.useTabInfo }
+}
+
+describe('GuideBody', () => {
+  it('renders the chain with the same tab hook, and the shipped guide as its fallback', () => {
+    const { view, renderSlot, boxes, useTabInfo } = mountGuide([box('files', 10, Glyph), box('terminal', 20)])
+    expect(renderSlot).toHaveBeenCalledWith('sidebar.right.tab.guide', {}, {
+      hookContext: useTabInfo, fallback: expect.anything() as ReactNode,
+    })
+    const guide = view.container.querySelector('[data-sidebar-right-guide]')
+    expect(guide?.textContent).toContain('guide.lead')
+    expect(guide?.textContent).toContain('guide.body')
+    // One box per entry, in the registry's order, each with its own words; only the first brought a glyph.
+    expect(boxes()).toEqual(['files', 'terminal'])
+    const [files, terminal] = [...view.container.querySelectorAll('[data-sidebar-right-guide-entry]')]
+    expect(files?.textContent).toBe('files titlefiles description')
+    expect(files?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('16')
+    expect(terminal?.querySelector('[data-guide-glyph]')).toBeNull()
+    cleanup()
+  })
+
+  it('picking a box opens that type in the guide\'s own place', () => {
+    const { view, openTab } = mountGuide([box('files', 10)])
+    const entry = view.container.querySelector('[data-sidebar-right-guide-entry="files"]')
+    if (entry === null) throw new Error('expected the files box')
+    fireEvent.click(entry)
+    expect(openTab).toHaveBeenCalledWith('files', { replaceTab: true })
+    cleanup()
+  })
+
+  it('draws the words alone while no type contributed an entry, and follows the registry when one does', () => {
+    const { view, guideEntries, boxes } = mountGuide([])
+    expect(view.container.querySelector('[data-sidebar-right-guide]')).not.toBeNull()
+    expect(boxes()).toEqual([])
+    act(() => { guideEntries.set([box('files', 10)]) })
+    expect(boxes()).toEqual(['files'])
+    cleanup()
+  })
+
+  it('keeps two boxes of one type at the same order apart', () => {
+    const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
+    try {
+      const { boxes } = mountGuide([box('notes', 10), box('notes', 10)])
+      expect(boxes()).toEqual(['notes', 'notes'])
+      // React reports colliding keys through console.error; two boxes rendered
+      // without one is the whole assertion.
+      expect(errors).not.toHaveBeenCalled()
+    } finally {
+      errors.mockRestore()
+      cleanup()
+    }
+  })
+})

+ 375 - 0
packages/client/ui-sidebar-right/tests/seat.client.spec.tsx

@@ -0,0 +1,375 @@
+// @vitest-environment jsdom
+/** Sidebar presentation and tab subscriptions through the production slot renderer. */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act, fireEvent } from '@testing-library/react'
+import { useState } from 'react'
+import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { PaneId, SplitId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { dockPaneIds, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { apply, inject } from '../src/client/index.ts'
+import { intentsFor } from '../src/client/shell/SidebarRight.tsx'
+import type { SidebarRightTabInfo, SidebarRightTabMenuOwnerProps } from '../src/client/contract/slots.ts'
+import type { createSidebarRightStore } from '../src/client/stores.ts'
+
+declare module '../src/client/contract/params.ts' {
+  interface SidebarRightResourceParamsMap {
+    test: { line?: number; x?: number }
+  }
+}
+
+const SESSION = 's-test' as SessionId
+const OTHER = 's-other' as SessionId
+const runtimes: SlotTestRuntime[] = []
+
+afterEach(async () => {
+  for (const runtime of runtimes.splice(0)) await runtime.dispose()
+})
+
+async function mountSeat(viewportWidth = 1440, canShow = true) {
+  const runtime = await SlotTestRuntime.create()
+  runtimes.push(runtime)
+  const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn() }
+  const pin = vi.fn<(address: string, signal: AbortSignal) => void>()
+  runtime.ctx.provide('layout', frame as never)
+  runtime.ctx.provide('resources', { pin } as never)
+  const locale = new LocaleRuntime(runtime.ctx)
+  runtime.ctx.provide('locale', locale)
+  runtime.slots.installLocale(locale)
+  await runtime.declare({
+    'rightbar': { kind: 'single', scope: 'session' },
+    'conversation.session.header.corner': { kind: 'single', scope: 'session' },
+  })
+  await runtime.sessions.add({ id: SESSION })
+  const feature = await runtime.mount({ inject: [...inject], apply })
+  const bodies = new Map<string, SidebarRightTabInfo>()
+  const titles = new Map<string, SidebarRightTabInfo>()
+  const hooks = new Map<string, PropsRuntime<'sidebar.right.pane.tab'>['useTabInfo']>()
+  let mounts = 0
+  function Body(props: PropsRuntime<'sidebar.right.pane.tab'>) {
+    const info = props.useTabInfo()
+    const [instance] = useState(() => ++mounts)
+    bodies.set(info.tab.id, info)
+    hooks.set(info.tab.id, props.useTabInfo)
+    expect(['tabInfo', 'tab', 'paneId', 'visible', 'navigation', 'signal', 'tabActions'].filter(key => key in props)).toEqual([])
+    return <span data-tab-body={info.tab.id} data-instance={instance} data-revision={info.tab.navigation.revision} />
+  }
+  function Title({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.title'>) {
+    const info = useTabInfo()
+    titles.set(info.tab.id, info)
+    return <span data-tab-title={info.tab.id}>{info.tab.title}</span>
+  }
+  await act(async () => {
+    runtime.ctx.sidebarRightTabs.register({
+      id: 'test/text', kind: 'text', priority: 'builtin', patterns: ['dsh-resource://file/**'],
+      title: address => address.slice(address.lastIndexOf('/') + 1),
+    })
+    runtime.slots.register({ name: 'sidebar.right.pane.tab', key: 'test/text' }, Body)
+    runtime.slots.register({ name: 'sidebar.right.pane.tab.title', key: 'test/text' }, Title)
+  })
+  const view = runtime.renderSlot('rightbar', { width: 420, viewportWidth, canShow })
+  const instance = runtime.storeOf('rightbar', SESSION) as ReturnType<ReturnType<typeof createSidebarRightStore>['create']>
+  const controller = runtime.ctx.sidebarRight
+  const layout = () => instance.getSnapshot().bySession[SESSION]!.layout
+  const open = (name = 'a.txt', options?: Parameters<typeof controller.openResource>[1]) => {
+    act(() => { controller.openResource(`dsh-resource://file/session/s-test/${name}`, options) })
+    return controller.active()!
+  }
+  return { runtime, feature, controller, instance, actions: instance.actions, layout, open, frame, pin, bodies, titles, hooks, view }
+}
+
+function element(container: HTMLElement, selector: string): HTMLElement {
+  const node = container.querySelector<HTMLElement>(selector)
+  if (node === null) throw new Error(`expected ${selector}`)
+  return node
+}
+
+describe('RightbarSeat presentation', () => {
+  it('keeps the panel mounted while collapsed and releases the frame on unmount', async () => {
+    const h = await mountSeat()
+    const panel = element(h.view.container, '[data-sidebar-right-panel]')
+    expect(panel.getAttribute('aria-hidden')).toBe('true')
+    expect(h.frame.closeRightbar).toHaveBeenCalled()
+    h.open()
+    expect(element(h.view.container, '[data-sidebar-right-panel]')).toBe(panel)
+    expect(panel.hasAttribute('data-sidebar-right-open')).toBe(true)
+    expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, false)
+    await h.runtime.dispose()
+    expect(h.frame.closeRightbar).toHaveBeenCalled()
+  })
+
+  it('fills the viewport without replacing the content tree or releasing the wide track', async () => {
+    const h = await mountSeat()
+    const tab = h.open()
+    const panel = element(h.view.container, '[data-sidebar-right-panel]')
+    const body = element(h.view.container, '[data-tab-body]')
+    expect(panel.style.width).toBe('420px')
+    fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]'))
+    expect(h.layout().mode).toBe('fullscreen')
+    expect(panel.style.width).toBe('100%')
+    expect(panel.dataset['sidebarRightPanel']).toBe('fullscreen')
+    expect(element(h.view.container, '[data-tab-body]')).toBe(body)
+    expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, true)
+    expect(h.bodies.get(tab.id)?.sidebar).toEqual({ expanded: true, fullscreen: true })
+    fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]'))
+    expect(panel.style.width).toBe('420px')
+    expect(element(h.view.container, '[data-tab-body]')).toBe(body)
+    expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, false)
+    fireEvent.click(element(h.view.container, '[data-sidebar-right-toggle]'))
+    expect(h.layout().expanded).toBe(false)
+    expect(h.frame.closeRightbar).toHaveBeenCalled()
+  })
+
+  it('derives narrow fullscreen without recording mode and returns to normal when widened', async () => {
+    const h = await mountSeat(767, false)
+    h.open()
+    expect(h.layout().mode).toBe('push')
+    expect(h.frame.openRightbar).toHaveBeenLastCalledWith(false, true)
+    const stored = h.instance.getSnapshot()
+    const body = element(h.view.container, '[data-tab-body]')
+    h.view.update({ width: 420, viewportWidth: 768, canShow: true })
+    expect(h.instance.getSnapshot()).toBe(stored)
+    expect(element(h.view.container, '[data-tab-body]')).toBe(body)
+    expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, false)
+  })
+
+  it('closes on automatic fullscreen exit and stays closed after widening', async () => {
+    const h = await mountSeat(500, false)
+    const tab = h.open()
+    const signal = h.bodies.get(tab.id)!.tab.signal
+    fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]'))
+    expect(h.layout().expanded).toBe(false)
+    expect(h.layout().mode).toBe('push')
+    const stored = h.instance.getSnapshot()
+    h.view.update({ width: 420, viewportWidth: 1440, canShow: true })
+    expect(h.instance.getSnapshot()).toBe(stored)
+    expect(h.layout().tabs[tab.id]).toBeDefined()
+    expect(signal.aborted).toBe(false)
+    expect(h.layout().expanded).toBe(false)
+  })
+
+  it('preserves manual fullscreen through narrow and wide viewport changes', async () => {
+    const h = await mountSeat()
+    h.open()
+    fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]'))
+    const stored = h.instance.getSnapshot()
+    h.view.update({ width: 420, viewportWidth: 500, canShow: false })
+    expect(h.frame.openRightbar).toHaveBeenLastCalledWith(false, true)
+    h.view.update({ width: 420, viewportWidth: 1440, canShow: true })
+    expect(h.frame.openRightbar).toHaveBeenLastCalledWith(true, true)
+    expect(h.instance.getSnapshot()).toBe(stored)
+  })
+
+  it('collapses a normal panel that cannot fit without clearing records or reopening on growth', async () => {
+    const h = await mountSeat()
+    const tab = h.open()
+    const signal = h.bodies.get(tab.id)!.tab.signal
+    h.view.update({ width: 420, viewportWidth: 900, canShow: false })
+    expect(h.layout().expanded).toBe(false)
+    expect(h.layout().tabs[tab.id]).toBeDefined()
+    expect(signal.aborted).toBe(false)
+    const stored = h.instance.getSnapshot()
+    h.view.update({ width: 420, viewportWidth: 1440, canShow: true })
+    expect(h.instance.getSnapshot()).toBe(stored)
+    expect(h.layout().expanded).toBe(false)
+  })
+})
+
+describe('slot-owned useTabInfo', () => {
+  it('updates body and title navigation with no layout commit and retains the bound hook', async () => {
+    const h = await mountSeat()
+    const tab = h.open('a.txt', { params: { line: 3 } })
+    const stored = h.instance.getSnapshot()
+    const hook = h.hooks.get(tab.id)
+    const signal = h.bodies.get(tab.id)!.tab.signal
+    act(() => { h.controller.tabDomain.navigate(SESSION, tab.id, { address: tab.contentId, params: { line: 7 } }) })
+    expect(h.instance.getSnapshot()).toBe(stored)
+    expect(h.hooks.get(tab.id)).toBe(hook)
+    expect(h.bodies.get(tab.id)?.tab.navigation).toEqual({ address: tab.contentId, params: { line: 7 }, revision: 2 })
+    expect(h.titles.get(tab.id)?.tab.navigation).toBe(h.bodies.get(tab.id)?.tab.navigation)
+    expect(h.bodies.get(tab.id)?.tab.signal).toBe(signal)
+    expect(element(h.view.container, '[data-tab-body]').dataset['revision']).toBe('2')
+  })
+
+  it('isolates same-kind record state and reports inactive titles, hiding, floating and docking', async () => {
+    const h = await mountSeat()
+    const a = h.open('a.txt')
+    const b = h.open('b.txt')
+    const bodyB = element(h.view.container, '[data-tab-body]')
+    expect(h.titles.get(a.id)?.tab.visible).toBe(true)
+    act(() => { h.actions.focusTab(SESSION, a.id) })
+    const bodyA = element(h.view.container, '[data-tab-body]')
+    expect(bodyA.dataset['instance']).not.toBe(bodyB.dataset['instance'])
+    expect(bodyA.dataset['tabBody']).toBe(a.id)
+    const signal = h.bodies.get(a.id)!.tab.signal
+    act(() => { h.actions.setExpanded(SESSION, false) })
+    expect(h.bodies.get(a.id)?.tab.visible).toBe(false)
+    expect(h.titles.get(a.id)?.tab.visible).toBe(false)
+    expect(h.titles.get(b.id)?.tab.visible).toBe(false)
+    expect(element(h.view.container, '[data-tab-body]')).toBe(bodyA)
+    expect(signal.aborted).toBe(false)
+    act(() => { h.controller.float(a.id) })
+    expect(h.bodies.get(a.id)?.tab.visible).toBe(true)
+    const paneId = h.bodies.get(a.id)!.panel.id
+    expect(h.layout().floats).toContain(paneId)
+    act(() => { h.controller.dock(paneId) })
+    expect(h.layout().floats).toHaveLength(0)
+    expect(h.bodies.get(a.id)?.tab.signal).toBe(signal)
+  })
+
+  it('keeps session records and navigation while unmounted, aborting only removal and plugin unload', async () => {
+    const h = await mountSeat()
+    const own = h.open()
+    const info = h.bodies.get(own.id)!
+    const stored = h.instance.getSnapshot()
+    await h.runtime.sessions.add({ id: OTHER })
+    expect(h.instance.getSnapshot()).toBe(stored)
+    expect(info.tab.signal.aborted).toBe(false)
+    act(() => { h.controller.openResource('dsh-resource://file/session/s-other/other.txt', { params: { line: 9 } }) })
+    const otherTab = h.controller.active()!
+    expect(otherTab.id).toBe(own.id)
+    const otherInfo = h.bodies.get(otherTab.id)!
+    expect(otherInfo.tab.signal).not.toBe(info.tab.signal)
+    act(() => { info.tab.actions.openResource('dsh-resource://file/session/s-test/b.txt') })
+    expect(Object.values(h.layout().tabs).map(tab => tab.title)).toContain('b.txt')
+    expect(h.controller.active()?.contentId).toBe(otherTab.contentId)
+    expect(h.bodies.get(otherTab.id)?.tab.navigation.params).toEqual({ line: 9 })
+    act(() => { info.tab.actions.close() })
+    expect(info.tab.signal.aborted).toBe(true)
+    expect(otherInfo.tab.signal.aborted).toBe(false)
+    await h.runtime.sessions.setCurrent(SESSION)
+    const remaining = h.bodies.get(h.controller.active()!.id)!
+    expect(remaining.tab.navigation.revision).toBe(1)
+    await h.feature.dispose()
+    expect(remaining.tab.signal.aborted).toBe(true)
+    expect(otherInfo.tab.signal.aborted).toBe(true)
+    expect(h.runtime.ctx.get('sidebarRight')).toBeUndefined()
+  })
+
+  it('updates guide replacements through the same hook and guide boxes through framework injection', async () => {
+    const h = await mountSeat()
+    let captured: SidebarRightTabInfo | undefined
+    await act(async () => {
+      h.runtime.ctx.sidebarRightTabs.register({
+        id: 'test/files', kind: 'files', title: () => 'Files',
+        guide: [{ order: 1, title: () => 'Files', description: () => 'Browse' }],
+      })
+    })
+    expect(h.view.container.querySelector('[data-sidebar-right-guide-entry="files"]')).not.toBeNull()
+    await act(async () => {
+      h.runtime.slots.register({ name: 'sidebar.right.tab.guide', select: () => true },
+        ({ useTabInfo }: PropsRuntime<'sidebar.right.tab.guide'>) => {
+          captured = useTabInfo()
+          return <span data-guide-replacement={captured.tab.navigation.revision} />
+        })
+    })
+    expect(h.view.container.querySelector('[data-sidebar-right-guide]')).toBeNull()
+    expect(captured?.tab.kind).toBe('guide')
+    act(() => { h.controller.openTab('guide') })
+    const stored = h.instance.getSnapshot()
+    const guide = h.controller.active()!
+    act(() => { h.controller.tabDomain.navigate(SESSION, guide.id, { address: guide.contentId, params: undefined }) })
+    expect(h.instance.getSnapshot()).toBe(stored)
+    expect(captured?.tab.navigation.revision).toBe(2)
+    expect(element(h.view.container, '[data-guide-replacement]').dataset['guideReplacement']).toBe('2')
+    expect(captured?.sidebar.expanded).toBe(true)
+  })
+
+  it('follows type replacement and returns to the builtin when it leaves', async () => {
+    const h = await mountSeat()
+    h.open()
+    let release = () => {}
+    await act(async () => {
+      release = h.runtime.ctx.sidebarRightTabs.register({ id: 'extension/text', kind: 'text', title: () => 'Extension' })
+      h.runtime.slots.register({ name: 'sidebar.right.pane.tab', key: 'extension/text' },
+        ({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab'>) => <b data-extension>{useTabInfo().tab.title}</b>)
+    })
+    expect(element(h.view.container, '[data-extension]').textContent).toBe('a.txt')
+    await act(async () => { release() })
+    expect(h.view.container.querySelector('[data-extension]')).toBeNull()
+    expect(h.view.container.querySelector('[data-tab-body]')).not.toBeNull()
+  })
+
+  it('renders unavailable kinds and keeps menu tab/dismiss arguments', async () => {
+    const h = await mountSeat()
+    let menu: SidebarRightTabMenuOwnerProps | undefined
+    await act(async () => {
+      h.runtime.slots.register({ name: 'sidebar.right.tab.menu.item', id: 'test' },
+        (props: PropsRuntime<'sidebar.right.tab.menu.item'>) => { menu = props; return null })
+      h.actions.openContent(SESSION, { kind: 'missing', contentId: 'missing://content', title: 'Missing' }, () => {})
+    })
+    expect(h.view.container.querySelector('[data-sidebar-right-unavailable]')).not.toBeNull()
+    const chip = element(h.view.container, '[data-dockkit-tab]')
+    fireEvent.contextMenu(chip)
+    expect(menu?.tab.id).toBe(chip.getAttribute('data-dockkit-tab'))
+    act(() => { menu?.dismiss() })
+    expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull()
+  })
+
+  it('limits splits to two panes and adds a guide only to a pane without one', async () => {
+    const h = await mountSeat()
+    h.open()
+    act(() => { h.controller.split() })
+    expect(dockPaneIds(h.layout())).toHaveLength(2)
+    const stored = h.instance.getSnapshot()
+    act(() => { expect(h.controller.split()).toBeUndefined() })
+    expect(h.instance.getSnapshot()).toBe(stored)
+    const splitButtons = h.view.container.querySelectorAll<HTMLButtonElement>('[data-dockkit-split-button]')
+    expect(splitButtons).toHaveLength(2)
+    expect([...splitButtons].every(button => button.disabled)).toBe(true)
+    const right = dockPaneIds(h.layout())[1]!
+    h.open('right.txt', { paneId: right })
+    const guide = getPane(h.layout(), right).tabs.find(id => h.layout().tabs[id]?.kind === 'guide')!
+    act(() => { h.actions.closeTab(SESSION, guide) })
+    const add = element(h.view.container, '[data-dockkit-add-tab]')
+    expect(add.closest('[data-dockkit-pane]')?.getAttribute('data-dockkit-pane')).toBe(right)
+    fireEvent.click(add)
+    expect(getPane(h.layout(), right).tabs.filter(id => h.layout().tabs[id]?.kind === 'guide')).toHaveLength(1)
+  })
+})
+
+describe('intentsFor — the kit\'s gestures as one session\'s store actions', () => {
+  it('binds every intent to the session, and asks the navigation face for a guide on add', () => {
+    const actions = {
+      focusTab: vi.fn(), focusPane: vi.fn(), splitPane: vi.fn(), closeTab: vi.fn(), duplicateTab: vi.fn(),
+      floatTab: vi.fn(), unfloatPane: vi.fn(), placeTab: vi.fn(), dropTab: vi.fn(), moveFloat: vi.fn(),
+      resizeFloat: vi.fn(), resizeSplit: vi.fn(),
+    }
+    const openTab = vi.fn()
+    const intents = intentsFor(SESSION, actions as unknown as Parameters<typeof intentsFor>[1], openTab)
+    const rect = { x: 1, y: 2, width: 300, height: 200 }
+    const TAB_1 = 'tab-1' as TabId
+    const PANE_1 = 'pane-1' as PaneId
+    const PANE_2 = 'pane-2' as PaneId
+    const SPLIT_1 = 'split-1' as SplitId
+    intents.focusTab(TAB_1)
+    intents.focusPane(PANE_1)
+    intents.splitPane(PANE_1)
+    intents.closeTab(TAB_1)
+    intents.duplicateTab(TAB_1)
+    intents.floatTab(TAB_1, rect)
+    intents.unfloatPane(PANE_2)
+    intents.placeTab(TAB_1, PANE_1, 0)
+    intents.dropTab(TAB_1, PANE_1, 'right')
+    intents.moveFloat(PANE_2, 30, 40)
+    intents.resizeFloat(PANE_2, rect)
+    intents.resizeSplit(SPLIT_1, [0.3, 0.7])
+    expect(actions.focusTab).toHaveBeenCalledWith(SESSION, TAB_1)
+    expect(actions.focusPane).toHaveBeenCalledWith(SESSION, PANE_1)
+    expect(actions.splitPane).toHaveBeenCalledWith(SESSION, PANE_1)
+    expect(actions.closeTab).toHaveBeenCalledWith(SESSION, TAB_1)
+    expect(actions.duplicateTab).toHaveBeenCalledWith(SESSION, TAB_1)
+    expect(actions.floatTab).toHaveBeenCalledWith(SESSION, TAB_1, rect)
+    expect(actions.unfloatPane).toHaveBeenCalledWith(SESSION, PANE_2)
+    expect(actions.placeTab).toHaveBeenCalledWith(SESSION, TAB_1, PANE_1, 0)
+    expect(actions.dropTab).toHaveBeenCalledWith(SESSION, TAB_1, PANE_1, 'right')
+    expect(actions.moveFloat).toHaveBeenCalledWith(SESSION, PANE_2, 30, 40)
+    expect(actions.resizeFloat).toHaveBeenCalledWith(SESSION, PANE_2, rect)
+    expect(actions.resizeSplit).toHaveBeenCalledWith(SESSION, SPLIT_1, [0.3, 0.7])
+    // The add control is the guide opened by kind, in that pane, beside any guide elsewhere.
+    intents.addTab(PANE_1)
+    expect(openTab).toHaveBeenCalledWith('guide', { paneId: PANE_1, revealIfOpened: false })
+  })
+})

+ 580 - 0
packages/client/ui-sidebar-right/tests/service.client.spec.ts

@@ -0,0 +1,580 @@
+/**
+ * What `ctx.sidebarRight` promises other plugins.
+ *
+ * The service is root-scoped and the surface is per session, so every command
+ * depends on a binding the mounted seat publishes; the interesting cases are all
+ * about that seam. Opening is asserted against a real store instance, because
+ * "already open" and "in that tab's place" mean whatever the planner means by
+ * them and nothing else, and against the Tab domain, because an open is not
+ * complete until the tab knows how it was navigated to.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { LayoutState, PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { createSidebarRightController } from '../src/client/service.ts'
+import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts'
+import { createSidebarRightStore } from '../src/client/stores.ts'
+import { guideDefinition } from '../src/client/tabs/guide/definition.ts'
+
+// The params map is empty in this package; a test-only scheme lets specs hand
+// parameters through the typed `open`/`navigate` faces.
+declare module '../src/client/contract/params.ts' {
+  interface SidebarRightResourceParamsMap {
+    test: { line?: number; x?: number }
+  }
+}
+
+const SESSION = 's-test' as SessionId
+
+/** Key-echoing translate: this file asserts behaviour, not copy. */
+const t = ((key: string) => key) as Parameters<typeof guideDefinition>[0]
+
+/** A controller over a real registry and a real store, bound as a mounted seat would be. */
+function harness() {
+  const ctx = new Context()
+  const tabs = new SidebarRightTabRegistry(ctx)
+  tabs.register(guideDefinition(t))
+  // A type claiming `file:` addresses, standing in for whatever package owns
+  // them: this service's contract is that SOME registered type claims, not that
+  // a particular one does.
+  tabs.register({
+    id: 'test/text',
+    kind: 'text',
+    patterns: ['dsh-resource://file/**'],
+    priority: 'fallback',
+    title: address => address.slice(address.lastIndexOf('/') + 1),
+  })
+  const pin = vi.fn<(address: string, signal: AbortSignal) => void>()
+  const { controller, adopt } = createSidebarRightController(tabs, pin)
+  const instance = createSidebarRightStore(() => 'seed').create()
+  const layout = (): LayoutState => {
+    const surface = instance.getSnapshot().bySession[SESSION]
+    if (surface === undefined) throw new Error('expected a surface')
+    return surface.layout
+  }
+  /** Republish the way the seat does after each commit, and sync the domain the way it does too. */
+  /** The room rule's verdict the seat would report; a spec flips it to model a narrow pane. */
+  const room = { allowed: true }
+  const publish = (): (() => void) => {
+    const surface = instance.getSnapshot().bySession[SESSION]
+    if (surface !== undefined) controller.tabDomain.sync(SESSION, surface.layout)
+    return controller.bind({
+      sessionId: SESSION, actions: instance.actions, surfaces: instance.getSnapshot().bySession, canSplitPane: () => room.allowed,
+    })
+  }
+  const titles = (): string[] => {
+    const surface = instance.getSnapshot().bySession[SESSION]
+    return surface === undefined ? [] : Object.values(surface.layout.tabs).map(tab => tab.title)
+  }
+  const tabOf = (title: string): TabId => {
+    const found = Object.values(layout().tabs).find(tab => tab.title === title)
+    if (found === undefined) throw new Error(`expected a tab titled ${title}`)
+    return found.id
+  }
+  const entries = (): number => instance.getSnapshot().bySession[SESSION]?.history.entries.length ?? 0
+  return { controller, adopt, tabs, instance, pin, publish, titles, tabOf, layout, entries, room }
+}
+
+describe('SidebarRightController — opening', () => {
+  it('refuses every write while no seat is mounted', () => {
+    const { controller } = harness()
+    expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted')
+    expect(() => { controller.openTab('guide') }).toThrow('no session surface is mounted')
+    expect(() => { controller.close('tab1' as TabId) }).toThrow('no session surface is mounted')
+    expect(() => { controller.toggleExpanded() }).toThrow('no session surface is mounted')
+    expect(() => { controller.focus('tab1' as TabId) }).toThrow('no session surface is mounted')
+    expect(() => { controller.split() }).toThrow('no session surface is mounted')
+    expect(() => { controller.float('tab1' as TabId) }).toThrow('no session surface is mounted')
+    expect(() => { controller.dock('pane1' as PaneId) }).toThrow('no session surface is mounted')
+  })
+
+  it('refuses an address no registered type claims, before touching the surface', () => {
+    const { controller, publish, titles } = harness()
+    publish()
+    expect(() => { controller.openResource('https://example.com') }).toThrow('no registered tab type claims')
+    expect(titles()).toEqual([])
+  })
+
+  it('opens claimed content and reveals the column in one history entry', () => {
+    const { controller, publish, layout, titles, entries } = harness()
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt')
+    expect(layout().expanded).toBe(true)
+    expect(titles()).toContain('readme.txt')
+    // One intent, one entry: stepping back removes the tab and re-collapses.
+    expect(entries()).toBe(1)
+    publish()
+    controller._undo()
+    expect(layout().expanded).toBe(false)
+    expect(titles()).not.toContain('readme.txt')
+  })
+
+  it('focuses the tab already showing the same (kind, contentId) instead of opening a second one', () => {
+    const { controller, publish, titles, layout, tabOf } = harness()
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt')
+    publish()
+    controller.openTab('guide')
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt')
+    expect(titles().filter(title => title === 'readme.txt')).toHaveLength(1)
+    expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('readme.txt'))
+  })
+
+  it('opens another tab for the same address when told not to reveal, and when another kind is named', () => {
+    const { controller, tabs, publish, titles } = harness()
+    tabs.register({ id: 'test/hex', kind: 'hex', patterns: [], title: () => 'hex view' })
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt')
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt', { revealIfOpened: false })
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/notes/readme.txt', { kind: 'hex' })
+    expect(titles().filter(title => title === 'readme.txt')).toHaveLength(2)
+    expect(titles()).toContain('hex view')
+  })
+
+  it('lands a new tab in the pane the caller names', () => {
+    const { controller, instance, publish, layout, tabOf } = harness()
+    publish()
+    instance.actions.splitPane(SESSION)
+    publish()
+    const [left, right] = Object.values(layout().nodes).filter(node => node.kind === 'pane').map(node => node.id)
+    if (left === undefined || right === undefined) throw new Error('expected two panes')
+    controller.openResource('dsh-resource://file/session/s-test/a.txt', { paneId: left })
+    expect(findTabPane(layout(), tabOf('a.txt')).id).toBe(left)
+  })
+
+  it('takes the replaced tab\'s pane and slot, closes it, and records one entry', () => {
+    const { controller, publish, layout, tabOf, entries } = harness()
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/b.txt')
+    publish()
+    const a = tabOf('a.txt')
+    const before = entries()
+    controller.openResource('dsh-resource://file/session/s-test/c.txt', { replaceTab: a })
+    const pane = findTabPane(layout(), tabOf('c.txt'))
+    // The seeded guide sits at 0; a took 1; c took a's slot.
+    expect(pane.tabs.indexOf(tabOf('c.txt'))).toBe(1)
+    expect(layout().tabs[a]).toBeUndefined()
+    expect(entries()).toBe(before + 1)
+    publish()
+    controller._undo()
+    expect(layout().tabs[a]).toBeDefined()
+    expect(Object.values(layout().tabs).map(tab => tab.title)).not.toContain('c.txt')
+  })
+
+  it('still closes the replaced tab when the address is already open elsewhere', () => {
+    const { controller, publish, layout, tabOf, titles } = harness()
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/b.txt')
+    publish()
+    const spare = tabOf('b.txt')
+    controller.openResource('dsh-resource://file/session/s-test/a.txt', { replaceTab: spare })
+    expect(layout().tabs[spare]).toBeUndefined()
+    expect(titles().filter(title => title === 'a.txt')).toHaveLength(1)
+    expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('a.txt'))
+  })
+
+  it('refuses to copy the guide, and records nothing for the attempt', () => {
+    const { controller, instance, publish, layout, tabOf, entries } = harness()
+    instance.actions.open(SESSION)
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    const before = entries()
+    instance.actions.duplicateTab(SESSION, tabOf('seed'))
+    expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide')).toHaveLength(1)
+    expect(entries()).toBe(before)
+    // Any other tab still copies beside itself.
+    instance.actions.duplicateTab(SESSION, tabOf('a.txt'))
+    expect(Object.values(layout().tabs).filter(tab => tab.title === 'a.txt')).toHaveLength(2)
+  })
+
+  it('reveals an open guide for a plain open, as for any address', () => {
+    const { controller, instance, publish, layout, tabOf } = harness()
+    instance.actions.open(SESSION)
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    controller.openTab('guide')
+    expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide')).toHaveLength(1)
+    expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('seed'))
+  })
+
+  it('keeps the guide to one per pane: opening it into a pane that holds one settles on that one', () => {
+    const { controller, instance, publish, layout, tabOf } = harness()
+    instance.actions.open(SESSION)
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    const root = getPane(layout(), layout().rootId).id
+    let settled: TabId | undefined
+    instance.actions.openContent(SESSION, {
+      kind: 'guide', contentId: 'sidebar://guide', title: 'seed', paneId: root, revealIfOpened: false,
+    }, (tabId) => { settled = tabId })
+    expect(settled).toBe(tabOf('seed'))
+    expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide')).toHaveLength(1)
+    // A pane without one gets its own.
+    instance.actions.closeTab(SESSION, tabOf('seed'))
+    instance.actions.openContent(SESSION, {
+      kind: 'guide', contentId: 'sidebar://guide', title: 'seed', paneId: root, revealIfOpened: false,
+    }, () => {})
+    expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide')).toHaveLength(1)
+  })
+
+  it('merges a guide placed, dropped, or docked into a pane that already holds one', () => {
+    const { instance, publish, layout } = harness()
+    instance.actions.open(SESSION)
+    publish()
+    const guides = (): TabId[] => Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide').map(tab => tab.id)
+    // Place: the split's guide dragged into the first pane's strip.
+    instance.actions.splitPane(SESSION)
+    let [left, right] = dockPaneIds(layout())
+    if (left === undefined || right === undefined) throw new Error('expected two panes')
+    const [own] = getPane(layout(), left).tabs
+    const arrivingByPlace = getPane(layout(), right).tabs[0]
+    if (own === undefined || arrivingByPlace === undefined) throw new Error('expected seeded guides')
+    instance.actions.placeTab(SESSION, arrivingByPlace, left, 0)
+    expect(guides()).toEqual([own])
+    expect(dockPaneIds(layout())).toHaveLength(1)
+    expect(getPane(layout(), left).activeTabId).toBe(own)
+    // Drop on the centre: the same merge. An edge drop makes a new pane and keeps both.
+    instance.actions.splitPane(SESSION)
+    ;[left, right] = dockPaneIds(layout())
+    if (left === undefined || right === undefined) throw new Error('expected two panes')
+    const arrivingByDrop = getPane(layout(), right).tabs[0]
+    if (arrivingByDrop === undefined) throw new Error('expected the seeded guide')
+    instance.actions.dropTab(SESSION, arrivingByDrop, left, 'right')
+    expect(guides()).toHaveLength(2)
+    instance.actions.dropTab(SESSION, arrivingByDrop, left, 'center')
+    expect(guides()).toEqual([own])
+    // Dock: a floating guide returning to a pane that reseeded its own.
+    instance.actions.floatTab(SESSION, own)
+    expect(layout().floats).toHaveLength(1)
+    const reseeded = guides().find(id => id !== own)
+    if (reseeded === undefined) throw new Error('expected the root to reseed a guide')
+    const [float] = layout().floats
+    if (float === undefined) throw new Error('expected the float')
+    instance.actions.unfloatPane(SESSION, float)
+    expect(layout().floats).toHaveLength(0)
+    expect(guides()).toEqual([reseeded])
+  })
+
+  it('records the navigation in the Tab domain: params delivered, revision counting every open', () => {
+    const { controller, publish, tabOf } = harness()
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt', { params: { line: 3 } })
+    const occurrence = controller.tabDomain.occurrence(SESSION, { id: tabOf('a.txt') })
+    expect(occurrence.navigation.getSnapshot()).toEqual({ address: 'dsh-resource://file/session/s-test/a.txt', params: { line: 3 }, revision: 1 })
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    expect(occurrence.navigation.getSnapshot()).toEqual({ address: 'dsh-resource://file/session/s-test/a.txt', params: undefined, revision: 2 })
+  })
+
+  it('closes a tab of the mounted session', () => {
+    const { controller, publish, tabOf, titles } = harness()
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    controller.close(tabOf('a.txt'))
+    expect(titles()).not.toContain('a.txt')
+  })
+
+  it('records the presentation switch, so it steps back with everything else', () => {
+    const { controller, instance, publish, layout } = harness()
+    publish()
+    instance.actions.setMode(SESSION, 'fullscreen')
+    expect(layout().mode).toBe('fullscreen')
+    publish()
+    controller._undo()
+    expect(layout().mode).toBe('push')
+    controller._redo()
+    expect(layout().mode).toBe('fullscreen')
+  })
+})
+
+describe('SidebarRightController — the two opens', () => {
+  it('refuses an address outside dsh-resource:// on the same path as an unclaimed one', () => {
+    const { controller, instance, publish, entries } = harness()
+    instance.actions.open(SESSION)
+    publish()
+    const before = entries()
+    expect(() => { controller.openResource('sidebar://guide') }).toThrow('no registered tab type claims')
+    expect(() => { controller.openResource('https://example.com/a.txt') }).toThrow('no registered tab type claims')
+    expect(() => { controller.openResource('dsh-resource://unknown/x') }).toThrow('no registered tab type claims')
+    expect(entries()).toBe(before)
+  })
+
+  it('opens a page by kind at the address the package records it under, and refuses an unregistered kind', () => {
+    const { controller, instance, publish, layout, entries } = harness()
+    instance.actions.open(SESSION)
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    // The seeded guide is the page in force: opening it by kind reveals that tab.
+    controller.openTab('guide')
+    publish()
+    expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'sidebar://guide')).toHaveLength(1)
+    expect(controller.active()?.kind).toBe('guide')
+    const before = entries()
+    expect(() => { controller.openTab('nope') }).toThrow('no tab type is registered as "nope"')
+    expect(entries()).toBe(before)
+  })
+
+  it('replaceTab opens in the named tab\'s place and closes it, as one entry', () => {
+    const { controller, instance, publish, layout, tabOf, entries } = harness()
+    instance.actions.open(SESSION)
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    const before = entries()
+    controller.openTab('guide', { replaceTab: tabOf('a.txt') })
+    expect(Object.values(layout().tabs).map(tab => tab.title)).toEqual(['seed'])
+    expect(entries()).toBe(before + 1)
+  })
+})
+
+describe('SidebarRightController — layout operations', () => {
+  it('focuses an existing tab and its pane, and leaves a missing tab alone', () => {
+    const { controller, publish, layout, tabOf, entries } = harness()
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('a.txt'))
+    const before = entries()
+    controller.focus(tabOf('seed'))
+    expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('seed'))
+    expect(entries()).toBe(before + 1)
+    controller.focus('tab-nowhere' as TabId)
+    expect(entries()).toBe(before + 1)
+  })
+
+  it('splits nothing before the seat has materialized the surface', () => {
+    const { controller, publish } = harness()
+    publish()
+    expect(controller.split()).toBeUndefined()
+  })
+
+  it('splits the active docked pane and names the new one, or nothing when the budget or the room rule says no', () => {
+    const { controller, instance, publish, layout, entries, room } = harness()
+    instance.actions.open(SESSION)
+    publish()
+    const created = controller.split()
+    expect(created).toBeDefined()
+    expect(dockPaneIds(layout())).toHaveLength(2)
+    expect(dockPaneIds(layout())).toContain(created)
+    publish()
+    // The room rule blocks: nothing is split and nothing is recorded.
+    room.allowed = false
+    const before = entries()
+    expect(controller.split()).toBeUndefined()
+    expect(entries()).toBe(before)
+    room.allowed = true
+    // A floating pane cannot be split.
+    const seed = Object.values(layout().tabs).find(tab => tab.title === 'seed')
+    if (seed === undefined) throw new Error('expected the seeded guide')
+    controller.float(seed.id)
+    publish()
+    const [float] = layout().floats
+    expect(controller.split(float)).toBeUndefined()
+    // The budget: split to two panes, then no more.
+    while (dockPaneIds(layout()).length < 2) {
+      expect(controller.split(dockPaneIds(layout()).at(-1))).toBeDefined()
+      publish()
+    }
+    expect(controller.split()).toBeUndefined()
+  })
+
+  it('floats a docked tab, and leaves a floating or missing tab alone', () => {
+    const { controller, publish, layout, tabOf, entries } = harness()
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    controller.float(tabOf('a.txt'), { x: 10, y: 20, width: 300, height: 200 })
+    expect(layout().floats).toHaveLength(1)
+    expect(findTabPane(layout(), tabOf('a.txt')).rect).toEqual({ x: 10, y: 20, width: 300, height: 200 })
+    publish()
+    const before = entries()
+    controller.float(tabOf('a.txt'))
+    controller.float('tab-nowhere' as TabId)
+    expect(entries()).toBe(before)
+    expect(layout().floats).toHaveLength(1)
+  })
+
+  it('docks a floating panel back into the active docked pane, and leaves a docked or missing pane alone', () => {
+    const { controller, publish, layout, tabOf, entries } = harness()
+    publish()
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    controller.float(tabOf('a.txt'))
+    publish()
+    const [float] = layout().floats
+    if (float === undefined) throw new Error('expected a float')
+    controller.dock(float)
+    expect(layout().floats).toHaveLength(0)
+    expect(findTabPane(layout(), tabOf('a.txt')).host).toBe('dock')
+    publish()
+    const before = entries()
+    controller.dock(getPane(layout(), layout().rootId).id)
+    controller.dock('pane-nowhere' as PaneId)
+    expect(entries()).toBe(before)
+  })
+})
+
+describe('SidebarRightController — a tab\'s own actions', () => {
+  const OTHER = 's-other' as SessionId
+  const A_TXT = 'dsh-resource://file/session/s-test/a.txt'
+  const B_TXT = 'dsh-resource://file/session/s-test/b.txt'
+
+  it('land in the session the tab is in through its own adopted store, after another session\'s seat took over', () => {
+    const { controller, adopt, instance, publish, layout } = harness()
+    const releaseOwn = adopt(SESSION, instance)
+    publish()
+    controller.openResource(A_TXT)
+    const own = Object.values(layout().tabs).find(tab => tab.title === 'a.txt')
+    const guide = Object.values(layout().tabs).find(tab => tab.kind === 'guide')
+    if (own === undefined || guide === undefined) throw new Error('expected the opened tab and the seeded guide')
+    const fromOwn = controller.tabDomain.occurrence(SESSION, own).tabActions
+    const guideOccurrence = controller.tabDomain.occurrence(SESSION, guide)
+    // The user switches sessions: the other seat binds with the other session's
+    // own instance, whose store knows nothing of this session.
+    const other = createSidebarRightStore(() => 'seed').create(OTHER)
+    const releaseOther = adopt(OTHER, other)
+    other.actions.open(OTHER)
+    const otherSurface = other.getSnapshot().bySession[OTHER]
+    if (otherSurface === undefined) throw new Error('expected the other surface')
+    controller.bind({ sessionId: OTHER, actions: other.actions, surfaces: other.getSnapshot().bySession, canSplitPane: () => true })
+    fromOwn.openResource(B_TXT)
+    fromOwn.openTab('guide', { revealIfOpened: false })
+    expect(Object.values(layout().tabs).map(tab => tab.title)).toContain('b.txt')
+    expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface)
+    // Tab ids repeat across sessions: the seeded guides share one. Closing this
+    // session's closes this session's, aborts its occurrence at once although
+    // no seat draws the session, and the other keeps its own.
+    expect(otherSurface.layout.tabs[guide.id]).toBeDefined()
+    guideOccurrence.tabActions.close()
+    expect(layout().tabs[guide.id]).toBeUndefined()
+    expect(guideOccurrence.signal.aborted).toBe(true)
+    expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface)
+    expect(controller.active()?.kind).toBe('guide')
+    releaseOther()
+    releaseOwn()
+  })
+
+  it('do nothing for a session whose store is not adopted, and again once its adoption is released', () => {
+    const { controller, adopt, instance, publish, layout, titles } = harness()
+    publish()
+    controller.openResource(A_TXT)
+    publish()
+    const own = Object.values(layout().tabs).find(tab => tab.title === 'a.txt')
+    if (own === undefined) throw new Error('expected the opened tab')
+    const { tabActions } = controller.tabDomain.occurrence(SESSION, own)
+    const before = instance.getSnapshot().bySession
+    tabActions.openResource(B_TXT)
+    tabActions.openTab('guide')
+    tabActions.close()
+    expect(instance.getSnapshot().bySession).toBe(before)
+    // Adopted, they land; released, they stop again.
+    const release = adopt(SESSION, instance)
+    tabActions.openResource(B_TXT)
+    expect(titles()).toContain('b.txt')
+    release()
+    tabActions.close()
+    expect(titles()).toContain('a.txt')
+  })
+
+  it('adoption syncs the Tab domain on each commit of that store: the seeded guide is pinned, a closed tab aborted', () => {
+    const { controller, adopt, instance, pin } = harness()
+    const first = adopt(SESSION, instance)
+    // Nothing is synced at adoption, and a commit that materializes another
+    // session leaves this session's occurrences alone.
+    instance.actions.open(OTHER)
+    expect(pin).not.toHaveBeenCalled()
+    instance.actions.open(SESSION)
+    expect(pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal))
+    instance.actions.openContent(SESSION, { kind: 'text', contentId: A_TXT, title: 'a' }, () => {})
+    const surface = instance.getSnapshot().bySession[SESSION]
+    const tab = Object.values(surface?.layout.tabs ?? {}).find(record => record.contentId === A_TXT)
+    if (tab === undefined) throw new Error('expected the opened tab')
+    const occurrence = controller.tabDomain.occurrence(SESSION, tab)
+    expect(pin).toHaveBeenCalledWith(A_TXT, occurrence.signal)
+    instance.actions.closeTab(SESSION, tab.id)
+    expect(occurrence.signal.aborted).toBe(true)
+    // A release ends the syncing; a stale release leaves a newer adoption standing.
+    first()
+    const second = adopt(SESSION, instance)
+    first()
+    instance.actions.openContent(SESSION, { kind: 'text', contentId: B_TXT, title: 'b' }, () => {})
+    const again = Object.values(instance.getSnapshot().bySession[SESSION]?.layout.tabs ?? {}).find(record => record.contentId === B_TXT)
+    if (again === undefined) throw new Error('expected the second tab')
+    const held = controller.tabDomain.occurrence(SESSION, again)
+    expect(pin).toHaveBeenCalledWith(B_TXT, held.signal)
+    // Adopting another instance for the session ends the earlier adoption's
+    // subscription with its routing: the old store's commits sync nothing, the
+    // new store's commits reconcile the session against its own layout.
+    const replacement = createSidebarRightStore(() => 'seed').create()
+    const third = adopt(SESSION, replacement)
+    const pins = pin.mock.calls.length
+    instance.actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/c.txt', title: 'c' }, () => {})
+    expect(pin.mock.calls.length).toBe(pins)
+    expect(held.signal.aborted).toBe(false)
+    replacement.actions.open(SESSION)
+    expect(held.signal.aborted).toBe(true)
+    second()
+    third()
+  })
+})
+
+describe('SidebarRightController — the readable slice', () => {
+  it('answers for the no-session case rather than throwing', () => {
+    const { controller } = harness()
+    expect(controller.isExpanded()).toBe(false)
+    expect(controller.active()).toBeUndefined()
+  })
+
+  it('reports the last committed surface', () => {
+    const { controller, publish, layout } = harness()
+    publish()
+    controller.toggleExpanded()
+    publish()
+    expect(controller.isExpanded()).toBe(true)
+    controller.openResource('dsh-resource://file/session/s-test/a.txt')
+    publish()
+    expect(Object.values(layout().tabs).filter(tab => tab.contentId === 'dsh-resource://file/session/s-test/a.txt')).toHaveLength(1)
+    expect(controller.active()?.contentId).toBe('dsh-resource://file/session/s-test/a.txt')
+  })
+})
+
+describe('SidebarRightController — binding lifetime', () => {
+  it('acts on the newest binding when a seat republishes', () => {
+    const { controller, publish, layout } = harness()
+    publish()
+    controller.toggleExpanded()
+    publish()
+    expect(controller.isExpanded()).toBe(true)
+    expect(layout().expanded).toBe(true)
+  })
+
+  it('goes back to refusing writes once the seat releases', () => {
+    const { controller, publish } = harness()
+    const release = publish()
+    release()
+    expect(() => { controller.toggleExpanded() }).toThrow('no session surface is mounted')
+    expect(controller.isExpanded()).toBe(false)
+  })
+
+  it('a stale release does not clear a newer binding', () => {
+    const { controller, publish } = harness()
+    const stale = publish()
+    publish()
+    stale()
+    expect(() => { controller.toggleExpanded() }).not.toThrow()
+  })
+})

+ 241 - 0
packages/client/ui-sidebar-right/tests/stores.client.spec.ts

@@ -0,0 +1,241 @@
+/**
+ * The store shell over the docking kit: every action is one settled intent
+ * recorded as one history entry, or no entry when it changes nothing.
+ *
+ * The opens, the guide's uniqueness, and the merge rule are asserted through
+ * `ctx.sidebarRight` in service.client.spec.ts; here are the intents only the
+ * kit's gestures reach — floating-panel moves and resizes, divider drags — and
+ * the sequence's ends.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import type { LayoutState, PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { dockPaneIds, findTabPane, getPane, getSplit } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { createSidebarRightStore } from '../src/client/stores.ts'
+
+const SESSION = 's-test'
+
+function harness() {
+  const instance = createSidebarRightStore(() => 'Start').create()
+  instance.actions.open(SESSION)
+  const surface = () => {
+    const held = instance.getSnapshot().bySession[SESSION]
+    if (held === undefined) throw new Error('expected a surface')
+    return held
+  }
+  const layout = (): LayoutState => surface().layout
+  const entries = (): number => surface().history.entries.length
+  const guide = (): TabId => {
+    const seeded = Object.values(layout().tabs)[0]
+    if (seeded === undefined) throw new Error('expected the seeded guide')
+    return seeded.id
+  }
+  return { instance, actions: instance.actions, surface, layout, entries, guide }
+}
+
+describe('createSidebarRightStore — the sequence', () => {
+  it('allows two docked panes and rejects further splits without recording', () => {
+    const { actions, layout, entries } = harness()
+    actions.splitPane(SESSION)
+    expect(dockPaneIds(layout())).toHaveLength(2)
+    const before = entries()
+    const settled = vi.fn()
+    actions.splitPane(SESSION, undefined, settled)
+    expect(dockPaneIds(layout())).toHaveLength(2)
+    expect(entries()).toBe(before)
+    expect(settled).not.toHaveBeenCalled()
+  })
+
+  it('rejects vertical drops and keeps split ratios within twenty to eighty percent', () => {
+    const { actions, layout, entries, guide } = harness()
+    actions.openContent(SESSION, { kind: 'text', contentId: 'file:a', title: 'a' }, () => {})
+    const before = entries()
+    actions.dropTab(SESSION, guide(), layout().activePaneId, 'top')
+    actions.dropTab(SESSION, guide(), layout().activePaneId, 'bottom')
+    expect(entries()).toBe(before)
+    actions.splitPane(SESSION)
+    const split = getSplit(layout(), layout().rootId).id
+    actions.resizeSplit(SESSION, split, [0.01, 0.99])
+    expect(getSplit(layout(), split).sizes).toEqual([0.2, 0.8])
+    actions.resizeSplit(SESSION, split, [0.99, 0.01])
+    expect(getSplit(layout(), split).sizes).toEqual([0.8, 0.2])
+    const splitCount = entries()
+    actions.dropTab(SESSION, guide(), layout().activePaneId, 'right')
+    expect(entries()).toBe(splitCount)
+  })
+
+  it('materializes a session on open without recording, then records each change of expansion once', () => {
+    const { actions, layout, entries } = harness()
+    expect(layout().expanded).toBe(false)
+    expect(entries()).toBe(0)
+    actions.setExpanded(SESSION, true)
+    expect(layout().expanded).toBe(true)
+    expect(entries()).toBe(1)
+    // Already expanded: nothing to plan, nothing recorded, the surface kept by reference.
+    const before = layout()
+    actions.setExpanded(SESSION, true)
+    expect(layout()).toBe(before)
+    expect(entries()).toBe(1)
+  })
+
+  it('closes a tab once: a second close of a record already gone records nothing and throws nothing', () => {
+    const { actions, layout, entries } = harness()
+    actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {})
+    const tab = Object.values(layout().tabs).find(record => record.title === 'a')
+    if (tab === undefined) throw new Error('expected the opened tab')
+    actions.closeTab(SESSION, tab.id)
+    expect(layout().tabs[tab.id]).toBeUndefined()
+    const recorded = entries()
+    // A racing callback closing what the user already closed.
+    expect(() => { actions.closeTab(SESSION, tab.id) }).not.toThrow()
+    expect(entries()).toBe(recorded)
+  })
+
+  it('steps nowhere before the first entry, and forward again through an undone one', () => {
+    const { actions, surface, layout } = harness()
+    const start = surface()
+    actions.undo(SESSION)
+    expect(surface()).toBe(start)
+    actions.redo(SESSION)
+    expect(surface()).toBe(start)
+    actions.toggleExpanded(SESSION)
+    actions.undo(SESSION)
+    expect(layout().expanded).toBe(false)
+    actions.redo(SESSION)
+    expect(layout().expanded).toBe(true)
+  })
+})
+
+describe('createSidebarRightStore — floating panels and dividers', () => {
+  it('moves and resizes a floating panel, one entry each', () => {
+    const { actions, layout, entries, guide } = harness()
+    actions.floatTab(SESSION, guide(), { x: 10, y: 20, width: 300, height: 200 })
+    const [float] = layout().floats
+    if (float === undefined) throw new Error('expected a floating pane')
+    const recorded = entries()
+    actions.moveFloat(SESSION, float, 40, 60)
+    expect(findTabPane(layout(), guide()).rect).toEqual({ x: 40, y: 60, width: 300, height: 200 })
+    actions.resizeFloat(SESSION, float, { x: 40, y: 60, width: 420, height: 260 })
+    expect(findTabPane(layout(), guide()).rect).toEqual({ x: 40, y: 60, width: 420, height: 260 })
+    expect(entries()).toBe(recorded + 2)
+  })
+
+  it('focuses a pane as one entry, which a click on a floating panel records', () => {
+    const { actions, layout, entries, guide } = harness()
+    actions.floatTab(SESSION, guide(), { x: 10, y: 20, width: 300, height: 200 })
+    const [float] = layout().floats
+    if (float === undefined) throw new Error('expected a floating pane')
+    // The root pane was reseeded when its guide floated away, so it is docked and focusable.
+    const docked = getPane(layout(), layout().rootId).id
+    actions.focusPane(SESSION, docked)
+    expect(layout().activePaneId).toBe(docked)
+    const recorded = entries()
+    actions.focusPane(SESSION, float)
+    expect(layout().activePaneId).toBe(float)
+    expect(entries()).toBe(recorded + 1)
+  })
+
+  it('records a divider drag as the split\'s new fractions', () => {
+    const { actions, layout, entries } = harness()
+    actions.splitPane(SESSION)
+    const rootId = getSplit(layout(), layout().rootId).id
+    expect(getSplit(layout(), rootId).sizes).toEqual([0.5, 0.5])
+    const recorded = entries()
+    actions.resizeSplit(SESSION, rootId, [0.3, 0.7])
+    expect(getSplit(layout(), rootId).sizes).toEqual([0.3, 0.7])
+    expect(entries()).toBe(recorded + 1)
+  })
+
+  it('reports the pane a split created, and only then', () => {
+    const { actions, layout } = harness()
+    const settled = vi.fn<(paneId: PaneId) => void>()
+    const before = dockPaneIds(layout())
+    actions.splitPane(SESSION, undefined, settled)
+    const created = dockPaneIds(layout()).filter(id => !before.includes(id))
+    expect(created).toHaveLength(1)
+    expect(settled).toHaveBeenCalledExactlyOnceWith(created[0])
+    // A floating pane cannot be split: nothing changes and nothing is reported.
+    const [tab] = getPane(layout(), created[0] as PaneId).tabs
+    if (tab === undefined) throw new Error('expected the seeded guide')
+    actions.floatTab(SESSION, tab)
+    const [float] = layout().floats
+    if (float === undefined) throw new Error('expected a floating pane')
+    actions.splitPane(SESSION, float, settled)
+    expect(settled).toHaveBeenCalledTimes(1)
+  })
+})
+
+describe('createSidebarRightStore — focus', () => {
+  it('records a focus only when it changes which tab or pane is active, docked or floating', () => {
+    const { actions, layout, entries, guide } = harness()
+    actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {})
+    const text = Object.values(layout().tabs).find(tab => tab.kind === 'text')
+    if (text === undefined) throw new Error('expected the text tab')
+    const home = layout().activePaneId
+    expect(getPane(layout(), home).activeTabId).toBe(text.id)
+    const recorded = entries()
+    // Already the active tab of the active pane, already the active pane: nothing to record.
+    actions.focusTab(SESSION, text.id)
+    actions.focusPane(SESSION, home)
+    expect(entries()).toBe(recorded)
+    // Another tab of the same pane: recorded.
+    actions.focusTab(SESSION, guide())
+    expect(getPane(layout(), home).activeTabId).toBe(guide())
+    expect(entries()).toBe(recorded + 1)
+    // Two panes: the home pane's active tab, focused while the other pane is active, is recorded.
+    actions.splitPane(SESSION)
+    const other = dockPaneIds(layout()).find(id => id !== home)
+    if (other === undefined) throw new Error('expected a second pane')
+    actions.focusPane(SESSION, other)
+    const split = entries()
+    actions.focusTab(SESSION, guide())
+    expect(layout().activePaneId).toBe(home)
+    expect(entries()).toBe(split + 1)
+    // A floating pane: focusing it while it is active records nothing; after
+    // a docked pane took focus, focusing its pane and its tab record again.
+    actions.floatTab(SESSION, text.id)
+    const [float] = layout().floats
+    if (float === undefined) throw new Error('expected a floating pane')
+    expect(layout().activePaneId).toBe(float)
+    const floated = entries()
+    actions.focusPane(SESSION, float)
+    actions.focusTab(SESSION, text.id)
+    expect(entries()).toBe(floated)
+    actions.focusPane(SESSION, home)
+    actions.focusPane(SESSION, float)
+    expect(layout().activePaneId).toBe(float)
+    actions.focusPane(SESSION, home)
+    actions.focusTab(SESSION, text.id)
+    expect(layout().activePaneId).toBe(float)
+    expect(entries()).toBe(floated + 4)
+  })
+})
+
+describe('createSidebarRightStore — the guide\'s uniqueness', () => {
+  it('places and drops any other tab as the kit plans it, guides in the target pane or not', () => {
+    const { actions, layout } = harness()
+    actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {})
+    const text = Object.values(layout().tabs).find(tab => tab.kind === 'text')
+    if (text === undefined) throw new Error('expected the text tab')
+    actions.splitPane(SESSION)
+    const [left, right] = dockPaneIds(layout())
+    if (left === undefined || right === undefined) throw new Error('expected two panes')
+    // Placed beside the right pane's guide: both stay, the text tab at the named slot.
+    actions.placeTab(SESSION, text.id, right, 0)
+    expect(getPane(layout(), right).tabs[0]).toBe(text.id)
+    expect(getPane(layout(), right).tabs).toHaveLength(2)
+    // Dropped on the left pane's centre: it moves in beside that pane's guide too.
+    actions.dropTab(SESSION, text.id, left, 'center')
+    expect(getPane(layout(), left).tabs).toContain(text.id)
+    expect(Object.values(layout().tabs).filter(tab => tab.kind === 'guide')).toHaveLength(2)
+  })
+
+  it('reorders the guide within its own pane as a plain place', () => {
+    const { actions, layout, guide } = harness()
+    const pane = layout().activePaneId
+    actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {})
+    expect(getPane(layout(), pane).tabs[0]).toBe(guide())
+    actions.placeTab(SESSION, guide(), pane, 2)
+    expect(getPane(layout(), pane).tabs.at(-1)).toBe(guide())
+    expect(Object.values(layout().tabs).filter(tab => tab.kind === 'guide')).toHaveLength(1)
+  })
+})

+ 210 - 0
packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts

@@ -0,0 +1,210 @@
+/**
+ * The Tab domain: what a record carries that the layout does not, and how long.
+ *
+ * Every assertion here is a lifetime rule a tab type relies on through its
+ * owner props: the signal aborts exactly when the record is gone, the pin lasts
+ * exactly as long, an undone close is a new occurrence, another session's
+ * records are left alone, and a tab's own actions name the tab's session and land where the tab is now.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { LayoutState, TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { DockController, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { TabDomain } from '../src/client/tab-domain.ts'
+import type { SidebarRightNavigator } from '../src/client/tab-domain.ts'
+
+// The params map is empty in this package; a test-only scheme lets specs hand
+// parameters through the typed `open`/`navigate` faces.
+declare module '../src/client/contract/params.ts' {
+  interface SidebarRightResourceParamsMap {
+    test: { line?: number; x?: number }
+  }
+}
+
+const SESSION = 's-one' as SessionId
+const OTHER = 's-two' as SessionId
+
+/** A layout driven by the kit's own controller; the domain only ever reads snapshots. */
+function layouts() {
+  const controller = new DockController({
+    makeInitialTab: id => ({ id, kind: 'guide', contentId: 'sidebar://guide', title: 'Start' }),
+    makePaneTab: id => ({ id, kind: 'guide', contentId: 'sidebar://guide', title: 'Start' }),
+  })
+  const current = (): LayoutState => controller.getSnapshot().state
+  return { controller, current }
+}
+
+function harness() {
+  const navigator = { openResourceIn: vi.fn(), openTabIn: vi.fn(), closeIn: vi.fn() } satisfies
+    SidebarRightNavigator & Record<string, ReturnType<typeof vi.fn>>
+  const pin = vi.fn<(address: string, signal: AbortSignal) => void>()
+  const domain = new TabDomain(navigator, pin)
+  return { domain, navigator, pin, ...layouts() }
+}
+
+/** The record for a tab id, as the seat would hand it over. */
+function recordOf(state: LayoutState, tabId: TabId) {
+  const tab = state.tabs[tabId]
+  if (tab === undefined) throw new Error(`expected tab ${tabId}`)
+  return tab
+}
+
+describe('TabDomain — occurrences follow records', () => {
+  it('pins a record\'s address when it first appears, with the occurrence\'s signal', () => {
+    const { domain, pin, controller, current } = harness()
+    const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    domain.sync(SESSION, current())
+    const occurrence = domain.occurrence(SESSION, recordOf(current(), tabId))
+    expect(pin).toHaveBeenCalledWith('dsh-resource://file/session/s-one/a.txt', occurrence.signal)
+    expect(occurrence.signal.aborted).toBe(false)
+    // Seeded records are occurrences too; the guide is pinned like anything else.
+    expect(pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal))
+    expect(pin).toHaveBeenCalledTimes(2)
+  })
+
+  it('pins once per occurrence, however many times the layout commits', () => {
+    const { domain, pin, controller, current } = harness()
+    controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    domain.sync(SESSION, current())
+    controller.setExpanded(true)
+    domain.sync(SESSION, current())
+    expect(pin).toHaveBeenCalledTimes(2)
+  })
+
+  it('aborts the occurrence when its record vanishes, and builds a new one when undo restores it', () => {
+    const { domain, pin, controller, current } = harness()
+    const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    domain.sync(SESSION, current())
+    const first = domain.occurrence(SESSION, recordOf(current(), tabId))
+    controller.closeTab(tabId)
+    domain.sync(SESSION, current())
+    expect(first.signal.aborted).toBe(true)
+    controller.undo()
+    domain.sync(SESSION, current())
+    const second = domain.occurrence(SESSION, recordOf(current(), tabId))
+    expect(second).not.toBe(first)
+    expect(second.signal.aborted).toBe(false)
+    expect(second.navigation.getSnapshot()).toEqual({ address: 'dsh-resource://file/session/s-one/a.txt', params: undefined, revision: 0 })
+    expect(pin.mock.calls.filter(([address]) => address === 'dsh-resource://file/session/s-one/a.txt')).toHaveLength(2)
+  })
+
+  it('leaves another session\'s occurrences alone when a session is synced', () => {
+    const { domain, controller, current } = harness()
+    const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    domain.sync(SESSION, current())
+    const held = domain.occurrence(SESSION, recordOf(current(), tabId))
+    const other = layouts()
+    domain.sync(OTHER, other.current())
+    expect(held.signal.aborted).toBe(false)
+  })
+
+  it('aborts every occurrence of every session on dispose', () => {
+    const { domain, controller, current } = harness()
+    const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    domain.sync(SESSION, current())
+    domain.sync(OTHER, layouts().current())
+    const held = domain.occurrence(SESSION, recordOf(current(), tabId))
+    domain.dispose()
+    expect(held.signal.aborted).toBe(true)
+  })
+})
+
+describe('TabDomain — navigation', () => {
+  it('creates the occurrence for a tab the seat has not shown yet, at revision 1, and pins it on the next sync', () => {
+    const { domain, pin, controller, current } = harness()
+    const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    domain.navigate(SESSION, tabId, { address: 'dsh-resource://file/session/s-one/a.txt', params: { line: 7 } })
+    expect(pin).not.toHaveBeenCalled()
+    const occurrence = domain.occurrence(SESSION, recordOf(current(), tabId))
+    expect(occurrence.navigation.getSnapshot()).toEqual({ address: 'dsh-resource://file/session/s-one/a.txt', params: { line: 7 }, revision: 1 })
+    domain.sync(SESSION, current())
+    expect(domain.occurrence(SESSION, recordOf(current(), tabId))).toBe(occurrence)
+    expect(pin).toHaveBeenCalledWith('dsh-resource://file/session/s-one/a.txt', occurrence.signal)
+  })
+
+  it('steps the revision on every navigation, params changed or not, and notifies', () => {
+    const { domain, controller, current } = harness()
+    const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    domain.sync(SESSION, current())
+    const occurrence = domain.occurrence(SESSION, recordOf(current(), tabId))
+    const seen = vi.fn()
+    occurrence.navigation.subscribe(seen)
+    domain.navigate(SESSION, tabId, { address: 'dsh-resource://file/session/s-one/a.txt', params: { line: 3 } })
+    domain.navigate(SESSION, tabId, { address: 'dsh-resource://file/session/s-one/a.txt', params: { line: 3 } })
+    expect(occurrence.navigation.getSnapshot()).toEqual({ address: 'dsh-resource://file/session/s-one/a.txt', params: { line: 3 }, revision: 2 })
+    expect(seen).toHaveBeenCalledTimes(2)
+  })
+
+  it('refuses an uncommitted occurrence without creating it during a read', () => {
+    const { domain, pin, controller, current } = harness()
+    const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    expect(() => domain.occurrence(SESSION, { id: tabId })).toThrow('has no committed occurrence')
+    expect(pin).not.toHaveBeenCalled()
+    domain.sync(SESSION, current())
+    const occurrence = domain.occurrence(SESSION, { id: tabId })
+    expect(domain.occurrence(SESSION, { id: tabId })).toBe(occurrence)
+    expect(pin).toHaveBeenCalledWith('dsh-resource://file/session/s-one/a.txt', occurrence.signal)
+    controller.closeTab(tabId)
+    domain.sync(SESSION, current())
+    expect(() => domain.occurrence(SESSION, { id: tabId })).toThrow('has no committed occurrence')
+  })
+})
+
+describe('TabDomain — a tab\'s own actions', () => {
+  it('opens into the pane holding the tab at call time, including after it was moved', () => {
+    const { domain, navigator, controller, current } = harness()
+    controller.setExpanded(true)
+    const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    controller.splitPane()
+    domain.sync(SESSION, current())
+    const occurrence = domain.occurrence(SESSION, recordOf(current(), tabId))
+    const home = current().nodes[current().rootId]
+    if (home?.kind !== 'split') throw new Error('expected a split root')
+    const [left, right] = home.children
+    if (left === undefined || right === undefined) throw new Error('expected two panes')
+    const leftPane = getPane(current(), left).id
+    const rightPane = getPane(current(), right).id
+    occurrence.tabActions.openResource('dsh-resource://file/session/s-one/b.txt')
+    expect(navigator.openResourceIn).toHaveBeenLastCalledWith(SESSION, 'dsh-resource://file/session/s-one/b.txt', { paneId: leftPane })
+    controller.placeTab(tabId, rightPane, 0)
+    domain.sync(SESSION, current())
+    occurrence.tabActions.openResource('dsh-resource://file/session/s-one/b.txt', { params: { line: 1 } })
+    expect(navigator.openResourceIn).toHaveBeenLastCalledWith(SESSION, 'dsh-resource://file/session/s-one/b.txt', { paneId: rightPane, params: { line: 1 } })
+    // The caller's pane wins over the default.
+    occurrence.tabActions.openResource('dsh-resource://file/session/s-one/b.txt', { paneId: leftPane })
+    expect(navigator.openResourceIn).toHaveBeenLastCalledWith(SESSION, 'dsh-resource://file/session/s-one/b.txt', { paneId: leftPane })
+  })
+
+  it('opens unplaced from a floating tab, since a floating pane holds one tab', () => {
+    const { domain, navigator, controller, current } = harness()
+    const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    controller.floatTab(tabId)
+    domain.sync(SESSION, current())
+    domain.occurrence(SESSION, recordOf(current(), tabId)).tabActions.openResource('dsh-resource://file/session/s-one/b.txt')
+    expect(navigator.openResourceIn).toHaveBeenLastCalledWith(SESSION, 'dsh-resource://file/session/s-one/b.txt', {})
+  })
+
+  it('replaces itself and closes itself through the navigator', () => {
+    const { domain, navigator, controller, current } = harness()
+    const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    domain.sync(SESSION, current())
+    const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId))
+    tabActions.openTab('files', { replaceTab: true })
+    expect(navigator.openTabIn).toHaveBeenLastCalledWith(SESSION, 'files', { replaceTab: tabId })
+    tabActions.close()
+    expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId)
+  })
+
+  it('passes revealIfOpened through, so a tab may open a second copy beside itself', () => {
+    const { domain, navigator, controller, current } = harness()
+    const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' })
+    domain.sync(SESSION, current())
+    const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId))
+    tabActions.openResource('dsh-resource://file/session/s-one/a.txt', { revealIfOpened: false })
+    expect(navigator.openResourceIn).toHaveBeenLastCalledWith(
+      SESSION,
+      'dsh-resource://file/session/s-one/a.txt',
+      { paneId: current().rootId, revealIfOpened: false },
+    )
+  })
+})

+ 286 - 0
packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts

@@ -0,0 +1,286 @@
+/**
+ * Stage one of tab-type registration: how the registry decides who opens an
+ * address.
+ *
+ * The decision follows VS Code's editor resolver — declared globs narrow, an
+ * optional predicate vetoes, and survivors rank by band, matched-pattern length,
+ * then registration order — and every step is contract: a type shipped from
+ * another package relies on each one. So each is asserted, not assumed.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts'
+import type { SidebarRightTabDefinition } from '../src/client/tab-registry.ts'
+
+/** A type recognizing `patterns`, titled by its kind. */
+function typeFor(
+  kind: string,
+  patterns: readonly string[],
+  extra: Partial<Omit<SidebarRightTabDefinition, 'kind' | 'patterns'>> = {},
+): SidebarRightTabDefinition {
+  return { id: `test/${kind}`, kind, patterns, title: address => `${kind}:${address}`, ...extra }
+}
+
+/** Kinds of the ranked candidates, best first. */
+function ranked(registry: SidebarRightTabRegistry, address: string): string[] {
+  return registry.candidates(address).map(definition => definition.kind)
+}
+
+describe('SidebarRightTabRegistry — recognition', () => {
+  it('matches a pattern containing ":" against the whole address', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('guide', ['sidebar://guide']))
+    registry.register(typeFor('text', ['dsh-resource://file/**']))
+    expect(registry.claim('sidebar://guide').kind).toBe('guide')
+    expect(registry.claim('dsh-resource://file/session/s/notes/a.txt').kind).toBe('text')
+    expect(ranked(registry, 'sidebar://files')).toEqual([])
+  })
+
+  it('matches a pattern without ":" against the URI path at any depth, ignoring case', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('image', ['*.png']))
+    expect(registry.claim('dsh-resource://file/session/s/deep/er/shot.PNG').kind).toBe('image')
+    expect(ranked(registry, 'dsh-resource://file/session/s/shot.png.txt')).toEqual([])
+  })
+
+  it('matches no path pattern for an address that is not a URI', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('image', ['*.png']))
+    registry.register(typeFor('raw', ['raw:*']))
+    expect(ranked(registry, 'shot.png')).toEqual([])
+    expect(ranked(registry, 'not a uri/shot.png')).toEqual([])
+    // A whole-address pattern still reads the string as given.
+    expect(registry.claim('raw:thing').kind).toBe('raw')
+  })
+
+  it('does not hide dotfiles from a path pattern', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('env', ['.env*']))
+    expect(registry.claim('dsh-resource://file/session/s/proj/.env.local').kind).toBe('env')
+  })
+})
+
+describe('SidebarRightTabRegistry — ranking', () => {
+  it('ranks by band: extension over builtin over fallback, whatever the registration order', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('text', ['dsh-resource://file/**'], { priority: 'fallback' }))
+    registry.register(typeFor('markdown', ['*.md'], { priority: 'builtin' }))
+    registry.register(typeFor('third', ['*.md']))
+    expect(ranked(registry, 'dsh-resource://file/session/s/a.md')).toEqual(['third', 'markdown', 'text'])
+    expect(registry.claim('dsh-resource://file/session/s/a.md').kind).toBe('third')
+  })
+
+  it('lets a more specific builtin beat the fallback viewer despite the viewer\'s longer pattern', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('text', ['dsh-resource://file/**'], { priority: 'fallback' }))
+    registry.register(typeFor('image', ['*.png'], { priority: 'builtin' }))
+    expect(registry.claim('dsh-resource://file/session/s/shot.png').kind).toBe('image')
+    expect(registry.claim('dsh-resource://file/session/s/notes.txt').kind).toBe('text')
+  })
+
+  it('lets an extension take over a builtin kind, and hands it back when the extension leaves', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('text', ['dsh-resource://file/**'], { priority: 'builtin', title: () => 'builtin text' }))
+    const release = registry.register(typeFor('text', ['*.txt'], { id: 'ext/text', title: () => 'extension text' }))
+    // The extension is the type in force: lookups, claims, and the listing.
+    expect(registry.get('text')?.title('x')).toBe('extension text')
+    expect(registry.claim('dsh-resource://file/session/s/a.txt').title).toBe('extension text')
+    expect(registry.entries().map(definition => definition.title('x'))).toEqual(['extension text'])
+    // The shadowed builtin's globs no longer count.
+    expect(ranked(registry, 'dsh-resource://file/session/s/a.bin')).toEqual([])
+    release()
+    expect(registry.get('text')?.title('x')).toBe('builtin text')
+    expect(registry.claim('dsh-resource://file/session/s/a.bin').title).toBe('builtin text')
+  })
+
+  it('lets a builtin register under an extension already holding its kind, shadowed until the extension leaves', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'third-party/text' }))
+    const releaseBuiltin = registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'shipped/text', priority: 'builtin' }))
+    expect(registry.get('text')?.id).toBe('third-party/text')
+    expect(ranked(registry, 'dsh-resource://file/session/s/a.txt')).toEqual(['text'])
+    // The shadowed builtin leaving changes nothing in force, and its band is free again.
+    releaseBuiltin()
+    expect(registry.get('text')?.id).toBe('third-party/text')
+    expect(() => registry.register(typeFor('text', ['*.md'], { id: 'other/text', priority: 'builtin' }))).not.toThrow()
+  })
+
+  it('refuses a second registration in the same band, and any meeting a fallback of the kind', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'a/text', priority: 'builtin' }))
+    expect(() => registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'b/text', priority: 'builtin' }))).toThrow('already registered')
+    registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'c/text' }))
+    expect(() => registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'd/text' }))).toThrow('already registered')
+    expect(() => registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'e/text', priority: 'fallback' }))).toThrow('already registered')
+    registry.register(typeFor('hex', ['dsh-resource://file/**'], { id: 'a/hex', priority: 'fallback' }))
+    expect(() => registry.register(typeFor('hex', ['dsh-resource://file/**'], { id: 'b/hex', priority: 'builtin' }))).toThrow('already registered')
+    expect(() => registry.register(typeFor('hex', ['dsh-resource://file/**'], { id: 'c/hex' }))).toThrow('already registered')
+  })
+
+  it('refuses a second registration of an id, whatever its kind', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    const dispose = registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'pkg/viewer', priority: 'builtin' }))
+    expect(() => registry.register(typeFor('hex', ['*.bin'], { id: 'pkg/viewer' }))).toThrow('tab type id "pkg/viewer" is already registered')
+    dispose()
+    expect(() => registry.register(typeFor('hex', ['*.bin'], { id: 'pkg/viewer' }))).not.toThrow()
+  })
+
+  it('within a band, the longer matched pattern wins', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('markdown', ['*.md'], { priority: 'builtin' }))
+    registry.register(typeFor('readme', ['README.md'], { priority: 'builtin' }))
+    expect(ranked(registry, 'dsh-resource://file/session/s/proj/README.md')).toEqual(['readme', 'markdown'])
+    expect(ranked(registry, 'dsh-resource://file/session/s/proj/notes.md')).toEqual(['markdown'])
+  })
+
+  it('then registration order', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('first', ['dsh-resource://file/**']))
+    registry.register(typeFor('second', ['dsh-resource://file/**']))
+    expect(ranked(registry, 'dsh-resource://file/session/s/a.txt')).toEqual(['first', 'second'])
+  })
+
+  it('measures specificity by the longest pattern that matched, not the longest declared', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('wide', ['*.txt', 'some/very/long/**/never.matches']))
+    registry.register(typeFor('narrow', ['notes.txt']))
+    expect(ranked(registry, 'dsh-resource://file/session/s/notes.txt')).toEqual(['narrow', 'wide'])
+  })
+})
+
+describe('SidebarRightTabRegistry — claiming', () => {
+  it('lets canOpen veto an address its globs matched', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('image', ['*.png'], { canOpen: address => address.startsWith('dsh-resource://file/') }))
+    expect(registry.claim('dsh-resource://file/session/s/shot.png').kind).toBe('image')
+    expect(ranked(registry, 'https://example.com/shot.png')).toEqual([])
+  })
+
+  it('opens with a named type, skipping its globs but honouring its canOpen', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('text', ['dsh-resource://file/**'], { canOpen: address => !address.endsWith('.bin') }))
+    expect(registry.claim('sidebar://guide', 'text').kind).toBe('text')
+    expect(() => registry.claim('dsh-resource://file/session/s/a.bin', 'text')).toThrow('tab type "text" refuses')
+    expect(() => registry.claim('dsh-resource://file/session/s/a.txt', 'nope')).toThrow('no tab type is registered as "nope"')
+  })
+
+  it('answers with the address as contentId and the type\'s title', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('text', ['dsh-resource://file/**']))
+    expect(registry.claim('dsh-resource://file/session/s/a.txt')).toEqual({
+      kind: 'text',
+      contentId: 'dsh-resource://file/session/s/a.txt',
+      title: 'text:dsh-resource://file/session/s/a.txt',
+    })
+  })
+
+  it('reads the title fresh, so a language change needs no re-registration', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    let language = 'zh'
+    registry.register({ id: 'shipped/guide', kind: 'guide', title: () => language === 'zh' ? '开始' : 'Start' })
+    expect(registry.get('guide')?.title('sidebar://guide')).toBe('开始')
+    language = 'en'
+    expect(registry.get('guide')?.title('sidebar://guide')).toBe('Start')
+  })
+})
+
+describe('SidebarRightTabRegistry — ids and page types', () => {
+  it('answers by kind with the definition in force, whose id is where its body lives', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    const disposeBuiltin = registry.register(typeFor('text', ['dsh-resource://file/**'], { id: 'shipped/text', priority: 'builtin' }))
+    expect(registry.get('text')?.id).toBe('shipped/text')
+    const disposeExtension = registry.register(typeFor('text', ['*.txt'], { id: 'third-party/text' }))
+    expect(registry.get('text')?.id).toBe('third-party/text')
+    disposeExtension()
+    expect(registry.get('text')?.id).toBe('shipped/text')
+    disposeBuiltin()
+    expect(registry.get('text')).toBeUndefined()
+  })
+
+  it('lets a page type omit patterns: it claims no address but is found by kind, and its guide entries carry its kind', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register({
+      id: 'shipped/files',
+      kind: 'files',
+      priority: 'builtin',
+      title: () => 'Files',
+      guide: [{ order: 10, title: () => 'Files', description: () => 'Browse' }],
+    })
+    expect(ranked(registry, 'dsh-resource://file/session/s/a.txt')).toEqual([])
+    expect(registry.get('files')?.title('x')).toBe('Files')
+    expect(registry.guide().map(entry => [entry.kind, entry.order])).toEqual([['files', 10]])
+  })
+})
+
+describe('SidebarRightTabRegistry — lifetime', () => {
+  it('lists registered types in registration order', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('guide', ['sidebar://guide']))
+    registry.register(typeFor('text', ['dsh-resource://file/**']))
+    expect(registry.entries().map(entry => entry.kind)).toEqual(['guide', 'text'])
+  })
+
+  it('refuses a second type for the same kind in the same band', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('text', ['dsh-resource://file/**']))
+    expect(() => registry.register(typeFor('text', ['other://**'], { id: 'other/text' })))
+      .toThrow('tab kind "text" is already registered')
+  })
+
+  it('drops a type when its owner disposes, and frees the kind again', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    const dispose = registry.register(typeFor('text', ['dsh-resource://file/**']))
+    dispose()
+    expect(registry.entries()).toEqual([])
+    expect(registry.get('text')).toBeUndefined()
+    expect(() => registry.register(typeFor('text', ['other://**']))).not.toThrow()
+  })
+
+  it('drops a type registered inside another plugin\'s effect when that plugin is disposed', async () => {
+    const ctx = new Context()
+    const registry = new SidebarRightTabRegistry(ctx)
+    const fiber = ctx.plugin({
+      apply(inner: Context) {
+        inner.effect(() => registry.register(typeFor('text', ['dsh-resource://file/**'])), 'test: text type')
+      },
+    })
+    await fiber.await()
+    expect(registry.get('text')).toBeDefined()
+    await fiber.dispose()
+    expect(registry.get('text')).toBeUndefined()
+  })
+
+  it('collects every type\'s guide entries in order, reference-stable between changes', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    const entry = (order: number) => ({ order, title: () => `#${order}`, description: () => '' })
+    registry.register(typeFor('files', [], { guide: [entry(10)] }))
+    const first = registry.guide()
+    expect(registry.guide()).toBe(first)
+    registry.register(typeFor('artifacts', [], { guide: [entry(5)] }))
+    registry.register(typeFor('text', ['dsh-resource://file/**']))
+    expect(registry.guide().map(item => item.kind)).toEqual(['artifacts', 'files'])
+    expect(registry.guide()).not.toBe(first)
+  })
+
+  it('notifies subscribers on registration and on disposal', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    const seen = vi.fn()
+    const unsubscribe = registry.subscribe(seen)
+    const dispose = registry.register(typeFor('text', ['dsh-resource://file/**']))
+    expect(seen).toHaveBeenCalledTimes(1)
+    dispose()
+    expect(seen).toHaveBeenCalledTimes(2)
+    unsubscribe()
+    registry.register(typeFor('other', ['other://**']))
+    expect(seen).toHaveBeenCalledTimes(2)
+  })
+
+  it('keeps entries reference-stable between changes', () => {
+    const registry = new SidebarRightTabRegistry(new Context())
+    registry.register(typeFor('text', ['dsh-resource://file/**']))
+    const first = registry.entries()
+    expect(registry.entries()).toBe(first)
+    registry.register(typeFor('guide', ['sidebar://guide']))
+    expect(registry.entries()).not.toBe(first)
+  })
+})

+ 84 - 0
packages/client/ui-sidebar-textpreview/tests/apply.client.spec.ts

@@ -0,0 +1,84 @@
+/**
+ * The plugin's registrations, and their removal when the plugin goes.
+ *
+ * The registry is real, because "registered" means what it says a type is; the
+ * slot, locale, and Remote faces are recorders, because what matters here is
+ * what was handed to them — one body seat under the type's kind with its store
+ * and face — and that every registration is gone after dispose, which is what
+ * makes a reload safe.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
+import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND } from '../src/client/definition.ts'
+import { apply, inject } from '../src/client/index.ts'
+import { apply as hostApply } from '../src/index.ts'
+import { TextPreview } from '../src/client/TextPreview.tsx'
+import { en, zh } from '../src/client/locales.ts'
+
+interface Recorded {
+  name: string
+  key: string
+  locale: string
+  store: unknown
+  inject: unknown
+  component: unknown
+}
+
+async function boot() {
+  const ctx = new Context()
+  const tabs = new SidebarRightTabRegistry(ctx)
+  const registered: Recorded[] = []
+  const slots = {
+    inject: vi.fn((_name: string, register: () => () => void) => register()),
+    register: vi.fn((options: Omit<Recorded, 'component'>, component: unknown) => {
+      const entry: Recorded = { ...options, component }
+      registered.push(entry)
+      return () => { registered.splice(registered.indexOf(entry), 1) }
+    }),
+  }
+  const dictionaries = new Map<string, unknown>()
+  const locale = {
+    register: vi.fn((ns: string, dicts: unknown) => {
+      dictionaries.set(ns, dicts)
+      return () => { dictionaries.delete(ns) }
+    }),
+  }
+  const workspaceFiles = { read: vi.fn() }
+  ctx.provide('sidebarRightTabs', tabs as never)
+  ctx.provide('slots', slots as never)
+  ctx.provide('locale', locale as never)
+  ctx.provide('remote', { workspaceFiles } as never)
+  ctx.provide('remote.workspaceFiles', workspaceFiles as never)
+  const fiber = ctx.plugin({ inject: [...inject], apply })
+  await fiber.await()
+  return { tabs, registered, dictionaries, fiber }
+}
+
+describe('ui-sidebar-textpreview apply', () => {
+  it('keeps the host Loader entry inert', () => {
+    expect(hostApply).not.toThrow()
+  })
+
+  it('registers the type, its dictionaries, and the body seat under the type\'s id with a store and a face', async () => {
+    const { tabs, registered, dictionaries } = await boot()
+    expect(tabs.get(TEXTPREVIEW_KIND)?.priority).toBe('fallback')
+    expect(tabs.get(TEXTPREVIEW_KIND)?.id).toBe(TEXTPREVIEW_ID)
+    expect(dictionaries.get('sidebarTextpreview')).toEqual({ zh, en })
+    // The seat key is the implementation's id, not the kind: an extension may
+    // take the kind over, and the seat must still find this body.
+    expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
+      ['sidebar.right.pane.tab', TEXTPREVIEW_ID, 'sidebarTextpreview', TextPreview],
+    ])
+    expect(registered[0]?.store).toBeDefined()
+    expect(typeof registered[0]?.inject).toBe('function')
+  })
+
+  it('takes every registration back when the plugin is disposed', async () => {
+    const { tabs, registered, dictionaries, fiber } = await boot()
+    await fiber.dispose()
+    expect(tabs.get(TEXTPREVIEW_KIND)).toBeUndefined()
+    expect(registered).toEqual([])
+    expect(dictionaries.size).toBe(0)
+  })
+})

+ 88 - 0
packages/client/ui-sidebar-textpreview/tests/definition.client.spec.ts

@@ -0,0 +1,88 @@
+/**
+ * What the `text` type claims, and how it yields.
+ *
+ * The type is the fallback viewer for every `file` resource address, so the contract
+ * worth asserting is the yielding: a narrower type registered at a higher
+ * band takes its addresses, and everything else still lands here. Routing is
+ * exercised through the real registry, because "fallback" means whatever the
+ * registry's ranking means by it.
+ */
+import { describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
+import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND, basenameOf, textDefinition } from '../src/client/definition.ts'
+
+describe('basenameOf', () => {
+  it('decodes the last segment, so an escaped name reads as itself', () => {
+    expect(basenameOf('dsh-resource://file/session/s-1/work/notes/a%20b%23c.md')).toBe('a b#c.md')
+  })
+
+  it('falls back to the whole address when there is no last segment', () => {
+    expect(basenameOf('dsh-resource://file/session/s-1/')).toBe('dsh-resource://file/session/s-1/')
+  })
+
+  it('keeps a malformed percent escape as it is rather than refusing the address', () => {
+    expect(basenameOf('dsh-resource://file/session/s-1/work/%E0%A4%A')).toBe('%E0%A4%A')
+  })
+})
+
+describe('textDefinition', () => {
+  it('is the fallback claimant of every file: address, titled by basename', () => {
+    const definition = textDefinition()
+    expect(definition.id).toBe(TEXTPREVIEW_ID)
+    expect(definition.kind).toBe(TEXTPREVIEW_KIND)
+    expect(definition.patterns).toEqual(['dsh-resource://file/**'])
+    expect(definition.priority).toBe('fallback')
+    expect(definition.title('dsh-resource://file/session/s-1/work/README.md')).toBe('README.md')
+    // Both scopes have a read endpoint here; anything the grammar rejects is refused.
+    expect(definition.canOpen?.('dsh-resource://file/session/s-1/work/README.md')).toBe(true)
+    expect(definition.canOpen?.('dsh-resource://file/absolute/home/me/README.md')).toBe(true)
+    expect(definition.canOpen?.('dsh-resource://file/shared/team/README.md')).toBe(false)
+    expect(definition.canOpen?.('dsh-resource://file/session')).toBe(false)
+  })
+})
+
+describe('text type in the registry', () => {
+  function registry() {
+    const tabs = new SidebarRightTabRegistry(new Context())
+    tabs.register(textDefinition())
+    return tabs
+  }
+
+  it('claims files of any extension, depth, and dot-prefix in either scope', () => {
+    const tabs = registry()
+    for (const address of [
+      'dsh-resource://file/session/s-1/a.md',
+      'dsh-resource://file/session/s-1/deep/er/path/x.py',
+      'dsh-resource://file/session/s-1/w/.env',
+      'dsh-resource://file/session/s-1/w/Makefile',
+      'dsh-resource://file/absolute/home/me/notes.md',
+      'dsh-resource://file/absolute/C:/w/x.ts',
+    ]) {
+      expect(tabs.claim(address)).toEqual({ kind: TEXTPREVIEW_KIND, contentId: address, title: basenameOf(address) })
+    }
+  })
+
+  it('refuses a file address in no known scope at claim time, named or ranked', () => {
+    const tabs = registry()
+    const shared = 'dsh-resource://file/shared/team/notes.md'
+    expect(tabs.candidates(shared)).toEqual([])
+    expect(() => tabs.claim(shared)).toThrow('no registered tab type claims')
+    expect(() => tabs.claim(shared, TEXTPREVIEW_KIND)).toThrow(`tab type "${TEXTPREVIEW_KIND}" refuses`)
+  })
+
+  it('yields an address to a narrower type at the extension band, and keeps the rest', () => {
+    const tabs = registry()
+    tabs.register({ id: 'test/image', kind: 'image', patterns: ['*.png'], priority: 'extension', title: () => 'image' })
+    expect(tabs.claim('dsh-resource://file/session/s-1/w/logo.png').kind).toBe('image')
+    expect(tabs.claim('dsh-resource://file/session/s-1/w/logo.md').kind).toBe(TEXTPREVIEW_KIND)
+    // Still listed for the picture: a caller naming the kind may open it as text.
+    expect(tabs.candidates('dsh-resource://file/session/s-1/w/logo.png').map(type => type.kind)).toEqual(['image', TEXTPREVIEW_KIND])
+  })
+
+  it('does not claim addresses of other schemes', () => {
+    const tabs = registry()
+    expect(() => tabs.claim('sidebar://guide')).toThrow('no registered tab type claims')
+    expect(() => tabs.claim('https://example.com/a.md')).toThrow('no registered tab type claims')
+  })
+})

+ 157 - 0
packages/client/ui-sidebar-textpreview/tests/face.client.spec.ts

@@ -0,0 +1,157 @@
+/**
+ * The face's contract with the store: a read in flight is visible, its outcome
+ * lands as a page or a failure, a read outlived by its tab writes nothing, a
+ * reload starts over from the first line and retires the reads still out, and
+ * a newer file version arriving past the first line restarts the walk. The read
+ * runs under the session the file names, not the one the face was injected for.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types'
+import { textFace } from '../src/client/face.ts'
+import type { ReadWorkspaceFilePage } from '../src/client/rpc.ts'
+import { createTextStore } from '../src/client/store.ts'
+import { FILE, PATH, SESSION, failure, page } from './fixtures.client.ts'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+
+const TAB_1 = 'tab-1' as TabId
+
+/** One read awaiting the spec's answer. */
+interface PendingRead {
+  readonly offset: number
+  resolve(result: RemoteResult<WorkspaceFileText>): void
+}
+
+function bench() {
+  const instance = createTextStore().create()
+  const pending: PendingRead[] = []
+  const read = vi.fn<ReadWorkspaceFilePage>((_session, _path, offset) =>
+    new Promise((resolve) => { pending.push({ offset, resolve }) }))
+  // The store's own `forget`, counted: the record's end must forget a tab exactly once.
+  const forget = vi.fn(instance.actions.forget)
+  // Injected for another session on purpose: the address's session must win.
+  const face = textFace(read)('other-session' as SessionId, { ...instance.actions, forget })
+  /** Settle the oldest outstanding read, or the oldest one for `offset`. */
+  const settle = (result: RemoteResult<WorkspaceFileText>, offset?: number): void => {
+    const at = offset === undefined ? 0 : pending.findIndex(call => call.offset === offset)
+    const [call] = pending.splice(at, 1)
+    if (call === undefined) throw new Error('no outstanding read to settle')
+    call.resolve(result)
+  }
+  return {
+    instance, read, face, forget, settle,
+    outstanding: () => pending.map(call => call.offset),
+    tab: () => instance.getSnapshot().byTab[TAB_1],
+  }
+}
+
+const flush = (): Promise<void> => new Promise((resolve) => { setTimeout(resolve, 0) })
+
+describe('textFace', () => {
+  it('marks the read in flight, then keeps the page', async () => {
+    const { read, face, settle, tab } = bench()
+    const controller = new AbortController()
+    face.loadPage(TAB_1, FILE, 1, controller.signal)
+    expect(read).toHaveBeenCalledWith(SESSION, PATH, 1, controller.signal)
+    expect(tab()?.loading).toBe(true)
+    settle(page(1, ['a', 'b'], false))
+    await flush()
+    expect(tab()).toMatchObject({ loading: false, pages: { 1: { text: 'a\nb', lines: 2 } }, eof: false })
+  })
+
+  it('records a failed read', async () => {
+    const { face, settle, tab } = bench()
+    face.loadPage(TAB_1, FILE, 1, new AbortController().signal)
+    settle(failure('workspace-file/outside-workspace', { path: PATH }))
+    await flush()
+    expect(tab()?.failure?.code).toBe('workspace-file/outside-workspace')
+    expect(tab()?.loading).toBe(false)
+  })
+
+  it('forgets the tab when its record ends, once, however many reads armed it, and writes nothing afterwards', async () => {
+    const { read, face, forget, settle, tab } = bench()
+    const controller = new AbortController()
+    const armed = vi.spyOn(controller.signal, 'addEventListener')
+    face.loadPage(TAB_1, FILE, 1, controller.signal)
+    settle(page(1, ['a'], false))
+    await flush()
+    face.loadPage(TAB_1, FILE, 2, controller.signal)
+    face.reloadPages(TAB_1, FILE, controller.signal)
+    expect(armed.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(1)
+    expect(tab()).toBeDefined()
+    controller.abort()
+    expect(forget).toHaveBeenCalledExactlyOnceWith(TAB_1)
+    expect(tab()).toBeUndefined()
+    // The reads still out settle into nothing, and no request is made for the ended record.
+    settle(page(1, ['A'], true))
+    await flush()
+    expect(tab()).toBeUndefined()
+    face.loadPage(TAB_1, FILE, 1, controller.signal)
+    face.reloadPages(TAB_1, FILE, controller.signal)
+    expect(read).toHaveBeenCalledTimes(3)
+    expect(tab()).toBeUndefined()
+  })
+
+  it('reloads from the first line, dropping the pages and keeping the view', async () => {
+    const { instance, read, face, settle, tab } = bench()
+    const controller = new AbortController()
+    face.loadPage(TAB_1, FILE, 1, controller.signal)
+    settle(page(1, ['a'], false))
+    await flush()
+    instance.actions.scrolled(TAB_1, 77)
+    face.reloadPages(TAB_1, FILE, controller.signal)
+    expect(tab()).toMatchObject({ pages: {}, eof: false, version: undefined, loading: true, scrollTop: 77 })
+    expect(read).toHaveBeenLastCalledWith(SESSION, PATH, 1, controller.signal)
+  })
+
+  it('drops a page that settles after a reload retired it, whichever lands first', async () => {
+    const { face, settle, outstanding, tab } = bench()
+    const signal = new AbortController().signal
+    face.loadPage(TAB_1, FILE, 1, signal)
+    settle(page(1, ['a', 'b', 'c'], false))
+    await flush()
+    // Load-more is out when the reader reloads: the new first page lands first.
+    face.loadPage(TAB_1, FILE, 4, signal)
+    face.reloadPages(TAB_1, FILE, signal)
+    expect(outstanding()).toEqual([4, 1])
+    settle(page(1, ['A'], false, 'v2'), 1)
+    await flush()
+    expect(tab()).toMatchObject({ pages: { 1: { text: 'A', lines: 1 } }, version: 'v2', eof: false, loading: false })
+    // The retired page lands afterwards and changes nothing, not even the end flag.
+    settle(page(4, ['d'], true), 4)
+    await flush()
+    expect(tab()).toMatchObject({ pages: { 1: { text: 'A', lines: 1 } }, version: 'v2', eof: false, loading: false })
+  })
+
+  it('starts the walk over when a page of a newer version arrives past the first line', async () => {
+    const { read, face, settle, outstanding, tab } = bench()
+    const signal = new AbortController().signal
+    face.loadPage(TAB_1, FILE, 1, signal)
+    settle(page(1, ['a', 'b', 'c'], false))
+    await flush()
+    face.loadPage(TAB_1, FILE, 4, signal)
+    // The file changed between the two reads: the page is not kept beside the older ones.
+    settle(page(4, ['D'], true, 'v2'))
+    await flush()
+    expect(tab()).toMatchObject({ pages: {}, version: undefined, eof: false, loading: true })
+    expect(read).toHaveBeenCalledTimes(3)
+    expect(outstanding()).toEqual([1])
+    settle(page(1, ['A', 'B'], true, 'v2'))
+    await flush()
+    expect(tab()).toMatchObject({ pages: { 1: { text: 'A\nB', lines: 2 } }, version: 'v2', eof: true, loading: false })
+  })
+
+  it('keeps a first page of a newer version, since the store drops the older pages for it', async () => {
+    const { face, settle, tab } = bench()
+    const signal = new AbortController().signal
+    face.loadPage(TAB_1, FILE, 1, signal)
+    settle(page(1, ['a'], false))
+    await flush()
+    // A retry of the first page after the file changed lands as the new version.
+    face.loadPage(TAB_1, FILE, 1, signal)
+    settle(page(1, ['A'], true, 'v2'))
+    await flush()
+    expect(tab()).toMatchObject({ pages: { 1: { text: 'A', lines: 1 } }, version: 'v2', eof: true })
+  })
+})

+ 36 - 0
packages/client/ui-sidebar-textpreview/tests/failure-line.client.spec.ts

@@ -0,0 +1,36 @@
+/**
+ * One sentence per endpoint code, and the transport's own words for anything else.
+ */
+import { describe, expect, it } from 'vitest'
+import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
+import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
+// The namespace declaration `TranslateNS<'sidebarTextpreview'>` resolves against.
+import type {} from '../src/client/index.ts'
+import { failureLine } from '../src/client/failure-line.ts'
+
+/** Key-echoing translate that also shows its parameters, so a formatted value is visible. */
+const t: TranslateNS<'sidebarTextpreview'> = (key, params) =>
+  params === undefined ? key : `${key}(${Object.entries(params).map(([k, v]) => `${k}=${String(v)}`).join(',')})`
+
+function failure(code: string, details: Record<string, unknown> = {}, message = 'boom'): RemoteFailure {
+  return { code, message, details } as unknown as RemoteFailure
+}
+
+describe('failureLine', () => {
+  it('names each workspace-file code', () => {
+    expect(failureLine(t, failure('workspace-file/not-found'))).toBe('error.notFound')
+    expect(failureLine(t, failure('workspace-file/outside-workspace'))).toBe('error.outsideWorkspace')
+    expect(failureLine(t, failure('workspace-file/not-text'))).toBe('error.notText')
+    expect(failureLine(t, failure('workspace-file/not-regular-file'))).toBe('error.notRegularFile')
+  })
+
+  it('states the byte cap the way a person reads one', () => {
+    expect(failureLine(t, failure('workspace-file/too-large', { limit: 512 }))).toBe('error.tooLarge(limit=512 B)')
+    expect(failureLine(t, failure('workspace-file/too-large', { limit: 4096 }))).toBe('error.tooLarge(limit=4 KB)')
+    expect(failureLine(t, failure('workspace-file/too-large', { limit: 3 * 1024 * 1024 }))).toBe('error.tooLarge(limit=3 MB)')
+  })
+
+  it('passes any other failure through in its own words', () => {
+    expect(failureLine(t, failure('gateway/internal', {}, 'socket closed'))).toBe('error.unavailable(message=socket closed)')
+  })
+})

+ 146 - 0
packages/client/ui-sidebar-textpreview/tests/fixtures.client.ts

@@ -0,0 +1,146 @@
+/**
+ * Shared harness for the body specs: a real store instance, a real face over a
+ * scripted paged read, a scripted `useResource`, and the owner props a tab
+ * record carries.
+ *
+ * The framework's standard kit is replaced by the few members these components
+ * read, behind one documented cast, so the specs exercise the components and
+ * not the slot runtime.
+ */
+import { vi } from 'vitest'
+import type { Mock } from 'vitest'
+import { act } from '@testing-library/react'
+import { useSyncExternalStore } from 'react'
+import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
+import type { ResourceSnapshot } from '@deepseek-ai/dsh-client-resources/client'
+import type { WorkspaceFileResource } from '@deepseek-ai/dsh-api-workspace-files/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { WorkspaceFileText } from '@deepseek-ai/dsh-api-workspace-files/types'
+import type { TextPreviewProps } from '../src/client/TextPreview.tsx'
+import { textFace } from '../src/client/face.ts'
+import type { TextInjected } from '../src/client/face.ts'
+import type { ReadWorkspaceFilePage, SessionFile } from '../src/client/rpc.ts'
+import { createTextStore } from '../src/client/store.ts'
+import type { TextStore } from '../src/client/store.ts'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+
+export const TAB_ID = 'tab-1' as TabId
+export const SESSION = 's-1' as SessionId
+/** The path relative to the session's workspace root, as the Host receives it. */
+export const PATH = 'work/notes.md'
+/** The tab's address: the file under this session's scope. */
+export const ADDRESS = 'dsh-resource://file/session/s-1/work/notes.md'
+/** What the address names, as the face receives it. */
+export const FILE: SessionFile = { sessionId: SESSION, path: PATH }
+
+/** One page the Host would return: the lines joined without a terminator, and their count. */
+export function page(offset: number, lines: readonly string[], eof: boolean, version = 'v1'): RemoteResult<WorkspaceFileText> {
+  return { ok: true, value: { absolutePath: PATH, version, offset, text: lines.join('\n'), lines: lines.length, eof, bytes: 100 } }
+}
+
+/** One failed page read. */
+export function failure(code: string, details: Record<string, unknown> = {}): RemoteResult<WorkspaceFileText> {
+  return { ok: false, error: { code, message: 'boom', details } as unknown as RemoteFailure }
+}
+
+/** The `file` resource's metadata: live, or failed beside the last live value. */
+function meta(changed: boolean, failure: RemoteFailure | undefined, reload: () => void): ResourceSnapshot<WorkspaceFileResource> {
+  const value: WorkspaceFileResource = { version: 'v1', bytes: 100, changed }
+  return failure === undefined
+    ? { status: 'live', value, failure: undefined, reload }
+    : { status: 'failed', value, failure, reload }
+}
+
+/** Test-local selector hook over a framework-neutral store instance. */
+function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
+  return function useSelector<S>(sel: (s: T) => S): S {
+    return sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
+  }
+}
+
+/** Key-echoing translate that also shows its parameters. */
+export function t(key: string, params?: Record<string, unknown>): string {
+  return params === undefined ? key : `${key}(${Object.entries(params).map(([k, v]) => `${k}=${String(v)}`).join(',')})`
+}
+
+/** Flush page reads that resolved since the last render, then React's work. */
+export async function settle(): Promise<void> {
+  await act(async () => {
+    await Promise.resolve()
+    await Promise.resolve()
+  })
+}
+
+/** What one tab record's harness hands a spec. Named so the helper's declaration stays portable. */
+export interface Harness {
+  /** The live store instance both components read. */
+  instance: ReturnType<TextStore['create']>
+  /** The face bound to the scripted read. */
+  face: TextInjected
+  /** The scripted paged read. */
+  read: Mock<ReadWorkspaceFilePage>
+  /** The resource's `reload`. */
+  reload: Mock<() => void>
+  /** The tab record's lifetime. */
+  controller: AbortController
+  /** The scripted `useResource`. */
+  useResource: Mock<() => ResourceSnapshot<WorkspaceFileResource>>
+  /** Composed props for one navigation state. */
+  props: (navigation?: { params?: unknown; revision: number }) => TextPreviewProps
+  /** Script what one offset resolves to from now on. */
+  script(offset: number, result: RemoteResult<WorkspaceFileText>): void
+  /** Script whether the next render's `useResource` reports a pending change. */
+  setChanged(changed: boolean): void
+  /** Script the next render's `useResource` as failed with `failure`, or live again with `undefined`. */
+  setFailure(failure: RemoteFailure | undefined): void
+}
+
+/**
+ * One tab record's harness.
+ * @param script - the page each offset resolves to; an unscripted offset fails `not-found`.
+ * @returns the store, the scripted faces, and a props builder.
+ */
+export function harness(script: Record<number, RemoteResult<WorkspaceFileText>> = {}): Harness {
+  const instance = createTextStore().create()
+  const pages: Record<number, RemoteResult<WorkspaceFileText>> = { ...script }
+  const read = vi.fn<ReadWorkspaceFilePage>((_session, _path, offset) =>
+    Promise.resolve(pages[offset] ?? failure('workspace-file/not-found', { path: PATH })))
+  const face = textFace(read)(SESSION, instance.actions)
+  const reload = vi.fn<() => void>()
+  const current = { changed: false, failure: undefined as RemoteFailure | undefined, snapshot: meta(false, undefined, reload) }
+  const refresh = (): void => { current.snapshot = meta(current.changed, current.failure, reload) }
+  const useResource = vi.fn<() => ResourceSnapshot<WorkspaceFileResource>>(() => current.snapshot)
+  const controller = new AbortController()
+  const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() }
+  const props = (navigation: { params?: unknown; revision: number } = { revision: 1 }) => ({
+    useTabInfo: () => ({
+      sidebar: { expanded: true, fullscreen: false },
+      panel: { id: 'pane-1' },
+      tab: {
+        id: TAB_ID, kind: 'text', contentId: ADDRESS, title: 'notes.md', visible: true,
+        navigation: { address: ADDRESS, params: navigation.params, revision: navigation.revision },
+        signal: controller.signal,
+        actions: tabActions,
+      },
+    }),
+    sessionId: SESSION,
+    useResource,
+    useStore: hookOf(instance),
+    actions: instance.actions,
+    loadPage: face.loadPage,
+    reloadPages: face.reloadPages,
+    t,
+  }) as unknown as TextPreviewProps
+  return {
+    instance,
+    face,
+    read,
+    reload,
+    controller,
+    useResource,
+    props,
+    script(offset, result) { pages[offset] = result },
+    setChanged(changed) { current.changed = changed; refresh() },
+    setFailure(failure) { current.failure = failure; refresh() },
+  }
+}

+ 43 - 0
packages/client/ui-sidebar-textpreview/tests/lines.client.spec.ts

@@ -0,0 +1,43 @@
+/**
+ * The body's page arithmetic: how a page's text and line count become lines,
+ * how the store's page table becomes the pages in file order, and how far they
+ * reach.
+ */
+import { describe, expect, it } from 'vitest'
+import { lastLineLoaded, linesOf, loadedPages } from '../src/client/TextPreview.tsx'
+import type { TextPage } from '../src/client/store.ts'
+
+const held = (text: string, lines: number): TextPage => ({ text, lines })
+
+describe('linesOf', () => {
+  it('splits on newlines, so a trailing one ends an empty last line as the Host counted it', () => {
+    expect(linesOf(held('a\nb', 2))).toEqual(['a', 'b'])
+    expect(linesOf(held('a\n', 2))).toEqual(['a', ''])
+    expect(linesOf(held('a\nb\n', 3))).toEqual(['a', 'b', ''])
+    expect(linesOf(held('a\n\nb', 3))).toEqual(['a', '', 'b'])
+  })
+
+  it('tells a page holding one empty line from a page past the file\'s last line by the count', () => {
+    expect(linesOf(held('', 1))).toEqual([''])
+    expect(linesOf(held('', 0))).toEqual([])
+  })
+})
+
+describe('loadedPages', () => {
+  it('orders the store\'s page table by the line each page starts at', () => {
+    expect(loadedPages({})).toEqual([])
+    expect(loadedPages({ 4: held('d\ne', 2), 1: held('a\nb\nc', 3) })).toEqual([
+      { offset: 1, text: 'a\nb\nc', lines: 3 },
+      { offset: 4, text: 'd\ne', lines: 2 },
+    ])
+  })
+})
+
+describe('lastLineLoaded', () => {
+  it('is 0 before the first page and the last line of the last page after, by the Host\'s count', () => {
+    expect(lastLineLoaded([])).toBe(0)
+    expect(lastLineLoaded(loadedPages({ 4: held('d\ne', 2), 1: held('a\nb\nc', 3) }))).toBe(5)
+    expect(lastLineLoaded(loadedPages({ 1: held('', 1) }))).toBe(1)
+    expect(lastLineLoaded(loadedPages({ 1: held('a', 1), 2: held('', 0) }))).toBe(1)
+  })
+})

+ 40 - 0
packages/client/ui-sidebar-textpreview/tests/rpc.client.spec.ts

@@ -0,0 +1,40 @@
+/**
+ * The address-to-read translation: a `dsh-resource://file/session/<id>/<path>`
+ * address names the session the read runs under and the workspace-relative path
+ * it hands the Host; a `dsh-resource://file/absolute/<path>` address is read
+ * through the seat's own session; anything else fails loud.
+ */
+import { describe, expect, it, vi } from 'vitest'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { createReadPage, hostFileOf } from '../src/client/rpc.ts'
+import type { WorkspaceFilesReadRemote } from '../src/client/rpc.ts'
+import { ADDRESS, FILE, PATH, SESSION, page } from './fixtures.client.ts'
+
+const SEAT = 's-seat' as SessionId
+
+describe('hostFileOf', () => {
+  it('reads the session and the decoded relative path out of a session file address, whatever the seat\'s session', () => {
+    expect(hostFileOf(ADDRESS, SEAT)).toEqual(FILE)
+    expect(hostFileOf('dsh-resource://file/session/s%2F1/work/a%20b%23c.md', SEAT)).toEqual({ sessionId: 's/1', path: 'work/a b#c.md' })
+  })
+
+  it('reads an absolute address through the seat\'s session with the decoded absolute path', () => {
+    expect(hostFileOf('dsh-resource://file/absolute/etc/hosts', SEAT)).toEqual({ sessionId: SEAT, path: '/etc/hosts' })
+    expect(hostFileOf('dsh-resource://file/absolute/C:/w/a%20b.md', SEAT)).toEqual({ sessionId: SEAT, path: 'C:/w/a b.md' })
+  })
+
+  it('throws for an address that is not a file address', () => {
+    for (const address of ['dsh-resource://file/shared/team/notes.md', 'dsh-resource://file/session', 'file:///work/notes.md', 'sidebar://guide']) {
+      expect(() => hostFileOf(address, SEAT)).toThrow('not a file address')
+    }
+  })
+})
+
+describe('createReadPage', () => {
+  it('binds the paged read to the Remote with the offset as the only range', async () => {
+    const read = vi.fn<WorkspaceFilesReadRemote['workspaceFiles']['read']>(() => Promise.resolve(page(4, ['d'], true)))
+    const signal = new AbortController().signal
+    await expect(createReadPage({ workspaceFiles: { read } })(SESSION, PATH, 4, signal)).resolves.toEqual(page(4, ['d'], true))
+    expect(read).toHaveBeenCalledWith(SESSION, PATH, { offset: 4 }, signal)
+  })
+})

+ 84 - 0
packages/client/ui-sidebar-textpreview/tests/store.client.spec.ts

@@ -0,0 +1,84 @@
+/**
+ * The store's write set: pages keyed by their first line, invalidated by a newer
+ * file version; a view that survives a reset; one bucket per tab, dropped on
+ * `forget` so a closed tab leaves nothing behind.
+ */
+import { describe, expect, it } from 'vitest'
+import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
+import { createTextStore, fresh } from '../src/client/store.ts'
+import { page } from './fixtures.client.ts'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+
+const TAB_1 = 'tab-1' as TabId
+const TAB_2 = 'tab-2' as TabId
+const TAB_9 = 'tab-9' as TabId
+
+function pageValue(offset: number, lines: readonly string[], eof: boolean, version = 'v1') {
+  const result = page(offset, lines, eof, version)
+  if (!result.ok) throw new Error('fixture')
+  return result.value
+}
+
+describe('text store', () => {
+  it('starts empty and mints a bucket on the first write', () => {
+    const instance = createTextStore().create()
+    expect(instance.getSnapshot().byTab).toEqual({})
+    instance.actions.loading(TAB_1)
+    expect(instance.getSnapshot().byTab[TAB_1]).toEqual({ ...fresh(), loading: true })
+  })
+
+  it('keeps pages by their first line and reports the end of the file', () => {
+    const instance = createTextStore().create()
+    instance.actions.loading(TAB_1)
+    instance.actions.page(TAB_1, pageValue(1, ['a', 'b'], false))
+    instance.actions.page(TAB_1, pageValue(3, ['c'], true))
+    const state = instance.getSnapshot().byTab[TAB_1]
+    expect(state?.pages).toEqual({ 1: { text: 'a\nb', lines: 2 }, 3: { text: 'c', lines: 1 } })
+    expect(state?.version).toBe('v1')
+    expect(state?.eof).toBe(true)
+    expect(state?.loading).toBe(false)
+  })
+
+  it('drops the pages of an older version when a newer page arrives', () => {
+    const instance = createTextStore().create()
+    instance.actions.page(TAB_1, pageValue(1, ['a'], false))
+    instance.actions.page(TAB_1, pageValue(2, ['B'], true, 'v2'))
+    expect(instance.getSnapshot().byTab[TAB_1]?.pages).toEqual({ 2: { text: 'B', lines: 1 } })
+    expect(instance.getSnapshot().byTab[TAB_1]?.version).toBe('v2')
+  })
+
+  it('records a failure beside the pages already held, and the next page clears it', () => {
+    const instance = createTextStore().create()
+    instance.actions.page(TAB_1, pageValue(1, ['a'], false))
+    const failure = { code: 'workspace-file/too-large', message: 'x', details: {} } as unknown as RemoteFailure
+    instance.actions.failed(TAB_1, failure)
+    expect(instance.getSnapshot().byTab[TAB_1]?.failure).toBe(failure)
+    expect(instance.getSnapshot().byTab[TAB_1]?.pages).toEqual({ 1: { text: 'a', lines: 1 } })
+    instance.actions.page(TAB_1, pageValue(2, ['b'], true))
+    expect(instance.getSnapshot().byTab[TAB_1]?.failure).toBeUndefined()
+  })
+
+  it('resets the pages but keeps the view', () => {
+    const instance = createTextStore().create()
+    instance.actions.page(TAB_1, pageValue(1, ['a'], true))
+    instance.actions.scrolled(TAB_1, 120)
+    instance.actions.toggledWrap(TAB_1)
+    instance.actions.navigated(TAB_1, 3)
+    instance.actions.reset(TAB_1)
+    expect(instance.getSnapshot().byTab[TAB_1]).toEqual({
+      ...fresh(), scrollTop: 120, wrap: false, revision: 3,
+    })
+  })
+
+  it('forgets one tab and keeps the rest', () => {
+    const instance = createTextStore().create()
+    instance.actions.toggledWrap(TAB_1)
+    instance.actions.toggledWrap(TAB_2)
+    instance.actions.forget(TAB_1)
+    expect(Object.keys(instance.getSnapshot().byTab)).toEqual([TAB_2])
+    // Forgetting an unknown tab is a no-op, not a fault: the abort listener may
+    // fire for a tab that never wrote anything.
+    instance.actions.forget(TAB_9)
+    expect(Object.keys(instance.getSnapshot().byTab)).toEqual([TAB_2])
+  })
+})

+ 298 - 0
packages/client/ui-sidebar-textpreview/tests/text-preview.client.spec.tsx

@@ -0,0 +1,298 @@
+// @vitest-environment jsdom
+/**
+ * What the body draws from its pages and the file's metadata, and what it does
+ * with a navigation: load until the asked line is held, jump to it once, then
+ * keep the reader's place.
+ *
+ * jsdom lays nothing out, so two geometry facts are supplied here: a line's
+ * offset is its number times one line height, and `scrollTop` holds what it is
+ * set to. Both are the browser's job; the specs assert the body's arithmetic
+ * over them.
+ */
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render } from '@testing-library/react'
+import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
+import { TextPreview } from '../src/client/TextPreview.tsx'
+import { ADDRESS, PATH, SESSION, TAB_ID, failure, harness, page, settle } from './fixtures.client.ts'
+
+const LINE_HEIGHT = 20
+
+const originals = {
+  offsetTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetTop'),
+  scrollTop: Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollTop'),
+}
+
+beforeAll(() => {
+  Object.defineProperty(HTMLElement.prototype, 'offsetTop', {
+    configurable: true,
+    get(this: HTMLElement) {
+      const line = this.getAttribute('data-textpreview-line')
+      return line === null ? 0 : (Number(line) - 1) * LINE_HEIGHT
+    },
+  })
+  Object.defineProperty(HTMLElement.prototype, 'scrollTop', {
+    configurable: true,
+    get(this: HTMLElement & { __scrollTop?: number }) { return this.__scrollTop ?? 0 },
+    set(this: HTMLElement & { __scrollTop?: number }, value: number) { this.__scrollTop = value },
+  })
+})
+
+afterAll(() => {
+  for (const [name, descriptor] of Object.entries(originals)) {
+    if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name)
+    else Object.defineProperty(HTMLElement.prototype, name, descriptor)
+  }
+})
+
+afterEach(cleanup)
+
+function body(container: HTMLElement): HTMLElement {
+  const element = container.querySelector<HTMLElement>('[data-textpreview-body]')
+  if (element === null) throw new Error('expected the file body')
+  return element
+}
+
+function lines(container: HTMLElement): string[] {
+  return Array.from(container.querySelectorAll('[data-textpreview-line]'), row => row.textContent ?? '')
+}
+
+function target(container: HTMLElement): string | null {
+  return container.querySelector('[data-textpreview-target]')?.getAttribute('data-textpreview-target') ?? null
+}
+
+function click(container: HTMLElement, selector: string): void {
+  const button = container.querySelector<HTMLButtonElement>(selector)
+  if (button === null) throw new Error(`expected ${selector}`)
+  fireEvent.click(button)
+}
+
+describe('TextPreview — pages', () => {
+  it('reads the first page on first mount and draws its lines, offering the next', async () => {
+    const h = harness({ 1: page(1, ['one', 'two', 'three'], false) })
+    const view = render(<TextPreview {...h.props()} />)
+    // The read is in flight from the mount effect on: the body is up with no
+    // lines yet, and its next-page control reports the progress.
+    const pending = view.container.querySelector<HTMLButtonElement>('[data-textpreview-more]')
+    expect(pending?.disabled).toBe(true)
+    expect(pending?.textContent).toBe('loading')
+    expect(lines(view.container)).toEqual([])
+    await settle()
+    expect(h.read).toHaveBeenCalledTimes(1)
+    expect(h.read).toHaveBeenCalledWith(SESSION, PATH, 1, h.controller.signal)
+    expect(lines(view.container)).toEqual(['one\n', 'two\n', 'three\n'])
+    expect(view.container.querySelector('[data-textpreview-url]')?.getAttribute('data-textpreview-url')).toBe(ADDRESS)
+    expect(view.container.textContent).toContain(PATH)
+    expect(view.container.querySelector('[data-textpreview-more]')).not.toBeNull()
+    expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
+  })
+
+  it('reads nothing on a remount while the store holds the pages', async () => {
+    const h = harness({ 1: page(1, ['one'], true) })
+    const first = render(<TextPreview {...h.props()} />)
+    await settle()
+    first.unmount()
+    const second = render(<TextPreview {...h.props()} />)
+    await settle()
+    expect(h.read).toHaveBeenCalledTimes(1)
+    expect(lines(second.container)).toEqual(['one\n'])
+  })
+
+  it('loads the next page where the loaded text ends, until the file ends', async () => {
+    const h = harness({ 1: page(1, ['a', 'b', 'c'], false), 4: page(4, ['d', 'e'], true) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    click(view.container, '[data-textpreview-more]')
+    await settle()
+    expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 4, h.controller.signal)
+    expect(lines(view.container)).toEqual(['a\n', 'b\n', 'c\n', 'd\n', 'e\n'])
+    expect(view.container.querySelectorAll('[data-textpreview-page]').length).toBe(2)
+    expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
+  })
+
+  it('says why a page failed and retries the same page', async () => {
+    const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text')
+    expect(view.container.textContent).toContain('error.notText')
+    expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
+    h.script(1, page(1, ['one'], true))
+    click(view.container, '[data-textpreview-retry]')
+    await settle()
+    expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal)
+    expect(lines(view.container)).toEqual(['one\n'])
+    expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
+  })
+
+  it('announces a change and, on request, re-reads the pages keeping the reader\'s place', async () => {
+    const h = harness({ 1: page(1, ['a', 'b'], true) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    fireEvent.scroll(body(view.container), { target: { scrollTop: 50 } })
+    h.setChanged(true)
+    view.rerender(<TextPreview {...h.props()} />)
+    expect(view.container.querySelector('[data-textpreview-changed]')?.textContent).toContain('changed')
+    expect(lines(view.container)).toEqual(['a\n', 'b\n'])
+    h.script(1, page(1, ['A', 'B', 'C'], true, 'v2'))
+    click(view.container, '[data-textpreview-reload-now]')
+    expect(h.reload).toHaveBeenCalledTimes(1)
+    await settle()
+    expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal)
+    expect(lines(view.container)).toEqual(['A\n', 'B\n', 'C\n'])
+    expect(body(view.container).scrollTop).toBe(50)
+  })
+})
+
+describe('TextPreview — the file\'s metadata', () => {
+  it('announces a failed metadata frame over the pages already loaded, ahead of a pending change, until a reload stats it live again', async () => {
+    const h = harness({ 1: page(1, ['a', 'b'], true) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    h.setChanged(true)
+    h.setFailure(new RemoteError('workspace-file/not-found', 'gone', { path: PATH }))
+    view.rerender(<TextPreview {...h.props()} />)
+    const bar = view.container.querySelector('[data-textpreview-meta-failed]')
+    expect(bar?.getAttribute('data-textpreview-meta-failed')).toBe('workspace-file/not-found')
+    expect(bar?.textContent).toContain('error.notFound')
+    expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
+    expect(lines(view.container)).toEqual(['a\n', 'b\n'])
+    // The bar's reload is the same gesture: stat again through the resource and re-read the pages.
+    h.script(1, page(1, ['A'], true, 'v2'))
+    click(view.container, '[data-textpreview-reload-now]')
+    expect(h.reload).toHaveBeenCalledTimes(1)
+    await settle()
+    expect(lines(view.container)).toEqual(['A\n'])
+    // The stat succeeds: the next frame is live and the bar is gone.
+    h.setChanged(false)
+    h.setFailure(undefined)
+    view.rerender(<TextPreview {...h.props()} />)
+    expect(view.container.querySelector('[data-textpreview-meta-failed]')).toBeNull()
+    expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
+  })
+
+  it('draws a page holding one empty line as one line, and nothing for a page past the end', async () => {
+    const h = harness({ 1: page(1, [''], false), 2: page(2, [], true) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    expect(lines(view.container)).toEqual(['\n'])
+    click(view.container, '[data-textpreview-more]')
+    await settle()
+    expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 2, h.controller.signal)
+    expect(lines(view.container)).toEqual(['\n'])
+    expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
+  })
+})
+
+describe('TextPreview — navigation and view', () => {
+  it('loads until the navigated line is held, then jumps to it once and marks it', async () => {
+    const h = harness({ 1: page(1, ['a', 'b', 'c'], false), 4: page(4, ['d', 'e', 'f'], true) })
+    const view = render(<TextPreview {...h.props({ params: { line: 5 }, revision: 1 })} />)
+    await settle()
+    // The first page does not reach line 5, so the body asks for the next on its own.
+    await settle()
+    expect(h.read).toHaveBeenCalledTimes(2)
+    expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 4, h.controller.signal)
+    expect(body(view.container).scrollTop).toBe(4 * LINE_HEIGHT)
+    expect(target(view.container)).toBe('5')
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1)
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.scrollTop).toBe(4 * LINE_HEIGHT)
+  })
+
+  it('comes back where the reader was on a remount, instead of jumping again', async () => {
+    const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
+    const first = render(<TextPreview {...h.props({ params: { line: 3 }, revision: 1 })} />)
+    await settle()
+    expect(body(first.container).scrollTop).toBe(2 * LINE_HEIGHT)
+    fireEvent.scroll(body(first.container), { target: { scrollTop: 300 } })
+    first.unmount()
+    const second = render(<TextPreview {...h.props({ params: { line: 3 }, revision: 1 })} />)
+    await settle()
+    expect(body(second.container).scrollTop).toBe(300)
+  })
+
+  it('jumps again for a new navigation to the same tab', async () => {
+    const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
+    const view = render(<TextPreview {...h.props({ params: { line: 3 }, revision: 1 })} />)
+    await settle()
+    fireEvent.scroll(body(view.container), { target: { scrollTop: 300 } })
+    view.rerender(<TextPreview {...h.props({ params: { line: 2 }, revision: 2 })} />)
+    expect(body(view.container).scrollTop).toBe(1 * LINE_HEIGHT)
+    expect(target(view.container)).toBe('2')
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2)
+  })
+
+  it('stops at the end of the file for a line past it, and answers a navigation without a line', async () => {
+    const h = harness({ 1: page(1, ['a', 'b'], true) })
+    const view = render(<TextPreview {...h.props({ params: { line: 99 }, revision: 1 })} />)
+    await settle()
+    expect(h.read).toHaveBeenCalledTimes(1)
+    expect(target(view.container)).toBeNull()
+    expect(body(view.container).scrollTop).toBe(0)
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(1)
+    view.rerender(<TextPreview {...h.props({ params: {}, revision: 2 })} />)
+    expect(target(view.container)).toBeNull()
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.revision).toBe(2)
+  })
+
+  it('wraps by default and stops when the shared store says so', async () => {
+    const h = harness({ 1: page(1, ['a'], true) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    expect(body(view.container).hasAttribute('data-textpreview-wrap')).toBe(true)
+    act(() => { h.instance.actions.toggledWrap(TAB_ID) })
+    expect(body(view.container).hasAttribute('data-textpreview-wrap')).toBe(false)
+  })
+})
+
+describe('TextPreview — header controls', () => {
+  it('toggles wrap off from the header, reporting the pressed state', async () => {
+    const h = harness({ 1: page(1, ['a'], true) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    const wrap = view.container.querySelector<HTMLButtonElement>('[data-textpreview-tool="wrap"]')
+    if (wrap === null) throw new Error('expected the wrap control')
+    expect(wrap.getAttribute('aria-pressed')).toBe('true')
+    fireEvent.click(wrap)
+    expect(h.instance.getSnapshot().byTab[TAB_ID]?.wrap).toBe(false)
+    expect(wrap.getAttribute('aria-pressed')).toBe('false')
+    expect(body(view.container).hasAttribute('data-textpreview-wrap')).toBe(false)
+  })
+
+  it('reloads from the header through the resource and the face, without a change announced', async () => {
+    const h = harness({ 1: page(1, ['one'], true) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    expect(h.useResource).toHaveBeenCalledWith(ADDRESS)
+    expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
+    h.script(1, page(1, ['uno'], true, 'v2'))
+    click(view.container, '[data-textpreview-tool="reload"]')
+    expect(h.reload).toHaveBeenCalledTimes(1)
+    await settle()
+    expect(h.read).toHaveBeenLastCalledWith(SESSION, PATH, 1, h.controller.signal)
+    expect(lines(view.container)).toEqual(['uno\n'])
+  })
+
+  it('forgets at once when mounted for a record that has already ended', async () => {
+    const h = harness({ 1: page(1, ['a'], true) })
+    h.controller.abort()
+    render(<TextPreview {...h.props()} />)
+    await settle()
+    expect(h.instance.getSnapshot().byTab[TAB_ID]).toBeUndefined()
+  })
+
+  it('forgets its state when the record ends, even with the body unmounted, through one listener however often it mounted', async () => {
+    const h = harness({ 1: page(1, ['a'], true) })
+    const armed = vi.spyOn(h.controller.signal, 'addEventListener')
+    const first = render(<TextPreview {...h.props()} />)
+    await settle()
+    expect(h.instance.getSnapshot().byTab[TAB_ID]).toBeDefined()
+    // Switched away and back: the store outlives the body, so nothing re-arms.
+    first.unmount()
+    const second = render(<TextPreview {...h.props()} />)
+    await settle()
+    second.unmount()
+    expect(armed.mock.calls.filter(([type]) => type === 'abort')).toHaveLength(1)
+    h.controller.abort()
+    expect(h.instance.getSnapshot().byTab[TAB_ID]).toBeUndefined()
+  })
+})

+ 6 - 1
packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx

@@ -5,12 +5,16 @@
  * asserted in scrollbar-quiet-styles.spec.ts (node environment — a jsdom spec
  * has no file: module URL to read the sheet through).
  */
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
 import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts'
 import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
 import { en } from '../src/client/locales.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 /** Pinned column box; the shell compares pointer coordinates against it. */
 const COLUMN_WIDTH = 280
 const COLUMN_HEIGHT = 600
@@ -35,7 +39,8 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } {
   const view = render(
     <SidebarRoot
       collapsed={false} width={300}
-      useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
+      useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
+      useResource={useResource} useWorkspaces={neverHook}
       startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
       renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>
         <div data-testid="region" data-wide={owner.wide} />) as SidebarRootComponentProps['renderSlot']}

+ 12 - 4
packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { ReactNode } from 'react'
@@ -10,6 +11,9 @@ import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
 import { en } from '../src/client/locales.ts'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 // English-dictionary translate stub: the shell renders the same copy the
 // assertions below query by accessible name.
 const t: SidebarRootComponentProps['t'] = key =>
@@ -40,7 +44,8 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
   const root = () => (
     <SidebarRoot
       collapsed={current.collapsed} width={current.width}
-      useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
+      useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
+      useResource={useResource} useWorkspaces={neverHook}
       startSession={startSession} toggleSidebar={toggleSidebar} t={t}
       renderSlot={((
         key: string,
@@ -104,7 +109,8 @@ describe('SidebarRoot shell', () => {
     vi.stubEnv('DSH_CLIENT_VERSION', '1.2.3-rc.4')
     const { container } = render(<SidebarRoot
       collapsed={false} width={300}
-      useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
+      useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
+      useResource={useResource} useWorkspaces={neverHook}
       startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
       renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
         options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
@@ -122,7 +128,8 @@ describe('SidebarRoot shell', () => {
     for (const [name, value] of Object.entries(environment)) vi.stubEnv(name, value)
     render(<SidebarRoot
       collapsed={false} width={300}
-      useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
+      useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
+      useResource={useResource} useWorkspaces={neverHook}
       startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
       renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
         options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}
@@ -135,7 +142,8 @@ describe('SidebarRoot shell', () => {
   it('retains the local-build fallback without complete build metadata', () => {
     render(<SidebarRoot
       collapsed={false} width={300}
-      useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction} useWorkspaces={neverHook}
+      useSessions={neverHook} useSessionPendingInteraction={useSessionPendingInteraction}
+      useResource={useResource} useWorkspaces={neverHook}
       startSession={vi.fn()} toggleSidebar={vi.fn()} t={t}
       renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) =>
         options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']}

+ 5 - 0
packages/client/ui-theme/tests/appearance-row.client.spec.tsx

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -10,6 +11,9 @@ import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.ts
 import { createAppearanceRowStore } from '../src/client/settings-store.ts'
 import type { ThemePreference } from '../src/client/index.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(cleanup)
 
 const COPY: Record<string, string> = {
@@ -43,6 +47,7 @@ function mount(preference: ThemePreference = 'system') {
   const props: AppearanceRowComponentProps = {
     useSessions: emptySessions(),
     useSessionPendingInteraction,
+    useResource,
     useWorkspaces: emptyWorkspaces(),
     useStore: bindSnapshotSelector(store),
     actions: store.actions,

+ 5 - 0
packages/client/ui-theme/tests/font-size-row.client.spec.tsx

@@ -1,6 +1,7 @@
 // @vitest-environment jsdom
 /** FontSizeRow behavior: value display, arrow clicks drive setFontSize,
  * bound-value arrows disable, display follows the store mirror. */
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -11,6 +12,9 @@ import { FontSizeRow } from '../src/client/FontSizeRow.tsx'
 import type { FontSizeRowComponentProps } from '../src/client/FontSizeRow.tsx'
 import { createFontSizeRowStore } from '../src/client/settings-store.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(cleanup)
 
 const COPY: Record<string, string> = {
@@ -45,6 +49,7 @@ function mount(fontSize = 14) {
   const props: FontSizeRowComponentProps = {
     useSessions: emptySessions(),
     useSessionPendingInteraction,
+    useResource,
     useWorkspaces: emptyWorkspaces(),
     useStore: bindSnapshotSelector(store),
     actions: store.actions,

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

@@ -1,3 +1,4 @@
+import { toolSessionEvents } from './tool-fixtures.client.ts'
 // @vitest-environment jsdom
 /** Tool assembly acceptance through the real ui-conversation host. */
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,7 +14,6 @@ import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
 import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
-import { toolSessionEvents } from './tool-details-render.client.tsx'
 
 // The service reads its initial locale from the browser; these specs assert
 // the shipped Chinese copy, so they state the browser they assume.
@@ -59,14 +59,13 @@ const bashResult = (seq: number, callId: string, over?: Partial<ToolResultNode>)
 })
 
 /** Test-owned AppFrame role: declares and renders the resident conversation area. */
-type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
+type AppRootProps = PropsRenderSlots<'conversation'>
 function AppRoot({ renderSlot }: AppRootProps) {
   return <>{renderSlot('conversation', {})}</>
 }
 
 const LAYOUT_CHILDREN = {
   'conversation': { kind: 'single', scope: 'session-maybe' },
-  'details': { kind: 'single', scope: 'session' },
 } as const
 
 async function bench(nodes: ToolResultNode[]) {
@@ -78,6 +77,7 @@ async function bench(nodes: ToolResultNode[]) {
   })
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
+  runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
   runtime.ctx.provide('uiWorkspace', {
     connectWorkspace: vi.fn(async () => SID),
   } as never)

+ 9 - 7
packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx

@@ -16,7 +16,7 @@ import {
 import { en as conversationEn, NS as CONVERSATION_NS, zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
 import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { apply as applyTool, inject as injectTool } from '../src/client/apply.ts'
-import { toolChatSnapshot } from './tool-details-render.client.tsx'
+import { toolChatSnapshot } from './tool-fixtures.client.ts'
 
 const SID = 's1' as SessionId
 
@@ -112,12 +112,14 @@ async function bench(snapshot: ChatSnapshot) {
 
   await runtime.sessions.add({
     id: SID,
-    summary: { title: 'S', displayTitle: 'S' },
+    summary: { title: 'S', displayTitle: 'S', cwd: '/w' },
     snapshot: { running: snapshot.legacy.runningCalls.length > 0 },
   })
   const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
   const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } }))
   ctx.provide('layout', layout as never)
+  const sidebarRight = { openResource: vi.fn<(address: string) => void>() }
+  ctx.provide('sidebarRight', sidebarRight as never)
   ctx.provide('uiWorkspace', {} as never)
   new TestRemote(ctx, { session: { openWorkspacePath } })
   const locale = new LocaleRuntime(ctx)
@@ -128,7 +130,7 @@ async function bench(snapshot: ChatSnapshot) {
   await runtime.root.declare(ROOT_CHILDREN, AppRoot)
   await runtime.mount({ inject: [...injectChat], apply: applyChat })
   await runtime.mount({ inject: [...injectTool], apply: applyTool })
-  return { runtime, layout, openWorkspacePath }
+  return { runtime, layout, openWorkspacePath, sidebarRight }
 }
 
 function mountApp(runtime: SlotTestRuntime) {
@@ -214,7 +216,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
     expect(nested).not.toBeNull()
   })
 
-  it('a file sub-row click opens the host path; bash sub-rows do not open details', async () => {
+  it('a file sub-row click opens the file in the Sidebar; bash sub-rows open nothing', async () => {
     const parent = 'call-64'
     const subCalls = [
       subCall(11, parent, 1, 'read', { path: 'notes/demo.txt' }, 'ok'),
@@ -223,12 +225,12 @@ describe('run_code sub-calls through the real chat machinery', () => {
     const b = await bench(snapshotWith([codeResult(10, parent)], subCalls))
     const view = mountApp(b.runtime)
     view.getByText('notes/demo.txt').click()
-    expect(b.layout.openDetails).not.toHaveBeenCalled()
     await vi.waitFor(() => {
-      expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: 'notes/demo.txt' })
+      expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/s1/notes/demo.txt')
     })
+    expect(b.openWorkspacePath).not.toHaveBeenCalled()
     view.getByText('List notes').click()
-    expect(b.layout.openDetails).not.toHaveBeenCalled()
+    expect(b.sidebarRight.openResource).toHaveBeenCalledTimes(1)
   })
 
   it('a RUNNING run_code call nests its so-far dispatches under the spinner row', async () => {

+ 2 - 95
packages/client/ui-tool/tests/diff-card.client.spec.tsx

@@ -2,25 +2,17 @@
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render } from '@testing-library/react'
-import {
-  bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot,
-} from '@deepseek-ai/dsh-client-test-runtime'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import type {
-  ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode,
-} from '@deepseek-ai/dsh-client-ui-chat/client'
+import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { CHAT_DIFF_MAX_LINES, diffCardModel } from '../src/client/tool/models/diff-card-model.ts'
-import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts'
 import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
-import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx'
 import { FileMutationRow, fileMutationToolview } from '../src/client/tool/toolviews/file-mutation-row.tsx'
-import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx'
 import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
-import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts'
 
 afterEach(cleanup)
 
@@ -29,7 +21,6 @@ type FileMutationRowProps = Parameters<typeof FileMutationRow>[0]
 const SID = 's1' as SessionId
 
 const t = makeTranslate(zh, commonZh)
-const chatT = makeTranslate(chatZh, commonZh)
 
 const ARGS = '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}'
 
@@ -356,87 +347,3 @@ describe('fileMutationToolview registration', () => {
     expect(registered.every(r => r.disposed)).toBe(true)
   })
 })
-
-describe('DetailsPanel diff Output section', () => {
-  function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null, cwd?: string) {
-    localStorage.clear()
-    const chat = createChatStore().create()
-    if (selection !== null) chat.actions.select(selection)
-    const sessions = createSnapshotStore<SessionListState>(cwd === undefined
-      ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
-      : {
-        ids: [SID],
-        byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
-        current: SID,
-        phase: 'ready',
-        subagentsByParent: {}, jobsBySession: {},
-        currentAddress: undefined,
-      })
-    const session = createSnapshotStore(sessionSnapshot(SID))
-    const conversation = createSnapshotStore(conversationSnapshot())
-    const workspaces = createSnapshotStore(workspaceSnapshot())
-    const attention = createSnapshotStore(new Map())
-    return render(
-      <DetailsPanel
-        renderSlot={renderToolDetails(t)}
-        SessionProvider={({ children }) => children}
-        sessionId={SID}
-        useSession={bindSnapshotSelector(session)}
-        useSessions={bindSnapshotSelector(sessions)}
-        useSessionPendingInteraction={bindSnapshotSelector(attention)}
-        useWorkspaces={bindSnapshotSelector(workspaces)}
-        useConversation={bindSnapshotSelector(conversation)}
-        useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
-        useTrajectory={useEmptyTrajectory}
-        useInput={(() => { throw new Error('unused') })}
-        inputActions={{
-          setDraft: () => {},
-          addAttachments: () => true,
-          removeAttachment: () => {},
-          pruneAttachments: () => {},
-          submit: () => {},
-        }}
-        useProjection={(() => undefined)}
-        useStore={bindSnapshotSelector(chat)}
-        actions={chat.actions}
-        closeDetails={vi.fn()}
-        t={chatT}
-      />,
-    )
-  }
-
-  function snapshot(over: {
-    nodes?: readonly ConversationNode[]
-    runningCalls?: readonly RunningToolCall[]
-  } = {}): ChatSnapshot {
-    const nodes = over.nodes ?? []
-    const runningCalls = over.runningCalls ?? []
-    return toolChatSnapshot(nodes, runningCalls)
-  }
-
-  const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'edit' }
-
-  it('renders the applied diff at full height, keeping the JSON Input section', () => {
-    const view = mount(snapshot({ nodes: [settled()] }), target)
-    expect(view.getByText(/"file_path"/)).toBeTruthy()
-    expect(view.container.querySelector('[data-diff]')).not.toBeNull()
-    expect(view.getByText('hello fixture')).toBeTruthy()
-  })
-
-  it('a running diff call renders its intended change, not the 运行中… placeholder', () => {
-    const view = mount(snapshot({ runningCalls: [running()] }), target)
-    expect(view.container.querySelector('[data-diff]')).not.toBeNull()
-    expect(view.queryByText('运行中…')).toBeNull()
-  })
-
-  it('a non-diff result keeps the flattened pre', () => {
-    const view = mount(snapshot({
-      nodes: [settled({
-        meta: undefined,
-        content: [{ type: 'text', text: 'permission denied' }],
-      })],
-    }), target)
-    expect(view.container.querySelector('[data-diff]')).toBeNull()
-    expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('permission denied')
-  })
-})

+ 30 - 117
packages/client/ui-tool/tests/read-card.client.spec.tsx

@@ -3,24 +3,16 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render } from '@testing-library/react'
 import { Context } from '@deepseek-ai/cordis'
-import {
-  bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, workspaceSnapshot,
-} from '@deepseek-ai/dsh-client-test-runtime'
+import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
-import type {
-  ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode,
-} from '@deepseek-ai/dsh-client-ui-chat/client'
+import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/tool/models/read-card-model.ts'
-import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts'
+import { CHAT_READ_MAX_LINES, readCallLine, readCardModel } from '../src/client/tool/models/read-card-model.ts'
 import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
 import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
-import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts'
-import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx'
 import { ReadRow, readToolview } from '../src/client/tool/toolviews/read-row.tsx'
-import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx'
 
 afterEach(cleanup)
 
@@ -28,7 +20,6 @@ const SID = 's1' as SessionId
 
 /** The chat-view locale seat: this package's namespace over the common fallback. */
 const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
-const chatT = makeTranslate(chatZh, commonZh)
 
 // The read tool's real schema key is `file_path`; the top-level read samples
 // use it so the row exercises a production-shaped call. `web_fetch` (below) has
@@ -149,6 +140,28 @@ describe('readCardModel', () => {
   })
 })
 
+describe('readCallLine', () => {
+  it('reads the 1-based offset a well-formed read call started from, running or settled', () => {
+    expect(readCallLine(running())).toBe(41)
+    expect(readCallLine(settled())).toBe(41)
+  })
+
+  it.each([
+    ['no offset', '{"file_path":"src/a.ts"}'],
+    ['a string offset', '{"file_path":"src/a.ts","offset":"41"}'],
+    ['zero', '{"file_path":"src/a.ts","offset":0}'],
+    ['a negative offset', '{"file_path":"src/a.ts","offset":-3}'],
+    ['a fraction', '{"file_path":"src/a.ts","offset":2.5}'],
+    ['a read without a path', '{"offset":3}'],
+  ])('names no line for %s', (_label, argsRaw) => {
+    expect(readCallLine(running({ argsRaw }))).toBeUndefined()
+  })
+
+  it('names no line for a call that is not read', () => {
+    expect(readCallLine(running({ name: 'echo', argsRaw: '{"offset":3}' }))).toBeUndefined()
+  })
+})
+
 describe('GenericToolCard read body', () => {
   const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({
     loadImage: vi.fn(() => Promise.reject(new Error('not used'))),
@@ -228,13 +241,14 @@ describe('ReadRow keyed toolview', () => {
     expect(view.getAllByText('src/a.ts').length).toBe(1)
   })
 
-  it('the path summary opens the file through the host', () => {
+  it('the path summary opens the file at the line the call started from', () => {
     const openFile = vi.fn()
     const view = render(<ReadRow {...{ ...rowProps(settled()), openFile }} />)
     fireEvent.click(view.getByRole('button', { name: 'src/a.ts' }))
-    // The row derives the file path from args; the chat view resolves it against
-    // the cwd before this callback opens it, so the arg path is what arrives.
-    expect(openFile).toHaveBeenCalledWith('src/a.ts')
+    // The row derives the file path and the `offset` line from args; the chat
+    // view resolves the path against the cwd before this callback opens it, so
+    // the arg path is what arrives.
+    expect(openFile).toHaveBeenCalledWith('src/a.ts', { line: 41 })
   })
 
   it('a running read renders the summary row alone, and its state', () => {
@@ -271,104 +285,3 @@ describe('ReadRow keyed toolview', () => {
     expect(readToolview.inject).toEqual(['slots'])
   })
 })
-
-describe('DetailsPanel Output section (read)', () => {
-  function mount(
-    snapshot: ChatSnapshot,
-    selection: SelectionTarget | null,
-    cwd?: string,
-    description?: Parameters<typeof renderToolDetails>[1],
-  ) {
-    localStorage.clear()
-    const chat = createChatStore().create()
-    if (selection !== null) chat.actions.select(selection)
-    const sessions = createSnapshotStore<SessionListState>(cwd === undefined
-      ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
-      : {
-        ids: [SID],
-        byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
-        current: SID,
-        phase: 'ready',
-        subagentsByParent: {}, jobsBySession: {},
-        currentAddress: undefined,
-      })
-    const session = createSnapshotStore(sessionSnapshot(SID))
-    const conversation = createSnapshotStore(conversationSnapshot())
-    const workspaces = createSnapshotStore(workspaceSnapshot())
-    const attention = createSnapshotStore(new Map())
-    return render(
-      <DetailsPanel
-        renderSlot={renderToolDetails(t, description)}
-        SessionProvider={({ children }) => children}
-        sessionId={SID}
-        t={chatT}
-        useSession={bindSnapshotSelector(session)}
-        useSessions={bindSnapshotSelector(sessions)}
-        useSessionPendingInteraction={bindSnapshotSelector(attention)}
-        useWorkspaces={bindSnapshotSelector(workspaces)}
-        useConversation={bindSnapshotSelector(conversation)}
-        useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
-        useTrajectory={useEmptyTrajectory}
-        useInput={(() => { throw new Error('unused') })}
-        inputActions={{
-          setDraft: () => {},
-          addAttachments: () => true,
-          removeAttachment: () => {},
-          pruneAttachments: () => {},
-          submit: () => {},
-        }}
-        useProjection={(() => undefined)}
-        useStore={bindSnapshotSelector(chat)}
-        actions={chat.actions}
-        closeDetails={vi.fn()}
-      />,
-    )
-  }
-
-  function snapshot(over: {
-    nodes?: readonly ConversationNode[]
-    runningCalls?: readonly RunningToolCall[]
-  } = {}): ChatSnapshot {
-    const nodes = over.nodes ?? []
-    const runningCalls = over.runningCalls ?? []
-    return toolChatSnapshot(nodes, runningCalls)
-  }
-
-  const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' }
-
-  it('renders the read card at full height, keeping the JSON Input section', () => {
-    const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` }))
-    const view = mount(snapshot({
-      nodes: [settled({ meta: readMeta({ offset: 1, lines: long, totalLines: 20 }) })],
-    }), target)
-    expect(view.getByText(/"file_path"/)).toBeTruthy()
-    expect(view.container.querySelector('[data-read]')).not.toBeNull()
-    // The panel takes the primitive's own default cap (16), not the row's.
-    expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
-    expect(contentTexts(view.container)).toContain('row-0')
-  })
-
-  it('a non-read result keeps the flattened pre form', () => {
-    const view = mount(snapshot({
-      nodes: [settled({
-        meta: undefined,
-        content: [{ type: 'text', text: 'plain result' }],
-      })],
-    }), target)
-    expect(view.container.querySelector('[data-read]')).toBeNull()
-    expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result')
-  })
-
-  it('abbreviates a leftover POSIX home path on the read card label', () => {
-    const view = mount(snapshot({
-      nodes: [settled({ meta: readMeta({ path: '/Users/u/notes.md' }) })],
-    }), target, '/tmp/ws', '/Users/u')
-    expect(view.getByText('~/notes.md')).toBeTruthy()
-  })
-
-  it('a running read keeps the 运行中… placeholder (no result metadata)', () => {
-    const view = mount(snapshot({ runningCalls: [running()] }), target)
-    expect(view.getByText('运行中…')).toBeTruthy()
-    expect(view.container.querySelector('[data-read]')).toBeNull()
-  })
-})

+ 1 - 99
packages/client/ui-tool/tests/search-card.client.spec.tsx

@@ -2,32 +2,20 @@
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render } from '@testing-library/react'
-import {
-  bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot,
-} from '@deepseek-ai/dsh-client-test-runtime'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import type {
-  ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode,
-} from '@deepseek-ai/dsh-client-ui-chat/client'
-import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { CHAT_SEARCH_MAX_LINES, searchCardModel } from '../src/client/tool/models/search-card-model.ts'
 import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
-import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts'
-import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts'
 import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
-import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx'
 import { SearchRow, searchToolview } from '../src/client/tool/toolviews/search-row.tsx'
-import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx'
 
 type SearchRowProps = Parameters<typeof SearchRow>[0]
 
 afterEach(cleanup)
 
 const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
-const chatT = makeTranslate(chatZh, commonZh)
 
 /** The rendered search card's kind attribute, so a render site cannot silently drop it. */
 function searchKindOf(container: HTMLElement): string | null {
@@ -367,89 +355,3 @@ describe('SearchRow keyed card', () => {
     expect(searchToolview.inject).toEqual(['slots'])
   })
 })
-
-describe('DetailsPanel Output section (search)', () => {
-  function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null) {
-    localStorage.clear()
-    const chat = createChatStore().create()
-    if (selection !== null) chat.actions.select(selection)
-    const sessions = createSnapshotStore<SessionListState>({
-      ids: [], byId: {}, current: undefined, phase: 'ready',
-      subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
-    })
-    const session = createSnapshotStore(sessionSnapshot(SID))
-    const conversation = createSnapshotStore(conversationSnapshot())
-    const workspaces = createSnapshotStore(workspaceSnapshot())
-    const attention = createSnapshotStore(new Map())
-    return render(
-      <DetailsPanel
-        renderSlot={renderToolDetails(t)}
-        SessionProvider={({ children }) => children}
-        sessionId={SID}
-        useSession={bindSnapshotSelector(session)}
-        useSessions={bindSnapshotSelector(sessions)}
-        useSessionPendingInteraction={bindSnapshotSelector(attention)}
-        useWorkspaces={bindSnapshotSelector(workspaces)}
-        useConversation={bindSnapshotSelector(conversation)}
-        useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
-        useTrajectory={useEmptyTrajectory}
-        useInput={(() => { throw new Error('unused') })}
-        inputActions={{
-          setDraft: () => {},
-          addAttachments: () => true,
-          removeAttachment: () => {},
-          pruneAttachments: () => {},
-          submit: () => {},
-        }}
-        useProjection={(() => undefined)}
-        useStore={bindSnapshotSelector(chat)}
-        actions={chat.actions}
-        closeDetails={vi.fn()}
-        t={chatT}
-      />,
-    )
-  }
-
-  function snapshot(over: {
-    nodes?: readonly ConversationNode[]
-    runningCalls?: readonly RunningToolCall[]
-  } = {}): ChatSnapshot {
-    const nodes = over.nodes ?? []
-    const runningCalls = over.runningCalls ?? []
-    return toolChatSnapshot(nodes, runningCalls)
-  }
-
-  const grepTarget: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'grep' }
-  const globTarget: SelectionTarget = { turnSeq: 11, callId: 'c2', toolName: 'glob' }
-
-  it('renders the grep matches card at full height, keeping the JSON Input section', () => {
-    const view = mount(snapshot({ nodes: [settledGrep()] }), grepTarget)
-    expect(view.getByText(/"pattern"/)).toBeTruthy()
-    expect(searchRows(view.container)).toContain('12: const foo = 1')
-    expect(searchKindOf(view.container)).toBe('matches')
-  })
-
-  it('renders the glob path card', () => {
-    const view = mount(snapshot({ nodes: [settledGlob()] }), globTarget)
-    expect(view.getByText('src/a.ts')).toBeTruthy()
-    expect(searchKindOf(view.container)).toBe('paths')
-  })
-
-  it('renders the recovery footer below the card for a capped search', () => {
-    const recovery = 'src/a.ts\nsrc/b.ts\n\n(Showing 2 of 23 paths. Full sorted result stored at: spill://glob-7.)'
-    const view = mount(snapshot({
-      nodes: [settledGlob({ content: [{ type: 'text', text: recovery }], meta: pathsMeta({ truncated: true, total: 23 }) })],
-    }), globTarget)
-    expect(searchKindOf(view.container)).toBe('paths')
-    expect(view.getByText(/Full sorted result stored at: spill:\/\/glob-7/)).toBeTruthy()
-  })
-
-  it('a non-search result keeps the flattened pre form', () => {
-    const view = mount(snapshot({
-      nodes: [settledGrep({ meta: undefined })],
-    }), grepTarget)
-    expect(searchKindOf(view.container)).toBeNull()
-    const output = view.getByText('输出').closest('section')
-    expect(output?.querySelector('pre')?.textContent).toContain('const foo = 1')
-  })
-})

+ 2 - 269
packages/client/ui-tool/tests/terminal-card.client.spec.tsx

@@ -2,13 +2,9 @@
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render } from '@testing-library/react'
-import {
-  bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot,
-} from '@deepseek-ai/dsh-client-test-runtime'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import type {
-  ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode,
-} from '@deepseek-ai/dsh-client-ui-chat/client'
+import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -17,19 +13,14 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
 import {
   localizeTerminalCardModel, terminalCardModel, terminalFailed,
 } from '../src/client/tool/models/terminal-card-model.ts'
-import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts'
 import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
-import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx'
 import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx'
-import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx'
 import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
-import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts'
 
 type BashRowProps = Parameters<typeof BashRow>[0]
 
 const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
 const enT: GenericToolCardProps['t'] = makeTranslate(en, commonEn)
-const chatT = makeTranslate(chatZh, commonZh)
 
 afterEach(cleanup)
 
@@ -531,261 +522,3 @@ describe('BashRow terminal card', () => {
     expect(view.container.querySelector('[data-error]')?.textContent).toBe('Error: command aborted')
   })
 })
-
-describe('DetailsPanel Output section', () => {
-  function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null, cwd?: string) {
-    localStorage.clear()
-    const chat = createChatStore().create()
-    if (selection !== null) chat.actions.select(selection)
-    const sessions = createSnapshotStore<SessionListState>(cwd === undefined
-      ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined }
-      : {
-        ids: [SID],
-        byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } },
-        current: SID,
-        phase: 'ready',
-        subagentsByParent: {}, jobsBySession: {},
-        currentAddress: undefined,
-      })
-    const session = createSnapshotStore(sessionSnapshot(SID))
-    const conversation = createSnapshotStore(conversationSnapshot())
-    const workspaces = createSnapshotStore(workspaceSnapshot())
-    const attention = createSnapshotStore(new Map())
-    return render(
-      <DetailsPanel
-        renderSlot={renderToolDetails(t)}
-        SessionProvider={({ children }) => children}
-        sessionId={SID}
-        useSession={bindSnapshotSelector(session)}
-        useSessions={bindSnapshotSelector(sessions)}
-        useSessionPendingInteraction={bindSnapshotSelector(attention)}
-        useWorkspaces={bindSnapshotSelector(workspaces)}
-        useConversation={bindSnapshotSelector(conversation)}
-        useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
-        useTrajectory={useEmptyTrajectory}
-        useInput={(() => { throw new Error('unused') })}
-        inputActions={{
-          setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {},
-          pruneAttachments: () => {}, submit: () => {},
-        }}
-        useProjection={(() => undefined)}
-        useStore={bindSnapshotSelector(chat)}
-        actions={chat.actions}
-        closeDetails={vi.fn()}
-        t={chatT}
-      />,
-    )
-  }
-
-  function snapshot(over: {
-    nodes?: readonly ConversationNode[]
-    runningCalls?: readonly RunningToolCall[]
-  } = {}): ChatSnapshot {
-    const nodes = over.nodes ?? []
-    const runningCalls = over.runningCalls ?? []
-    return toolChatSnapshot(nodes, runningCalls)
-  }
-
-  const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' }
-
-  // The panel never unmounts between selections, so per-call view state has to
-  // be keyed off the selected call or it leaks into the next one.
-  it('resets the card\'s expand state when the selected call changes', () => {
-    const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
-    const view = mount(snapshot({
-      nodes: [settled({ content: [{ type: 'text', text: `${long.join('\n')}\n` }] })],
-    }), target)
-    fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' }))
-    expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy()
-    // A second call, selected without unmounting the panel, starts collapsed.
-    cleanup()
-    const second = mount(snapshot({
-      nodes: [settled({
-        callId: 'c2', content: [{ type: 'text', text: `${long.join('\n')}\n` }],
-      })],
-    }), { turnSeq: 10, callId: 'c2', toolName: 'bash' })
-    expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
-  })
-
-  it('renders the raw call description above the card', () => {
-    const view = mount(snapshot({
-      nodes: [settled({ call: { name: 'bash', argsRaw: shellArgs({ description: 'Terminal 3' }) } })],
-    }), target)
-    const description = view.getByText('Terminal 3')
-    const card = view.container.querySelector('[data-terminal]')
-    expect(card).not.toBeNull()
-    // Above, not below: document order is what places it as the card's heading.
-    expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
-  })
-
-  it('localizes terminal_send copy in Details', () => {
-    const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: '' })
-    const view = mount(snapshot({
-      nodes: [settled({
-        call: { name: 'terminal_send', argsRaw },
-        content: [{ type: 'text', text: 'ok' }],
-      })],
-    }), { ...target, toolName: 'terminal_send' })
-    expect(view.getByText('终端 pty-3')).toBeTruthy()
-    expect(view.getByText('(发送输入)')).toBeTruthy()
-  })
-
-  it('resolves the prompt cwd against the session workspace', () => {
-    const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
-    // No workdir in the call args: the prompt label is the workspace basename.
-    expect(view.getByText('app')).toBeTruthy()
-  })
-
-  it('renders the terminal card at full height, keeping the JSON Input section', () => {
-    const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
-    const view = mount(snapshot({
-      nodes: [settled({ content: [{ type: 'text', text: `${long.join('\n')}\n` }] })],
-    }), target)
-    expect(view.getByText(/"command"/)).toBeTruthy()
-    expect(view.getByText('ls -la')).toBeTruthy()
-    // The panel takes the primitive's own default cap (16), not the row's.
-    expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
-    expect(view.getByText('row-0')).toBeTruthy()
-  })
-
-  it('a running terminal call shows the prompt line, not the 运行中… placeholder', () => {
-    const view = mount(snapshot({ runningCalls: [running()] }), target)
-    expect(view.getByText('ls -la')).toBeTruthy()
-    expect(view.queryByText('运行中…')).toBeNull()
-    expect(runStateOf(view.container)).toBe('ongoing')
-  })
-
-  it('a running non-terminal call keeps the 运行中… placeholder', () => {
-    const view = mount(snapshot({ runningCalls: [running({ argsRaw: shellArgs({ run_in_background: true }) })] }), target)
-    expect(view.getByText('运行中…')).toBeTruthy()
-  })
-
-  it('a non-terminal result keeps the flattened pre with its error styling', () => {
-    const view = mount(snapshot({
-      nodes: [settled({
-        isError: true,
-        content: [{ type: 'text', text: 'permission denied' }],
-      })],
-    }), target)
-    const pre = view.container.querySelector('pre[data-error]')
-    expect(pre?.textContent).toBe('permission denied')
-  })
-
-  it('a Code Dispatch child renders its terminal output in Details', () => {
-    const child = settled({ callId: 'c1', parentCallId: 'p1' })
-    const view = mount(snapshot({
-      runningCalls: [running({ callId: 'p1', subCalls: [child] })],
-    }), target)
-    expect(view.container.querySelector('[data-terminal]')?.textContent).toContain('a.ts  b.ts')
-    expect(view.getByText('ls -la')).toBeTruthy()
-    expect(runStateOf(view.container)).toBe('done')
-  })
-
-  it('a running Code Dispatch child renders its terminal prompt in Details', () => {
-    const view = mount(snapshot({
-      // The leading non-matching sub-call exercises the scan's skip.
-      runningCalls: [running({
-        callId: 'p1',
-        subCalls: [
-          running({ callId: 'other', parentCallId: 'p1' }),
-          running({ parentCallId: 'p1' }),
-        ],
-      })],
-    }), target)
-    expect(view.queryByText('运行中…')).toBeNull()
-    expect(view.getByText('ls -la')).toBeTruthy()
-    expect(runStateOf(view.container)).toBe('ongoing')
-  })
-
-  it('a window-truncated call head titles the panel by callId and drops the Input section', () => {
-    const view = mount(snapshot({
-      nodes: [settled({ call: null })],
-    }), target)
-    expect(view.getByText('c1')).toBeTruthy()
-    expect(view.queryByText('输入')).toBeNull()
-    expect(view.getByText('输出')).toBeTruthy()
-  })
-
-  it('scans past other nodes and other calls before reporting the call out of window', () => {
-    const view = mount(snapshot({
-      nodes: [
-        { kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [] },
-        settled({ callId: 'elsewhere' }),
-      ],
-      runningCalls: [running({ callId: 'also-elsewhere' })],
-    }), target)
-    expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
-  })
-
-  it('no selection at all renders the guidance line and the default title', () => {
-    const view = mount(snapshot(), null)
-    expect(view.getByText('详情')).toBeTruthy()
-    expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
-  })
-
-  it('a step selection without a callId renders the guidance line too', () => {
-    const view = mount(snapshot(), { turnSeq: 3, stepSeq: 1 })
-    expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
-  })
-
-  it('the close button reaches closeDetails', () => {
-    localStorage.clear()
-    const chat = createChatStore().create()
-    const closeDetails = vi.fn()
-    const snap = snapshot()
-    const session = createSnapshotStore(sessionSnapshot(SID))
-    const conversation = createSnapshotStore(conversationSnapshot())
-    const workspaces = createSnapshotStore(workspaceSnapshot())
-    const attention = createSnapshotStore(new Map())
-    const view = render(
-      <DetailsPanel
-        renderSlot={renderToolDetails(t)}
-        SessionProvider={({ children }) => children}
-        sessionId={SID}
-        useSession={bindSnapshotSelector(session)}
-        useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
-          {
-            ids: [], byId: {}, current: undefined, phase: 'ready',
-            subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
-          }))}
-        useSessionPendingInteraction={bindSnapshotSelector(attention)}
-        useWorkspaces={bindSnapshotSelector(workspaces)}
-        useConversation={bindSnapshotSelector(conversation)}
-        useChat={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
-        useTrajectory={useEmptyTrajectory}
-        useInput={(() => { throw new Error('unused') })}
-        inputActions={{
-          setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {},
-          pruneAttachments: () => {}, submit: () => {},
-        }}
-        useProjection={(() => undefined)}
-        useStore={bindSnapshotSelector(chat)}
-        actions={chat.actions}
-        closeDetails={closeDetails}
-        t={chatT}
-      />,
-    )
-    fireEvent.click(view.getByRole('button', { name: '关闭详情' }))
-    expect(closeDetails).toHaveBeenCalledTimes(1)
-  })
-
-  it('a non-text result block renders as JSON, and an empty result falls back to its error', () => {
-    const nonText = mount(snapshot({
-      nodes: [settled({
-        content: [{ type: 'reasoning', text: 'why' }],
-      })],
-    }), target)
-    // Scope to the Output section: the Input section's CodeBlock renders a
-    // <pre> of its own, and it comes first in document order.
-    expect(nonText.getByText('输出').closest('section')?.querySelector('pre')?.textContent)
-      .toBe('{\n  "type": "reasoning",\n  "text": "why"\n}')
-    cleanup()
-    const empty = mount(snapshot({
-      nodes: [settled({
-        content: [], isError: true,
-        error: { name: 'ToolError', code: 'interrupted' },
-      })],
-    }), target)
-    expect(empty.getByText('ToolError: interrupted')).toBeTruthy()
-  })
-})

+ 1 - 32
packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx

@@ -56,46 +56,15 @@ function props(
 }
 
 describe('ToolCallTree', () => {
-  it('owns the root marker, generic fallback, and selected state for a window-truncated call', () => {
+  it('owns the root marker and the generic fallback for a window-truncated call', () => {
     const block = root('w1', null)
     const view = render(<ToolCallTree {...props(block, 'w1')} />)
     const row = view.container.querySelector('[data-chat-call-id="w1"]')
     expect(row?.getAttribute('data-chat-anchor-key')).toBe('call:w1')
-    expect(row?.getAttribute('data-selected')).toBe('true')
     expect(view.container.querySelector('[data-variant="others"]')).not.toBeNull()
     expect(view.getByText('w1')).toBeTruthy()
   })
 
-  it('recursively renders a selected leaf without selecting its ancestors', () => {
-    const owners: ToolCallOwnerProps[] = []
-    const leaf = {
-      ...root('parent:code:1:code:1', { name: 'read', argsRaw: '{"path":"a.ts"}' }),
-      parentCallId: 'parent:code:1',
-    }
-    const child = {
-      ...root('parent:code:1', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
-      parentCallId: 'parent',
-      subCalls: [leaf],
-    }
-    const block = {
-      ...root('parent', { name: 'run_code', argsRaw: '{"code":"return 1"}' }),
-      subCalls: [child],
-    }
-    const view = render(<ToolCallTree {...props(block, leaf.callId, undefined, owners)} />)
-    const nests = view.container.querySelectorAll('[data-subcalls]')
-    expect(nests[0]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent"]'))
-    expect(nests[1]?.parentElement).toBe(view.container.querySelector('[data-chat-call-id="parent:code:1"]'))
-    expect(view.container.querySelector('[data-chat-call-id="parent"]')?.hasAttribute('data-selected')).toBe(false)
-    expect(view.container.querySelector('[data-chat-call-id="parent:code:1"]')?.hasAttribute('data-selected')).toBe(false)
-    expect(view.container.querySelector('[data-chat-call-id="parent:code:1:code:1"]')?.getAttribute('data-selected')).toBe('true')
-    expect(nests).toHaveLength(2)
-    expect(owners.map(owner => [owner.callId, owner.block.parentCallId ?? null])).toEqual([
-      ['parent', null],
-      ['parent:code:1', 'parent'],
-      ['parent:code:1:code:1', 'parent:code:1'],
-    ])
-  })
-
   it('abbreviates a POSIX home path in the generic tool summary', () => {
     const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' })
     const view = render(<ToolCallTree {...props(block, 'w1', '/h')} />)

+ 2 - 42
packages/client/ui-tool/tests/tool-details-render.client.tsx → packages/client/ui-tool/tests/tool-fixtures.client.ts

@@ -1,27 +1,10 @@
-/** Test adapter for the production conversation.details.tool registration. */
+/** Shared Chat-slice and Session-event fixtures for Tool row tests. */
 import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
 import { SessionSeq } from '@deepseek-ai/dsh-session/types'
 import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
 import type {
-  ChatConversationViewNode, ChatSnapshot, ConversationNode, DetailsSlotProps,
-  DetailsToolOwnerProps, RunningToolCall, ToolResultNode,
+  ChatConversationViewNode, ChatSnapshot, ConversationNode, RunningToolCall, ToolResultNode,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
-import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
-import { ToolDetails } from '../src/client/tool/ToolDetails.tsx'
-
-type TrajectorySnapshot = Parameters<Parameters<DetailsSlotProps['useTrajectory']>[0]>[0]
-
-const emptyTrajectory: TrajectorySnapshot = {
-  eventNodes: [],
-  eventLocations: new Map(),
-  requests: [],
-  callSchemas: new Map(),
-  partial: null,
-  runningCalls: [],
-}
-
-/** Stable empty Trajectory source for DetailsPanel fixtures. */
-export const useEmptyTrajectory: DetailsSlotProps['useTrajectory'] = selector => selector(emptyTrajectory)
 
 function jsonFixture(value: unknown): JsonValue {
   if (!isJsonValue(value)) throw new Error('tool event fixture must be lossless JSON')
@@ -142,26 +125,3 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se
   }
   return entries
 }
-
-/**
- * Bind ui-tool's details renderer to the conversation slot callback shape.
- * @param t - conversation locale seat used by Tool cards.
- * @param home - optional Host account home for POSIX `~` summaries.
- * @returns a direct-test renderSlot implementation.
- */
-export function renderToolDetails(
-  t: TranslateNS<'conversation'>,
-  home?: string,
-): DetailsSlotProps['renderSlot'] {
-  return (_key, owner) => {
-    // PropsRenderSlots keeps its key generic even for this one-key share;
-    // recover the concrete owner selected by the adapter's fixed slot.
-    const details = owner as unknown as DetailsToolOwnerProps
-    return <ToolDetails
-      block={details.block}
-      cwd={details.cwd}
-      useHostInfo={selector => selector({ home, isLoopback: true })}
-      t={t}
-    />
-  }
-}

+ 13 - 10
packages/client/ui-tool/tests/toolview-slot.client.spec.tsx

@@ -13,7 +13,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import { apply as applyConversation, inject as injectConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { apply as applyTool, inject as injectTool } from '@deepseek-ai/dsh-client-ui-tool/client'
 import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
-import { toolSessionEvents } from './tool-details-render.client.tsx'
+import { toolSessionEvents } from './tool-fixtures.client.ts'
 
 const SID = 's1' as SessionId
 
@@ -42,14 +42,13 @@ const toolResult = (seq: number, callId: string, name: string, args = '{"command
 })
 
 /** Test-owned AppFrame role: declares and renders the resident conversation area. */
-type AppRootProps = PropsRenderSlots<'conversation' | 'details'>
+type AppRootProps = PropsRenderSlots<'conversation'>
 function AppRoot({ renderSlot }: AppRootProps) {
   return <>{renderSlot('conversation', {})}</>
 }
 
 const LAYOUT_CHILDREN = {
   'conversation': { kind: 'single', scope: 'session-maybe' },
-  'details': { kind: 'single', scope: 'session' },
 } as const
 
 /**
@@ -64,6 +63,8 @@ async function bench(nodes: ToolResultNode[]) {
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   const layout = { openDetails: vi.fn(), closeDetails: vi.fn() }
   runtime.ctx.provide('layout', layout)
+  const sidebarRight = { openResource: vi.fn<(address: string) => void>() }
+  runtime.ctx.provide('sidebarRight', sidebarRight as never)
   runtime.ctx.provide('uiWorkspace', {
     connectWorkspace: vi.fn(async () => SID),
   } as never)
@@ -72,7 +73,7 @@ async function bench(nodes: ToolResultNode[]) {
   runtime.slots.installLocale(locale)
   await runtime.sessions.add({
     id: SID,
-    summary: { title: 'S', displayTitle: 'S' },
+    summary: { title: 'S', displayTitle: 'S', cwd: '/w' },
     events: toolSessionEvents(nodes),
     session: {
       loadOlder: vi.fn<ISession['loadOlder']>(),
@@ -83,7 +84,7 @@ async function bench(nodes: ToolResultNode[]) {
   await runtime.mount({ inject: [...injectConversation], apply: applyConversation })
   await runtime.mount({ inject: [...injectChat], apply: applyChat })
   await runtime.mount({ inject: [...injectTool], apply: applyTool })
-  return { runtime, slots: runtime.slots, layout, openWorkspacePath }
+  return { runtime, slots: runtime.slots, layout, openWorkspacePath, sidebarRight }
 }
 
 describe('keyed toolview hole through the real machinery', () => {
@@ -126,22 +127,23 @@ describe('keyed toolview hole through the real machinery', () => {
     await b.runtime.dispose()
   })
 
-  it('file-path clicks travel owner openFile → chat inject → session.openWorkspacePath', async () => {
+  it('file-path clicks travel owner openFile → chat inject → the right Sidebar', async () => {
     const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
     const view = b.runtime.renderRoot()
     view.getByText('src/a.ts').click()
-    expect(b.layout.openDetails).not.toHaveBeenCalled()
     await vi.waitFor(() => {
-      expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: 'src/a.ts' })
+      expect(b.sidebarRight.openResource).toHaveBeenCalledWith('dsh-resource://file/session/s1/src/a.ts')
     })
+    // Nothing on this path reaches the local machine any more.
+    expect(b.openWorkspacePath).not.toHaveBeenCalled()
     await b.runtime.dispose()
   })
 
-  it('bash summary clicks do not open details or host paths', async () => {
+  it('bash summary clicks open nothing at all', async () => {
     const b = await bench([toolResult(3, 'c1', 'bash')])
     const view = b.runtime.renderRoot()
     view.getByText('Build').click()
-    expect(b.layout.openDetails).not.toHaveBeenCalled()
+    expect(b.sidebarRight.openResource).not.toHaveBeenCalled()
     expect(b.openWorkspacePath).not.toHaveBeenCalled()
     await b.runtime.dispose()
   })
@@ -208,6 +210,7 @@ describe('registrant declaration injection', () => {
     })
     runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
     runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
+    runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
     runtime.ctx.provide('uiWorkspace', {
       connectWorkspace: vi.fn(async () => SID),
     } as never)

+ 1 - 95
packages/client/ui-tool/tests/web-card.client.spec.tsx

@@ -2,34 +2,20 @@
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render } from '@testing-library/react'
-import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
-import type {
-  ChatSnapshot, ConversationNode, RunningToolCall, SelectionTarget, ToolResultNode,
-} from '@deepseek-ai/dsh-client-ui-chat/client'
-import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import {
-  bindSnapshotSelector, conversationSnapshot, sessionSnapshot, workspaceSnapshot,
-} from '@deepseek-ai/dsh-client-test-runtime'
+import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type { ToolCallOwnerProps } from '@deepseek-ai/dsh-client-ui-tool/client'
 import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import { webCardModel } from '../src/client/tool/models/web-card-model.ts'
-import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts'
 import { GenericToolCard } from '../src/client/tool/toolviews/GenericToolCard.tsx'
-import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx'
 import { WebRow, webToolview } from '../src/client/tool/toolviews/web-row.tsx'
-import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
-import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts'
 
 afterEach(cleanup)
 
-const SID = 's1' as SessionId
 
 const t = makeTranslate(zh, commonZh)
-const chatT = makeTranslate(chatZh, commonZh)
 
 const SEARCH_ARGS = '{"queries":["deepseek harness"]}'
 const FETCH_ARGS = '{"url":"https://example.com/page"}'
@@ -215,86 +201,6 @@ describe('chat row web body', () => {
   })
 })
 
-describe('DetailsPanel web Output section', () => {
-  function mount(snapshot: ChatSnapshot, selection: SelectionTarget | null) {
-    localStorage.clear()
-    const chat = createChatStore().create()
-    if (selection !== null) chat.actions.select(selection)
-    const sessions = createSnapshotStore<SessionListState>({
-      ids: [], byId: {}, current: undefined, phase: 'ready',
-      subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
-    })
-    const session = createSnapshotStore(sessionSnapshot(SID))
-    const conversation = createSnapshotStore(conversationSnapshot())
-    const workspaces = createSnapshotStore(workspaceSnapshot())
-    const attention = createSnapshotStore(new Map())
-    return render(
-      <DetailsPanel
-        renderSlot={renderToolDetails(t)}
-        SessionProvider={({ children }) => children}
-        sessionId={SID}
-        useSession={bindSnapshotSelector(session)}
-        useSessions={bindSnapshotSelector(sessions)}
-        useSessionPendingInteraction={bindSnapshotSelector(attention)}
-        useWorkspaces={bindSnapshotSelector(workspaces)}
-        useConversation={bindSnapshotSelector(conversation)}
-        useChat={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
-        useTrajectory={useEmptyTrajectory}
-        useInput={(() => { throw new Error('unused') })}
-        inputActions={{
-          setDraft: () => {},
-          addAttachments: () => true,
-          removeAttachment: () => {},
-          pruneAttachments: () => {},
-          submit: () => {},
-        }}
-        useProjection={(() => undefined)}
-        useStore={bindSnapshotSelector(chat)}
-        actions={chat.actions}
-        closeDetails={vi.fn()}
-        t={chatT}
-      />,
-    )
-  }
-
-  function snapshot(over: {
-    nodes?: readonly ConversationNode[]
-    runningCalls?: readonly RunningToolCall[]
-  } = {}): ChatSnapshot {
-    const nodes = over.nodes ?? []
-    const runningCalls = over.runningCalls ?? []
-    return toolChatSnapshot(nodes, runningCalls)
-  }
-
-  it('renders the search card at full source allowance', () => {
-    const view = mount(snapshot({ nodes: [settledSearch()] }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
-    expect(view.getByText('Titled')).toBeTruthy()
-    expect(view.getByText('excerpt')).toBeTruthy()
-    // The Input JSON section survives beside it.
-    expect(view.getByText(/"queries"/)).toBeTruthy()
-  })
-
-  it('renders the fetch card and keeps the fetched body below it', () => {
-    const view = mount(snapshot({ nodes: [settledFetch()] }), { turnSeq: 11, callId: 'c2', toolName: 'web_fetch' })
-    const card = view.container.querySelector('[data-web="fetch"]')
-    expect(card?.querySelector('a')?.getAttribute('href')).toBe('https://example.com/page')
-    expect(view.getByText('HTTP 200')).toBeTruthy()
-    // The card is a summary (URL + status only); the panel is the single-call
-    // reading surface, so the fetched body still renders below the card.
-    const output = view.getByText('输出').closest('section')
-    expect(output?.querySelector('pre')?.textContent).toContain('fetch body')
-  })
-
-  it('a non-web result keeps the flattened pre form', () => {
-    const view = mount(snapshot({
-      nodes: [settledSearch({ meta: undefined })],
-    }), { turnSeq: 10, callId: 'c1', toolName: 'web_search' })
-    expect(view.container.querySelector('[data-web]')).toBeNull()
-    const output = view.getByText('输出').closest('section')
-    expect(output?.querySelector('pre')?.textContent).toContain('search text')
-  })
-})
-
 describe('web toolview registration', () => {
   it('registers one WebRow under both web_search and web_fetch', () => {
     const registered: { key: string; locale: unknown; component: unknown }[] = []

+ 7 - 0
packages/client/ui-trajectory/tests/views.client.spec.tsx

@@ -7,6 +7,7 @@
  * event ledger with its timing overview, and fiber disposal removes the tab.
  * Timeline projection and inclusive focus edge cases ride along.
  */
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { createElement, type ComponentProps, type FC, type ReactNode } from 'react'
@@ -52,12 +53,16 @@ import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts'
 import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
 import { t as tTrajectory, tZh } from './locale.client.ts'
 
+// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 function TrajectoryTimeline(
   props: Omit<ComponentProps<typeof LocalizedTrajectoryTimeline>, 't'>,
 ) {
   return <LocalizedTrajectoryTimeline {...props} t={tTrajectory} />
 }
 
+
 const SID = 's1' as SessionId
 const tConversation: ConversationSessionHeaderProps['t'] =
   key => (conversationZh as Record<string, string>)[key] ?? key
@@ -219,6 +224,7 @@ function standaloneProps(
     sessionId: SID,
     useChat: bindSnapshotSelector(createSnapshotStore(EMPTY_CHAT_SNAPSHOT)),
     useSessions: emptySessions(),
+    useResource,
     useSessionPendingInteraction: bindSnapshotSelector(
       createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()),
     ),
@@ -345,6 +351,7 @@ function mount(fixture: Awaited<ReturnType<typeof bench>>) {
     useConversation,
     useConversationViews,
     useSessions,
+    useResource,
     useSessionPendingInteraction,
     useWorkspaces,
     useProjection,

+ 6 - 0
packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -11,8 +12,12 @@ import { en, zh } from '../src/client/locales.ts'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 
+// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(cleanup)
 
+
 const SID = 's1' as SessionId
 
 const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
@@ -110,6 +115,7 @@ const kit: Omit<QuestionComposerProps, 'matched'> = {
   pendingInteraction: undefined,
   useSession: selector => selector(sessionState),
   useSessions: selector => selector(sessionList),
+  useResource,
   useSessionPendingInteraction: selector => selector(attentionState),
   useWorkspaces: selector => selector(workspaceState),
   useConversation: selector => selector(conversationState),

+ 6 - 0
packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { useSyncExternalStore } from 'react'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
@@ -10,8 +11,12 @@ import { en, zh } from '../src/client/locales.ts'
 import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 
+// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(cleanup)
 
+
 const SID = 's1' as SessionId
 
 const seatOver = (dict: Record<string, string>, common: Record<string, string>): QuestionComposerProps['t'] =>
@@ -109,6 +114,7 @@ const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> =
   pendingInteraction: undefined,
   useSession: selector => selector(sessionState),
   useSessions: selector => selector(sessionList),
+  useResource,
   useSessionPendingInteraction: selector => selector(attentionState),
   useWorkspaces: selector => selector(workspaceState),
   useConversation: selector => selector(conversationState),

+ 6 - 0
packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { Context, Service } from '@deepseek-ai/cordis'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -31,8 +32,12 @@ import {
 import { apply as applyNode } from '../src/index.ts'
 import type {} from '../src/client/index.ts'
 
+// Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(cleanup)
 
+
 const PARENT_ID = 'parent' as SessionId
 const CHILD_ID = 'child-1' as SessionId
 const SECOND_ID = 'child-2' as SessionId
@@ -309,6 +314,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi
     node: node(data),
     sessionId: PARENT_ID,
     useSessions: selector => selector(sessions),
+    useResource,
     useSessionPendingInteraction: selector => selector(panelAttention),
     useSession: selector => selector(panelSession),
     useProjection: () => undefined,

+ 5 - 0
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
@@ -15,6 +16,9 @@ import { UNGROUPED_KEY } from '../src/client/tree.ts'
 import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
 import { zh } from '../src/client/locales.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(cleanup)
 const scrollIntoView = vi.fn()
 beforeEach(() => {
@@ -74,6 +78,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     expandSidebar: vi.fn(),
     useSessions: hook(sessionState([])),
     useSessionPendingInteraction: hook(noPendingInteraction),
+    useResource,
     useWorkspaces: hook(workspaceState([])),
     useStore: bindSnapshotSelector(store),
     actions: store.actions,

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

@@ -1,4 +1,5 @@
 // @vitest-environment jsdom
+import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -13,6 +14,9 @@ import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/clien
 import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
 import { zh } from '../src/client/locales.ts'
 
+// Every fixture carries the resource hook the resources plugin merges into GlobalStandardProps.
+const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined, reload: () => {} })) as GlobalStandardProps['useResource']
+
 afterEach(cleanup)
 
 // The seat's key domain is workspace ∪ common; the stub mirrors the real
@@ -94,6 +98,7 @@ function mount(
       anchorRef={anchorRef}
       useSessions={hook(sessions)}
       useSessionPendingInteraction={hook(noPendingInteraction)}
+      useResource={useResource}
       useWorkspaces={hook(workspaceState(nextItems))}
       onPick={onPick}
       onClose={onClose}
@@ -215,6 +220,7 @@ describe('WorkspacePicker', () => {
       <WorkspacePicker
         open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([workspace('alpha', 'Alpha')]))}
         useSessionPendingInteraction={hook(noPendingInteraction)}
+        useResource={useResource}
         onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
         useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
       />,
@@ -231,6 +237,7 @@ describe('WorkspacePicker', () => {
       <WorkspacePicker
         open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
         useSessionPendingInteraction={hook(noPendingInteraction)}
+        useResource={useResource}
         onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
         useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t}
       />,

+ 4 - 0
packages/context/agent-instructions/tests/agent-instructions.spec.ts

@@ -140,6 +140,10 @@ class RecordingFileSystem extends FileSystem {
     throw new Error('not needed in agent-instructions tests')
   }
 
+  override async readByteRange(_target: FsTarget, _range: { offset: number; length: number }, _signal?: AbortSignal): Promise<Uint8Array> {
+    throw new Error('not needed in agent-instructions tests')
+  }
+
   override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
     if (signal !== undefined) this.signals.push(signal)
     signal?.throwIfAborted()

+ 53 - 0
packages/e2b/fs-e2b/tests/filesystem.spec.ts

@@ -171,6 +171,8 @@ class FakeRemote {
           start: (controller) => {
             for (const chunk of chunks) controller.enqueue(chunk)
             if (!this.streamKeepOpen) controller.close()
+            // SDK fidelity: an abort of the request signal fails the open stream.
+            options.signal?.addEventListener('abort', () => { controller.error(new DOMException('aborted', 'AbortError')) }, { once: true })
           },
           cancel: () => { this.streamCancel() },
         })
@@ -508,6 +510,57 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
     expect((await fs.readBytes(await fs.resolve('empty.bin'), undefined, 4)).byteLength).toBe(0)
   })
 
+  it('readByteRange skips to the offset, keeps the window, and cancels the stream there', async () => {
+    const remote = new FakeRemote()
+    remote.file('/workspace/ramp.bin', [1, 2, 3, 4, 5, 6, 7, 8, 9])
+    const { fs } = await setup(remote)
+    const target = await fs.resolve('ramp.bin')
+    remote.streamChunks = [bytes([1, 2, 3]), bytes([4, 5, 6]), bytes([7, 8, 9])]
+    remote.streamKeepOpen = true
+    expect(Array.from(await fs.readByteRange(target, { offset: 4, length: 3 }))).toEqual([5, 6, 7])
+    expect(remote.reads).toEqual([{ path: '/workspace/ramp.bin', format: 'stream' }])
+    expect(remote.streamCancel).toHaveBeenCalledOnce()
+  })
+
+  it('readByteRange shortens at the end, empties past it, and skips the read for length 0', async () => {
+    const remote = new FakeRemote()
+    remote.file('/workspace/ramp.bin', [1, 2, 3, 4, 5, 6, 7, 8, 9])
+    remote.dir('/workspace/directory')
+    const { fs } = await setup(remote)
+    const target = await fs.resolve('ramp.bin')
+    remote.streamChunks = [bytes([1, 2, 3]), bytes([4, 5, 6]), bytes([7, 8, 9])]
+    expect(Array.from(await fs.readByteRange(target, { offset: 7, length: 10 }))).toEqual([8, 9])
+    expect(remote.streamCancel).not.toHaveBeenCalled()
+    expect((await fs.readByteRange(target, { offset: 9, length: 2 })).byteLength).toBe(0)
+    remote.reads.length = 0
+    expect((await fs.readByteRange(target, { offset: 0, length: 0 })).byteLength).toBe(0)
+    expect(remote.reads).toEqual([])
+    await expectCode(fs.readByteRange(await fs.resolve('missing'), { offset: 0, length: 1 }), 'FS_NOT_FOUND')
+    await expectCode(fs.readByteRange(await fs.resolve('directory'), { offset: 0, length: 1 }), 'FS_NOT_REGULAR_FILE')
+  })
+
+  it('readByteRange maps a failing open, an abort mid-stream, and tolerates a failing cancel', async () => {
+    const remote = new FakeRemote()
+    remote.file('/workspace/ramp.bin', [1, 2, 3, 4])
+    const { fs } = await setup(remote)
+    const target = await fs.resolve('ramp.bin')
+    remote.nextReadError = new DOMException('aborted', 'AbortError')
+    await expectCode(fs.readByteRange(target, { offset: 0, length: 2 }), 'FS_ABORTED')
+
+    // The window wants more than the one chunk delivered; the abort fails the open stream.
+    remote.streamChunks = [bytes([1])]
+    remote.streamKeepOpen = true
+    const controller = new AbortController()
+    const pending = fs.readByteRange(target, { offset: 0, length: 4 }, controller.signal)
+    await new Promise<void>((resolve) => { setTimeout(resolve, 0) })
+    controller.abort()
+    await expectCode(pending, 'FS_ABORTED')
+
+    remote.streamChunks = [bytes([1, 2, 3, 4])]
+    remote.streamCancel.mockRejectedValueOnce(new Error('cancel failed'))
+    expect(Array.from(await fs.readByteRange(target, { offset: 1, length: 2 }))).toEqual([2, 3])
+  })
+
   it('honors aborts before and during remote reads', async () => {
     const remote = new FakeRemote()
     remote.file('/workspace/a', 'a')

+ 39 - 0
packages/fs/fs-local/tests/filesystem.spec.ts

@@ -308,6 +308,45 @@ describe('readBytes', () => {
   })
 })
 
+describe('readByteRange', () => {
+  /** 256 bytes, each equal to its offset. */
+  const ramp = Buffer.from(Array.from({ length: 256 }, (_, i) => i))
+
+  it('reads a window from the middle of a file larger than the window', async () => {
+    await writeFile(join(dir, 'ramp.bin'), ramp)
+    const window = await fs.readByteRange(await fs.resolve('ramp.bin'), { offset: 100, length: 4 })
+    expect([...window]).toEqual([100, 101, 102, 103])
+  })
+
+  it('shortens a window the file ends inside and empties one at or past the end', async () => {
+    await writeFile(join(dir, 'ramp.bin'), ramp)
+    const target = await fs.resolve('ramp.bin')
+    expect([...await fs.readByteRange(target, { offset: 253, length: 10 })]).toEqual([253, 254, 255])
+    expect((await fs.readByteRange(target, { offset: 256, length: 10 })).length).toBe(0)
+    expect((await fs.readByteRange(target, { offset: 1000, length: 10 })).length).toBe(0)
+  })
+
+  it('returns an empty window for length 0 without touching content', async () => {
+    await writeFile(join(dir, 'ramp.bin'), ramp)
+    expect((await fs.readByteRange(await fs.resolve('ramp.bin'), { offset: 0, length: 0 })).length).toBe(0)
+  })
+
+  it('carries NUL and invalid UTF-8 untouched', async () => {
+    const raw = Buffer.from([0x00, 0xff, 0xfe, 0x41])
+    await writeFile(join(dir, 'a.bin'), raw)
+    expect(Buffer.from(await fs.readByteRange(await fs.resolve('a.bin'), { offset: 0, length: 4 }))).toEqual(raw)
+  })
+
+  it('rejects a missing file, a directory, and an already-aborted signal', async () => {
+    await expect(fs.readByteRange(await fs.resolve('nope'), { offset: 0, length: 1 })).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
+    await expect(fs.readByteRange(await fs.resolve('.'), { offset: 0, length: 1 })).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
+    await writeFile(join(dir, 'a.bin'), 'data')
+    const controller = new AbortController()
+    controller.abort()
+    await expect(fs.readByteRange(await fs.resolve('a.bin'), { offset: 0, length: 1 }, controller.signal)).rejects.toMatchObject({ code: 'FS_ABORTED' })
+  })
+})
+
 describe('listDir', () => {
   it('lists files and directories in stable name order with resolved child targets', async () => {
     await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true })

+ 14 - 0
packages/fs/fs/tests/service.spec.ts

@@ -57,6 +57,9 @@ class FakeFileSystem extends FileSystem {
     }
     return bytes
   }
+  override async readByteRange(target: FsTarget, range: { offset: number; length: number }): Promise<Uint8Array> {
+    return new TextEncoder().encode(await this.readText(target)).subarray(range.offset, range.offset + range.length)
+  }
   override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
     if (target.targetKey !== 'skills') throw new FsError(`not a directory: ${target.displayPath}`, 'FS_NOT_DIRECTORY')
     return [
@@ -120,6 +123,17 @@ describe('FileSystem provider seam', () => {
     expect(streamed).toBe(await fs.readText(target))
   })
 
+  it('readByteRange returns the window, shorter at the end and empty past it', async () => {
+    const ctx = new Context()
+    await ctx.plugin(FakeFileSystem)
+    const fs = ctx.fs as FakeFileSystem
+    fs.files.set('a.bin', 'hello')
+    const target = await fs.resolve('a.bin')
+    expect(await fs.readByteRange(target, { offset: 1, length: 3 })).toEqual(new TextEncoder().encode('ell'))
+    expect(await fs.readByteRange(target, { offset: 3, length: 10 })).toEqual(new TextEncoder().encode('lo'))
+    expect(await fs.readByteRange(target, { offset: 9, length: 2 })).toHaveLength(0)
+  })
+
   it('readBytes returns raw content and enforces the byte cap with FS_TOO_LARGE', async () => {
     const ctx = new Context()
     await ctx.plugin(FakeFileSystem)

+ 3 - 0
packages/fs/tool-fs/tests/tools.spec.ts

@@ -81,6 +81,9 @@ class FakeFs extends FileSystem {
     }
     return bytes
   }
+  override async readByteRange(target: FsTarget, range: { offset: number; length: number }): Promise<Uint8Array> {
+    return new TextEncoder().encode(this.files.get(target.targetKey) ?? '').subarray(range.offset, range.offset + range.length)
+  }
   override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
     return []
   }

+ 4 - 0
packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts

@@ -108,6 +108,10 @@ class TestFileSystem extends FileSystem {
     throw new Error('not needed in skill tests')
   }
 
+  override async readByteRange(_target: FsTarget, _range: { offset: number; length: number }, _signal?: AbortSignal): Promise<Uint8Array> {
+    throw new Error('not needed in skill tests')
+  }
+
   override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
     this.listDirCalls += 1
     if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed')

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است