feat(host,client): compose directory picking through slots — dual-face -native, no wire advertisement
ui-workspace's two trigger surfaces each declare a single-kind directory-flow hole (conversation.hero.workspace.directoryFlow / sidebar.workspaces.directoryFlow, same owner contract) and keep only the trigger and the adoption: the Open-local- folder entry renders while the surface's hole is occupied, and the occupant reports one picked path per open through the hole's owner conversation (open/busy/onPicked/onCancel/onError). directory-picker-native becomes dual-face: its browser half fills both holes with a renderless occupant driving host.pickDirectory, so the cordis.yml row that mounts the backend also composes the client interaction — a mismatch is impossible and a second flow package fails at client load. With composition wiring both sides, the host.describe.directoryPicker advertisement and the client's kind branching lose their last consumer: the field, WorkspacesService.directoryPickerKind(), the DirectoryPickerKind wire type, and the picker's per-open describe read are deleted. The connection fixture now serves a deterministic pickDirectory path so the keyless snapshot drives the full pick-then-adopt flow. ui-workspace's hand-rolled declaration deferral is replaced by the deferRegistration helper it duplicated.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
|
||||
2026-07-28-directory-picker-capability-seam.md: 78ae05e0da67bff791c0b4f315451aa02e1fa6f4
|
||||
2026-07-28-directory-picker-capability-seam.zh.md: bd527e2a1dba6934300a50877d4777f7f9fa24b1
|
||||
2026-07-28-directory-picker-capability-seam.md: ce5a2695345e29db5739df206965720559783ce3
|
||||
2026-07-28-directory-picker-capability-seam.zh.md: 5b73c3c48493d4f178a523db19bc124eda9c7cca
|
||||
|
||||
@@ -10,7 +10,9 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p
|
||||
|
||||
## Decision
|
||||
|
||||
A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, advertises the kind through `host.describe.directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind; the client branches on the advertised kind and hides the affordance for unknown kinds (merge-extensible default). Composition (`cordis.yml`) is the swap point; the union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape.
|
||||
A three-package capability seam in `packages/host/` — `directory-picker` (interface), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape.
|
||||
|
||||
**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Each backend package is **dual-face**: its browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app browsing dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, conflict/error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read.
|
||||
|
||||
Placement and policy rulings folded into this decision:
|
||||
|
||||
@@ -30,7 +32,7 @@ Placement and policy rulings folded into this decision:
|
||||
|
||||
## Consequences
|
||||
|
||||
- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-native` (unchanged behavior). The GUI already gates its picking affordance on `describe.directoryPicker` (non-`native` kinds hide it); the in-app browser PR flips the default to `-browse` and adds the browse UI.
|
||||
- The wire gains `host.listDirectory`/`host.createDirectory`, four error codes, and the `describe.directoryPicker` field; the connection fixture serves a deterministic browse tree for keyless assembled tests.
|
||||
- A future interaction (or an Electron provider of the `native` interaction) is one backend package plus a client branch — no gateway surgery.
|
||||
- `cordis.yml` chooses the interaction; `apps/cli` currently mounts `-native` (unchanged behavior). The in-app browser PR flips that one row to `-browse`, swapping backend and UI together.
|
||||
- The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests.
|
||||
- A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits.
|
||||
- `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service.
|
||||
|
||||
@@ -10,7 +10,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
|
||||
|
||||
## 决策
|
||||
|
||||
在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,经 `host.describe.directoryPicker` 广播 kind,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答;客户端按广播的 kind 分支,未知 kind 隐藏入口(可合并扩展的默认分支)。组合(`cordis.yml`)就是换装点;联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。
|
||||
在 `packages/host/` 落一个三包能力 seam——`directory-picker`(接口)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一契约方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。
|
||||
|
||||
**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 契约相同、占用者相同)。每个后端包都是**双面包**:其 browser half 把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内浏览对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、冲突/错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。
|
||||
|
||||
并入本决策的位置与策略裁决:
|
||||
|
||||
@@ -30,7 +32,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick
|
||||
|
||||
## 后果
|
||||
|
||||
- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-native`(行为不变)。GUI 已按 `describe.directoryPicker` 门控其选目录入口(非 `native` kind 一律隐藏);应用内浏览器 PR 将把默认翻到 `-browse` 并补上浏览 UI。
|
||||
- 协议新增 `host.listDirectory`/`host.createDirectory`、四个错误码与 `describe.directoryPicker` 字段;connection fixture 提供确定性浏览树供无密钥组装测试使用。
|
||||
- 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个后端包加一个客户端分支——无需网关手术。
|
||||
- `cordis.yml` 决定交互形态;`apps/cli` 当前挂 `-native`(行为不变)。应用内浏览器 PR 只翻这一行到 `-browse`,后端与 UI 同时切换。
|
||||
- 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。
|
||||
- 未来的新交互(或提供 `native` 交互的 Electron 实现)只是一个双面后端包——无需网关手术,也不动 ui-workspace。
|
||||
- `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。
|
||||
|
||||
Reference in New Issue
Block a user