Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
T
creatixchu 85ca8be104 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.
2026-07-28 21:51:01 +08:00

6.5 KiB

Agent Note: A capability-discriminated directory-picker seam for the web-GUI host

Status: implemented

English | 中文

Problem

The web GUI's "Open local folder" flow was hardwired to one interaction: host.pickDirectory invoked a native OS chooser compiled into dsh-host-apiproxy (private module, test-only injection seam). That shape cannot serve remote deployments — no OS dialog reaches a browser on another machine — and the planned in-app directory browser (Figma Harness 802-56979) needs listing/creation primitives, which are a different interaction contract, not a different implementation of the same one. Swapping interactions required editing gateway source, against the repo's everything-is-a-plugin stance.

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, 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:

  • Not the ctx.fs seam. packages/fs/ is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping fs-sandbox for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; packages/host/ is its consumer-domain home.
  • Dependency survey (hand-roll vs adopt). Node's stdlib is the maintained cross-platform OS layer (readdir(withFileTypes), homedir, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (node-file-manager, files-and-folders, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (drivelist native addon, windows-drive-letters ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib.
  • Hidden entries: return-and-flag. The host stamps hidden (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' FILE_ATTRIBUTE_HIDDEN is not exposed by dirents — documented limitation until a native probe pays for itself.
  • Symlinks: follow for enterability. stat probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and workspace.create already canonicalizes via realpath at adoption.
  • Whole-filesystem scope, no roots config. workspace.create accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it.
  • The native backend stays. Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the native interaction through its own dialog API). Kind naming: dialog was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; native names where the chooser runs.

Alternatives considered

  • Extend ctx.fs with browse methods. Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam.
  • One uniform seam method set (pick(): path). Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant.
  • Direct stdlib calls inside apiproxy (no seam). Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work.
  • Adopting a file-manager/drive-enumeration dependency. Rejected per the survey above; recorded here as the dependency policy requires.

Consequences

  • cordis.yml chooses the interaction; apps/cli currently mounts -native (unchanged behavior). The in-app browser PR flips that one row to -browse, swapping backend and UI together.
  • 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.