Context
Measured on a downstream dock consumer while verifying the pop-out round trip end to end (2026-09-03). The tear-out and the return both work; the pane just does not come back where it left.
BEFORE pop-out : ["Dokumente", "Editor", "Verbindung"] Verbindung is the RIGHT EDGE zone
AFTER pop-out : ["Dokumente", "Editor"] aux-tabs emptied and was removed
AFTER close : ["Dokumente", "Verbindung", "Editor"] back in the CENTRE, beside Dokumente
Same component instance returns (neo-webstudio-connection-info-1 both times, and Neo does not reuse ids), so object permanence holds. Only the placement is wrong.
The Problem
Workspace#reintegrateTearOutItem resolves the pane's home like this:
placement = me.tearOutPlacements[itemId],
storedHome = placement && doc?.nodes?.[placement.tabsNodeId]?.type === 'tabs' ? placement.tabsNodeId : null,
fallback = storedHome || Object.entries(doc?.nodes || {}).find(([, node]) => node.type === 'tabs')?.[0],tearOutPlacements[itemId] records the exact pre-detach placement — captureTearOutPane's sibling discipline, and the right intent. But the storedHome guard requires that node to still exist, and for a single-pane zone it never does: detaching the last item empties the node, an empty tabs node is removed, and an emptied edge zone collapses. So storedHome is null and the fallback is the first tabs node in document order — an arbitrary zone that merely happens to be enumerated first.
The failure is therefore guaranteed, not occasional, for exactly the panes most likely to be torn out: a pane that is alone in its zone. A pane sharing a node with siblings returns correctly, because its node survives the detach.
Two consequences worth separating:
- The user's layout silently changes. A right-edge inspector comes back as a centre tab. Nothing reports it, and a saved perspective committed afterwards persists the wrong arrangement.
- The recorded intent is discarded.
tearOutPlacements holds a tabsNodeId that is no longer resolvable, and nothing tries to reconstruct what it describes.
The Architectural Reality
src/dashboard/dock/Workspace.mjs — reintegrateTearOutItem(itemId, pane): the storedHome / fallback resolution above, then applyDockZoneOperation({operation: 'addTab', itemId, tabsNodeId: fallback}).
tearOutPlacements[itemId] is written pre-detach and deleted at the top of reintegrateTearOutItem, so the record's only consumer is the branch that cannot use it once the node is gone.
- Node removal on empty is correct behaviour and is not in question here (docking design record §2.7): the defect is that reintegration has no way to reverse it.
Document owns the node vocabulary, so reconstructing a zone is a reducer concern rather than something the return path should hand-roll.
The Fix
Make the recorded placement sufficient to rebuild its own home, rather than only to find one that survived.
tearOutPlacements[itemId] should capture enough of the pre-detach position to reconstruct it — the owning zone and the node's position within its parent, not just a tabsNodeId string. On return, when the stored node is absent, reintegration recreates it in that position and adds the item there; when it is present, today's behaviour is unchanged.
The arbitrary Object.entries(...).find(...) fallback should remain only as the genuine last resort — a document with no recorded placement at all — and it is worth making that path say so rather than looking like a considered choice.
Contract Ledger Matrix
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
Workspace#tearOutPlacements entry |
src/dashboard/dock/Workspace.mjs |
{tabsNodeId, index, home} — the added home carries the node's own parent slot and, for a split parent, orientation, size share and a sibling anchor |
absent entry → last-resort first-tabs-node, as today |
JSDoc |
round-trip arm asserting the exact zone |
Workspace#reintegrateTearOutItem |
same |
restores through the recorded home; retries via the last resort when it no longer resolves |
node still present → unchanged path |
JSDoc |
red-first: single-pane edge zone returns to that edge |
Document.captureNodeHome(document, nodeId) |
src/dashboard/dock/model/Document.mjs |
new static. Records a node's own position well enough to rebuild it. The sibling anchor is what makes the record outlive its own parent: a two-child split collapses into the sibling, so parentId is unresolvable in exactly the case the record exists for |
no parent slot → null |
JSDoc |
capture/restore round-trip arms |
Document.restoreNodeHome(document, nodeId, home) |
same |
new static. Grafts a node back, most faithful first: recorded parent still holds the slot → re-split against the sibling → fail closed. Must treat an OCCUPIED single-slot home as unresolved rather than overwriting its occupant |
unresolvable → returns errors, caller decides |
JSDoc |
occupancy arm (AC-8) + fail-closed arm |
Operations.restoreTab |
src/dashboard/dock/model/Operations.mjs |
new entry in the public operation vocabulary (Operations.operations 15 → 16, and consumers read that list). Distinct from addTab because the precondition is inverted: it rebuilds a missing target and delegates verbatim when the target exists |
target unresolvable → errors |
JSDoc |
reducer arms per resolution path |
Decision Record impact
aligned-with ADR 0029 — §2.7's collapse-on-empty stays exactly as it is; this restores the inverse for a returning pane and changes no zone semantics.
Acceptance Criteria
Out of Scope
- Collapse-on-empty itself; removing an emptied node stays correct.
- The vessel lifecycle, adoption, and the declining-hook family — #18151 and its subs.
- Restoring geometry (split sizes) beyond the node's position; a returning pane may inherit the current sizes.
Avoided Traps
- Reading tab order as placement. The symptom surfaced as a changed tab sequence, but the assertion has to be on the document's nodes: a correct tab order over a wrong zone would pass, and the two are only coincidentally related.
- Suppressing collapse-on-empty to keep the home alive. That would leave empty zones behind for every tear-out and contradict §2.7 — the return path is what needs to reverse it, not the detach.
- Treating this as rare. It is guaranteed for a pane alone in its zone, which is the common tear-out case; the passing case is the one with siblings.
- Guarding only existence, never ownership. A recorded coordinate can be invalid or taken, and restoration code naturally checks the first and forgets the second. An additive slot (a split's
children) is immune; a single-value slot (an edge zone's nodeId) is not. My own AC set missed this until review — corrected as AC-8.
- Asserting DOM identity for AC-5. A node moved between documents is necessarily re-created. I measured this and briefly read it as a lost instance; the component id is the only honest witness.
Related
- #18151 — the epic this sits beside; distinct, because the hooks there decline while this path runs and gets the answer wrong.
- #18153 / #18160 — the vessel seam and its engine defaults; this is downstream of a working round trip.
- #15635 (closed) — a torn-out pane stranding in a dead vessel tree; same seam, different failure.
- ADR 0029 §2.7 — collapse-on-empty.
Live latest-open sweep: checked the 20 latest open issues at 2026-09-03T07:00Z; duplicate sweep over reintegrateTearOutItem / tearOutPlacements / reintegration-placement returned no equivalent — #15635 is closed and concerns stranding rather than placement.
Origin Session ID: a5db4dc8-310d-4702-9bfa-3780d32e932b
Retrieval Hint: "pane returns to wrong zone after vessel close tearOutPlacements storedHome collapsed home node reintegrateTearOutItem"
Context
Measured on a downstream dock consumer while verifying the pop-out round trip end to end (2026-09-03). The tear-out and the return both work; the pane just does not come back where it left.
Same component instance returns (
neo-webstudio-connection-info-1both times, and Neo does not reuse ids), so object permanence holds. Only the placement is wrong.The Problem
Workspace#reintegrateTearOutItemresolves the pane's home like this:placement = me.tearOutPlacements[itemId], storedHome = placement && doc?.nodes?.[placement.tabsNodeId]?.type === 'tabs' ? placement.tabsNodeId : null, fallback = storedHome || Object.entries(doc?.nodes || {}).find(([, node]) => node.type === 'tabs')?.[0],tearOutPlacements[itemId]records the exact pre-detach placement —captureTearOutPane's sibling discipline, and the right intent. But thestoredHomeguard requires that node to still exist, and for a single-pane zone it never does: detaching the last item empties the node, an empty tabs node is removed, and an emptied edge zone collapses. SostoredHomeisnulland the fallback is the first tabs node in document order — an arbitrary zone that merely happens to be enumerated first.The failure is therefore guaranteed, not occasional, for exactly the panes most likely to be torn out: a pane that is alone in its zone. A pane sharing a node with siblings returns correctly, because its node survives the detach.
Two consequences worth separating:
tearOutPlacementsholds atabsNodeIdthat is no longer resolvable, and nothing tries to reconstruct what it describes.The Architectural Reality
src/dashboard/dock/Workspace.mjs—reintegrateTearOutItem(itemId, pane): thestoredHome/fallbackresolution above, thenapplyDockZoneOperation({operation: 'addTab', itemId, tabsNodeId: fallback}).tearOutPlacements[itemId]is written pre-detach and deleted at the top ofreintegrateTearOutItem, so the record's only consumer is the branch that cannot use it once the node is gone.Documentowns the node vocabulary, so reconstructing a zone is a reducer concern rather than something the return path should hand-roll.The Fix
Make the recorded placement sufficient to rebuild its own home, rather than only to find one that survived.
tearOutPlacements[itemId]should capture enough of the pre-detach position to reconstruct it — the owning zone and the node's position within its parent, not just atabsNodeIdstring. On return, when the stored node is absent, reintegration recreates it in that position and adds the item there; when it is present, today's behaviour is unchanged.The arbitrary
Object.entries(...).find(...)fallback should remain only as the genuine last resort — a document with no recorded placement at all — and it is worth making that path say so rather than looking like a considered choice.Contract Ledger Matrix
Workspace#tearOutPlacementsentrysrc/dashboard/dock/Workspace.mjs{tabsNodeId, index, home}— the addedhomecarries the node's own parent slot and, for a split parent, orientation, size share and a sibling anchorWorkspace#reintegrateTearOutItemDocument.captureNodeHome(document, nodeId)src/dashboard/dock/model/Document.mjsparentIdis unresolvable in exactly the case the record exists fornullDocument.restoreNodeHome(document, nodeId, home)Operations.restoreTabsrc/dashboard/dock/model/Operations.mjsOperations.operations15 → 16, and consumers read that list). Distinct fromaddTabbecause the precondition is inverted: it rebuilds a missing target and delegates verbatim when the target existsDecision Record impact
aligned-with ADR 0029— §2.7's collapse-on-empty stays exactly as it is; this restores the inverse for a returning pane and changes no zone semantics.Acceptance Criteria
Out of Scope
Avoided Traps
children) is immune; a single-value slot (an edge zone'snodeId) is not. My own AC set missed this until review — corrected as AC-8.Related
Live latest-open sweep: checked the 20 latest open issues at 2026-09-03T07:00Z; duplicate sweep over
reintegrateTearOutItem/tearOutPlacements/ reintegration-placement returned no equivalent — #15635 is closed and concerns stranding rather than placement.Origin Session ID: a5db4dc8-310d-4702-9bfa-3780d32e932b
Retrieval Hint: "pane returns to wrong zone after vessel close tearOutPlacements storedHome collapsed home node reintegrateTearOutItem"