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):
Click the Queues tab header. Actions on the header: reload, unpin, pop out, maximize, close.
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.
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 constanthidden: 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
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.
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.
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).
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.
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 itsreloadheader 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.The Problem
Reproducer (Chromium,
apps/workstation, fresh load, both readings taken from the DOM through the header toolbar's actionaria-labels):reload,unpin,pop out,maximize,close.unpin→ the pane collapses onto the left rail. Click the rail tab → the reveal overlay opens. Click itsPin→ the pane returns into flow at its original position.unpin,pop out,maximize,close.reloadis gone — not focus-gated (neo-toolbar-action-context-inactive), but absent from the DOM, i.e. the action instance sits athidden: 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.mjsprojects the enginereloadaction with a constanthidden: 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 throughWorkspace#syncDockReloadAction(src/dashboard/dock/Workspace.mjs:2283), which un-hides it whenever an active item resolves and either the pane carriesdockReload()orhasDockRecreateFallback()holds.Workspace#refreshDockWorkspace— twice, both times assyncDockHeaderActions(result?.currentTabs)(Workspace.mjs:3372,:3379).result.currentTabsis the reconciler's map of the old shell's tabs:Reconciler.collectProjectedTabs(oldShell)(src/dashboard/dock/projection/Reconciler.mjs:204for the stable path,:403for 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.autoHidden; pinning it back creates a fresh tabs node in the next shell. That node is not incurrentTabs, the map is non-empty (every other node was retained), so the fallback walk never fires and the fresh node'sreloadstays at its projectedhidden: true.Workspace#onDockActiveIndexChangewould 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-outderive theirhiddenfrom the projection context at projection time, so they are right on a fresh node without any sync.reloadis 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
reloadagain. A unit witness reproduces it in isolation (a recreate-fallback workspace, an edge item committedautoHidden,setItemPinnedthrough 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
resolvePanereturns a config with a deterministicid) hits a second failure on the same gesture, measured on thedock-maximizefixture with a temporary refresh trace:Workspace#resolveRevealPanedefaults toresolvePane, so the rail's reveal overlay materializes its own pane from the same config (Rail#syncRevealPane, cached inrevealPaneCache, parked on dismissal — removed from the slot without destroy, keeping a staleparentId).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.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 (refreshPromisetails are.catch(() => {})-ed), so neither post-settle sweep runs — and the flow pane is an unaddressable zombie.promiseUpdatethat never settles). The reveal pane has to leave through its slot, in the dismissal's own update, ahead of the refresh.The Fix
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 forreload— the stable-instance rule stays.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.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 inrefreshDockWorkspacefor 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 oneforEachDockRailwalker withsyncDockLockRails.dock-maximizefixture (pinnedgains the reload contract; unpin → rail reveal → Pin → the returned node offersreload, 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 forreleaseRevealPane(cached pane destroyed and forgotten; pin escape releases through the slot; live instance untouched).Contract Ledger
Neo.dashboard.dock.Workspace#syncDockHeaderActions()src/dashboard/dock/Workspace.mjs:1726reloadavailability after the rail round tripWorkspace.mjs:2283+LayoutAdapter.mjsengine header sethiddenwhen neitherdockReload()nor the recreate fallback can serve the item)Neo.dashboard.dock.interaction.Rail#releaseRevealPane(itemId, silent)src/dashboard/dock/interaction/Rail.mjsNeo.dashboard.dock.Workspace#releaseStaleRevealPanes(document)src/dashboard/dock/Workspace.mjsRevealStateMachinecontract table (itemClearedon pin, restore, transfer, policy)revealMachine.itemCleared(itemId)after releasing the pane, so the machine is idle and the overlay'srevealPaneItemIdis cleared for a departed itemreleaseStaleRevealPanesJSDocDockLockcomponent armDecision 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
dock-maximizefixture and asserts the returned node's header exposesreloadas a focus-gated action; it fails on currentdev(received 0 reload nodes) and passes with the fix.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,refreshPromisesettled, and the sweep run.releaseRevealPanedestroys and forgets a cached pane, the pin escape releases through the slot (slot empty, id free), a live instance is never cached.DockReload,DockLock,DockMaximize,DockPopOut,DockBootSweep,DockRailTabPaint) and the dashboard unit folder stay green.reload, no sync inside the reconcile's own update train.reload.Out of Scope
#18028, merged).#17948,#17966, both merged).#18027) and the native-titlebar popup drag path (#18029).refreshPromisetails 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
reload'shiddenat projection time — it would fix mechanism 1 and reintroduce the mid-reconcile action-group replacement the LayoutAdapter comment forbids.Workspace.mjs:3365); the fix stays in the post-settle slot.currentTabsas "all tabs" — its name is honest; the bug is the consumer assuming otherwise.resolveRevealPaneis 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