The hover affordance framed only the strip right of the crumbs. The bar itself now carries the outline and the padding in both modes, so hovering previews exactly the field the click produces and nothing resizes when the two swap. One keystroke moved the view twice: deleting a separator first narrowed the pane the draft had just walked away from, then replaced it with its landing. The tail now filters only the LAST pane — the one whose level the path names — so a pane on its way out holds still until its landing arrives. Also from the review round: the walk waits both legs out instead of taking the submitted-navigation bound (a speculative scan has nothing waiting on it, and a tail keystroke aborting a slow parent leg would otherwise strand the two-pane view); a level keeps answering the directory text that produced it, so `..` segments and Windows forward slashes filter and stop rescanning; the release-on-miss rule counts displayable rows, so it survives `hidden` ever meaning more than dot-prefixed; and the editor's 250ms rest joins the other two constants on the remote-recalibration list.
17 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}), retryable 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.fsseam.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 — swappingfs-sandboxfor 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 (drivelistnative 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_HIDDENis 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. - The path editor advertises itself, and the panes follow the draft. The click-to-edit zone is not invisible: a pencil glyph sits at the bar's right edge and hover/focus lights the WHOLE bar in the editor's own box — the bar carries the outline and padding in both modes, so the hover previews exactly the field the click produces and nothing resizes when zone and input swap. While the editor is open the panes track the draft instead of whatever level happened to be listed when it opened — the final segment prefix-filters the level its directory part names, a tail nobody matches releases the filter (a name still being spelled must not empty the pane it is being spelled into), and any other directory part is scanned after a 250ms rest and lands through the same selection-anchored, two-pane landing every navigation uses, so typing a path moves the Miller view exactly as a crumb jump does — typing deeper descends, erasing segments walks back up — without leaving the editor. The pane arity is the invariant: the last pane always lists the level the path names, with its parent beside it and nothing but a display root listing alone. Skipping the scan whenever any pane happened to list the directory was the cheaper rule and the wrong one — erasing a segment then left the level being typed on the left with its own child pane still standing to its right, so the panes stopped reading as "where I am, and where I came from". Only the last pane's own tail costs no scan. One landing shape, two callers: a submitted path closes the editor and announces failures, the draft-following scan keeps both to itself. That scan is speculative — half-typed directories are unreadable most of the time — so a failure keeps the last readable panes and stays silent. Enter remains the authoritative commit: it owns the view from submission until landing (a debounce timer armed by the same keystrokes is held back rather than superseding the navigation, and a rejected submission stays held until the next edit) and it alone surfaces the failure. Two consequences are deliberate. The wait is keyed on the draft, not on the directory part it names, so a keystroke that superseded an in-flight scan re-arms one and an edit after a rejected submission releases the hold; the panes it reads are a ref rather than a dependency, or the landing would re-arm the wait and a host answering with a differently spelled path would scan forever. And a walk is not rewound: closing the editor — cancellation included — leaves the panes where the draft took them, named by the crumbs and followed by Open's fallback target, because the operator watched them move. A landing that unmounts the row a keyboard operator Tabbed onto re-parks focus on the editor, since the Modal has no focus trap. Two further rules keep one keystroke to one movement: the walk waits BOTH legs out rather than taking the submitted-navigation wait bound (nothing waits on a speculative scan, so landing single-pane and upgrading would be the very flash this exists to avoid, and it would strand the two-pane view whenever a tail keystroke aborted a slow parent leg), and the tail filters only the LAST pane — narrowing a pane the draft has walked away from would move the view once as it narrows and again as its landing replaces it. A level also keeps answering the directory text that produced it (
scanned), because the Host resolves what it is given:..segments and, on Windows, forward slashes reach a level whose own path spells the request differently, and without the memo those drafts would rescan on every keystroke and never filter. - Navigation lands selection-anchored, quiet, and bounded. Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the landing is two-pane: 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. Target and parent legs land as one frame when the parent leg settles within the 200ms wait bound — the stale view keeps rendering until then, so navigation swaps the panes without an intermediate single-pane flash — and past the bound the target commits alone at once (an Enter-submitted navigation is never held hostage by a stalled parent) with the late parent leg upgrading the landing in place. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent (Escape inside the landing window therefore withdraws the whole navigation); a failed parent leg, or a truncated parent window lacking the target, leaves the single-pane landing — the upgrade must never orphan the selection it exists to anchor. The loading indicator follows the same quiet rule: it floats over the content's bottom-right corner (never a layout-shifting row; the truncated/error rows own the bottom left and keep rendering through a scan) and only once a scan outlives a 300ms silence window, so a local listing swaps with nothing shown at all. Row picks are deliberately exempt from the one-frame rule: a pick's immediate pane split is its selected-state feedback (aria-current, crumbs following), while a navigation has nothing to acknowledge the click but the swap itself. All three timing constants — the 200ms parent-leg bound, the 300ms silence window, and the editor's 250ms draft rest — are calibrated for local enumeration; a remote deployment (one RPC per level, commonly 100–400ms) would sit inside the silence window with no pressed state on the crumbs, and would pay rest plus RPC before the panes follow a typed path — revisit all three together when a remote consumer lands.
- Symlinks: follow for enterability.
statprobes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, andworkspace.createalready canonicalizes via realpath at adoption. - Listing levels are bounded, and streamed. One
listcall returns at mostmaxEntriesrows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams viaopendirinto a name-sorted window ofmaxEntries + 1candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wireDirectoryListingcarries a requiredtruncatedflag 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), andlist(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.createaccepts 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
nativeinteraction through its own dialog API). Kind naming:dialogwas the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate;nativenames where the chooser runs.
Alternatives considered
- Extend
ctx.fswith 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
relatedTargetand 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 permanently visible path input above the Miller view. Rejected: the breadcrumb is already the "where am I" reading, and a second always-present field duplicates it while costing a row of a 500px card that the columns need. The glyph plus the hover-lit zone puts the affordance on the bar that already answers the question.
- Scanning the draft on every keystroke, or only on Enter. Per keystroke: walking one path segment issues a listing per character, most of them for directories the operator is typing through, not at. Only on Enter (what shipped first): the panes and the typed text disagreed for the whole edit — the complaint this bullet answers. The 250ms rest keeps one scan per directory the typing actually settles on.
- Emptying a pane on a prefix miss (what shipped first). Rejected: mid-name the miss is the normal state, so the pane blanked exactly while the operator needed it to confirm the name; releasing the filter keeps the level readable and costs only the transient wideness.
- A wire
separatorfield onDirectoryListing(host stampspath.sep). Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats thelisting.homeheuristic — but it touches the seam type and every backend; the browse client'sseparatorOfcarries a TODO pointing at this alternative until a wire change is next scheduled.
Consequences
cordis.ymlchooses the interaction;apps/climounts the-autochooser, which resolves the host's situation at boot and mounts-nativeor-browseitself, one row still swapping backend and UI together; composing a backend row directly pins the interaction.- The wire gains
host.listDirectory/host.createDirectoryand four error codes; the connection fixture serves a deterministic browse tree and a deterministicpickDirectorypath for keyless assembled tests. - A future interaction (or an Electron provider of the
nativeinteraction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. ApiProxyDefaults.pickDirectory(test-only injection) is gone; tests provide a stubctx.directoryPickerlike any other service.