Merge origin/master into fix/web-details-session-lifecycle

This commit is contained in:
NI0317
2026-07-29 11:21:48 +08:00
222 changed files with 8347 additions and 1465 deletions

View File

@@ -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/README.md
README.md: f5420b6f2f30837b030a0e832a438c34674a6f23
README.zh.md: 7beeaadf380a742cbdb6447553f42692a97fad10
README.md: 7a86e0f034264d4059e75775016d8d5d84600d8d
README.zh.md: bfcba626bea2a70f5c2aa508bb2a5b8c09bb61dc

View File

@@ -44,6 +44,8 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
| [`ui/`](ui/README.md) | TUI and JSON-RPC integrations, approval/interaction seams, ask-user tool | Product — stable surface |
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |

View File

@@ -44,6 +44,8 @@
| [`sdk/`](sdk/README.md) | 项目 SDK 工具 | 产品:稳定表面 |
| [`acp/`](acp/README.md) | 仅面向自动化的 Agent Client Protocol 服务器 | 产品:稳定表面 |
| [`ui/`](ui/README.md) | TUI 与 JSON-RPC 集成、批准/交互 seam、用户问答工具 | 产品:稳定表面 |
| [`host/`](host/README.md) | web GUI 宿主半侧API 网关 + HTTP 路由服务器 | 产品:稳定表面 |
| [`client/`](client/README.md) | web GUI 浏览器半侧shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定表面 |
| [`examples/`](examples/README.md) | 演示组合包agent-spine + TUI/CLI/ACP/JSON-RPC bin由叶节点加载 | 支持:示例基础设施 |
| [`support/`](support/README.md) | 支持基础设施testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 |
| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded<B>`、Harness home路径辅助函数、超时、保留策略 | 支持:小型、稳定、无 harness 依赖 |

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 965ae25a5e29a4f767adfcb73e4a77f1060e4b46
README.zh.md: 60be5c5ca5624719f5ca651a78b6ba56f3f3df06
# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md
README.md: deb6b899c81cb8c335b4c1cffdde4797e0a8be92
README.zh.md: c2514308fb9f234e6d191a6b1a821ac3d195378b

View File

@@ -57,7 +57,7 @@ When `run_in_background` is true, this plugin preflights `ctx.tasks.start()` bef
## UI presentation
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, raw output, and parsed exit status. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe.
The tool owns its `presentCall`/`presentResult` render intent. A foreground call is a terminal card carrying command, description, cwd, output, and parsed exit status. Because the card shows the exit as its own pill, the `[exit code: N]` / `[killed by signal: …]` marker the parse consumes leaves the output; every other marker (truncation, timeout, sandbox) stays in it. A background start is a generic execute card because it returns only a task id; the generic `task_*` tools own their own cards. These presenters are pure and replay-safe.
## The tool builds its request from named args only
@@ -153,6 +153,6 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay; a display-only known residual.
- **Replay exit pills parse from result text** — output whose final line happens to be exactly `[exit code: N]` / `[killed by signal: …]` shows a wrong pill on session replay and loses that line from the card body, because the parse treats it as the marker it consumes; a display-only known residual.
- **The `bash` tool opts out of `timeout-policy` budgets** — it keeps the executor-owned `BASH_TIMEOUT` path, per [the tool-call timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md).
- **Background processes have no executor timeout** — callers must use `task_kill`, or rely on owner/service disposal, when work no longer matters.

View File

@@ -57,7 +57,7 @@ overlay 根据当前 `ToolExecution` 计算,并通过专用的 `BashExecReques
## UI 展示
工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片包含命令、说明、cwd、原始输出和解析后的退出状态。后台启动只返回 task id因此使用通用执行卡片通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。
工具持有自己的 `presentCall`/`presentResult` 渲染意图。前台调用是终端卡片包含命令、说明、cwd、输出和解析后的退出状态。由于卡片以独立的 pill 展示退出状态,解析所消耗的 `[exit code: N]` / `[killed by signal: …]` 标记会从输出中移除;其他所有标记(截断、超时、沙箱)都保留在输出中。后台启动只返回 task id因此使用通用执行卡片通用 `task_*` 工具持有各自的卡片。这些 presenter 是纯函数,可安全回放。
## 工具仅使用具名参数构建请求
@@ -153,6 +153,6 @@ renderer 先输出依数据而定的 stdout 尾部,再输出可选的 `[stderr
## 已知限制与延期工作
- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill这是仅影响展示的已知残留问题。
- **回放退出状态 pill 从结果文本解析**:如果输出最后一行恰好精确为 `[exit code: N]` / `[killed by signal: …]`,会话回放将显示错误的 pill,并且该行会从卡片正文中丢失,因为解析会把它当作自己消耗的标记;这是仅影响展示的已知残留问题。
- **`bash` 工具不采用 `timeout-policy` 预算**:根据[工具调用 timeout-policy Agent Note](../../../.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md),它保留由执行器持有的 `BASH_TIMEOUT` 路径。
- **后台进程没有执行器超时**:工作不再需要时,调用方必须使用 `task_kill`,或依赖持有者/服务的 dispose。

View File

@@ -296,7 +296,9 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
if (isBackground || result.isError) {
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
}
return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
// The exit marker becomes the card's exit pill, so it leaves the output body.
const { body, ...exit } = parseExitStatus(raw)
return { card: 'terminal', output: body, ...exit }
}
/**

View File

@@ -95,10 +95,23 @@ export function renderProcessRead(
}
/**
* Recover the structured exit status from a rendered {@link renderResult}
* string — the inverse of the status markers it appends. A killed marker
* yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both
* means a clean exit 0.
* The exit status recovered from a rendered result, with the output body that
* status was split off from.
*/
export type ParsedExitStatus =
& { body: string }
& ({ exitCode: number } | { signal: string })
/**
* Split a rendered {@link renderResult} string into its output body and the
* structured exit status — the inverse of the status markers it appends. A
* killed marker yields `signal`; otherwise a non-zero marker yields `exitCode`;
* absent both means a clean exit 0.
*
* The consumed marker is removed from `body` because a terminal presentation
* shows the exit status as its own pill: leaving the marker in the output would
* render the exit twice. Other markers (timeout, sandbox denial) carry facts no
* pill shows, so they stay in the body.
*
* Replay only retains the rendered content text, not the original
* `BashRunResult`, so terminal presentation must recover the exit pill here.
@@ -106,12 +119,12 @@ export function renderProcessRead(
* that merely ends with marker-like text from matching unless the final line
* is indistinguishable from a real marker.
* @param text - rendered model-facing bash result.
* @returns the recovered terminal exit code or signal.
* @returns the marker-free body plus the recovered terminal exit code or signal.
*/
export function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
export function parseExitStatus(text: string): ParsedExitStatus {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { signal: signal[1] }
if (signal?.[1] !== undefined) return { body: text.slice(0, signal.index), signal: signal[1] }
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
return { exitCode: 0 }
if (exit?.[1] !== undefined) return { body: text.slice(0, exit.index), exitCode: Number(exit[1]) }
return { body: text, exitCode: 0 }
}

View File

@@ -911,22 +911,32 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('bash presentResult: a terminal result carries RAW output (newlines intact) + parsed exit code', async () => {
const ctx = await setup()
const present = ctx.tools.get('bash')!.presentResult!(
{ command: 'echo hi', description: 'echo' },
{ content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
{ command: 'printf "hi\\n\\n"', description: 'echo' },
// A clean run renders no exit marker at all, so the body is the raw bytes.
{ content: [{ type: 'text', text: 'hi\n\n' }], isError: false },
)
// A terminal result keeps the RAW bytes (newlines intact) a terminal renderer
// needs; the bridge derives the fenced fallback. exitCode is parsed back from
// the [exit code: N] marker.
expect(present).toEqual({ card: 'terminal', output: 'hi\n[exit code: 0]\n\n', exitCode: 0 })
// needs; the bridge derives the fenced fallback.
expect(present).toEqual({ card: 'terminal', output: 'hi\n\n', exitCode: 0 })
})
it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
const ctx = await setup()
const args = { command: 'x', description: 'x' }
const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })
expect(nonzero).toEqual({ card: 'terminal', output: 'oops\n[exit code: 3]', exitCode: 3 })
expect(nonzero).toEqual({ card: 'terminal', output: 'oops', exitCode: 3 })
const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })
expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
expect(killed).toEqual({ card: 'terminal', output: 'gone', signal: 'SIGKILL' })
})
it('bash presentResult: markers a pill CANNOT show (timeout, sandbox denial) stay in the terminal output', async () => {
const ctx = await setup()
const args = { command: 'x', description: 'x' }
const timedOut = ctx.tools.get('bash')!.presentResult!(
args,
{ content: [{ type: 'text', text: 'slow\n[timed out after 100ms]\n[exit code: 143]' }], isError: false },
)
expect(timedOut).toEqual({ card: 'terminal', output: 'slow\n[timed out after 100ms]', exitCode: 143 })
})
it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
@@ -952,8 +962,11 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
const rendered = renderResult(c.result)
const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
// Drop card + output; the remaining fields are the parsed exit.
const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
const { card: _c, output, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
expect(exit).toEqual(c.expect)
// Whatever the parse consumed is gone from the body, so a card with an exit
// pill never shows the same status twice.
expect(output).not.toMatch(/\[exit code: \d+\]|\[killed by signal: /)
}
})
@@ -964,6 +977,7 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
// newline; parsing requires the leading newline emitted for real markers, so this stays exit 0.
const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
expect(out).toEqual({ card: 'terminal', output: '[exit code: 5]', exitCode: 0 })
// Unparsed marker-like text is real output, so it is NOT stripped from the body.
// Same for a fake signal marker with no leading newline.
const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
expect(sig).toEqual({ card: 'terminal', output: '[killed by signal: SIGKILL]', exitCode: 0 })

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/client/README.md
README.md: b111d67fa49e06227e324a33bd53417ad28c3a5b
README.zh.md: b498008eb82f6ab357718f2af761f38e51140ef8

34
packages/client/README.md Normal file
View File

@@ -0,0 +1,34 @@
# client/ — web-GUI browser half
English | [中文](README.zh.md)
The browser side of the dsh web GUI: shell kernel, module system, wire consumer, React-free object services, the slot system, and the `ui-*` feature-plugin roster. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All **product** packages, named `@deepseek-ai/dsh-client-<name>`.
| Package | Role | ctx key / slot |
|---|---|---|
| `web/` | Shell kernel: `AppWebEntry` runs the two-stage boot over the host-pushed entry graph | (boots the tree) |
| `modules/` | Client module system: browser peer of Node's ESM loader as a lazy CJS table under the vendored cordis Loader | (module face) |
| `web-react/` | Shell-side React glue: `createSlotRenderer` + `SessionProvider` render seats | (renderer install) |
| `connection/` | Wire consumer both ends: browser `ctx.connection` (shared api client + stream loop) and the node half mounting the `/api` route with its browser-trust fence | `ctx.connection` |
| `runtime/` | Client cordis boot and React-free object services: slots, Sessions, Workspaces, per-session bindings | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
| `hmr/` | Dev-only hot reload for fetch-arrival client plugins (`--dev` graphs) | (dev entry) |
| `locale/` | Browser locale preference (`zh`/`en`) plus the ns×locale dictionary registry | `ctx.locale` |
| `ui-slots/` | Slot registry pure core: SlotMap merging, single `register` API, the four-share props family | (types + core) |
| `ui-theme/` | Theme preference over the `--dsw-*` token stylesheets (`light`/`dark`/`system`) | `ctx.theme` |
| `ui-primitives/` | Pure React atoms: icons, Button/Pill/Menu/Modal/Input, markdown family | (component library) |
| `ui-layout/` | Shell three-column AppFrame; declares `sidebar` / `conversation` / `details` / `conversation.empty` | `ctx.layout` |
| `ui-sidebar/` | Sidebar shell: Workspace/session rail, search, collapse; declares `sidebar.workspaces` | (slot host) |
| `ui-workspace/` | Shared Workspace picker: browser region + hero picker over the same creation flow | (fills `sidebar.workspaces`, `conversation.hero.workspace`) |
| `ui-conversation/` | Conversation domain: skeleton, chat view, input dock, per-tool row slots | (slot host) |
| `ui-trajectory/` | Trajectory/Waterfall view tabs; the minimal pure-consumer plugin exemplar | (fills `conversation.view`) |
| `ui-command/` | Command surface: session-keyed directory cache, `/` source, three-kind dispatch | `ctx.command` |
| `ui-slash/` | Input trigger pipeline: `/` and `@` detection, grouped candidate menu, source roster | `ctx.slash` |
| `ui-skill/` | `/`-trigger skill reference source over the `skill.list` RPC | (registers into `ctx.slash`) |
| `ui-subagent/` | `@`-trigger subagent reference source over the sessions snapshot | (registers into `ctx.slash`) |
| `ui-model/` | Model selection: `/model` popupSelect + the composer model seat over `ModelService` | `ctx.models` |
| `ui-question/` | Web `ask_user_question`: host half mounts the tool, browser half fills the composer seat | (fills `conversation.composer`) |
| `ui-settings/` | Settings shell: trigger chrome + modal panel; declares the `settings.*` slots | (slot host) |
| `ui-settings-general/` | Settings ownerless copy: chrome content + General section skeleton | (fills `settings.*`) |
| `ui-models/` | Models settings nav entry (content column lands in a later phase) | (fills `settings.section`) |
Feature UI composes only through the slot system (`ctx.slots.register`) — the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive model; the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer.

View File

@@ -0,0 +1,34 @@
# client/ — web GUI 浏览器半侧
[English](README.md) | 中文
dsh web GUI 的浏览器侧shell 内核、模块系统、协议消费层、无 React 依赖的对象服务、slot 系统,以及 `ui-*` 特性插件阵列。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。全部为**产品**包,命名为 `@deepseek-ai/dsh-client-<name>`
| 包 | 角色 | ctx 键slot |
|---|---|---|
| `web/` | shell 内核:`AppWebEntry` 基于宿主推送的条目图运行两阶段启动 | (启动整棵树) |
| `modules/` | 客户端模块系统Node ESM 加载器的浏览器对等物,是 vendored cordis Loader 之下的惰性 CJS 表 | (模块面) |
| `web-react/` | shell 侧 React 胶水:`createSlotRenderer` + `SessionProvider` 渲染座位 | (渲染器安装) |
| `connection/` | 协议两端的消费者:浏览器侧 `ctx.connection`(共享 api 客户端 + 流循环node 半侧挂载带浏览器信任栅栏的 `/api` 路由 | `ctx.connection` |
| `runtime/` | 客户端 cordis 启动与无 React 对象服务slots、Session、Workspace、逐会话绑定 | `ctx.slots` `ctx.sessions` `ctx.workspaces` |
| `hmr/` | 仅开发用的 fetch 到达型客户端插件热重载(`--dev` 图) | (开发条目) |
| `locale/` | 浏览器语言偏好(`zh``en`)与 ns×locale 词典注册表 | `ctx.locale` |
| `ui-slots/` | slot 注册表纯核心SlotMap 合并、单一 `register` API、四份额 props 族 | (类型 + 核心) |
| `ui-theme/` | 基于 `--dsw-*` token 样式表的主题偏好(`light``dark``system` | `ctx.theme` |
| `ui-primitives/` | 纯 React 原子图标、Button/Pill/Menu/Modal/Input、markdown 族 | (组件库) |
| `ui-layout/` | shell 三栏 AppFrame声明 `sidebar``conversation``details``conversation.empty` | `ctx.layout` |
| `ui-sidebar/` | 侧栏 shellWorkspace/会话栏、搜索、折叠;声明 `sidebar.workspaces` | slot 宿主) |
| `ui-workspace/` | 共享 Workspace 选择器:浏览区域 + hero 选择器共用同一创建流程 | (填充 `sidebar.workspaces``conversation.hero.workspace` |
| `ui-conversation/` | 会话域:骨架、聊天视图、输入坞、逐工具行 slot | slot 宿主) |
| `ui-trajectory/` | TrajectoryWaterfall 视图标签;最小纯消费者插件范例 | (填充 `conversation.view` |
| `ui-command/` | 命令面:按会话键控的目录缓存、`/` 源、三类分发 | `ctx.command` |
| `ui-slash/` | 输入触发流水线:光标下的 `/``@` 检测、分组候选菜单、源名册 | `ctx.slash` |
| `ui-skill/` | 基于 `skill.list` RPC 的 `/` 触发技能引用源 | (注册进 `ctx.slash` |
| `ui-subagent/` | 基于会话快照的 `@` 触发子代理引用源 | (注册进 `ctx.slash` |
| `ui-model/` | 模型选择:`/model` popupSelect + 输入坞模型座位,均由 `ModelService` 驱动 | `ctx.models` |
| `ui-question/` | Web `ask_user_question`:宿主半侧挂载工具,浏览器半侧填充输入坞座位 | (填充 `conversation.composer` |
| `ui-settings/` | 设置 shell触发 chrome + 模态面板;声明 `settings.*` slot | slot 宿主) |
| `ui-settings-general/` | 设置的无主文案chrome 内容 + General 分区骨架 | (填充 `settings.*` |
| `ui-models/` | 模型设置导航项(内容列留待后续阶段) | (填充 `settings.section` |
特性 UI 只通过 slot 系统组合(`ctx.slots.register`)——[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威模型;[web 客户端架构 Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) 拥有加载链与对象层。

View File

@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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
README.md: 80228a180faba0c556ff720e999b29b5bb1635b6
README.zh.md: f4b857886bfafa891ceb1bd6b79b27e1fb725819
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: 173a9b9998e17d201b2d31d73ea74a94b319dae6
README.zh.md: ca5da643db443956c25399f07c8b460900942ad4

View File

@@ -4,6 +4,10 @@ English | [中文](README.zh.md)
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
## /api browser-trust fence
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
## Keyless fixture
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.

View File

@@ -4,6 +4,10 @@
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam以及循环的 sink配置类型。平台子类WebApiClient/FixtureApiClient、ConnectionController 循环和 fixture 数据源都属于包内部apply 负责选择并驱动它们,测试则通过 src 访问。契约api-contracts v3 §3。
## /api 浏览器信任栅栏
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取EventSource、图片、导航——这些头只发给可信目标既不带 `Origin` 也不带 Fetch-Metadata因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取而 Host 是重绑唯一伪造不了的请求头非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`部署需要让自己的服务权威被信任dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
## 无密钥 fixture
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。

View File

@@ -33,7 +33,8 @@
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^"
"@deepseek-ai/dsh-tools": "workspace:^",
"schemastery": "^3.18.0"
},
"files": [
"lib/index.js",

View File

@@ -0,0 +1,129 @@
/**
* Browser-trust fence for every /api request. Defends the two confused-deputy
* paths a browser opens against a local HTTP API — DNS rebinding (Host names
* the attacker's domain while the socket reaches this server) and cross-site
* requests fired from a malicious page. The Host fence binds every request,
* browser-looking or not: over plain HTTP a browser attaches neither Origin
* nor Fetch-Metadata to reads (EventSource, images, navigations — those
* headers go only to trustworthy destinations), so an unmarked request may
* still be a rebound browser read and Host is the one header rebinding cannot
* forge. Non-browser and remote clients pass the same fence via loopback, the
* CLI-derived LAN IP literals, or a declared `trustedHosts` authority.
* Network reachability and authentication stay out of scope: binding policy
* belongs to the webserver config, and this fence is not an auth layer.
*/
import type { IncomingHttpHeaders } from 'node:http'
/** The request facts the fence reads (structural subset of IncomingMessage). */
interface ApiTrustRequest {
headers: IncomingHttpHeaders
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/** Normalized URL of a Host-header authority (hostname lowercased, default port stripped, IPv6 bracketed), or undefined when unparsable. */
function parseAuthority(authority: string): URL | undefined {
try {
// http: is a WHATWG "special scheme": parsing yields a non-empty hostname or throws.
return new URL(`http://${authority}`)
} catch {
return undefined
}
}
/**
* Assert one configured `trustedHosts` entry is a bare authority (`host` or
* `host:port`) in canonical form: it must survive WHATWG parsing unchanged
* (case aside). Anything parsing would silently rewrite is refused as a typo
* that must fail the load loudly instead of being ignored until requests 403
* or quietly changing the grant: URL parts beyond the authority
* (`harness.internal/path`, `user@harness.internal` — which would authorize
* the embedded hostname), stripped whitespace, a dangling colon or
* zero-padded port (which would broaden an intended exact-port grant to every
* port), and non-canonical host spellings (`0x7f.0.0.1`, percent-encoding,
* unbracketed IPv6; IDN hosts are declared in punycode, the form the wire
* carries).
* @param entry - the configured value, verbatim.
*/
export function assertTrustedAuthority(entry: string): void {
const entryUrl = parseAuthority(entry)
if (entryUrl !== undefined && canonicalAuthority(entry, entryUrl) === entry.toLowerCase()) return
throw new Error(`client-connection: trustedHosts entry ${JSON.stringify(entry)} is not a bare host[:port] authority`)
}
/**
* Canonical form of a parsed authority: `hostname` when no port was written,
* else `hostname:port`. The port is judged from URL parses under both special
* schemes (their default ports differ, so `:80` and `:443` still count as
* explicit), never from the raw string, where WHATWG trimming would misread
* shapes like `host:port ` as port-less.
*/
function canonicalAuthority(entry: string, entryUrl: URL): string {
// An authority that parsed under http cannot fail under https.
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
}
/**
* Whether the request authority matches a `trustedHosts` entry. An entry with
* an explicit port matches that exact authority; a port-less entry matches the
* hostname on any port (the shape the CLI derives for IP-literal LAN serving,
* where the bound port may be OS-assigned). Both sides compare through WHATWG
* normalization, so case and a redundant `:80` never decide trust.
*/
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
return trustedHosts.some((entry) => {
const entryUrl = parseAuthority(entry)
if (entryUrl === undefined) return false
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
? entryUrl.hostname === hostUrl.hostname
: entryUrl.host === hostUrl.host
})
}
/**
* Decide whether one /api request may reach the RPC bridge.
* @param request - node HTTP request facts (headers).
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
*/
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
// Host fence (DNS-rebinding defense), applied to every request: the browser
// fills Host from the URL it believes it is talking to, so a rebound page
// carries the attacker's domain here even though the socket lands on this
// server. There is no marker shortcut — a browser read over plain HTTP
// (EventSource, images, navigations) arrives with neither Origin nor
// Fetch-Metadata, indistinguishable from curl, and its response is readable
// by the rebound page.
const host = header(request.headers, 'host')
if (host === undefined) return false
const hostUrl = parseAuthority(host)
if (hostUrl === undefined) return false
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
// Cross-site fence: modern browsers label the initiator relationship on
// every fetch; an explicit cross-site marker is refused regardless of Origin.
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
// Origin fence: when a browser attaches an Origin it must be exactly this
// authority (compared through the same normalization as the Host). Absent
// Origin is fine — the Host fence above already bound the request. The
// literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
const origin = header(request.headers, 'origin')
if (origin === undefined) return true
try {
return new URL(origin).host === hostUrl.host
} catch {
return false
}
}

View File

@@ -8,6 +8,7 @@
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,

View File

@@ -575,6 +575,37 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
updatedAt: fixtureEpoch,
}]
let nextWorkspace = 1
// In-memory browse tree behind the fixture's `browse` picker capability —
// deterministic content mirroring the design mock so assembled Web tests
// and snapshots can walk it. Leaves are materialized lazily: a child listed
// by its parent lists as empty until something is created inside it.
const FIXTURE_HOME = '/home/fixture'
const directoryTree = new Map<string, string[]>([
['/', ['home']],
['/home', ['fixture']],
[FIXTURE_HOME, ['Documents', 'Downloads', '.config']],
[`${FIXTURE_HOME}/Documents`, [
'project', 'deepseek-iOS', 'deepseek-android', 'deepseek-platform',
'deepseek-web', 'deepseek-harness', 'deepseek-app', 'deepseek-landing-blog',
]],
])
const childrenOf = (path: string): string[] | undefined => {
const known = directoryTree.get(path)
if (known !== undefined) return known
const parent = path.slice(0, path.lastIndexOf('/')) || '/'
const name = path.slice(path.lastIndexOf('/') + 1)
return directoryTree.get(parent)?.includes(name) === true ? [] : undefined
}
const crumbsOf = (path: string): { name: string; path: string; hidden: boolean }[] => {
const crumbs = [{ name: '/', path: '/', hidden: false }]
let acc = ''
for (const segment of path.split('/').filter(Boolean)) {
acc += `/${segment}`
crumbs.push({ name: segment, path: acc, hidden: false })
}
return crumbs
}
const mint = (): ReturnType<typeof RpcId> => RpcId(`fx-rpc-${nextRpc++}`)
/** Resident pending approval (stable rpcId: every mux open replays the same id, matching host replay semantics). */
const pendingApprovalRpcId = mint()
@@ -980,7 +1011,42 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
},
host: {
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
pickDirectory: request => ok(request, { path: null }),
// Deterministic native pick: the keyless lanes drive the full
// pick-then-adopt path without an OS chooser (design-mock content,
// same tree the browse primitives serve).
pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }),
listDirectory: (request) => {
const target = request.payload.path ?? FIXTURE_HOME
const children = childrenOf(target)
if (children === undefined) {
return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } })
}
return ok(request, {
path: target,
home: FIXTURE_HOME,
crumbs: crumbsOf(target),
entries: [...children].sort((a, b) => a.localeCompare(b))
.map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })),
// The fixture tree is tiny; no level ever reaches a backend bound.
truncated: false,
})
},
createDirectory: (request) => {
const parent = request.payload.path
const children = childrenOf(parent)
if (children === undefined) {
return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } })
}
// Same root special case as listDirectory's entry paths: a plain join
// under '/' would mint '//name' and fork the tree's identity.
const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}`
if (children.includes(request.payload.name)) {
return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } })
}
directoryTree.set(parent, [...children, request.payload.name])
directoryTree.set(target, [])
return ok(request, { path: target })
},
openPath: request => ok(request, { opened: true as const }),
},
workspace: {
@@ -1335,6 +1401,8 @@ export class FixtureApiClient extends AbstractApiClient {
case 'session.cancel': return this.api.sessions.cancel(request)
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
case 'host.createDirectory': return this.api.host.createDirectory(request)
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)

View File

@@ -13,6 +13,7 @@ import { WebApiClient } from './web-api-client.ts'
export type {
ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,

View File

@@ -1,11 +1,12 @@
/** Host HTTP bridge for browser-client RPC. */
import type { Context } from 'cordis'
import z from 'schemastery'
// Activates the httpServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
import { isTrustedNativeDialogRequest } from './native-dialog-request.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
export { API_PATH } from './api-path.ts'
@@ -15,20 +16,42 @@ export const name = 'client-connection'
/** Services required before mounting the route. */
export const inject = ['httpServer', 'apiProxy']
/** Plugin config: the deployment's non-loopback serving authorities. */
export interface ConnectionConfig {
/**
* Authorities this deployment serves beyond loopback: exact `host:port`, or
* port-less `host` matching any port. The /api trust fence refuses any
* request whose Host is neither loopback nor listed here, so a
* non-loopback (`0.0.0.0`) deployment must declare the names it is reached
* by (the dsh CLI derives the machine's LAN IP literals itself). An entry
* that is not a bare, canonical authority fails the plugin load.
*/
trustedHosts?: string[]
}
export const Config: z<ConnectionConfig> = z.object({
trustedHosts: z.array(String).default([]),
})
/**
* Mounts the API gateway under the browser transport prefix.
* Mounts the API gateway under the browser transport prefix. Every request on
* the prefix passes the browser-trust fence first (DNS-rebinding and
* cross-site defense — [api-request-trust](./api-request-trust.ts)).
* @param ctx - Host plugin context.
* @param config - resolved plugin config (schema defaults applied).
*/
export function apply(ctx: Context): void {
export function apply(ctx: Context, config?: ConnectionConfig): void {
// The Loader resolves schema defaults; hand-built test contexts may pass none.
const trustedHosts = config?.trustedHosts ?? []
// Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
const apiHandler = toFetchHandler(ctx.apiProxy)
const route: WebRoute = {
kind: 'prefix',
path: API_PATH,
handler: async (req, res) => {
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
if ((pathname === `${API_PATH}/host.pickDirectory`
|| pathname === `${API_PATH}/host.openPath`)
&& !isTrustedNativeDialogRequest(req)) {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return

View File

@@ -1,52 +0,0 @@
/** Trust check for browser requests that can invoke privileged native host actions. */
import type { IncomingHttpHeaders } from 'node:http'
interface NativeDialogRequest {
headers: IncomingHttpHeaders
socket: { remoteAddress?: string | undefined }
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
function isLoopback(address: string | undefined): boolean {
if (address === undefined) return false
if (address === '::1') return true
const ipv4 = address.startsWith('::ffff:') ? address.slice('::ffff:'.length) : address
const first = ipv4.split('.')[0]
return first === '127'
}
function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/**
* Require a local socket plus browser-controlled same-origin metadata.
* @param request - the node HTTP request facts used by the carrier guard.
* @returns true only for a same-origin browser request whose peer and URL are loopback.
*/
export function isTrustedNativeDialogRequest(request: NativeDialogRequest): boolean {
if (!isLoopback(request.socket.remoteAddress)) return false
if (header(request.headers, 'sec-fetch-site') !== 'same-origin') return false
const origin = header(request.headers, 'origin')
const host = header(request.headers, 'host')
if (origin === undefined || host === undefined) return false
try {
const parsed = new URL(origin)
const hostUrl = new URL(`http://${host}`)
return (parsed.protocol === 'http:' || parsed.protocol === 'https:')
&& parsed.host === host
&& isLoopbackHostname(parsed.hostname)
&& isLoopbackHostname(hostUrl.hostname)
} catch {
return false
}
}

View File

@@ -0,0 +1,108 @@
/** Behavior of the /api browser-trust fence (rebinding + cross-site defense). */
import { describe, expect, it } from 'vitest'
import { assertTrustedAuthority, isTrustedApiRequest } from '../src/api-request-trust.ts'
function request(headers: Record<string, string | undefined>): { headers: Record<string, string | undefined> } {
return { headers }
}
describe('isTrustedApiRequest', () => {
it('holds markerless requests to the same Host fence — a plain-HTTP browser read carries no markers', () => {
// Over plain HTTP a browser attaches neither Origin nor Fetch-Metadata to
// reads (EventSource, images, navigations), so a rebound-origin GET is
// markerless and its response readable: no marker shortcut may exist.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080' }), [])).toBe(true)
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), ['192.168.1.5'])).toBe(true)
expect(isTrustedApiRequest(request({ host: '192.168.1.5:3080' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ host: 'harness.example' }), [])).toBe(false)
expect(isTrustedApiRequest(request({}), [])).toBe(false)
})
it('accepts loopback Hosts in every spelling, with and without ports, for browser requests', () => {
for (const host of ['localhost', 'localhost:3080', '127.0.0.1', '127.0.0.1:3080', '127.8.9.10:80', '[::1]', '[::1]:3080', 'LOCALHOST:3080']) {
expect(isTrustedApiRequest(request({ host, origin: `http://${host}` }), [])).toBe(true)
}
})
it('refuses a rebound Host: the attacker domain names the socket it did not expect', () => {
expect(isTrustedApiRequest(request({
host: 'evil.example:3080',
origin: 'http://evil.example:3080',
'sec-fetch-site': 'same-origin',
}), [])).toBe(false)
})
it('accepts a declared public authority: exact on host:port entries, any port on port-less entries', () => {
const headers = { host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }
expect(isTrustedApiRequest(request(headers), ['harness.internal:3080'])).toBe(true)
expect(isTrustedApiRequest(request(headers), ['harness.internal'])).toBe(true)
expect(isTrustedApiRequest(request(headers), ['harness.internal:9999'])).toBe(false)
expect(isTrustedApiRequest(request(headers), [])).toBe(false)
})
it('matches Host, Origin, and trusted entries through WHATWG normalization (case, default port)', () => {
expect(isTrustedApiRequest(request({ host: 'Harness.INTERNAL:3080', origin: 'http://harness.internal:3080' }), ['harness.internal:3080'])).toBe(true)
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['HARNESS.internal:80'])).toBe(true)
// An unparsable entry never matches; it must not poison the rest of the list.
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry', 'harness.internal'])).toBe(true)
expect(isTrustedApiRequest(request({ host: 'harness.internal', origin: 'http://harness.internal' }), ['bad entry'])).toBe(false)
})
it('refuses cross-origin browser markers even on a loopback Host', () => {
// Origin present and different → cross-site request that survived preflight rules.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), [])).toBe(false)
// Explicit cross-site label → refused regardless of Origin.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', 'sec-fetch-site': 'cross-site' }), [])).toBe(false)
// Opaque origin (sandboxed iframe, file: page) parses to no authority.
expect(isTrustedApiRequest(request({ host: '127.0.0.1:3080', origin: 'null' }), [])).toBe(false)
})
it('accepts a same-origin browser request, with or without an Origin header', () => {
expect(isTrustedApiRequest(request({
host: 'localhost:3080',
origin: 'http://localhost:3080',
'sec-fetch-site': 'same-origin',
}), [])).toBe(true)
// Origin-less browser shapes (same-origin GETs) still carry sec-fetch-site.
expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true)
})
it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => {
for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) {
expect(() => { assertTrustedAuthority(entry) }).not.toThrow()
}
// WHATWG parsing would quietly read a hostname out of each of these; the
// config boundary must refuse them instead of authorizing the prefix.
for (const entry of ['harness.internal/path', 'harness.internal/', 'user@harness.internal', 'harness.internal?x', 'harness.internal#f', 'harness.internal\\path', 'bad entry', '']) {
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
}
// WHATWG trimming would silently strip these; the entry must fail instead.
for (const entry of ['harness.internal:3080 ', ' harness.internal', 'harness.internal:30\t80']) {
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
}
// WHATWG parsing would silently rewrite these — a dangling colon or
// zero-padded port would broaden an intended exact-port grant to every
// port, and non-canonical host spellings would not read back as written.
for (const entry of ['harness.internal:', '[::1]:', 'harness.internal:0080', '0x7f.0.0.1', '[0:0:0:0:0:0:0:1]']) {
expect(() => { assertTrustedAuthority(entry) }).toThrow(/not a bare host\[:port\] authority/)
}
})
it('never lets stray whitespace broaden an exact-port entry to every port', () => {
// Defense in depth below the load-time assert: the explicit-port judgment
// reads the parsed URL, so a trimmed `host:port ` entry stays exact.
const trusted = ['harness.internal:3080 ']
expect(isTrustedApiRequest(request({ host: 'harness.internal:9999', origin: 'http://harness.internal:9999' }), trusted)).toBe(false)
expect(isTrustedApiRequest(request({ host: 'harness.internal:3080', origin: 'http://harness.internal:3080' }), trusted)).toBe(true)
})
it('refuses malformed or untrusted authorities on browser requests', () => {
const markers = { 'sec-fetch-site': 'same-origin' }
expect(isTrustedApiRequest(request({ ...markers }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: 'bad host' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '127.0.0.999' }), [])).toBe(false)
expect(isTrustedApiRequest(request({ ...markers, host: '128.0.0.1' }), [])).toBe(false)
})
})

View File

@@ -70,6 +70,18 @@ export class FakeApiClient implements IApiClient {
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -91,6 +103,8 @@ export class FakeApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
}

View File

@@ -319,6 +319,22 @@ describe('createFixtureApi', () => {
expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } })
})
it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => {
const api = createFixtureApi()
const created = await api.host.createDirectory(req({ path: '/', name: 'srv' }))
if (!created.result.ok) throw new Error('create failed')
expect(created.result.value.path).toBe('/srv')
const listed = await api.host.listDirectory(req({ path: '/srv' }), new AbortController().signal)
if (!listed.result.ok) throw new Error('list failed')
expect(listed.result.value.crumbs).toEqual([
{ name: '/', path: '/', hidden: false },
{ name: 'srv', path: '/srv', hidden: false },
])
const root = await api.host.listDirectory(req({ path: '/' }), new AbortController().signal)
if (!root.result.ok) throw new Error('root list failed')
expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false })
})
it('workspace.list serves the resident account and create reuses on path collision', async () => {
const api = createFixtureApi()
const listed = await api.workspace.list(req({}))

View File

@@ -1,57 +0,0 @@
import type { IncomingHttpHeaders } from 'node:http'
import { describe, expect, it } from 'vitest'
import { isTrustedNativeDialogRequest } from '../src/native-dialog-request.ts'
function request(
remoteAddress: string | undefined,
headers: IncomingHttpHeaders = {
host: '127.0.0.1:3080',
origin: 'http://127.0.0.1:3080',
'sec-fetch-site': 'same-origin',
},
) {
return { socket: { remoteAddress }, headers }
}
describe('native dialog request trust', () => {
it('accepts loopback same-origin browser requests', () => {
expect(isTrustedNativeDialogRequest(request('127.0.0.1'))).toBe(true)
expect(isTrustedNativeDialogRequest(request('::1', {
host: '[::1]:3080', origin: 'http://[::1]:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
expect(isTrustedNativeDialogRequest(request('::ffff:127.0.0.1'))).toBe(true)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: 'localhost:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
expect(isTrustedNativeDialogRequest(request('127.0.0.2', {
host: '127.0.0.2:3080', origin: 'https://127.0.0.2:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(true)
})
it('rejects remote sockets and requests without matching browser metadata', () => {
expect(isTrustedNativeDialogRequest(request('192.168.1.5'))).toBe(false)
expect(isTrustedNativeDialogRequest(request(undefined))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'http://evil.example', 'sec-fetch-site': 'cross-site',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'http://localhost:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', { host: '127.0.0.1:3080' }))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
origin: 'http://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: 'attacker.example:3080', origin: 'http://attacker.example:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.0.0.1:3080', origin: 'ftp://127.0.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '127.999.0.1:3080', origin: 'http://127.999.0.1:3080', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
expect(isTrustedNativeDialogRequest(request('127.0.0.1', {
host: '[invalid', origin: 'http://[invalid', 'sec-fetch-site': 'same-origin',
}))).toBe(false)
})
})

View File

@@ -1,4 +1,6 @@
/** Node half: registers the /api prefix route bridging to the api gateway. */
import { EventEmitter } from 'node:events'
import { Readable } from 'node:stream'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { IncomingMessage, ServerResponse } from 'node:http'
@@ -6,48 +8,113 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { API_PATH, apply, inject } from '../src/index.ts'
describe('connection node half', () => {
it('registers the /api prefix route and removes it with the fiber', async () => {
const ctx = new Context()
const routes: WebRoute[] = []
// Structural fake: the plugin only touches register(); the service class
// carries private state a literal cannot (and need not) reproduce.
const httpServer: Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> = {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
ctx.provide('httpServer', httpServer as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
/** Structural httpServer fake: the plugin only touches register(). */
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
return {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
port: 0,
}
}
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
function fakeRequest(headers: Record<string, string>): IncomingMessage {
const request = Readable.from([]) as unknown as IncomingMessage
Object.assign(request, { url: `${API_PATH}/session.list`, method: 'GET', headers })
return request
}
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
const state: { status?: number; body?: unknown } = {}
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(value: number) { state.status = value; return this },
write() { return true },
end(this: { writableEnded: boolean }, value?: unknown) {
if (value !== undefined) state.body = value
this.writableEnded = true
return this
},
}) as unknown as ServerResponse
return { response, state }
}
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
await fiber.await()
return { routes, dispose: () => fiber.dispose() }
}
describe('connection node half', () => {
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
const routes: WebRoute[] = []
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
// The apply throw also escapes cordis as a late rejection — the shape the
// boot's installFailLoud is contracted to catch. Capture it so the run
// stays clean, same pattern as the webserver bind-failure test.
const rejections: unknown[] = []
const onUnhandled = (err: unknown): void => { rejections.push(err) }
process.on('unhandledRejection', onUnhandled)
try {
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
await expect(fiber.await()).rejects.toThrow(/not a bare host\[:port\] authority/)
expect(routes).toHaveLength(0)
for (let i = 0; i < 100 && rejections.length === 0; i++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
expect(rejections.map(String).join('\n')).toContain('not a bare host[:port] authority')
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('registers the /api prefix route and removes it with the fiber', async () => {
const { routes, dispose } = await mounted()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
for (const url of ['/api/host.pickDirectory', '/api/host.openPath']) {
let status: number | undefined
let body: unknown
const deniedRequest = {
url,
headers: {
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
},
socket: { remoteAddress: '192.168.1.8' },
} as unknown as IncomingMessage
const deniedResponse = {
writeHead(value: number) { status = value; return this },
end(value?: unknown) { body = value; return this },
} as unknown as ServerResponse
await routes[0]!.handler(deniedRequest, deniedResponse)
expect(status).toBe(403)
expect(body).toBe('forbidden')
}
await fiber.dispose()
await dispose()
expect(routes).toHaveLength(0)
})
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
const { routes, dispose } = await mounted()
const { response, state } = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
}), response)
expect(state.status).toBe(403)
expect(state.body).toBe('forbidden')
await dispose()
})
it('passes loopback and declared-authority requests through to the bridge', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
// Loopback, no browser markers (curl shape): the fence passes; the carrier
// answers 404 for a GET unary path — proof the bridge ran.
const loopback = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
expect(loopback.state.status).toBe(404)
// LAN authority declared as a port-less IP literal — the shape the CLI
// derives for `--host 0.0.0.0` — passes markerless curl on any port.
const lan = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
expect(lan.state.status).toBe(404)
// Declared public authority, same-origin browser shape.
const declared = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
}), declared.response)
expect(declared.state.status).toBe(404)
await dispose()
})
})

View File

@@ -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.

View File

@@ -18,7 +18,7 @@ export { SessionProvideChannel } from './sessions/provide.ts'
export type { SessionProvideChannelHost } from './sessions/provide.ts'
export { createScope } from './agents/scope.ts'
export type { AgentScopeHandle } from './agents/scope.ts'
export { WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from './workspaces/service.ts'
export type { Session } from './sessions/session.ts'
export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts'
export type { ISessions } from './contract/sessions.ts'
@@ -29,7 +29,9 @@ export type {
export type { SessionListPhase } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
export type {
DirectoryEntry, DirectoryListing, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
// Runtime owns the snapshot store; web-react only binds it to React.
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
export type {

View File

@@ -2,7 +2,8 @@
import type { Context } from 'cordis'
import type {
IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
@@ -30,6 +31,14 @@ export class WorkspaceCreateError extends Error {
}
}
/** Structured browse failure so the directory browser can branch on Host business codes. */
export class DirectoryBrowseError extends Error {
constructor(readonly rpcError: RpcError) {
super(`directory browse failed: ${rpcError.code}: ${rpcError.message}`)
this.name = 'DirectoryBrowseError'
}
}
/** Real Workspace object layer and Host actions. */
export class WorkspacesService implements IWorkspaces {
/** UI-facing immutable projection; the manager remains wire truth. */
@@ -172,7 +181,7 @@ export class WorkspacesService implements IWorkspaces {
}
/**
* Open the Host's native directory picker.
* Open the Host's native directory picker (the `native` capability).
* @returns the selected path, or null when the user cancelled.
*/
async pickDirectory(): Promise<string | null> {
@@ -183,6 +192,30 @@ export class WorkspacesService implements IWorkspaces {
return response.result.value.path
}
/**
* List one directory level through the Host's `browse` capability.
* @param path - absolute directory to list; absent lists the Host home directory.
* @param signal - aborts the wire request (and the Host's scan) when the caller supersedes it.
* @returns the level's listing with breadcrumb ancestry.
*/
async listDirectory(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
const response = await this.api.host.listDirectory(path === undefined ? {} : { path }, signal)
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
return response.result.value
}
/**
* Create one child directory through the Host's `browse` capability.
* @param path - absolute existing parent directory.
* @param name - single non-blank path segment.
* @returns the created directory's absolute path.
*/
async createDirectory(path: string, name: string): Promise<string> {
const response = await this.api.host.createDirectory({ path, name })
if (!response.result.ok) throw new DirectoryBrowseError(response.result.error)
return response.result.value.path
}
/**
* Open a filesystem path with the Host operating system's default application.
* @param path - absolute or host-resolvable path.

View File

@@ -88,6 +88,18 @@ export class FakeApiClient implements IApiClient {
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly muxConns: StreamConn<MuxFrame>[] = []
private readonly hostConns: StreamConn<HostFrame>[] = []
@@ -109,6 +121,8 @@ export class FakeApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
}

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client'
import { SessionsService } from '../src/client/sessions/service.ts'
import { WorkspaceManager } from '../src/client/workspaces/manager.ts'
import { WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
import { DirectoryBrowseError, WorkspaceCreateError, WorkspacesService } from '../src/client/workspaces/service.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
const sid = (id: string): SessionId => id as SessionId
@@ -234,6 +234,29 @@ describe('WorkspacesService', () => {
api.onPickDirectory = () => Promise.resolve(ok({ path: null }))
await expect(workspaces.pickDirectory()).resolves.toBeNull()
expect(api.callsOf('host.pickDirectory')).toEqual([{}, {}])
api.onPickDirectory = () => Promise.resolve(err({ code: 'internal', message: 'no chooser', details: {} }))
await expect(workspaces.pickDirectory()).rejects.toThrow(/no chooser/)
})
it('passes listings and creation through the browse wire, wrapping business failures', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const workspaces = new WorkspacesService(ctx, api, new SessionsService(ctx, api))
const listing = { path: '/home/u', home: '/home/u', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [{ name: 'p', path: '/home/u/p', hidden: false }], truncated: false }
api.onListDirectory = () => Promise.resolve(ok(listing))
await expect(workspaces.listDirectory()).resolves.toEqual(listing)
await expect(workspaces.listDirectory('/home/u')).resolves.toEqual(listing)
// The optional path is omitted from the payload, not sent as undefined.
expect(api.callsOf('host.listDirectory')).toEqual([{}, { path: '/home/u' }])
api.onListDirectory = () => Promise.resolve(err({ code: 'directory-unreadable', message: 'denied', details: { path: '/x' } }))
const listFailure = workspaces.listDirectory('/x')
await expect(listFailure).rejects.toBeInstanceOf(DirectoryBrowseError)
await expect(listFailure).rejects.toMatchObject({ rpcError: { code: 'directory-unreadable' } })
await expect(workspaces.createDirectory('/home/u', 'fresh')).resolves.toBe('/home/fake/new')
expect(api.callsOf('host.createDirectory')).toEqual([{ path: '/home/u', name: 'fresh' }])
api.onCreateDirectory = () => Promise.resolve(err({ code: 'directory-exists', message: 'taken', details: { path: '/home/u/fresh' } }))
await expect(workspaces.createDirectory('/home/u', 'fresh')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' } })
})
it('opens a filesystem path through the host without local state', async () => {

View File

@@ -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.

View File

@@ -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', () => {

View File

@@ -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>
)

View File

@@ -36,13 +36,22 @@ export interface DeferredRegistration {
* @param name - target slot name.
* @param component - the component whose ledger presence marks "registered".
* @param register - performs the actual registration; returns its disposer.
* @param onFailure - owns a registration failure that fires from a LATER
* ledger flush (a declaration landing after two providers deferred, say):
* the deferral first removes its own subscription, then hands the error
* over instead of throwing through the flush — the callback's chance to
* roll back sibling deferrals and surface the conflict on a loud channel.
* Absent, a late failure rethrows out of the flush.
* @returns the deferral handle (dispose in the owning effect's disposer).
* @throws the immediate registration's failure, after removing the
* just-installed subscription — a throwing construction leaves nothing live.
*/
export function deferRegistration(
registry: DeferralRegistry,
name: string,
component: unknown,
register: () => () => void,
onFailure?: (error: unknown) => void,
): DeferredRegistration {
let dispose: (() => void) | undefined
const tryRegister = (): void => {
@@ -50,8 +59,24 @@ export function deferRegistration(
if (registry.entries(name).some(e => e.component === component)) return
dispose = register()
}
const unsubscribe = registry.subscribe(name, () => { tryRegister() })
tryRegister()
const unsubscribe = registry.subscribe(name, () => {
try {
tryRegister()
} catch (error) {
unsubscribe()
if (onFailure === undefined) throw error
onFailure(error)
}
})
try {
tryRegister()
} catch (error) {
// A synchronous registration failure (the declared slot is already
// occupied) must not leave the just-installed subscription behind: the
// caller receives no handle to dispose it through.
unsubscribe()
throw error
}
return {
refresh() {
dispose?.()
@@ -64,3 +89,40 @@ export function deferRegistration(
},
}
}
/**
* Defer ONE occupant into several holes as a unit. Construction that throws
* partway (a declared hole already occupied registers synchronously) rolls
* every earlier deferral back before rethrowing; a failure surfacing from a
* LATER ledger flush (holes declared after rival providers activated) rolls
* the whole group back the same way and re-raises the wrapped error on the
* global channel the boot's fail-loud handler owns — never a throw through
* the slot flush, never partial occupancy from the group's owner.
* @param registry - the slot registry face.
* @param names - the target holes (one registration per name).
* @param component - the occupant whose ledger presence marks "registered".
* @param register - performs one hole's registration; returns its disposer.
* @returns the group handle (dispose in the owning effect's disposer).
* @throws the immediate registration's failure, after rolling the group back.
*/
export function deferGroupRegistration<K extends string>(
registry: DeferralRegistry,
names: readonly K[],
component: unknown,
register: (name: K) => () => void,
): { dispose: () => void } {
const deferred: DeferredRegistration[] = []
const lateFailure = (error: unknown): void => {
for (const entry of deferred) entry.dispose()
queueMicrotask(() => { throw error instanceof Error ? error : new Error(String(error)) })
}
try {
for (const name of names) {
deferred.push(deferRegistration(registry, name, component, () => register(name), lateFailure))
}
} catch (error) {
for (const entry of deferred) entry.dispose()
throw error
}
return { dispose: () => { for (const entry of deferred) entry.dispose() } }
}

View File

@@ -0,0 +1,126 @@
// deferRegistration lifecycle: declaration-aware registration, HMR
// re-registration, and — the failure contract — no subscription survives a
// construction that throws synchronously (an already-occupied single slot).
import { describe, expect, it, vi } from 'vitest'
import { deferGroupRegistration, deferRegistration, SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
// Shares the merges declared by core.spec.ts (same program); reuse its keys.
const HOLE = 'test.single' as const
function declared(): SlotCore {
const core = new SlotCore()
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
return core
}
describe('deferRegistration', () => {
it('registers immediately under an existing declaration and disposes cleanly', () => {
const core = declared()
const component = (): null => null
const handle = deferRegistration(core, HOLE, component, () =>
core.register({ name: HOLE } as never, component as never))
expect(core.entries(HOLE)).toHaveLength(1)
handle.dispose()
expect(core.entries(HOLE)).toHaveLength(0)
})
it('hands a late registration failure to onFailure after unsubscribing itself', async () => {
const core = new SlotCore()
const component = (): null => null
const foreign = (): null => null
const failures: unknown[] = []
// Nothing is declared yet: the deferral just subscribes and waits.
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
deferRegistration(core, HOLE, component, register, (error) => { failures.push(error) })
// The declaration lands with a foreign occupant racing in first: the
// deferral's flush-time attempt fails, unsubscribes itself, and reports
// through onFailure instead of throwing out of the flush.
core.register({ name: 'root', children: { [HOLE]: { kind: 'single', scope: 'root' } } } as never, (() => null) as never)
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
await Promise.resolve()
expect(failures.map(String).join('')).toContain('already has a registration')
// Unsubscribed: freeing the hole must not resurrect the loser.
disposeForeign()
await Promise.resolve()
expect(core.entries(HOLE)).toHaveLength(0)
})
it('drops its subscription when the immediate registration throws', async () => {
const core = declared()
const foreign = (): null => null
const disposeForeign = core.register({ name: HOLE } as never, foreign as never)
const component = (): null => null
const register = vi.fn(() => core.register({ name: HOLE } as never, component as never))
// The single hole is occupied: the immediate attempt throws out of the
// constructor, and the caller never receives a handle to dispose.
expect(() => deferRegistration(core, HOLE, component, register)).toThrow(/already has a registration/)
expect(register).toHaveBeenCalledOnce()
// The subscription rolled back with it: freeing the hole flushes a
// notification that must not resurrect the failed registration.
disposeForeign()
await Promise.resolve()
expect(register).toHaveBeenCalledOnce()
expect(core.entries(HOLE)).toHaveLength(0)
})
})
describe('deferGroupRegistration', () => {
const HOLES = ['test.single', 'test.grandchild'] as const
function declaredPair(): SlotCore {
const core = new SlotCore()
core.register({
name: 'root',
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
} as never, (() => null) as never)
return core
}
it('registers the whole group and disposes it as a unit', () => {
const core = declaredPair()
const component = (): null => null
const group = deferGroupRegistration(core, HOLES, component, name =>
core.register({ name } as never, component as never))
for (const name of HOLES) expect(core.entries(name)).toHaveLength(1)
group.dispose()
for (const name of HOLES) expect(core.entries(name)).toHaveLength(0)
})
it('rolls the group back when construction fails partway', () => {
const core = declaredPair()
const component = (): null => null
core.register({ name: HOLES[1] } as never, (() => null) as never)
expect(() => deferGroupRegistration(core, HOLES, component, name =>
core.register({ name } as never, component as never))).toThrow(/already has a registration/)
// The first hole's registration and subscription rolled back with it.
expect(core.entries(HOLES[0])).toHaveLength(0)
})
it('rolls the group back and re-raises loudly on a late conflict', async () => {
const core = new SlotCore()
const component = (): null => null
const failures: unknown[] = []
const onLoud = (reason: unknown): void => { failures.push(reason) }
process.on('uncaughtException', onLoud)
try {
const group = deferGroupRegistration(core, HOLES, component, name =>
core.register({ name } as never, component as never))
// Declaration lands with a rival racing in ahead of the flush.
core.register({
name: 'root',
children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
} as never, (() => null) as never)
core.register({ name: HOLES[0] } as never, (() => null) as never)
core.register({ name: HOLES[1] } as never, (() => null) as never)
await new Promise(resolve => setTimeout(resolve, 20))
expect(failures.map(String).join('')).toContain('already has a registration')
// No partial occupancy from the group's owner survives.
for (const name of HOLES) {
expect(core.entries(name).filter(entry => entry.component === component)).toHaveLength(0)
}
group.dispose()
} finally {
process.off('uncaughtException', onLoud)
}
})
})

View File

@@ -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/client/ui-workspace/README.md
README.md: edd6c2f9373d97832def86bb44658d7c1c68dae9
README.zh.md: f7b73dde953d4294d4d157f479fe932adf1a29c4
README.md: 8acf819121b46512d38b39ff858bb2bf797cfe96
README.zh.md: e97d93f7e38d00af91b43f0df9fb0e3b17ae8ed5

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sidebar's `sidebar.workspaces` slot and `WorkspacePicker` into the page-local Session Intent hero's `conversation.hero.workspace` slot, so both surfaces use the same menu and creation flow.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. The flat **Open local folder...** action delegates to the Host's native single-directory picker, adopts a returned path through the object layer, and selects the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors remain retryable. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped.
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
@@ -19,4 +19,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No Session deletion control** — the existing Session menu row remains visual-only; Workspace registration deletion does not delete Sessions.
- **Native folder selection depends on the local Host carrier** — fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.

View File

@@ -4,7 +4,7 @@
共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot因此两个表层使用同一菜单和创建流程。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。平铺显示的 **打开本地文件夹…** 操作会委托 Host 的原生单目录选择器,通过对象层接纳返回的路径,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示发生错误后仍可重试。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染每次菜单渲染读取占用状态洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`每次打开上报一个所选路径owner 通过对象层接纳,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
@@ -19,4 +19,4 @@
## 已知限制与暂缓事项
- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
- **原生文件夹选择依赖本地 Host 载体**:仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。
- **原生文件夹选择依赖本地 Host 载体**`-native` 组合下,仅使用 fixture测试前置数据的部署或远程浏览器部署无法打开本地操作系统对话框模态框会显示平台故障并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

View File

@@ -253,7 +253,8 @@ export function WorkspaceBrowser({
deleteWorkspace,
insertSessionBefore,
createWorkspace,
pickDirectory,
useDirectoryFlow,
renderSlot,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
@@ -371,7 +372,8 @@ export function WorkspaceBrowser({
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
useDirectoryFlow={useDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('sidebar.workspaces.directoryFlow', owner)}
createOnly
side="right"
onPick={(workspaceId) => {

View File

@@ -2,10 +2,12 @@
* Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
* (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
* package) and wrapped by WorkspacePicker for the conversation empty-state
* slot registration.
* slot registration. Directory picking itself lives in the composed flow
* package's slot occupant (see the contract module doc): this core only
* opens the flow, adopts the picked path, and owns the error surface.
*/
import type { RefObject } from 'react'
import { useCallback, useRef, useState } from 'react'
import type { ReactNode, RefObject } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
@@ -13,7 +15,8 @@ import {
WorkspaceCreateError,
type WorkspaceId, type WorkspaceListState, type WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerProps } from './contract/slots.ts'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
const OPEN_LOCAL_FOLDER = '::open-local-folder'
@@ -31,8 +34,10 @@ export interface WorkspaceCreateFlowProps {
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
/** Create or adopt a real Host Workspace. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Open the Host's native single-directory picker. */
pickDirectory: () => Promise<string | null>
/** Bound occupancy selector hook for this surface's directory-flow hole (empty hides the local-folder entry). */
useDirectoryFlow: SnapshotSelectorHook<boolean>
/** Render this surface's directory-flow hole with the owner conversation (the entry's narrowed renderSlot). */
renderDirectoryFlow: (owner: DirectoryFlowOwnerProps) => ReactNode
/** A real Workspace was picked or created. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
@@ -55,7 +60,8 @@ export function WorkspaceCreateFlow({
anchorRef,
useWorkspaces,
createWorkspace,
pickDirectory,
useDirectoryFlow,
renderDirectoryFlow,
onPick,
onClose,
createOnly = false,
@@ -72,16 +78,37 @@ export function WorkspaceCreateFlow({
const [workspaceName, setWorkspaceName] = useState('')
const [creating, setCreating] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
const [flowOpen, setFlowOpen] = useState(false)
const [pickingFolder, setPickingFolder] = useState(false)
const [folderConflict, setFolderConflict] = useState(false)
const composingRef = useRef(false)
// One picking interaction at a time: while the flow is open (native chooser
// pending, browse dialog up) or its pick is being adopted, every other
// menu action stays disabled — a late outcome must not race a concurrent
// selection or creation.
const flowBusy = flowOpen || pickingFolder
const normalizedWorkspaceName = workspaceName.trim()
const duplicateWorkspaceName = !creating && normalizedWorkspaceName !== ''
&& workspaces.some(workspace => workspace.title === normalizedWorkspaceName)
// The occupied hole gates the picking affordance: with no composed flow the
// entry simply is not there (the seam's documented no-flow default). The
// framework-bound hook keeps occupancy live: flow plugins activate (and
// HMR-reload) independently of this menu's renders.
const flowAvailable = useDirectoryFlow(occupied => occupied)
// An occupant that unloads mid-interaction leaves nobody to cancel: an
// open flow over an empty hole withdraws so the menu actions come back.
// flowOpen is a dependency because the flow can also OPEN over an already
// empty hole (Choose again after the occupant unloaded with the error
// dialog up) — that transition must snap back too, not just occupancy loss.
useEffect(() => {
if (flowOpen && !flowAvailable) setFlowOpen(false)
}, [flowOpen, flowAvailable])
const createEntries: MenuEntry[] = [
{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: pickingFolder },
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: pickingFolder },
...(flowAvailable
? [{ id: OPEN_LOCAL_FOLDER, label: 'Open local folder…', icon: <IconFolderClose16 size={16} />, disabled: flowBusy }]
: []),
{ id: CREATE_NEW, label: 'Create a new workspace', icon: <IconPlusOutline16 size={16} />, disabled: flowBusy },
]
// With workspaces listed, the create actions pin below the scroll region
// (divider + always visible); otherwise they ARE the menu.
@@ -91,7 +118,7 @@ export function WorkspaceCreateFlow({
id: workspace.workspaceId,
label: workspace.title,
icon: <IconFolderClose16 size={16} />,
disabled: pickingFolder,
disabled: flowBusy,
}))
: createEntries
@@ -101,15 +128,10 @@ export function WorkspaceCreateFlow({
setModalError(null)
}
const openLocalFolder = (): void => {
onClose()
setModalKind(null)
setModalError(null)
setFolderConflict(false)
setPickingFolder(true)
void pickDirectory().then(async (path) => {
if (path === null) return
const workspace = await createWorkspace({ path })
/** Adopt a picked directory; failures land in the folder-error dialog (Choose again reopens the flow). */
const adoptDirectory = (path: string): Promise<void> =>
createWorkspace({ path }).then((workspace) => {
setFlowOpen(false)
onPick(workspace.workspaceId)
}).catch((reason: unknown) => {
setFolderConflict(
@@ -117,8 +139,33 @@ export function WorkspaceCreateFlow({
&& reason.rpcError.code === 'workspace-name-conflict',
)
setModalError(reason instanceof Error ? reason.message : String(reason))
setFlowOpen(false)
setModalKind('folder-error')
}).finally(() => { setPickingFolder(false) })
})
const openLocalFolder = (): void => {
onClose()
setModalKind(null)
setModalError(null)
setFolderConflict(false)
setFlowOpen(true)
}
/** Owner side of the flow conversation: adopt keeps the flow open (busy) until the Host answers. */
const flowOwner: DirectoryFlowOwnerProps = {
open: flowOpen,
busy: pickingFolder,
onPicked: (path) => {
setPickingFolder(true)
void adoptDirectory(path).finally(() => { setPickingFolder(false) })
},
onCancel: () => { setFlowOpen(false) },
onError: (message) => {
setFlowOpen(false)
setFolderConflict(false)
setModalError(message)
setModalKind('folder-error')
},
}
const handleSelect = (id: string): void => {
@@ -172,6 +219,7 @@ export function WorkspaceCreateFlow({
getAnchorRect={getAnchorRect}
/>
{open && workspaceSnapshot.phase === 'pending' && <div className={css.menuStatus} role="status">Loading workspaces</div>}
{renderDirectoryFlow(flowOwner)}
<Modal
open={modalKind === 'folder-error'}
onClose={closeModal}
@@ -179,7 +227,9 @@ export function WorkspaceCreateFlow({
footer={(
<>
<Button variant="outline" className={css.modalAction} onClick={closeModal}>Cancel</Button>
<Button variant="primary" className={css.modalAction} onClick={openLocalFolder}>Choose again</Button>
{/* Retrying needs an occupant to serve the flow; without one the
* button would open a flow nobody can answer or cancel. */}
<Button variant="primary" className={css.modalAction} disabled={!flowAvailable} onClick={openLocalFolder}>Choose again</Button>
</>
)}
>
@@ -249,7 +299,8 @@ export function WorkspacePicker({
onPick,
onClose,
createWorkspace,
pickDirectory,
useDirectoryFlow,
renderSlot,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
@@ -257,7 +308,8 @@ export function WorkspacePicker({
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
useDirectoryFlow={useDirectoryFlow}
renderDirectoryFlow={owner => renderSlot('conversation.hero.workspace.directoryFlow', owner)}
selectedId={selectedId}
onPick={onPick}
onClose={onClose}

View File

@@ -7,8 +7,19 @@
* consumes the shell's two-fact owner share (wide / expandSidebar).
* - WorkspacePicker fills the conversation empty-state hole (menu +
* create dialogs shared with the browser).
*
* Each registration also declares one **directory-flow hole** (`single`
* kind): the slot a composed picker package's client half fills with its
* picking interaction — a renderless native-chooser driver or an in-app
* browsing dialog. ui-workspace owns the trigger (the "Open local folder…"
* menu entry, shown only while the hole is occupied) and the adoption
* semantics (`createWorkspace({ path })`, the conflict/error dialog, Choose
* again); the occupant owns everything between `open` and the picked path.
* Two holes exist because the two menu surfaces are independent slot entries
* and a hole has exactly one declaring entry — they carry the same owner
* contract and the same occupant.
*/
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull the owner SlotMap merges into programs that resolve the
// runtime shares below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
@@ -16,12 +27,64 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
* Owner share of the directory-flow holes: the complete conversation between
* the trigger surface and the picking interaction. The occupant reads `open`
* to run/render its interaction and reports exactly one outcome per open.
*/
export interface DirectoryFlowOwnerProps {
/** True while a picking interaction is requested; flipping back to false withdraws the request. */
open: boolean
/** True while the owner adopts a picked path (`createWorkspace` in flight); occupants disable their commit affordances. */
busy: boolean
/** The operator picked a directory (absolute host path); the owner adopts it. */
onPicked: (path: string) => void
/** The operator dismissed the interaction; the owner just closes the flow. */
onCancel: () => void
/** The interaction itself failed (chooser missing, listing denied); the owner shows its error surface. */
onError: (message: string) => void
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** Directory-flow hole under the conversation empty-state picker (declared by the WorkspacePicker entry). */
'conversation.hero.workspace.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps }
/** Directory-flow hole under the sidebar browsing region (declared by the WorkspaceBrowser entry). */
'sidebar.workspaces.directoryFlow': { kind: 'single'; scope: 'root'; owner: DirectoryFlowOwnerProps }
}
}
/** The two directory-flow holes; a flow package's client half registers its one component into both. */
export type DirectoryFlowSlotName =
| 'conversation.hero.workspace.directoryFlow'
| 'sidebar.workspaces.directoryFlow'
/**
* Directory-picking share both trigger surfaces consume. Occupancy rides the
* inject face's reserved `hooks` compartment: the renderer binds the source
* into the `useDirectoryFlow` selector hook, so an empty hole hides the
* "Open local folder…" entry reactively and the surface withdraws an open
* flow whose occupant unloaded mid-interaction (nobody is left to cancel).
*/
export type DirectoryPickingInjected = {
hooks: {
/** True while this surface's directory-flow hole is occupied. */
directoryFlow: HostObservable<boolean>
}
}
/** Component-side view of the picking share: the bound occupancy selector hook. */
export type DirectoryPickingHooks = {
/** Selector hook over this surface's directory-flow occupancy. */
useDirectoryFlow: SnapshotSelectorHook<boolean>
}
/**
* Browser-private injected share (arrives via the register inject factory).
* Data reads use the global framework hooks; these are the Host actions the
* browsing region drives.
*/
export type WorkspaceBrowserInjected = {
export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
/**
* Start a New Session in a Workspace: reuse-or-create its blank session
* and open it; with no workspace, clear the selection into the New Session
@@ -42,26 +105,24 @@ export type WorkspaceBrowserInjected = {
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
}
/** Full browser props: shell owner share + viewing store + injected actions. */
export type WorkspaceBrowserProps =
PropsRuntime<'sidebar.workspaces'>
& PropsRenderSlots<'sidebar.workspaces.directoryFlow'>
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
& WorkspaceBrowserInjected
& Omit<WorkspaceBrowserInjected, 'hooks'>
& DirectoryPickingHooks
/**
* Picker-private injected share. Pick semantics remain in the owner's onPick
* callback; this callback creates only the real Host Workspace. A type alias
* supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = {
export type WorkspacePickerInjected = DirectoryPickingInjected & {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** Ask the local Host to open its native single-directory picker. */
pickDirectory: () => Promise<string | null>
}
/**
@@ -70,4 +131,7 @@ export type WorkspacePickerInjected = {
* currency, so one composed type serves both registrations.
*/
export type WorkspacePickerProps =
PropsRuntime<'conversation.hero.workspace'> & WorkspacePickerInjected
PropsRuntime<'conversation.hero.workspace'>
& PropsRenderSlots<'conversation.hero.workspace.directoryFlow'>
& Omit<WorkspacePickerInjected, 'hooks'>
& DirectoryPickingHooks

View File

@@ -3,9 +3,13 @@
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
* and WorkspacePicker fills the conversation hero's picker hole
* (`conversation.hero.workspace` — both hero forms). Both read real Host
* Workspaces through the global useWorkspaces hook. Export discipline:
* Workspaces through the global useWorkspaces hook, and each declares its
* own `single` directory-flow child hole for the composed picker package's
* client half (see the contract module doc). Export discipline:
* packages/client/AGENTS.md.
*/
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
import { createWorkspaceViewStore } from './stores.ts'
@@ -13,6 +17,7 @@ import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
export type {
DirectoryFlowOwnerProps, DirectoryFlowSlotName, DirectoryPickingHooks, DirectoryPickingInjected,
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
} from './contract/slots.ts'
@@ -33,6 +38,14 @@ export const inject = ['slots', 'sessions', 'workspaces']
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
// Stable per-surface occupancy sources (the renderer's hook cache keys by
// source identity): true while the surface's directory-flow hole is filled.
const flowSource = (hole: 'sidebar.workspaces.directoryFlow' | 'conversation.hero.workspace.directoryFlow'): HostObservable<boolean> => ({
getSnapshot: () => ctx.slots.entries(hole).length > 0,
subscribe: listener => ctx.slots.subscribe(hole, listener),
})
const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow')
const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow')
const browserInjected = (): WorkspaceBrowserInjected => ({
// Explicit group actions keep their target; unscoped New Session rides
// the runtime's shared action (recent-Workspace projection inside).
@@ -44,48 +57,40 @@ export function apply(ctx: ClientContext): void {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
hooks: { directoryFlow: browserFlowSource },
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
pickDirectory: () => ctx.workspaces.pickDirectory(),
hooks: { directoryFlow: pickerFlowSource },
})
// Declaration-aware registration: each owner's declaring apply may activate
// after this one (entry activation order is unconstrained), and a register
// into an undeclared slot throws. Register once the declaration is on the
// ledger; the subscription also re-registers after an HMR collapse
// re-declares the slot (the cascade disposed our entry with it).
// Declaration-aware registration (deferRegistration): each owner's
// declaring apply may activate after this one, and a register into an
// undeclared slot throws; the deferral also re-registers after an HMR
// collapse re-declares the slot. Each registration declares its own
// directory-flow child hole in the same call (declaration = render
// authorization, one table).
ctx.effect(() => {
const registrations = [
{
name: 'sidebar.workspaces' as const,
component: WorkspaceBrowser,
register: () => ctx.slots.register(
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
const deferred = [
deferRegistration(ctx.slots, 'sidebar.workspaces', WorkspaceBrowser, () =>
ctx.slots.register(
{
name: 'sidebar.workspaces',
children: { 'sidebar.workspaces.directoryFlow': { kind: 'single', scope: 'root' } },
store: createWorkspaceViewStore(),
inject: browserInjected,
},
WorkspaceBrowser,
),
},
{
name: 'conversation.hero.workspace' as const,
component: WorkspacePicker,
register: () => ctx.slots.register(
{ name: 'conversation.hero.workspace', inject: pickerInjected },
)),
deferRegistration(ctx.slots, 'conversation.hero.workspace', WorkspacePicker, () =>
ctx.slots.register(
{
name: 'conversation.hero.workspace',
children: { 'conversation.hero.workspace.directoryFlow': { kind: 'single', scope: 'root' } },
inject: pickerInjected,
},
WorkspacePicker,
),
},
)),
]
const disposers = new Map<string, () => void>()
const tryRegister = (entry: (typeof registrations)[number]): void => {
if (ctx.slots.spec(entry.name) === undefined) return
if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
disposers.set(entry.name, entry.register())
}
const unsubscribers = registrations.map(entry =>
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
for (const entry of registrations) tryRegister(entry)
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
for (const dispose of disposers.values()) dispose()
}
return () => { for (const entry of deferred) entry.dispose() }
}, 'ui-workspace: browser + picker registrations')
}

View File

@@ -14,17 +14,16 @@ async function bench() {
path: 'name' in input ? `/projects/${input.name}` : input.path,
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
const pickDirectory = vi.fn(async () => '/tmp/picked')
const startSession = vi.fn()
const rename = vi.fn(async () => ({}))
const insertSessionBefore = vi.fn(async () => ({}))
const open = vi.fn()
const clear = vi.fn()
ctx.provide('workspaces', {
create, pickDirectory, startSession, rename, insertSessionBefore,
create, startSession, rename, insertSessionBefore,
} as never)
ctx.provide('sessions', { open, clear } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, create, pickDirectory, startSession, rename, insertSessionBefore, open, clear }
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear }
}
type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace'
@@ -73,14 +72,35 @@ describe('ui-workspace apply', () => {
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
await browser.createWorkspace({ name: 'project' })
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
await browser.pickDirectory()
expect(b.pickDirectory).toHaveBeenCalledOnce()
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
await picker.createWorkspace({ path: '/tmp/project' })
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
await picker.pickDirectory()
expect(b.pickDirectory).toHaveBeenCalledTimes(2)
})
it('declares the two directory-flow holes and reports their occupancy per surface', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace')
await b.ctx.plugin({ inject: [...inject], apply }).await()
// Registration declared the child holes (declaration = render authorization).
expect(b.slots.spec('sidebar.workspaces.directoryFlow')).toMatchObject({ kind: 'single' })
expect(b.slots.spec('conversation.hero.workspace.directoryFlow')).toMatchObject({ kind: 'single' })
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)()
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
// A flow occupant flips exactly its own surface, and the source notifies.
const notified = vi.fn()
const unsubscribe = browser.hooks.directoryFlow.subscribe(notified)
const dispose = b.slots.register({ name: 'sidebar.workspaces.directoryFlow' } as never, () => null)
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(true)
expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false)
await Promise.resolve()
expect(notified).toHaveBeenCalled()
dispose()
expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false)
unsubscribe()
})
it('unregisters every entry on teardown', async () => {

View File

@@ -59,7 +59,8 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
deleteWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
pickDirectory: vi.fn(async () => null),
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ? <div data-testid="directory-flow" /> : null)) as never,
...overrides,
}
const view = render(<WorkspaceBrowser {...props} />)

View File

@@ -5,6 +5,8 @@ import type {
SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { WorkspaceCreateError } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryFlowOwnerProps } from '../src/client/contract/slots.ts'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
afterEach(cleanup)
@@ -35,14 +37,49 @@ function anchor(): { current: HTMLElement } {
return { current: element }
}
/**
* Probe occupant of the directory-flow hole: records the latest owner
* conversation so tests drive onPicked/onCancel/onError like a composed flow
* package would, and renders a marker element while the flow is open.
*/
function flowProbe() {
const probe: { owner: DirectoryFlowOwnerProps | undefined } = { owner: undefined }
const renderSlot = ((_name: string, owner: DirectoryFlowOwnerProps) => {
probe.owner = owner
return owner.open ? <div data-testid="directory-flow" data-busy={owner.busy} /> : null
}) as never
return { probe, renderSlot }
}
/** Manual occupancy source bound like the renderer would: flip() drives the hook like a real registration change. */
function occupancySource(initial = true) {
let occupied = initial
const listeners = new Set<() => void>()
const useDirectoryFlow = bindSnapshotSelector({
getSnapshot: () => occupied,
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
})
return {
useDirectoryFlow,
flip: (next: boolean) => {
occupied = next
for (const listener of [...listeners]) listener()
},
}
}
function mount(
items: readonly WorkspaceView[] = [workspace('alpha', 'Alpha')],
createWorkspace = vi.fn(),
pickDirectory = vi.fn(async () => null as string | null),
occupancy = occupancySource(),
) {
const onPick = vi.fn()
const onClose = vi.fn()
const anchorRef = anchor()
const { probe, renderSlot } = flowProbe()
const renderPicker = (nextItems: readonly WorkspaceView[]) => (
<WorkspacePicker
open
@@ -52,14 +89,15 @@ function mount(
onPick={onPick}
onClose={onClose}
createWorkspace={createWorkspace}
pickDirectory={pickDirectory}
useDirectoryFlow={occupancy.useDirectoryFlow}
renderSlot={renderSlot}
/>
)
const view = render(
renderPicker(items),
)
return {
view, onPick, onClose, createWorkspace, pickDirectory,
view, onPick, onClose, createWorkspace, probe, occupancy,
rerenderItems: (nextItems: readonly WorkspaceView[]) => { view.rerender(renderPicker(nextItems)) },
}
}
@@ -87,65 +125,75 @@ describe('WorkspacePicker', () => {
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
})
it('opens a native directory picker, adopts its path, and selects the returned Workspace', async () => {
it('opens the composed directory flow, adopts its picked path, and selects the returned Workspace', async () => {
const created = { ...workspace('adopted'), path: '/tmp/project', title: 'project' }
const createWorkspace = vi.fn(async () => created)
const pickDirectory = vi.fn(async () => '/tmp/project')
const b = mount([], createWorkspace, pickDirectory)
const b = mount([], createWorkspace)
expect(screen.queryByTestId('directory-flow')).toBeNull()
chooseItem('Open local folder…')
expect(pickDirectory).toHaveBeenCalledOnce()
await waitFor(() => { expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' }) })
expect(b.onClose).toHaveBeenCalled()
expect(screen.getByTestId('directory-flow')).toBeTruthy()
await act(async () => { b.probe.owner!.onPicked('/tmp/project') })
expect(createWorkspace).toHaveBeenCalledWith({ path: '/tmp/project' })
await waitFor(() => { expect(b.onPick).toHaveBeenCalledWith(created.workspaceId) })
// Successful adoption withdraws the flow request.
expect(screen.queryByTestId('directory-flow')).toBeNull()
})
it('treats native picker cancellation as a silent no-op', async () => {
const b = mount([], vi.fn(), vi.fn(async () => null))
it('treats flow cancellation as a silent no-op', () => {
const b = mount([])
chooseItem('Open local folder…')
await waitFor(() => { expect(b.pickDirectory).toHaveBeenCalledOnce() })
act(() => { b.probe.owner!.onCancel() })
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(b.createWorkspace).not.toHaveBeenCalled()
expect(b.onPick).not.toHaveBeenCalled()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('shows a name conflict and retries through the native picker', async () => {
const pickDirectory = vi.fn()
.mockResolvedValueOnce('/one/project')
.mockResolvedValueOnce(null)
it('shows a name conflict and retries by reopening the flow', async () => {
const createWorkspace = vi.fn(async () => {
throw new WorkspaceCreateError({
code: 'workspace-name-conflict', message: 'project already exists', details: { name: 'project' },
})
})
const b = mount([], createWorkspace, pickDirectory)
const b = mount([], createWorkspace)
chooseItem('Open local folder…')
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => {
expect(screen.getByRole('dialog', { name: 'A workspace with this name already exists' })).toBeTruthy()
})
expect(screen.getByRole('alert').textContent).toBe('Choose a folder with a different name.')
// The failed adoption withdrew the flow; Choose again reopens it.
expect(b.probe.owner!.open).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Choose again' }))
await waitFor(() => { expect(pickDirectory).toHaveBeenCalledTimes(2) })
expect(b.probe.owner!.open).toBe(true)
expect(b.onPick).not.toHaveBeenCalled()
})
it('disables the folder action while the native picker is already open', async () => {
let resolve!: (path: string | null) => void
const pending = new Promise<string | null>((settle) => { resolve = settle })
const b = mount([], vi.fn(), vi.fn(() => pending))
it('disables every menu action from flow open through adoption, and reports busy to the flow', async () => {
let resolve!: (workspace: WorkspaceView) => void
const pending = new Promise<WorkspaceView>((settle) => { resolve = settle })
const created = workspace('adopted')
const b = mount([workspace('alpha', 'Alpha')], vi.fn(() => pending))
chooseItem('Open local folder…')
// The flow is open but nothing is picked yet: a chooser pending on the
// host display must already block concurrent workspace actions.
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Alpha' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
act(() => { b.probe.owner!.onPicked('/tmp/project') })
expect(b.probe.owner!.busy).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Open local folder…' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(true)
fireEvent.click(screen.getByRole('menuitem', { name: 'Open local folder…' }))
expect(b.pickDirectory).toHaveBeenCalledTimes(1)
await act(async () => { resolve(null); await pending })
await act(async () => { resolve(created); await pending })
expect(b.probe.owner!.busy).toBe(false)
})
it('reports non-Error native picker failures', async () => {
const b = mount([], vi.fn(), vi.fn(async () => { throw 'picker unavailable' }))
it('shows the flow-reported failure in the folder-error surface', () => {
const b = mount([])
chooseItem('Open local folder…')
await waitFor(() => {
expect(screen.getByRole('alert').textContent).toBe('picker unavailable')
})
act(() => { b.probe.owner!.onError('no chooser installed') })
expect(screen.getByRole('alert').textContent).toBe('no chooser installed')
expect(screen.queryByTestId('directory-flow')).toBeNull()
expect(b.createWorkspace).not.toHaveBeenCalled()
})
@@ -215,10 +263,12 @@ describe('WorkspacePicker', () => {
})
it('waits to show its menu until an optional anchor is available', () => {
const { renderSlot } = flowProbe()
render(
<WorkspacePicker
open useSessions={hook(sessions)} useWorkspaces={hook(workspaceState([]))}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
/>,
)
expect(screen.queryByRole('menu')).toBeNull()
@@ -228,12 +278,54 @@ describe('WorkspacePicker', () => {
const state: WorkspaceListState = {
...workspaceState([]), phase: 'pending', state: 'loading', baselinesReady: false,
}
const { renderSlot } = flowProbe()
render(
<WorkspacePicker
open anchorRef={anchor()} useSessions={hook(sessions)} useWorkspaces={hook(state)}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} pickDirectory={vi.fn()}
onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()}
useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot}
/>,
)
expect(screen.getByRole('status').textContent).toBe('Loading workspaces…')
})
it('hides the folder entry while the directory-flow hole is empty', () => {
mount([], vi.fn(), occupancySource(false))
expect(screen.getByRole('menuitem', { name: 'Create a new workspace' })).toBeTruthy()
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
it('shows the folder entry when a flow package activates after the first paint', () => {
const b = mount([], vi.fn(), occupancySource(false))
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
// Registration changes flow through the subscription, no re-render needed.
act(() => { b.occupancy.flip(true) })
expect(screen.getByRole('menuitem', { name: 'Open local folder…' })).toBeTruthy()
})
it('keeps Choose again inert while the flow occupant is gone, and snaps back a flow opened over an empty hole', async () => {
const b = mount([], vi.fn(async () => { throw new Error('adoption failed') }))
chooseItem('Open local folder…')
await act(async () => { b.probe.owner!.onPicked('/one/project') })
await waitFor(() => { expect(screen.getByRole('dialog', { name: 'Couldnt open folder' })).toBeTruthy() })
// The occupant unloads while the error dialog is up: retrying would open
// a flow nobody can serve or cancel, so the button goes inert.
act(() => { b.occupancy.flip(false) })
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Choose again' }).disabled).toBe(true)
// Cancel stays the way out, and the menu actions are usable again.
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
})
it('withdraws an open flow when its occupant unloads, re-enabling the menu actions', () => {
const b = mount([])
chooseItem('Open local folder…')
expect(screen.getByTestId('directory-flow')).toBeTruthy()
// The flow plugin unloads mid-interaction (HMR): nobody is left to
// cancel, so the owner withdraws and the actions come back.
act(() => { b.occupancy.flip(false) })
expect(b.probe.owner!.open).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('menuitem', { name: 'Create a new workspace' }).disabled).toBe(false)
expect(screen.queryByRole('menuitem', { name: 'Open local folder…' })).toBeNull()
})
})

View File

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

View File

@@ -264,6 +264,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'directoryPicker',
summary: 'Abstract directory-picking service.',
methods: [
{
signature: 'abstract capability(): DirectoryPickerCapability',
jsDoc: '/**\n * The backend\'s interaction capability.\n * @returns the discriminated capability consumers switch on.\n */',
},
],
},
{
key: 'fs',
summary: 'Abstract filesystem provider.',
@@ -1649,6 +1659,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'DiffResultView',
declaration: 'export interface DiffResultView {\n card: \'diff\';\n title?: string;\n diffs: FileDiff[];\n}',
},
{
name: 'DirectoryEntry',
declaration: 'export interface DirectoryEntry {\n name: string;\n path: string;\n hidden: boolean;\n}',
},
{
name: 'DirectoryListing',
declaration: 'export interface DirectoryListing {\n path: string;\n home: string;\n crumbs: DirectoryEntry[];\n entries: DirectoryEntry[];\n truncated: boolean;\n}',
},
{
name: 'DirectoryPickerBrowseCapability',
declaration: 'export interface DirectoryPickerBrowseCapability {\n kind: \'browse\';\n list(path?: string, signal?: AbortSignal): Promise<DirectoryListing>;\n createDirectory(path: string, name: string): Promise<string>;\n}',
},
{
name: 'DirectoryPickerCapabilities',
declaration: 'export interface DirectoryPickerCapabilities {\n native: DirectoryPickerNativeCapability;\n browse: DirectoryPickerBrowseCapability;\n}',
},
{
name: 'DirectoryPickerCapability',
declaration: 'export type DirectoryPickerCapability = DirectoryPickerCapabilities[keyof DirectoryPickerCapabilities];',
},
{
name: 'DirectoryPickerNativeCapability',
declaration: 'export interface DirectoryPickerNativeCapability {\n kind: \'native\';\n pick(signal: AbortSignal): Promise<string | null>;\n}',
},
{
name: 'Domain',
declaration: 'export interface Domain<S extends DomainSpec> {\n readonly name: string;\n readonly global: DomainGlobalHandleOf<S>;\n table<N extends keyof S[\'tables\'] & string>(name: N): KvTable<TableKeyOf<S, N>, TableValueOf<S, N>>;\n close(): Promise<void>;\n}',

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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: d44770f70be16c12f44b78155089e092a3e9bba0
README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014

15
packages/host/README.md Normal file
View File

@@ -0,0 +1,15 @@
# host/ — web-GUI host half
English | [中文](README.zh.md)
The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages.
| Package | Role | ctx key |
|---|---|---|
| `apiproxy/` | The shared API gateway: the zero-Node TS wire contract (`src/api/`), the fetch carrier pair (`toFetchHandler` host-side, `AbstractApiClient` client-side), and the host implementation over `ctx.agents`/`ctx.workspace` | `ctx.apiProxy` |
| `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/` | 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.

View File

@@ -0,0 +1,15 @@
# host/ — web GUI 宿主半侧
[English](README.md) | 中文
dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。
| 包 | 角色 | ctx 键 |
|---|---|---|
| `apiproxy/` | 共享 API 网关:零 Node 依赖的 TS 协议契约(`src/api/`、fetch 载体对(宿主侧 `toFetchHandler`、客户端侧 `AbstractApiClient`),以及基于 `ctx.agents``ctx.workspace` 的宿主实现 | `ctx.apiProxy` |
| `webserver/` | 纯 HTTP 路由注册载体:激活即监听的 `node:http` 服务器;路由以命名的 `exact``prefix` 处理器注册 | `ctx.httpServer` |
| `directory-picker/` | 工作区目录选择 seam网关的 picker RPC 委托的可辨识 `native``browse` 能力 | `ctx.directoryPicker` |
| `directory-picker-native/` | 双面原生交互OS 选择器后端osascriptPowerShellZenity+KDialog仅宿主屏幕可用+ 填入 ui-workspace 目录流 slot 的 browser half | (注册 `ctx.directoryPicker` |
| `directory-picker-browse/` | 双面浏览交互:基于 Node 标准库的列举/创建原语(可远程)+ 渲染应用内选择工作区目录对话框的 browser half | (注册 `ctx.directoryPicker` |
`apiproxy` 在设计上与传输方式无关——它不注册任何路由;载体自行包装 `ctx.apiProxy`。HTTP 载体路由(连同其 `/api` 浏览器信任栅栏)由 [`client/connection`](../client/connection/README.md) 的 node 半侧挂载,这正是该包住在 client 组的原因:它拥有这条线的两端。

View File

@@ -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/apiproxy/README.md
README.md: c20887b73b9b9deb278db30d34d84df07257d664
README.zh.md: 18a2f97477e5f57127371429b0dd59ba01f04341
README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74
README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9

View File

@@ -6,7 +6,7 @@ The API gateway every client shape shares: the TS contract (`src/api/`, zero Nod
## Contract layer (`/api`)
Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/<method>` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload<K>`/`ResponseValue<K>`. Zod schemas anchor `satisfies z.ZodType<Wire<T>>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier.
Wire messages form a four-quadrant discriminated union — who initiates × request/response — decoupled from the physical channel: `ClientRequest` (POST `/api/<method>` body), `ServerResponse` (that POST's response body), `ServerRequest` (SSE frame), `ClientResponse` (POST `/api/respond` body). Responses always echo the matching request's `rpcId` and never mint a new one. Method parameter/return structures live only in the domain interface signatures (`SessionsApi`, `HostApi`, `EventsApi`); `RpcMethodMap` registers the methods and every other position derives via `RequestPayload<K>`/`ResponseValue<K>`. Zod schemas anchor `satisfies z.ZodType<Wire<T>>` and parse at two levels: envelope first, business payload second, dispatched per method. Business errors ride `RpcResult`'s error branch (`RpcErrorDetailsMap` closes the code set); HTTP status expresses only the carrier. Every `/api` POST must declare the `application/json` media type — anything else is refused with 415 before dispatch, so cross-site "simple" requests (which browsers send without a CORS preflight) can never execute a side-effectful method blind.
The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
@@ -18,7 +18,7 @@ Session model routing is a session-domain contract. `session.models` returns the
Workspace and Session lists are separate reconnect baselines. `workspace.create` creates a unique name or adopts an existing directory, `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
`host.pickDirectory` opens one native directory picker and returns its selected path, or `null` when the user cancels. Its host implementation invokes platform tools without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux. The picker function is injectable for tests. This user-paced method is the sole unary call exempt from the default 30-second timeout; caller and connection aborts still propagate to the native process. The browser carrier separately restricts this privileged method to loopback, same-origin requests.
Directory picking delegates to the composed `ctx.directoryPicker` backend ([the directory-picker seam](../directory-picker/README.md)); a method called outside the composed capability's kind fails with `directory-picker-unavailable` (the client needs no advertisement — the composed picker package's own client half renders the matching interaction). Under `native`, `host.pickDirectory` opens one native chooser and returns its selected path (`null` on cancel); this user-paced method is the sole unary call exempt from the default 30-second timeout, and caller/connection aborts still propagate to the native process. Under `browse`, `host.listDirectory` returns one name-sorted directory level with breadcrumb ancestry, a `home` anchor, and host-owned `hidden` flags (absent path = home directory), and `host.createDirectory` creates one validated child segment; the backend's typed failures map 1:1 onto the `directory-unreadable`/`directory-exists`/`directory-create-failed` codes. The browser carrier's prefix-wide trust fence (dsh-client-connection) covers all of these like every other `/api` request.
`host.openPath` opens a filesystem path with the operating system's default application (`open` on macOS, `Invoke-Item` on Windows, `xdg-open` on Linux). The opener is injectable for tests. The browser carrier applies the same loopback, same-origin restriction as `host.pickDirectory`.
@@ -41,4 +41,4 @@ None; this package neither assembles nor sends a provider request.
- **`respond` routing is shipped, but pending-interaction state is host-side work** — the wire shape (POST `/api/respond`, `RpcReceipt`) is final; the pending table that makes late/duplicate answers meaningful lives in `src/api-proxy.ts` and is still minimal (questions only, no approvals).
- **Reserved seams stay out of `RpcMethodMap`** — `session.fork`, `prompt.mode: 'inject'`, `task.list`, `host.listModels`, and a describe `hostInstanceId` are documented reservations; an unknown method fails loud at envelope parse rather than getting a not-implemented code.
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
- **Linux native picker requires desktop tooling** — `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; it does not fall back to a custom or typed-path browser.
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).

View File

@@ -6,7 +6,7 @@
## 契约层(`/api`
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`SSE 帧)和 `ClientResponse`POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi``HostApi``EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>``ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合HTTP 状态只表达载体层结果。
协议消息组成一个四象限可辨识联合:发起方 × 请求/响应,与物理通道解耦。四种消息分别是 `ClientRequest`POST `/api/<method>` 的请求体)、`ServerResponse`(该 POST 的响应体)、`ServerRequest`SSE 帧)和 `ClientResponse`POST `/api/respond` 的请求体)。响应始终回显对应请求的 `rpcId`,绝不签发新值。方法的参数与返回值结构只存在于领域接口签名(`SessionsApi``HostApi``EventsApi`)中;`RpcMethodMap` 注册方法,其他所有位置均通过 `RequestPayload<K>``ResponseValue<K>` 派生。Zod schema 以 `satisfies z.ZodType<Wire<T>>` 锚定类型,并分两层解析:先解析信封,再解析业务载荷,随后按方法分发。业务错误由 `RpcResult` 的错误分支承载(`RpcErrorDetailsMap` 封闭错误码集合HTTP 状态只表达载体层结果。每个 `/api` POST 都必须声明 `application/json` 媒体类型——否则在分发前即以 415 拒绝,因此跨站"简单请求"(浏览器不经 CORS 预检就会发出)永远无法盲目执行有副作用的方法。
分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
@@ -18,7 +18,7 @@
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create` 会创建唯一名称或接纳现有目录,`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id`host/workspace-changed``host/workspace-removed``host/session-added` 则以任意到达顺序携带已提交的增量。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank``host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
`host.pickDirectory` 会打开一个原生目录选择器并返回选中的路径;用户取消时返回 `null`。宿主实现不经 shell 调用平台工具macOS 使用 `osascript`Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`Linux 使用 Zenity并以 KDialog 作为回退。选择器函数可在测试中注入。该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用调用方发出的中止信号和连接中止仍会传播至原生进程。浏览器载体另行将这一特权方法限制为仅接受来自回环地址的同源请求
目录选择委托给组合的 `ctx.directoryPicker` 后端([目录选择 seam](../directory-picker/README.md));调用组合能力 kind 之外的方法会以 `directory-picker-unavailable` 失败(客户端不需要广播——组合的选择器包自己的 client half 渲染匹配的交互)。在 `native` 下,`host.pickDirectory` 打开一个原生选择器并返回选中路径(取消为 `null`该方法需等待用户完成操作,是唯一不受默认 30 秒超时限制的一元调用调用方连接中止仍会传播至原生进程。`browse` 下,`host.listDirectory` 返回一个按名称排序的目录层级,携带面包屑祖先链、`home` 锚点与宿主判定的 `hidden` 标志(不带路径即家目录),`host.createDirectory` 创建一个经校验的子段;后端的类型化失败 1:1 映射为 `directory-unreadable``directory-exists``directory-create-failed` 错误码。浏览器载体的前缀级信任栅栏dsh-client-connection像覆盖其他所有 `/api` 请求一样覆盖上述全部方法
`host.openPath` 会用操作系统的默认应用打开一个文件系统路径macOS 为 `open`Windows 为 `Invoke-Item`Linux 为 `xdg-open`)。打开器可在测试中注入。浏览器载体对其施加与 `host.pickDirectory` 相同的回环、同源限制。
@@ -41,4 +41,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
- **`respond` 路由已经发布,但待处理交互状态仍属宿主侧工作**协议形状POST `/api/respond``RpcReceipt`)已经定型;使延迟或重复回答具有明确语义的待处理表位于 `src/api-proxy.ts`,目前仍很精简(只支持问题,不支持审批)。
- **预留 seam 不进入 `RpcMethodMap`**`session.fork``prompt.mode: 'inject'``task.list``host.listModels` 和描述字段 `hostInstanceId` 都是已记录的预留项;未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
- **Linux 原生选择器依赖桌面工具**Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;它不会回退到自定义目录浏览器,也不会要求用户手动输入路径
- **Linux 原生选择器依赖桌面工具**`native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md)

View File

@@ -44,7 +44,9 @@
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",

View File

@@ -45,7 +45,7 @@ import type {
AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
} from '@deepseek-ai/dsh-user-interaction'
import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
import { pickNativeDirectory } from './native-directory-picker.ts'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import { openNativePath } from './native-path-opener.ts'
/** Page size when history is called without maxMessages. */
@@ -195,6 +195,14 @@ async function summarizeCold(persistence: SessionPersistence, meta: SessionHeade
}
}
/** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */
function directoryError(error: unknown): RpcError {
if (error instanceof DirectoryPickerError) {
return { code: error.code, message: error.message, details: { path: error.path } }
}
return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }
}
/** Resolved Host routing and project-directory defaults consumed by the API implementation. */
export interface ApiProxyDefaults {
provider: string
@@ -203,8 +211,6 @@ export interface ApiProxyDefaults {
cwd: string
/** Parent directory for name-created workspaces. */
workspaceRoot: string
/** Native single-directory picker; injectable for carrier tests. */
pickDirectory?: (signal: AbortSignal) => Promise<string | null>
/** Native open-with-default-application; injectable for carrier tests. */
openPath?: (path: string, signal: AbortSignal) => Promise<void>
}
@@ -1090,8 +1096,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
},
async pickDirectory(request, signal) {
const capability = ctx.directoryPicker.capability()
if (capability.kind !== 'native') {
return err(request, {
code: 'directory-picker-unavailable',
message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`,
details: { capability: capability.kind },
})
}
try {
const path = await (defaults.pickDirectory ?? pickNativeDirectory)(signal)
const path = await capability.pick(signal)
return ok(request, { path })
} catch (error: unknown) {
if (signal.aborted) {
@@ -1109,6 +1123,45 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async listDirectory(request, signal) {
const capability = ctx.directoryPicker.capability()
if (capability.kind !== 'browse') {
return err(request, {
code: 'directory-picker-unavailable',
message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
details: { capability: capability.kind },
})
}
try {
// The carrier's signal follows the caller: a disconnect or timeout
// stops the backend's directory scan instead of outliving it.
return ok(request, await capability.list(request.payload.path, signal))
} catch (error: unknown) {
// An abort is the caller's own timeout/disconnect, not a server
// failure — same code pickDirectory and command.execute report.
if (signal.aborted) {
return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} })
}
return err(request, directoryError(error))
}
},
async createDirectory(request) {
const capability = ctx.directoryPicker.capability()
if (capability.kind !== 'browse') {
return err(request, {
code: 'directory-picker-unavailable',
message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
details: { capability: capability.kind },
})
}
try {
return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) })
} catch (error: unknown) {
return err(request, directoryError(error))
}
},
async openPath(request, signal) {
try {
const open = defaults.openPath

View File

@@ -3,6 +3,7 @@
*/
import { z } from 'zod'
import type { DirectoryEntry } from './host.ts'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
@@ -16,6 +17,8 @@ export const hostDescribeValueSchema = z.object({
provider: z.string().optional(),
model: z.string().optional(),
attachedSessions: z.number().int().nonnegative(),
// Open string, not a literal union: unknown kinds must survive the wire so
// a merge-added capability can advertise (the client hides the affordance).
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
/** host.pickDirectory request payload (empty object literal). */
@@ -26,6 +29,41 @@ export const hostPickDirectoryValueSchema = z.object({
path: z.string().nullable(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.pickDirectory'>>>
/** Directory row shared by listing entries and breadcrumb crumbs. */
export const directoryEntrySchema = z.object({
name: z.string(),
path: z.string(),
hidden: z.boolean(),
}) satisfies z.ZodType<Wire<DirectoryEntry>>
/** host.listDirectory request payload; an absent path lists the home directory. */
export const hostListDirectoryRequestSchema = z.object({
path: z.string().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'host.listDirectory'>>>
/** host.listDirectory response value. */
export const hostListDirectoryValueSchema = z.object({
path: z.string(),
home: z.string(),
crumbs: z.array(directoryEntrySchema),
entries: z.array(directoryEntrySchema),
truncated: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.listDirectory'>>>
/** host.createDirectory request payload: name must be one plain path segment. */
export const hostCreateDirectoryRequestSchema = z.object({
path: z.string(),
name: z.string(),
}).refine(
payload => payload.name.trim() !== '' && payload.name !== '.' && payload.name !== '..'
&& !/[/\\]/.test(payload.name),
{ message: 'host.createDirectory requires a single non-blank path segment name' },
) satisfies z.ZodType<Wire<RequestPayload<'host.createDirectory'>>>
/** host.createDirectory response value: the created directory's absolute path. */
export const hostCreateDirectoryValueSchema = z.object({
path: z.string(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.createDirectory'>>>
/** host.openPath request payload. */
export const hostOpenPathRequestSchema = z.object({
path: z.string().min(1),

View File

@@ -5,6 +5,33 @@
import type { RpcRequest, RpcResponse } from './rpc.ts'
/** One directory row of a listing: a child entry or a breadcrumb ancestor. */
export interface DirectoryEntry {
/** Base name shown in a browser row (a root crumb carries its full path). */
name: string
/** Absolute host path — the client never joins path segments itself. */
path: string
/** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */
hidden: boolean
}
/** host.listDirectory response value: one directory level plus its ancestry. */
export interface DirectoryListing {
/** Absolute path of the listed directory. */
path: string
/** The host account's home directory (breadcrumb "Home" rooting). */
home: string
/**
* Ancestor chain from the filesystem root to the listed directory
* inclusive; every crumb is a jump target (crumb `hidden` is always false).
*/
crumbs: DirectoryEntry[]
/** Direct child directories, name-sorted; symlinks to directories included. */
entries: DirectoryEntry[]
/** True when the backend cut `entries` at its complete-result bound (the name-sorted tail is absent). */
truncated: boolean
}
/** Host-level unary methods. */
export interface HostApi {
/**
@@ -13,7 +40,7 @@ export interface HostApi {
* directory (root for session persistence and tool execution); provider/model = the defaults
* applied when a new agent doesn't specify them explicitly, absent when the host configures
* no explicit default (the adapter falls back internally);
* attachedSessions = count of currently attached sessions (those with a live agent).
* attachedSessions = count of currently attached sessions (those with a live agent);
*/
describe(request: RpcRequest<{}>): Promise<RpcResponse<{
version: string
@@ -23,16 +50,42 @@ export interface HostApi {
attachedSessions: number
}>>
/** Open the operating system's single-directory picker; cancellation returns null. */
/**
* Open the operating system's single-directory picker; cancellation returns
* null. Only served under the `native` capability.
*/
pickDirectory(
request: RpcRequest<{}>,
signal: AbortSignal,
): Promise<RpcResponse<{ path: string | null }>>
/**
* List one directory level for the in-app browser; an absent path lists the
* host account's home directory. Only served under the `browse` capability;
* unreadable or missing targets fail with `directory-unreadable`. The
* carrier's request signal follows the caller, stopping the backend's scan
* on disconnect or timeout.
*/
listDirectory(
request: RpcRequest<{ path?: string }>,
signal: AbortSignal,
): Promise<RpcResponse<DirectoryListing>>
/**
* Create one child directory under an existing parent (the browser's
* "New folder"). Only served under the `browse` capability; an existing
* child fails with `directory-exists`, every other filesystem failure with
* `directory-create-failed`.
*/
createDirectory(
request: RpcRequest<{ path: string; name: string }>,
): Promise<RpcResponse<{ path: string }>>
/**
* Open a filesystem path with the operating system's default application
* (Finder / Explorer / xdg-open hand-off). The browser carrier restricts this
* privileged method to loopback, same-origin requests.
* (Finder / Explorer / xdg-open hand-off). The browser carrier's
* prefix-wide trust fence covers this privileged method like every other
* `/api` request.
*/
openPath(
request: RpcRequest<{ path: string }>,

View File

@@ -31,7 +31,7 @@ export type {
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, SessionModels, SessionProjectionsBlock, SessionsApi, SessionSummary,
} from './sessions.ts'
export type { HostApi } from './host.ts'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
export type { CommandsApi, CommandDescriptor } from './commands.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'

View File

@@ -27,6 +27,8 @@ export interface RpcMethodMap {
'session.cancel': SessionsApi['cancel']
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']
'host.createDirectory': HostApi['createDirectory']
'host.openPath': HostApi['openPath']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']

View File

@@ -42,6 +42,10 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('unknown-command'), message: z.string(), details: z.object({}) }),

View File

@@ -39,6 +39,10 @@ export interface RpcErrorDetailsMap {
'workspace-invalid-path': { path: string }
'workspace-name-conflict': { name: string }
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
'directory-unreadable': { path: string }
'directory-exists': { path: string }
'directory-create-failed': { path: string }
'directory-picker-unavailable': { capability: string }
'agent-busy': { reason: string }
/** A known slash command reported a usage/state error; the message is the command's own text. */
'command-error': {}

View File

@@ -14,7 +14,8 @@ import type { Wire } from '../api/rpc.schema.ts'
import { rpcReceiptSchema, serverRequestSchema, serverResponseSchema } from '../api/rpc.schema.ts'
import { hostFrameSchema, muxFrameSchema } from '../api/events.schema.ts'
import {
hostDescribeValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema,
hostCreateDirectoryValueSchema, hostDescribeValueSchema,
hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema,
} from '../api/host.schema.ts'
import {
sessionCancelValueSchema,
@@ -71,6 +72,8 @@ export interface IApiClient {
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
listDirectory(payload: RequestPayload<'host.listDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.listDirectory'>>>
createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.createDirectory'>>>
openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.openPath'>>>
}
workspace: {
@@ -117,6 +120,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'session.cancel': sessionCancelValueSchema,
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
'host.createDirectory': hostCreateDirectoryValueSchema,
'host.openPath': hostOpenPathValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
@@ -331,6 +336,8 @@ export abstract class AbstractApiClient implements IApiClient {
// A native system dialog is user-paced and may legitimately stay open
// longer than the normal unary deadline. Caller/connection aborts remain.
pickDirectory: (payload, signal) => this.callUnary('host.pickDirectory', payload, signal, false),
listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal),
createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal),
openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal),
}

View File

@@ -2,8 +2,8 @@
* Server side of the fetch carrier: maps an ApiProxy onto a pure
* WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method +
* path==method) -> payload dispatched per method. HTTP status expresses only the carrier
* (404 unknown path / 400 non-JSON body / 500 handler crash); business errors are always
* 200 + ServerResponse.
* (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash);
* business errors are always 200 + ServerResponse.
*/
import { randomUUID } from 'node:crypto'
@@ -24,7 +24,9 @@ import {
sessionSelectModelRequestSchema,
} from '../api/sessions.schema.ts'
import {
hostDescribeRequestSchema, hostOpenPathRequestSchema, hostPickDirectoryRequestSchema,
hostCreateDirectoryRequestSchema, hostDescribeRequestSchema,
hostListDirectoryRequestSchema, hostOpenPathRequestSchema,
hostPickDirectoryRequestSchema,
} from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
@@ -70,6 +72,8 @@ const UNARY_ROUTES: UnaryRoutes = {
'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) },
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },
'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) },
'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
@@ -205,6 +209,17 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
return new Response('not found', { status: 404 })
}
// Cross-site write fence: browsers send "simple" POSTs (text/plain,
// form encodings) without a CORS preflight, so a malicious page could
// otherwise execute side-effectful RPCs blind — the response stays
// unreadable cross-origin, but session.prompt would still run. Only the
// JSON media type is accepted; anything else is forced into a preflight
// this server never answers. 415 = carrier layer, like the 400 below.
const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
if (mediaType !== 'application/json') {
return new Response('content type must be application/json', { status: 415 })
}
let body: unknown
try {
body = await req.json()

View File

@@ -45,7 +45,7 @@ export interface Config {
* project directory and the fallback parent for name-created Workspaces.
*/
export class ApiProxyService extends Service implements ApiProxy {
static inject = ['agents', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
static inject = ['agents', 'directoryPicker', 'llm', 'sessions', 'tools', 'userInteraction', 'workspace']
static Config: z<Config> = z.object({
provider: z.string().required(),

View File

@@ -1,6 +1,6 @@
/** Cross-platform open-with-default-application used by the local GUI carrier. */
import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
/** Testable command boundary; native implementations never invoke a shell. */
export type PathOpenerRunner = NativeCommandRunner

View File

@@ -3,13 +3,15 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, {} from '@deepseek-ai/dsh-agent'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import Storage from '@deepseek-ai/dsh-storage'
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -57,10 +59,8 @@ function stubAgent(session: Session): Agent {
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness(
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
extras: {
pickDirectory?: (signal: AbortSignal) => Promise<string | null>
openPath?: (path: string, signal: AbortSignal) => Promise<void>
} = {},
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -95,31 +95,34 @@ async function harness(
},
}
ctx.agents.setFactory(factory)
// Structural picker fake: the gateway only reads capability(); a stable
// object per harness mirrors the seam's stability contract.
ctx.provide('directoryPicker', { capability: () => picker } as never)
const api = createApiProxy(ctx, {
provider: 'test',
model: 'test-model',
cwd: workspaceRoot,
workspaceRoot,
...extras.pickDirectory === undefined ? {} : { pickDirectory: extras.pickDirectory },
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
})
return { api, ctx, storageDomain, workspaceRoot }
}
describe('host.pickDirectory', () => {
it('returns a selected path or explicit cancellation from the injected native boundary', async () => {
const selected = await harness(undefined, { pickDirectory: async () => '/tmp/project' })
it('returns a selected path or explicit cancellation from the native capability', async () => {
const selected = await harness(undefined, { kind: 'native', pick: async () => '/tmp/project' })
expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: '/tmp/project' } })
const cancelled = await harness(undefined, { pickDirectory: async () => null })
const cancelled = await harness(undefined, { kind: 'native', pick: async () => null })
expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: null } })
})
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
it('propagates abort into the native capability as a cancelled RPC error', async () => {
const { api } = await harness(undefined, {
pickDirectory: signal => new Promise((_resolve, reject) => {
kind: 'native',
pick: signal => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),
})
@@ -128,12 +131,97 @@ describe('host.pickDirectory', () => {
abort.abort()
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
})
it('folds a non-abort native-chooser failure into an internal error', async () => {
const { api } = await harness(undefined, { kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
const response = await api.host.pickDirectory(request({}), new AbortController().signal)
expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } })
})
it('refuses the native RPC under a browse composition', async () => {
const { api } = await harness(undefined, BROWSE_STUB)
const response = await api.host.pickDirectory(request({}), new AbortController().signal)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } },
})
})
})
/** Canned browse capability: one listing, one created path, typed failures on demand. */
const BROWSE_STUB: DirectoryPickerCapability = {
kind: 'browse',
list: async (path) => {
if (path === '/denied') throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied')
const target = path ?? '/home/user'
return {
path: target,
home: '/home/user',
crumbs: [{ name: '/', path: '/', hidden: false }],
entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }],
truncated: false,
}
},
createDirectory: async (path, name) => {
if (name === 'taken') throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists')
if (name === 'unwritable') throw new Error('disk detached')
return `${path}/${name}`
},
}
describe('host.listDirectory / host.createDirectory', () => {
it('serves listings and creation through the browse capability, defaulting to home', async () => {
const { api } = await harness(undefined, BROWSE_STUB)
const home = await api.host.listDirectory(request({}), new AbortController().signal)
expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } })
const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }), new AbortController().signal)
expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } })
const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' }))
expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } })
})
it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => {
const { api } = await harness(undefined, BROWSE_STUB)
expect((await api.host.listDirectory(request({ path: '/denied' }), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } },
})
expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({
ok: false, error: { code: 'directory-exists' },
})
expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result).toMatchObject({
ok: false, error: { code: 'internal' },
})
})
it('reports an aborted listing as cancelled, like the other signal-following RPCs', async () => {
const { api } = await harness(undefined, {
kind: 'browse',
list: (_path, signal) => new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
}),
createDirectory: async () => '/never',
})
const abort = new AbortController()
const pending = api.host.listDirectory(request({}), abort.signal)
abort.abort()
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
})
it('refuses the browse RPCs under a native composition', async () => {
const { api } = await harness()
expect((await api.host.listDirectory(request({}), new AbortController().signal)).result).toMatchObject({
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
})
expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
})
})
})
describe('host.openPath', () => {
it('opens through the injected native boundary', async () => {
const opened: string[] = []
const { api } = await harness(undefined, {
const { api } = await harness(undefined, undefined, {
openPath: async (path) => { opened.push(path) },
})
expect((await api.host.openPath(request({ path: '/tmp/a.txt' }), new AbortController().signal)).result)
@@ -142,7 +230,7 @@ describe('host.openPath', () => {
})
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
const { api } = await harness(undefined, {
const { api } = await harness(undefined, undefined, {
openPath: (_path, signal) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),

View File

@@ -53,6 +53,8 @@ function scriptedApi(overrides: {
host: {
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
pickDirectory: r => ok(r, { path: null }),
listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }),
createDirectory: r => ok(r, { path: '/t/new' }),
openPath: r => ok(r, { opened: true as const }),
...overrides.host,
},
@@ -151,7 +153,7 @@ describe('unary round trip', () => {
it('rejects a method/path mismatch as bad-request', async () => {
const handler = toFetchHandler(scriptedApi())
const body = { type: 'client-request', rpcId: 'r1', method: 'session.create', payload: {} }
const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify(body) })
const response = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
expect(response.status).toBe(200)
const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } }
expect(parsed.result.ok).toBe(false)
@@ -162,13 +164,13 @@ describe('unary round trip', () => {
it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
const handler = toFetchHandler(scriptedApi())
// No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ nonsense: true }) })
const noId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) })
expect(noId.status).toBe(200)
const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
expect(noIdParsed.result.ok).toBe(false)
expect(noIdParsed.rpcId).toBe('invalid-request')
// A string rpcId in the otherwise-bad body is salvaged for correlation.
const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
const withId = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
expect(withIdParsed.result.ok).toBe(false)
expect(withIdParsed.rpcId).toBe('salvage-me')
@@ -177,16 +179,34 @@ describe('unary round trip', () => {
it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
const handler = toFetchHandler(scriptedApi())
// Unknown method → 404.
const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', body: '{}' })
const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
expect(notFound.status).toBe(404)
// Non-JSON body → 400.
const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body: '{oops' })
const badBody = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' })
expect(badBody.status).toBe(400)
// Impl crash → 500, and through the client that is a throw, not an err result.
const crashing = scriptedApi({ sessions: { list: () => { throw new Error('impl exploded') } } })
await expect(client(crashing).sessions.list({})).rejects.toThrow(/transport failure .*500/)
})
it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => {
const list = vi.fn((r: RpcRequest<{}>) => ok(r, { items: [] }))
const handler = toFetchHandler(scriptedApi({ sessions: { list } }))
const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'session.list', payload: {} })
// A "simple" browser POST (text/plain — sent with no CORS preflight) is
// refused at the carrier before the impl runs.
const plain = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'text/plain' }, body })
expect(plain.status).toBe(415)
// A string body with no explicit header defaults to text/plain — same fence.
const unlabelled = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', body })
expect(unlabelled.status).toBe(415)
expect(list).not.toHaveBeenCalled()
// Media-type parameters pass: the fence checks the type, not the exact string.
const charset = await handler.fetch('http://dsh.internal/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body })
expect(charset.status).toBe(200)
expect(list).toHaveBeenCalledTimes(1)
})
it('rejects when the transport never resolves within timeoutMs', async () => {
// AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
const never = new InProcessApiClient({
@@ -495,7 +515,7 @@ describe('respond path', () => {
it('returns bad-response for a malformed client-response without reaching the impl', async () => {
const respond = vi.fn()
const handler = toFetchHandler(scriptedApi({ respond }))
const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', body: JSON.stringify({ type: 'client-response' }) })
const response = await handler.fetch('http://dsh.internal/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-response' }) })
expect(await response.json()).toEqual({ accepted: false, reason: 'bad-response' })
expect(respond).not.toHaveBeenCalled()
})

View File

@@ -81,6 +81,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async pickDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } }
},
async listDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false } } }
},
async createDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } }
},
async openPath(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
},
@@ -233,6 +239,19 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } })
})
it('round-trips the browse listing and creation calls through the wire form', async () => {
const c = client()
const listed = await c.host.listDirectory({ path: '/w' })
expect(listed.result).toEqual({
ok: true,
value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false },
})
const home = await c.host.listDirectory({})
expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } })
const created = await c.host.createDirectory({ path: '/w', name: 'fresh' })
expect(created.result).toEqual({ ok: true, value: { path: '/w/new' } })
})
it('round-trips host.openPath through the wire form', async () => {
const api = fakeApi()
let opened: string | undefined
@@ -263,7 +282,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-sig', method: 'command.execute', payload: { sessionId: 's', line: '/hang' } })
// The fake's /hang settles only when the invoke-level signal aborts: a
// completed response with the cancelled error proves req.signal reached it.
const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', body, signal: controller.signal }))
const pending = handler.fetch(new Request('http://x/api/command.execute', { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal }))
controller.abort()
const response = await pending
const parsed = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
@@ -288,7 +307,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const controller = new AbortController()
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} })
const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', {
method: 'POST', body, signal: controller.signal,
method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal,
}))
controller.abort()
const parsed = await (await pending).json() as { result: { error?: { code: string } } }
@@ -300,18 +319,18 @@ describe('handler carrier-layer statuses', () => {
const handler = toFetchHandler(fakeApi())
it('404s unknown paths and non-POST non-stream methods', async () => {
expect((await handler.fetch(new Request('http://x/other', { method: 'POST', body: '{}' }))).status).toBe(404)
expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404)
expect((await handler.fetch(new Request('http://x/api/session.list', { method: 'GET' }))).status).toBe(404)
expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
})
it('400s a non-JSON body', async () => {
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: 'not json' }))
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' }))
expect(response.status).toBe(400)
})
it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => {
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body: JSON.stringify({ nope: true }) }))
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) }))
expect(response.status).toBe(200)
const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
expect(body.rpcId).toBe('invalid-request')
@@ -320,7 +339,7 @@ describe('handler carrier-layer statuses', () => {
it('rejects a method/path mismatch echoing the envelope rpcId', async () => {
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'session.cancel', payload: {} })
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
const response = await handler.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } }
expect(parsed.rpcId).toBe('r-9')
expect(parsed.result.error?.message).toContain('does not match path')
@@ -328,7 +347,7 @@ describe('handler carrier-layer statuses', () => {
it('rejects an invalid payload with the zod issues attached', async () => {
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'session.cancel', payload: {} })
const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', body }))
const response = await handler.fetch(new Request('http://x/api/session.cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } }
expect(parsed.result.error?.code).toBe('bad-request')
expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0)
@@ -337,23 +356,23 @@ describe('handler carrier-layer statuses', () => {
it('500s when the impl itself throws', async () => {
const crashing = toFetchHandler(fakeApi({ crashOn: 'session.list' }))
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'session.list', payload: {} })
const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', body }))
const response = await crashing.fetch(new Request('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
expect(response.status).toBe(500)
expect(await response.text()).toContain('impl crashed')
})
it('routes /api/respond, rejecting malformed client-responses as a receipt', async () => {
const good = JSON.stringify({ type: 'client-response', rpcId: 'known', result: { ok: true, value: null } })
const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: good }))).json()
const goodReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: good }))).json()
expect(goodReceipt).toEqual({ accepted: true })
const bad = JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'x', payload: {} })
const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', body: bad }))).json()
const badReceipt: unknown = await (await handler.fetch(new Request('http://x/api/respond', { method: 'POST', headers: { 'content-type': 'application/json' }, body: bad }))).json()
expect(badReceipt).toEqual({ accepted: false, reason: 'bad-response' })
})
it('accepts (url, init) form fetch invocation', async () => {
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'session.list', payload: {} })
const response = await handler.fetch('http://x/api/session.list', { method: 'POST', body })
const response = await handler.fetch('http://x/api/session.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })
expect(response.status).toBe(200)
})
})

View File

@@ -12,7 +12,11 @@ import {
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
} from '../src/api/sessions.schema.ts'
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
import {
hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema,
hostDescribeRequestSchema, hostDescribeValueSchema,
hostListDirectoryRequestSchema, hostListDirectoryValueSchema,
} from '../src/api/host.schema.ts'
import {
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
@@ -227,6 +231,26 @@ describe('host domain schemas', () => {
expect(value.attachedSessions).toBe(2)
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
})
it('validates the browse listing/creation payloads', () => {
expect(hostListDirectoryRequestSchema.parse({})).toEqual({})
expect(hostListDirectoryRequestSchema.parse({ path: '/x' })).toEqual({ path: '/x' })
const listing = hostListDirectoryValueSchema.parse({
path: '/home/u/p',
home: '/home/u',
crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'p', path: '/home/u/p', hidden: false }],
entries: [{ name: '.dot', path: '/home/u/p/.dot', hidden: true }],
truncated: false,
})
expect(listing.entries[0]?.hidden).toBe(true)
// The flag is part of the wire value, not an optional decoration.
expect(() => hostListDirectoryValueSchema.parse({ path: '/x', home: '/x', crumbs: [], entries: [] })).toThrow()
expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' })
for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow()
}
expect(hostCreateDirectoryValueSchema.parse({ path: '/x/new' })).toEqual({ path: '/x/new' })
})
})
describe('workspace domain schemas', () => {

View File

@@ -56,8 +56,14 @@
{
"path": "../../workspace/workspace"
},
{
"path": "../directory-picker"
},
{
"path": "../../support/invariants"
},
{
"path": "../../util/native-command"
}
]
}

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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: 318380405214d5f25ad77e348c4e134a8981ffb3
README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754

View File

@@ -0,0 +1,23 @@
# @deepseek-ai/dsh-host-directory-picker-browse
English | [中文](README.zh.md)
The **in-app browsing backend** of the [directory-picker seam](../directory-picker/README.md): `BrowseDirectoryPicker` registers `ctx.directoryPicker` with the `browse` capability — one-level directory listing and child-directory creation over Node's stdlib, which already carries the per-OS adaptation. Nothing renders on the host display, so this backend serves remote clients the native backend cannot.
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.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **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.

View File

@@ -0,0 +1,23 @@
# @deepseek-ai/dsh-host-directory-picker-browse
[English](README.md) | 中文
[目录选择 seam](../directory-picker/README.md) 的**应用内浏览后端**`BrowseDirectoryPicker``browse` 能力注册 `ctx.directoryPicker`——基于 Node 标准库(跨 OS 适配本就由它承担)提供单层目录列举与子目录创建。宿主屏幕上不渲染任何东西,因此该后端能服务 native 后端无法触及的远程客户端。
行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `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 宿主的目录选择;这里没有任何内容进入模型请求。
#### KV 缓存影响
无;该包既不组装也不发送提供方请求。
## 已知限制与延期工作
- **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。
- **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。
- **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。

View File

@@ -0,0 +1,68 @@
{
"name": "@deepseek-ai/dsh-host-directory-picker-browse",
"description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"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:^",
"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",
"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:^",
"@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"
}
}

View File

@@ -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;
}

View File

@@ -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>
)
}

View File

@@ -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,
})
}

View File

@@ -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')
}

View File

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

View File

@@ -0,0 +1,324 @@
/**
* Browse backend of the directory-picker seam: registers `ctx.directoryPicker`
* with the `browse` capability — one-level directory listing and child-directory
* creation over the host filesystem via Node's stdlib (which already carries
* the per-OS adaptation). Nothing renders on the host display, so this backend
* serves remote clients the dialog backend cannot. Policy decisions (hidden
* entries flagged but returned, symlinks followed, whole-filesystem scope) are
* recorded in the directory-picker seam Agent Note.
* @module @deepseek-ai/dsh-host-directory-picker-browse
*/
import { mkdir, opendir, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { basename, dirname, join, posix, resolve, win32 } from 'node:path'
import type { Context } from 'cordis'
import z from 'schemastery'
import {
DirectoryPicker, DirectoryPickerError,
} from '@deepseek-ai/dsh-host-directory-picker'
import type {
DirectoryEntry, DirectoryListing, DirectoryPickerCapability,
} from '@deepseek-ai/dsh-host-directory-picker'
/**
* Ancestor chain from the filesystem root to `target` inclusive — the
* breadcrumb rows of a listing, every one a jump target.
*/
function ancestryCrumbs(target: string): DirectoryEntry[] {
const crumbs: DirectoryEntry[] = []
let current = target
for (;;) {
const parent = dirname(current)
// basename of a root is '' — label the root crumb by its full path ('/', 'C:\').
crumbs.unshift({ name: parent === current ? current : basename(current), path: current, hidden: false })
if (parent === current) return crumbs
current = parent
}
}
/**
* True when the path names one fixed filesystem location regardless of
* process state: POSIX-absolute on POSIX; on Windows only drive-qualified
* (`C:\…`) or complete UNC (`\\server\share…`) forms. Rooted drive-less
* forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`)
* pass `isAbsolute` yet still resolve against the process's current drive.
* @param path - candidate path.
* @param platform - replaces `process.platform` for deterministic tests.
* @returns whether the path is fully qualified on the platform.
*/
export function fullyQualified(path: string, platform: NodeJS.Platform = process.platform): boolean {
return platform === 'win32'
? win32.isAbsolute(path) && /^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)/.test(path)
: posix.isAbsolute(path)
}
/** One streamed listing candidate: the dirent facts a row needs, nothing else retained. */
export interface ListingCandidate {
/** Base name within the streamed level. */
name: string
/** Dirent says directory (no probe needed). */
isDirectory: boolean
/** Dirent says symlink (enterability needs a stat probe). */
isSymbolicLink: boolean
}
/**
* Insert a streamed candidate into the name-sorted bounded window, evicting
* the name-largest candidate when the window exceeds `keep`. Memory over an
* arbitrarily large level therefore stays O(keep) regardless of how many
* children the directory holds.
* @param window - the name-ascending window, mutated in place.
* @param candidate - the streamed candidate to place.
* @param keep - the window bound.
* @returns true when an eviction happened (the level has candidates beyond the window).
*/
export function boundedInsert(window: ListingCandidate[], candidate: ListingCandidate, keep: number): boolean {
// Full window, name at or beyond the tail: one comparison rejects, so an
// oversized level costs O(1) per candidate past the head instead of a
// window scan (100k children against a 1,001 window must not approach
// 10^8 comparisons).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- a full window (length === keep >= 1) has a tail
if (window.length === keep && candidate.name.localeCompare(window[window.length - 1]!.name) >= 0) return true
// Binary insertion keeps a retained candidate at O(log keep) comparisons.
let lo = 0
let hi = window.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
if (candidate.name.localeCompare(window[mid]!.name) < 0) hi = mid
else lo = mid + 1
}
window.splice(lo, 0, candidate)
if (window.length <= keep) return false
window.pop()
return true
}
/**
* Await `operation`, but reject with the signal's reason the moment it
* aborts. Node's filesystem reads are not retractable, so the operation
* itself keeps running against a handle the caller then closes — its late
* settlement is swallowed here so an abandoned read cannot surface as an
* unhandled rejection.
* @param operation - the in-flight filesystem step.
* @param signal - caller lifetime; absent means plain awaiting.
* @returns the operation's value.
*/
export function raceAbort<T>(operation: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (signal === undefined) return operation
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
operation.catch(() => {
// Abandoned read: its handle is being closed by the aborting caller,
// and the abort reason already carried the outcome.
})
reject(asError(signal.reason))
}
if (signal.aborted) {
onAbort()
return
}
signal.addEventListener('abort', onAbort, { once: true })
operation.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(reason: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(asError(reason))
},
)
})
}
/** The thrown value as an Error (wire/abort reasons may be anything). */
function asError(reason: unknown): Error {
return reason instanceof Error ? reason : new Error(String(reason))
}
/* v8 ignore start -- a close failure of an abandoned handle has no consumer, and forcing one needs a filesystem torn down mid-request. */
/** Swallow the close failure of a handle its caller already departed. */
function swallowCloseFailure(): void {}
/* v8 ignore stop */
/** Message text of an unknown thrown value. */
function messageOf(error: unknown): string {
/* v8 ignore next -- node:fs rejects with Error instances; the String arm only satisfies the unknown narrowing. */
return error instanceof Error ? error.message : String(error)
}
/**
* One listing row for a dirent, following symlinks to directories; null for
* non-directories and broken/cyclic links (skipped silently — the browser
* shows what can be entered, and a broken link cannot).
*/
async function directoryRow(
parent: string, name: string, isDirectory: boolean, isSymbolicLink: boolean, signal: AbortSignal | undefined,
): Promise<DirectoryEntry | null> {
const path = join(parent, name)
let enterable = isDirectory
if (!enterable && isSymbolicLink) {
try {
// The probe races the caller too: a symlink target on a stalled
// network filesystem must not keep a departed caller's request alive.
enterable = (await raceAbort(stat(path), signal)).isDirectory()
} catch {
/* v8 ignore next 2 -- an abort landing mid-probe needs a stalled stat; the per-candidate check in list covers the settled path. */
if (signal?.aborted) throw asError(signal.reason)
// Broken or cyclic symlink: stat is the probe, failure means "not enterable".
return null
}
}
if (!enterable) return null
// POSIX hidden convention; Windows' hidden attribute is not exposed by
// dirents (Known Limitations). The client owns whether hidden rows show.
return { name, path, hidden: name.startsWith('.') }
}
/** Validated plugin configuration. */
export interface Config {
/** Complete-result bound of one listing level; see {@link BrowseDirectoryPicker.Config}. */
maxEntries: number
}
/** The `ctx.directoryPicker` browse implementation (stable capability object per service life). */
export default class BrowseDirectoryPicker extends DirectoryPicker {
/**
* `maxEntries` bounds the complete listing level a single `list` call may
* materialize and put on the wire: at most this many child-directory rows
* (hidden rows included), with `truncated` flagging a cut level. The
* default follows GitHub's web UI, which truncates directory listings at
* 1,000 entries.
*/
static Config: z<Config> = z.object({
maxEntries: z.natural().min(1).default(1000),
})
private readonly browseCapability: DirectoryPickerCapability = {
kind: 'browse',
list: (path, signal) => this.list(path, signal),
createDirectory: (path, name) => this.createDirectory(path, name),
}
constructor(ctx: Context, private readonly config: Config) {
super(ctx)
}
/**
* The browse interaction capability.
* @returns the stable `browse` capability object.
*/
capability(): DirectoryPickerCapability {
return this.browseCapability
}
private async list(path?: string, signal?: AbortSignal): Promise<DirectoryListing> {
const home = homedir()
// The seam contract takes fully qualified paths only; resolve() would
// silently rebase a relative or empty wire value under the host process
// cwd (or, for rooted drive-less Windows forms, its current drive).
if (path !== undefined && !fullyQualified(path)) {
throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`)
}
const target = resolve(path ?? home)
// Stream the level (opendir, one dirent at a time) into a name-sorted
// window of maxEntries + 1 candidates: memory stays bounded no matter how
// many children the directory holds, the window keeps the name-sorted
// head, and the +1 slot lets an in-window extra row prove the cut. A
// window candidate that turns out non-enterable (broken symlink) is not
// backfilled from beyond the window — an eviction already marks the
// level truncated, which stays the honest answer.
const keep = this.config.maxEntries + 1
const window: ListingCandidate[] = []
let evicted = false
try {
// Every filesystem await races the caller's signal: a stalled
// opendir/read on a network filesystem must not keep a departed
// caller's scan alive, and an already-aborted request rejects even
// when the level is empty.
const opening = opendir(target)
const level = await raceAbort(opening, signal).catch((error: unknown) => {
// The abandoned open can still mint a handle after the abort won;
// close it so a departed caller cannot leak a descriptor. (A lost
// race against opendir's own rejection has nothing to close, and
// the close's own failure is swallowed — the request already
// returned, so a cleanup error has no consumer.)
void opening.then(dir => dir.close().catch(swallowCloseFailure), () => {
// Already rejected: raceAbort surfaced or swallowed it.
})
throw error
})
try {
for (;;) {
const dirent = await raceAbort(level.read(), signal)
if (dirent === null) break
// Only rows a browser could enter contend for the window; dirent
// says "directory" outright, a symlink needs the later stat probe.
if (!dirent.isDirectory() && !dirent.isSymbolicLink()) continue
const candidate = { name: dirent.name, isDirectory: dirent.isDirectory(), isSymbolicLink: dirent.isSymbolicLink() }
if (boundedInsert(window, candidate, keep)) evicted = true
}
} finally {
// Manual read() never auto-closes; close on every exit. The aborted
// exit must not await it — Node queues close behind any in-flight
// read, so awaiting would chain the departed caller back onto the
// very stall the abort escaped (the abandoned read's settlement is
// already swallowed by raceAbort).
const closing = level.close()
/* v8 ignore next 3 -- an abort between open and close needs a stalled read; the abandoned-close arm has no observable outcome. */
if (signal?.aborted) {
closing.catch(swallowCloseFailure)
} else {
await closing
}
}
} catch (error: unknown) {
// An abort is the caller's own reason, not an unreadable directory.
signal?.throwIfAborted()
throw new DirectoryPickerError('directory-unreadable', target, `cannot list ${target}: ${messageOf(error)}`)
}
const entries: DirectoryEntry[] = []
let truncated = evicted
for (const candidate of window) {
// A caller that departed between reads and probes stops before the
// next probe (each probe's own await is raced inside directoryRow).
signal?.throwIfAborted()
const row = await directoryRow(target, candidate.name, candidate.isDirectory, candidate.isSymbolicLink, signal)
if (row === null) continue
if (entries.length === this.config.maxEntries) {
truncated = true
break
}
entries.push(row)
}
return { path: target, home, crumbs: ancestryCrumbs(target), entries, truncated }
}
private async createDirectory(path: string, name: string): Promise<string> {
// Same fully-qualified fence as list: never rebase a parent under the
// cwd or the current drive.
if (!fullyQualified(path)) {
throw new DirectoryPickerError('directory-create-failed', path, `cannot create under "${path}": not a fully qualified parent path`)
}
const parent = resolve(path)
// The backend owns segment validation (the wire schema also refuses these,
// but direct service consumers must hit the same fence).
if (name.trim() === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
throw new DirectoryPickerError('directory-create-failed', join(parent, name), `"${name}" is not a single path segment`)
}
const target = join(parent, name)
try {
// Non-recursive: the parent is the directory the browser is showing, so
// a missing parent is a real failure, not a level to invent.
await mkdir(target)
return target
} catch (error: unknown) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST') {
throw new DirectoryPickerError('directory-exists', target, `${target} already exists`)
}
throw new DirectoryPickerError('directory-create-failed', target, `cannot create ${target}: ${messageOf(error)}`)
}
}
}

View File

@@ -0,0 +1,25 @@
/**
* Package-owned invariant companion for the browse directory-picker backend.
* @module @deepseek-ai/dsh-host-directory-picker-browse/invariant
*/
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-browse'
/** Cordis companion plugin name. */
export const name = 'host-directory-picker-browse-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: each list/create is one stateless filesystem round trip; the filesystem itself is the authoritative state. */
const install: InvariantInstaller = () => {}
/**
* Register the browse directory-picker invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -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('')
})
})

View File

@@ -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))
})
})

View File

@@ -0,0 +1,231 @@
/** Behavior of the browse backend over a real temporary directory tree. */
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { homedir, tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker'
import BrowseDirectoryPicker, { boundedInsert, fullyQualified, raceAbort } from '../src/index.ts'
import type { ListingCandidate } from '../src/index.ts'
let root: string
let capability: DirectoryPickerBrowseCapability
let dispose: () => Promise<void>
beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-browse-'))
await mkdir(join(root, 'projects'))
await mkdir(join(root, 'projects', 'harness'))
await mkdir(join(root, '.hidden-dir'))
await writeFile(join(root, 'notes.txt'), 'not a directory')
await symlink(join(root, 'projects'), join(root, 'linked'), 'junction')
await symlink(join(root, 'gone'), join(root, 'broken'), 'junction')
try {
await symlink(join(root, 'notes.txt'), join(root, 'file-link'))
} catch {
// Windows denies unprivileged file symlinks; the file-link row only
// feeds the POSIX lanes' coverage of the symlink-to-file arm, and every
// assertion below expects it to be filtered out anyway.
}
const ctx = new Context()
const fiber = ctx.plugin(BrowseDirectoryPicker)
await fiber.await()
const picked = ctx.get('directoryPicker')!.capability()
if (picked.kind !== 'browse') throw new Error('browse backend must advertise the browse capability')
capability = picked
dispose = () => fiber.dispose()
})
afterAll(async () => {
await dispose()
await rm(root, { recursive: true, force: true })
})
describe('BrowseDirectoryPicker', () => {
it('lists directories only, flags hidden rows, follows symlinks, skips broken links, sorts by name', async () => {
const listing = await capability.list(root)
expect(listing.path).toBe(root)
expect(listing.home).toBe(homedir())
expect(listing.entries.map(entry => entry.name)).toEqual(['.hidden-dir', 'linked', 'projects'])
expect(listing.entries.map(entry => entry.hidden)).toEqual([true, false, false])
// Every entry path is absolute and host-joined — clients never join segments.
expect(listing.entries.every(entry => entry.path === join(root, entry.name))).toBe(true)
// Well under the default bound: the complete level, not a cut one.
expect(listing.truncated).toBe(false)
})
it('cuts a level at maxEntries keeping the name-sorted head, and flags the cut', async () => {
const ctx = new Context()
const fiber = ctx.plugin(BrowseDirectoryPicker, { maxEntries: 1 })
await fiber.await()
const bounded = ctx.get('directoryPicker')!.capability()
if (bounded.kind !== 'browse') throw new Error('browse backend must advertise the browse capability')
try {
const cut = await bounded.list(root)
expect(cut.entries.map(entry => entry.name)).toEqual(['.hidden-dir'])
expect(cut.truncated).toBe(true)
// Exactly at the bound is complete, not truncated.
const exact = await bounded.list(join(root, 'projects'))
expect(exact.entries.map(entry => entry.name)).toEqual(['harness'])
expect(exact.truncated).toBe(false)
// A level that fits the window but exceeds the bound (two rows, bound
// one): the in-window extra row proves the cut without any eviction.
await mkdir(join(root, 'projects', 'harness', 'a'))
await mkdir(join(root, 'projects', 'harness', 'b'))
const inWindow = await bounded.list(join(root, 'projects', 'harness'))
expect(inWindow.entries.map(entry => entry.name)).toEqual(['a'])
expect(inWindow.truncated).toBe(true)
} finally {
await fiber.dispose()
}
})
it('stops the scan with the caller: an aborted signal rejects with its own reason', async () => {
const gone = new AbortController()
gone.abort(new Error('caller left'))
// The abort surfaces as-is, not dressed as an unreadable directory —
// and rejects even before any level row is read.
await expect(capability.list(root, gone.signal)).rejects.toThrow('caller left')
// The abandoned open that still succeeds is closed, not leaked.
await new Promise(resolve => setTimeout(resolve, 10))
// Aborted against a missing target: the abandoned open rejects on its
// own and there is nothing to close.
await expect(capability.list(join(root, 'no-such-dir'), gone.signal)).rejects.toThrow('caller left')
await new Promise(resolve => setTimeout(resolve, 10))
// A live signal leaves a normal listing untouched — the reads and the
// symlink probes race it without ever losing.
const live = new AbortController()
const complete = await capability.list(root, live.signal)
expect(complete.truncated).toBe(false)
expect(complete.entries.map(entry => entry.name)).toContain('linked')
// A live signal changes nothing about ordinary failures.
const missing = join(root, 'no-such-dir')
const failure = await capability.list(missing, live.signal).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(DirectoryPickerError)
expect((failure as DirectoryPickerError).code).toBe('directory-unreadable')
})
it('raceAbort follows the operation until the signal wins, and swallows the abandoned settlement', async () => {
// No signal / settled operations: plain passthrough, listener removed.
await expect(raceAbort(Promise.resolve('ok'), undefined)).resolves.toBe('ok')
const live = new AbortController()
await expect(raceAbort(Promise.resolve('ok'), live.signal)).resolves.toBe('ok')
// Failure passthrough keeps the operation's own error.
await expect(raceAbort(Promise.reject(new Error('raw failure')), live.signal)).rejects.toThrow('raw failure')
// The abort wins over a pending operation and carries its own reason;
// the operation's late rejection is swallowed, never unhandled.
const rejections: unknown[] = []
const onUnhandled = (reason: unknown): void => { rejections.push(reason) }
process.on('unhandledRejection', onUnhandled)
try {
let rejectLate!: (reason: unknown) => void
const pending = new Promise<never>((_resolve, reject) => { rejectLate = reject })
const controller = new AbortController()
const raced = raceAbort(pending, controller.signal)
// A bare-string abort reason exercises the Error wrap.
controller.abort('caller left')
await expect(raced).rejects.toThrow('caller left')
rejectLate(new Error('late read failure'))
await new Promise(resolve => setTimeout(resolve, 10))
expect(rejections).toEqual([])
} finally {
process.off('unhandledRejection', onUnhandled)
}
})
it('boundedInsert keeps the window name-sorted and bounded, reporting evictions', () => {
const candidate = (name: string): ListingCandidate => ({ name, isDirectory: true, isSymbolicLink: false })
const window: ListingCandidate[] = []
expect(boundedInsert(window, candidate('m'), 2)).toBe(false)
expect(boundedInsert(window, candidate('z'), 2)).toBe(false)
// A smaller name lands in place and pushes the current largest out.
expect(boundedInsert(window, candidate('a'), 2)).toBe(true)
expect(window.map(entry => entry.name)).toEqual(['a', 'm'])
// A name at or beyond the full window's tail rejects on one comparison.
expect(boundedInsert(window, candidate('t'), 2)).toBe(true)
expect(window.map(entry => entry.name)).toEqual(['a', 'm'])
expect(boundedInsert(window, candidate('m'), 2)).toBe(true)
expect(window.map(entry => entry.name)).toEqual(['a', 'm'])
})
it('reports the ancestry as jump-target crumbs ending at the listed directory', async () => {
const listing = await capability.list(join(root, 'projects'))
const tail = listing.crumbs.at(-1)!
expect(tail).toMatchObject({ name: 'projects', path: join(root, 'projects'), hidden: false })
expect(listing.crumbs.at(-2)!.path).toBe(root)
expect(listing.crumbs.at(-2)!.name).toBe(basename(root))
// The chain starts at the filesystem root, whose crumb is labeled by its full path.
expect(listing.crumbs[0]!.name).toBe(listing.crumbs[0]!.path)
})
it('lists the home directory when no path is given', async () => {
const listing = await capability.list()
expect(listing.path).toBe(homedir())
})
it('throws directory-unreadable for a missing target', async () => {
const missing = join(root, 'no-such-dir')
const failure = await capability.list(missing).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(DirectoryPickerError)
expect((failure as DirectoryPickerError).code).toBe('directory-unreadable')
expect((failure as DirectoryPickerError).path).toBe(missing)
})
it('classifies fully qualified paths per platform (drive-less rooted Windows forms rejected)', () => {
expect(fullyQualified('/home/x', 'linux')).toBe(true)
expect(fullyQualified('x/y', 'darwin')).toBe(false)
expect(fullyQualified('C:\\projects', 'win32')).toBe(true)
expect(fullyQualified('C:/projects', 'win32')).toBe(true)
expect(fullyQualified('\\\\server\\share', 'win32')).toBe(true)
expect(fullyQualified('//server/share/deep', 'win32')).toBe(true)
// Rooted but drive-less: isAbsolute accepts these, yet resolve() would
// inject the process's current drive.
expect(fullyQualified('\\foo', 'win32')).toBe(false)
expect(fullyQualified('/foo', 'win32')).toBe(false)
expect(fullyQualified('C:relative', 'win32')).toBe(false)
// Incomplete UNC prefixes collapse to drive-relative roots under resolve().
expect(fullyQualified('\\\\', 'win32')).toBe(false)
expect(fullyQualified('\\\\server', 'win32')).toBe(false)
expect(fullyQualified('\\\\server\\', 'win32')).toBe(false)
})
it('rejects non-absolute paths instead of rebasing them under the process cwd', async () => {
for (const relative of ['', 'projects', './projects', '..']) {
const listFailure = await capability.list(relative).catch((error: unknown) => error)
expect(listFailure).toBeInstanceOf(DirectoryPickerError)
expect((listFailure as DirectoryPickerError).code).toBe('directory-unreadable')
expect((listFailure as DirectoryPickerError).path).toBe(relative)
const createFailure = await capability.createDirectory(relative, 'child').catch((error: unknown) => error)
expect(createFailure).toBeInstanceOf(DirectoryPickerError)
expect((createFailure as DirectoryPickerError).code).toBe('directory-create-failed')
expect((createFailure as DirectoryPickerError).path).toBe(relative)
}
})
it('creates one child directory and surfaces it in the next listing', async () => {
const created = await capability.createDirectory(root, 'fresh')
expect(created).toBe(join(root, 'fresh'))
const listing = await capability.list(root)
expect(listing.entries.map(entry => entry.name)).toContain('fresh')
})
it('refuses an existing child with directory-exists', async () => {
const failure = await capability.createDirectory(root, 'projects').catch((error: unknown) => error)
expect(failure).toBeInstanceOf(DirectoryPickerError)
expect((failure as DirectoryPickerError).code).toBe('directory-exists')
})
it('refuses non-segment names and other filesystem failures with directory-create-failed', async () => {
for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
const failure = await capability.createDirectory(root, name).catch((error: unknown) => error)
expect(failure).toBeInstanceOf(DirectoryPickerError)
expect((failure as DirectoryPickerError).code).toBe('directory-create-failed')
}
// Missing parent is a real failure, not a level to invent.
const missingParent = await capability.createDirectory(join(root, 'no-such-dir'), 'child').catch((error: unknown) => error)
expect((missingParent as DirectoryPickerError).code).toBe('directory-create-failed')
})
})

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-native/README.md
README.md: 0b54c651d4f5382021d0f8832ab4f1146b7652c8
README.zh.md: e5ac2762a691a16a7e6d9d6dd9aefc70a59dcd4f

View File

@@ -0,0 +1,19 @@
# @deepseek-ai/dsh-host-directory-picker-native
English | [中文](README.zh.md)
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native 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.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level).

View File

@@ -0,0 +1,19 @@
# @deepseek-ai/dsh-host-directory-picker-native
[English](README.md) | 中文
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**`NativeDirectoryPicker``native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用macOS 使用 `osascript`Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
**双面包**browser half`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind
## 模型体验
无。该后端服务于 GUI 宿主的目录选择;这里没有任何内容进入模型请求。
#### KV 缓存影响
无;该包既不组装也不发送提供方请求。
## 已知限制与延期工作
- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。

View File

@@ -0,0 +1,62 @@
{
"name": "@deepseek-ai/dsh-host-directory-picker-native",
"description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"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-native-command": "workspace:^"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^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",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@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"
],
"platform": "web"
}
}

View File

@@ -0,0 +1,65 @@
/**
* The native picking occupant (package-internal; the `./client` surface
* exposes only the Loader exports). Same-package tests exercise it directly
* through this module.
*/
import { useEffect, useRef } from 'react'
import type { ReactElement } from 'react'
// Type-only: the owner contract of the directory-flow holes.
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
/** Injected face: the wire call the flow drives (bound in apply's closure). */
export interface NativeFlowInjected {
/** Ask the local Host to open its native single-directory chooser. */
pick: () => Promise<string | null>
}
/**
* Renderless flow occupant: each rising `open` edge runs exactly one pick and
* reports exactly one outcome; the ref arms once per open so re-renders (and
* an adoption keeping `open` true while `busy`) never launch a second
* chooser. The owner withdrawing `open` re-arms the next request.
* @param props - owner conversation plus the injected pick call.
* @returns nothing — the native chooser renders on the host display.
*/
export function NativeDirectoryFlow(props: DirectoryFlowOwnerProps & NativeFlowInjected): ReactElement | null {
const { open, pick } = props
const armed = useRef(false)
// Callbacks ride a ref so the settled pick reports through the owner's
// latest handlers, not the ones captured when the chooser opened.
const outcome = useRef(props)
outcome.current = props
// Unmount (HMR replacing the occupant) discards settlements wholesale: the
// dead instance must neither adopt a path nor drive the owner's error
// surface. The wire carries no per-request abort, so the host-side chooser
// survives until answered — its answer just lands nowhere; the replacement
// instance re-arms under the owner's still-open request. An injected-face
// identity change alone (re-registration) keeps the pending settlement:
// the chooser on the host display is still the same dialog.
const alive = useRef(true)
useEffect(() => {
// StrictMode's development replay runs the cleanup once before the real
// lifetime: re-arm on setup or every outcome would be discarded.
alive.current = true
return () => { alive.current = false }
}, [])
useEffect(() => {
if (!open) {
armed.current = false
return
}
if (armed.current) return
armed.current = true
pick().then(
(path) => {
if (!alive.current) return
if (path === null) outcome.current.onCancel(); else outcome.current.onPicked(path)
},
(reason: unknown) => {
if (!alive.current) return
outcome.current.onError(reason instanceof Error ? reason.message : String(reason))
},
)
}, [open, pick])
return null
}

View File

@@ -0,0 +1,41 @@
/**
* Browser half of the native directory-picker backend: fills ui-workspace's
* two directory-flow holes with a renderless occupant that answers each
* `open` by driving `host.pickDirectory` (the node half's OS chooser) and
* reporting the one outcome — picked path, cancellation, or failure — back
* through the owner conversation. Mounting this package therefore composes
* both sides of the native interaction with one cordis.yml row; no client
* code branches on a capability kind.
*/
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 { NativeFlowInjected } from './flow.ts'
import { NativeDirectoryFlow } from './flow.ts'
/** Required services (cordis fiber inject): the slot registry and the wire-facing workspace service. */
export const inject = ['slots', 'workspaces']
/**
* Client plugin body: register the renderless native 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 {
const injected = (): NativeFlowInjected => ({ pick: () => ctx.workspaces.pickDirectory() })
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,
NativeDirectoryFlow,
name => ctx.slots.register({ name, inject: injected }, NativeDirectoryFlow),
)
return () => { group.dispose() }
}, 'directory-picker-native: flow registrations')
}

View File

@@ -0,0 +1,33 @@
/**
* Native backend of the directory-picker seam: registers `ctx.directoryPicker`
* with the `native` capability, opening one native OS chooser on the host
* display per pick (macOS `osascript`, Windows STA PowerShell
* `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable
* when the operator sits at the host's screen; remote deployments compose the
* browse backend instead.
* @module @deepseek-ai/dsh-host-directory-picker-native
*/
import { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
import { pickNativeDirectory } from './native-picker.ts'
export type { DirectoryPickerInternals, DirectoryPickerRunner } from './native-picker.ts'
export { pickNativeDirectory } from './native-picker.ts'
/** The `ctx.directoryPicker` native implementation (stable capability object per service life). */
export default class NativeDirectoryPicker extends DirectoryPicker {
private readonly nativeCapability: DirectoryPickerCapability = {
kind: 'native',
/* v8 ignore next -- pure forward to pickNativeDirectory (its spec owns behavior); invoking here opens a real chooser. */
pick: signal => pickNativeDirectory(signal),
}
/**
* The native interaction capability.
* @returns the stable `native` capability object.
*/
capability(): DirectoryPickerCapability {
return this.nativeCapability
}
}

View File

@@ -0,0 +1,25 @@
/**
* Package-owned invariant companion for the native directory-picker backend.
* @module @deepseek-ai/dsh-host-directory-picker-native/invariant
*/
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-native'
/** Cordis companion plugin name. */
export const name = 'host-directory-picker-native-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** No runtime invariant: each pick is one stateless subprocess round trip; the chooser outcome is only the returned path. */
const install: InvariantInstaller = () => {}
/**
* Register the native directory-picker invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

View File

@@ -1,6 +1,6 @@
/** Cross-platform native single-directory picker used by the local GUI carrier. */
/** Cross-platform native single-directory chooser behind the native backend's capability. */
import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
/** Testable command boundary; native implementations never invoke a shell. */
export type DirectoryPickerRunner = NativeCommandRunner

View File

@@ -0,0 +1,219 @@
// @vitest-environment jsdom
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { act, cleanup, render } from '@testing-library/react'
import { afterEach } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { apply, inject } from '../src/client/index.ts'
import { NativeDirectoryFlow } from '../src/client/flow.ts'
afterEach(cleanup)
const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const pickDirectory = vi.fn(async (): Promise<string | null> => '/tmp/picked')
ctx.provide('workspaces', { pickDirectory } 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, pickDirectory, 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-native client half', () => {
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'workspaces'])
})
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 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) }
// queueMicrotask throws surface as uncaughtException, not a rejection.
process.on('unhandledRejection', onUnhandled)
process.on('uncaughtException', onUnhandled)
try {
// This provider activates BEFORE any hole exists: both deferrals wait.
// (Duplicate rows of the SAME package converge silently — the deferral
// skips a hole its own component already occupies; the conflict needs
// a rival provider.)
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 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('rejects a second flow occupant at load (single-kind hole)', async () => {
const b = await bench()
b.declare()
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(() => b.slots.register({ name: HOLES[0] } as never, () => null))
.toThrow(/already has a registration/)
})
it('drives the injected pick through the hole entry and reports the picked path', 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 () => { pick: () => Promise<string | null> })()
await expect(injected.pick()).resolves.toBe('/tmp/picked')
expect(b.pickDirectory).toHaveBeenCalledOnce()
})
it('runs one pick per open edge and reports the path to the latest onPicked', async () => {
let resolve!: (path: string | null) => void
const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
const first = owner()
const view = render(<NativeDirectoryFlow {...first} pick={pick} />)
expect(pick).toHaveBeenCalledOnce()
// Re-renders while open (busy flips, handler identity changes) must not relaunch the chooser.
const second = owner()
view.rerender(<NativeDirectoryFlow {...second} busy pick={pick} />)
expect(pick).toHaveBeenCalledOnce()
// Even a fresh injected face (re-registration re-runs the inject factory)
// must not relaunch while the same request is still open.
const replacedPick = vi.fn(() => new Promise<string | null>(() => {}))
view.rerender(<NativeDirectoryFlow {...second} busy pick={replacedPick} />)
expect(replacedPick).not.toHaveBeenCalled()
await act(async () => { resolve('/tmp/project') })
expect(second.onPicked).toHaveBeenCalledWith('/tmp/project')
expect(first.onPicked).not.toHaveBeenCalled()
})
it('discards a settlement that lands after the flow unmounted', async () => {
let resolve!: (path: string | null) => void
const pick = vi.fn(() => new Promise<string | null>((settle) => { resolve = settle }))
const props = owner()
const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
expect(pick).toHaveBeenCalledOnce()
view.unmount()
// The dead instance must neither adopt nor error; the owner's callbacks
// stay untouched by the orphaned chooser's answer.
await act(async () => { resolve('/tmp/late') })
expect(props.onPicked).not.toHaveBeenCalled()
expect(props.onCancel).not.toHaveBeenCalled()
expect(props.onError).not.toHaveBeenCalled()
// The failure arm is discarded the same way.
let reject!: (reason: unknown) => void
const failing = vi.fn(() => new Promise<string | null>((_settle, rejectPick) => { reject = rejectPick }))
const late = owner()
const failingView = render(<NativeDirectoryFlow {...late} pick={failing} />)
failingView.unmount()
await act(async () => { reject(new Error('too late')) })
expect(late.onError).not.toHaveBeenCalled()
})
it('reports null as cancellation and re-arms after the owner withdraws open', async () => {
const pick = vi.fn(async () => null as string | null)
const props = owner()
const view = render(<NativeDirectoryFlow {...props} pick={pick} />)
await act(async () => {})
expect(props.onCancel).toHaveBeenCalledOnce()
expect(props.onPicked).not.toHaveBeenCalled()
// Withdraw and reopen: a fresh request runs a fresh pick.
view.rerender(<NativeDirectoryFlow {...props} open={false} pick={pick} />)
view.rerender(<NativeDirectoryFlow {...props} pick={pick} />)
await act(async () => {})
expect(pick).toHaveBeenCalledTimes(2)
})
it('folds pick failures into onError messages', async () => {
const props = owner()
render(<NativeDirectoryFlow {...props} pick={vi.fn(async () => { throw new Error('no chooser installed') })} />)
await act(async () => {})
expect(props.onError).toHaveBeenCalledWith('no chooser installed')
const nonError = owner()
render(<NativeDirectoryFlow {...nonError} pick={vi.fn(async () => { throw 'denied' })} />)
await act(async () => {})
expect(nonError.onError).toHaveBeenCalledWith('denied')
})
it('renders nothing while closed and while open', () => {
const closed = render(<NativeDirectoryFlow {...owner({ open: false })} pick={vi.fn(async () => null)} />)
expect(closed.container.innerHTML).toBe('')
const opened = render(<NativeDirectoryFlow {...owner()} pick={vi.fn(async () => null)} />)
expect(opened.container.innerHTML).toBe('')
})
})

View File

@@ -15,7 +15,7 @@ const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>()
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { describe, expect, it, vi } from 'vitest'
import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts'
import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-picker.ts'
function failure(code: string | number, stderr = ''): Error {
return Object.assign(new Error(`command failed: ${String(code)}`), { code, stderr })

View File

@@ -0,0 +1,21 @@
/** Registration/capability behavior of the native backend (the seam's cordis half). */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import NativeDirectoryPicker from '../src/index.ts'
describe('NativeDirectoryPicker', () => {
it('registers ctx.directoryPicker with a stable native capability and leaves with its fiber', async () => {
const ctx = new Context()
const fiber = ctx.plugin(NativeDirectoryPicker)
await fiber.await()
const picker = ctx.get('directoryPicker')
expect(picker).toBeInstanceOf(NativeDirectoryPicker)
const capability = picker!.capability()
expect(capability.kind).toBe('native')
// Stability: consumers may capture the capability object across calls.
expect(picker!.capability()).toBe(capability)
await fiber.dispose()
expect(ctx.get('directoryPicker')).toBeUndefined()
})
})

Some files were not shown because too many files have changed in this diff Show More