Răsfoiți Sursa

feat(host,client): ship the in-app directory browser as the browse package's client half

directory-picker-browse becomes dual-face: its browser half fills
ui-workspace's two directory-flow holes with the Select Workspace Directory
dialog (figma Harness 813-23126 family — Miller two-column view, breadcrumb
with click-to-edit path zone, nested New-folder dialog), driving the node
half's host.listDirectory/host.createDirectory and owning its locale
namespace (directory-browser, zh default / en). The dialog moves here from
ui-workspace wholesale — the trigger surfaces keep only the flow-hole owner
conversation.

apps/cli flips its one directory-picker row -native -> -browse, swapping the
host backend and the client interaction together; picking now works for
remote deployments out of the box. The keyless workspace-flow snapshot boots
the browse bundle and drives menu -> dialog -> Documents -> project -> Open
against the fixture tree.
creatixchu 1 lună în urmă
părinte
comite
4822622cb7
26 a modificat fișierele cu 1419 adăugiri și 51 ștergeri
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md
  4. 2 2
      apps/cli/cordis.yml
  5. 1 1
      apps/cli/package.json
  6. 11 8
      apps/web/tests/workspace-flow.snapshot.ts
  7. 18 12
      docs/module-graph.md
  8. 0 3
      packages/client/ui-workspace/package.json
  9. 2 2
      packages/host/README.i18n.yaml
  10. 1 1
      packages/host/README.md
  11. 1 1
      packages/host/README.zh.md
  12. 2 2
      packages/host/directory-picker-browse/README.i18n.yaml
  13. 2 0
      packages/host/directory-picker-browse/README.md
  14. 2 0
      packages/host/directory-picker-browse/README.zh.md
  15. 30 3
      packages/host/directory-picker-browse/package.json
  16. 269 0
      packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css
  17. 376 0
      packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx
  18. 109 0
      packages/host/directory-picker-browse/src/client/index.ts
  19. 6 0
      packages/host/directory-picker-browse/src/css-modules.d.ts
  20. 127 0
      packages/host/directory-picker-browse/tests/client-flow.spec.tsx
  21. 405 0
      packages/host/directory-picker-browse/tests/directory-browser.spec.tsx
  22. 18 6
      packages/host/directory-picker-browse/tsconfig.json
  23. 3 0
      packages/host/directory-picker-browse/tsdown.config.ts
  24. 26 5
      pnpm-lock.yaml
  25. 3 0
      tsconfig.client.json
  26. 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: ce5a2695345e29db5739df206965720559783ce3
-2026-07-28-directory-picker-capability-seam.zh.md: 5b73c3c48493d4f178a523db19bc124eda9c7cca
+2026-07-28-directory-picker-capability-seam.md: df31b43e0678815847a48844b69928bdd1bb5175
+2026-07-28-directory-picker-capability-seam.zh.md: 0840c020d4826c74583f100251b6f3352a3b4b4a

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

@@ -32,7 +32,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.

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

@@ -32,7 +32,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`。

+ 2 - 2
apps/cli/cordis.yml

@@ -243,9 +243,9 @@
 # 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: mount
-# '-browse' instead for the in-app browser (remote-capable).
+# '-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 - 1
apps/cli/package.json

@@ -48,7 +48,7 @@
     "@deepseek-ai/dsh-fs-local": "workspace:^",
     "@deepseek-ai/dsh-fs-policy": "workspace:^",
     "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
-    "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
+    "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-llm-deepseek": "workspace:^",

+ 11 - 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,12 @@ 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: '选择工作区目录' })
+  fireEvent.click(await within(dialog).findByRole('listitem', { name: /Documents/ }))
+  fireEvent.click(await within(dialog).findByRole('listitem', { name: /^project/ }))
+  fireEvent.click(within(dialog).getByRole('button', { name: '打开' }))
   await findHeroComposer()
   await waitFor(() => {
     expect(visibleText(screen.getByRole('tree', { name: 'Sessions' }))).toContain('project')

+ 18 - 12
docs/module-graph.md

@@ -266,7 +266,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
@@ -296,10 +295,6 @@ flowchart TD
   pkg_client_ui_slash --> pkg_client_runtime
   pkg_client_ui_slash --> pkg_client_ui_slots
   pkg_client_ui_slash --> pkg_invariants
-  pkg_client_ui_workspace --> pkg_client_runtime
-  pkg_client_ui_workspace --> pkg_client_ui_primitives
-  pkg_client_ui_workspace --> pkg_client_ui_slots
-  pkg_client_ui_workspace --> pkg_invariants
   pkg_helper --> pkg_brand
   pkg_helper --> pkg_invariants
   pkg_helper --> pkg_subprocess
@@ -354,10 +349,11 @@ 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_native --> pkg_client_runtime
-  pkg_host_directory_picker_native --> pkg_client_ui_slots
-  pkg_host_directory_picker_native --> pkg_client_ui_workspace
-  pkg_host_directory_picker_native --> pkg_invariants
+  pkg_client_ui_workspace --> pkg_client_locale
+  pkg_client_ui_workspace --> pkg_client_runtime
+  pkg_client_ui_workspace --> pkg_client_ui_primitives
+  pkg_client_ui_workspace --> pkg_client_ui_slots
+  pkg_client_ui_workspace --> pkg_invariants
   pkg_lsp --> pkg_brand
   pkg_lsp --> pkg_invariants
   pkg_lsp --> pkg_llm
@@ -422,6 +418,16 @@ flowchart TD
   pkg_code_runtime_worker --> pkg_invariants
   pkg_code_runtime_worker --> pkg_session
   pkg_code_runtime_worker --> pkg_timeout
+  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
+  pkg_host_directory_picker_native --> pkg_invariants
   pkg_lsp_local --> pkg_brand
   pkg_lsp_local --> pkg_invariants
   pkg_lsp_local --> pkg_llm
@@ -946,7 +952,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) |
@@ -958,7 +963,6 @@ flowchart TD
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
-| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
 | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
 | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
@@ -975,7 +979,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-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) |
+| [`client-ui-workspace`](../packages/client/ui-workspace) | `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) |
 | [`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) |
 | [`token-meter`](../packages/llm/token-meter) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -995,6 +999,8 @@ flowchart TD
 | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) |
 | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
+| [`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-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
 | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
 | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |

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

@@ -25,7 +25,6 @@
   "dshClient": {
     "inject": [
       "@deepseek-ai/dsh-client-runtime",
-      "@deepseek-ai/dsh-client-locale",
       "@deepseek-ai/dsh-client-ui-conversation",
       "@deepseek-ai/dsh-client-ui-sidebar"
     ],
@@ -40,7 +39,6 @@
     "clsx": "^2.0.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",
@@ -49,7 +47,6 @@
     "react": "^18.2.0"
   },
   "devDependencies": {
-    "@deepseek-ai/dsh-client-locale": "workspace:^",
     "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",

+ 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: 688a60894cb0ab066a6d501e4df310f8d31bfb5f
-README.zh.md: c19bccc2ff9268cb7a6c931da4671bebc9f76b0c
+README.md: 5cec04b68c4c81c4ab1db4b2a5709b5f56139dc0
+README.zh.md: 1a26b496c1b30c5c90c5b7f123292a1c57ddb74a

+ 2 - 0
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. 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.

+ 2 - 0
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 或当前盘符之下。失败抛出 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 宿主的目录选择;这里没有任何内容进入模型请求。

+ 30 - 3
packages/host/directory-picker-browse/package.json

@@ -15,26 +15,53 @@
       "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"
   ],
   "license": "BSD-3-Clause",
   "dependencies": {
-    "@deepseek-ai/dsh-host-directory-picker": "workspace:^"
+    "@deepseek-ai/dsh-host-directory-picker": "workspace:^",
+    "clsx": "^2.0.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"
   }
 }

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

@@ -0,0 +1,269 @@
+/* Directory-browser dialog (figma 813-23126 family). The shared Modal renders
+ * headless here — mask, card, Escape only — and this module owns the figma
+ * frame exactly: fixed 600×420 card, 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. */
+.dialog.dialog {
+  width: min(600px, 100%);
+  height: 420px;
+  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;
+}
+
+.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 1 0;
+  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;
+  align-items: stretch;
+  flex: 1 1 0;
+  min-height: 0;
+  gap: 20px;
+  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);
+}
+
+.row {
+  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;
+  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;
+}

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

@@ -0,0 +1,376 @@
+/**
+ * The in-app workspace-directory browser (figma Harness 813-23126 family): a
+ * fixed 600×420 dialog 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). */
+  listDirectory: (path?: string) => 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 (
+          <button
+            key={entry.path}
+            type="button"
+            role="listitem"
+            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>
+        )
+      })}
+    </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)
+
+  /** Replace the whole view with one freshly listed level (no selection). */
+  const navigate = useCallback((path?: string) => {
+    const seq = ++requestSeq.current
+    setLoading(true)
+    setError(null)
+    listDirectory(path).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))
+    })
+  }, [listDirectory])
+
+  /** Select a row of the listed level and preview its children on the right. */
+  const select = useCallback((entry: DirectoryEntry) => {
+    const seq = ++requestSeq.current
+    setSelected(entry)
+    setChild(null)
+    setLoading(true)
+    setError(null)
+    listDirectory(entry.path).then((next) => {
+      if (seq !== requestSeq.current) return
+      setChild(next)
+      setLoading(false)
+    }, (reason: unknown) => {
+      if (seq !== requestSeq.current) return
+      setLoading(false)
+      setError(failureText(reason))
+    })
+  }, [listDirectory])
+
+  /** 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(() => {
+    if (open) {
+      setParent(null)
+      setSelected(null)
+      setChild(null)
+      navigate()
+      return
+    }
+    requestSeq.current += 1
+    setError(null)
+    setPathDraft(null)
+    setFolderDraft(null)
+    setCreateError(null)
+  }, [open, navigate])
+
+  /** 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
+    const name = folderDraft.trim()
+    if (name === '') return
+    setCreatingFolder(true)
+    setCreateError(null)
+    createDirectory(targetPath, name).then((createdPath) => {
+      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 = ++requestSeq.current
+      setLoading(true)
+      listDirectory(targetPath).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) => {
+      setCreatingFolder(false)
+      setCreateError(failureText(reason))
+    })
+  }
+
+  // After the hooks: a closed dialog renders nothing and evaluates no copy.
+  if (!open) return null
+
+  const crumbSource = child ?? parent
+  const crumbs = crumbSource === null ? [] : displayCrumbs(crumbSource, t('browser.home'))
+  const twoPane = selected !== null
+
+  return (
+    <Modal
+      open={open}
+      onClose={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
+            ? (
+              <>
+                {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={busy}
+                      onClick={() => { navigate(crumb.path) }}
+                    >
+                      {crumb.name}
+                    </button>
+                  </span>
+                ))}
+                {/* The empty zone right of the crumbs is the path-edit affordance. */}
+                <button
+                  type="button"
+                  className={css.crumbEditZone}
+                  aria-label={t('browser.editPath')}
+                  disabled={parent === null || busy}
+                  /* v8 ignore next -- narrowing guard: the zone disables while the level is null. */
+                  onClick={() => { if (parent !== null) setPathDraft(selected?.path ?? parent.path) }}
+                />
+              </>
+            )
+            : (
+              <input
+                className={css.pathInput}
+                value={pathDraft}
+                aria-label={t('browser.editPath')}
+                autoFocus
+                disabled={busy}
+                onChange={(event) => { setPathDraft(event.target.value) }}
+                onKeyDown={(event) => {
+                  if (event.key === 'Enter') {
+                    event.preventDefault()
+                    const target = pathDraft.trim()
+                    if (target !== '') navigate(target)
+                  }
+                  if (event.key === 'Escape') {
+                    event.stopPropagation()
+                    setPathDraft(null)
+                    setError(null)
+                  }
+                }}
+              />
+            )}
+        </div>
+      </div>
+      <div className={css.content}>
+        {parent !== null && (
+          <LevelColumn
+            entries={parent.entries}
+            selectedPath={selected?.path ?? null}
+            busy={busy}
+            onPick={select}
+            wide={!twoPane}
+          />
+        )}
+        {twoPane && <span className={css.divider} />}
+        {twoPane && child !== null && (
+          <LevelColumn
+            entries={child.entries}
+            selectedPath={null}
+            busy={busy}
+            onPick={advance}
+            wide={false}
+          />
+        )}
+        {loading && <div className={css.status} role="status">{t('browser.loading')}</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 || busy || folderDraft !== null}
+          onClick={() => {
+            setFolderDraft('')
+            setCreateError(null)
+          }}
+        >
+          {t('browser.newFolder')}
+        </Button>
+        <span className={css.footerGap} />
+        <Button variant="outline" className={clsx(css.footerAction)} disabled={busy} onClick={onClose}>{t('browser.cancel')}</Button>
+        <Button
+          variant="primary"
+          className={clsx(css.footerAction)}
+          disabled={targetPath === null || loading || busy}
+          /* 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) }}
+            onKeyDown={(event) => {
+              if (event.key === 'Enter') {
+                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>
+  )
+}

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

@@ -0,0 +1,109 @@
+/**
+ * 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 { createElement } from 'react'
+import type { ReactElement } from 'react'
+import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ClientContext, DirectoryListing } from '@deepseek-ai/dsh-client-runtime/client'
+import type { Translate } from '@deepseek-ai/dsh-client-locale/client'
+// Type-only: the SlotMap merge declaring the directory-flow holes and their owner contract.
+import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
+import { DirectoryBrowser } from './DirectoryBrowser.tsx'
+
+/** Locale namespace owning the browser dialog's copy. */
+const LOCALE_NS = 'directory-browser'
+
+/** Injected face: the browse wire calls and copy the dialog drives (bound in apply's closure). */
+interface BrowseFlowInjected {
+  /** List one directory level (absent path = the Host home directory). */
+  listDirectory: (path?: string) => 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,
+  })
+}
+
+/** 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(() => {
+    const disposers = [
+      ctx.locale.register(LOCALE_NS, 'zh', {
+        'browser.title': '选择工作区目录',
+        'browser.home': '主目录',
+        'browser.newFolder': '新建文件夹',
+        'browser.folderName': '文件夹名称',
+        'browser.createIn': '在"{name}"中新建文件夹',
+        'browser.untitledFolder': '未命名文件夹',
+        'browser.create': '创建',
+        'browser.cancel': '取消',
+        'browser.open': '打开',
+        'browser.editPath': '编辑路径',
+        'browser.loading': '加载中…',
+      }),
+      ctx.locale.register(LOCALE_NS, '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…',
+      }),
+    ]
+    return () => { for (const dispose of disposers) dispose() }
+  }, 'directory-picker-browse: dialog dictionaries')
+
+  const injected = (): BrowseFlowInjected => ({
+    listDirectory: path => ctx.workspaces.listDirectory(path),
+    createDirectory: (path, name) => ctx.workspaces.createDirectory(path, name),
+    t: ctx.locale.bind(LOCALE_NS),
+  })
+  ctx.effect(() => {
+    const deferred = [
+      deferRegistration(ctx.slots, 'conversation.hero.workspace.directoryFlow', BrowseDirectoryFlow, () =>
+        ctx.slots.register({ name: 'conversation.hero.workspace.directoryFlow', inject: injected }, BrowseDirectoryFlow)),
+      deferRegistration(ctx.slots, 'sidebar.workspaces.directoryFlow', BrowseDirectoryFlow, () =>
+        ctx.slots.register({ name: 'sidebar.workspaces.directoryFlow', inject: injected }, BrowseDirectoryFlow)),
+    ]
+    return () => { for (const entry of deferred) entry.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'

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

@@ -0,0 +1,127 @@
+// @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, BrowseDirectoryFlow, inject } from '../src/client/index.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 }],
+}
+
+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('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('')
+  })
+})

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

@@ -0,0 +1,405 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { 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 },
+      ],
+    },
+    [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 }],
+    },
+    [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: [],
+    },
+  }
+  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')
+}
+
+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(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(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(selectedRow.getAttribute('aria-current')).toBe('true')
+    expect(within(preview!).getByRole('listitem').textContent).toBe('harness')
+    expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS)
+    expect(screen.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(screen.getByRole('listitem'))
+    await waitFor(() => { expect(columns()).toHaveLength(2) })
+    fireEvent.click(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(selectedRow.getAttribute('aria-current')).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(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(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(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('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: [],
+    }
+    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('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(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: [],
+        }
+      }
+      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(screen.getByRole('button', { name: 'Documents' })).toBeTruthy()
+      const level = columns()[0]!
+      const rows = within(level).getAllByRole('listitem')
+      expect(rows.some(row => row.textContent === 'fresh' && 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 selection-preview failure while keeping the selection marked', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    b.listDirectory.mockRejectedValueOnce(
+      new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } }))
+    fireEvent.click(screen.getByRole('listitem'))
+    await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
+    expect(screen.getByRole('listitem').getAttribute('aria-current')).toBe('true')
+    // No preview column arrived for the failed selection.
+    expect(columns()).toHaveLength(1)
+  })
+
+  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(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(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(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(screen.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(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(screen.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: [] }
+    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('starts back at home on reopen', async () => {
+    const b = mount()
+    await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
+    fireEvent.click(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)
+  })
+})

+ 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'])

+ 26 - 5
pnpm-lock.yaml

@@ -212,9 +212,9 @@ importers:
       '@deepseek-ai/dsh-host-apiproxy':
         specifier: workspace:^
         version: link:../../packages/host/apiproxy
-      '@deepseek-ai/dsh-host-directory-picker-native':
+      '@deepseek-ai/dsh-host-directory-picker-browse':
         specifier: workspace:^
-        version: link:../../packages/host/directory-picker-native
+        version: link:../../packages/host/directory-picker-browse
       '@deepseek-ai/dsh-host-webserver':
         specifier: workspace:^
         version: link:../../packages/host/webserver
@@ -1412,9 +1412,6 @@ importers:
         specifier: ^2.0.0
         version: 2.1.1
     devDependencies:
-      '@deepseek-ai/dsh-client-locale':
-        specifier: workspace:^
-        version: link:../locale
       '@deepseek-ai/dsh-client-runtime':
         specifier: workspace:^
         version: link:../runtime
@@ -2746,13 +2743,37 @@ importers:
       '@deepseek-ai/dsh-host-directory-picker':
         specifier: workspace:^
         version: link:../directory-picker
+      clsx:
+        specifier: ^2.0.0
+        version: 2.1.1
     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"
   ],
@@ -168,7 +169,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" },