fix(terminals): restore pane detach tab placement (#7215)

* fix(tabs): restore deferred tab activation so dragging a terminal tab doesn't switch panes mid-gesture

PR #5927 shipped terminal-pane drag (reorder tabs, move a tab into another
pane, edge-split into a new column). It deliberately DEFERRED tab activation
to pointer-up and suppressed it during a drag, so pressing a tab to drag it
never switched the active tab or stole terminal focus mid-gesture.

PR #6395 (d32d62a395) deleted tab-strip-pointer-activation.ts and made
SortableTab/EditorFileTab/BrowserTab activate eagerly on pointerdown — fixing
click-to-switch-after-reorder but regressing the drag: grabbing a tab now
flips the active tab + focused pane and yanks xterm keyboard focus before the
drag is even recognized (aggravated by PR #6210 raising the dnd-kit threshold
5px->12px). The move itself still lands, but the gesture feels broken.

Restore the deferred-activation hook, but gate it on measured pointer
DISPLACEMENT instead of the drag-active context ref the old hook used — that
ref clears asynchronously relative to the drop's pointerup, which is exactly
what made #6395's click-after-reorder misfire. Displacement mirrors dnd-kit's
own activation threshold: a release within TAB_DRAG_ACTIVATION_DISTANCE_PX is
a click (activate); crossing it is a drag (suppress). Because each press
measures its own gesture, a click after a reorder always activates.

- Recreate src/renderer/src/components/tab-bar/tab-strip-pointer-activation.ts
  (displacement-gated) + unit tests, incl. an explicit #6395 regression case.
- Rewire onPointerDown in SortableTab, EditorFileTab, BrowserTab to use it.
- BrowserTab.test.tsx shallow-renders via mocked React hooks; add useRef/
  useCallback stubs so the new hook works under that harness.

Verified end-to-end in a dev build via CDP: pressing a tab no longer
activates/steals focus on pointerdown; a click still activates on release;
dragging a tab into another pane still moves it; clicking tabs after a drag
still switches (the #6395 guard in tests/e2e/tabs.spec.ts stays green because
it uses zero-displacement clicks). 233 unit tests pass; oxlint + renderer
typecheck clean.

* fix: restore pane detach tab placement
This commit is contained in:
Neil
2026-07-03 01:45:15 -07:00
committed by GitHub
parent 7c9059c4d4
commit a84f737cf7
22 changed files with 1624 additions and 53 deletions
+6
View File
@@ -219,6 +219,12 @@
pointer-events: none;
}
.pane-drop-overlay[data-pane-drop-overlay-kind='insertion'] {
background: var(--chart-2);
border: 0;
border-radius: 0;
}
.tab-drop-overlay__label {
font-size: 11px;
color: var(--primary-foreground);
@@ -11,6 +11,11 @@ vi.mock('react', async () => {
return {
...actual,
useEffect: () => {},
// Why: this shallow harness calls the component as a plain function (no React
// render), so ref/callback hooks must be stubbed like useState/useEffect. The
// favicon tests never fire pointer events, so non-persistent refs are fine.
useRef: <T,>(initial: T) => ({ current: initial }),
useCallback: <T,>(fn: T) => fn,
useState<T>(initial: T | (() => T)) {
const stateIndex = reactHookRuntime.index++
if (!(stateIndex in reactHookRuntime.states)) {
@@ -26,6 +26,7 @@ import { preventMiddleButtonDefault } from './middle-button-default-guard'
import { translate } from '@/i18n/i18n'
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
function formatBrowserTabUrlLabel(url: string): string {
if (url === ORCA_BROWSER_BLANK_URL || url === 'about:blank') {
@@ -169,6 +170,10 @@ export default function BrowserTab({
return () => window.removeEventListener('blur', dismiss)
}, [menuOpen])
// Why: defer activation to pointer-up so dragging the tab (reorder / move into
// another pane / split) does not switch the active tab mid-gesture.
const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({ onActivate })
const tabRoot = (
<div
ref={setNodeRef}
@@ -178,11 +183,10 @@ export default function BrowserTab({
{...listeners}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
onPointerDown={(e) => {
if (e.button !== 0) {
return
}
onActivate()
listeners?.onPointerDown?.(e)
onTabPointerDown(
e,
listeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
)
}}
onMouseDown={(e) => {
if (e.button === 1) {
@@ -30,6 +30,7 @@ import { EditorFileTabContextMenu } from './EditorFileTabContextMenu'
import { translate } from '@/i18n/i18n'
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
import { EditorFileTabCloseButton } from './EditorFileTabCloseButton'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
export default function EditorFileTab({
file,
@@ -201,6 +202,12 @@ export default function EditorFileTab({
}, [menuOpen])
const dragListeners = isRenaming ? undefined : listeners
// Why: defer activation to pointer-up so dragging the tab (reorder / move into
// another pane / split) does not switch the active tab mid-gesture.
const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
onActivate,
disabled: isRenaming
})
const tabRoot = (
<div
@@ -211,11 +218,10 @@ export default function EditorFileTab({
{...dragListeners}
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
onPointerDown={(e) => {
if (isRenaming || e.button !== 0) {
return
}
onActivate()
dragListeners?.onPointerDown?.(e)
onTabPointerDown(
e,
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
)
}}
onDoubleClick={() => {
if (file.isPreview && onMakePermanent) {
@@ -25,6 +25,7 @@ import { SortableTabContextMenu } from './SortableTabContextMenu'
import { translate } from '@/i18n/i18n'
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
type SortableTabProps = {
tab: TerminalTab
@@ -210,6 +211,16 @@ export default function SortableTab({
// so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only
// the pointer listeners are gated so a drag can't start while typing.
const dragListeners = isEditing ? undefined : listeners
const handleActivate = useCallback(() => {
onActivate(tab.id)
}, [onActivate, tab.id])
// Why: defer activation to pointer-up so pressing a tab to drag it (reorder /
// move into another pane / split) does not switch the active tab or steal
// terminal focus mid-gesture. See tab-strip-pointer-activation.
const { onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
onActivate: handleActivate,
disabled: isEditing
})
const closeShortcut = useShortcutKeyDetails('tab.close')
const tabTitle = tab.customTitle ?? tab.title
const tabRoot = (
@@ -243,11 +254,10 @@ export default function SortableTab({
handleRenameOpen()
}}
onPointerDown={(e) => {
if (isEditing || e.button !== 0) {
return
}
onActivate(tab.id)
dragListeners?.onPointerDown?.(e)
onTabPointerDown(
e,
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
)
}}
onMouseDown={(e) => {
// Why: prevent default browser middle-click behavior (auto-scroll)
@@ -0,0 +1,111 @@
// @vitest-environment happy-dom
import { act, renderHook } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit'
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
function pointerDownEvent(clientX: number, clientY: number, button = 0): React.PointerEvent {
return { button, clientX, clientY } as unknown as React.PointerEvent
}
function firePointer(type: string, clientX: number, clientY: number): void {
act(() => {
window.dispatchEvent(new PointerEvent(type, { clientX, clientY, bubbles: true }))
})
}
afterEach(() => {
vi.clearAllMocks()
})
describe('useTabStripPointerActivation', () => {
it('activates on a release that never crossed the drag threshold (a click)', () => {
const onActivate = vi.fn()
const dragListener = vi.fn()
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener))
// Why: the dnd-kit gesture must start on pointerdown even though activation
// is deferred.
expect(dragListener).toHaveBeenCalledTimes(1)
expect(onActivate).not.toHaveBeenCalled()
// Release within the threshold -> click -> activate.
firePointer('pointerup', 12, 11)
expect(onActivate).toHaveBeenCalledTimes(1)
})
it('suppresses activation when the pointer travels past the drag threshold', () => {
const onActivate = vi.fn()
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
firePointer('pointermove', 10 + TAB_DRAG_ACTIVATION_DISTANCE_PX + 5, 10)
firePointer('pointerup', 300, 40)
expect(onActivate).not.toHaveBeenCalled()
})
it('stays a drag even if the pointer returns near the start before release', () => {
const onActivate = vi.fn()
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
// Cross the threshold, then come back over the origin and release.
firePointer('pointermove', 200, 200)
firePointer('pointerup', 11, 11)
expect(onActivate).not.toHaveBeenCalled()
})
it('does not activate when the press is cancelled', () => {
const onActivate = vi.fn()
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
firePointer('pointercancel', 10, 10)
firePointer('pointerup', 10, 10)
expect(onActivate).not.toHaveBeenCalled()
})
it('ignores non-left buttons and disabled presses', () => {
const onActivate = vi.fn()
const dragListener = vi.fn()
const { result, rerender } = renderHook(
({ disabled }: { disabled: boolean }) =>
useTabStripPointerActivation({ onActivate, disabled }),
{ initialProps: { disabled: false } }
)
// Right-click: ignored.
act(() => result.current.onPointerDown(pointerDownEvent(10, 10, 2), dragListener))
firePointer('pointerup', 10, 10)
expect(onActivate).not.toHaveBeenCalled()
expect(dragListener).not.toHaveBeenCalled()
// Disabled: ignored.
rerender({ disabled: true })
act(() => result.current.onPointerDown(pointerDownEvent(10, 10), dragListener))
firePointer('pointerup', 10, 10)
expect(onActivate).not.toHaveBeenCalled()
expect(dragListener).not.toHaveBeenCalled()
})
it('activates a click that lands after a prior drag gesture (regression #6395)', () => {
const onActivate = vi.fn()
const { result } = renderHook(() => useTabStripPointerActivation({ onActivate }))
// First gesture: a drag (reorder). No activation.
act(() => result.current.onPointerDown(pointerDownEvent(10, 10)))
firePointer('pointermove', 300, 10)
firePointer('pointerup', 300, 10)
expect(onActivate).not.toHaveBeenCalled()
// Second gesture: a plain click. Must activate.
act(() => result.current.onPointerDown(pointerDownEvent(400, 10)))
firePointer('pointerup', 400, 10)
expect(onActivate).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useRef } from 'react'
import { TAB_DRAG_ACTIVATION_DISTANCE_PX } from '../tab-group/useTabDragSplit'
/**
* Defer tab activation to pointer-up and suppress it when the press turns into a
* drag. PR #5927 shipped this so dragging a tab (to reorder, move into another
* pane, or split) never switched the active tab or stole terminal focus
* mid-gesture; #6395 removed it (activating eagerly on pointerdown) to fix
* click-to-switch-after-reorder, which regressed the drag feature.
*
* We gate on measured pointer DISPLACEMENT, not the drag-active context ref the
* old hook used — that ref clears asynchronously relative to the drop's
* pointerup, which is what made #6395's click-after-reorder misfire. Displacement
* mirrors dnd-kit's own activation threshold exactly: once the pointer travels
* past it the gesture is a drag (activation suppressed); a release within it is a
* click (activate). Because each press measures its own gesture, a click after a
* reorder always activates.
*/
export function useTabStripPointerActivation({
onActivate,
disabled = false
}: {
onActivate: () => void
disabled?: boolean
}): {
onPointerDown: (
event: React.PointerEvent,
dragListener?: (event: React.PointerEvent<Element>) => void
) => void
} {
const onActivateRef = useRef(onActivate)
onActivateRef.current = onActivate
const cleanupRef = useRef<(() => void) | null>(null)
// Why: a press still holding when the tab unmounts (tab closed mid-drag, group
// collapse) would otherwise leak its window listeners and later fire activation
// on a dead closure.
useEffect(() => () => cleanupRef.current?.(), [])
const onPointerDown = useCallback(
(event: React.PointerEvent, dragListener?: (event: React.PointerEvent<Element>) => void) => {
if (disabled || event.button !== 0) {
return
}
// Why: start the dnd-kit gesture immediately on pointerdown; only the
// activation decision is deferred to release.
dragListener?.(event)
cleanupRef.current?.()
const startX = event.clientX
const startY = event.clientY
let draggedPastThreshold = false
const cleanup = (): void => {
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerCancel)
cleanupRef.current = null
}
const onPointerMove = (moveEvent: PointerEvent): void => {
if (
Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY) >=
TAB_DRAG_ACTIVATION_DISTANCE_PX
) {
draggedPastThreshold = true
}
}
const onPointerUp = (): void => {
const wasDrag = draggedPastThreshold
cleanup()
// Why: only a release that never crossed the drag threshold is a click.
// Activating after a real drag would yank the just-dropped tab's pane
// back to the source selection.
if (!wasDrag) {
onActivateRef.current()
}
}
const onPointerCancel = (): void => {
cleanup()
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
window.addEventListener('pointercancel', onPointerCancel)
cleanupRef.current = cleanup
},
[disabled]
)
return { onPointerDown }
}
@@ -225,7 +225,9 @@ export default function TabGroupPanel({
user can only drag from the tiny left-sidebar header strip. */}
<div
className="h-[32px] shrink-0 border-b border-border bg-card"
data-tab-group-strip-id={groupId}
data-terminal-focus-release-surface="true"
data-worktree-id={worktreeId}
>
<div className="flex h-full items-stretch pr-1.5">
{/* Why: Electron's native drag hit-test only respects no-drag on DOM
@@ -15,7 +15,11 @@ import {
resolveOpaqueTerminalBackground,
resolveEffectiveTerminalAppearance
} from '@/lib/terminal-theme'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import type {
ManagedPane,
PaneExternalDropTarget,
PaneManager
} from '@/lib/pane-manager/pane-manager'
import TerminalSearch from '@/components/TerminalSearch'
import type { PtyTransport } from './pty-transport'
import { fitPanes, isWindowsUserAgent } from './pane-helpers'
@@ -62,6 +66,11 @@ import { useSystemPrefersDark } from './use-system-prefers-dark'
import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects'
import { useTerminalPaneLifecycle } from './use-terminal-pane-lifecycle'
import { useTerminalPaneContextMenu } from './use-terminal-pane-context-menu'
import {
detachTerminalPaneToTab,
isTerminalTabStripDropTarget,
resolveTerminalTabStripDropTarget
} from './terminal-pane-tab-detach'
import type { PreparedAgentSessionFork } from './terminal-agent-session-fork'
import { useNotificationDispatch } from './use-notification-dispatch'
import { connectPanePty } from './pty-connection'
@@ -1298,6 +1307,54 @@ export default function TerminalPane({
setPendingCloseConfirmation(null)
}, [])
const resolveExternalPaneDropTarget = useCallback(
({
sourcePaneId,
clientX,
clientY
}: {
sourcePaneId: number
clientX: number
clientY: number
}): PaneExternalDropTarget | null => {
const manager = managerRef.current
const panes = manager?.getPanes() ?? []
if (panes.length <= 1 || !panes.some((pane) => pane.id === sourcePaneId)) {
return null
}
return resolveTerminalTabStripDropTarget({
clientX,
clientY,
groupsByWorktree: useAppStore.getState().groupsByWorktree,
worktreeId
})
},
[worktreeId]
)
const handleExternalPaneDrop = useCallback(
(sourcePaneId: number, target: PaneExternalDropTarget): boolean => {
if (!isTerminalTabStripDropTarget(target)) {
return false
}
const fallbackPtyId = paneTransportsRef.current.get(sourcePaneId)?.getPtyId() ?? null
return (
detachTerminalPaneToTab({
fallbackPtyId,
getStore: useAppStore.getState,
manager: managerRef.current,
persistLayoutSnapshot,
sourcePaneId,
sourceTabId: tabId,
targetGroupId: target.groupId,
targetIndex: target.insertionIndex,
worktreeId
}) !== null
)
},
[persistLayoutSnapshot, tabId, worktreeId]
)
useTerminalPaneLifecycle({
tabId,
worktreeId,
@@ -1354,7 +1411,9 @@ export default function TerminalPane({
paneTitlesRef,
setRenamingPaneId,
setPaneCount,
setPaneLayoutRevision
setPaneLayoutRevision,
resolveExternalPaneDropTarget,
onExternalPaneDrop: handleExternalPaneDrop
})
useEffect(() => {
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
const LEAF_2 = '22222222-2222-4222-8222-222222222222'
const LEAF_3 = '33333333-3333-4333-8333-333333333333'
function splitLayout(): TerminalLayoutSnapshot {
return {
root: {
type: 'split',
direction: 'vertical',
ratio: 0.25,
first: { type: 'leaf', leafId: LEAF_1 },
second: {
type: 'split',
direction: 'horizontal',
ratio: 0.7,
first: { type: 'leaf', leafId: LEAF_2 },
second: { type: 'leaf', leafId: LEAF_3 }
}
},
activeLeafId: LEAF_2,
expandedLeafId: LEAF_3,
ptyIdsByLeafId: {
[LEAF_1]: 'pty-1',
[LEAF_2]: 'remote:env-1@@terminal-1',
[LEAF_3]: 'pty-3'
},
buffersByLeafId: {
[LEAF_1]: 'buffer-1',
[LEAF_2]: 'buffer-2',
[LEAF_3]: 'buffer-3'
},
scrollbackRefsByLeafId: {
[LEAF_1]: 'scrollback-1',
[LEAF_2]: 'scrollback-2',
[LEAF_3]: 'scrollback-3'
},
titlesByLeafId: {
[LEAF_1]: 'one',
[LEAF_2]: 'remote shell',
[LEAF_3]: 'three'
}
}
}
describe('detachTerminalLayoutLeaf', () => {
it('extracts a nested leaf into a single-pane layout while preserving SSH PTY state', () => {
const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_2)
expect(detached?.ptyId).toBe('remote:env-1@@terminal-1')
expect(detached?.detachedLayout).toEqual({
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
buffersByLeafId: { [LEAF_2]: 'buffer-2' },
scrollbackRefsByLeafId: { [LEAF_2]: 'scrollback-2' },
titlesByLeafId: { [LEAF_2]: 'remote shell' }
})
expect(detached?.sourceLayout).toEqual({
root: {
type: 'split',
direction: 'vertical',
ratio: 0.25,
first: { type: 'leaf', leafId: LEAF_1 },
second: { type: 'leaf', leafId: LEAF_3 }
},
activeLeafId: LEAF_1,
expandedLeafId: LEAF_3,
ptyIdsByLeafId: {
[LEAF_1]: 'pty-1',
[LEAF_3]: 'pty-3'
},
buffersByLeafId: {
[LEAF_1]: 'buffer-1',
[LEAF_3]: 'buffer-3'
},
scrollbackRefsByLeafId: {
[LEAF_1]: 'scrollback-1',
[LEAF_3]: 'scrollback-3'
},
titlesByLeafId: {
[LEAF_1]: 'one',
[LEAF_3]: 'three'
}
})
})
it('clears source expanded selection when the expanded leaf is detached', () => {
const detached = detachTerminalLayoutLeaf(splitLayout(), LEAF_3)
expect(detached?.sourceLayout.expandedLeafId).toBeNull()
expect(detached?.sourceLayout.root).toEqual({
type: 'split',
direction: 'vertical',
ratio: 0.25,
first: { type: 'leaf', leafId: LEAF_1 },
second: { type: 'leaf', leafId: LEAF_2 }
})
})
it('returns null for missing or only leaf layouts', () => {
expect(detachTerminalLayoutLeaf(splitLayout(), 'missing')).toBeNull()
expect(
detachTerminalLayoutLeaf(
{
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_1]: 'pty-1' }
},
LEAF_1
)
).toBeNull()
})
})
@@ -0,0 +1,119 @@
import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode } from '../../../../shared/types'
import {
collectLeafIdsInOrder,
normalizeTerminalLayoutSnapshot,
resolveTerminalLayoutActiveLeafId
} from './terminal-layout-leaf-ids'
export type DetachedTerminalLayoutLeaf = {
sourceLayout: TerminalLayoutSnapshot
detachedLayout: TerminalLayoutSnapshot
ptyId: string | null
}
function removeLeafFromTree(
node: TerminalPaneLayoutNode,
leafId: string
): { node: TerminalPaneLayoutNode | null; removed: boolean } {
if (node.type === 'leaf') {
return node.leafId === leafId ? { node: null, removed: true } : { node, removed: false }
}
const first = removeLeafFromTree(node.first, leafId)
const second = removeLeafFromTree(node.second, leafId)
if (!first.removed && !second.removed) {
return { node, removed: false }
}
if (!first.node) {
return { node: second.node, removed: true }
}
if (!second.node) {
return { node: first.node, removed: true }
}
return {
node: {
...node,
first: first.node,
second: second.node
},
removed: true
}
}
function omitLeafRecord(
source: Record<string, string> | undefined,
leafId: string
): Record<string, string> | undefined {
if (!source || !Object.prototype.hasOwnProperty.call(source, leafId)) {
return source
}
const next = { ...source }
delete next[leafId]
return Object.keys(next).length > 0 ? next : undefined
}
function singleLeafRecord(
source: Record<string, string> | undefined,
leafId: string
): Record<string, string> | undefined {
const value = source?.[leafId]
return value ? { [leafId]: value } : undefined
}
export function detachTerminalLayoutLeaf(
snapshot: TerminalLayoutSnapshot | null | undefined,
leafId: string
): DetachedTerminalLayoutLeaf | null {
const layout = normalizeTerminalLayoutSnapshot(snapshot).snapshot
if (!layout.root) {
return null
}
const originalLeafIds = collectLeafIdsInOrder(layout.root)
if (!originalLeafIds.includes(leafId) || originalLeafIds.length <= 1) {
return null
}
const removal = removeLeafFromTree(layout.root, leafId)
if (!removal.removed || !removal.node) {
return null
}
const ptyIdsByLeafId = omitLeafRecord(layout.ptyIdsByLeafId, leafId)
const buffersByLeafId = omitLeafRecord(layout.buffersByLeafId, leafId)
const scrollbackRefsByLeafId = omitLeafRecord(layout.scrollbackRefsByLeafId, leafId)
const titlesByLeafId = omitLeafRecord(layout.titlesByLeafId, leafId)
const sourceLayout: TerminalLayoutSnapshot = {
root: removal.node,
activeLeafId: resolveTerminalLayoutActiveLeafId({
root: removal.node,
activeLeafId: layout.activeLeafId === leafId ? null : layout.activeLeafId,
ptyIdsByLeafId
}),
expandedLeafId: layout.expandedLeafId === leafId ? null : layout.expandedLeafId,
...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {}),
...(buffersByLeafId ? { buffersByLeafId } : {}),
...(scrollbackRefsByLeafId ? { scrollbackRefsByLeafId } : {}),
...(titlesByLeafId ? { titlesByLeafId } : {})
}
const detachedPtyIdsByLeafId = singleLeafRecord(layout.ptyIdsByLeafId, leafId)
const detachedBuffersByLeafId = singleLeafRecord(layout.buffersByLeafId, leafId)
const detachedScrollbackRefsByLeafId = singleLeafRecord(layout.scrollbackRefsByLeafId, leafId)
const detachedTitlesByLeafId = singleLeafRecord(layout.titlesByLeafId, leafId)
return {
sourceLayout,
detachedLayout: {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null,
...(detachedPtyIdsByLeafId ? { ptyIdsByLeafId: detachedPtyIdsByLeafId } : {}),
...(detachedBuffersByLeafId ? { buffersByLeafId: detachedBuffersByLeafId } : {}),
...(detachedScrollbackRefsByLeafId
? { scrollbackRefsByLeafId: detachedScrollbackRefsByLeafId }
: {}),
...(detachedTitlesByLeafId ? { titlesByLeafId: detachedTitlesByLeafId } : {})
},
ptyId: detachedPtyIdsByLeafId?.[leafId] ?? null
}
}
@@ -0,0 +1,383 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types'
import {
detachTerminalPaneToTab,
resolveTerminalTabStripDropTarget,
type TerminalPaneTabDetachStore
} from './terminal-pane-tab-detach'
const WORKTREE_ID = 'repo-1::/worktree'
const SOURCE_TAB_ID = 'tab-source'
const TARGET_GROUP_ID = 'group-target'
const EXISTING_TAB_1 = 'tab-existing-1'
const EXISTING_TAB_2 = 'tab-existing-2'
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
const LEAF_2 = '22222222-2222-4222-8222-222222222222'
function rect(args: { left: number; top: number; width: number; height: number }): DOMRect {
return {
left: args.left,
top: args.top,
right: args.left + args.width,
bottom: args.top + args.height,
width: args.width,
height: args.height
} as DOMRect
}
function splitLayout(): TerminalLayoutSnapshot {
return {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF_1 },
second: { type: 'leaf', leafId: LEAF_2 }
},
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: {
[LEAF_1]: 'pty-left',
[LEAF_2]: 'remote:env-1@@terminal-1'
},
buffersByLeafId: {
[LEAF_2]: 'remote-buffer'
},
titlesByLeafId: {
[LEAF_2]: 'remote shell'
}
}
}
function createTerminalTab(id: string, ptyId: string | null): TerminalTab {
return {
id,
ptyId,
worktreeId: WORKTREE_ID,
title: 'Terminal 2',
defaultTitle: 'Terminal 2',
customTitle: null,
color: null,
sortOrder: 1,
createdAt: 1
}
}
function createStore(
layout: TerminalLayoutSnapshot = splitLayout(),
targetTabOrder: string[] = [EXISTING_TAB_1, EXISTING_TAB_2]
): TerminalPaneTabDetachStore {
const store = {
createTab: vi.fn((_worktreeId, _targetGroupId, _shellOverride, options) => {
const tab = createTerminalTab('tab-detached', options?.initialPtyId ?? null)
const group = store.groupsByWorktree[WORKTREE_ID]?.find(
(candidate) => candidate.id === TARGET_GROUP_ID
)
if (group && !group.tabOrder.includes(tab.id)) {
group.tabOrder = [...group.tabOrder, tab.id]
}
return tab
}),
groupsByWorktree: {
[WORKTREE_ID]: [
{
id: TARGET_GROUP_ID,
worktreeId: WORKTREE_ID,
activeTabId: targetTabOrder[0] ?? null,
tabOrder: targetTabOrder,
recentTabIds: []
}
]
},
reorderUnifiedTabs: vi.fn((groupId: string, tabIds: string[]) => {
const group = store.groupsByWorktree[WORKTREE_ID]?.find(
(candidate) => candidate.id === groupId
)
if (group) {
group.tabOrder = tabIds
}
}),
setActiveTab: vi.fn(),
setActiveTabType: vi.fn(),
setTabLayout: vi.fn((tabId: string, nextLayout: TerminalLayoutSnapshot | null) => {
if (nextLayout) {
store.terminalLayoutsByTabId[tabId] = nextLayout
} else {
delete store.terminalLayoutsByTabId[tabId]
}
}),
syncPaneDetachPtyOwnership: vi.fn(),
terminalLayoutsByTabId: {
[SOURCE_TAB_ID]: layout
}
}
return store as unknown as TerminalPaneTabDetachStore
}
describe('resolveTerminalTabStripDropTarget', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('finds a same-worktree tab strip under overlay elements', () => {
const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 })
const strip = {
dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID },
getBoundingClientRect: () => stripRect,
querySelectorAll: () => []
}
const overlay = { closest: () => null }
const child = { closest: () => strip }
vi.stubGlobal('document', {
elementsFromPoint: vi.fn(() => [overlay, child]),
elementFromPoint: vi.fn()
})
expect(
resolveTerminalTabStripDropTarget({
clientX: 10,
clientY: 10,
groupsByWorktree: {
[WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]]
},
worktreeId: WORKTREE_ID
})
).toEqual({
id: TARGET_GROUP_ID,
groupId: TARGET_GROUP_ID,
worktreeId: WORKTREE_ID,
rect: stripRect
})
})
it('resolves the insertion slot from the hovered tab side', () => {
const stripRect = rect({ left: 0, top: 0, width: 300, height: 32 })
const firstTabRect = rect({ left: 0, top: 0, width: 80, height: 32 })
const secondTabRect = rect({ left: 80, top: 0, width: 80, height: 32 })
const firstTab = {
dataset: { tabId: EXISTING_TAB_1 },
getBoundingClientRect: () => firstTabRect
}
const secondTab = {
dataset: { tabId: EXISTING_TAB_2 },
getBoundingClientRect: () => secondTabRect
}
const strip = {
dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: WORKTREE_ID },
getBoundingClientRect: () => stripRect,
querySelectorAll: () => [firstTab, secondTab]
}
vi.stubGlobal('document', {
elementsFromPoint: vi.fn(() => [{ closest: () => firstTab }, { closest: () => strip }]),
elementFromPoint: vi.fn()
})
expect(
resolveTerminalTabStripDropTarget({
clientX: 60,
clientY: 10,
groupsByWorktree: {
[WORKTREE_ID]: [
{
id: TARGET_GROUP_ID,
activeTabId: EXISTING_TAB_1,
tabOrder: [EXISTING_TAB_1, EXISTING_TAB_2],
worktreeId: WORKTREE_ID
} as AppState['groupsByWorktree'][string][number]
]
},
worktreeId: WORKTREE_ID
})
).toMatchObject({
groupId: TARGET_GROUP_ID,
insertionIndex: 1,
overlayKind: 'insertion',
rect: rect({ left: 80, top: 0, width: 2, height: 32 })
})
})
it('ignores strips from another worktree', () => {
const strip = {
dataset: { tabGroupStripId: TARGET_GROUP_ID, worktreeId: 'other-worktree' },
getBoundingClientRect: () =>
({ left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 }) as DOMRect
}
vi.stubGlobal('document', {
elementsFromPoint: vi.fn(() => [{ closest: () => strip }]),
elementFromPoint: vi.fn()
})
expect(
resolveTerminalTabStripDropTarget({
clientX: 10,
clientY: 10,
groupsByWorktree: {
[WORKTREE_ID]: [{ id: TARGET_GROUP_ID } as AppState['groupsByWorktree'][string][number]]
},
worktreeId: WORKTREE_ID
})
).toBeNull()
})
})
describe('detachTerminalPaneToTab', () => {
it('creates a new terminal tab with the detached leaf layout and PTY id', () => {
const store = createStore()
const manager = {
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)),
detachPaneForExternalMove: vi.fn(() => true)
}
const persistLayoutSnapshot = vi.fn()
const result = detachTerminalPaneToTab({
manager,
getStore: () => store,
persistLayoutSnapshot,
sourcePaneId: 2,
sourceTabId: SOURCE_TAB_ID,
targetGroupId: TARGET_GROUP_ID,
worktreeId: WORKTREE_ID
})
expect(result?.ptyId).toBe('remote:env-1@@terminal-1')
expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(2)
expect(store.createTab).toHaveBeenCalledWith(WORKTREE_ID, TARGET_GROUP_ID, undefined, {
activate: true,
initialPtyId: 'remote:env-1@@terminal-1',
recordInteraction: true
})
expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, {
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_1]: 'pty-left' }
})
expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
buffersByLeafId: { [LEAF_2]: 'remote-buffer' },
titlesByLeafId: { [LEAF_2]: 'remote shell' }
})
expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({
detachedPtyId: 'remote:env-1@@terminal-1',
sourceLayout: {
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_1]: 'pty-left' }
},
sourceTabId: SOURCE_TAB_ID,
targetTabId: 'tab-detached'
})
expect(store.setActiveTab).toHaveBeenCalledWith('tab-detached')
expect(store.setActiveTabType).toHaveBeenCalledWith('terminal')
expect(persistLayoutSnapshot).toHaveBeenCalled()
})
it('syncs PTY ownership when the primary source pane is detached', () => {
const store = createStore()
const manager = {
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
getLeafId: vi.fn((paneId: number) => (paneId === 1 ? LEAF_1 : LEAF_2)),
detachPaneForExternalMove: vi.fn(() => true)
}
const result = detachTerminalPaneToTab({
getStore: () => store,
manager,
persistLayoutSnapshot: vi.fn(),
sourcePaneId: 1,
sourceTabId: SOURCE_TAB_ID,
targetGroupId: TARGET_GROUP_ID,
worktreeId: WORKTREE_ID
})
expect(result?.ptyId).toBe('pty-left')
expect(store.setTabLayout).toHaveBeenCalledWith(SOURCE_TAB_ID, {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
buffersByLeafId: { [LEAF_2]: 'remote-buffer' },
titlesByLeafId: { [LEAF_2]: 'remote shell' }
})
expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith({
detachedPtyId: 'pty-left',
sourceLayout: {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
buffersByLeafId: { [LEAF_2]: 'remote-buffer' },
titlesByLeafId: { [LEAF_2]: 'remote shell' }
},
sourceTabId: SOURCE_TAB_ID,
targetTabId: 'tab-detached'
})
})
it('moves the detached tab into the requested group slot', () => {
const store = createStore(splitLayout(), [EXISTING_TAB_1, EXISTING_TAB_2])
const manager = {
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
getLeafId: vi.fn((paneId: number) => (paneId === 2 ? LEAF_2 : LEAF_1)),
detachPaneForExternalMove: vi.fn(() => true)
}
detachTerminalPaneToTab({
getStore: () => store,
manager,
persistLayoutSnapshot: vi.fn(),
sourcePaneId: 2,
sourceTabId: SOURCE_TAB_ID,
targetGroupId: TARGET_GROUP_ID,
targetIndex: 1,
worktreeId: WORKTREE_ID
})
expect(store.reorderUnifiedTabs).toHaveBeenCalledWith(
TARGET_GROUP_ID,
[EXISTING_TAB_1, 'tab-detached', EXISTING_TAB_2],
{ recordInteraction: false }
)
})
it('uses the live transport PTY id when the snapshot has not persisted it yet', () => {
const store = createStore({
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF_1 },
second: { type: 'leaf', leafId: LEAF_2 }
},
activeLeafId: LEAF_2,
expandedLeafId: null
})
const manager = {
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
getLeafId: vi.fn(() => LEAF_2),
detachPaneForExternalMove: vi.fn(() => true)
}
detachTerminalPaneToTab({
fallbackPtyId: 'remote:env-2@@terminal-9',
getStore: () => store,
manager,
persistLayoutSnapshot: vi.fn(),
sourcePaneId: 2,
sourceTabId: SOURCE_TAB_ID,
targetGroupId: TARGET_GROUP_ID,
worktreeId: WORKTREE_ID
})
expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' }
})
})
})
@@ -0,0 +1,306 @@
import type { PaneExternalDropTarget } from '@/lib/pane-manager/pane-manager'
import type { AppState } from '@/store'
import type { TerminalTab } from '../../../../shared/types'
import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach'
const TAB_GROUP_STRIP_SELECTOR = '[data-tab-group-strip-id][data-worktree-id]'
export type TerminalPaneTabDetachStore = Pick<
AppState,
| 'createTab'
| 'groupsByWorktree'
| 'reorderUnifiedTabs'
| 'setActiveTab'
| 'setActiveTabType'
| 'setTabLayout'
| 'syncPaneDetachPtyOwnership'
| 'terminalLayoutsByTabId'
>
type TerminalPaneTabDetachManager = {
getPanes: () => readonly { id: number }[]
getLeafId: (paneId: number) => string | null
detachPaneForExternalMove: (paneId: number) => boolean
}
export type TerminalTabStripDropTarget = PaneExternalDropTarget & {
groupId: string
insertionIndex?: number
worktreeId: string
}
export type DetachedTerminalPaneTab = {
tab: TerminalTab
leafId: string
ptyId: string | null
}
function pointWithinRect(clientX: number, clientY: number, rect: DOMRect): boolean {
return (
clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom
)
}
function rectFromBox(args: { left: number; top: number; width: number; height: number }): DOMRect {
return {
left: args.left,
top: args.top,
right: args.left + args.width,
bottom: args.top + args.height,
width: args.width,
height: args.height
} as DOMRect
}
function clampIndex(index: number, max: number): number {
return Math.min(Math.max(index, 0), max)
}
function getTabElements(strip: HTMLElement): HTMLElement[] {
return Array.from(strip.querySelectorAll<HTMLElement>('[data-tab-id]')).filter(
(element) => typeof element.dataset.tabId === 'string' && element.dataset.tabId.length > 0
)
}
function getInsertionMarkerRect(
tabRects: DOMRect[],
insertionIndex: number,
stripRect: DOMRect
): DOMRect {
const markerWidth = 2
const clampedIndex = clampIndex(insertionIndex, tabRects.length)
const rawLeft =
clampedIndex < tabRects.length
? (tabRects[clampedIndex]?.left ?? stripRect.left)
: (tabRects.at(-1)?.right ?? stripRect.left) - markerWidth
const left = Math.min(Math.max(rawLeft, stripRect.left), stripRect.right - markerWidth)
return rectFromBox({ left, top: stripRect.top, width: markerWidth, height: stripRect.height })
}
function resolveTabStripInsertion(args: {
clientX: number
clientY: number
groupTabOrderLength: number
strip: HTMLElement
stripRect: DOMRect
}): { index: number; rect: DOMRect } | null {
const tabs = getTabElements(args.strip)
if (tabs.length === 0) {
return null
}
const tabRects = tabs.map((tab) => tab.getBoundingClientRect())
for (let index = 0; index < tabRects.length; index += 1) {
const tabRect = tabRects[index]
if (!tabRect) {
continue
}
if (args.clientX < tabRect.left) {
const insertionIndex = clampIndex(index, args.groupTabOrderLength)
return {
index: insertionIndex,
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
}
}
if (pointWithinRect(args.clientX, args.clientY, tabRect)) {
const insertionIndex = clampIndex(
index + (args.clientX < tabRect.left + tabRect.width / 2 ? 0 : 1),
args.groupTabOrderLength
)
return {
index: insertionIndex,
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
}
}
}
const insertionIndex = args.groupTabOrderLength
return {
index: insertionIndex,
rect: getInsertionMarkerRect(tabRects, insertionIndex, args.stripRect)
}
}
function getElementsFromPoint(clientX: number, clientY: number): Element[] {
if (typeof document === 'undefined') {
return []
}
const elements = document.elementsFromPoint?.(clientX, clientY)
if (elements && elements.length > 0) {
return elements
}
const element = document.elementFromPoint?.(clientX, clientY)
return element ? [element] : []
}
export function resolveTerminalTabStripDropTarget(args: {
clientX: number
clientY: number
groupsByWorktree: TerminalPaneTabDetachStore['groupsByWorktree']
worktreeId: string
}): TerminalTabStripDropTarget | null {
const groups = args.groupsByWorktree[args.worktreeId] ?? []
const groupById = new Map(groups.map((group) => [group.id, group]))
const validGroupIds = new Set(groups.map((group) => group.id))
if (validGroupIds.size === 0) {
return null
}
for (const element of getElementsFromPoint(args.clientX, args.clientY)) {
const strip = element.closest<HTMLElement>(TAB_GROUP_STRIP_SELECTOR)
const groupId = strip?.dataset.tabGroupStripId
const worktreeId = strip?.dataset.worktreeId
if (!strip || !groupId || worktreeId !== args.worktreeId || !validGroupIds.has(groupId)) {
continue
}
const rect = strip.getBoundingClientRect()
if (!pointWithinRect(args.clientX, args.clientY, rect)) {
continue
}
const group = groupById.get(groupId)
const insertion = group
? resolveTabStripInsertion({
clientX: args.clientX,
clientY: args.clientY,
groupTabOrderLength: group.tabOrder?.length ?? 0,
strip,
stripRect: rect
})
: null
return insertion
? {
id: groupId,
groupId,
insertionIndex: insertion.index,
overlayKind: 'insertion',
rect: insertion.rect,
worktreeId
}
: { id: groupId, groupId, worktreeId, rect }
}
return null
}
function withDetachedPtyFallback(args: {
leafId: string
ptyId: string | null
detachedLayout: NonNullable<ReturnType<typeof detachTerminalLayoutLeaf>>['detachedLayout']
}): NonNullable<ReturnType<typeof detachTerminalLayoutLeaf>>['detachedLayout'] {
if (!args.ptyId || args.detachedLayout.ptyIdsByLeafId?.[args.leafId]) {
return args.detachedLayout
}
return {
...args.detachedLayout,
ptyIdsByLeafId: {
...args.detachedLayout.ptyIdsByLeafId,
[args.leafId]: args.ptyId
}
}
}
export function isTerminalTabStripDropTarget(
target: PaneExternalDropTarget
): target is TerminalTabStripDropTarget {
const candidate = target as Partial<TerminalTabStripDropTarget>
return typeof candidate.groupId === 'string' && typeof candidate.worktreeId === 'string'
}
function moveCreatedTabToIndex(args: {
groupId: string
store: TerminalPaneTabDetachStore
tabId: string
targetIndex: number | undefined
worktreeId: string
}): void {
if (args.targetIndex === undefined) {
return
}
const group = args.store.groupsByWorktree[args.worktreeId]?.find(
(candidate) => candidate.id === args.groupId
)
if (!group) {
return
}
const orderWithoutCreatedTab = (group.tabOrder ?? []).filter((id) => id !== args.tabId)
const insertionIndex = clampIndex(args.targetIndex, orderWithoutCreatedTab.length)
const nextOrder = [...orderWithoutCreatedTab]
nextOrder.splice(insertionIndex, 0, args.tabId)
args.store.reorderUnifiedTabs(args.groupId, nextOrder, { recordInteraction: false })
}
export function detachTerminalPaneToTab(args: {
fallbackPtyId?: string | null
getStore: () => TerminalPaneTabDetachStore
manager: TerminalPaneTabDetachManager | null
persistLayoutSnapshot: () => void
sourcePaneId: number
sourceTabId: string
targetGroupId: string
targetIndex?: number
worktreeId: string
}): DetachedTerminalPaneTab | null {
const initialStore = args.getStore()
const targetGroupExists =
initialStore.groupsByWorktree[args.worktreeId]?.some(
(group) => group.id === args.targetGroupId
) ?? false
if (!args.manager || !targetGroupExists || args.manager.getPanes().length <= 1) {
return null
}
const sourceLeafId = args.manager.getLeafId(args.sourcePaneId)
if (!sourceLeafId) {
return null
}
args.persistLayoutSnapshot()
const store = args.getStore()
const detached = detachTerminalLayoutLeaf(
store.terminalLayoutsByTabId[args.sourceTabId],
sourceLeafId
)
if (!detached) {
return null
}
const ptyId = detached.ptyId ?? args.fallbackPtyId ?? null
const detachedLayout = withDetachedPtyFallback({
leafId: sourceLeafId,
ptyId,
detachedLayout: detached.detachedLayout
})
// Why: remove the renderer pane only after the layout/PTY handoff has been
// computed; the close callback detaches listeners but must not kill the PTY.
if (!args.manager.detachPaneForExternalMove(args.sourcePaneId)) {
return null
}
const latestStore = args.getStore()
const tab = latestStore.createTab(args.worktreeId, args.targetGroupId, undefined, {
activate: true,
initialPtyId: ptyId ?? undefined,
recordInteraction: true
})
const afterCreateStore = args.getStore()
moveCreatedTabToIndex({
groupId: args.targetGroupId,
store: afterCreateStore,
tabId: tab.id,
targetIndex: args.targetIndex,
worktreeId: args.worktreeId
})
afterCreateStore.setTabLayout(args.sourceTabId, detached.sourceLayout)
afterCreateStore.setTabLayout(tab.id, detachedLayout)
afterCreateStore.syncPaneDetachPtyOwnership({
detachedPtyId: ptyId,
sourceLayout: detached.sourceLayout,
sourceTabId: args.sourceTabId,
targetTabId: tab.id
})
afterCreateStore.setActiveTab(tab.id)
afterCreateStore.setActiveTabType('terminal')
return { tab, leafId: sourceLeafId, ptyId }
}
@@ -2,7 +2,11 @@
import { useEffect, useRef } from 'react'
import type { IDisposable, Terminal } from '@xterm/xterm'
import type { ParsedAgentStatusPayload } from '../../../../shared/agent-status-types'
import { PaneManager } from '@/lib/pane-manager/pane-manager'
import {
PaneManager,
type PaneExternalDropHandler,
type PaneExternalDropResolver
} from '@/lib/pane-manager/pane-manager'
import { consumePendingWebRuntimeSplitMirrorTelemetry } from '@/runtime/web-runtime-session'
import {
normalizeTerminalFastScrollSensitivity,
@@ -273,6 +277,8 @@ type UseTerminalPaneLifecycleDeps = {
// Why: same pane count does not imply same geometry; drag-reorder can move
// panes without resizing them, so overlay rects need a layout-change tick.
setPaneLayoutRevision: React.Dispatch<React.SetStateAction<number>>
resolveExternalPaneDropTarget?: PaneExternalDropResolver
onExternalPaneDrop?: PaneExternalDropHandler
}
export function suppressIntentionalPaneCloseExit(
@@ -532,7 +538,9 @@ export function useTerminalPaneLifecycle({
paneTitlesRef,
setRenamingPaneId,
setPaneCount,
setPaneLayoutRevision
setPaneLayoutRevision,
resolveExternalPaneDropTarget,
onExternalPaneDrop
}: UseTerminalPaneLifecycleDeps): void {
const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows(
settings?.terminalScrollbackRows
@@ -1094,6 +1102,7 @@ export function useTerminalPaneLifecycle({
queueResizeAll(true)
},
onPaneClosed: (paneId, closedPane) => {
const isDetachedToTab = closedPane?.reason === 'detach'
const linkProviderDisposable = linkProviderDisposablesRef.current.get(paneId)
if (linkProviderDisposable) {
linkProviderDisposable.dispose()
@@ -1169,31 +1178,37 @@ export function useTerminalPaneLifecycle({
panePtyBinding.dispose()
panePtyBindings.delete(paneId)
}
// Why: closing a pane is user-initiated teardown of this row — drop
// (not remove) so any retained `done` snapshot for this pane is also
// cleared and a same-frame live→gone transition cannot re-snapshot
// it via the retention sync. This is pane-keyed state, so it must
// clear even if the PTY transport was already removed.
const leafId = closedPane?.leafId
if (leafId) {
if (leafId && !isDetachedToTab) {
// Why: closing a pane is user-initiated teardown of this row — drop
// (not remove) so any retained `done` snapshot for this pane is also
// cleared and a same-frame live→gone transition cannot re-snapshot
// it via the retention sync. This is pane-keyed state, so it must
// clear even if the PTY transport was already removed.
const paneKey = makePaneKey(tabId, leafId)
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
clearTerminalPaneUnread(paneKey)
useAppStore.getState().dropAgentStatus(paneKey)
}
if (transport) {
const ptyId = suppressIntentionalPaneCloseExit(
transport,
useAppStore.getState().suppressPtyExit
)
if (ptyId) {
// Why: user/CLI pane closes intentionally tear down this PTY after
// PaneManager has already promoted the sibling. Suppress that exit
// so the last-surviving pane is not mistaken for an exited tab.
syncPanePtyLayoutBinding(paneId, null)
clearTabPtyId(tabId, ptyId)
if (isDetachedToTab) {
// Why: pane-to-tab detach hands the PTY to a newly-created tab;
// detach renderer listeners without sending a process teardown.
transport.detach?.()
} else {
const ptyId = suppressIntentionalPaneCloseExit(
transport,
useAppStore.getState().suppressPtyExit
)
if (ptyId) {
// Why: user/CLI pane closes intentionally tear down this PTY after
// PaneManager has already promoted the sibling. Suppress that exit
// so the last-surviving pane is not mistaken for an exited tab.
syncPanePtyLayoutBinding(paneId, null)
clearTabPtyId(tabId, ptyId)
}
transport.destroy?.()
}
transport.destroy?.()
paneTransportsRef.current.delete(paneId)
}
clearRuntimePaneTitle(tabId, paneId)
@@ -1292,6 +1307,8 @@ export function useTerminalPaneLifecycle({
releaseWebviewDragPassthrough?.()
releaseWebviewDragPassthrough = null
},
resolveExternalPaneDropTarget,
onExternalPaneDrop,
terminalOptions: () => {
const currentSettings = settingsRef.current
const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight)
@@ -1,4 +1,4 @@
import type { DropZone, ManagedPaneInternal } from './pane-manager-types'
import type { DropZone, ManagedPaneInternal, PaneExternalDropTarget } from './pane-manager-types'
import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder'
import {
handlePaneDrop,
@@ -62,14 +62,18 @@ export function beginPaneDragFromPointerDown(
callbacks.getRoot().classList.remove('is-pane-dragging')
callbacks.getPanes().get(paneId)?.container.classList.remove('is-drag-source')
try {
if (commitDrop && state.currentDropTarget && state.dragSourcePaneId !== null) {
handlePaneDrop(
state.dragSourcePaneId,
state.currentDropTarget.paneId,
state.currentDropTarget.zone,
state,
callbacks
)
if (commitDrop && state.dragSourcePaneId !== null) {
if (state.currentDropTarget) {
handlePaneDrop(
state.dragSourcePaneId,
state.currentDropTarget.paneId,
state.currentDropTarget.zone,
state,
callbacks
)
} else if (state.currentExternalDropTarget) {
callbacks.onExternalPaneDrop?.(state.dragSourcePaneId, state.currentExternalDropTarget)
}
}
} finally {
// Why: pointer capture can be lost crossing Electron webviews; always
@@ -78,6 +82,7 @@ export function beginPaneDragFromPointerDown(
hideDropOverlay(state)
state.dragSourcePaneId = null
state.currentDropTarget = null
state.currentExternalDropTarget = null
}
}
@@ -166,8 +171,20 @@ function updateDropTarget(
}
const targetPane = findDropTargetPane(clientX, clientY, state, callbacks)
if (!targetPane) {
overlay.style.display = 'none'
const sourcePaneId = state.dragSourcePaneId
const externalTarget =
sourcePaneId === null
? null
: (callbacks.resolveExternalDropTarget?.({ sourcePaneId, clientX, clientY }) ?? null)
if (!externalTarget) {
overlay.style.display = 'none'
state.currentDropTarget = null
state.currentExternalDropTarget = null
return
}
state.currentDropTarget = null
state.currentExternalDropTarget = externalTarget
positionExternalDropOverlay(overlay, externalTarget)
return
}
@@ -180,9 +197,11 @@ function updateDropTarget(
) {
overlay.style.display = 'none'
state.currentDropTarget = null
state.currentExternalDropTarget = null
return
}
state.currentDropTarget = { paneId: targetPane.id, zone }
state.currentExternalDropTarget = null
positionDropOverlay(overlay, rect, zone)
}
@@ -223,6 +242,7 @@ function resolveDropZone(clientX: number, clientY: number, rect: DOMRect): DropZ
function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone): void {
overlay.style.display = ''
overlay.dataset.paneDropOverlayKind = 'area'
const scrollX = window.scrollX
const scrollY = window.scrollY
const halfWidth = rect.width / 2
@@ -233,3 +253,13 @@ function positionDropOverlay(overlay: HTMLElement, rect: DOMRect, zone: DropZone
overlay.style.width = `${zone === 'left' || zone === 'right' ? halfWidth : rect.width}px`
overlay.style.height = `${zone === 'top' || zone === 'bottom' ? halfHeight : rect.height}px`
}
function positionExternalDropOverlay(overlay: HTMLElement, target: PaneExternalDropTarget): void {
const rect = target.rect
overlay.style.display = ''
overlay.dataset.paneDropOverlayKind = target.overlayKind ?? 'area'
overlay.style.left = `${rect.left + window.scrollX}px`
overlay.style.top = `${rect.top + window.scrollY}px`
overlay.style.width = `${rect.width}px`
overlay.style.height = `${rect.height}px`
}
@@ -229,6 +229,7 @@ describe('attachPaneDrag', () => {
expect(appendedElements[0].removed).toBe(true)
expect(state.dragSourcePaneId).toBeNull()
expect(state.currentDropTarget).toBeNull()
expect(state.currentExternalDropTarget).toBeNull()
expect(state.cleanupActiveDrag).toBeNull()
expect(onDragActiveChange).toHaveBeenLastCalledWith(false)
expect(detachPaneFromTree).not.toHaveBeenCalled()
@@ -288,10 +289,83 @@ describe('attachPaneDrag', () => {
expect(appendedElements[0]?.removed).toBe(true)
expect(state.dragSourcePaneId).toBeNull()
expect(state.currentDropTarget).toBeNull()
expect(state.currentExternalDropTarget).toBeNull()
expect(state.cleanupActiveDrag).toBeNull()
expect(onDragActiveChange).toHaveBeenLastCalledWith(false)
})
it('drops onto an external target when no pane target is under the pointer', () => {
const handle = new FakeElement()
const root = new FakeElement(['pane-manager-root'])
const sourcePane = createPane(
1,
new FakeElement(['pane'], {
left: 0,
top: 80,
right: 100,
bottom: 180,
width: 100,
height: 100
})
)
const siblingPane = createPane(
2,
new FakeElement(['pane'], {
left: 120,
top: 80,
right: 220,
bottom: 180,
width: 100,
height: 100
})
)
const panes = new Map<number, ManagedPaneInternal>([
[sourcePane.id, sourcePane],
[siblingPane.id, siblingPane]
])
const externalTarget = {
id: 'group-1',
overlayKind: 'insertion' as const,
rect: { left: 0, top: 0, right: 300, bottom: 32, width: 300, height: 32 } as DOMRect
}
const onExternalPaneDrop = vi.fn(() => true)
const state = createDragReorderState()
attachPaneDrag(handle as unknown as HTMLElement, sourcePane.id, state, {
getPanes: () => panes,
getRoot: () => root as unknown as HTMLElement,
getStyleOptions: () => ({}),
isDestroyed: () => false,
safeFit: vi.fn(),
applyPaneOpacity: vi.fn(),
applyDividerStyles: vi.fn(),
refitPanesUnder: vi.fn(),
resolveExternalDropTarget: ({ clientX, clientY }) =>
clientX === 10 && clientY === 10 ? externalTarget : null,
onExternalPaneDrop
})
handle.dispatchPointer('pointerdown', pointerEvent({ clientX: 10, clientY: 90 }))
handle.dispatchPointer('pointermove', pointerEvent({ clientX: 10, clientY: 10 }))
expect(state.currentDropTarget).toBeNull()
expect(state.currentExternalDropTarget).toBe(externalTarget)
expect(appendedElements[0].style).toMatchObject({
left: '0px',
top: '0px',
width: '300px',
height: '32px'
})
expect(appendedElements[0].dataset.paneDropOverlayKind).toBe('insertion')
handle.dispatchPointer('pointerup', pointerEvent({ pointerId: 1 }))
expect(onExternalPaneDrop).toHaveBeenCalledWith(sourcePane.id, externalTarget)
expect(detachPaneFromTree).not.toHaveBeenCalled()
expect(insertPaneNextTo).not.toHaveBeenCalled()
expect(state.currentExternalDropTarget).toBeNull()
})
it('returns cleanup that removes handle listeners and cancels active drag capture', () => {
const handle = new FakeElement()
const root = new FakeElement(['pane-manager-root'])
@@ -1,5 +1,12 @@
import type { DropZone, ManagedPane, ManagedPaneInternal } from './pane-manager-types'
import type { PaneStyleOptions } from './pane-manager-types'
import type {
DropZone,
ManagedPane,
ManagedPaneInternal,
PaneExternalDropHandler,
PaneExternalDropResolver,
PaneExternalDropTarget,
PaneStyleOptions
} from './pane-manager-types'
import { detachPaneFromTree, findPaneChildren, insertPaneNextTo } from './pane-tree-ops'
// ---------------------------------------------------------------------------
@@ -10,6 +17,7 @@ export type DragReorderState = {
dragSourcePaneId: number | null
dropOverlay: HTMLElement | null
currentDropTarget: { paneId: number; zone: DropZone } | null
currentExternalDropTarget: PaneExternalDropTarget | null
cleanupActiveDrag: ((commitDrop: boolean) => void) | null
}
@@ -25,6 +33,8 @@ export type DragReorderCallbacks = {
requestPaneReparentFrame?: (callback: FrameRequestCallback) => void
onLayoutChanged?: () => void
onDragActiveChange?: (active: boolean) => void
resolveExternalDropTarget?: PaneExternalDropResolver
onExternalPaneDrop?: PaneExternalDropHandler
}
export function createDragReorderState(): DragReorderState {
@@ -32,6 +42,7 @@ export function createDragReorderState(): DragReorderState {
dragSourcePaneId: null,
dropOverlay: null,
currentDropTarget: null,
currentExternalDropTarget: null,
cleanupActiveDrag: null
}
}
@@ -44,6 +55,7 @@ export function cancelActivePaneDrag(state: DragReorderState): void {
hideDropOverlay(state)
state.dragSourcePaneId = null
state.currentDropTarget = null
state.currentExternalDropTarget = null
}
/** True when dropping source onto target in zone would leave pane order unchanged. */
@@ -27,8 +27,26 @@ export type PaneSpawnHints = {
export type ClosedPaneInfo = {
paneId: number
leafId: TerminalLeafId
reason?: 'close' | 'detach'
}
export type PaneExternalDropTarget = {
id: string
rect: DOMRect
overlayKind?: 'area' | 'insertion'
}
export type PaneExternalDropResolver = (args: {
sourcePaneId: number
clientX: number
clientY: number
}) => PaneExternalDropTarget | null
export type PaneExternalDropHandler = (
sourcePaneId: number,
target: PaneExternalDropTarget
) => boolean
export type PaneManagerOptions = {
onPaneCreated?: (pane: ManagedPane, spawnHints?: PaneSpawnHints) => void | Promise<void>
onPaneClosed?: (paneId: number, closedPane?: ClosedPaneInfo) => void
@@ -37,6 +55,8 @@ export type PaneManagerOptions = {
/** Why: Electron webviews can steal pointer streams from renderer-owned
* pane drags unless callers temporarily put them in pointer passthrough. */
onPaneDragActiveChange?: (active: boolean) => void
resolveExternalPaneDropTarget?: PaneExternalDropResolver
onExternalPaneDrop?: PaneExternalDropHandler
terminalOptions?: (paneId: number) => Partial<ITerminalOptions>
terminalTuiScrollSensitivity?: () => number | undefined
onLinkClick?: (event: MouseEvent | undefined, url: string) => void
@@ -5,7 +5,10 @@ import type {
ManagedPane,
ManagedPaneInternal,
PaneRenderingDiagnostics,
DropZone
DropZone,
PaneExternalDropHandler,
PaneExternalDropResolver,
PaneExternalDropTarget
} from './pane-manager-types'
import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split'
import {
@@ -40,11 +43,23 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry'
import { schedulePaneRevealRepaint } from './pane-reveal-repaint'
import { PaneIdentityRegistry } from './pane-identity-registry'
import { closeManagedPane, splitManagedPane } from './pane-split-close'
import {
closeManagedPane,
detachManagedPaneForExternalMove,
splitManagedPane
} from './pane-split-close'
import { FIRST_PANE_ID } from '../../../../shared/pane-key'
import { splitPaneAroundMountedSubtree } from './pane-subtree-split'
export type { PaneManagerOptions, PaneStyleOptions, ManagedPane, DropZone }
export type {
PaneManagerOptions,
PaneStyleOptions,
ManagedPane,
DropZone,
PaneExternalDropTarget,
PaneExternalDropResolver,
PaneExternalDropHandler
}
export class PaneManager {
private root: HTMLElement
@@ -158,6 +173,22 @@ export class PaneManager {
})
}
detachPaneForExternalMove(paneId: number): boolean {
return detachManagedPaneForExternalMove({
paneId,
activePaneId: this.activePaneId,
panes: this.panes,
root: this.root,
styleOptions: this.styleOptions,
managerOptions: this.options,
getDragCallbacks: () => this.getDragCallbacks(),
releasePaneIdentity: (numericPaneId) => this.identities.release(numericPaneId),
setActivePaneId: (id) => {
this.activePaneId = id
}
})
}
getPanes(): ManagedPane[] {
return Array.from(this.panes.values()).map(toPublicPane)
}
@@ -404,7 +435,9 @@ export class PaneManager {
this.requestPaneReparentFrame(callback)
},
onLayoutChanged: this.options.onLayoutChanged,
onDragActiveChange: this.options.onPaneDragActiveChange
onDragActiveChange: this.options.onPaneDragActiveChange,
resolveExternalDropTarget: this.options.resolveExternalPaneDropTarget,
onExternalPaneDrop: this.options.onExternalPaneDrop
}
}
@@ -180,10 +180,39 @@ export function closeManagedPane(args: CloseManagedPaneArgs): void {
safeFit(p)
}
updateMultiPaneState(args.getDragCallbacks())
args.managerOptions.onPaneClosed?.(args.paneId, { paneId: args.paneId, leafId: closedLeafId })
args.managerOptions.onPaneClosed?.(args.paneId, {
paneId: args.paneId,
leafId: closedLeafId,
reason: 'close'
})
args.managerOptions.onLayoutChanged?.()
}
export function detachManagedPaneForExternalMove(args: CloseManagedPaneArgs): boolean {
const pane = args.panes.get(args.paneId)
if (!pane || args.panes.size <= 1) {
return false
}
const closedLeafId = pane.leafId
args.releasePaneIdentity(args.paneId)
removePaneContainer(args, pane)
const nextActivePaneId = activateReplacementPane(args)
applyPaneOpacity(args.panes.values(), nextActivePaneId, args.styleOptions)
for (const p of args.panes.values()) {
safeFit(p)
}
updateMultiPaneState(args.getDragCallbacks())
// Why: pane-to-tab detach tears down only this renderer pane; the PTY is
// adopted by the new tab, so TerminalPane must skip process-close cleanup.
args.managerOptions.onPaneClosed?.(args.paneId, {
paneId: args.paneId,
leafId: closedLeafId,
reason: 'detach'
})
args.managerOptions.onLayoutChanged?.()
return true
}
function removePaneContainer(args: CloseManagedPaneArgs, pane: ManagedPaneInternal): void {
const paneContainer = pane.container
const parent = paneContainer.parentElement
@@ -1117,6 +1117,57 @@ describe('setActiveWorktree', () => {
expect(groups[0].tabOrder).toEqual([terminal.id])
})
it('moves live PTY ownership when detaching a primary pane to a tab', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
const sourceTabId = 'tab-source'
const targetTabId = 'tab-target'
seedStore(store, {
worktreesByRepo: {
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
},
tabsByWorktree: {
[wt]: [
makeTab({ id: sourceTabId, worktreeId: wt, ptyId: 'pty-detached' }),
makeTab({ id: targetTabId, worktreeId: wt, ptyId: null })
]
},
ptyIdsByTabId: {
[sourceTabId]: ['pty-detached', 'pty-survivor'],
[targetTabId]: ['pty-detached']
},
lastKnownRelayPtyIdByTabId: {
[sourceTabId]: 'pty-detached',
[targetTabId]: 'pty-detached'
}
})
store.getState().syncPaneDetachPtyOwnership({
detachedPtyId: 'pty-detached',
sourceLayout: {
root: { type: 'leaf', leafId: 'survivor-leaf' },
activeLeafId: 'survivor-leaf',
expandedLeafId: null,
ptyIdsByLeafId: { 'survivor-leaf': 'pty-survivor' }
},
sourceTabId,
targetTabId
})
const state = store.getState()
expect(state.ptyIdsByTabId[sourceTabId]).toEqual(['pty-survivor'])
expect(state.ptyIdsByTabId[targetTabId]).toEqual(['pty-detached'])
expect(state.lastKnownRelayPtyIdByTabId[sourceTabId]).toBe('pty-survivor')
expect(state.lastKnownRelayPtyIdByTabId[targetTabId]).toBe('pty-detached')
expect(state.tabsByWorktree[wt].find((tab) => tab.id === sourceTabId)?.ptyId).toBe(
'pty-survivor'
)
expect(state.tabsByWorktree[wt].find((tab) => tab.id === targetTabId)?.ptyId).toBe(
'pty-detached'
)
})
it('stores trimmed quick command labels on terminal and unified tabs', () => {
const store = createTestStore()
const wt = 'repo1::/path/wt1'
@@ -293,6 +293,36 @@ function equalStringSets(a: readonly string[], b: readonly string[]): boolean {
return a.every((value) => bSet.has(value))
}
function uniquePtyIds(ptyIds: readonly (string | null | undefined)[]): string[] {
return [...new Set(ptyIds.filter((ptyId): ptyId is string => Boolean(ptyId)))]
}
function resolvePrimaryLayoutPtyId(layout: TerminalLayoutSnapshot): string | null {
const ptyIdsByLeafId = layout.ptyIdsByLeafId ?? {}
const activePtyId = layout.activeLeafId ? ptyIdsByLeafId[layout.activeLeafId] : undefined
return activePtyId ?? Object.values(ptyIdsByLeafId)[0] ?? null
}
function withTerminalTabPtyId(
tabsByWorktree: Record<string, TerminalTab[]>,
tabId: string,
ptyId: string | null
): Record<string, TerminalTab[]> {
for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) {
const index = tabs.findIndex((tab) => tab.id === tabId)
if (index === -1) {
continue
}
if (tabs[index]?.ptyId === ptyId) {
return tabsByWorktree
}
const nextTabs = [...tabs]
nextTabs[index] = { ...nextTabs[index]!, ptyId }
return { ...tabsByWorktree, [worktreeId]: nextTabs }
}
return tabsByWorktree
}
export type AutomaticAgentResumeClaim = {
worktreeId: string
launchAgent: TuiAgent
@@ -506,6 +536,12 @@ export type TerminalSlice = {
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
setTabCanExpandPane: (tabId: string, canExpand: boolean) => void
setTabLayout: (tabId: string, layout: TerminalLayoutSnapshot | null) => void
syncPaneDetachPtyOwnership: (args: {
detachedPtyId: string | null
sourceLayout: TerminalLayoutSnapshot
sourceTabId: string
targetTabId: string
}) => void
queueTabStartupCommand: (
tabId: string,
startup: {
@@ -2455,6 +2491,54 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
})
},
syncPaneDetachPtyOwnership: ({ detachedPtyId, sourceLayout, sourceTabId, targetTabId }) => {
set((s) => {
const layoutSourcePtyIds = uniquePtyIds(Object.values(sourceLayout.ptyIdsByLeafId ?? {}))
const existingSourcePtyIds = (s.ptyIdsByTabId[sourceTabId] ?? []).filter(
(ptyId) => ptyId !== detachedPtyId
)
const sourcePtyIds = layoutSourcePtyIds.length > 0 ? layoutSourcePtyIds : existingSourcePtyIds
const sourcePrimaryPtyId = resolvePrimaryLayoutPtyId(sourceLayout) ?? sourcePtyIds[0] ?? null
const nextPtyIdsByTabId = {
...s.ptyIdsByTabId,
[sourceTabId]: sourcePtyIds
}
if (detachedPtyId) {
nextPtyIdsByTabId[targetTabId] = uniquePtyIds([
...(nextPtyIdsByTabId[targetTabId] ?? []),
detachedPtyId
])
}
const nextLastKnownRelayPtyIdByTabId = { ...s.lastKnownRelayPtyIdByTabId }
if (sourcePrimaryPtyId) {
nextLastKnownRelayPtyIdByTabId[sourceTabId] = sourcePrimaryPtyId
} else {
delete nextLastKnownRelayPtyIdByTabId[sourceTabId]
}
if (detachedPtyId) {
nextLastKnownRelayPtyIdByTabId[targetTabId] = detachedPtyId
}
// Why: pane-to-tab detach moves live PTY ownership without spawning or
// exiting processes, so sync identity maps directly without activity bumps.
const sourceTabsByWorktree = withTerminalTabPtyId(
s.tabsByWorktree,
sourceTabId,
sourcePrimaryPtyId
)
const nextTabsByWorktree = detachedPtyId
? withTerminalTabPtyId(sourceTabsByWorktree, targetTabId, detachedPtyId)
: sourceTabsByWorktree
return {
ptyIdsByTabId: nextPtyIdsByTabId,
lastKnownRelayPtyIdByTabId: nextLastKnownRelayPtyIdByTabId,
...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {})
}
})
},
queueTabStartupCommand: (tabId, startup) => {
// Why: launchToken is only meaningful for tracked launch-config reuse;
// plain startup commands must not mint or carry a synthetic token.