feat(tab-drag): gate cross-group terminal drops when daemon is off

Block terminal-tab drops that would cross groups (sortable into a
different group, tab-bar empty-space, or pane-body split) while
settings.experimentalTerminalDaemon is false. Browser and editor
tabs drag freely. Same-group terminal reorder is unaffected.

Why: moving a live terminal between groups remounts the pane, and
the non-daemon reattach path on this branch was too brittle to ship
(was just reverted). Daemon-on reattach uses snapshots and is safe,
so the gate is a no-op there. Gate can be removed once the daemon
flips on by default.

UX: the overlay/drop-indicator is hidden for blocked targets via
resetOver() in handleDragOver, so the user doesn't see a preview
for a drop that will be refused. Defensive guards in handleDragEnd
catch any race between dragOver state and drop.
This commit is contained in:
brennanb2025 2026-04-20 19:08:19 -07:00
parent 2d8a49422f
commit f0a89a2d5d

View file

@ -1,3 +1,8 @@
/* oxlint-disable max-lines -- Why: the cross-group DnD context coordinates
* collision detection, drag-state tracking, three distinct drop targets
* (sortable tab, group droppable, pane-body split), the shared drag gate for
* terminal tabs, and the drag overlay splitting would fragment the reducer-
* like state machine these callbacks share via dragStateRef. */
import { useCallback, useMemo, useRef, useState } from 'react'
import {
closestCenter,
@ -25,7 +30,8 @@ import {
isTabDragData,
parseGroupDropId,
parseSharedSortableId,
type CrossGroupDragState
type CrossGroupDragState,
type TabDragData
} from './CrossGroupDragContext'
import {
canDropTabIntoPaneBody,
@ -48,6 +54,33 @@ import {
const EMPTY_GROUPS: TabGroup[] = []
const EMPTY_TABS: Tab[] = []
// Why: moving a live terminal tab between groups remounts the pane, and the
// non-daemon PTY reattach path relies on a brittle rAF-retry + width-jitter
// sequence that can leave the destination pane blank under load. Until the
// terminal daemon flips on by default (which gives snapshot-based reattach),
// gate cross-group terminal moves so users never see a half-working drag.
// Same-group reorder is unaffected — it doesn't tear down the pane. Browser
// and editor tabs have no PTY lifecycle and always drag freely. Pane-body
// split drops onto another group are treated like cross-group moves because
// they also remount the terminal pane. Center-zone drops don't create a new
// group, so they reduce to the tab-bar path and the same rules apply.
function isTerminalCrossGroupDropBlocked(
drag: TabDragData,
targetGroupId: string,
splitZone: TabDropZone | null,
daemonEnabled: boolean
): boolean {
if (daemonEnabled || drag.contentType !== 'terminal') {
return false
}
// Split (non-center) into any group, including the source, creates a new
// group and remounts the pane — block it the same way as a cross-group move.
if (splitZone && splitZone !== 'center') {
return true
}
return drag.sourceGroupId !== targetGroupId
}
export default function TabGroupDndContext({
worktreeId,
children
@ -66,6 +99,7 @@ export default function TabGroupDndContext({
const closeEmptyGroup = useAppStore((state) => state.closeEmptyGroup)
const groups = useAppStore((state) => state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS)
const unifiedTabs = useAppStore((state) => state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_TABS)
const daemonEnabled = useAppStore((state) => state.settings?.experimentalTerminalDaemon === true)
const [dragState, setDragState] = useState<CrossGroupDragState>(EMPTY_DRAG_STATE)
const dragStateRef = useRef(dragState)
dragStateRef.current = dragState
@ -234,6 +268,12 @@ export default function TabGroupDndContext({
const overId = String(event.over.id)
const sortableTarget = parseSharedSortableId(overId)
if (sortableTarget) {
if (
isTerminalCrossGroupDropBlocked(activeTab, sortableTarget.groupId, null, daemonEnabled)
) {
resetOver()
return
}
const orderedTabs = groupVisibleOrder.get(sortableTarget.groupId) ?? []
const insertionIndex = computeInsertionIndex(
orderedTabs,
@ -252,6 +292,12 @@ export default function TabGroupDndContext({
const groupDropTarget = parseGroupDropId(overId)
if (groupDropTarget) {
if (
isTerminalCrossGroupDropBlocked(activeTab, groupDropTarget.groupId, null, daemonEnabled)
) {
resetOver()
return
}
const orderedTabs = groupVisibleOrder.get(groupDropTarget.groupId) ?? []
const insertionIndex = computeInsertionFromPointer(
orderedTabs,
@ -303,6 +349,12 @@ export default function TabGroupDndContext({
resetOver()
return
}
if (
isTerminalCrossGroupDropBlocked(activeTab, paneBodyTarget.groupId, zone, daemonEnabled)
) {
resetOver()
return
}
setDragState({
activeTab,
overGroupId: paneBodyTarget.groupId,
@ -314,7 +366,7 @@ export default function TabGroupDndContext({
resetOver()
},
[groupVisibleOrder, worktreeId]
[daemonEnabled, groupVisibleOrder, worktreeId]
)
const handleDragCancel = useCallback(
@ -347,6 +399,9 @@ export default function TabGroupDndContext({
if (zone === 'center' && activeTab.sourceGroupId === targetGroupId) {
return
}
if (isTerminalCrossGroupDropBlocked(activeTab, targetGroupId, zone, daemonEnabled)) {
return
}
// Why: store actions can throw (e.g. group disappeared mid-drag) — catch
// so the drag still ends cleanly (clearDragState already ran above).
try {
@ -389,6 +444,9 @@ export default function TabGroupDndContext({
return
}
if (isTerminalCrossGroupDropBlocked(activeTab, targetGroupId, null, daemonEnabled)) {
return
}
try {
moveUnifiedTabToGroup(activeTab.unifiedTabId, targetGroupId, {
index: currentDragState.overTabBarIndex,
@ -402,6 +460,7 @@ export default function TabGroupDndContext({
},
[
clearDragState,
daemonEnabled,
dropUnifiedTab,
maybeCloseEmptySourceGroup,
moveUnifiedTabToGroup,