Преглед на файлове

Merge pull request #821 from deepseek-harness/feat/workspace-directory-browser

feat(host,client): ship the in-app directory browser as the browse package's client half
imccyu преди 2 месеца
родител
ревизия
75b32f7d76
променени са 34 файла, в които са добавени 2295 реда и са изтрити 80 реда
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md
  4. 3 5
      apps/cli/cordis.yml
  5. 1 0
      apps/cli/package.json
  6. 23 0
      apps/web/tests/snapshots/workspace-management/directory-browser.expected.md
  7. 19 8
      apps/web/tests/workspace-flow.snapshot.ts
  8. 76 24
      apps/web/tests/workspace-management.e2e.ts
  9. 7 2
      docs/module-graph.md
  10. 15 1
      packages/client/runtime/src/client/contract/workspaces.ts
  11. 3 2
      packages/client/runtime/src/client/workspaces/service.ts
  12. 43 1
      packages/client/test-runtime/src/workspaces.ts
  13. 26 0
      packages/client/test-runtime/tests/runtime.spec.tsx
  14. 24 14
      packages/client/ui-primitives/src/Modal.tsx
  15. 3 0
      packages/client/ui-workspace/tsconfig.json
  16. 2 2
      packages/host/README.i18n.yaml
  17. 1 1
      packages/host/README.md
  18. 1 1
      packages/host/README.zh.md
  19. 2 2
      packages/host/directory-picker-browse/README.i18n.yaml
  20. 2 1
      packages/host/directory-picker-browse/README.md
  21. 2 1
      packages/host/directory-picker-browse/README.zh.md
  22. 29 2
      packages/host/directory-picker-browse/package.json
  23. 306 0
      packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css
  24. 503 0
      packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx
  25. 43 0
      packages/host/directory-picker-browse/src/client/flow.ts
  26. 91 0
      packages/host/directory-picker-browse/src/client/index.ts
  27. 6 0
      packages/host/directory-picker-browse/src/css-modules.d.ts
  28. 214 0
      packages/host/directory-picker-browse/tests/client-flow.spec.tsx
  29. 792 0
      packages/host/directory-picker-browse/tests/directory-browser.spec.tsx
  30. 18 6
      packages/host/directory-picker-browse/tsconfig.json
  31. 3 0
      packages/host/directory-picker-browse/tsdown.config.ts
  32. 27 0
      pnpm-lock.yaml
  33. 3 0
      tsconfig.client.json
  34. 1 1
      tsconfig.host.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
-2026-07-28-directory-picker-capability-seam.md: 9f2703b7499870bf2d5ff8735e3c8cb2c351a503
-2026-07-28-directory-picker-capability-seam.zh.md: 946156a5d06cea30c833c768147b36b30f258192
+2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38
+2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md

@@ -12,7 +12,7 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p
 
 A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape.
 
-**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` ships here with a renderless occupant driving `host.pickDirectory`; `-browse`'s half (the in-app browsing dialog) lands in the stacked follow-up PR, until which a `-browse` composition shows no picking affordance (the documented empty-hole default). The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read.
+**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read.
 
 Placement and policy rulings folded into this decision:
 
@@ -33,7 +33,7 @@ Placement and policy rulings folded into this decision:
 
 ## Consequences
 
-- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-native` (unchanged behavior). The in-app browser PR flips that one row to `-browse`, swapping backend and UI together.
+- `cordis.yml` chooses the interaction; `apps/cli` mounts `-browse` (the shipped default — remote-capable picking out of the box), one row having swapped backend and UI together; `-native` remains the host-display alternative.
 - The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests.
 - A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits.
 - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service.

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md

@@ -12,7 +12,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
 
 在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。
 
-**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。后端包是**双面包**:browser half 把匹配的交互注册进两个洞——`-native` 在本 PR 随附驱动 `host.pickDirectory` 的无渲染占用者;`-browse` 的那一半(应用内浏览对话框)在栈中的后续 PR 落地,在那之前 `-browse` 组合不显示选目录入口(文档化的空洞默认行为)。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。
+**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。后端包是**双面包**:browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。
 
 并入本决策的位置与策略裁决:
 
@@ -33,7 +33,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
 
 ## 后果
 
-- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-native`(行为不变)。应用内浏览器 PR 只翻这一行到 `-browse`,后端与 UI 同时切换。
+- `cordis.yml` 决定交互形态;`apps/cli` 挂 `-browse`(随附默认——开箱即得可远程的选取),一行同时切换了后端与 UI;`-native` 仍是宿主屏幕方案。
 - 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。
 - 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个双面后端包——无需网关手术,也不动 ui-workspace。
 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。

+ 3 - 5
apps/cli/cordis.yml

@@ -264,12 +264,10 @@
 # mapping target (user config overrides these engineering defaults).
 # Directory-picking package, dual-face: the node half serves the gateway's
 # host.* picker RPCs, the browser half fills ui-workspace's directory-flow
-# slots — one row composes the whole interaction. Swap point: '-browse'
-# serves remote-capable listing primitives; its in-app dialog (and this
-# row's flip) land in the stacked follow-up PR — until then a '-browse'
-# composition has no picking affordance.
+# slots — one row composes the whole interaction. Swap point: mount
+# '-native' instead for the host-display OS chooser.
 - id: directory-picker
-  name: '@deepseek-ai/dsh-host-directory-picker-native'
+  name: '@deepseek-ai/dsh-host-directory-picker-browse'
 
 - id: api-gateway
   name: '@deepseek-ai/dsh-host-apiproxy'

+ 1 - 0
apps/cli/package.json

@@ -53,6 +53,7 @@
     "@deepseek-ai/dsh-goal": "workspace:^",
     "@deepseek-ai/dsh-goal-session": "workspace:^",
     "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
+    "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
     "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",

+ 23 - 0
apps/web/tests/snapshots/workspace-management/directory-browser.expected.md

@@ -0,0 +1,23 @@
+- dialog "选择工作区目录":
+  - heading "选择工作区目录" [level=2]
+  - navigation:
+    - button "主目录"
+    - img
+    - button "browse-golden"
+  - button "编辑路径"
+  - list:
+    - listitem:
+      - button "alpha":
+        - img
+        - text: alpha
+        - img
+    - listitem:
+      - button "beta":
+        - img
+        - text: beta
+        - img
+  - button "新建文件夹":
+    - img
+    - text: 新建文件夹
+  - button "取消"
+  - button "打开"

+ 19 - 8
apps/web/tests/workspace-flow.snapshot.ts

@@ -40,11 +40,11 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
   // Dual-face host package: its browser half fills the directory-flow holes
   // (the same composition row apps/cli mounts for the node-side backend).
   {
-    id: '@deepseek-ai/dsh-host-directory-picker-native',
-    dir: '../host/directory-picker-native',
-    url: '/plugins/directory-picker-native.js',
+    id: '@deepseek-ai/dsh-host-directory-picker-browse',
+    dir: '../host/directory-picker-browse',
+    url: '/plugins/directory-picker-browse.js',
     rev: 'fx',
-    inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace'],
+    inject: ['@deepseek-ai/dsh-client-runtime', '@deepseek-ai/dsh-client-ui-workspace', '@deepseek-ai/dsh-client-locale'],
   },
 ]
 
@@ -183,7 +183,7 @@ it('locks the composer in the New Session view state until a Workspace is chosen
   `)
 })
 
-it('adopts a directory through the composed native flow and lands in its blank session', async () => {
+it('adopts a directory through the composed in-app browse flow and lands in its blank session', async () => {
   boot('?fixture=empty')
 
   await findLockedComposer()
@@ -194,9 +194,20 @@ it('adopts a directory through the composed native flow and lands in its blank s
   expect(within(menu).getAllByRole('menuitem').map(item => visibleText(item)))
     .toEqual(['Open local folder…', 'Create a new workspace'])
   fireEvent.click(within(menu).getByRole('menuitem', { name: 'Open local folder…' }))
-  // The renderless native flow drives the fixture's deterministic pick and
-  // the owner adopts the returned path into a real Workspace.
-  await act(async () => {})
+  // The browse occupant renders the Select Workspace Directory dialog at the
+  // fixture home; select Documents, advance into project, and adopt it.
+  const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 })
+  // Row targeting goes through the visible label text: listitem accessible-name
+  // computation differs across dom-accessibility-api environments, while the
+  // row's name span is stable (clicks bubble to the row button).
+  fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 }))
+  fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 }))
+  // Open disables while the selection's child listing is in flight; wait for
+  // the enabled state or the click lands on a dead button on slow runners.
+  await waitFor(() => {
+    expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: '打开' }).disabled).toBe(false)
+  }, { timeout: 10_000 })
+  fireEvent.click(within(dialog).getByRole('button', { name: '打开' }))
   await findHeroComposer()
   await waitFor(() => {
     expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project')

+ 76 - 24
apps/web/tests/workspace-management.e2e.ts

@@ -13,8 +13,8 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import {
-  acknowledgeReloadConnectionLoss, assertFixtureInventory, launchWebScaffold, seedSession, watchConsole,
-  webSnapshotMode, type WebScaffold,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { saveFailureShot } from './support.ts'
 
@@ -23,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/workspace-management', i
 // spec needs any one cold session row, not new recorded content.
 const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
 const MODE = webSnapshotMode()
+const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
 const SEED_ID = 'workspace-management-web-e2e'
 
 describe('web e2e: workspace management (create / rename / flat view / hover card)', () => {
@@ -30,14 +31,40 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
   let browser: Browser
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
-  let pickedDirectory: string | null = null
+
+  /**
+   * Drive the in-app browser to a directory via its path-edit affordance,
+   * confirm it, and wait for the adoption to settle host-side (workspace
+   * registered + the flow's New-Session agent up), so later test steps can't
+   * race the in-flight blank-session attach.
+   */
+  async function openLocalFolder(path: string, options: { waitForAgent?: boolean } = {}): Promise<void> {
+    const agentsBefore = scaffold.ctx.agents.list().length
+    await page.getByRole('button', { name: 'Create workspace' }).click()
+    await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+    const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
+    await dialog.waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: '编辑路径' }).click()
+    await dialog.getByLabel('编辑路径').fill(path)
+    await dialog.getByLabel('编辑路径').press('Enter')
+    await dialog.getByRole('button', { name: '打开' }).click()
+    await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
+    await expect.poll(
+      () => scaffold.ctx.workspace.resolveByPath(path),
+      { timeout: 10_000 },
+    ).not.toBeUndefined()
+    // First adoption births a blank Session+Agent whose workspace attach must
+    // settle before a test may delete the registration; the reuse path (same
+    // canonical cwd already has a blank session) creates no agent, so callers
+    // opt in only where a fresh attach is possible.
+    if (options.waitForAgent === true) {
+      await expect.poll(() => scaffold.ctx.agents.list().length, { timeout: 10_000 })
+        .toBeGreaterThan(agentsBefore)
+    }
+  }
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
-    scaffold.ctx.apiProxy.host.pickDirectory = request => Promise.resolve({
-      rpcId: request.rpcId,
-      result: { ok: true, value: { path: pickedDirectory } },
-    })
     // Seed one cold session (Ungrouped bucket) for the flat view + hover card.
     const sessionCwd = join(scaffold.workspaceCwd, 'workspace')
     await mkdir(sessionCwd, { recursive: true })
@@ -137,14 +164,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
       collect()
     })
     // Register the scaffold's existing project directory through the real UI.
-    pickedDirectory = scaffold.workspaceCwd
-    await page.getByRole('button', { name: 'Create workspace' }).click()
-    await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
-
-    await expect.poll(
-      () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
-      { timeout: 10_000 },
-    ).not.toBeUndefined()
+    await openLocalFolder(scaffold.workspaceCwd, { waitForAgent: true })
     const workspace = await scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd)
     if (workspace === undefined) throw new Error('GUI did not register the existing project directory')
     await workspace.attachSession(SessionId(SEED_ID))
@@ -200,9 +220,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
     // Re-registering the exact deleted path immediately, without a reload, is
     // a supported reversible flow. It creates a fresh Workspace id without
     // re-adopting the retained Session.
-    pickedDirectory = scaffold.workspaceCwd
-    await page.getByRole('button', { name: 'Create workspace' }).click()
-    await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+    await openLocalFolder(scaffold.workspaceCwd)
     await expect.poll(
       () => scaffold.ctx.workspace.resolveByPath(scaffold.workspaceCwd),
       { timeout: 10_000 },
@@ -272,9 +290,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
       collect()
     })
 
-    pickedDirectory = oldPath
-    await page.getByRole('button', { name: 'Create workspace' }).click()
-    await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+    await openLocalFolder(oldPath)
     await expect.poll(
       () => scaffold.ctx.workspace.resolveByPath(oldPath),
       { timeout: 10_000 },
@@ -330,6 +346,42 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
 
+  it('matches the directory-browser dialog aria golden at a staged directory', async () => {
+    // A staged subtree under the scaffold cwd keeps the listing deterministic
+    // (normalizeAria scrubs the cwd), and pointing the in-process host's HOME
+    // at the cwd collapses the breadcrumb ancestry into the Home crumb — no
+    // machine-specific path segments or real $HOME contents enter the golden.
+    const staged = join(scaffold.workspaceCwd, 'browse-golden')
+    await mkdir(join(staged, 'alpha'), { recursive: true })
+    await mkdir(join(staged, 'beta'), { recursive: true })
+    // homedir() reads HOME on POSIX and USERPROFILE on Windows: root both
+    // at the scaffold cwd so the golden's ancestry collapses everywhere.
+    const realHome = process.env.HOME
+    const realUserProfile = process.env.USERPROFILE
+    process.env.HOME = scaffold.workspaceCwd
+    process.env.USERPROFILE = scaffold.workspaceCwd
+    try {
+      await page.getByRole('button', { name: 'Create workspace' }).click()
+      await page.getByRole('menuitem', { name: 'Open local folder…' }).click()
+      const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
+      await dialog.waitFor({ timeout: 10_000 })
+      await dialog.getByRole('button', { name: '编辑路径' }).click()
+      await dialog.getByLabel('编辑路径').fill(staged)
+      await dialog.getByLabel('编辑路径').press('Enter')
+      await expect.poll(() => dialog.getByText('alpha', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+      const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(BROWSER_EXPECTED, snapshot, MODE)
+      await dialog.getByRole('button', { name: '取消' }).click()
+      await dialog.waitFor({ state: 'hidden', timeout: 10_000 })
+    } finally {
+      if (realHome === undefined) delete process.env.HOME
+      else process.env.HOME = realHome
+      if (realUserProfile === undefined) delete process.env.USERPROFILE
+      else process.env.USERPROFILE = realUserProfile
+    }
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+
   it('shows the session hover card after a dwell on the row', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
     // Expand Ungrouped to reveal the seeded session row, then dwell on it
@@ -361,8 +413,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
 
   it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
     expect(tripwire.warnings).toEqual([])
-    // This spec mints no fixture directory contents of its own; the seed it
-    // reuses is owned (and inventory-guarded) by seeded-history.
-    await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep'])
+    // The directory-browser aria golden is this spec's one owned artifact;
+    // the seed it reuses is owned (and inventory-guarded) by seeded-history.
+    await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md'])
   })
 })

+ 7 - 2
docs/module-graph.md

@@ -270,7 +270,6 @@ flowchart TD
   pkg_jsonrpc_demo --> pkg_invariants
   pkg_host_apiproxy --> pkg_invariants
   pkg_host_directory_picker --> pkg_invariants
-  pkg_host_directory_picker_browse --> pkg_invariants
   pkg_host_webserver --> pkg_invariants
   pkg_storage --> pkg_invariants
   pkg_subprocess --> pkg_invariants
@@ -362,6 +361,12 @@ flowchart TD
   pkg_client_ui_theme --> pkg_client_ui_primitives
   pkg_client_ui_theme --> pkg_client_ui_slots
   pkg_client_ui_theme --> pkg_invariants
+  pkg_host_directory_picker_browse --> pkg_client_locale
+  pkg_host_directory_picker_browse --> pkg_client_runtime
+  pkg_host_directory_picker_browse --> pkg_client_ui_primitives
+  pkg_host_directory_picker_browse --> pkg_client_ui_slots
+  pkg_host_directory_picker_browse --> pkg_client_ui_workspace
+  pkg_host_directory_picker_browse --> pkg_invariants
   pkg_host_directory_picker_native --> pkg_client_runtime
   pkg_host_directory_picker_native --> pkg_client_ui_slots
   pkg_host_directory_picker_native --> pkg_client_ui_workspace
@@ -976,7 +981,6 @@ flowchart TD
 | [`jsonrpc-demo`](../packages/examples/jsonrpc-demo) | `examples` | [`invariants`](../packages/support/invariants) |
 | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
 | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/support/invariants) |
-| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/support/invariants) |
 | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
 | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
 | [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) |
@@ -1006,6 +1010,7 @@ flowchart TD
 | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
 | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
 | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
 | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |

+ 15 - 1
packages/client/runtime/src/client/contract/workspaces.ts

@@ -6,7 +6,7 @@
  * the concrete class. Widening this interface is the explicit act of
  * widening what features may do to the workspaces domain.
  */
-import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
+import type { DirectoryListing, SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
 import type { WorkspaceListState } from '../workspaces/service.ts'
 import type { ObservableSnapshot } from './store.ts'
 
@@ -37,6 +37,20 @@ export interface IWorkspaces {
    * @returns the selected path, or null when the user cancelled.
    */
   pickDirectory(): Promise<string | null>
+  /**
+   * List one directory level through the Host's `browse` capability.
+   * @param path - absolute directory to list; absent lists the Host home directory.
+   * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
+   * @returns the level's listing with breadcrumb ancestry.
+   */
+  listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing>
+  /**
+   * Create one child directory through the Host's `browse` capability.
+   * @param path - absolute existing parent directory.
+   * @param name - single non-blank path segment.
+   * @returns the created directory's absolute path.
+   */
+  createDirectory(path: string, name: string): Promise<string>
   /**
    * Open a filesystem path with the Host operating system's default application.
    * @param path - absolute or host-resolvable path.

+ 3 - 2
packages/client/runtime/src/client/workspaces/service.ts

@@ -195,10 +195,11 @@ export class WorkspacesService implements IWorkspaces {
   /**
    * List one directory level through the Host's `browse` capability.
    * @param path - absolute directory to list; absent lists the Host home directory.
+   * @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
    * @returns the level's listing with breadcrumb ancestry.
    */
-  async listDirectory(path?: string): Promise<DirectoryListing> {
-    const response = await this.api.host.listDirectory(path === undefined ? {} : { path })
+  async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
+    const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
     if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
     return response.result.value
   }

+ 43 - 1
packages/client/test-runtime/src/workspaces.ts

@@ -1,7 +1,7 @@
 /** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
 import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
 import type {
-  IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
+  DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import { workspaceListState } from './fixtures.ts'
 import type { Stabilizer } from './fixtures.ts'
@@ -109,6 +109,48 @@ export class TestWorkspaces implements IWorkspaces {
     return null
   }
 
+  /**
+   * Browse listing (recorded). The default serves an empty home level; stub
+   * to shape a tree.
+   * @param path - absolute directory to list; absent lists the home level.
+   * @returns the level's listing.
+   */
+  async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
+    // The signal is recorded and forwarded like the production face passes
+    // it to the wire, so cancellation integration tests can observe or
+    // reject on a superseded scan.
+    this.calls.push({ method: 'listDirectory', args: [path, signal] })
+    const stub = this.stubs.get('listDirectory')
+    if (stub !== undefined) return await (stub(path, signal) as Promise<DirectoryListing>)
+    // The chain runs root-to-target inclusive, per the DirectoryListing
+    // contract — a bare root crumb would mislabel the level in browsers
+    // driven by this double.
+    return {
+      path: '/home/test',
+      home: '/home/test',
+      crumbs: [
+        { name: '/', path: '/', hidden: false },
+        { name: 'home', path: '/home', hidden: false },
+        { name: 'test', path: '/home/test', hidden: false },
+      ],
+      entries: [],
+      truncated: false,
+    }
+  }
+
+  /**
+   * Browse child creation (recorded). The default joins parent and name.
+   * @param path - absolute existing parent directory.
+   * @param name - single path segment.
+   * @returns the created directory's absolute path.
+   */
+  async createDirectory(path: string, name: string): Promise<string> {
+    this.calls.push({ method: 'createDirectory', args: [path, name] })
+    const stub = this.stubs.get('createDirectory')
+    if (stub !== undefined) return await (stub(path, name) as Promise<string>)
+    return `${path}/${name}`
+  }
+
   /**
    * Rename a Workspace (recorded). The default echoes a minimal view.
    * @param workspaceId - target workspace.

+ 26 - 0
packages/client/test-runtime/tests/runtime.spec.tsx

@@ -322,6 +322,32 @@ describe('workspaces', () => {
     expect(stub).toHaveBeenCalledOnce()
     await runtime.dispose()
   })
+
+  it('records the browse calls: listDirectory serves an empty home, createDirectory joins, stubs override', async () => {
+    const runtime = await runtimeWithFrame()
+    // Defaults: an empty home level and parent/name joining.
+    await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] })
+    await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' })
+    await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh')
+    // The recorded signal seat mirrors the production face (undefined here;
+    // cancellation tests pass and observe a real one).
+    expect(runtime.workspaces.calls).toEqual([
+      { method: 'listDirectory', args: [undefined, undefined] },
+      { method: 'listDirectory', args: ['/home/test', undefined] },
+      { method: 'createDirectory', args: ['/home/test', 'fresh'] },
+    ])
+    // Stubs replace the defaults like every sibling method.
+    const listing = { path: '/x', home: '/x', crumbs: [], entries: [] }
+    const listStub = vi.fn(() => Promise.resolve(listing as never))
+    runtime.workspaces.stub('listDirectory', listStub)
+    runtime.workspaces.stub('createDirectory', vi.fn(() => Promise.resolve('/x/made' as never)))
+    const scan = new AbortController()
+    await expect(runtime.workspaces.listDirectory('/x', scan.signal)).resolves.toBe(listing)
+    // The stub receives the signal too, like the production face gives the wire.
+    expect(listStub).toHaveBeenLastCalledWith('/x', scan.signal)
+    await expect(runtime.workspaces.createDirectory('/x', 'made')).resolves.toBe('/x/made')
+    await runtime.dispose()
+  })
 })
 
 describe('feature mount and disposal', () => {

+ 24 - 14
packages/client/ui-primitives/src/Modal.tsx

@@ -12,13 +12,16 @@ import css from './Modal.module.css'
  * Render a centered modal over a blurred page mask.
  * @param props.open - whether the dialog is showing.
  * @param props.onClose - Escape or mask click.
- * @param props.title - dialog heading.
+ * @param props.title - dialog heading (aria-label in every mode).
  * @param props.description - optional supporting sentence under the title.
  * @param props.children - body (inputs, etc.).
  * @param props.footer - action row (Cancel / Create).
+ * @param props.headless - render children directly in the card (no default
+ * header/close/body chrome) for dialogs whose figma frame owns its own
+ * header structure; mask, card, Escape, and aria-label remain.
  * @returns null when closed; otherwise the overlay tree.
  */
-export function Modal({ open, onClose, title, description, children, footer, className }: {
+export function Modal({ open, onClose, title, description, children, footer, className, headless = false }: {
   open: boolean
   onClose: () => void
   title: string
@@ -26,6 +29,7 @@ export function Modal({ open, onClose, title, description, children, footer, cla
   children?: ReactNode
   footer?: ReactNode
   className?: string
+  headless?: boolean
 }) {
   useEffect(() => {
     if (!open) return
@@ -47,19 +51,25 @@ export function Modal({ open, onClose, title, description, children, footer, cla
         aria-modal="true"
         aria-label={title}
       >
-        <div className={css.content}>
-          <div className={css.header}>
-            <h2 className={css.title}>{title}</h2>
-            <button type="button" className={css.close} aria-label="Close" onClick={onClose}>
-              <IconCloseOutline16 size={14} />
-            </button>
-          </div>
-          {description !== undefined && description !== '' && (
-            <p className={css.description}>{description}</p>
+        {headless
+          ? children
+          : (
+            <>
+              <div className={css.content}>
+                <div className={css.header}>
+                  <h2 className={css.title}>{title}</h2>
+                  <button type="button" className={css.close} aria-label="Close" onClick={onClose}>
+                    <IconCloseOutline16 size={14} />
+                  </button>
+                </div>
+                {description !== undefined && description !== '' && (
+                  <p className={css.description}>{description}</p>
+                )}
+                {children !== undefined && <div className={css.body}>{children}</div>}
+              </div>
+              {footer !== undefined && <div className={css.footer}>{footer}</div>}
+            </>
           )}
-          {children !== undefined && <div className={css.body}>{children}</div>}
-        </div>
-        {footer !== undefined && <div className={css.footer}>{footer}</div>}
       </div>
     </div>
   )

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

@@ -8,6 +8,9 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../locale"
+    },
     {
       "path": "../../../vendor/cordis"
     },

+ 2 - 2
packages/host/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/README.md
-README.md: 7df0ecc4a362be1149188d133233307b1fc48c8a
-README.zh.md: 90d5ea2b0947d2cff9ba06e89b6225b39dad7fce
+README.md: d44770f70be16c12f44b78155089e092a3e9bba0
+README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014

+ 1 - 1
packages/host/README.md

@@ -10,6 +10,6 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and
 | `webserver/` | Plain HTTP route-registration carrier: `node:http` server listening on activation; routes register as named `exact`/`prefix` handlers | `ctx.httpServer` |
 | `directory-picker/` | Workspace-directory picking seam: discriminated `native`/`browse` capability the gateway's picker RPCs delegate to | `ctx.directoryPicker` |
 | `directory-picker-native/` | Dual-face native interaction: OS-chooser backend (osascript / PowerShell / Zenity+KDialog, host-display only) + the browser half filling ui-workspace's directory-flow slots | (registers `ctx.directoryPicker`) |
-| `directory-picker-browse/` | In-app browsing backend: listing/creation primitives over Node stdlib; remote-capable | (registers `ctx.directoryPicker`) |
+| `directory-picker-browse/` | Dual-face browse interaction: listing/creation primitives over Node stdlib (remote-capable) + the browser half rendering the in-app Select Workspace Directory dialog | (registers `ctx.directoryPicker`) |
 
 `apiproxy` is transport-agnostic by design — it registers no routes; carriers wrap `ctx.apiProxy` themselves. The HTTP carrier route (with its `/api` browser-trust fence) is mounted by [`client/connection`](../client/connection/README.md)'s node half, which is why that package lives in the client group: it owns both ends of the wire.

+ 1 - 1
packages/host/README.zh.md

@@ -10,6 +10,6 @@ dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承
 | `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact`/`prefix` 处理器注册 | `ctx.httpServer` |
 | `directory-picker/` | 工作区目录选择 seam:网关的 picker RPC 委托的可辨识 `native`/`browse` 能力 | `ctx.directoryPicker` |
 | `directory-picker-native/` | 双面原生交互:OS 选择器后端(osascript/PowerShell/Zenity+KDialog,仅宿主屏幕可用)+ 填入 ui-workspace 目录流 slot 的 browser half | (注册 `ctx.directoryPicker`) |
-| `directory-picker-browse/` | 应用内浏览后端:基于 Node 标准库的列举/创建原语;支持远程 | (注册 `ctx.directoryPicker`) |
+| `directory-picker-browse/` | 双面浏览交互:基于 Node 标准库的列举/创建原语(可远程)+ 渲染应用内选择工作区目录对话框的 browser half | (注册 `ctx.directoryPicker`) |
 
 `apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。

+ 2 - 2
packages/host/directory-picker-browse/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md
-README.md: 9543fc89f1314d05d02df72e9b5d86af21fdad66
-README.zh.md: dd314c5b5a709b2cf2e6840911fd665ead7d7e87
+README.md: 318380405214d5f25ad77e348c4e134a8981ffb3
+README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754

+ 2 - 1
packages/host/directory-picker-browse/README.md

@@ -6,6 +6,8 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick
 
 Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
 
+**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
+
 ## Model Experience
 
 None, as the backend serves the GUI host's directory selection; nothing here reaches a model request.
@@ -16,7 +18,6 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **No client half yet** — the in-app browsing dialog that consumes these primitives lands in the next PR of this stack; until then a `-browse` composition hides the picking affordance entirely (ui-workspace's documented empty-hole default) and the listing/creation RPCs go unconsumed.
 - **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost.
 - **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here.
 - **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it.

+ 2 - 1
packages/host/directory-picker-browse/README.zh.md

@@ -6,6 +6,8 @@
 
 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
 
+**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
+
 ## 模型体验
 
 无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
@@ -16,7 +18,6 @@
 
 ## 已知限制与延期工作
 
-- **尚无 client half**——消费这些原语的应用内浏览对话框在本栈的下一个 PR 落地;在那之前 `-browse` 组合会完全隐藏选目录入口(ui-workspace 文档化的空洞默认行为),列举/创建 RPC 无消费者。
 - **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。
 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。
 - **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。

+ 29 - 2
packages/host/directory-picker-browse/package.json

@@ -15,12 +15,17 @@
       "types": "./lib/types/invariant.d.ts",
       "default": "./lib/invariant.js"
     },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
     "./src/*": "./src/*",
     "./package.json": "./package.json"
   },
   "files": [
     "lib/index.js",
     "lib/invariant.js",
+    "lib/client.js",
     "lib/types/**/*.d.ts",
     "lib/types/**/*.d.ts.map",
     "src"
@@ -28,14 +33,36 @@
   "license": "BSD-3-Clause",
   "dependencies": {
     "@deepseek-ai/dsh-host-directory-picker": "workspace:^",
+    "clsx": "^2.0.0",
     "schemastery": "^3.18.0"
   },
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-locale": "^0.0.1",
+    "@deepseek-ai/dsh-client-runtime": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-workspace": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
-    "cordis": "^4.0.0-rc.7"
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
   },
   "devDependencies": {
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "cordis": "^4.0.0-rc.7"
+    "@types/react": "~18.3.1",
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
+  },
+  "dshClient": {
+    "inject": [
+      "@deepseek-ai/dsh-client-runtime",
+      "@deepseek-ai/dsh-client-ui-workspace",
+      "@deepseek-ai/dsh-client-locale"
+    ],
+    "platform": "web"
   }
 }

+ 306 - 0
packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css

@@ -0,0 +1,306 @@
+/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
+ * headless here — mask, card, Escape only — and this module owns the figma
+ * frame: 600×420 card (viewport-clamped), header (title + crumbs, l3 separator),
+ * the one-or-two-column Miller content, and the bordered footer. */
+
+/* Doubled class beats Modal's own .dialog regardless of stylesheet order. */
+/* Short viewports clamp the card: header/footer are flex-none and the
+ * columns scroll, so shrinking the height keeps Open/Cancel reachable
+ * instead of clipping them below a fixed overlay. */
+.dialog.dialog {
+  width: min(600px, 100%);
+  height: min(420px, calc(100dvh - 32px));
+  padding: 0;
+  gap: 0;
+}
+
+/* Header block: pl24 pr14 pt22 pb12, 8px between title row and crumb row. */
+.header {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  flex: none;
+  padding: 22px 14px 12px 24px;
+  border-bottom: 1px solid var(--dsw-alias-border-l3);
+}
+
+.title {
+  display: flex;
+  align-items: flex-end;
+  min-height: 28px;
+  margin: 0;
+  font-size: 16px;
+  line-height: 24px;
+  font-weight: 510;
+  color: var(--dsw-alias-label-primary);
+}
+
+.crumbBar {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  min-height: 20px;
+}
+
+/* Deep chains scroll inside the trail (the effect pins the tail into view)
+ * so the edit zone to the right never leaves the bar. */
+/* The Miller columns keep their own row so a status/error line below never
+ * competes with the fixed column widths for horizontal space. */
+/* A narrow viewport shrinks the dialog below two fixed panes; the row
+ * scrolls horizontally (the effect pins the child pane into view) so
+ * descent never hides behind the Modal's clipping. */
+.millerRow {
+  display: flex;
+  align-items: stretch;
+  flex: 1 1 0;
+  min-height: 0;
+  gap: 20px;
+  overflow-x: auto;
+}
+
+.crumbTrail {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  flex: 0 1 auto;
+  min-width: 0;
+  overflow-x: auto;
+  scrollbar-width: none;
+}
+
+.crumbSeat {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  flex: none;
+  min-width: 0;
+}
+
+.crumb {
+  border: none;
+  background: transparent;
+  padding: 0;
+  max-width: 160px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 13px;
+  line-height: 20px;
+  font-weight: 500;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+}
+
+.crumb:hover {
+  color: var(--dsw-alias-label-primary);
+}
+
+.crumbChevron {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+/* The empty remainder of the bar: invisible, but a real click target that
+ * flips the bar into path-edit mode. */
+.crumbEditZone {
+  flex: 1 0 34px;
+  min-width: 34px;
+  align-self: stretch;
+  border: none;
+  background: transparent;
+  cursor: text;
+}
+
+.pathInput {
+  box-sizing: border-box;
+  flex: 1 1 0;
+  min-width: 0;
+  height: 24px;
+  padding: 0 8px;
+  border: 1px solid var(--dsw-alias-border-l2);
+  border-radius: 8px;
+  outline: none;
+  background: transparent;
+  font-size: 13px;
+  line-height: 20px;
+  color: var(--dsw-alias-label-primary);
+}
+
+/* Miller content: pt16 px24; columns are 256 wide (or full width solo) with
+ * the hairline divider centered between them; each column scrolls alone. */
+.content {
+  display: flex;
+  flex-direction: column;
+  flex: 1 1 0;
+  min-height: 0;
+  padding: 16px 24px 0;
+}
+
+.column {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  width: 256px;
+  flex: none;
+  overflow-y: auto;
+}
+
+.columnWide {
+  width: 100%;
+  flex: 1 1 0;
+}
+
+.divider {
+  flex: none;
+  width: 1px;
+  background: var(--dsw-alias-border-l3);
+}
+
+.rowSeat {
+  display: flex;
+  flex: none;
+}
+
+.row {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  height: 28px;
+  flex: none;
+  padding: 4px;
+  border: none;
+  border-radius: 6px;
+  background: transparent;
+  text-align: left;
+  cursor: pointer;
+}
+
+.row:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* Selection: pill fill + the open-folder glyph in the info accent. */
+.rowSelected,
+.rowSelected:hover {
+  background: var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover));
+}
+
+.rowIcon {
+  flex: none;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.rowIconSelected {
+  flex: none;
+  color: var(--dsw-alias-button-info-fill);
+}
+
+.rowName {
+  flex: 1 1 0;
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  font-size: 13px;
+  line-height: 20px;
+  font-weight: 500;
+  color: var(--dsw-alias-label-primary);
+}
+
+.rowChevron {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.status,
+.error {
+  padding: 4px;
+  font-size: 12px;
+  line-height: 18px;
+}
+
+.status {
+  color: var(--dsw-alias-label-secondary);
+}
+
+.error {
+  color: var(--dsw-alias-state-error-primary);
+}
+
+/* Footer: l3 separator on top, pt12 px24, New-folder pinned left; the fixed
+ * card leaves the figma 28px below the 36px buttons. */
+.footerBar {
+  display: flex;
+  align-items: center;
+  /* Narrow viewports wrap the confirm/cancel pair onto their own row
+   * instead of clipping Open past the card's hidden overflow. */
+  flex-wrap: wrap;
+  gap: 8px;
+  flex: none;
+  padding: 12px 24px 28px;
+  border-top: 1px solid var(--dsw-alias-border-l3);
+}
+
+.footerGap {
+  flex: 1 1 0;
+}
+
+.footerAction {
+  min-width: 72px;
+}
+
+/* Nested create dialog (figma 813:23278): a small centered card. */
+.createDialog.createDialog {
+  width: min(380px, 100%);
+  padding: 0;
+  gap: 0;
+}
+
+.createBody {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding: 22px 24px 20px;
+}
+
+.createTitle {
+  margin: 0;
+  font-size: 16px;
+  line-height: 24px;
+  font-weight: 510;
+  color: var(--dsw-alias-label-primary);
+}
+
+.createIn {
+  margin: 0;
+  font-size: 14px;
+  line-height: 22px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.createInput {
+  box-sizing: border-box;
+  width: 100%;
+  height: 44px;
+  padding: 7px 14px;
+  border: 1px solid var(--dsw-alias-border-l2);
+  border-radius: 22px;
+  outline: none;
+  background: transparent;
+  font-size: 14px;
+  line-height: 22px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.createInput::placeholder {
+  color: var(--dsw-alias-label-caption);
+}
+
+.createActions {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 8px;
+  margin-top: 8px;
+}

+ 503 - 0
packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx

@@ -0,0 +1,503 @@
+/**
+ * The in-app workspace-directory browser (figma Harness 813-23126 family): a
+ * 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls
+ * sideways, the columns scroll down) whose header carries the title, the selection-path
+ * breadcrumb, and a click-to-edit path zone; below it a Miller view — one
+ * full-width level until a row is selected, then two 256px columns (level |
+ * selected folder's children) around a hairline divider. Selecting in the
+ * right column shifts the view one level deeper. "New folder" opens a nested
+ * create dialog targeting the selected folder (or the level itself) and
+ * selects the created folder. Open adopts the selected folder, falling back
+ * to the listed level. Pure consumer of the injected browse calls — the
+ * owning flow decides what "Open" means and owns the workspace-creation
+ * error surface. Hidden entries are host-flagged and filtered here (a
+ * show-hidden toggle is deferred work, client-side only).
+ */
+import { useCallback, useEffect, useRef, useState } from 'react'
+import clsx from 'clsx'
+import {
+  Button, IconChevronRightOutline14, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, Modal,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
+import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
+import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
+import css from './DirectoryBrowser.module.css'
+
+/** Owner-supplied browser props: browse calls, pick semantics, and copy. */
+export interface DirectoryBrowserProps {
+  /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */
+  open: boolean
+  /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */
+  listDirectory: (path?: string, signal?: AbortSignal) => Promise<DirectoryListing>
+  /** Create one child directory under an existing parent. */
+  createDirectory: (path: string, name: string) => Promise<string>
+  /** The operator confirmed a directory (the selection, else the listed level). */
+  onOpen: (path: string) => void
+  /** Close without picking (mask, Escape, Cancel). */
+  onClose: () => void
+  /** The owner's confirm is in flight: Open disables, the view freezes. */
+  busy: boolean
+  /** Localized copy. */
+  t: Translate
+}
+
+/** Failure text: the Host business message when typed, else the throw's text. */
+function failureText(error: unknown): string {
+  if (error instanceof DirectoryBrowseError) return error.rpcError.message
+  return error instanceof Error ? error.message : String(error)
+}
+
+/**
+ * Breadcrumb rows for display: inside the home subtree the chain starts at a
+ * localized Home crumb; outside it the full ancestry shows, the root labeled
+ * by its own path.
+ */
+function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] {
+  const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home)
+  if (homeIndex === -1) return listing.crumbs
+  const tail = listing.crumbs.slice(homeIndex + 1)
+  return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail]
+}
+
+/** One column of folder rows (the Miller view renders one or two of these). */
+function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
+  entries: readonly DirectoryEntry[]
+  selectedPath: string | null
+  busy: boolean
+  onPick: (entry: DirectoryEntry) => void
+  wide: boolean
+}) {
+  return (
+    <div className={clsx(css.column, wide && css.columnWide)} role="list">
+      {entries.filter(entry => !entry.hidden).map((entry) => {
+        const selected = entry.path === selectedPath
+        return (
+          // The wrapper carries the list semantics; the row keeps its NATIVE
+          // button role so assistive technology exposes an actionable control.
+          <span key={entry.path} role="listitem" className={css.rowSeat}>
+            <button
+              type="button"
+              aria-current={selected || undefined}
+              className={clsx(css.row, selected && css.rowSelected)}
+              disabled={busy}
+              onClick={() => { onPick(entry) }}
+            >
+              {selected
+                ? <IconFolderOpen16 size={16} className={css.rowIconSelected} />
+                : <IconFolderClose16 size={16} className={css.rowIcon} />}
+              <span className={css.rowName}>{entry.name}</span>
+              <IconChevronRightOutline14 size={12} className={css.rowChevron} />
+            </button>
+          </span>
+        )
+      })}
+    </div>
+  )
+}
+
+/**
+ * Render the directory-browser dialog.
+ * @param props - owner-controlled browser props.
+ * @returns the dialog element (null while closed, via Modal).
+ */
+export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClose, busy, t }: DirectoryBrowserProps) {
+  // Miller state: the listed level, the selected row in it, and the selected
+  // folder's own listing (the right column; null while nothing is selected).
+  const [parent, setParent] = useState<DirectoryListing | null>(null)
+  const [selected, setSelected] = useState<DirectoryEntry | null>(null)
+  const [child, setChild] = useState<DirectoryListing | null>(null)
+  const [loading, setLoading] = useState(false)
+  const [error, setError] = useState<string | null>(null)
+  // Path-edit state: null = breadcrumb mode; a string = the draft being typed.
+  const [pathDraft, setPathDraft] = useState<string | null>(null)
+  // Create-folder state: null = closed; a string = the nested dialog's draft.
+  const [folderDraft, setFolderDraft] = useState<string | null>(null)
+  const [creatingFolder, setCreatingFolder] = useState(false)
+  const [createError, setCreateError] = useState<string | null>(null)
+  const requestSeq = useRef(0)
+  // The in-flight listing's controller: superseding intent aborts the wire
+  // request too — the Host stops scanning — instead of only discarding the
+  // eventual result while the scan keeps consuming host resources.
+  const scanController = useRef<AbortController | null>(null)
+  // Bumped on every open/close edge: settlements from a previous open (a
+  // pending creation included) must never mutate a reopened dialog.
+  const openGeneration = useRef(0)
+  // Deep ancestry overflows the trail; keep its tail (the current directory
+  // and the edit zone beside it) in view whenever the chain changes.
+  const crumbTrailRef = useRef<HTMLSpanElement | null>(null)
+  // IME confirmation (Enter selecting a candidate) must not submit either
+  // text input; the same guard the workspace-name inputs carry, shared by
+  // the path editor and the folder-name input.
+  const composingRef = useRef(false)
+  // HMR/unmount invalidation: a completion from a disposed flow must not
+  // update state or issue follow-up requests from a dead component.
+  useEffect(() => () => {
+    requestSeq.current += 1
+    openGeneration.current += 1
+    scanController.current?.abort()
+  }, [])
+  const compositionGuard = {
+    onCompositionStart: () => { composingRef.current = true },
+    onCompositionEnd: () => { composingRef.current = false },
+  }
+
+  /** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */
+  const supersede = useCallback((): number => {
+    scanController.current?.abort()
+    scanController.current = null
+    return ++requestSeq.current
+  }, [])
+
+  /** Launch one listing under a fresh controller so a later supersession can abort it. */
+  const launchListing = useCallback((path: string | undefined): { seq: number; scan: Promise<DirectoryListing> } => {
+    const seq = supersede()
+    const controller = new AbortController()
+    scanController.current = controller
+    return { seq, scan: listDirectory(path, controller.signal) }
+  }, [supersede, listDirectory])
+
+  /** Replace the whole view with one freshly listed level (no selection). */
+  const navigate = useCallback((path?: string) => {
+    const { seq, scan } = launchListing(path)
+    setLoading(true)
+    setError(null)
+    scan.then((next) => {
+      if (seq !== requestSeq.current) return
+      setParent(next)
+      setSelected(null)
+      setChild(null)
+      setLoading(false)
+      setPathDraft(null)
+    }, (reason: unknown) => {
+      if (seq !== requestSeq.current) return
+      setLoading(false)
+      setError(failureText(reason))
+    })
+  }, [launchListing])
+
+  /** Select a row of the listed level and preview its children on the right. */
+  const select = useCallback((entry: DirectoryEntry) => {
+    const { seq, scan } = launchListing(entry.path)
+    setSelected(entry)
+    setChild(null)
+    setLoading(true)
+    setError(null)
+    scan.then((next) => {
+      if (seq !== requestSeq.current) return
+      setChild(next)
+      setLoading(false)
+    }, (reason: unknown) => {
+      if (seq !== requestSeq.current) return
+      setLoading(false)
+      setError(failureText(reason))
+      // An unreadable selection cannot be the committing target while the
+      // breadcrumb still names the level: fall back to the single pane.
+      setSelected(null)
+    })
+  }, [launchListing])
+
+  /** A right-column pick advances the view one level: child becomes the level. */
+  const advance = useCallback((entry: DirectoryEntry) => {
+    /* v8 ignore next -- narrowing guard: the right column only renders with a child listing. */
+    if (child === null) return
+    setParent(child)
+    select(entry)
+  }, [child, select])
+
+  // Every open starts fresh at the Host home directory; closing invalidates
+  // any in-flight response so a late arrival cannot repopulate a closed dialog.
+  useEffect(() => {
+    openGeneration.current += 1
+    if (open) {
+      setParent(null)
+      setSelected(null)
+      setChild(null)
+      setCreatingFolder(false)
+      navigate()
+      return
+    }
+    supersede()
+    setError(null)
+    setPathDraft(null)
+    setFolderDraft(null)
+    setCreateError(null)
+  }, [open, navigate, supersede])
+
+  /** The folder a create or Open acts on: the selection, else the listed level. */
+  const targetPath = selected?.path ?? parent?.path ?? null
+  const targetName = selected?.name
+    ?? (parent === null ? '' : (displayCrumbs(parent, t('browser.home')).at(-1)?.name ?? parent.path))
+
+  const confirmCreate = (): void => {
+    /* v8 ignore next -- reentry fence: the nested dialog only renders with a target and disables while creating. */
+    if (targetPath === null || folderDraft === null || creatingFolder) return
+    // Trim only rejects an all-whitespace draft; the Host gets the original
+    // spelling — the backend accepts any non-blank single segment verbatim,
+    // and trimming here would create (and select) a different sibling.
+    const name = folderDraft
+    if (name.trim() === '') return
+    setCreatingFolder(true)
+    setCreateError(null)
+    const generation = openGeneration.current
+    createDirectory(targetPath, name).then((createdPath) => {
+      // A settlement from a closed (possibly reopened) flow must not touch
+      // the fresh dialog or issue a relist against the stale target.
+      if (generation !== openGeneration.current) return
+      setCreatingFolder(false)
+      setFolderDraft(null)
+      // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the
+      // create target becomes the listed level and the new folder its selection.
+      const { seq, scan } = launchListing(targetPath)
+      setLoading(true)
+      scan.then((level) => {
+        /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
+        if (seq !== requestSeq.current) return
+        setParent(level)
+        setLoading(false)
+        select({ name, path: createdPath, hidden: false })
+      }, (reason: unknown) => {
+        /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */
+        if (seq !== requestSeq.current) return
+        setLoading(false)
+        setError(failureText(reason))
+      })
+    }, (reason: unknown) => {
+      if (generation !== openGeneration.current) return
+      setCreatingFolder(false)
+      setCreateError(failureText(reason))
+    })
+  }
+
+  // After the hooks: a closed dialog renders nothing and evaluates no copy.
+  const crumbSource = child ?? parent
+  const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
+  const crumbTail = crumbs.at(-1)?.path
+  useEffect(() => {
+    const trail = crumbTrailRef.current
+    if (trail !== null) trail.scrollLeft = trail.scrollWidth
+  }, [crumbTail])
+  // On viewports too narrow for both fixed panes the Miller row scrolls;
+  // whenever a child preview lands, pin it into view the way the crumb tail
+  // pins — otherwise descent is unreachable on a phone-width window.
+  const millerRowRef = useRef<HTMLDivElement | null>(null)
+  const childPath = child?.path
+  useEffect(() => {
+    const row = millerRowRef.current
+    if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth
+  }, [childPath])
+
+  if (!open) return null
+  const twoPane = selected !== null
+  // The nested create dialog owns the interaction while open: Modal has no
+  // focus trap, so every parent control goes inert (Shift-Tab or AT must not
+  // close, adopt, or retarget underneath the child).
+  const parentInert = busy || folderDraft !== null
+  // An uncommitted path draft makes targetPath stale relative to the header:
+  // committing actions must not act on the previous selection/listing while
+  // a different path is displayed.
+  const draftPending = pathDraft !== null
+
+  return (
+    <Modal
+      open={open}
+      // Escape and mask reach every mounted Modal's document listener; while
+      // the nested create dialog is up only that topmost dialog may close
+      // (its own guard keeps an in-flight creation open), and an in-flight
+      // adoption pins the flow — dismissing it would leave the owner's
+      // createWorkspace to land after an apparent cancel.
+      onClose={() => { if (folderDraft === null && !busy) onClose() }}
+      title={t('browser.title')}
+      className={clsx(css.dialog)}
+      headless
+    >
+      <div className={css.header}>
+        <h2 className={css.title}>{t('browser.title')}</h2>
+        <div className={css.crumbBar}>
+          {pathDraft === null
+            ? (
+              <>
+                <span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
+                  {crumbs.map((crumb, index) => (
+                    <span key={crumb.path} className={css.crumbSeat}>
+                      {index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
+                      <button
+                        type="button"
+                        className={css.crumb}
+                        disabled={parentInert}
+                        onClick={() => { navigate(crumb.path) }}
+                      >
+                        {crumb.name}
+                      </button>
+                    </span>
+                  ))}
+                </span>
+                {/* The empty zone right of the crumbs is the path-edit affordance. */}
+                <button
+                  type="button"
+                  className={css.crumbEditZone}
+                  aria-label={t('browser.editPath')}
+                  // Stays available with no listed level: when the home
+                  // listing itself fails, typing an absolute path is the one
+                  // remaining way forward.
+                  disabled={parentInert}
+                  onClick={() => {
+                    // Opening the editor supersedes any pending listing: a
+                    // settlement landing before the first keystroke would
+                    // otherwise close the editor via navigate's draft reset.
+                    supersede()
+                    setLoading(false)
+                    setPathDraft(selected?.path ?? parent?.path ?? '')
+                  }}
+                />
+              </>
+            )
+            : (
+              <input
+                className={css.pathInput}
+                value={pathDraft}
+                aria-label={t('browser.editPath')}
+                autoFocus
+                disabled={parentInert}
+                onChange={(event) => {
+                  // Editing the draft supersedes any in-flight navigation:
+                  // its completion must neither clear the newer text nor
+                  // repopulate the view with the older path.
+                  supersede()
+                  setLoading(false)
+                  setPathDraft(event.target.value)
+                }}
+                {...compositionGuard}
+                onKeyDown={(event) => {
+                  if (event.key === 'Enter' && !composingRef.current) {
+                    event.preventDefault()
+                    // Trim only detects a blank draft; the Host gets the
+                    // original text — a real directory name may end in
+                    // whitespace, and trimming would list its sibling.
+                    if (pathDraft.trim() !== '') navigate(pathDraft)
+                  }
+                  if (event.key === 'Escape') {
+                    event.stopPropagation()
+                    // Cancel also withdraws a navigation the editor already
+                    // launched: its late success must not jump to the
+                    // cancelled path, so the pending request is superseded
+                    // and the view leaves the loading state.
+                    supersede()
+                    setLoading(false)
+                    setPathDraft(null)
+                    setError(null)
+                    // Editing may have superseded the selection's preview
+                    // request; a selection with no preview would render a
+                    // half-empty two-pane view, so cancel falls back to the
+                    // single-pane level.
+                    if (child === null) setSelected(null)
+                    // With no level listed yet (the editor superseded the
+                    // initial home listing), plain cancellation would leave a
+                    // permanently blank picker: restart the home listing.
+                    if (parent === null) navigate()
+                  }
+                }}
+              />
+            )}
+        </div>
+      </div>
+      <div className={css.content}>
+        <div className={css.millerRow} ref={millerRowRef}>
+          {parent !== null && (
+            <LevelColumn
+              entries={parent.entries}
+              selectedPath={selected?.path ?? null}
+              busy={parentInert}
+              onPick={select}
+              wide={!twoPane}
+            />
+          )}
+          {twoPane && <span className={css.divider} />}
+          {twoPane && child !== null && (
+            <LevelColumn
+              entries={child.entries}
+              selectedPath={null}
+              busy={parentInert}
+              onPick={advance}
+              wide={false}
+            />
+          )}
+        </div>
+        {loading && <div className={css.status} role="status">{t('browser.loading')}</div>}
+        {/* The backend bounds a level at its complete-result limit; say so
+          * whenever a visible pane was cut instead of letting the tail of a
+          * huge directory go silently missing. */}
+        {(parent?.truncated === true || child?.truncated === true) && !loading
+          && <div className={css.status} role="status">{t('browser.truncated')}</div>}
+        {error !== null && <div className={css.error} role="alert">{error}</div>}
+      </div>
+      <div className={css.footerBar}>
+        <Button
+          variant="outline"
+          icon={<IconPlusOutline16 size={14} />}
+          disabled={parent === null || loading || parentInert || draftPending}
+          onClick={() => {
+            setFolderDraft('')
+            setCreateError(null)
+          }}
+        >
+          {t('browser.newFolder')}
+        </Button>
+        <span className={css.footerGap} />
+        <Button variant="outline" className={clsx(css.footerAction)} disabled={parentInert} onClick={onClose}>{t('browser.cancel')}</Button>
+        <Button
+          variant="primary"
+          className={clsx(css.footerAction)}
+          disabled={targetPath === null || loading || parentInert || draftPending}
+          /* v8 ignore next -- narrowing guard: Open disables while no target exists. */
+          onClick={() => { if (targetPath !== null) onOpen(targetPath) }}
+        >
+          {t('browser.open')}
+        </Button>
+      </div>
+      {/* Nested create dialog (figma 813:23278): names one folder inside the target. */}
+      <Modal
+        open={folderDraft !== null}
+        onClose={() => { if (!creatingFolder) setFolderDraft(null) }}
+        title={t('browser.newFolder')}
+        className={clsx(css.createDialog)}
+        headless
+      >
+        <div className={css.createBody}>
+          <h3 className={css.createTitle}>{t('browser.newFolder')}</h3>
+          <p className={css.createIn}>{t('browser.createIn', { name: targetName })}</p>
+          <input
+            className={css.createInput}
+            value={folderDraft ?? ''}
+            aria-label={t('browser.folderName')}
+            placeholder={t('browser.untitledFolder')}
+            autoFocus
+            disabled={creatingFolder}
+            onChange={(event) => { setFolderDraft(event.target.value) }}
+            {...compositionGuard}
+            onKeyDown={(event) => {
+              if (event.key === 'Enter' && !composingRef.current) {
+                event.preventDefault()
+                confirmCreate()
+              }
+              if (event.key === 'Escape') {
+                event.stopPropagation()
+                if (!creatingFolder) setFolderDraft(null)
+              }
+            }}
+          />
+          {createError !== null && <div className={css.error} role="alert">{createError}</div>}
+          <div className={css.createActions}>
+            <Button variant="outline" disabled={creatingFolder} onClick={() => { setFolderDraft(null) }}>{t('browser.cancel')}</Button>
+            <Button
+              variant="primary"
+              disabled={creatingFolder || folderDraft === null || folderDraft.trim() === ''}
+              onClick={confirmCreate}
+            >
+              {t('browser.create')}
+            </Button>
+          </div>
+        </div>
+      </Modal>
+    </Modal>
+  )
+}

+ 43 - 0
packages/host/directory-picker-browse/src/client/flow.ts

@@ -0,0 +1,43 @@
+/**
+ * The browse picking occupant (package-internal; the `./client` surface
+ * exposes only the Loader exports). Same-package tests exercise it directly
+ * through this module.
+ */
+import { createElement } from 'react'
+import type { ReactElement } from 'react'
+import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
+import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
+// Type-only: the owner contract of the directory-flow holes.
+import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
+import { DirectoryBrowser } from './DirectoryBrowser.tsx'
+
+/** Injected face: the browse wire calls and copy the dialog drives (bound in apply's closure). */
+export interface BrowseFlowInjected {
+  /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */
+  listDirectory: (path?: string, signal?: AbortSignal) => Promise<DirectoryListing>
+  /** Create one child directory under an existing parent. */
+  createDirectory: (path: string, name: string) => Promise<string>
+  /** Localized dialog copy (this package's namespace). */
+  t: Translate
+}
+
+/**
+ * Flow occupant: adapts the hole's owner conversation onto the browser
+ * dialog — a confirmed directory is the picked path, dismissal is the
+ * cancellation. Browse failures (unreadable targets, create conflicts) stay
+ * inside the dialog's own alert surfaces, so the owner's `onError` arm is
+ * never driven by this occupant.
+ * @param props - owner conversation plus the injected browse face.
+ * @returns the dialog element (renders nothing while closed).
+ */
+export function BrowseDirectoryFlow(props: DirectoryFlowOwnerProps & BrowseFlowInjected): ReactElement {
+  return createElement(DirectoryBrowser, {
+    open: props.open,
+    busy: props.busy,
+    listDirectory: props.listDirectory,
+    createDirectory: props.createDirectory,
+    t: props.t,
+    onOpen: props.onPicked,
+    onClose: props.onCancel,
+  })
+}

+ 91 - 0
packages/host/directory-picker-browse/src/client/index.ts

@@ -0,0 +1,91 @@
+/**
+ * Browser half of the browse directory-picker backend: fills ui-workspace's
+ * two directory-flow holes with the in-app Select Workspace Directory dialog
+ * (figma `Harness` 813-23126 family), driving the node half's
+ * `host.listDirectory`/`host.createDirectory` primitives. Mounting this
+ * package therefore composes both sides of the browse interaction with one
+ * cordis.yml row; no client code branches on a capability kind. The dialog's
+ * copy is locale-registered here — the flow package owns its own strings.
+ */
+import { deferGroupRegistration } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+// Type-only: pulls the SlotMap merge declaring the directory-flow holes.
+import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
+import type { BrowseFlowInjected } from './flow.ts'
+import { BrowseDirectoryFlow } from './flow.ts'
+
+/** Locale namespace owning the browser dialog's copy. */
+const LOCALE_NS = 'directory-browser'
+
+/** Required services (cordis fiber inject): the slot registry, the wire-facing workspace service, and locale. */
+export const inject = ['slots', 'workspaces', 'locale']
+
+/**
+ * Client plugin body: register the dialog's dictionaries and the browse flow
+ * into both directory-flow holes (declaration-aware deferral — the declaring
+ * ui-workspace entries may activate later, and an HMR collapse re-declares).
+ * @param ctx - client root context.
+ */
+export function apply(ctx: ClientContext): void {
+  ctx.effect(() => {
+    // The two dictionaries land as a unit: if the second registration hits a
+    // rival owner of the namespace, the first rolls back before the throw —
+    // a failed activation must not squat the namespace's other locale.
+    const disposers: (() => void)[] = []
+    const dictionaries: [locale: string, dict: Record<string, string>][] = [
+      ['zh', {
+        'browser.title': '选择工作区目录',
+        'browser.home': '主目录',
+        'browser.newFolder': '新建文件夹',
+        'browser.folderName': '文件夹名称',
+        'browser.createIn': '在"{name}"中新建文件夹',
+        'browser.untitledFolder': '未命名文件夹',
+        'browser.create': '创建',
+        'browser.cancel': '取消',
+        'browser.open': '打开',
+        'browser.editPath': '编辑路径',
+        'browser.loading': '加载中…',
+        'browser.truncated': '文件夹过多,仅显示开头部分。',
+      }],
+      ['en', {
+        'browser.title': 'Select Workspace Directory',
+        'browser.home': 'Home',
+        'browser.newFolder': 'New folder',
+        'browser.folderName': 'Folder name',
+        'browser.createIn': 'New folder in "{name}"',
+        'browser.untitledFolder': 'Untitled folder',
+        'browser.create': 'Create',
+        'browser.cancel': 'Cancel',
+        'browser.open': 'Open',
+        'browser.editPath': 'Edit path',
+        'browser.loading': 'Loading…',
+        'browser.truncated': 'Too many folders to list; only the beginning is shown.',
+      }],
+    ]
+    try {
+      for (const [locale, dict] of dictionaries) disposers.push(ctx.locale.register(LOCALE_NS, locale, dict))
+    } catch (error) {
+      for (const dispose of disposers.reverse()) dispose()
+      throw error
+    }
+    return () => { for (const dispose of disposers) dispose() }
+  }, 'directory-picker-browse: dialog dictionaries')
+
+  const injected = (): BrowseFlowInjected => ({
+    listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
+    createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
+    t: ctx.locale.bind(LOCALE_NS),
+  })
+  ctx.effect(() => {
+    // One occupant, both holes, as a unit: construction or late conflicts
+    // (holes declared after rival providers activated) roll the whole pair
+    // back and fail loud — semantics owned by deferGroupRegistration.
+    const group = deferGroupRegistration(
+      ctx.slots,
+      ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const,
+      BrowseDirectoryFlow,
+      name => ctx.slots.register({ name, inject: injected }, BrowseDirectoryFlow),
+    )
+    return () => { group.dispose() }
+  }, 'directory-picker-browse: flow registrations')
+}

+ 6 - 0
packages/host/directory-picker-browse/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 214 - 0
packages/host/directory-picker-browse/tests/client-flow.spec.tsx

@@ -0,0 +1,214 @@
+// @vitest-environment jsdom
+import { Context } from 'cordis'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, render, screen } from '@testing-library/react'
+import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
+import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
+import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
+import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
+import { apply, inject } from '../src/client/index.ts'
+import { BrowseDirectoryFlow } from '../src/client/flow.ts'
+
+afterEach(cleanup)
+
+const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
+
+const HOME = '/home/u'
+const homeListing: DirectoryListing = {
+  path: HOME,
+  home: HOME,
+  crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'u', path: HOME, hidden: false }],
+  entries: [{ name: 'Documents', path: `${HOME}/Documents`, hidden: false }],
+  truncated: false,
+}
+
+async function bench() {
+  const ctx = new Context()
+  await ctx.plugin(SlotsService).await()
+  ctx.provide('locale', new LocaleService(ctx))
+  const listDirectory = vi.fn(async (): Promise<DirectoryListing> => homeListing)
+  const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
+  ctx.provide('workspaces', { listDirectory, createDirectory } as never)
+  const slots = ctx.get('slots') as SlotsService
+  const declare = () => slots.register({
+    name: 'root',
+    children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
+  } as never, () => null)
+  return { ctx, slots, listDirectory, createDirectory, declare }
+}
+
+function owner(overrides: Partial<DirectoryFlowOwnerProps> = {}): DirectoryFlowOwnerProps {
+  return {
+    open: true, busy: false,
+    onPicked: vi.fn(), onCancel: vi.fn(), onError: vi.fn(),
+    ...overrides,
+  }
+}
+
+describe('directory-picker-browse client half', () => {
+  it('declares the services it drives', () => {
+    expect(inject).toEqual(['slots', 'workspaces', 'locale'])
+  })
+
+  it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => {
+    const before = await bench()
+    before.declare()
+    const fiber = before.ctx.plugin({ inject: [...inject], apply })
+    await fiber.await()
+    for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(1)
+    // Registry-contribution disposal proof: the fiber going down empties the holes.
+    await fiber.dispose()
+    for (const hole of HOLES) expect(before.slots.entries(hole)).toHaveLength(0)
+
+    const after = await bench()
+    await after.ctx.plugin({ inject: [...inject], apply }).await()
+    for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(0)
+    after.declare()
+    await Promise.resolve()
+    for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
+  })
+
+  it('rolls back the first deferral when the second hole is already occupied', async () => {
+    const b = await bench()
+    b.declare()
+    // Foreign occupant in the SECOND registered hole: the pair construction
+    // throws after the first deferral installed its subscription.
+    b.slots.register({ name: HOLES[1] } as never, () => null)
+    const rejections: unknown[] = []
+    const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
+    process.on('unhandledRejection', onUnhandled)
+    try {
+      const fiber = b.ctx.plugin({ inject: [...inject], apply })
+      await expect(fiber.await()).rejects.toThrow(/already has a registration/)
+      // A leaked first deferral would now race this probe registration and
+      // throw from its orphaned subscription against the HERO hole; the
+      // rollback leaves only the activation failure itself (cordis re-raises
+      // the apply throw as a late rejection — installFailLoud's contract).
+      const disposeProbe = b.slots.register({ name: HOLES[0] } as never, () => null)
+      await new Promise(resolve => setTimeout(resolve, 20))
+      expect(rejections.map(String).filter(text => text.includes(HOLES[0]))).toEqual([])
+      disposeProbe()
+    } finally {
+      process.off('unhandledRejection', onUnhandled)
+    }
+  })
+
+  it('rolls back wholesale and reports loudly when a rival provider wins after deferred activation', async () => {
+    const b = await bench()
+    const rejections: unknown[] = []
+    const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
+    process.on('unhandledRejection', onUnhandled)
+    process.on('uncaughtException', onUnhandled)
+    try {
+      // This provider activates BEFORE any hole exists: both deferrals wait.
+      await b.ctx.plugin({ inject: [...inject], apply }).await()
+      b.declare()
+      // A rival occupies both holes ahead of the pending microtask flush.
+      b.slots.register({ name: HOLES[0] } as never, () => null)
+      b.slots.register({ name: HOLES[1] } as never, () => null)
+      await new Promise(resolve => setTimeout(resolve, 20))
+      // The rival keeps both holes; this provider rolled back wholesale and
+      // surfaced the conflict on the fail-loud channel — no partial mix.
+      for (const hole of HOLES) expect(b.slots.entries(hole)).toHaveLength(1)
+      expect(rejections.map(String).join('\n')).toContain('already has a registration')
+
+      // Non-Error conflicts wrap before the loud rethrow (same channel).
+      const c = await bench()
+      await c.ctx.plugin({ inject: [...inject], apply }).await()
+      const original = c.slots.register.bind(c.slots)
+      const slotsAny = c.slots as { register: typeof original }
+      slotsAny.register = ((options: never, component: never) => {
+        if ((options as { name?: string }).name === HOLES[0]) throw 'string conflict'
+        return original(options, component)
+      }) as typeof original
+      c.declare()
+      await new Promise(resolve => setTimeout(resolve, 20))
+      expect(rejections.map(String).join('\n')).toContain('string conflict')
+    } finally {
+      process.off('unhandledRejection', onUnhandled)
+      process.off('uncaughtException', onUnhandled)
+    }
+  })
+
+  it('rolls back the zh dictionary when a rival already owns the namespace en slot', async () => {
+    const b = await bench()
+    b.declare()
+    const locale = b.ctx.get('locale') as LocaleService
+    const disposeRival = locale.register('directory-browser', 'en', { 'browser.title': 'rival' })
+    const rejections: unknown[] = []
+    const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
+    // cordis re-raises the apply throw as a late rejection (installFailLoud's contract).
+    process.on('unhandledRejection', onUnhandled)
+    try {
+      const fiber = b.ctx.plugin({ inject: [...inject], apply })
+      await expect(fiber.await()).rejects.toThrow(/already has locale/)
+      // The zh registration rolled back with the failure: once the rival
+      // leaves, a fresh registrant owns the whole namespace again.
+      disposeRival()
+      const disposeZh = locale.register('directory-browser', 'zh', { 'browser.title': '空闲' })
+      disposeZh()
+    } finally {
+      await new Promise(resolve => setTimeout(resolve, 0))
+      process.off('unhandledRejection', onUnhandled)
+    }
+  })
+
+  it('registers the dialog dictionaries and binds this package namespace', async () => {
+    const b = await bench()
+    b.declare()
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+    const entry = b.slots.entries(HOLES[0])[0]!
+    const injected = (entry.inject as () => { t: (key: string) => string })()
+    // zh is the shipped default locale.
+    expect(injected.t('browser.title')).toBe('选择工作区目录')
+    expect(injected.t('browser.newFolder')).toBe('新建文件夹')
+  })
+
+  it('drives the injected browse calls through the hole entry', async () => {
+    const b = await bench()
+    b.declare()
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+    const entry = b.slots.entries(HOLES[1])[0]!
+    const injected = (entry.inject as () => {
+      listDirectory: (path?: string) => Promise<DirectoryListing>
+      createDirectory: (path: string, name: string) => Promise<string>
+    })()
+    await expect(injected.listDirectory()).resolves.toBe(homeListing)
+    await expect(injected.createDirectory(HOME, 'fresh')).resolves.toBe(`${HOME}/fresh`)
+    expect(b.listDirectory).toHaveBeenCalledOnce()
+    expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'fresh')
+  })
+
+  it('adapts the owner conversation onto the dialog: confirm picks, dismissal cancels', async () => {
+    const props = owner()
+    const listDirectory = vi.fn(async (): Promise<DirectoryListing> => homeListing)
+    const t = (key: string): string => key
+    render(
+      <BrowseDirectoryFlow
+        {...props}
+        listDirectory={listDirectory}
+        createDirectory={vi.fn(async () => '')}
+        t={t}
+      />,
+    )
+    // The dialog opened at home; its confirm (browser.open) adopts the listed level.
+    const openButton = await screen.findByRole('button', { name: 'browser.open' })
+    openButton.click()
+    expect(props.onPicked).toHaveBeenCalledWith(HOME)
+    screen.getByRole('button', { name: 'browser.cancel' }).click()
+    expect(props.onCancel).toHaveBeenCalled()
+    expect(props.onError).not.toHaveBeenCalled()
+  })
+
+  it('renders nothing while the flow is closed', () => {
+    const view = render(
+      <BrowseDirectoryFlow
+        {...owner({ open: false })}
+        listDirectory={vi.fn(async () => homeListing)}
+        createDirectory={vi.fn(async () => '')}
+        t={key => key}
+      />,
+    )
+    expect(view.container.innerHTML).toBe('')
+  })
+})

+ 792 - 0
packages/host/directory-picker-browse/tests/directory-browser.spec.tsx

@@ -0,0 +1,792 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import type { DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
+import { DirectoryBrowseError } from '@deepseek-ai/dsh-client-runtime/client'
+import { DirectoryBrowser } from '../src/client/DirectoryBrowser.tsx'
+
+afterEach(cleanup)
+
+const HOME = '/home/u'
+const DOCS = `${HOME}/Documents`
+const HARNESS = `${DOCS}/harness`
+
+/** Listing fake over a tiny fixed tree; unknown paths reject like the Host. */
+function listingFor(path?: string): DirectoryListing {
+  const target = path ?? HOME
+  const tree: Record<string, DirectoryListing> = {
+    [HOME]: {
+      path: HOME,
+      home: HOME,
+      crumbs: [
+        { name: '/', path: '/', hidden: false },
+        { name: 'home', path: '/home', hidden: false },
+        { name: 'u', path: HOME, hidden: false },
+      ],
+      entries: [
+        { name: '.config', path: `${HOME}/.config`, hidden: true },
+        { name: 'Documents', path: DOCS, hidden: false },
+      ],
+      truncated: false,
+    },
+    [DOCS]: {
+      path: DOCS,
+      home: HOME,
+      crumbs: [
+        { name: '/', path: '/', hidden: false },
+        { name: 'home', path: '/home', hidden: false },
+        { name: 'u', path: HOME, hidden: false },
+        { name: 'Documents', path: DOCS, hidden: false },
+      ],
+      entries: [{ name: 'harness', path: HARNESS, hidden: false }],
+      truncated: false,
+    },
+    [HARNESS]: {
+      path: HARNESS,
+      home: HOME,
+      crumbs: [
+        { name: '/', path: '/', hidden: false },
+        { name: 'home', path: '/home', hidden: false },
+        { name: 'u', path: HOME, hidden: false },
+        { name: 'Documents', path: DOCS, hidden: false },
+        { name: 'harness', path: HARNESS, hidden: false },
+      ],
+      entries: [],
+      truncated: false,
+    },
+  }
+  const found = tree[target]
+  if (found === undefined) {
+    throw new DirectoryBrowseError({ code: 'directory-unreadable', message: `cannot list ${target}`, details: { path: target } })
+  }
+  return found
+}
+
+function mount(overrides: Partial<Parameters<typeof DirectoryBrowser>[0]> = {}) {
+  const listDirectory = vi.fn(async (path?: string) => listingFor(path))
+  const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
+  const onOpen = vi.fn()
+  const onClose = vi.fn()
+  const props = {
+    open: true,
+    listDirectory,
+    createDirectory,
+    onOpen,
+    onClose,
+    busy: false,
+    t: (key: string, params?: Record<string, unknown>) => (params === undefined ? key : `${key}:${String(params.name)}`),
+    ...overrides,
+  }
+  const view = render(<DirectoryBrowser {...props} />)
+  return { view, props, listDirectory, createDirectory, onOpen, onClose }
+}
+
+/** The rendered level columns, left-to-right. */
+function columns(): HTMLElement[] {
+  return screen.getAllByRole('list')
+}
+
+/** The actionable button inside a listitem seat (rows keep native button semantics). */
+function rowButton(item: HTMLElement): HTMLButtonElement {
+  return within(item).getByRole<HTMLButtonElement>('button')
+}
+
+describe('DirectoryBrowser', () => {
+  it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    expect(b.listDirectory).toHaveBeenCalledWith(undefined, expect.any(AbortSignal))
+    expect(columns()).toHaveLength(1)
+    expect(screen.getByRole('listitem').textContent).toBe('Documents')
+    expect(screen.queryByText('.config')).toBeNull()
+    expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy()
+    expect(screen.queryByRole('button', { name: '/' })).toBeNull()
+  })
+
+  it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    const [level, preview] = columns()
+    const selectedRow = within(level!).getByRole('listitem')
+    expect(selectedRow.textContent).toBe('Documents')
+    expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true')
+    expect(within(preview!).getByRole('listitem').textContent).toBe('harness')
+    expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal))
+    expect(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })).toBeTruthy()
+  })
+
+  it('advances one level when a right-column row is picked', async () => {
+    mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    fireEvent.click(rowButton(within(columns()[1]!).getByRole('listitem')))
+    await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() })
+    const [level] = columns()
+    const selectedRow = within(level!).getByRole('listitem')
+    expect(selectedRow.textContent).toBe('harness')
+    expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true')
+  })
+
+  it('aborts a superseded listing on the wire, and the in-flight one on close', async () => {
+    const signals: (AbortSignal | undefined)[] = []
+    const gates: (() => void)[] = []
+    const listDirectory = vi.fn((path?: string, signal?: AbortSignal) => {
+      signals.push(signal)
+      if (signals.length === 1) return Promise.resolve(listingFor(path))
+      // Later listings hang until released: supersession must abort them
+      // on the wire, not merely discard their eventual results.
+      return new Promise<DirectoryListing>((resolve) => { gates.push(() => { resolve(listingFor(path)) }) })
+    })
+    const b = mount({ listDirectory })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    expect(signals).toHaveLength(2)
+    // A crumb jump supersedes the hanging preview: its request aborts.
+    fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
+    expect(signals[1]?.aborted).toBe(true)
+    expect(signals[2]?.aborted).toBe(false)
+    // Closing the dialog aborts the still-pending navigation too.
+    b.view.rerender(<DirectoryBrowser {...b.props} listDirectory={listDirectory} open={false} />)
+    expect(signals[2]?.aborted).toBe(true)
+  })
+
+  it('jumps back through a crumb into a fresh single-column level', async () => {
+    mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
+    await waitFor(() => { expect(columns()).toHaveLength(1) })
+    expect(screen.getByRole('listitem').textContent).toBe('Documents')
+    expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
+  })
+
+  it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
+    expect(b.onOpen).toHaveBeenCalledWith(HOME)
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
+    expect(b.onOpen).toHaveBeenLastCalledWith(DOCS)
+    fireEvent.click(screen.getByRole('button', { name: 'browser.cancel' }))
+    expect(b.onClose).toHaveBeenCalled()
+
+    const busy = mount({ busy: true })
+    await waitFor(() => { expect(busy.listDirectory).toHaveBeenCalled() })
+    expect(screen.getAllByRole<HTMLButtonElement>('button', { name: 'browser.open' }).at(-1)!.disabled).toBe(true)
+  })
+
+  it('edits the path from the crumb bar: Enter navigates, Escape restores, blank is ignored', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    expect(input.value).toBe(HOME)
+    fireEvent.change(input, { target: { value: DOCS } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
+    expect(columns()).toHaveLength(1)
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const again = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    fireEvent.change(again, { target: { value: '   ' } })
+    fireEvent.keyDown(again, { key: 'Enter' })
+    expect(b.listDirectory).toHaveBeenCalledTimes(2)
+    fireEvent.keyDown(again, { key: 'Escape' })
+    expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull()
+  })
+
+  it('restarts the home listing when Escape cancels an edit opened before any level listed', async () => {
+    // The initial home listing hangs; Edit Path supersedes it while parent
+    // is still null, and Escape must not strand a blank picker.
+    let settled = false
+    const gate = new Promise<never>(() => {})
+    const listDirectory = vi.fn(async (path?: string) => {
+      if (!settled) { settled = true; return gate }
+      return listingFor(path)
+    })
+    mount({ listDirectory })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    expect(input.value).toBe('')
+    fireEvent.keyDown(input, { key: 'Escape' })
+    // Cancellation relaunched the home listing instead of leaving neither
+    // rows nor status behind.
+    await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
+    expect(listDirectory).toHaveBeenCalledTimes(2)
+    expect(listDirectory).toHaveBeenLastCalledWith(undefined, expect.any(AbortSignal))
+  })
+
+  it('passes the entered path to the Host untrimmed (trim only gates blank drafts)', async () => {
+    const listDirectory = vi.fn(async (path?: string) => listingFor(path))
+    mount({ listDirectory })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText<HTMLInputElement>('browser.editPath')
+    fireEvent.change(input, { target: { value: `${DOCS} ` } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    // A trailing space may name a real directory; trimming would list its sibling.
+    await waitFor(() => { expect(listDirectory).toHaveBeenLastCalledWith(`${DOCS} `, expect.any(AbortSignal)) })
+  })
+
+  it('surfaces an unreadable target as an alert and keeps the edit open for correction', async () => {
+    mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText('browser.editPath')
+    fireEvent.change(input, { target: { value: '/nope' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('cannot list /nope') })
+    expect(screen.getByLabelText('browser.editPath')).toBeTruthy()
+    expect(screen.getByRole('listitem').textContent).toBe('Documents')
+  })
+
+  it('folds non-typed failures into readable text (Error message, String otherwise)', async () => {
+    const b = mount({ listDirectory: vi.fn(async () => { throw new Error('socket down') }) })
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('socket down') })
+    b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
+    const raw = mount({ listDirectory: vi.fn(async () => { throw 'raw failure' }) })
+    await waitFor(() => { expect(screen.getAllByRole('alert').at(-1)!.textContent).toBe('raw failure') })
+    expect(raw.onOpen).not.toHaveBeenCalled()
+  })
+
+  it('renders the full ancestry when the level sits outside the home subtree', async () => {
+    const outside: DirectoryListing = {
+      path: '/srv/data',
+      home: HOME,
+      crumbs: [
+        { name: '/', path: '/', hidden: false },
+        { name: 'srv', path: '/srv', hidden: false },
+        { name: 'data', path: '/srv/data', hidden: false },
+      ],
+      entries: [],
+      truncated: false,
+    }
+    mount({ listDirectory: vi.fn(async () => outside) })
+    await waitFor(() => { expect(screen.getByRole('button', { name: 'data' })).toBeTruthy() })
+    expect(screen.getByRole('button', { name: '/' })).toBeTruthy()
+    expect(screen.queryByRole('button', { name: 'browser.home' })).toBeNull()
+  })
+
+  it('scopes Escape to the topmost dialog: the nested create closes first, the browser only after', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    expect(screen.getByLabelText('browser.folderName')).toBeTruthy()
+    fireEvent.keyDown(document, { key: 'Escape' })
+    // The nested dialog consumed Escape; the browser stays up.
+    expect(screen.queryByLabelText('browser.folderName')).toBeNull()
+    expect(b.onClose).not.toHaveBeenCalled()
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(b.onClose).toHaveBeenCalledOnce()
+  })
+
+  it('keeps both dialogs open when Escape lands during an in-flight creation', async () => {
+    let resolve!: (path: string) => void
+    const createDirectory = vi.fn(() => new Promise<string>((settle) => { resolve = settle }))
+    const b = mount({ createDirectory })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'pending' } })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
+    fireEvent.keyDown(document, { key: 'Escape' })
+    // The in-flight fence holds the nested dialog, and the browser must not
+    // fall out from under it either.
+    expect(screen.getByLabelText('browser.folderName')).toBeTruthy()
+    expect(b.onClose).not.toHaveBeenCalled()
+    await act(async () => { resolve(`${HOME}/pending`) })
+  })
+
+  it('keeps New folder disabled while the post-create relist is still loading', async () => {
+    const pending: (() => void)[] = []
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    // Every listing after the create hangs until drained: the button must not
+    // offer a second create against a target the pending relist/select
+    // sequence is about to change.
+    const fresh: DirectoryListing = {
+      path: `${HOME}/fresh`, home: HOME,
+      crumbs: [...listingFor(HOME).crumbs, { name: 'fresh', path: `${HOME}/fresh`, hidden: false }],
+      entries: [],
+      truncated: false,
+    }
+    b.listDirectory.mockImplementation((path?: string) =>
+      new Promise<DirectoryListing>((settle) => {
+        pending.push(() => { settle(path === `${HOME}/fresh` ? fresh : listingFor(path)) })
+      }))
+    fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'fresh' } })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
+    await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
+    expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.newFolder' }).disabled).toBe(true)
+    // Drain the relist and the follow-up selection listing; only then does
+    // the affordance return.
+    await act(async () => { for (const settle of pending.splice(0)) settle() })
+    await act(async () => { for (const settle of pending.splice(0)) settle() })
+    expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.newFolder' }).disabled).toBe(false)
+  })
+
+  it('keeps path entry available when the home listing fails', async () => {
+    const listDirectory = vi.fn(async (): Promise<DirectoryListing> => {
+      throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'home unreadable', details: { path: HOME } })
+    })
+    mount({ listDirectory })
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('home unreadable') })
+    // With no listed level, typing an absolute path is the one way forward.
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText('browser.editPath')
+    fireEvent.change(input, { target: { value: DOCS } })
+    listDirectory.mockImplementation(async (path?: string) => listingFor(path))
+    fireEvent.keyDown(input, { key: 'Enter' })
+    await waitFor(() => { expect(screen.getByText('harness')).toBeTruthy() })
+  })
+
+  it('disables Open and New folder while a path draft is uncommitted', async () => {
+    mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    // targetPath still names the previous listing; committing actions must
+    // not act on it while a different path is displayed in the header.
+    expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' }).disabled).toBe(true)
+    expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.newFolder' }).disabled).toBe(true)
+    fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' })
+    expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' }).disabled).toBe(false)
+  })
+
+  it('ignores Enter while an IME composition is active in either input', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    // Path editor: a composing Enter confirms the candidate, not the path.
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const pathInput = screen.getByLabelText('browser.editPath')
+    fireEvent.change(pathInput, { target: { value: DOCS } })
+    const listCalls = b.listDirectory.mock.calls.length
+    fireEvent.compositionStart(pathInput)
+    fireEvent.keyDown(pathInput, { key: 'Enter' })
+    expect(b.listDirectory.mock.calls.length).toBe(listCalls)
+    fireEvent.compositionEnd(pathInput)
+    fireEvent.keyDown(pathInput, { key: 'Enter' })
+    await waitFor(() => { expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS, expect.any(AbortSignal)) })
+    // Create dialog: same guard.
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    const nameInput = screen.getByLabelText('browser.folderName')
+    fireEvent.change(nameInput, { target: { value: '新建' } })
+    fireEvent.compositionStart(nameInput)
+    fireEvent.keyDown(nameInput, { key: 'Enter' })
+    expect(b.createDirectory).not.toHaveBeenCalled()
+    fireEvent.compositionEnd(nameInput)
+    fireEvent.keyDown(nameInput, { key: 'Enter' })
+    await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(DOCS, '新建') })
+  })
+
+  it('surfaces a two-pane navigation failure as an alert below the columns', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    b.listDirectory.mockImplementation(async () => {
+      throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: HOME } })
+    })
+    fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'browser.home' }))
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
+    // Both panes survive the failure; the alert renders in the flow, not as a
+    // third column competing for the fixed widths.
+    expect(columns()).toHaveLength(2)
+  })
+
+  it('keeps the editor open when a pending listing settles right after Edit Path was clicked', async () => {
+    const pending: ((listing: DirectoryListing) => void)[] = []
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    // A crumb navigation hangs; the user opens the editor before it settles.
+    b.listDirectory.mockImplementation(() =>
+      new Promise<DirectoryListing>((settle) => { pending.push(settle) }))
+    fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'browser.home' }))
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    expect(screen.getByLabelText('browser.editPath')).toBeTruthy()
+    await act(async () => { pending.shift()!(listingFor(HOME)) })
+    // The superseded settlement must not close the editor underneath the user.
+    expect(screen.getByLabelText('browser.editPath')).toBeTruthy()
+  })
+
+  it('ignores a pending navigation that settles after Escape cancelled the editor', async () => {
+    const pending: ((listing: DirectoryListing) => void)[] = []
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText('browser.editPath')
+    b.listDirectory.mockImplementation(() =>
+      new Promise<DirectoryListing>((settle) => { pending.push(settle) }))
+    fireEvent.change(input, { target: { value: DOCS } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    fireEvent.keyDown(input, { key: 'Escape' })
+    // The cancelled navigation settling late must not jump the view to DOCS.
+    await act(async () => { pending.shift()!(listingFor(DOCS)) })
+    expect(screen.queryByText('harness')).toBeNull()
+    expect(screen.getByText('Documents')).toBeTruthy()
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
+  it('keeps a newer path edit when an older slow navigation settles', async () => {
+    const pending: ((listing: DirectoryListing) => void)[] = []
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText('browser.editPath')
+    b.listDirectory.mockImplementation(() =>
+      new Promise<DirectoryListing>((settle) => { pending.push(settle) }))
+    fireEvent.change(input, { target: { value: DOCS } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    // The user keeps typing while the lookup hangs; the older completion must
+    // neither clear this newer draft nor swap the view to the older path.
+    fireEvent.change(input, { target: { value: `${DOCS}/har` } })
+    await act(async () => { pending.shift()!(listingFor(DOCS)) })
+    expect(screen.getByLabelText<HTMLInputElement>('browser.editPath').value).toBe(`${DOCS}/har`)
+    expect(screen.queryByText('harness')).toBeNull()
+  })
+
+  it('keeps an intact selection preview when a path edit is cancelled', async () => {
+    mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' })
+    // Nothing was superseded: the two-pane view survives the cancel.
+    expect(columns()).toHaveLength(2)
+  })
+
+  it('falls back to the single-pane level when a path edit superseded the preview and was cancelled', async () => {
+    const pending: ((listing: DirectoryListing) => void)[] = []
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    // Selection starts a preview that never lands (superseded below).
+    b.listDirectory.mockImplementation(() =>
+      new Promise<DirectoryListing>((settle) => { pending.push(settle) }))
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' }))
+    const input = screen.getByLabelText('browser.editPath')
+    fireEvent.change(input, { target: { value: `${DOCS}/x` } })
+    fireEvent.keyDown(input, { key: 'Escape' })
+    // No half-empty two-pane residue: back to the single wide level.
+    expect(columns()).toHaveLength(1)
+    expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy()
+  })
+
+  it('drops a creation that settles after the browser unmounted', async () => {
+    let settleCreate!: (path: string) => void
+    const createDirectory = vi.fn(() => new Promise<string>((settle) => { settleCreate = settle }))
+    const b = mount({ createDirectory })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'slow' } })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
+    const listCalls = b.listDirectory.mock.calls.length
+    b.view.unmount()
+    // The dead flow must not issue the post-create relist.
+    await act(async () => { settleCreate(`${HOME}/slow`) })
+    expect(b.listDirectory.mock.calls.length).toBe(listCalls)
+  })
+
+  it('clears the selection when its preview listing fails', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    b.listDirectory.mockImplementation(async () => {
+      throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } })
+    })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
+    // The breadcrumb names the level, so the level must be the committing
+    // target: no half-selected two-pane state survives the failure.
+    expect(columns()).toHaveLength(1)
+    expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
+  })
+
+  it('ignores dismissal while adoption is busy', async () => {
+    const b = mount({ busy: true })
+    await waitFor(() => { expect(screen.getByRole('dialog')).toBeTruthy() })
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(b.onClose).not.toHaveBeenCalled()
+  })
+
+  it('makes every parent control inert while the nested create dialog is open', async () => {
+    mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    // Modal traps no focus: Shift-Tab/AT reach the parent, so closing,
+    // adopting, and retargeting must all disable underneath the child. Both
+    // dialogs carry a cancel: the parent's disables, the child's stays live.
+    const cancels = screen.getAllByRole<HTMLButtonElement>('button', { name: 'browser.cancel' })
+    expect(cancels.map(button => button.disabled).sort()).toEqual([false, true])
+    expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' }).disabled).toBe(true)
+    expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.editPath' }).disabled).toBe(true)
+    for (const row of screen.getAllByRole('listitem')) {
+      expect(rowButton(row).disabled).toBe(true)
+    }
+  })
+
+  it('drops a creation failure that lands after the flow closed and reopened', async () => {
+    let rejectCreate!: (reason: unknown) => void
+    const createDirectory = vi.fn(() => new Promise<string>((_settle, reject) => { rejectCreate = reject }))
+    const b = mount({ createDirectory })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'slow' } })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
+    b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
+    b.view.rerender(<DirectoryBrowser {...b.props} open />)
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    // The stale failure must not surface an alert inside the fresh flow.
+    await act(async () => { rejectCreate(new Error('too late')) })
+    expect(screen.queryByText('too late')).toBeNull()
+  })
+
+  it('drops a creation that settles after the flow closed and reopened', async () => {
+    let settleCreate!: (path: string) => void
+    const createDirectory = vi.fn(() => new Promise<string>((settle) => { settleCreate = settle }))
+    const b = mount({ createDirectory })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'slow' } })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.create' }))
+    b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
+    b.view.rerender(<DirectoryBrowser {...b.props} open />)
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    const listCallsBefore = b.listDirectory.mock.calls.length
+    // The stale settlement must not relist the old target or reopen the
+    // nested dialog's state inside the fresh flow.
+    await act(async () => { settleCreate(`${HOME}/slow`) })
+    expect(b.listDirectory.mock.calls.length).toBe(listCallsBefore)
+    expect(screen.queryByLabelText('browser.folderName')).toBeNull()
+    expect(screen.getByText('Documents')).toBeTruthy()
+  })
+
+  it('passes the folder name to the Host untrimmed (trim only gates blank drafts)', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    const input = screen.getByLabelText('browser.folderName')
+    fireEvent.change(input, { target: { value: 'project ' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    // A trailing space may be the wanted spelling; trimming would create a sibling.
+    await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(HOME, 'project ') })
+  })
+
+  it('creates a folder through the nested dialog and lands with it selected', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    // The nested dialog names the create target (the selected folder).
+    expect(screen.getByText('browser.createIn:Documents')).toBeTruthy()
+    // The created folder becomes listable (like the real backend after mkdir).
+    b.listDirectory.mockImplementation(async (path?: string) => {
+      if (path === `${DOCS}/fresh`) {
+        return {
+          path: `${DOCS}/fresh`, home: HOME,
+          crumbs: [...listingFor(DOCS).crumbs, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }],
+          entries: [],
+          truncated: false,
+        }
+      }
+      if (path === DOCS) {
+        const docs = listingFor(DOCS)
+        return { ...docs, entries: [...docs.entries, { name: 'fresh', path: `${DOCS}/fresh`, hidden: false }] }
+      }
+      return listingFor(path)
+    })
+    const input = screen.getByLabelText('browser.folderName')
+    fireEvent.change(input, { target: { value: 'fresh' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(DOCS, 'fresh') })
+    // The create target became the level and the new folder its selection.
+    await waitFor(() => {
+      expect(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })).toBeTruthy()
+      const level = columns()[0]!
+      const rows = within(level).getAllByRole('listitem')
+      expect(rows.some(row => row.textContent === 'fresh' && rowButton(row).getAttribute('aria-current') === 'true')).toBe(true)
+    })
+  })
+
+  it('keeps the nested dialog open on a creation failure and cancels cleanly', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    b.createDirectory.mockRejectedValueOnce(
+      new DirectoryBrowseError({ code: 'directory-exists', message: 'taken already', details: { path: `${HOME}/x` } }))
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    expect(screen.getByText('browser.createIn:browser.home')).toBeTruthy()
+    const input = screen.getByLabelText('browser.folderName')
+    // A blank name never submits.
+    fireEvent.change(input, { target: { value: '   ' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    expect(b.createDirectory).not.toHaveBeenCalled()
+    fireEvent.change(input, { target: { value: 'x' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('taken already') })
+    fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' })
+    await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
+
+    // The nested Cancel button and the nested mask both close only the child dialog.
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    const nested = screen.getByRole('dialog', { name: 'browser.newFolder' })
+    fireEvent.click(within(nested).getByRole('button', { name: 'browser.cancel' }))
+    await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    const masks = document.querySelectorAll('[aria-hidden="true"]')
+    fireEvent.click(masks[masks.length - 1]!)
+    await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
+    expect(screen.getByRole('dialog', { name: 'browser.title' })).toBeTruthy()
+  })
+
+  it('surfaces a post-create relist failure on the browser surface', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    // Creation succeeds, but relisting the target fails afterwards.
+    b.listDirectory.mockRejectedValueOnce(new Error('level vanished'))
+    const input = screen.getByLabelText('browser.folderName')
+    fireEvent.change(input, { target: { value: 'fresh' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('level vanished') })
+  })
+
+  it('drops a stale child listing that resolves after a crumb jump', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    let resolveSlow!: (value: DirectoryListing) => void
+    const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
+    b.listDirectory.mockReturnValueOnce(slow)
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
+    await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
+    await waitFor(() => { expect(columns()).toHaveLength(1) })
+    resolveSlow(listingFor(DOCS))
+    await new Promise(settle => setTimeout(settle, 0))
+    // The superseded selection preview did not reopen the second pane.
+    expect(columns()).toHaveLength(1)
+  })
+
+  it('drops a stale failure that rejects after a newer navigation', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    let rejectSlow!: (reason: unknown) => void
+    const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail })
+    b.listDirectory.mockReturnValueOnce(slow)
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
+    await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
+    rejectSlow(new Error('too late to matter'))
+    await new Promise(settle => setTimeout(settle, 0))
+    expect(screen.queryByRole('alert')).toBeNull()
+    expect(screen.getByRole('listitem').textContent).toBe('Documents')
+  })
+
+  it('drops a stale navigation failure that rejects after a newer jump', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    let rejectSlow!: (reason: unknown) => void
+    const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail })
+    b.listDirectory.mockReturnValueOnce(slow)
+    // A slow crumb jump superseded by a second jump.
+    fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
+    fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' }))
+    await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(4) })
+    rejectSlow(new Error('late nav failure'))
+    await new Promise(settle => setTimeout(settle, 0))
+    expect(screen.queryByRole('alert')).toBeNull()
+  })
+
+  it('drops a stale navigation listing that resolves after a newer jump', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    let resolveSlow!: (value: DirectoryListing) => void
+    const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
+    b.listDirectory.mockReturnValueOnce(slow)
+    fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
+    fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' }))
+    await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
+    resolveSlow(listingFor(undefined))
+    await new Promise(settle => setTimeout(settle, 0))
+    // The stale home listing did not replace the newer Documents level.
+    expect(screen.getByRole('listitem').textContent).toBe('harness')
+  })
+
+  it('names the create target by its path when the level reports no crumbs', async () => {
+    const bare: DirectoryListing = { path: '/srv/data', home: HOME, crumbs: [], entries: [], truncated: false }
+    mount({ listDirectory: vi.fn(async () => bare) })
+    await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.newFolder' })).toBeTruthy() })
+    await waitFor(() => {
+      expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.newFolder' }).disabled).toBe(false)
+    })
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    expect(screen.getByText('browser.createIn:/srv/data')).toBeTruthy()
+  })
+
+  it('refuses to close the nested dialog while the creation is in flight', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    let settleCreate!: (path: string) => void
+    b.createDirectory.mockReturnValueOnce(new Promise<string>((settle) => { settleCreate = settle }))
+    fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
+    const input = screen.getByLabelText('browser.folderName')
+    fireEvent.change(input, { target: { value: 'slow' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    // Escape and the mask are both inert while creating.
+    fireEvent.keyDown(screen.getByLabelText('browser.folderName'), { key: 'Escape' })
+    const masks = document.querySelectorAll('[aria-hidden="true"]')
+    fireEvent.click(masks[masks.length - 1]!)
+    expect(screen.getByLabelText('browser.folderName')).toBeTruthy()
+    settleCreate(`${HOME}/slow`)
+    await waitFor(() => { expect(screen.queryByLabelText('browser.folderName')).toBeNull() })
+  })
+
+  it('says a level is incomplete when the backend cut it at its bound', async () => {
+    const cut = { ...listingFor(HOME), truncated: true }
+    mount({ listDirectory: vi.fn(async () => cut) })
+    await screen.findByText('browser.truncated')
+  })
+
+  it('flags a truncated child preview under a complete level', async () => {
+    mount({
+      listDirectory: vi.fn(async (path?: string) =>
+        (path === DOCS ? { ...listingFor(DOCS), truncated: true } : listingFor(path))),
+    })
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    expect(screen.queryByText('browser.truncated')).toBeNull()
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    await screen.findByText('browser.truncated')
+  })
+
+  it('pins the child pane into view when its preview lands (narrow viewports scroll the miller row)', async () => {
+    mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    const row = document.querySelector('[class*=millerRow]') as HTMLElement
+    // jsdom does no layout: stub the overflow width the effect pins against.
+    Object.defineProperty(row, 'scrollWidth', { value: 640, configurable: true })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    await waitFor(() => { expect(row.scrollLeft).toBe(640) })
+  })
+
+  it('starts back at home on reopen', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(rowButton(screen.getByRole('listitem')))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
+    b.view.rerender(<DirectoryBrowser {...b.props} open />)
+    await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') })
+    expect(columns()).toHaveLength(1)
+    expect(b.listDirectory).toHaveBeenLastCalledWith(undefined, expect.any(AbortSignal))
+  })
+})

+ 18 - 6
packages/host/directory-picker-browse/tsconfig.json

@@ -1,24 +1,36 @@
 {
-  "extends": "../../../tsconfig.base.json",
+  "extends": "../../../tsconfig.base.client.json",
   "compilerOptions": {
     "rootDir": "src",
-    "outDir": "lib/types"
+    "outDir": "lib/types",
+    "types": [
+      "node"
+    ]
   },
   "include": [
     "src"
   ],
   "references": [
     {
-      "path": "../../../vendor/cosmokit"
+      "path": "../directory-picker"
     },
     {
-      "path": "../../../vendor/cordis"
+      "path": "../../support/invariants"
     },
     {
-      "path": "../directory-picker"
+      "path": "../../client/ui-slots"
     },
     {
-      "path": "../../support/invariants"
+      "path": "../../client/ui-primitives"
+    },
+    {
+      "path": "../../client/locale"
+    },
+    {
+      "path": "../../client/runtime"
+    },
+    {
+      "path": "../../client/ui-workspace"
     }
   ]
 }

+ 3 - 0
packages/host/directory-picker-browse/tsdown.config.ts

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

+ 27 - 0
pnpm-lock.yaml

@@ -227,6 +227,9 @@ importers:
       '@deepseek-ai/dsh-host-apiproxy':
         specifier: workspace:^
         version: link:../../packages/host/apiproxy
+      '@deepseek-ai/dsh-host-directory-picker-browse':
+        specifier: workspace:^
+        version: link:../../packages/host/directory-picker-browse
       '@deepseek-ai/dsh-host-directory-picker-native':
         specifier: workspace:^
         version: link:../../packages/host/directory-picker-native
@@ -2894,16 +2897,40 @@ importers:
       '@deepseek-ai/dsh-host-directory-picker':
         specifier: workspace:^
         version: link:../directory-picker
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
       schemastery:
         specifier: ^3.18.0
         version: 3.18.0
     devDependencies:
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../../client/locale
+      '@deepseek-ai/dsh-client-runtime':
+        specifier: workspace:^
+        version: link:../../client/runtime
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../../client/ui-primitives
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../../client/ui-slots
+      '@deepseek-ai/dsh-client-ui-workspace':
+        specifier: workspace:^
+        version: link:../../client/ui-workspace
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
       cordis:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
 
   packages/host/directory-picker-native:
     dependencies:

+ 3 - 0
tsconfig.client.json

@@ -19,6 +19,8 @@
     "packages/client/*/src/css-modules.d.ts",
     "packages/client/*/tests/**/*.ts",
     "packages/client/*/tests/**/*.tsx",
+    "packages/host/directory-picker-browse/tests/**/*.ts",
+    "packages/host/directory-picker-browse/tests/**/*.tsx",
     "packages/host/directory-picker-native/tests/**/*.ts",
     "packages/host/directory-picker-native/tests/**/*.tsx",
     "packages/client/tsdown.client.ts",
@@ -33,6 +35,7 @@
     // browser half registers the picking flow into ui-workspace's slot —
     // client-side Context merges keep it out of the host program.
     { "path": "./packages/host/directory-picker-native" },
+    { "path": "./packages/host/directory-picker-browse" },
     { "path": "./packages/client/ui-slots" },
     { "path": "./packages/client/ui-primitives" },
     { "path": "./packages/client/web-react" },

+ 1 - 1
tsconfig.host.json

@@ -33,6 +33,7 @@
   ],
   "exclude": [
     "packages/client/**",
+    "packages/host/directory-picker-browse/**",
     "packages/host/directory-picker-native/**",
     "scripts/client-bundle-purity.spec.ts"
   ],
@@ -169,7 +170,6 @@
     { "path": "./packages/mcp/mcp-client" },
     { "path": "./packages/host/apiproxy" },
     { "path": "./packages/host/directory-picker" },
-    { "path": "./packages/host/directory-picker-browse" },
     { "path": "./packages/host/webserver" },
     { "path": "./packages/sdk/sdk-client" },
     { "path": "./packages/sdk/helper" },