deepseek-harness/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md

10 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). Backend packages are dual-face: the browser half registers the matching interaction into both holes — -native a renderless occupant driving host.pickDirectory, -browse the in-app Select Workspace Directory dialog. The hole's owner conversation (open/busy/onPicked/onCancel/onError) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (createWorkspace({path}), conflict/error dialog, Choose again), the occupant owns everything between open and the picked path. One cordis.yml row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (single hole). The earlier host.describe.directoryPicker advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (ctx.slots.entries) replaces it as the per-menu-open occupancy read.

Placement and policy rulings folded into this decision:

  • 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 show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (aria-pressed + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' FILE_ATTRIBUTE_HIDDEN is not exposed by dirents — documented limitation until a native probe pays for itself.
  • Path-editor cancel scope: the dialog card. The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from listing.home; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (aria-expanded/aria-controls/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets.
  • Navigation lands selection-anchored. Away from the display root, the browse client's navigate (a crumb jump or a submitted path) lists the target's parent level with the target selected and its children on the right — two panes throughout, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The display root (home, or a rootward chain with no parent crumb) keeps the single wide level; the parent leg runs under the same supersession scope as the landing, and its failure falls back to the single-pane landing quietly — the target listed fine, and nobody asked to see the parent.
  • 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.
  • Listing levels are bounded, and streamed. One list call returns at most maxEntries rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via opendir into a name-sorted window of maxEntries + 1 candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire DirectoryListing carries a required truncated flag so the client states incompleteness instead of silently missing tail entries. 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 (an oversized level must not pay a window scan per dirent), and list(path, signal) threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories.
  • 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.
  • A flip-label show-hidden toggle ("Hide hidden files"). Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once.
  • Pure relatedTarget blur cancellation (no mousedown suppression). Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null relatedTarget and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together.
  • A wire separator field on DirectoryListing (host stamps path.sep). Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the listing.home heuristic — but it touches the seam type and every backend; the browse client's separatorOf carries a TODO pointing at this alternative until a wire change is next scheduled.

Consequences

  • cordis.yml chooses the interaction; apps/cli mounts -browse (the shipped default — remote-capable picking out of the box), one row having swapped backend and UI together; -native remains the host-display alternative.
  • The wire gains host.listDirectory/host.createDirectory and four error codes; the connection fixture serves a deterministic browse tree and a deterministic pickDirectory path for keyless assembled tests.
  • A future interaction (or an Electron provider of the native interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits.
  • ApiProxyDefaults.pickDirectory (test-only injection) is gone; tests provide a stub ctx.directoryPicker like any other service.