feat(ui): Cross-instance tab attach via TabDragBroker (Phase 4)#12
Conversation
Introduce a TabDragBroker context that coordinates drag sessions across multiple EditorTabs instances on the same page. Tabs can be detached from one strip and dropped onto another with visual insertion indicators. - Add TabDragBrokerProvider with pointer tracking, drop zone registry, and floating ghost rendering via portal - Add useTabAttach hook for drop zone registration and insert index computation - Extend useTabDetach with horizontal threshold detection and onDetachArmed callback for mid-drag broker handoff - Add detachToBroker opt-in prop to EditorTabs (default false) - Add drop target highlight and insertion indicator CSS - Suppress sortable transition during detach to prevent visual glitch - Add CrossWindowAttach and SplitPaneAttach stories - Add unit tests for broker, attach hook, and horizontal detach
📝 WalkthroughWalkthroughA TabDragBroker-based cross-window/pane attach system is added. EditorTabs integrates broker lifecycle, attach/drop detection, and insert indicators; useTabAttach and TabDragBroker manage drop zones and ghost rendering. CSS and EditorTabItem transitions are adjusted to suppress snap-back during detach-armed drags. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant EditorTabs as EditorTabs<br/>(Main)
participant Broker as TabDragBroker<br/>(Provider)
participant Ghost as DetachGhost<br/>(Portal)
participant EditorTabs2 as EditorTabs<br/>(Target Pane)
User->>EditorTabs: pointerdown on tab
EditorTabs->>EditorTabs: enter reorder mode
User->>EditorTabs: move pointer beyond horizontal threshold
EditorTabs->>EditorTabs: transition to detach-armed
EditorTabs->>Broker: beginSession(tab, clientX, clientY)
Broker->>Ghost: render ghost at pointer pos
User->>Broker: pointerMove events
Broker->>Broker: hit-test drop zones, compute insertIndex
Broker->>EditorTabs2: set hover / isAttachDropTarget
EditorTabs2->>EditorTabs2: render AttachDropIndicator at insertIndex
User->>Broker: pointerup over EditorTabs2
Broker->>EditorTabs2: onAttach(commit {tab, sourceInstanceId, insertIndex})
EditorTabs2->>EditorTabs2: insert tab at index
Broker->>Ghost: clear session and unmount ghost
sequenceDiagram
participant Hook as useTabAttach
participant Broker as TabDragBroker
participant Viewport as EditorTabsViewport
rect rgba(100,150,200,0.5)
Hook->>Broker: registerDropZone({instanceId, getRect, getElement, onAttach})
Broker->>Broker: add zone to registry
end
rect rgba(150,200,100,0.5)
Broker->>Broker: pointerMove -> hitTest zones -> set hoverInstanceId, pointerX
Broker->>Hook: cause re-render via context
end
rect rgba(200,100,100,0.5)
Hook->>Hook: compute insertIndex from tab midpoints and broker.pointerX
Viewport->>Viewport: render AttachDropIndicator at computed left
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/base-ui/src/components/editor-tabs/context/TabDragBroker.tsx`:
- Around line 83-96: The cleanup function currently only runs on
pointerup/cancel and leaves document-level listeners if the provider unmounts;
add a React unmount effect in TabDragBrokerProvider that calls cleanup() (or
cancelSession() if you need onCancel semantics) to remove pointermove/pointerup
listeners, cancelAnimationFrame, clear sessionRef, and reset state when the
provider unmounts; locate the cleanup function in TabDragBroker.tsx and add a
useEffect with a return handler that invokes cleanup() so listeners and stale
closures are torn down on unmount.
- Around line 22-25: The broker currently seeds its pointerX/pointerY from
screen coordinates causing the fixed-position ghost to be misaligned; change the
TabDragBroker API so beginSession(session: TabDragSession, clientX: number,
clientY: number) (and any related calls) use client coordinates end-to-end:
update the TabDragBrokerValue interface, the beginSession implementation in
TabDragBroker, and all callers (notably useTabDetach) to pass clientX/clientY
instead of screenX/screenY, and ensure subsequent pointer updates and
cancelSession still operate on client-space pointerX/pointerY so the ghost is
positioned correctly immediately on beginSession.
- Around line 98-125: computeInsertIndex is scanning the entire document for
'[data-tab-id]' which can include tabs from other viewports causing wrong
hit-testing and insert indexes; change it to query only within the matched
DropZoneRegistration/viewport returned by zone.getRect() (or a zone-provided
element) instead of document.querySelectorAll, and use that zone-scoped element
for both the intersection check and the zoneTabs sort/midpoint logic; update any
other similar logic (the other function at lines ~144-176) that uses
document-wide queries to instead use zone-scoped queries and/or a registered
viewport element from dropZonesRef.current so the computed index only considers
tabs inside the target drop zone (refer to computeInsertIndex,
dropZonesRef.current, zone.getRect, and the other drop-handling function
mentioned).
In `@packages/base-ui/src/components/editor-tabs/EditorTabs.stories.tsx`:
- Around line 786-791: The BrokerFakeWindow story registers a fake attach target
by passing onAttachTab but handleAttachTab is a no-op, causing tabs to be
removed via onDetachCommit and never inserted into the target; either implement
the attach logic in handleAttachTab to update the fake window's tab state
(matching the detach flow) or stop passing onAttachTab to BrokerFakeWindow so it
isn't advertised as a drop target; update the same pattern for the second
occurrence (lines 860-863) and remove the unused handleAttachTab to clear the
ESLint error if you choose to stop advertising attach support.
In `@packages/base-ui/src/components/editor-tabs/hooks/useTabAttach.test.ts`:
- Around line 5-33: Add a broker-backed test around useTabAttach by wrapping the
hook with TabDragBrokerProvider; create a viewportRef containing two sibling tab
elements (with known boundingClientRect widths/positions) and render the hook
via renderHook({ wrapper: ({ children }) =>
<TabDragBrokerProvider>{children}</TabDragBrokerProvider> }). Use the
broker-backed flow to simulate hover: dispatch pointer/mouse events (inside act)
with clientX placed just left of the midpoint and assert
result.current.insertIndex === leftIndex, then dispatch with clientX just right
of the midpoint and assert insertIndex === rightIndex; keep instanceId and
viewportRef parameters the same as existing tests and ensure events target the
viewport/tab elements so hover detection and midpoint-based insertIndex logic in
useTabAttach are exercised.
In `@packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.ts`:
- Around line 11-12: The onDetachArmed callback currently forwards
screenX/screenY to the broker which breaks insertIndex calculations in
useTabAttach.ts that compare broker.pointerX to element.getBoundingClientRect()
midpoints; update the handoff for onDetachArmed in useTabDetach.ts to pass
clientX/clientY (not screenX/screenY) to the broker so broker.pointerX/ptr align
with getBoundingClientRect() coordinates, while keeping onDetachCommit unchanged
to continue using screenX/screenY; locate usages of onDetachArmed/onDetachCommit
and the broker assignment so you adjust only the onDetachArmed path and leave
onDetachCommit as-is.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: dcbb1677-75f0-44a9-9580-cd1c57eb8618
📒 Files selected for processing (15)
packages/base-ui/src/components/editor-tabs/EditorTabItem.tsxpackages/base-ui/src/components/editor-tabs/EditorTabs.module.csspackages/base-ui/src/components/editor-tabs/EditorTabs.stories.tsxpackages/base-ui/src/components/editor-tabs/EditorTabs.test.tsxpackages/base-ui/src/components/editor-tabs/EditorTabs.tsxpackages/base-ui/src/components/editor-tabs/EditorTabsViewport.tsxpackages/base-ui/src/components/editor-tabs/context/EditorTabsContext.tsxpackages/base-ui/src/components/editor-tabs/context/TabDragBroker.test.tsxpackages/base-ui/src/components/editor-tabs/context/TabDragBroker.tsxpackages/base-ui/src/components/editor-tabs/hooks/useTabAttach.test.tspackages/base-ui/src/components/editor-tabs/hooks/useTabAttach.tspackages/base-ui/src/components/editor-tabs/hooks/useTabDetach.test.tspackages/base-ui/src/components/editor-tabs/hooks/useTabDetach.tspackages/base-ui/src/components/editor-tabs/index.tspackages/base-ui/src/components/editor-tabs/types.ts
- Add useEffect cleanup in TabDragBrokerProvider to remove document listeners on unmount - Change broker API from screen to client coordinates so the ghost positions correctly with position:fixed - Scope computeInsertIndex to zone element via getElement() instead of querying all [data-tab-id] in the document - Remove no-op onAttachTab from BrokerFakeWindow so it doesn't silently swallow drops - Add broker-backed tests for useTabAttach verifying drop zone registration and insert index computation at various pointer positions
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.ts (1)
1-1:⚠️ Potential issue | 🟠 MajorAdd unmount cleanup for the document-level drag listener.
cleanup()only runs on drag end/cancel. If anEditorTabsinstance unmounts while a drag is active, the documentpointermovehandler and pending RAF persist with stale closures, preventing proper teardown. Mirror the unmount cleanup pattern fromTabDragBrokerProvider(line 119 in TabDragBroker.tsx).Patch sketch
-import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; @@ const cleanup = useCallback(() => { if (listenerRef.current) { document.removeEventListener('pointermove', listenerRef.current); listenerRef.current = null; @@ stripRectRef.current = null; activeIdRef.current = null; }, []); + + useEffect(() => cleanup, [cleanup]);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.ts` at line 1, The document-level pointermove handler and pending requestAnimationFrame in useTabDetach (cleanup only runs on drag end/cancel) need teardown on unmount to avoid stale closures; update the hook (useTabDetach) to register a useEffect cleanup that calls the existing cleanup function when the component unmounts, and ensure that cleanup removes the document pointermove listener and cancels any pending RAF (mirror the unmount cleanup pattern used in TabDragBrokerProvider). Locate the cleanup logic inside useTabDetach and make the effect call it on unmount so event listeners and RAF are always cleared even if a drag is active during unmount.
♻️ Duplicate comments (2)
packages/base-ui/src/components/editor-tabs/hooks/useTabAttach.test.ts (1)
51-199: 🧹 Nitpick | 🔵 TrivialThese tests still miss the live hover path.
Both broker-backed cases only validate the commit payload produced by
pointerup; they never drivepointermoveor assertresult.current.attach.isDropTarget/insertIndex. A regression in the visual attach-indicator path ofuseTabAttach()would still pass this suite.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/base-ui/src/components/editor-tabs/hooks/useTabAttach.test.ts` around lines 51 - 199, Tests only assert commit payload on pointerup and miss exercising the live hover path; update the two broker-backed tests that use useTabAttach and useTabDragBroker to also simulate pointermove events between beginSession and pointerup, find and call the registered 'pointermove' handler (like you did for 'pointerup'), and assert result.current.attach.isDropTarget becomes true and that result.current.attach.insertIndex updates to the expected index during hover before calling the pointerup commit; ensure you reference the broker.beginSession, attach.isDropTarget, and attach.insertIndex symbols when locating the code to change.packages/base-ui/src/components/editor-tabs/context/TabDragBroker.tsx (1)
137-145:⚠️ Potential issue | 🟠 MajorResolve the topmost hit zone, not the first registered one.
Both loops stop at the first matching entry in
dropZonesRef.current. Inpackages/base-ui/src/components/editor-tabs/EditorTabs.stories.tsxLines 952-966, the fake windows are draggable fixed overlays, so when a window overlaps the main strip the broker can still commit to whichever zone registered first. UsegetElement()together withdocument.elementsFromPoint()(or equivalent) so the broker picks the visually topmost intersecting zone before computinginsertIndex.Also applies to: 154-162
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/base-ui/src/components/editor-tabs/context/TabDragBroker.tsx` around lines 137 - 145, The current loop over dropZonesRef.current picks the first registered matching zone; change it to resolve the visually topmost matching zone by: collect all zones where hitTestZone(zone.getRect(), e.clientX, e.clientY) is true (and skip sessionRef.current?.sourceInstanceId), then use each zone's DOM element (zone.getElement()) and call document.elementsFromPoint(e.clientX, e.clientY) to pick the first element in that returned list that matches one of the zone elements; set foundId to that zone's id and proceed to compute insertIndex. Apply this change to both places in TabDragBroker.tsx where you iterate dropZonesRef.current (the initial hit-test loop and the subsequent similar loop) so the broker always commits to the visually topmost intersecting zone.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/base-ui/src/components/editor-tabs/hooks/useTabAttach.ts`:
- Around line 21-37: The effect in useTabAttach currently depends on the whole
broker context so pointer-state changes retrigger unregister/register; to fix,
pull the stable callbacks off the broker (e.g. const { registerDropZone,
unregisterDropZone } = broker || {}) and use those in the useEffect dependency
array instead of broker, then call registerDropZone({ instanceId, getRect: ...,
getElement: ..., onAttach: onAttachTab }) and return () =>
unregisterDropZone(instanceId); also guard the effect to return early if
registerDropZone or unregisterDropZone or onAttachTab are falsy and keep
instanceId and viewportRef in deps.
In `@packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.ts`:
- Around line 89-99: The hysteresis re-entry path currently skips when
onDetachArmed is present, making broker drags irreversible; change the logic in
useTabDetach so that when currentMode === 'detach-armed' you still compute
withinX/withinY and allow updateMode('reorder') to run regardless of
onDetachArmed; when performing that transition also call the broker recovery to
clear the session (i.e., invoke broker.clearSession() or the appropriate clear
method used by detachToBroker/EditorTabs) so the EditorTabs onDetachCommit path
no longer fires and broker state is reset. Ensure onDetachArmed/onDetachCommit
semantics are preserved and only the hysteresis re-entry gains
broker.clearSession() on the reorder transition.
---
Outside diff comments:
In `@packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.ts`:
- Line 1: The document-level pointermove handler and pending
requestAnimationFrame in useTabDetach (cleanup only runs on drag end/cancel)
need teardown on unmount to avoid stale closures; update the hook (useTabDetach)
to register a useEffect cleanup that calls the existing cleanup function when
the component unmounts, and ensure that cleanup removes the document pointermove
listener and cancels any pending RAF (mirror the unmount cleanup pattern used in
TabDragBrokerProvider). Locate the cleanup logic inside useTabDetach and make
the effect call it on unmount so event listeners and RAF are always cleared even
if a drag is active during unmount.
---
Duplicate comments:
In `@packages/base-ui/src/components/editor-tabs/context/TabDragBroker.tsx`:
- Around line 137-145: The current loop over dropZonesRef.current picks the
first registered matching zone; change it to resolve the visually topmost
matching zone by: collect all zones where hitTestZone(zone.getRect(), e.clientX,
e.clientY) is true (and skip sessionRef.current?.sourceInstanceId), then use
each zone's DOM element (zone.getElement()) and call
document.elementsFromPoint(e.clientX, e.clientY) to pick the first element in
that returned list that matches one of the zone elements; set foundId to that
zone's id and proceed to compute insertIndex. Apply this change to both places
in TabDragBroker.tsx where you iterate dropZonesRef.current (the initial
hit-test loop and the subsequent similar loop) so the broker always commits to
the visually topmost intersecting zone.
In `@packages/base-ui/src/components/editor-tabs/hooks/useTabAttach.test.ts`:
- Around line 51-199: Tests only assert commit payload on pointerup and miss
exercising the live hover path; update the two broker-backed tests that use
useTabAttach and useTabDragBroker to also simulate pointermove events between
beginSession and pointerup, find and call the registered 'pointermove' handler
(like you did for 'pointerup'), and assert result.current.attach.isDropTarget
becomes true and that result.current.attach.insertIndex updates to the expected
index during hover before calling the pointerup commit; ensure you reference the
broker.beginSession, attach.isDropTarget, and attach.insertIndex symbols when
locating the code to change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: f3041717-35c0-4c44-802e-2f232562a68a
📒 Files selected for processing (7)
packages/base-ui/src/components/editor-tabs/EditorTabs.stories.tsxpackages/base-ui/src/components/editor-tabs/EditorTabs.tsxpackages/base-ui/src/components/editor-tabs/context/TabDragBroker.test.tsxpackages/base-ui/src/components/editor-tabs/context/TabDragBroker.tsxpackages/base-ui/src/components/editor-tabs/hooks/useTabAttach.test.tspackages/base-ui/src/components/editor-tabs/hooks/useTabAttach.tspackages/base-ui/src/components/editor-tabs/hooks/useTabDetach.ts
| if (currentMode === 'reorder' && (above || below || pastLeft || pastRight)) { | ||
| updateMode('detach-armed'); | ||
| } else if (currentMode === 'detach-armed') { | ||
| // Hysteresis: must come back closer than half threshold to revert | ||
| const withinHysteresis = | ||
| onDetachArmed?.(activeIdRef.current!, e.clientX, e.clientY); | ||
| } else if (currentMode === 'detach-armed' && !onDetachArmed) { | ||
| // Hysteresis: must come back within half-threshold on BOTH axes to revert. | ||
| const withinY = | ||
| e.clientY >= rect.top - hysteresis && e.clientY <= rect.bottom + hysteresis; | ||
| if (withinHysteresis) { | ||
| const withinX = | ||
| e.clientX >= rect.left - hysteresis && e.clientX <= rect.right + hysteresis; | ||
| if (withinX && withinY) { | ||
| updateMode('reorder'); |
There was a problem hiding this comment.
Broker mode can’t return to reorder once it arms.
Line 92 disables the hysteresis path whenever onDetachArmed is provided. In detachToBroker mode that makes the handoff irreversible: dragging back over the source strip still ends in onDetachCommit, and the broker.clearSession() recovery path in packages/base-ui/src/components/editor-tabs/EditorTabs.tsx Lines 197-200 never becomes reachable. Keep the reorder re-entry path for broker drags and clear the broker session when that transition happens.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/base-ui/src/components/editor-tabs/hooks/useTabDetach.ts` around
lines 89 - 99, The hysteresis re-entry path currently skips when onDetachArmed
is present, making broker drags irreversible; change the logic in useTabDetach
so that when currentMode === 'detach-armed' you still compute withinX/withinY
and allow updateMode('reorder') to run regardless of onDetachArmed; when
performing that transition also call the broker recovery to clear the session
(i.e., invoke broker.clearSession() or the appropriate clear method used by
detachToBroker/EditorTabs) so the EditorTabs onDetachCommit path no longer fires
and broker state is reset. Ensure onDetachArmed/onDetachCommit semantics are
preserved and only the hysteresis re-entry gains broker.clearSession() on the
reorder transition.
- Stabilize useTabAttach effect deps by destructuring registerDropZone and unregisterDropZone from broker instead of depending on the whole context object which is recreated every render - Allow hysteresis revert even when onDetachArmed is present so broker drags are reversible; add onDetachReverted callback wired to broker.clearSession() in EditorTabs - Add useEffect unmount cleanup in useTabDetach to remove document pointermove listener and cancel pending rAF on unmount - Add live hover assertions (isDropTarget, insertIndex) to broker-backed useTabAttach tests via rAF stub
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/base-ui/src/components/editor-tabs/EditorTabs.tsx (1)
197-210:⚠️ Potential issue | 🟠 MajorGuard the legacy detach commit when the broker already consumed the drop.
With
detachToBrokerenabled, a target strip can already handle thepointerupviaonAttachTab, but this branch still callsdetach.handleDetachDragEnd()for everydetach-armedend. That means the same drag can both attach through the broker and re-fire the sourceonDetachCommit. Please plumb the broker’s end-state into this branch and only run the legacy detach path when the broker session finishes without an attach.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/base-ui/src/components/editor-tabs/EditorTabs.tsx` around lines 197 - 210, When handling DragEnd in handleDragEnd, guard the legacy detach commit so it only runs if the broker did not consume/complete an attach: inside the detach.dragModeRef.current === 'detach-armed' branch, after calling reorder.handleDragCancel(), check brokerSessionStarted.current and broker and whether the broker reports a completed attach/consumption (add/use a small query API such as broker.wasAttached() or broker.sessionConsumed flag); only call detach.handleDetachDragEnd(event) when that broker query indicates no attach occurred—otherwise skip detach.handleDetachDragEnd to avoid double-committing; keep detach.handleDetachDragCancel() in the other branch as-is and ensure brokerSessionStarted.current is reset afterward.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Outside diff comments:
In `@packages/base-ui/src/components/editor-tabs/EditorTabs.tsx`:
- Around line 197-210: When handling DragEnd in handleDragEnd, guard the legacy
detach commit so it only runs if the broker did not consume/complete an attach:
inside the detach.dragModeRef.current === 'detach-armed' branch, after calling
reorder.handleDragCancel(), check brokerSessionStarted.current and broker and
whether the broker reports a completed attach/consumption (add/use a small query
API such as broker.wasAttached() or broker.sessionConsumed flag); only call
detach.handleDetachDragEnd(event) when that broker query indicates no attach
occurred—otherwise skip detach.handleDetachDragEnd to avoid double-committing;
keep detach.handleDetachDragCancel() in the other branch as-is and ensure
brokerSessionStarted.current is reset afterward.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 8ca0214f-480c-4f0f-9539-6f2e52db607f
📒 Files selected for processing (4)
packages/base-ui/src/components/editor-tabs/EditorTabs.tsxpackages/base-ui/src/components/editor-tabs/hooks/useTabAttach.test.tspackages/base-ui/src/components/editor-tabs/hooks/useTabAttach.tspackages/base-ui/src/components/editor-tabs/hooks/useTabDetach.ts
Summary
TabDragBrokerProvidercontext that coordinates drag sessions across multipleEditorTabsinstances, enabling detach-from-one / drop-onto-another workflowsuseTabDetachwith horizontal threshold detection (left/right edge escape) and anonDetachArmedcallback so the broker takes over mid-drag while the pointer is still downuseTabAttachhook for drop zone registration, hover detection, and insert index computation from pointer positiondetachToBrokeropt-in prop onEditorTabs(defaultfalse) — standalone Phase 3 detach is unaffectedTest plan
pnpm typecheckpassespnpm test)Summary by CodeRabbit
New Features
Bug Fixes
Tests