Context
Reported by @tobiu 2026-09-01 against the Workstation on dev@96987351df, with a screenshot and — decisively — the mechanism: "look at the 2 active tabs => it is in fact the header i dragged left, getting restored."
The measured mechanism — this replaces an earlier claim in this body that the source keeps a real, restored button. It does not.
The App Worker moves the component correctly: getTabButtons() on the SOURCE toolbar no longer lists the dragged item. What survives is the item's old DOM node, never removed. So one element id — neo-tab-header-button-3 in the captured run — renders under two headers at once, and the stale node keeps the pressed class it left with. Nothing is restored, and there is no second component; the document holds a duplicate id.
Evidence, joined by bar id rather than list position:
bar toolbar-7 (new) DOM ["Priority Alert Observatory"] engine tabs 1
bar toolbar-3 (source) DOM ["Resident Activity Timeline",
"Priority Alert Observatory"] engine tabs 11
node ids in MORE THAN ONE bar: [{id: neo-tab-header-button-3, bars: [toolbar-7, toolbar-3]}]The 11 is not an accumulation and not part of the defect: one rendered button plus ten legitimately collapsed catalog items (hidden: true, mounted: false, removeDom: true). The dragged item is not among them.
So the fix belongs in the projection delta that moves a retained button between owners — not in the sort zone's restore path, and not in the dock model, both of which behave correctly.
Live latest-open sweep at 2026-09-01T13:35:45Z: 12 newest open issues checked plus a targeted search on drag/restore/pressed/duplicate. Nearest neighbours are #16499 ("Re-entry paints the pane in both embodiments", closed, about window embodiments rather than a within-window source header) and #17889 (pointer-event sensor migration, unrelated). No equivalent open issue.
Reproduction
- Open
apps/workstation.
- Drag Priority Alert Observatory out of its group — the group also holds Resident Activity Timeline — leftwards over the 100k grid.
- Dock it as its own pane.
Observed: the new pane renders correctly. The source header now shows both Resident Activity Timeline and Priority Alert Observatory, each with an active underline.
Expected: the source header shows only the tabs it still owns.
It is a regression, and the first bad commit is isolated
@tobiu suspected a regression and was right. Bisected against a deterministic reproduction (see below), 3 runs per probe:
| commit |
date |
result |
478a9fafb8 before the DockLayouts anatomy rewrite |
08-29 |
3/3 pass |
e9f10ba17e the anatomy rewrite itself (#17841) |
08-29 |
3/3 pass |
36332ff3f5 dock workspace tab drag boundary (#17926) |
08-30 |
3/3 pass |
9954179bb7 inline + standalone chrome variants (#17950) |
08-31 |
3/3 pass |
14d1024f7a project overflow into header actions (#17951 / #17960) |
08-31 |
3/3 FAIL |
937feac74e · 8cfa78702b · origin/dev |
08-31 → 09-01 |
3/3 FAIL |
14d1024f7a is the immediate child of the last good commit — git log 9954179bb7..14d1024f7a lists exactly one commit — so the boundary needs no further narrowing.
Two candidate mechanisms were ruled in/out at that boundary rather than assumed:
- Not the drag/overflow handshake.
whenProjectionIdle / onSortSnapshotReady / sortSnapshotPending appear 6 times in the tab SortZone at both the last-good and first-bad commits. That coupling predates the regression.
- The action-contribution surface is what arrived. #17951 added
addActionContribution, bindActionItems and isToolbarActionContribution to src/toolbar/Base.mjs, and addActionContribution inserts an item into the same owner.items collection the sort math indexes into (startIndex, currentIndex, moveTo(fromIndex, toIndex)). tab/header/toolbar/SortZone#moveTo already remaps item indexes to tab indexes to survive interleaved action items, which shows the hazard was anticipated there — but me.dragComponent is restored by reference, on a different path.
That is the area, not yet the line. The line-level cause is the next step and is not claimed here.
The Architectural Reality
The container/SortZone.mjs visibility-restore suspect below is RETIRED, not merely demoted. The engine's source-bar tab set does not contain the dragged item, so nothing restored it into that collection and no visibility toggle explains a duplicate DOM id. That block also last changed at 7de06abd13 (#8156) and is unchanged across the regression boundary. It is recorded here so the path is not re-walked.
A second retraction: an earlier revision claimed this was "not the doubled-subtree failure mode" on the grounds that duplicate ids were empty. That instrument was scoped within one bar while the duplication is across two, so it answered a narrower question than the one asked. This defect is in the same duplicate-id family as #17980, reached by a different route — a cross-zone move rather than a bar write racing a reconcile.
src/draggable/container/SortZone.mjs:590-595, in the drag-end path:
if (me.dragComponent) {
let style = me.dragComponent.wrapperStyle || {};
style.visibility = null;
me.dragComponent.wrapperStyle = style;
}The dragged tab button is hidden for the duration of the gesture and this restores it unconditionally. For the within-toolbar reorder the base class is written for, that is exactly right — the button never left. For a dock cross-zone move it is not: the item has been re-homed to another node, and the source's button is a component that should be retiring, not becoming visible again.
Neo.dashboard.dock.interaction.TabSortZone#processDragEnd (src/dashboard/dock/interaction/TabSortZone.mjs:1136) handles the cross-zone and tear-out terminals and calls into the base drag-end, but nothing in the path above distinguishes "the item stayed and was reordered" from "the item was re-homed" for the purposes of that visibility restore.
Why the existing witness never caught it. test/playwright/e2e/workstation/WorkstationDockSplitChromeNL.spec.mjs already records pressedCount and tabTexts per container and deliberately leaves pressedCount unasserted, documented there as belonging to "the orphaned-DOM class". Its cue commits edge-bottom on scale-tabs, and on that path every container reports pressedCount: 1 — so the arm is honest but its gesture does not reach this. The operator's gesture does.
The Fix
Not prescribed — the reachability question comes first. Candidate shapes, in the order I would test them:
- Scope the visibility restore to the reorder case. The base restores because it hid the component; if the terminal re-homed the item, the source's button is not the base's to un-hide. This keeps the change in the dock subclass rather than mutating shared drag semantics.
- Retire the source button at the cross-zone terminal, so there is nothing left for a later restore to reveal.
Option 1 is smaller and does not touch the within-toolbar contract every non-dock consumer relies on; option 2 is right if the button is genuinely leaking rather than merely being re-shown.
Acceptance Criteria
Out of Scope
- The drag-proxy density contract (#18020, landed as #18023).
- Config-derived class loss on projection (#18018, landed).
- Pointer-event sensor migration (#17889).
Refs #17836
Context
Reported by @tobiu 2026-09-01 against the Workstation on
dev@96987351df, with a screenshot and — decisively — the mechanism: "look at the 2 active tabs => it is in fact the header i dragged left, getting restored."The measured mechanism — this replaces an earlier claim in this body that the source keeps a real, restored button. It does not.
The App Worker moves the component correctly:
getTabButtons()on the SOURCE toolbar no longer lists the dragged item. What survives is the item's old DOM node, never removed. So one element id —neo-tab-header-button-3in the captured run — renders under two headers at once, and the stale node keeps thepressedclass it left with. Nothing is restored, and there is no second component; the document holds a duplicate id.Evidence, joined by bar id rather than list position:
bar toolbar-7 (new) DOM ["Priority Alert Observatory"] engine tabs 1 bar toolbar-3 (source) DOM ["Resident Activity Timeline", "Priority Alert Observatory"] engine tabs 11 node ids in MORE THAN ONE bar: [{id: neo-tab-header-button-3, bars: [toolbar-7, toolbar-3]}]The
11is not an accumulation and not part of the defect: one rendered button plus ten legitimately collapsed catalog items (hidden: true, mounted: false, removeDom: true). The dragged item is not among them.So the fix belongs in the projection delta that moves a retained button between owners — not in the sort zone's restore path, and not in the dock model, both of which behave correctly.
Live latest-open sweep at 2026-09-01T13:35:45Z: 12 newest open issues checked plus a targeted search on drag/restore/pressed/duplicate. Nearest neighbours are #16499 ("Re-entry paints the pane in both embodiments", closed, about window embodiments rather than a within-window source header) and #17889 (pointer-event sensor migration, unrelated). No equivalent open issue.
Reproduction
apps/workstation.Observed: the new pane renders correctly. The source header now shows both
Resident Activity TimelineandPriority Alert Observatory, each with an active underline.Expected: the source header shows only the tabs it still owns.
It is a regression, and the first bad commit is isolated
@tobiu suspected a regression and was right. Bisected against a deterministic reproduction (see below), 3 runs per probe:
478a9fafb8before the DockLayouts anatomy rewritee9f10ba17ethe anatomy rewrite itself (#17841)36332ff3f5dock workspace tab drag boundary (#17926)9954179bb7inline + standalone chrome variants (#17950)14d1024f7aproject overflow into header actions (#17951 / #17960)937feac74e·8cfa78702b·origin/dev14d1024f7ais the immediate child of the last good commit —git log 9954179bb7..14d1024f7alists exactly one commit — so the boundary needs no further narrowing.Two candidate mechanisms were ruled in/out at that boundary rather than assumed:
whenProjectionIdle/onSortSnapshotReady/sortSnapshotPendingappear 6 times in the tab SortZone at both the last-good and first-bad commits. That coupling predates the regression.addActionContribution,bindActionItemsandisToolbarActionContributiontosrc/toolbar/Base.mjs, andaddActionContributioninserts an item into the sameowner.itemscollection the sort math indexes into (startIndex,currentIndex,moveTo(fromIndex, toIndex)).tab/header/toolbar/SortZone#moveToalready remaps item indexes to tab indexes to survive interleaved action items, which shows the hazard was anticipated there — butme.dragComponentis restored by reference, on a different path.That is the area, not yet the line. The line-level cause is the next step and is not claimed here.
The Architectural Reality
The
container/SortZone.mjsvisibility-restore suspect below is RETIRED, not merely demoted. The engine's source-bar tab set does not contain the dragged item, so nothing restored it into that collection and no visibility toggle explains a duplicate DOM id. That block also last changed at7de06abd13(#8156) and is unchanged across the regression boundary. It is recorded here so the path is not re-walked.A second retraction: an earlier revision claimed this was "not the doubled-subtree failure mode" on the grounds that duplicate ids were empty. That instrument was scoped within one bar while the duplication is across two, so it answered a narrower question than the one asked. This defect is in the same duplicate-id family as #17980, reached by a different route — a cross-zone move rather than a bar write racing a reconcile.
src/draggable/container/SortZone.mjs:590-595, in the drag-end path:// Restore visibility of the dragged component (it's not in sortableItems if placeholder is used) if (me.dragComponent) { let style = me.dragComponent.wrapperStyle || {}; style.visibility = null; me.dragComponent.wrapperStyle = style; }The dragged tab button is hidden for the duration of the gesture and this restores it unconditionally. For the within-toolbar reorder the base class is written for, that is exactly right — the button never left. For a dock cross-zone move it is not: the item has been re-homed to another node, and the source's button is a component that should be retiring, not becoming visible again.
Neo.dashboard.dock.interaction.TabSortZone#processDragEnd(src/dashboard/dock/interaction/TabSortZone.mjs:1136) handles the cross-zone and tear-out terminals and calls into the base drag-end, but nothing in the path above distinguishes "the item stayed and was reordered" from "the item was re-homed" for the purposes of that visibility restore.Why the existing witness never caught it.
test/playwright/e2e/workstation/WorkstationDockSplitChromeNL.spec.mjsalready recordspressedCountandtabTextsper container and deliberately leavespressedCountunasserted, documented there as belonging to "the orphaned-DOM class". Its cue commitsedge-bottomonscale-tabs, and on that path every container reportspressedCount: 1— so the arm is honest but its gesture does not reach this. The operator's gesture does.The Fix
Not prescribed — the reachability question comes first. Candidate shapes, in the order I would test them:
Option 1 is smaller and does not touch the within-toolbar contract every non-dock consumer relies on; option 2 is right if the button is genuinely leaking rather than merely being re-shown.
Acceptance Criteria
pressedtab, for the operator's gesture specifically.pressedCountassertion that passes on unfixed source does not count — and note the existing split-chrome arm passes today, so a new gesture is required rather than a new assertion on the old one.WorkstationDockSplitChromeNL.spec.mjs'spressedCountforensic note is updated — once this is fixed, the comment explaining why it is unasserted is stale, and the assertion may become enforceable there.Out of Scope
Refs #17836