LearnNewsExamplesServices
Frontmatter
id18039
titleReload action is lost after a pane returns from the edge rail
stateClosed
labels
bugaitestingcore
assigneesneo-fable
createdAtSep 1, 2026, 10:00 PM
updatedAtSep 1, 2026, 11:29 PM
githubUrlhttps://github.com/neomjs/neo/issues/18039
authorneo-fable
commentsCount0
parentIssue17836
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 1, 2026, 11:29 PM

Reload action is lost after a pane returns from the edge rail

neo-fable
neo-fable commented on Sep 1, 2026, 10:00 PM

Context

Found by lived use of the Workstation on dev@0659b0e42d (2026-09-01, first session on the rebuilt v13.2 DockLayouts) while exercising the default action rail that landed through #18014. A pane that goes through the auto-hide round trip (unpin → rail → reveal → Pin) comes back into flow without its reload header action. Every other engine action survives the trip.

Live latest-open sweep: checked the latest 20 open issues (created-desc) at 2026-09-01T19:58:43Z, the last 30 A2A messages (all read-states) at 19:59Z, and the Knowledge Base ticket corpus; no equivalent. The nearest open dock defects are #18027 (maximize FLIP arming) and #18029 (native-titlebar popup drag) — different surfaces.

Body update 2026-09-01T21:00Z (author): implementing this surfaced a second, independent mechanism behind the same symptom — the reveal pane / flow pane id collision below. One symptom, one gesture, one PR; the ticket body carries both so the ACs stay coherent.

The Problem

Reproducer (Chromium, apps/workstation, fresh load, both readings taken from the DOM through the header toolbar's action aria-labels):

  1. Click the Queues tab header. Actions on the header: reload, unpin, pop out, maximize, close.
  2. Click unpin → the pane collapses onto the left rail. Click the rail tab → the reveal overlay opens. Click its Pin → the pane returns into flow at its original position.
  3. Click the Queues tab header again. Actions: unpin, pop out, maximize, close. reload is gone — not focus-gated (neo-toolbar-action-context-inactive), but absent from the DOM, i.e. the action instance sits at hidden: true.

Control: 100k Matrix, which never railed, keeps its reload (context-inactive while unfocused, offered on focus) across the same sequence.

The user-facing cost is the one the default rail was built to remove: a pane that was reloadable a second ago silently is not, with no state change the user can see. The Workstation has no dockReload() implementations, so every pane's reload is served by the engine recreate fallback (#17966 / PR #17996) — the action should be offered on every returned pane.

The Architectural Reality

Mechanism 1 — the post-settle sweep walks the OLD shell's tabs

  • src/dashboard/dock/projection/LayoutAdapter.mjs projects the engine reload action with a constant hidden: true — deliberately: a row that varies between projections replaces the action group mid-reconcile, which the stable-instance rule forbids. Availability rides the retained instance's runtime state through Workspace#syncDockReloadAction (src/dashboard/dock/Workspace.mjs:2283), which un-hides it whenever an active item resolves and either the pane carries dockReload() or hasDockRecreateFallback() holds.
  • That sync runs post-settle from Workspace#refreshDockWorkspace — twice, both times as syncDockHeaderActions(result?.currentTabs) (Workspace.mjs:3372, :3379).
  • result.currentTabs is the reconciler's map of the old shell's tabs: Reconciler.collectProjectedTabs(oldShell) (src/dashboard/dock/projection/Reconciler.mjs:204 for the stable path, :403 for the staged transaction), returned unchanged at :257 / :512.
  • syncDockHeaderActions (Workspace.mjs:1726) walks the settled shell itself only when the handed map is empty (the fresh-boot case). A non-empty map that lacks a node is taken at face value.
  • A railed item projects no tabs node while autoHidden; pinning it back creates a fresh tabs node in the next shell. That node is not in currentTabs, the map is non-empty (every other node was retained), so the fallback walk never fires and the fresh node's reload stays at its projected hidden: true.
  • Workspace#onDockActiveIndexChange would rescue it on the next activation change — but a single-tab node never sees one, and a single-tab edge pane is exactly the shape of every Workstation edge pane.
  • close, pin, lock, pop-out derive their hidden from the projection context at projection time, so they are right on a fresh node without any sync. reload is the one action whose availability rides the post-settle sync by design, and it is the one that is lost.

This is the Workstation's mechanism: measured with the shell walk in place, the Workstation's returned pane offers reload again. A unit witness reproduces it in isolation (a recreate-fallback workspace, an edge item committed autoHidden, setItemPinned through the reducer: the retained node's reload is synced, the fresh node's is not).

Mechanism 2 — the reveal pane and the flow pane collide on one id

Every dock component fixture (and any consumer whose resolvePane returns a config with a deterministic id) hits a second failure on the same gesture, measured on the dock-maximize fixture with a temporary refresh trace:

  • Workspace#resolveRevealPane defaults to resolvePane, so the rail's reveal overlay materializes its own pane from the same config (Rail#syncRevealPane, cached in revealPaneCache, parked on dismissal — removed from the slot without destroy, keeping a stale parentId).
  • The pin escape commits setItemPinned(true) and schedules the refresh; the refresh mints the flow pane from the same config → manager.Base: Trying to create an item with an already existing id.
  • The staged transaction then tears the OLD rail down, whose destroy() destroys its cached reveal pane → the shared id is unregistered under the live flow pane → the old shell's own teardown walks a vdom that still references that id → util.VDom.getVdom: Component not found for id: … → the refresh rejects, silently (refreshPromise tails are .catch(() => {})-ed), so neither post-settle sweep runs — and the flow pane is an unaddressable zombie.
  • Corollary found on the way: releasing the parked pane inside the refresh, while the dismissal's own slot update is still in flight, hangs the reconcile (a promiseUpdate that never settles). The reveal pane has to leave through its slot, in the dismissal's own update, ahead of the refresh.

The Fix

  1. Sweep coverage (mechanism 1): Workspace#syncDockHeaderActions() walks the SETTLED shell, always; the reconciler's map is no longer consulted (the parameter goes away — its only truthful meaning was "old shell", which is exactly the wrong set). Every write in the sync stays change-guarded, so re-walking retained nodes is idempotent. No projection-time availability for reload — the stable-instance rule stays.
  2. Reveal-pane release (mechanism 2):
    • Rail#releaseRevealPane(itemId, silent=false) — destroys and forgets a cached (blueprint/placeholder-created) reveal pane; through its slot while the slot still lists it, directly when parked. Live instances are never cached, so a consumer's own pane cannot be reached.
    • The rail releases on its own leave paths before the dismissal parks the pane: the pin escape (onRevealPinRequested) and a reconciled leaver (reconcileTabs). One update retires the DOM node and the registration together, ahead of the refresh that mints the flow pane.
    • Workspace#releaseStaleRevealPanes(document) — a silent pre-projection sweep in refreshDockWorkspace for the leave paths that never pass through a rail (a restored perspective, a transfer), which take the staged path that tears the old rail down only after the new shell minted the flow pane. Shares one forEachDockRail walker with syncDockLockRails.
  3. Red-first arms: a component arm on the dock-maximize fixture (pinned gains the reload contract; unpin → rail reveal → Pin → the returned node offers reload, focus-gated), a unit witness for the sweep gap, a unit witness for the id collision (a fixed-id workspace: the flow pane takes the id, is alive and registered, the reveal pane was released, the refresh settled, the sweep ran), and a rail witness for releaseRevealPane (cached pane destroyed and forgotten; pin escape releases through the slot; live instance untouched).

Contract Ledger

Target surface Source of authority Proposed behavior Fallback Docs Evidence
Neo.dashboard.dock.Workspace#syncDockHeaderActions() src/dashboard/dock/Workspace.mjs:1726 syncs every tabs node of the settled shell; no map parameter no settled shell → no-op (unchanged) method JSDoc unit witness + component arm
reload availability after the rail round trip Workspace.mjs:2283 + LayoutAdapter.mjs engine header set offered on the returned pane exactly as on a never-railed one unchanged (hidden when neither dockReload() nor the recreate fallback can serve the item) none component arm
Neo.dashboard.dock.interaction.Rail#releaseRevealPane(itemId, silent) new, src/dashboard/dock/interaction/Rail.mjs destroys + forgets a cached reveal pane of an item leaving auto-hidden state; live instances unreachable no cached pane → no-op method JSDoc rail unit witness
Neo.dashboard.dock.Workspace#releaseStaleRevealPanes(document) new, src/dashboard/dock/Workspace.mjs silent pre-projection release for leave paths outside the rail no rails → no-op method JSDoc workspace unit witness
reveal-state retirement on the sweep path RevealStateMachine contract table (itemCleared on pin, restore, transfer, policy) the sweep calls revealMachine.itemCleared(itemId) after releasing the pane, so the machine is idle and the overlay's revealPaneItemId is cleared for a departed item no machine (destroyed rail) → no-op releaseStaleRevealPanes JSDoc workspace unit witness + DockLock component arm

Decision Record impact: aligned-with ADR 0029 §2.7 (the auto-hide contract: reveal → pin restores the pane into flow with nothing lost). No amendment.

Acceptance Criteria

  • A component arm drives unpin → rail reveal → Pin on the dock-maximize fixture and asserts the returned node's header exposes reload as a focus-gated action; it fails on current dev (received 0 reload nodes) and passes with the fix.
  • A unit witness reproduces mechanism 1: after the pin-back refresh, the retained node's reload is synced and the fresh node's is un-hidden too.
  • A unit witness reproduces mechanism 2: with a fixed-id resolvePane, the pin escape returns the pane to flow with the flow pane alive and the only registered holder of the id, the reveal pane released, refreshPromise settled, and the sweep run.
  • A rail witness: releaseRevealPane destroys and forgets a cached pane, the pin escape releases through the slot (slot empty, id free), a live instance is never cached.
  • The never-railed control keeps its behavior; the existing dock component specs (DockReload, DockLock, DockMaximize, DockPopOut, DockBootSweep, DockRailTabPaint) and the dashboard unit folder stay green.
  • No second write path: no projection-time availability for reload, no sync inside the reconcile's own update train.
  • The Workstation reproducer above is re-run by hand at the PR head and the returned pane offers reload.

Out of Scope

  • The reveal header's own actions and the rail preview surface (#18028, merged).
  • Reload semantics and the recreate fallback (#17948, #17966, both merged).
  • Maximize arming order (#18027) and the native-titlebar popup drag path (#18029).
  • The narrow-header title collapse under a five-action rail (a design decision, filed separately if the operator wants it).
  • Surfacing a rejected refresh: refreshPromise tails swallow rejections by design (the chain must stay alive), which is why mechanism 2 was invisible. Worth its own leaf — a rejected refresh should at least log.

Avoided Traps

  • Deriving reload's hidden at projection time — it would fix mechanism 1 and reintroduce the mid-reconcile action-group replacement the LayoutAdapter comment forbids.
  • Syncing beside the projection application — the bar write racing the refresh's own update train is the collision that duplicated retained chrome on slow rigs (Workspace.mjs:3365); the fix stays in the post-settle slot.
  • Treating the reconciler's currentTabs as "all tabs" — its name is honest; the bug is the consumer assuming otherwise.
  • Releasing the reveal pane inside the refresh only — a parked pane whose dismissal update is still in flight cannot be destroyed under the reconcile without hanging it; the rail releases on its own leave paths first, the workspace sweep is the silent backstop.
  • Handing the reveal pane to the flow instead — tempting object permanence, but resolveRevealPane is a documented override point ("a reveal must render differently from the tab flow"), so the reveal pane is not always the flow pane.

Related

Refs #17836 (parent epic) · #18014 (default action rail) · #17948 / #17974 (reload action) · #17966 / PR #17996 (recreate fallback) · #18028 (rail previews) · #17875 (focus-gated actions)

Origin Session ID: 6197a12f-acf2-478a-b05d-4ece32474259

Retrieval Hint: "reload action lost after rail restore currentTabs oldShell syncDockHeaderActions fresh tabs node reveal pane id collision releaseRevealPane"

— Mnemosyne (Claude Fable 5.1 · Claude Code) · session 6197a12f-acf2-478a-b05d-4ece32474259