deepseek-harness/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
creatixchu b46dcb16cb revert: restore the reviewer-approved tree (97a192d7b)
ZiyaZhang approved 97a192d7b; the subsequent bot-review rounds (7-24)
landed after that approval and were not requested by a human reviewer.
This commit restores the approved tree verbatim as a forward commit
(pushed history stays intact). git diff 97a192d7b is empty.
2026-07-30 09:22:16 +08:00

11 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, progressively. Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor.
  • 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.