refactor(panes,tabs): split pane manager and tab-group modules under the max-lines budget (#14760)

The two tab-group hooks, the pane manager, worktree activation, and the terminal
pane context menu each carried a file-level `eslint-disable max-lines` and ran
461-745 counted lines against a 300-line budget. AGENTS.md calls for splitting
rather than suppressing, and config/max-lines-baseline.txt is a shrink-only
ratchet, so this removes all five suppressions and prunes their entries
(341 -> 335).

Pure move, no behavior change. useTabDragSplit is cut into gesture lifecycle,
hover preview and drop commit; useTabGroupWorkspaceModel into item projections
plus the tab-close, close-scope, activation and creation command sets; the pane
manager into host, tree mutations, pane creation, drag wiring, reparent frame
tracking, layout sweeps and rendering diagnostics.

react-hooks exhaustive-deps stays at zero warnings, matching HEAD. Dependency
additions are only stable identifiers -- refs and callbacks that became
parameters -- and no `.current` dereference was added to any dependency array.

Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green
(the three remaining failures are pre-existing load flakes in untouched files,
each green when re-run serially), no new runtime import cycles among 1020
modules, no barrel files, and no lint suppression added anywhere.
This commit is contained in:
Neil
2026-08-15 19:10:36 -07:00
committed by GitHub
parent cc19692f93
commit 6854cceb90
53 changed files with 2957 additions and 2260 deletions
-5
View File
@@ -140,25 +140,20 @@ inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.t
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
inline src/renderer/src/components/terminal-pane/keyboard-handlers.ts
inline src/renderer/src/components/terminal-pane/pty-connection.ts
inline src/renderer/src/components/terminal-pane/pty-transport.ts
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
inline src/renderer/src/hooks/useComposerState.ts
inline src/renderer/src/hooks/useEditorExternalWatch.ts
inline src/renderer/src/hooks/useIpcEvents.ts
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
inline src/renderer/src/lib/pane-manager/pane-manager.ts
inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/lib/worktree-activation.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
inline src/renderer/src/runtime/runtime-file-client.ts
inline src/renderer/src/runtime/runtime-git-client.ts
@@ -28,7 +28,7 @@ vi.mock('@/lib/new-workspace', async (importOriginal) => {
import { useAppStore } from '@/store'
import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode'
import { resolveStartupLaunchDraftText } from '@/lib/worktree-activation'
import { resolveStartupLaunchDraftText } from '@/lib/worktree-startup-payload'
import {
getFolderWorkspaceAgentLaunchPlatform,
submitFolderWorkspaceCreate
@@ -0,0 +1,179 @@
import type { RefObject } from 'react'
import type { DragEndEvent } from '@dnd-kit/core'
import { useAppStore } from '../../store'
import { mirrorWebRuntimeTabMove } from '../tab-bar/web-runtime-tab-move-mirror'
import { resolveTabInsertion } from './tab-insertion'
import { resolveSourceGroupRestoreOnDrop } from './tab-drag-preview-target'
import { getDragPointer } from './tab-drag-pointer'
import {
resolveActivePaneColumnSplitTarget,
type TabGroupPanelGeometrySnapshot
} from './tab-group-panel-split-target'
import { isPaneDropData, isTabDragData, type TabDragItemData } from './tab-drag-data'
type AppState = ReturnType<typeof useAppStore.getState>
/** Turns a finished tab drag into store mutations: pane-column split,
* same-group reorder, cross-group move, or pane-body drop — each mirrored to
* the web runtime — then hands the snapshot-restore decision to finishDrag. */
export function commitTabDragDrop({
event,
worktreeId,
dragGeometryRef,
dropUnifiedTab,
reorderUnifiedTabs,
finishDrag
}: {
event: DragEndEvent
worktreeId: string
dragGeometryRef: RefObject<TabGroupPanelGeometrySnapshot | null>
dropUnifiedTab: AppState['dropUnifiedTab']
reorderUnifiedTabs: AppState['reorderUnifiedTabs']
finishDrag: (restoreSnapshot: boolean, activeData?: TabDragItemData) => void
}): void {
const activeData = event.active.data.current
const overData = event.over?.data.current
let shouldRestorePreDragActivation = true
if (!isTabDragData(activeData) || activeData.worktreeId !== worktreeId) {
finishDrag(true)
return
}
const state = useAppStore.getState()
const paneColumnSplit = resolveActivePaneColumnSplitTarget({
event,
groupsByWorktree: state.groupsByWorktree,
layoutByWorktree: state.layoutByWorktree,
worktreeId,
getDragPointer,
geometry: dragGeometryRef.current
})
if (paneColumnSplit) {
const moved = dropUnifiedTab(activeData.unifiedTabId, {
groupId: paneColumnSplit.groupId,
splitDirection: paneColumnSplit.zone
})
if (moved) {
shouldRestorePreDragActivation = false
mirrorWebRuntimeTabMove({
kind: 'split',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: paneColumnSplit.groupId,
splitDirection: paneColumnSplit.zone
})
}
finishDrag(
shouldRestorePreDragActivation,
resolveSourceGroupRestoreOnDrop(
activeData,
paneColumnSplit.groupId,
shouldRestorePreDragActivation
)
)
return
}
if (!event.over) {
finishDrag(true)
return
}
if (isTabDragData(overData)) {
if (activeData.unifiedTabId === overData.unifiedTabId) {
finishDrag(true)
return
}
const groups = state.groupsByWorktree[worktreeId] ?? []
const targetGroup = groups.find((group) => group.id === overData.groupId)
if (!targetGroup) {
finishDrag(true)
return
}
// Why: dnd-kit's `over` is the hovered tab, but the drop's true
// insertion point depends on which side of that tab the cursor sits.
// Using the bar's computed side (re-derived here to avoid stale
// closures) means the drop always lands where the blue bar was drawn.
const insertion = resolveTabInsertion(event, isTabDragData, getDragPointer)
if (!insertion) {
finishDrag(true)
return
}
const overIndex = targetGroup.tabOrder.indexOf(overData.unifiedTabId)
const rawInsertIndex = overIndex + (insertion.side === 'right' ? 1 : 0)
if (activeData.groupId === overData.groupId) {
const oldIndex = targetGroup.tabOrder.indexOf(activeData.unifiedTabId)
// Why: splicing out the dragged tab before inserting would shift the
// intended target slot left by one when moving forward. Adjust the
// insertion index to match the post-removal order.
const nextIndex = oldIndex < rawInsertIndex ? rawInsertIndex - 1 : rawInsertIndex
if (oldIndex !== -1 && oldIndex !== nextIndex) {
const nextOrder = targetGroup.tabOrder.filter((id) => id !== activeData.unifiedTabId)
nextOrder.splice(nextIndex, 0, activeData.unifiedTabId)
reorderUnifiedTabs(overData.groupId, nextOrder)
mirrorWebRuntimeTabMove({
kind: 'reorder',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId,
tabOrder: nextOrder
})
}
} else {
const index = overIndex === -1 ? targetGroup.tabOrder.length : rawInsertIndex
const moved = dropUnifiedTab(activeData.unifiedTabId, {
groupId: overData.groupId,
index
})
if (moved) {
shouldRestorePreDragActivation = false
mirrorWebRuntimeTabMove({
kind: 'move-to-group',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId,
index
})
}
}
finishDrag(
shouldRestorePreDragActivation,
resolveSourceGroupRestoreOnDrop(activeData, overData.groupId, shouldRestorePreDragActivation)
)
return
}
if (isPaneDropData(overData)) {
if (activeData.groupId !== overData.groupId) {
const moved = dropUnifiedTab(activeData.unifiedTabId, {
groupId: overData.groupId
})
if (moved) {
shouldRestorePreDragActivation = false
mirrorWebRuntimeTabMove({
kind: 'move-to-group',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId
})
}
}
}
finishDrag(
shouldRestorePreDragActivation,
isPaneDropData(overData)
? resolveSourceGroupRestoreOnDrop(
activeData,
overData.groupId,
shouldRestorePreDragActivation
)
: undefined
)
}
@@ -0,0 +1,71 @@
import { useCallback, useRef, type RefObject } from 'react'
import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry'
import { installTabDragMissedEndListeners } from './tab-drag-missed-end-listeners'
/** Global side effects a tab drag holds while in flight: webview pointer
* passthrough plus the window-level fallback for a missed drag end. Both refs
* are injected so this stays independent of the drag state machine. */
export function useTabDragGestureLifecycle({
clearDragStateRef,
tabDragActiveRef
}: {
clearDragStateRef: RefObject<() => void>
tabDragActiveRef: RefObject<boolean>
}): {
acquireWebviewDragPassthrough: () => void
installMissedEndFallback: () => void
releaseMissedEndFallback: () => void
releaseWebviewDragPassthrough: () => void
setDragRootNode: (node: HTMLDivElement | null) => void
} {
const releaseWebviewDragPassthroughRef = useRef<(() => void) | null>(null)
const releaseMissedEndFallbackRef = useRef<(() => void) | null>(null)
const releaseWebviewDragPassthrough = useCallback(() => {
releaseWebviewDragPassthroughRef.current?.()
releaseWebviewDragPassthroughRef.current = null
}, [])
const releaseMissedEndFallback = useCallback(() => {
releaseMissedEndFallbackRef.current?.()
releaseMissedEndFallbackRef.current = null
}, [])
const installMissedEndFallback = useCallback(() => {
releaseMissedEndFallback()
releaseMissedEndFallbackRef.current = installTabDragMissedEndListeners(() => {
if (tabDragActiveRef.current) {
clearDragStateRef.current()
}
})
}, [clearDragStateRef, releaseMissedEndFallback, tabDragActiveRef])
const acquireWebviewDragPassthrough = useCallback(() => {
// Why: dnd-kit tab drags are pointer-driven, so the native drag listeners
// in webview-registry never fire. Put webviews in passthrough explicitly.
releaseWebviewDragPassthrough()
releaseWebviewDragPassthroughRef.current = acquireWebviewsDragPassthrough()
}, [releaseWebviewDragPassthrough])
const setDragRootNode = useCallback(
(node: HTMLDivElement | null): void => {
if (node) {
return
}
// Why: this root owns the dnd-kit gesture that temporarily puts browser
// webviews in pointer passthrough and installs global fallback listeners,
// so root teardown must release both.
releaseWebviewDragPassthrough()
releaseMissedEndFallback()
},
[releaseMissedEndFallback, releaseWebviewDragPassthrough]
)
return {
acquireWebviewDragPassthrough,
installMissedEndFallback,
releaseMissedEndFallback,
releaseWebviewDragPassthrough,
setDragRootNode
}
}
@@ -0,0 +1,138 @@
import { useCallback, useRef, useState, type RefObject } from 'react'
import type { DragMoveEvent, DragOverEvent } from '@dnd-kit/core'
import { useAppStore } from '../../store'
import type { useHoveredTabInsertion } from './tab-insertion'
import { applyDragPreviewTab, type TabDragActivationSnapshot } from './tab-drag-preview-activation'
import { resolveDragPreviewTabId } from './tab-drag-preview-target'
import { getDragPointer } from './tab-drag-pointer'
import {
resolveActivePaneColumnSplitTarget,
type ActivePaneColumnSplitTarget,
type TabGroupPanelGeometrySnapshot
} from './tab-group-panel-split-target'
import { isTabDragData, type TabDragItemData, type TabDropZone } from './tab-drag-data'
export type HoveredTabDropTarget = {
groupId: string
zone: TabDropZone
panelRect?: DOMRect
}
/** Derives the in-flight hover state of a tab drag: the live preview tab to
* activate (deduped through private memo refs) and the hovered pane-column
* split target, delegating tab-strip insertion to the injected tabInsertion. */
export function useTabDragHoverPreview({
worktreeId,
preDragActivationSnapshotRef,
dragGeometryRef,
tabInsertion
}: {
worktreeId: string
preDragActivationSnapshotRef: RefObject<TabDragActivationSnapshot | null>
dragGeometryRef: RefObject<TabGroupPanelGeometrySnapshot | null>
tabInsertion: ReturnType<typeof useHoveredTabInsertion>
}): {
clear: () => void
handleDragUpdate: (event: DragMoveEvent | DragOverEvent) => void
hoveredDropTarget: HoveredTabDropTarget | null
} {
const [hoveredDropTarget, setHoveredDropTarget] = useState<HoveredTabDropTarget | null>(null)
const lastPreviewRef = useRef<{ groupId: string; tabId: string | null } | null>(null)
const lastHoveredTabPreviewRef = useRef<{ groupId: string; tabId: string } | null>(null)
const updateDragPreviewActivation = useCallback(
(event: DragMoveEvent | DragOverEvent, activeData: TabDragItemData) => {
const snapshot = preDragActivationSnapshotRef.current
if (!snapshot) {
return
}
const overData = event.over?.data.current
if (isTabDragData(overData) && overData.unifiedTabId !== activeData.unifiedTabId) {
lastHoveredTabPreviewRef.current = {
groupId: overData.groupId,
tabId: overData.unifiedTabId
}
}
const preview = resolveDragPreviewTabId({
activeDrag: activeData,
overData,
preDragActiveTabIdByGroup: snapshot.activeTabIdByGroup,
lastHoveredTabPreview: lastHoveredTabPreviewRef.current
})
const lastPreview = lastPreviewRef.current
if (lastPreview?.groupId === preview.groupId && lastPreview.tabId === preview.tabId) {
return
}
lastPreviewRef.current = preview
applyDragPreviewTab({
worktreeId,
groupId: preview.groupId,
tabId: preview.tabId,
activeGroupId: preview.groupId
})
},
[preDragActivationSnapshotRef, worktreeId]
)
const updateHoveredDropTargetFromSplit = useCallback(
(splitTarget: ActivePaneColumnSplitTarget | null) => {
if (!splitTarget) {
setHoveredDropTarget((prev) => (prev === null ? prev : null))
return
}
setHoveredDropTarget((prev) => {
if (prev?.groupId === splitTarget.groupId && prev?.zone === splitTarget.zone) {
return prev
}
return {
groupId: splitTarget.groupId,
zone: splitTarget.zone,
panelRect: splitTarget.panelRect
}
})
},
[]
)
const handleDragUpdate = useCallback(
(event: DragMoveEvent | DragOverEvent) => {
const activeData = event.active.data.current
if (isTabDragData(activeData) && activeData.worktreeId === worktreeId) {
updateDragPreviewActivation(event, activeData)
}
const state = useAppStore.getState()
const splitTarget = resolveActivePaneColumnSplitTarget({
event,
groupsByWorktree: state.groupsByWorktree,
layoutByWorktree: state.layoutByWorktree,
worktreeId,
getDragPointer,
geometry: dragGeometryRef.current
})
updateHoveredDropTargetFromSplit(splitTarget)
if (splitTarget) {
tabInsertion.clear()
} else {
tabInsertion.update(event)
}
},
[
dragGeometryRef,
tabInsertion,
updateDragPreviewActivation,
updateHoveredDropTargetFromSplit,
worktreeId
]
)
const clear = useCallback(() => {
setHoveredDropTarget(null)
lastPreviewRef.current = null
lastHoveredTabPreviewRef.current = null
}, [])
return { clear, handleDragUpdate, hoveredDropTarget }
}
@@ -0,0 +1,31 @@
/** Window-level safety net for a tab drag whose end/cancel event never arrives.
* Electron/dnd-kit can occasionally miss drag end; a stuck drag ref makes all
* later tab clicks look like drag releases. Returns the release function. */
export function installTabDragMissedEndListeners(onMissedEnd: () => void): () => void {
let cleanupTimer: number | null = null
const clearIfDndMissedEnd = (): void => {
if (cleanupTimer !== null) {
window.clearTimeout(cleanupTimer)
}
cleanupTimer = window.setTimeout(() => {
cleanupTimer = null
onMissedEnd()
}, 0)
}
window.addEventListener('pointerup', clearIfDndMissedEnd)
window.addEventListener('pointercancel', clearIfDndMissedEnd)
window.addEventListener('blur', clearIfDndMissedEnd)
window.addEventListener('focus', clearIfDndMissedEnd)
return () => {
if (cleanupTimer !== null) {
window.clearTimeout(cleanupTimer)
}
window.removeEventListener('pointerup', clearIfDndMissedEnd)
window.removeEventListener('pointercancel', clearIfDndMissedEnd)
window.removeEventListener('blur', clearIfDndMissedEnd)
window.removeEventListener('focus', clearIfDndMissedEnd)
}
}
@@ -1,6 +1,3 @@
/* oxlint-disable max-lines -- Why: the drag-split hook co-locates drop-zone
* resolution, same-group reordering, and cross-group handoff so state
* transitions stay readable in one place. */
import { useCallback, useRef, useState, type RefObject } from 'react'
import {
closestCenter,
@@ -16,38 +13,26 @@ import {
} from '@dnd-kit/core'
import type { TabGroup } from '../../../../shared/tab-types'
import { useAppStore } from '../../store'
import { mirrorWebRuntimeTabMove } from '../tab-bar/web-runtime-tab-move-mirror'
import { useHoveredTabInsertion, type HoveredTabInsertion } from './tab-insertion'
import {
resolveTabInsertion,
useHoveredTabInsertion,
type HoveredTabInsertion
} from './tab-insertion'
import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry'
import {
applyDragPreviewTab,
captureTabDragActivationSnapshot,
restoreSourceGroupActiveTabAfterCrossGroupDrop,
restoreTabDragActivationSnapshot,
type TabDragActivationSnapshot
} from './tab-drag-preview-activation'
import { resolveDragPreviewTabId, resolveSourceGroupRestoreOnDrop } from './tab-drag-preview-target'
import { getDragPointer } from './tab-drag-pointer'
import { TabDragPointerSensor } from './tab-drag-pointer-sensor'
import {
captureTabGroupPanelGeometrySnapshot,
resolveActivePaneColumnSplitTarget,
type ActivePaneColumnSplitTarget,
type TabGroupPanelGeometrySnapshot
} from './tab-group-panel-split-target'
import {
canDropTabIntoPaneBody,
isPaneDropData,
isTabDragData,
type TabDragItemData,
type TabDropZone
} from './tab-drag-data'
import { canDropTabIntoPaneBody, isTabDragData, type TabDragItemData } from './tab-drag-data'
import { useTabDragGestureLifecycle } from './tab-drag-gesture-lifecycle'
import { useTabDragHoverPreview, type HoveredTabDropTarget } from './tab-drag-hover-preview'
import { commitTabDragDrop } from './tab-drag-drop-commit'
export type { HoveredTabInsertion }
export type { HoveredTabDropTarget }
export {
canDropTabIntoPaneBody,
isPaneDropData,
@@ -61,12 +46,6 @@ export {
// tolerance to avoid treating ordinary click jitter as an intentional drag.
export const TAB_DRAG_ACTIVATION_DISTANCE_PX = 12
export type HoveredTabDropTarget = {
groupId: string
zone: TabDropZone
panelRect?: DOMRect
}
export function canDropTabForPaneColumnSplit(args: {
activeDrag: TabDragItemData | null
groupsByWorktree: Record<string, TabGroup[]>
@@ -123,15 +102,28 @@ export function useTabDragSplit({
const reorderUnifiedTabs = useAppStore((state) => state.reorderUnifiedTabs)
const dropUnifiedTab = useAppStore((state) => state.dropUnifiedTab)
const [activeDrag, setActiveDrag] = useState<TabDragItemData | null>(null)
const [hoveredDropTarget, setHoveredDropTarget] = useState<HoveredTabDropTarget | null>(null)
const releaseWebviewDragPassthroughRef = useRef<(() => void) | null>(null)
const preDragActivationSnapshotRef = useRef<TabDragActivationSnapshot | null>(null)
const lastPreviewRef = useRef<{ groupId: string; tabId: string | null } | null>(null)
const lastHoveredTabPreviewRef = useRef<{ groupId: string; tabId: string } | null>(null)
const tabDragActiveRef = useRef(false)
const dragGeometryRef = useRef<TabGroupPanelGeometrySnapshot | null>(null)
const releaseMissedEndFallbackRef = useRef<(() => void) | null>(null)
const clearDragStateRef = useRef<() => void>(() => {})
const tabInsertion = useHoveredTabInsertion(isTabDragData, getDragPointer)
const {
acquireWebviewDragPassthrough,
installMissedEndFallback,
releaseMissedEndFallback,
releaseWebviewDragPassthrough,
setDragRootNode
} = useTabDragGestureLifecycle({ clearDragStateRef, tabDragActiveRef })
const {
clear: clearHoveredDropTarget,
handleDragUpdate,
hoveredDropTarget
} = useTabDragHoverPreview({
worktreeId,
preDragActivationSnapshotRef,
dragGeometryRef,
tabInsertion
})
// Why: hidden worktrees stay mounted so their PTYs survive worktree
// switches, but their DndContext should not activate drags. We use an
@@ -144,84 +136,21 @@ export function useTabDragSplit({
})
const sensors = useSensors(pointerSensor)
const releaseWebviewDragPassthrough = useCallback(() => {
releaseWebviewDragPassthroughRef.current?.()
releaseWebviewDragPassthroughRef.current = null
}, [])
const releaseMissedEndFallback = useCallback(() => {
releaseMissedEndFallbackRef.current?.()
releaseMissedEndFallbackRef.current = null
}, [])
const clearDragStateRef = useRef<() => void>(() => {})
const installMissedEndFallback = useCallback(() => {
releaseMissedEndFallback()
let cleanupTimer: number | null = null
const clearIfDndMissedEnd = (): void => {
if (cleanupTimer !== null) {
window.clearTimeout(cleanupTimer)
}
cleanupTimer = window.setTimeout(() => {
cleanupTimer = null
if (tabDragActiveRef.current) {
// Why: Electron/dnd-kit can occasionally miss drag end/cancel; a
// stuck drag ref makes all later tab clicks look like drag releases.
clearDragStateRef.current()
}
}, 0)
}
window.addEventListener('pointerup', clearIfDndMissedEnd)
window.addEventListener('pointercancel', clearIfDndMissedEnd)
window.addEventListener('blur', clearIfDndMissedEnd)
window.addEventListener('focus', clearIfDndMissedEnd)
releaseMissedEndFallbackRef.current = () => {
if (cleanupTimer !== null) {
window.clearTimeout(cleanupTimer)
}
window.removeEventListener('pointerup', clearIfDndMissedEnd)
window.removeEventListener('pointercancel', clearIfDndMissedEnd)
window.removeEventListener('blur', clearIfDndMissedEnd)
window.removeEventListener('focus', clearIfDndMissedEnd)
}
}, [releaseMissedEndFallback])
const acquireWebviewDragPassthrough = useCallback(() => {
// Why: dnd-kit tab drags are pointer-driven, so the native drag listeners
// in webview-registry never fire. Put webviews in passthrough explicitly.
releaseWebviewDragPassthrough()
releaseWebviewDragPassthroughRef.current = acquireWebviewsDragPassthrough()
}, [releaseWebviewDragPassthrough])
const setDragRootNode = useCallback(
(node: HTMLDivElement | null): void => {
if (node) {
return
}
// Why: this root owns the dnd-kit gesture that temporarily puts browser
// webviews in pointer passthrough and installs global fallback listeners,
// so root teardown must release both.
releaseWebviewDragPassthrough()
releaseMissedEndFallback()
},
[releaseMissedEndFallback, releaseWebviewDragPassthrough]
)
const clearDragState = useCallback(() => {
tabDragActiveRef.current = false
releaseWebviewDragPassthrough()
releaseMissedEndFallback()
setActiveDrag(null)
setHoveredDropTarget(null)
clearHoveredDropTarget()
tabInsertion.clear()
preDragActivationSnapshotRef.current = null
lastPreviewRef.current = null
lastHoveredTabPreviewRef.current = null
dragGeometryRef.current = null
}, [releaseMissedEndFallback, releaseWebviewDragPassthrough, tabInsertion])
}, [
clearHoveredDropTarget,
releaseMissedEndFallback,
releaseWebviewDragPassthrough,
tabInsertion
])
clearDragStateRef.current = clearDragState
const restorePreDragActivation = useCallback(() => {
@@ -260,88 +189,6 @@ export function useTabDragSplit({
[clearDragState, restorePreDragActivation, restoreSourceGroupAfterCrossGroupDrop]
)
const updateDragPreviewActivation = useCallback(
(event: DragMoveEvent | DragOverEvent, activeData: TabDragItemData) => {
const snapshot = preDragActivationSnapshotRef.current
if (!snapshot) {
return
}
const overData = event.over?.data.current
if (isTabDragData(overData) && overData.unifiedTabId !== activeData.unifiedTabId) {
lastHoveredTabPreviewRef.current = {
groupId: overData.groupId,
tabId: overData.unifiedTabId
}
}
const preview = resolveDragPreviewTabId({
activeDrag: activeData,
overData,
preDragActiveTabIdByGroup: snapshot.activeTabIdByGroup,
lastHoveredTabPreview: lastHoveredTabPreviewRef.current
})
const lastPreview = lastPreviewRef.current
if (lastPreview?.groupId === preview.groupId && lastPreview.tabId === preview.tabId) {
return
}
lastPreviewRef.current = preview
applyDragPreviewTab({
worktreeId,
groupId: preview.groupId,
tabId: preview.tabId,
activeGroupId: preview.groupId
})
},
[worktreeId]
)
const updateHoveredDropTargetFromSplit = useCallback(
(splitTarget: ActivePaneColumnSplitTarget | null) => {
if (!splitTarget) {
setHoveredDropTarget((prev) => (prev === null ? prev : null))
return
}
setHoveredDropTarget((prev) => {
if (prev?.groupId === splitTarget.groupId && prev?.zone === splitTarget.zone) {
return prev
}
return {
groupId: splitTarget.groupId,
zone: splitTarget.zone,
panelRect: splitTarget.panelRect
}
})
},
[]
)
const handleDragUpdate = useCallback(
(event: DragMoveEvent | DragOverEvent) => {
const activeData = event.active.data.current
if (isTabDragData(activeData) && activeData.worktreeId === worktreeId) {
updateDragPreviewActivation(event, activeData)
}
const state = useAppStore.getState()
const splitTarget = resolveActivePaneColumnSplitTarget({
event,
groupsByWorktree: state.groupsByWorktree,
layoutByWorktree: state.layoutByWorktree,
worktreeId,
getDragPointer,
geometry: dragGeometryRef.current
})
updateHoveredDropTargetFromSplit(splitTarget)
if (splitTarget) {
tabInsertion.clear()
} else {
tabInsertion.update(event)
}
},
[tabInsertion, updateDragPreviewActivation, updateHoveredDropTargetFromSplit, worktreeId]
)
const onDragStart = useCallback(
(event: DragStartEvent) => {
const dragData = event.active.data.current
@@ -374,157 +221,16 @@ export function useTabDragSplit({
const onDragEnd = useCallback(
(event: DragEndEvent) => {
const activeData = event.active.data.current
const overData = event.over?.data.current
let shouldRestorePreDragActivation = true
if (!isTabDragData(activeData) || activeData.worktreeId !== worktreeId) {
finishDrag(true)
return
}
const state = useAppStore.getState()
const paneColumnSplit = resolveActivePaneColumnSplitTarget({
commitTabDragDrop({
event,
groupsByWorktree: state.groupsByWorktree,
layoutByWorktree: state.layoutByWorktree,
worktreeId,
getDragPointer,
geometry: dragGeometryRef.current
dragGeometryRef,
dropUnifiedTab,
reorderUnifiedTabs,
finishDrag
})
if (paneColumnSplit) {
const moved = dropUnifiedTab(activeData.unifiedTabId, {
groupId: paneColumnSplit.groupId,
splitDirection: paneColumnSplit.zone
})
if (moved) {
shouldRestorePreDragActivation = false
mirrorWebRuntimeTabMove({
kind: 'split',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: paneColumnSplit.groupId,
splitDirection: paneColumnSplit.zone
})
}
finishDrag(
shouldRestorePreDragActivation,
resolveSourceGroupRestoreOnDrop(
activeData,
paneColumnSplit.groupId,
shouldRestorePreDragActivation
)
)
return
}
if (!event.over) {
finishDrag(true)
return
}
if (isTabDragData(overData)) {
if (activeData.unifiedTabId === overData.unifiedTabId) {
finishDrag(true)
return
}
const groups = state.groupsByWorktree[worktreeId] ?? []
const targetGroup = groups.find((group) => group.id === overData.groupId)
if (!targetGroup) {
finishDrag(true)
return
}
// Why: dnd-kit's `over` is the hovered tab, but the drop's true
// insertion point depends on which side of that tab the cursor sits.
// Using the bar's computed side (re-derived here to avoid stale
// closures) means the drop always lands where the blue bar was drawn.
const insertion = resolveTabInsertion(event, isTabDragData, getDragPointer)
if (!insertion) {
finishDrag(true)
return
}
const overIndex = targetGroup.tabOrder.indexOf(overData.unifiedTabId)
const rawInsertIndex = overIndex + (insertion.side === 'right' ? 1 : 0)
if (activeData.groupId === overData.groupId) {
const oldIndex = targetGroup.tabOrder.indexOf(activeData.unifiedTabId)
// Why: splicing out the dragged tab before inserting would shift the
// intended target slot left by one when moving forward. Adjust the
// insertion index to match the post-removal order.
const nextIndex = oldIndex < rawInsertIndex ? rawInsertIndex - 1 : rawInsertIndex
if (oldIndex !== -1 && oldIndex !== nextIndex) {
const nextOrder = targetGroup.tabOrder.filter((id) => id !== activeData.unifiedTabId)
nextOrder.splice(nextIndex, 0, activeData.unifiedTabId)
reorderUnifiedTabs(overData.groupId, nextOrder)
mirrorWebRuntimeTabMove({
kind: 'reorder',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId,
tabOrder: nextOrder
})
}
} else {
const index = overIndex === -1 ? targetGroup.tabOrder.length : rawInsertIndex
const moved = dropUnifiedTab(activeData.unifiedTabId, {
groupId: overData.groupId,
index
})
if (moved) {
shouldRestorePreDragActivation = false
mirrorWebRuntimeTabMove({
kind: 'move-to-group',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId,
index
})
}
}
finishDrag(
shouldRestorePreDragActivation,
resolveSourceGroupRestoreOnDrop(
activeData,
overData.groupId,
shouldRestorePreDragActivation
)
)
return
}
if (isPaneDropData(overData)) {
if (activeData.groupId !== overData.groupId) {
const moved = dropUnifiedTab(activeData.unifiedTabId, {
groupId: overData.groupId
})
if (moved) {
shouldRestorePreDragActivation = false
mirrorWebRuntimeTabMove({
kind: 'move-to-group',
worktreeId,
tabId: activeData.unifiedTabId,
targetGroupId: overData.groupId
})
}
}
}
finishDrag(
shouldRestorePreDragActivation,
isPaneDropData(overData)
? resolveSourceGroupRestoreOnDrop(
activeData,
overData.groupId,
shouldRestorePreDragActivation
)
: undefined
)
},
[dropUnifiedTab, finishDrag, reorderUnifiedTabs, worktreeId]
[dragGeometryRef, dropUnifiedTab, finishDrag, reorderUnifiedTabs, worktreeId]
)
// Why: dnd-kit fires onDragCancel (not onDragEnd) when the user presses
@@ -0,0 +1,142 @@
import { useCallback } from 'react'
import type { Tab } from '../../../../shared/tab-types'
import { useAppStore } from '../../store'
import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import {
activateWebRuntimeSessionTab,
isWebRuntimeSessionActive
} from '../../runtime/web-runtime-session'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership'
import type { TabGroupWorktreeSnapshot } from './useTabGroupItemProjections'
export function useTabGroupActivationCommands({
groupId,
worktreeId,
groupTabs,
worktreeState
}: {
groupId: string
worktreeId: string
groupTabs: Tab[]
worktreeState: TabGroupWorktreeSnapshot
}) {
const focusGroup = useAppStore((state) => state.focusGroup)
const activateTab = useAppStore((state) => state.activateTab)
const setActiveTab = useAppStore((state) => state.setActiveTab)
const setActiveTabType = useAppStore((state) => state.setActiveTabType)
const setActiveFile = useAppStore((state) => state.setActiveFile)
const setActiveBrowserTab = useAppStore((state) => state.setActiveBrowserTab)
const activateTerminal = useCallback(
(terminalId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
worktreeId
)
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
void activateWebRuntimeSessionTab({
worktreeId,
tabId: terminalId,
environmentId: runtimeEnvironmentId
})
}
setActiveTab(terminalId)
setActiveTabType('terminal')
const activeLeafId = worktreeState.terminalLayoutsByTabId[terminalId]?.activeLeafId ?? null
// Why: restore xterm focus to the store-active leaf so keyboard input can't drift to a sibling pane.
focusTerminalTabSurface(terminalId, activeLeafId)
},
[
activateTab,
focusGroup,
groupId,
groupTabs,
setActiveTab,
setActiveTabType,
worktreeState.terminalLayoutsByTabId,
worktreeId
]
)
const toggleTerminalPaneExpand = useCallback(
(terminalId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (!item) {
return
}
// Why: the collapse icon stops pointer propagation, so activate here since the normal tab handler won't have run.
activateTerminal(terminalId)
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, {
detail: { tabId: terminalId }
})
)
})
},
[activateTerminal, groupTabs]
)
const activateEditor = useCallback(
(tabId: string) => {
const item = groupTabs.find((candidate) => candidate.id === tabId)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
if (item.contentType === 'simulator') {
setActiveTabType('simulator')
// simulator has no editor file entity
} else {
setActiveFile(item.entityId)
setActiveTabType('editor')
}
},
[activateTab, focusGroup, groupId, groupTabs, setActiveFile, setActiveTabType, worktreeId]
)
const activateBrowser = useCallback(
(browserTabId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser'
)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
worktreeId
)
if (
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
browserWorkspaceHasRemoteOwner(useAppStore.getState(), browserTabId, runtimeEnvironmentId)
) {
void activateWebRuntimeSessionTab({
worktreeId,
tabId: item.id,
environmentId: runtimeEnvironmentId
})
}
setActiveBrowserTab(browserTabId)
setActiveTabType('browser')
},
[activateTab, focusGroup, groupId, groupTabs, setActiveBrowserTab, setActiveTabType, worktreeId]
)
return { activateTerminal, toggleTerminalPaneExpand, activateEditor, activateBrowser }
}
@@ -0,0 +1,102 @@
import { useCallback } from 'react'
import type { Tab, TabGroup } from '../../../../shared/tab-types'
import { useAppStore } from '../../store'
export function useTabGroupCloseScopeCommands({
groupId,
worktreeId,
group,
groupTabs,
closeItem,
closeMany,
leaveWorktreeIfEmpty
}: {
groupId: string
worktreeId: string
group: TabGroup | null
groupTabs: Tab[]
closeItem: (itemId: string, opts?: { skipEmptyCheck?: boolean }) => void
closeMany: (itemIds: string[]) => void
leaveWorktreeIfEmpty: () => void
}) {
const closeEmptyGroup = useAppStore((state) => state.closeEmptyGroup)
const closeGroup = useCallback(() => {
const items = [...(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [])].filter(
(item) => item.groupId === groupId
)
for (const item of items) {
closeItem(item.id, { skipEmptyCheck: true })
}
// Why: closing tabs doesn't remove the group shell; empty split groups are layout state, collapse the placeholder pane here.
closeEmptyGroup(worktreeId, groupId)
leaveWorktreeIfEmpty()
}, [closeEmptyGroup, closeItem, groupId, leaveWorktreeIfEmpty, worktreeId])
const closeAllEditorTabsInGroup = useCallback(() => {
for (const item of groupTabs) {
if (
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review' ||
item.contentType === 'check-details'
) {
closeItem(item.id)
}
}
}, [closeItem, groupTabs])
const closeOthers = useCallback(
(itemId: string) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return
}
// Why: store closeOtherTabs strands dirty files if the save dialog is cancelled; route via closeMany to stay dirty-aware.
const siblingIds = groupTabs
.filter((candidate) => candidate.id !== itemId && !candidate.isPinned)
.map((candidate) => candidate.id)
closeMany(siblingIds)
},
[closeMany, groupTabs]
)
const closeToRight = useCallback(
(itemId: string) => {
// Why: store closeTabsToRight pre-closes dirty tabs; walk tabOrder (canonical L-to-R) via closeMany to stay dirty-aware.
const order = group?.tabOrder ?? []
const index = order.indexOf(itemId)
if (index === -1) {
return
}
const tabById = new Map(groupTabs.map((candidate) => [candidate.id, candidate]))
const rightIds = order.slice(index + 1).filter((id) => {
const candidate = tabById.get(id)
return candidate ? !candidate.isPinned : false
})
closeMany(rightIds)
},
[closeMany, group, groupTabs]
)
const closeToLeft = useCallback(
(itemId: string) => {
// Why: see closeToRight — walk tabOrder locally and route through the
// dirty-aware closeMany path instead of the store helper.
const order = group?.tabOrder ?? []
const index = order.indexOf(itemId)
if (index === -1) {
return
}
const tabById = new Map(groupTabs.map((candidate) => [candidate.id, candidate]))
const leftIds = order.slice(0, index).filter((id) => {
const candidate = tabById.get(id)
return candidate ? !candidate.isPinned : false
})
closeMany(leftIds)
},
[closeMany, group, groupTabs]
)
return { closeGroup, closeAllEditorTabsInGroup, closeOthers, closeToRight, closeToLeft }
}
@@ -0,0 +1,169 @@
import { useCallback } from 'react'
import { toast } from 'sonner'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { useAppStore } from '../../store'
import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface'
import {
createWebRuntimeSessionBrowserTab,
createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive
} from '../../runtime/web-runtime-session'
import { openTabBarEntry, type TabCreateEntryArgs } from '../tab-bar/tab-create-entry-action'
import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab'
import { ensureSimulatorTab, getSimulatorTabForWorktree } from '@/lib/ensure-simulator-tab'
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership'
import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy'
import type { TabGroupWorktreeSnapshot } from './useTabGroupItemProjections'
export function recordTerminalTabGroupSplit(createdTerminal: TerminalTab | null | undefined): void {
if (!createdTerminal) {
return
}
useAppStore.getState().recordFeatureInteraction('terminal-pane-split')
}
export function useTabGroupCreationCommands({
groupId,
worktreeId,
worktreeState
}: {
groupId: string
worktreeId: string
worktreeState: TabGroupWorktreeSnapshot
}) {
const focusGroup = useAppStore((state) => state.focusGroup)
const createTab = useAppStore((state) => state.createTab)
const setActiveTab = useAppStore((state) => state.setActiveTab)
const setActiveTabType = useAppStore((state) => state.setActiveTabType)
const createBrowserTab = useAppStore((state) => state.createBrowserTab)
const createEmptySplitGroup = useAppStore((state) => state.createEmptySplitGroup)
const openNewBrowserTabInActiveWorkspace = useAppStore(
(state) => state.openNewBrowserTabInActiveWorkspace
)
const openNewMarkdownInActiveWorkspace = useAppStore(
(state) => state.openNewMarkdownInActiveWorkspace
)
const openNewTerminalTabInActiveWorkspace = useAppStore(
(state) => state.openNewTerminalTabInActiveWorkspace
)
const createSplitGroup = useCallback(
(direction: 'left' | 'right' | 'up' | 'down') => {
focusGroup(worktreeId, groupId)
const newGroupId = createEmptySplitGroup(worktreeId, groupId, direction)
if (!newGroupId) {
return
}
// Why: this Split entry point always seeds a fresh terminal (tab-drag can open other directions).
const terminal = createTab(worktreeId, newGroupId)
recordTerminalTabGroupSplit(terminal)
setActiveTab(terminal.id)
setActiveTabType('terminal')
},
[
createEmptySplitGroup,
createTab,
focusGroup,
groupId,
setActiveTab,
setActiveTabType,
worktreeId
]
)
// Why: these stay unmemoized plain lambdas — the original built them inline in the returned commands object.
return {
createSplitGroup,
newBrowserTab: () => {
void openNewBrowserTabInActiveWorkspace(groupId).catch((error) => {
toast.error(error instanceof Error ? error.message : String(error))
})
},
newSimulatorTab: worktreeState.mobileEmulatorEnabled
? () => {
if (getSimulatorTabForWorktree(worktreeId)) {
void ensureSimulatorTab(worktreeId, { surfacePane: true })
return
}
// Why: mobile simulators are most useful beside the current tab group.
void openMobileEmulatorTab(worktreeId, {
placement: 'rightSplit',
targetGroupId: groupId
}).catch((error) => {
toast.error(error instanceof Error ? error.message : String(error))
})
}
: undefined,
openEntry: async (args: TabCreateEntryArgs) => {
await openTabBarEntry(args)
},
duplicateBrowserTab: (browserTabId: string) => {
void (async () => {
const state = useAppStore.getState()
const tabs = state.browserTabsByWorktree[worktreeId] ?? []
const source = tabs.find((t) => t.id === browserTabId)
if (!source) {
return
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
const browserAvailability = getClientCreationActionPolicy(state, worktreeId)[
'managed-browser'
]
if (browserAvailability.state !== 'enabled') {
throw new Error(browserAvailability.reason)
}
if (
browserAvailability.provider === 'paired-runtime' &&
browserWorkspaceHasRemoteOwner(state, source.id, runtimeEnvironmentId)
) {
const created = await createWebRuntimeSessionBrowserTab({
worktreeId,
environmentId: runtimeEnvironmentId,
url: source.url,
profileId: source.sessionProfileId,
targetGroupId: groupId
})
if (created) {
return
}
throw new Error('The paired runtime could not duplicate the managed browser tab.')
}
createBrowserTab(worktreeId, source.url, {
...buildDuplicatedBrowserTabOptions(source),
...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}),
targetGroupId: groupId
})
})().catch((error) => {
toast.error(error instanceof Error ? error.message : String(error))
})
},
// Why: target the owning group explicitly; the "+" menu can fire from an unfocused panel without updating global group focus.
newFileTab: async () => {
await openNewMarkdownInActiveWorkspace(groupId)
},
newTerminalTab: () => {
void openNewTerminalTabInActiveWorkspace(groupId)
},
newTerminalWithShell: (shellOverride: string) => {
void (async () => {
const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)
const outcome = await createWebRuntimeSessionTerminal({
worktreeId,
environmentId,
targetGroupId: groupId,
command: shellOverride,
activate: true
})
if (outcome.status === 'created' || isWebRuntimeSessionActive(environmentId)) {
return
}
const terminal = createTab(worktreeId, groupId, shellOverride)
setActiveTab(terminal.id)
setActiveTabType('terminal')
focusTerminalTabSurface(terminal.id)
})()
}
}
}
@@ -0,0 +1,136 @@
import { useMemo } from 'react'
import type { OpenFile } from '@/store/slices/editor'
import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types'
import type { Tab, TabGroup } from '../../../../shared/tab-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution'
import type { useAppStore } from '../../store'
type TabGroupAppState = ReturnType<typeof useAppStore.getState>
export type TabGroupWorktreeSnapshot = {
groups: readonly TabGroup[]
unifiedTabs: readonly Tab[]
terminalTabs: readonly TerminalTab[]
openFiles: TabGroupAppState['openFiles']
browserTabs: readonly BrowserTabState[]
expandedPaneByTabId: TabGroupAppState['expandedPaneByTabId']
terminalLayoutsByTabId: NonNullable<TabGroupAppState['terminalLayoutsByTabId']>
generatedTabTitlesEnabled: boolean
mobileEmulatorEnabled: boolean
}
export type GroupEditorItem = OpenFile & { tabId: string }
export type GroupBrowserItem = BrowserTabState & { tabId: string }
type TerminalTabItem = TerminalTab & { unifiedTabId: string }
export function useTabGroupItemProjections({
groupId,
worktreeId,
worktreeState
}: {
groupId: string
worktreeId: string
worktreeState: TabGroupWorktreeSnapshot
}) {
const group = useMemo(
() => worktreeState.groups.find((item) => item.id === groupId) ?? null,
[groupId, worktreeState.groups]
)
const groupTabs = useMemo(
() => worktreeState.unifiedTabs.filter((item) => item.groupId === groupId),
[groupId, worktreeState.unifiedTabs]
)
const activeItemId = group?.activeTabId ?? null
const activeTab = groupTabs.find((item) => item.id === activeItemId) ?? null
// Why: shell identity lives on the terminal tab (not the unified tab) so icons survive default-shell changes.
const terminalTabById = useMemo(
() => new Map(worktreeState.terminalTabs.map((item) => [item.id, item])),
[worktreeState.terminalTabs]
)
const terminalTabs = useMemo<TerminalTabItem[]>(
() =>
groupTabs
.filter((item) => item.contentType === 'terminal')
.map((item) => {
const terminalTab = terminalTabById.get(item.entityId)
return {
id: item.entityId,
unifiedTabId: item.id,
ptyId: terminalTab?.ptyId ?? null,
worktreeId,
title: resolveUnifiedTabLabel(
{
...item,
quickCommandLabel: item.quickCommandLabel ?? terminalTab?.quickCommandLabel,
generatedLabel: item.generatedLabel ?? terminalTab?.generatedTitle
},
worktreeState.generatedTabTitlesEnabled,
item.label
),
defaultTitle: terminalTab?.defaultTitle,
quickCommandLabel: terminalTab?.quickCommandLabel ?? item.quickCommandLabel ?? null,
generatedTitle: terminalTab?.generatedTitle ?? item.generatedLabel ?? null,
customTitle: item.customLabel ?? terminalTab?.customTitle ?? null,
color: item.color ?? terminalTab?.color ?? null,
sortOrder: item.sortOrder,
createdAt: item.createdAt,
generation: terminalTab?.generation,
shellOverride: terminalTab?.shellOverride,
startupCwd: terminalTab?.startupCwd,
// Why: rebuilt from the unified-tab model, so copy store-only launchAgent or the provider icon is missing until the first hook.
launchAgent: terminalTab?.launchAgent,
pendingActivationSpawn: terminalTab?.pendingActivationSpawn
}
}),
[groupTabs, terminalTabById, worktreeId, worktreeState.generatedTabTitlesEnabled]
)
const editorItems = useMemo<GroupEditorItem[]>(
() =>
groupTabs
.filter(
(item) =>
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review' ||
item.contentType === 'check-details'
)
.map((item) => {
const file = worktreeState.openFiles.find((candidate) => candidate.id === item.entityId)
return file ? { ...file, tabId: item.id } : null
})
.filter((item): item is GroupEditorItem => item !== null),
[groupTabs, worktreeState.openFiles]
)
const browserItems = useMemo<GroupBrowserItem[]>(
() =>
groupTabs
.filter((item) => item.contentType === 'browser')
.map((item) => {
const bt = worktreeState.browserTabs.find((candidate) => candidate.id === item.entityId)
return bt ? { ...bt, tabId: item.id } : null
})
.filter((item): item is GroupBrowserItem => item !== null),
[groupTabs, worktreeState.browserTabs]
)
const tabBarOrder = useMemo(
() =>
(group?.tabOrder ?? []).map((itemId) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return itemId
}
return item.contentType === 'terminal' || item.contentType === 'browser'
? item.entityId
: item.id
}),
[group, groupTabs]
)
return { group, groupTabs, activeTab, terminalTabs, editorItems, browserItems, tabBarOrder }
}
@@ -0,0 +1,181 @@
import { useCallback } from 'react'
import type { Tab } from '../../../../shared/tab-types'
import { useAppStore } from '../../store'
import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup'
import { requestEditorFileClose } from '../editor/editor-autosave'
import {
closeWebRuntimeSessionTab,
isWebRuntimeSessionActive
} from '../../runtime/web-runtime-session'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership'
export function useTabGroupTabCloseCommands({
worktreeId,
groupTabs
}: {
worktreeId: string
groupTabs: Tab[]
}) {
const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab)
const closeTab = useAppStore((state) => state.closeTab)
const closeFile = useAppStore((state) => state.closeFile)
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
const setActiveWorktree = useAppStore((state) => state.setActiveWorktree)
const closeEditorIfUnreferenced = useCallback(
(entityId: string, closingTabId: string) => {
const otherReference = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some(
(item) =>
item.id !== closingTabId &&
item.entityId === entityId &&
(item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review' ||
item.contentType === 'check-details')
)
if (!otherReference) {
const file = useAppStore.getState().openFiles.find((candidate) => candidate.id === entityId)
if (file?.isDirty) {
// Why: route through Terminal.tsx so the unsaved-confirmation save/discard queue stays centralized across all close paths.
requestEditorFileClose(entityId)
return false
}
closeFile(entityId)
}
return true
},
[closeFile, worktreeId]
)
const leaveWorktreeIfEmpty = useCallback(() => {
const state = useAppStore.getState()
if (state.activeWorktreeId !== worktreeId) {
return
}
// Why: split-group closes bypass legacy Terminal.tsx; deselect the emptied worktree here or the window goes blank instead of landing.
const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId)
if (renderableTabCount === 0) {
setActiveWorktree(null)
}
}, [setActiveWorktree, worktreeId])
const closeItem = useCallback(
(itemId: string, opts?: { skipEmptyCheck?: boolean }) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return
}
if (item.isPinned) {
return
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
worktreeId
)
if (item.contentType === 'terminal') {
// Why: closeTerminalTab can defer behind a pin / running-process dialog, so the
// empty check has to run on the actual close — never on cancel.
closeTerminalTab(
item.entityId,
opts?.skipEmptyCheck ? undefined : { onClosed: leaveWorktreeIfEmpty }
)
return
}
if (item.contentType === 'browser') {
const browserState = useAppStore.getState()
const hasLocalPages = (browserState.browserPagesByWorkspace[item.entityId] ?? []).length > 0
// Why: host-close a remote-owned browser or a pageless host-mirror (else un-closable); local fallbacks have pages so stay local.
const shouldCloseOnHost =
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
(browserWorkspaceHasRemoteOwner(browserState, item.entityId, runtimeEnvironmentId) ||
!hasLocalPages)
if (shouldCloseOnHost) {
void closeWebRuntimeSessionTab({
worktreeId,
tabId: item.id,
environmentId: runtimeEnvironmentId,
reason: 'user'
})
}
destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId)
closeBrowserTab(item.entityId)
closeUnifiedTab(item.id)
} else if (item.contentType === 'simulator') {
closeUnifiedTab(item.id)
} else {
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
if (!canCloseTab) {
return
}
closeUnifiedTab(item.id)
}
if (!opts?.skipEmptyCheck) {
leaveWorktreeIfEmpty()
}
},
[
closeBrowserTab,
closeEditorIfUnreferenced,
closeUnifiedTab,
groupTabs,
leaveWorktreeIfEmpty,
worktreeId
]
)
const closeMany = useCallback(
(itemIds: string[]) => {
for (const itemId of itemIds) {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item || item.isPinned) {
continue
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
worktreeId
)
if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) {
// Why: revoke local resume + hook authority before the host removes its canonical tab.
// No running-process prompt: a bulk close of N busy tabs would be a modal storm.
closeTerminalTab(item.entityId, { skipRunningProcessConfirm: true })
continue
}
if (item.contentType === 'browser') {
// Why: see closeItem — host-close a remote-owned browser or pageless host-mirror; always remove the visible tab.
const browserState = useAppStore.getState()
const hasLocalPages =
(browserState.browserPagesByWorkspace[item.entityId] ?? []).length > 0
const shouldCloseOnHost =
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
(browserWorkspaceHasRemoteOwner(browserState, item.entityId, runtimeEnvironmentId) ||
!hasLocalPages)
if (shouldCloseOnHost) {
void closeWebRuntimeSessionTab({
worktreeId,
tabId: item.id,
environmentId: runtimeEnvironmentId,
reason: 'user'
})
}
destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId)
closeBrowserTab(item.entityId)
closeUnifiedTab(item.id)
} else if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'simulator') {
closeUnifiedTab(item.id)
} else {
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
if (canCloseTab) {
closeUnifiedTab(item.id)
}
}
}
},
[closeBrowserTab, closeEditorIfUnreferenced, closeTab, closeUnifiedTab, groupTabs, worktreeId]
)
return { closeItem, closeMany, leaveWorktreeIfEmpty }
}
@@ -1,42 +1,13 @@
/* eslint-disable max-lines -- Why: keeps group-scoped activation, close, split, and tab-order rules together with the TabGroupPanel surface. */
import { useCallback, useMemo } from 'react'
import { toast } from 'sonner'
import { useShallow } from 'zustand/react/shallow'
import type { OpenFile } from '@/store/slices/editor'
import type { BrowserTab as BrowserTabState } from '../../../../shared/browser-workspace-types'
import type { Tab, TabGroup } from '../../../../shared/tab-types'
import type { TerminalTab } from '../../../../shared/terminal-tab-types'
import { resolveUnifiedTabLabel } from '../../../../shared/tab-title-resolution'
import { useAppStore } from '../../store'
import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup'
import { requestEditorFileClose } from '../editor/editor-autosave'
import { focusTerminalTabSurface } from '../../lib/focus-terminal-tab-surface'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import {
activateWebRuntimeSessionTab,
closeWebRuntimeSessionTab,
createWebRuntimeSessionBrowserTab,
createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive
} from '../../runtime/web-runtime-session'
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
import { openTabBarEntry, type TabCreateEntryArgs } from '../tab-bar/tab-create-entry-action'
import { openMobileEmulatorTab } from '@/lib/open-mobile-emulator-tab'
import { ensureSimulatorTab, getSimulatorTabForWorktree } from '@/lib/ensure-simulator-tab'
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership'
import { getClientCreationActionPolicy } from '@/lib/client-creation-action-policy'
export function recordTerminalTabGroupSplit(createdTerminal: TerminalTab | null | undefined): void {
if (!createdTerminal) {
return
}
useAppStore.getState().recordFeatureInteraction('terminal-pane-split')
}
export type GroupEditorItem = OpenFile & { tabId: string }
export type GroupBrowserItem = BrowserTabState & { tabId: string }
import { useTabGroupItemProjections } from './useTabGroupItemProjections'
import { useTabGroupTabCloseCommands } from './useTabGroupTabCloseCommands'
import { useTabGroupCloseScopeCommands } from './useTabGroupCloseScopeCommands'
import { useTabGroupActivationCommands } from './useTabGroupActivationCommands'
import { useTabGroupCreationCommands } from './useTabGroupCreationCommands'
const EMPTY_GROUPS: readonly TabGroup[] = []
const EMPTY_UNIFIED_TABS: readonly Tab[] = []
@@ -46,8 +17,6 @@ const EMPTY_TERMINAL_LAYOUTS_BY_TAB_ID: NonNullable<
ReturnType<typeof useAppStore.getState>['terminalLayoutsByTabId']
> = {}
type TerminalTabItem = TerminalTab & { unifiedTabId: string }
export function useTabGroupWorkspaceModel({
groupId,
worktreeId
@@ -71,494 +40,34 @@ export function useTabGroupWorkspaceModel({
)
const focusGroup = useAppStore((state) => state.focusGroup)
const activateTab = useAppStore((state) => state.activateTab)
const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab)
const closeEmptyGroup = useAppStore((state) => state.closeEmptyGroup)
const createTab = useAppStore((state) => state.createTab)
const closeTab = useAppStore((state) => state.closeTab)
const setActiveTab = useAppStore((state) => state.setActiveTab)
const setActiveFile = useAppStore((state) => state.setActiveFile)
const setActiveTabType = useAppStore((state) => state.setActiveTabType)
const createBrowserTab = useAppStore((state) => state.createBrowserTab)
const openNewBrowserTabInActiveWorkspace = useAppStore(
(state) => state.openNewBrowserTabInActiveWorkspace
)
const openNewMarkdownInActiveWorkspace = useAppStore(
(state) => state.openNewMarkdownInActiveWorkspace
)
const openNewTerminalTabInActiveWorkspace = useAppStore(
(state) => state.openNewTerminalTabInActiveWorkspace
)
const closeFile = useAppStore((state) => state.closeFile)
const makePreviewFilePermanent = useAppStore((state) => state.makePreviewFilePermanent)
const pinFile = useAppStore((state) => state.pinFile)
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
const setActiveBrowserTab = useAppStore((state) => state.setActiveBrowserTab)
const setActiveWorktree = useAppStore((state) => state.setActiveWorktree)
const createEmptySplitGroup = useAppStore((state) => state.createEmptySplitGroup)
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
const setTabColor = useAppStore((state) => state.setTabColor)
const group = useMemo(
() => worktreeState.groups.find((item) => item.id === groupId) ?? null,
[groupId, worktreeState.groups]
)
const groupTabs = useMemo(
() => worktreeState.unifiedTabs.filter((item) => item.groupId === groupId),
[groupId, worktreeState.unifiedTabs]
)
const activeItemId = group?.activeTabId ?? null
const activeTab = groupTabs.find((item) => item.id === activeItemId) ?? null
// Why: shell identity lives on the terminal tab (not the unified tab) so icons survive default-shell changes.
const terminalTabById = useMemo(
() => new Map(worktreeState.terminalTabs.map((item) => [item.id, item])),
[worktreeState.terminalTabs]
)
const { group, groupTabs, activeTab, terminalTabs, editorItems, browserItems, tabBarOrder } =
useTabGroupItemProjections({ groupId, worktreeId, worktreeState })
const terminalTabs = useMemo<TerminalTabItem[]>(
() =>
groupTabs
.filter((item) => item.contentType === 'terminal')
.map((item) => {
const terminalTab = terminalTabById.get(item.entityId)
return {
id: item.entityId,
unifiedTabId: item.id,
ptyId: terminalTab?.ptyId ?? null,
worktreeId,
title: resolveUnifiedTabLabel(
{
...item,
quickCommandLabel: item.quickCommandLabel ?? terminalTab?.quickCommandLabel,
generatedLabel: item.generatedLabel ?? terminalTab?.generatedTitle
},
worktreeState.generatedTabTitlesEnabled,
item.label
),
defaultTitle: terminalTab?.defaultTitle,
quickCommandLabel: terminalTab?.quickCommandLabel ?? item.quickCommandLabel ?? null,
generatedTitle: terminalTab?.generatedTitle ?? item.generatedLabel ?? null,
customTitle: item.customLabel ?? terminalTab?.customTitle ?? null,
color: item.color ?? terminalTab?.color ?? null,
sortOrder: item.sortOrder,
createdAt: item.createdAt,
generation: terminalTab?.generation,
shellOverride: terminalTab?.shellOverride,
startupCwd: terminalTab?.startupCwd,
// Why: rebuilt from the unified-tab model, so copy store-only launchAgent or the provider icon is missing until the first hook.
launchAgent: terminalTab?.launchAgent,
pendingActivationSpawn: terminalTab?.pendingActivationSpawn
}
}),
[groupTabs, terminalTabById, worktreeId, worktreeState.generatedTabTitlesEnabled]
)
const { closeItem, closeMany, leaveWorktreeIfEmpty } = useTabGroupTabCloseCommands({
worktreeId,
groupTabs
})
const editorItems = useMemo<GroupEditorItem[]>(
() =>
groupTabs
.filter(
(item) =>
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review' ||
item.contentType === 'check-details'
)
.map((item) => {
const file = worktreeState.openFiles.find((candidate) => candidate.id === item.entityId)
return file ? { ...file, tabId: item.id } : null
})
.filter((item): item is GroupEditorItem => item !== null),
[groupTabs, worktreeState.openFiles]
)
const browserItems = useMemo<GroupBrowserItem[]>(
() =>
groupTabs
.filter((item) => item.contentType === 'browser')
.map((item) => {
const bt = worktreeState.browserTabs.find((candidate) => candidate.id === item.entityId)
return bt ? { ...bt, tabId: item.id } : null
})
.filter((item): item is GroupBrowserItem => item !== null),
[groupTabs, worktreeState.browserTabs]
)
const closeEditorIfUnreferenced = useCallback(
(entityId: string, closingTabId: string) => {
const otherReference = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some(
(item) =>
item.id !== closingTabId &&
item.entityId === entityId &&
(item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review' ||
item.contentType === 'check-details')
)
if (!otherReference) {
const file = useAppStore.getState().openFiles.find((candidate) => candidate.id === entityId)
if (file?.isDirty) {
// Why: route through Terminal.tsx so the unsaved-confirmation save/discard queue stays centralized across all close paths.
requestEditorFileClose(entityId)
return false
}
closeFile(entityId)
}
return true
},
[closeFile, worktreeId]
)
const leaveWorktreeIfEmpty = useCallback(() => {
const state = useAppStore.getState()
if (state.activeWorktreeId !== worktreeId) {
return
}
// Why: split-group closes bypass legacy Terminal.tsx; deselect the emptied worktree here or the window goes blank instead of landing.
const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId)
if (renderableTabCount === 0) {
setActiveWorktree(null)
}
}, [setActiveWorktree, worktreeId])
const closeItem = useCallback(
(itemId: string, opts?: { skipEmptyCheck?: boolean }) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return
}
if (item.isPinned) {
return
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
worktreeId
)
if (item.contentType === 'terminal') {
// Why: closeTerminalTab can defer behind a pin / running-process dialog, so the
// empty check has to run on the actual close — never on cancel.
closeTerminalTab(
item.entityId,
opts?.skipEmptyCheck ? undefined : { onClosed: leaveWorktreeIfEmpty }
)
return
}
if (item.contentType === 'browser') {
const browserState = useAppStore.getState()
const hasLocalPages = (browserState.browserPagesByWorkspace[item.entityId] ?? []).length > 0
// Why: host-close a remote-owned browser or a pageless host-mirror (else un-closable); local fallbacks have pages so stay local.
const shouldCloseOnHost =
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
(browserWorkspaceHasRemoteOwner(browserState, item.entityId, runtimeEnvironmentId) ||
!hasLocalPages)
if (shouldCloseOnHost) {
void closeWebRuntimeSessionTab({
worktreeId,
tabId: item.id,
environmentId: runtimeEnvironmentId,
reason: 'user'
})
}
destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId)
closeBrowserTab(item.entityId)
closeUnifiedTab(item.id)
} else if (item.contentType === 'simulator') {
closeUnifiedTab(item.id)
} else {
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
if (!canCloseTab) {
return
}
closeUnifiedTab(item.id)
}
if (!opts?.skipEmptyCheck) {
leaveWorktreeIfEmpty()
}
},
[
closeBrowserTab,
closeEditorIfUnreferenced,
closeUnifiedTab,
groupTabs,
leaveWorktreeIfEmpty,
worktreeId
]
)
const closeMany = useCallback(
(itemIds: string[]) => {
for (const itemId of itemIds) {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item || item.isPinned) {
continue
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
worktreeId
)
if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) {
// Why: revoke local resume + hook authority before the host removes its canonical tab.
// No running-process prompt: a bulk close of N busy tabs would be a modal storm.
closeTerminalTab(item.entityId, { skipRunningProcessConfirm: true })
continue
}
if (item.contentType === 'browser') {
// Why: see closeItem — host-close a remote-owned browser or pageless host-mirror; always remove the visible tab.
const browserState = useAppStore.getState()
const hasLocalPages =
(browserState.browserPagesByWorkspace[item.entityId] ?? []).length > 0
const shouldCloseOnHost =
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
(browserWorkspaceHasRemoteOwner(browserState, item.entityId, runtimeEnvironmentId) ||
!hasLocalPages)
if (shouldCloseOnHost) {
void closeWebRuntimeSessionTab({
worktreeId,
tabId: item.id,
environmentId: runtimeEnvironmentId,
reason: 'user'
})
}
destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId)
closeBrowserTab(item.entityId)
closeUnifiedTab(item.id)
} else if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'simulator') {
closeUnifiedTab(item.id)
} else {
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
if (canCloseTab) {
closeUnifiedTab(item.id)
}
}
}
},
[closeBrowserTab, closeEditorIfUnreferenced, closeTab, closeUnifiedTab, groupTabs, worktreeId]
)
const activateTerminal = useCallback(
(terminalId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
worktreeId
)
if (isWebRuntimeSessionActive(runtimeEnvironmentId)) {
void activateWebRuntimeSessionTab({
worktreeId,
tabId: terminalId,
environmentId: runtimeEnvironmentId
})
}
setActiveTab(terminalId)
setActiveTabType('terminal')
const activeLeafId = worktreeState.terminalLayoutsByTabId[terminalId]?.activeLeafId ?? null
// Why: restore xterm focus to the store-active leaf so keyboard input can't drift to a sibling pane.
focusTerminalTabSurface(terminalId, activeLeafId)
},
[
activateTab,
focusGroup,
const { closeGroup, closeAllEditorTabsInGroup, closeOthers, closeToRight, closeToLeft } =
useTabGroupCloseScopeCommands({
groupId,
worktreeId,
group,
groupTabs,
setActiveTab,
setActiveTabType,
worktreeState.terminalLayoutsByTabId,
worktreeId
]
)
closeItem,
closeMany,
leaveWorktreeIfEmpty
})
const toggleTerminalPaneExpand = useCallback(
(terminalId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
)
if (!item) {
return
}
// Why: the collapse icon stops pointer propagation, so activate here since the normal tab handler won't have run.
activateTerminal(terminalId)
requestAnimationFrame(() => {
window.dispatchEvent(
new CustomEvent(TOGGLE_TERMINAL_PANE_EXPAND_EVENT, {
detail: { tabId: terminalId }
})
)
})
},
[activateTerminal, groupTabs]
)
const { activateTerminal, toggleTerminalPaneExpand, activateEditor, activateBrowser } =
useTabGroupActivationCommands({ groupId, worktreeId, groupTabs, worktreeState })
const activateEditor = useCallback(
(tabId: string) => {
const item = groupTabs.find((candidate) => candidate.id === tabId)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
if (item.contentType === 'simulator') {
setActiveTabType('simulator')
// simulator has no editor file entity
} else {
setActiveFile(item.entityId)
setActiveTabType('editor')
}
},
[activateTab, focusGroup, groupId, groupTabs, setActiveFile, setActiveTabType, worktreeId]
)
const activateBrowser = useCallback(
(browserTabId: string) => {
const item = groupTabs.find(
(candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser'
)
if (!item) {
return
}
focusGroup(worktreeId, groupId)
activateTab(item.id)
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
worktreeId
)
if (
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
browserWorkspaceHasRemoteOwner(useAppStore.getState(), browserTabId, runtimeEnvironmentId)
) {
void activateWebRuntimeSessionTab({
worktreeId,
tabId: item.id,
environmentId: runtimeEnvironmentId
})
}
setActiveBrowserTab(browserTabId)
setActiveTabType('browser')
},
[activateTab, focusGroup, groupId, groupTabs, setActiveBrowserTab, setActiveTabType, worktreeId]
)
const createSplitGroup = useCallback(
(direction: 'left' | 'right' | 'up' | 'down') => {
focusGroup(worktreeId, groupId)
const newGroupId = createEmptySplitGroup(worktreeId, groupId, direction)
if (!newGroupId) {
return
}
// Why: this Split entry point always seeds a fresh terminal (tab-drag can open other directions).
const terminal = createTab(worktreeId, newGroupId)
recordTerminalTabGroupSplit(terminal)
setActiveTab(terminal.id)
setActiveTabType('terminal')
},
[
createEmptySplitGroup,
createTab,
focusGroup,
groupId,
setActiveTab,
setActiveTabType,
worktreeId
]
)
const closeGroup = useCallback(() => {
const items = [...(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [])].filter(
(item) => item.groupId === groupId
)
for (const item of items) {
closeItem(item.id, { skipEmptyCheck: true })
}
// Why: closing tabs doesn't remove the group shell; empty split groups are layout state, collapse the placeholder pane here.
closeEmptyGroup(worktreeId, groupId)
leaveWorktreeIfEmpty()
}, [closeEmptyGroup, closeItem, groupId, leaveWorktreeIfEmpty, worktreeId])
const closeAllEditorTabsInGroup = useCallback(() => {
for (const item of groupTabs) {
if (
item.contentType === 'editor' ||
item.contentType === 'diff' ||
item.contentType === 'conflict-review' ||
item.contentType === 'check-details'
) {
closeItem(item.id)
}
}
}, [closeItem, groupTabs])
const closeOthers = useCallback(
(itemId: string) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return
}
// Why: store closeOtherTabs strands dirty files if the save dialog is cancelled; route via closeMany to stay dirty-aware.
const siblingIds = groupTabs
.filter((candidate) => candidate.id !== itemId && !candidate.isPinned)
.map((candidate) => candidate.id)
closeMany(siblingIds)
},
[closeMany, groupTabs]
)
const closeToRight = useCallback(
(itemId: string) => {
// Why: store closeTabsToRight pre-closes dirty tabs; walk tabOrder (canonical L-to-R) via closeMany to stay dirty-aware.
const order = group?.tabOrder ?? []
const index = order.indexOf(itemId)
if (index === -1) {
return
}
const tabById = new Map(groupTabs.map((candidate) => [candidate.id, candidate]))
const rightIds = order.slice(index + 1).filter((id) => {
const candidate = tabById.get(id)
return candidate ? !candidate.isPinned : false
})
closeMany(rightIds)
},
[closeMany, group, groupTabs]
)
const closeToLeft = useCallback(
(itemId: string) => {
// Why: see closeToRight — walk tabOrder locally and route through the
// dirty-aware closeMany path instead of the store helper.
const order = group?.tabOrder ?? []
const index = order.indexOf(itemId)
if (index === -1) {
return
}
const tabById = new Map(groupTabs.map((candidate) => [candidate.id, candidate]))
const leftIds = order.slice(0, index).filter((id) => {
const candidate = tabById.get(id)
return candidate ? !candidate.isPinned : false
})
closeMany(leftIds)
},
[closeMany, group, groupTabs]
)
const tabBarOrder = useMemo(
() =>
(group?.tabOrder ?? []).map((itemId) => {
const item = groupTabs.find((candidate) => candidate.id === itemId)
if (!item) {
return itemId
}
return item.contentType === 'terminal' || item.contentType === 'browser'
? item.entityId
: item.id
}),
[group, groupTabs]
)
const creationCommands = useTabGroupCreationCommands({ groupId, worktreeId, worktreeState })
return {
group,
@@ -582,99 +91,7 @@ export function useTabGroupWorkspaceModel({
closeOthers,
closeToRight,
closeToLeft,
createSplitGroup,
newBrowserTab: () => {
void openNewBrowserTabInActiveWorkspace(groupId).catch((error) => {
toast.error(error instanceof Error ? error.message : String(error))
})
},
newSimulatorTab: worktreeState.mobileEmulatorEnabled
? () => {
if (getSimulatorTabForWorktree(worktreeId)) {
void ensureSimulatorTab(worktreeId, { surfacePane: true })
return
}
// Why: mobile simulators are most useful beside the current tab group.
void openMobileEmulatorTab(worktreeId, {
placement: 'rightSplit',
targetGroupId: groupId
}).catch((error) => {
toast.error(error instanceof Error ? error.message : String(error))
})
}
: undefined,
openEntry: async (args: TabCreateEntryArgs) => {
await openTabBarEntry(args)
},
duplicateBrowserTab: (browserTabId: string) => {
void (async () => {
const state = useAppStore.getState()
const tabs = state.browserTabsByWorktree[worktreeId] ?? []
const source = tabs.find((t) => t.id === browserTabId)
if (!source) {
return
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
const browserAvailability = getClientCreationActionPolicy(state, worktreeId)[
'managed-browser'
]
if (browserAvailability.state !== 'enabled') {
throw new Error(browserAvailability.reason)
}
if (
browserAvailability.provider === 'paired-runtime' &&
browserWorkspaceHasRemoteOwner(state, source.id, runtimeEnvironmentId)
) {
const created = await createWebRuntimeSessionBrowserTab({
worktreeId,
environmentId: runtimeEnvironmentId,
url: source.url,
profileId: source.sessionProfileId,
targetGroupId: groupId
})
if (created) {
return
}
throw new Error('The paired runtime could not duplicate the managed browser tab.')
}
createBrowserTab(worktreeId, source.url, {
...buildDuplicatedBrowserTabOptions(source),
...(runtimeEnvironmentId ? { browserRuntimeEnvironmentId: null } : {}),
targetGroupId: groupId
})
})().catch((error) => {
toast.error(error instanceof Error ? error.message : String(error))
})
},
// Why: target the owning group explicitly; the "+" menu can fire from an unfocused panel without updating global group focus.
newFileTab: async () => {
await openNewMarkdownInActiveWorkspace(groupId)
},
newTerminalTab: () => {
void openNewTerminalTabInActiveWorkspace(groupId)
},
newTerminalWithShell: (shellOverride: string) => {
void (async () => {
const environmentId = getRuntimeEnvironmentIdForWorktree(
useAppStore.getState(),
worktreeId
)
const outcome = await createWebRuntimeSessionTerminal({
worktreeId,
environmentId,
targetGroupId: groupId,
command: shellOverride,
activate: true
})
if (outcome.status === 'created' || isWebRuntimeSessionActive(environmentId)) {
return
}
const terminal = createTab(worktreeId, groupId, shellOverride)
setActiveTab(terminal.id)
setActiveTabType('terminal')
focusTerminalTabSurface(terminal.id)
})()
},
...creationCommands,
makePreviewFilePermanent,
pinFile,
setTabColor,
@@ -0,0 +1,67 @@
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import type { PaneCwdMap } from './resolve-split-cwd'
import {
copyAgentSessionContextFromPane,
prepareAgentSessionForkFromPane,
type PreparedAgentSessionFork
} from './terminal-agent-session-fork'
import { prepareAgentSessionContinuationFromPane } from './terminal-agent-session-continuation'
import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation'
export type TerminalPaneMenuAgentSessionContext = {
paneCwdRef: React.RefObject<PaneCwdMap>
tabId: string
worktreeId: string
groupId: string | null
fallbackCwd: string
onAgentSessionForkReady: (fork: PreparedAgentSessionFork) => void
onAgentSessionContinuationReady: (request: AgentSessionContinuationRequest) => void
}
export const forkAgentSessionFromMenuPane = async (
context: TerminalPaneMenuAgentSessionContext,
pane: ManagedPane | null
): Promise<void> => {
if (!pane) {
return
}
const { tabId, worktreeId, groupId } = context
const fork = prepareAgentSessionForkFromPane({ pane, tabId, worktreeId, groupId })
if (fork) {
context.onAgentSessionForkReady(fork)
}
}
export const continueAgentSessionFromMenuPane = (
context: TerminalPaneMenuAgentSessionContext,
pane: ManagedPane | null
): void => {
if (!pane) {
return
}
const { tabId, worktreeId, groupId, fallbackCwd } = context
const initialCwd = context.paneCwdRef.current.get(pane.id)?.cwd || fallbackCwd
const request = prepareAgentSessionContinuationFromPane({
pane,
tabId,
worktreeId,
groupId,
workspacePath: fallbackCwd,
initialCwd
})
if (request) {
context.onAgentSessionContinuationReady(request)
}
}
// Why: the captured session transcript is often wanted on its own — to paste
// into another tool — so copy the bounded transcript directly, without the
// fork prompt's framing or the fork dialog detour (issue #5020).
export const copyAgentSessionContextFromMenuPane = async (
pane: ManagedPane | null
): Promise<void> => {
if (!pane) {
return
}
await copyAgentSessionContextFromPane(pane)
}
@@ -0,0 +1,85 @@
import { toast } from 'sonner'
import type { ManagedPane } from '@/lib/pane-manager/pane-manager'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { translate } from '@/i18n/i18n'
import { copyTerminalHandleForPane } from './terminal-handle-copy'
import { runCopyPaneId, runTerminalCopy } from './terminal-copy-rejection-guards'
export const copyTerminalPaneMenuSelection = async (pane: ManagedPane | null): Promise<void> => {
if (!pane) {
return
}
await runTerminalCopy({
selection: pane.terminal.getSelection(),
writeClipboardText: window.api.ui.writeTerminalClipboardText,
// Why: Radix returns focus to the menu trigger (the pane container) on
// close, but xterm.js only accepts input when its own helper textarea is
// focused. Without this, the user has to click the pane again before
// typing works (see #592).
focus: () => pane.terminal.focus()
})
}
export const copyTerminalPaneMenuPaneId = async (
pane: ManagedPane | null,
tabId: string
): Promise<void> => {
if (!pane) {
return
}
await runCopyPaneId({
// Why: orchestration targets use ORCA_PANE_KEY, which survives renderer
// remounts; the numeric PaneManager id is only a local runtime handle.
paneKey: makePaneKey(tabId, pane.leafId),
writeClipboardText: window.api.ui.writeTerminalClipboardText,
onSuccess: () =>
toast.success(
translate(
'auto.components.terminal.pane.use.terminal.pane.context.menu.a29b9faa01',
'Pane ID copied'
)
),
// Why: claiming success after a failed write is the exact silent lie this
// fallback exists to remove, so report it the way Copy Terminal ID does.
onError: () =>
toast.error(
translate(
'auto.components.terminal.pane.use.terminal.pane.context.menu.pane.id.copy.failed',
'Unable to copy pane ID'
)
),
focus: () => pane.terminal.focus()
})
}
export const copyTerminalPaneMenuTerminalId = async (
pane: ManagedPane | null,
tabId: string
): Promise<void> => {
if (!pane) {
return
}
try {
await copyTerminalHandleForPane({
tabId,
leafId: pane.leafId,
callRuntime: window.api.runtime.call,
writeClipboardText: window.api.ui.writeTerminalClipboardText
})
toast.success(
translate(
'auto.components.terminal.pane.use.terminal.pane.context.menu.terminal.id.copied',
'Terminal ID copied'
)
)
} catch {
toast.error(
translate(
'auto.components.terminal.pane.use.terminal.pane.context.menu.terminal.id.copy.failed',
'Unable to copy terminal ID'
)
)
} finally {
pane.terminal.focus()
}
}
@@ -0,0 +1,156 @@
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import { getConnectionId } from '@/lib/connection-context'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { pasteTerminalText } from './terminal-bracketed-paste'
import { pasteTerminalClipboard } from './terminal-clipboard-paste'
import {
executeTerminalPastePlan,
planTerminalPasteWithYield,
type TerminalPasteSource,
type TerminalPasteTextOptions
} from './terminal-paste-coordinator'
import { formatTerminalPasteExecutionError } from './terminal-paste-errors'
import { resolveTerminalPasteRuntime } from './terminal-paste-runtime'
import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform'
import { isTerminalPanePasteTargetCurrent } from './terminal-paste-target-state'
import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer'
import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
import { useAppStore } from '@/store'
import { resolveProtectedMultilinePasteOptionsForPane } from './terminal-agent-paste-bracketing'
import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
export type TerminalPaneMenuPasteContext = {
managerRef: React.RefObject<PaneManager | null>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
tabId: string
worktreeId: string
forceBracketedMultilineTextPaste: boolean
onPasteError: (message: string) => void
}
export const getTerminalPaneMenuShortcutPlatform = (): NodeJS.Platform => {
if (navigator.userAgent.includes('Mac')) {
return 'darwin'
}
return navigator.userAgent.includes('Windows') ? 'win32' : 'linux'
}
const isPanePasteTargetMounted = (
context: TerminalPaneMenuPasteContext,
pane: ManagedPane,
transport: PtyTransport | undefined,
ptyId: string | null
): boolean => {
return isTerminalPanePasteTargetCurrent({
manager: context.managerRef.current,
paneTransports: context.paneTransportsRef.current,
paneId: pane.id,
leafId: pane.leafId,
transport,
ptyId
})
}
export const executeTerminalPaneMenuPasteText = async (
context: TerminalPaneMenuPasteContext,
pane: ManagedPane,
source: TerminalPasteSource,
text: string,
options?: TerminalPasteTextOptions
): Promise<boolean> => {
const connectionId = getConnectionId(context.worktreeId) ?? null
const transport = context.paneTransportsRef.current.get(pane.id)
const ptyId = transport?.getPtyId() ?? null
const shortcutPlatform = getTerminalPaneMenuShortcutPlatform()
const plan = await planTerminalPasteWithYield({
text,
source,
target: {
kind: 'terminal',
paneId: pane.id,
leafId: pane.leafId,
ptyId,
runtime: resolveTerminalPasteRuntime({
platform: shortcutPlatform,
ptyId,
connectionId,
remotePlatform: getTerminalPasteSshRemotePlatform(connectionId),
transport,
isWindowsConpty: context.forceBracketedMultilineTextPaste
})
},
forceBracketedPaste: options?.forceBracketedPaste,
forceBracketedPasteForMultiline: options?.forceBracketedPasteForMultiline,
windowsInputRecordNewline: options?.windowsInputRecordNewline,
terminalBracketedPasteMode: pane.terminal.modes.bracketedPasteMode
})
const execution = await executeTerminalPastePlan(plan, {
pasteText: (pasteText, pasteOptions) =>
pasteTerminalText(pane.terminal, pasteText, pasteOptions),
writePty: (data) => writeTerminalPastePtyInput(transport, data),
isTargetCurrent: () => isPanePasteTargetMounted(context, pane, transport, ptyId),
canContinue: () => isPanePasteTargetMounted(context, pane, transport, ptyId)
})
if (execution.status !== 'pasted') {
context.onPasteError(formatTerminalPasteExecutionError(execution.reason))
return false
}
if (text) {
recordTerminalUserInputForLeaf(context.tabId, pane.leafId)
}
if (options?.recoverImagePasteWebglAtlas) {
scheduleImagePasteWebglAtlasRecovery()
}
return true
}
export const pasteTerminalPaneMenuClipboard = async (
context: TerminalPaneMenuPasteContext,
pane: ManagedPane | null,
source: Extract<TerminalPasteSource, 'context-menu' | 'right-click'>
): Promise<void> => {
if (!pane) {
return
}
const { tabId, worktreeId, forceBracketedMultilineTextPaste, onPasteError } = context
const connectionId = getConnectionId(worktreeId) ?? null
const state = useAppStore.getState()
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
const transport = context.paneTransportsRef.current.get(pane.id) ?? null
const result = await pasteTerminalClipboard({
readClipboardText: window.api.ui.readClipboardText,
saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile,
connectionId,
runtimeEnvironmentId,
protectedMultilineTextPasteOptions: resolveProtectedMultilinePasteOptionsForPane({
isWindowsClient: forceBracketedMultilineTextPaste,
hostPlatform: resolveTerminalInputHostPlatform({
clientPlatform: getTerminalPaneMenuShortcutPlatform(),
state,
worktreeId,
transport
}),
agentStatusByPaneKey: state.agentStatusByPaneKey,
paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey,
tabId,
leafId: pane.leafId
}),
pasteText: (text, options) =>
executeTerminalPaneMenuPasteText(context, pane, source, text, options),
onTextPasteError: () =>
onPasteError('Paste failed: clipboard text is too large for a safe terminal paste.'),
onImagePasteError: (error) => {
const detail = error instanceof Error ? error.message : String(error)
onPasteError(`Image paste failed: ${detail}`)
}
})
if (result.status !== 'pasted') {
return
}
// Why: Radix returns focus to the menu trigger (the pane container) on
// close. Refocus only after a completed paste so rejected async targets
// do not steal focus from the user's new control.
pane.terminal.focus()
}
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { recordKeyboardCreatedTerminalPaneSplit } from './keyboard-handlers'
import { recordContextMenuCreatedTerminalPaneSplit } from './use-terminal-pane-context-menu'
import { recordContextMenuCreatedTerminalPaneSplit } from './use-terminal-pane-split-actions'
import { recordRuntimeCreatedTerminalPaneSplit } from './use-terminal-pane-lifecycle'
const mocks = vi.hoisted(() => ({
@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { TerminalPasteSource } from './terminal-paste-coordinator'
import { copyTerminalSelection } from './terminal-selection-copy'
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
type UseTerminalContextMenuTriggerDeps = {
managerRef: React.RefObject<PaneManager | null>
containerRef: React.RefObject<HTMLDivElement | null>
contextPaneIdRef: React.RefObject<number | null>
rightClickToPaste: boolean
pasteResolvedPane: (
source: Extract<TerminalPasteSource, 'context-menu' | 'right-click'>
) => Promise<void>
}
type TerminalContextMenuTrigger = {
open: boolean
setOpen: React.Dispatch<React.SetStateAction<boolean>>
point: { x: number; y: number }
menuOpenedAtRef: React.RefObject<number>
onContextMenuCapture: (event: React.MouseEvent<HTMLDivElement>) => void
onPaneTitleContextMenu: (event: React.MouseEvent<HTMLElement>, paneId: number) => void
}
export function useTerminalContextMenuTrigger({
managerRef,
containerRef,
contextPaneIdRef,
rightClickToPaste,
pasteResolvedPane
}: UseTerminalContextMenuTriggerDeps): TerminalContextMenuTrigger {
const menuOpenedAtRef = useRef(0)
const [open, setOpen] = useState(false)
const [point, setPoint] = useState({ x: 0, y: 0 })
useEffect(() => {
const closeMenu = (): void => {
if (Date.now() - menuOpenedAtRef.current < 100) {
return
}
setOpen(false)
}
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
const openContextMenu = (
event: React.MouseEvent<HTMLElement>,
clickedPaneId: number | null,
boundsElement: HTMLElement
): void => {
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
const manager = managerRef.current
if (!manager) {
contextPaneIdRef.current = null
return
}
const clickedPane =
clickedPaneId !== null
? (manager.getPanes().find((pane) => pane.id === clickedPaneId) ?? null)
: null
contextPaneIdRef.current = clickedPane?.id ?? null
// Why: when users opt into terminal-style right-click, a selection copies
// and no selection pastes. Ctrl+right-click keeps the app menu reachable.
if (rightClickToPaste && !event.ctrlKey) {
event.stopPropagation()
if (!clickedPane) {
return
}
if (clickedPane.terminal.getSelection()) {
void copyTerminalSelection({
terminal: clickedPane.terminal,
writeClipboardText: window.api.ui.writeTerminalClipboardText,
clearSelectionOnSuccess: true
}).catch(() => {
/* ignore clipboard write failures */
})
} else {
void pasteResolvedPane('right-click')
}
return
}
menuOpenedAtRef.current = Date.now()
const bounds = boundsElement.getBoundingClientRect()
setPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top })
setOpen(true)
}
const onContextMenuCapture = (event: React.MouseEvent<HTMLDivElement>): void => {
const manager = managerRef.current
if (!manager) {
event.preventDefault()
contextPaneIdRef.current = null
return
}
const target = event.target
if (!(target instanceof Node)) {
event.preventDefault()
contextPaneIdRef.current = null
return
}
const clickedPane = manager.getPanes().find((pane) => pane.container.contains(target)) ?? null
openContextMenu(event, clickedPane?.id ?? null, event.currentTarget)
}
const onPaneTitleContextMenu = (event: React.MouseEvent<HTMLElement>, paneId: number): void => {
const boundsElement = containerRef.current
if (!boundsElement) {
event.preventDefault()
return
}
openContextMenu(event, paneId, boundsElement)
}
return { open, setOpen, point, menuOpenedAtRef, onContextMenuCapture, onPaneTitleContextMenu }
}
@@ -1,64 +1,27 @@
/* eslint-disable max-lines -- Why: context-menu actions share pane refs, focus
* recovery, inherited-cwd split behavior, and agent-fork state in one hook. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { toast } from 'sonner'
import { useCallback, useRef } from 'react'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import { getConnectionId } from '@/lib/connection-context'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import type { PaneCwdMap } from './resolve-split-cwd'
import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types'
import { isTerminalAgentQuickCommand } from '../../../../shared/terminal-quick-commands'
import { sendTerminalQuickCommandToPane } from './terminal-quick-command-dispatch'
import { pasteTerminalText } from './terminal-bracketed-paste'
import { pasteTerminalClipboard } from './terminal-clipboard-paste'
import {
executeTerminalPastePlan,
planTerminalPasteWithYield,
type TerminalPasteSource,
type TerminalPasteTextOptions
} from './terminal-paste-coordinator'
import { formatTerminalPasteExecutionError } from './terminal-paste-errors'
import { resolveTerminalPasteRuntime } from './terminal-paste-runtime'
import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform'
import { isTerminalPanePasteTargetCurrent } from './terminal-paste-target-state'
import { writeTerminalPastePtyInput } from './terminal-pty-paste-writer'
import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-atlas-recovery'
import {
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
type RequestActiveTerminalPaneSplitDetail
} from '@/constants/terminal'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import type { TerminalPasteSource } from './terminal-paste-coordinator'
import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab'
import {
copyAgentSessionContextFromPane,
prepareAgentSessionForkFromPane,
type PreparedAgentSessionFork
} from './terminal-agent-session-fork'
import { prepareAgentSessionContinuationFromPane } from './terminal-agent-session-continuation'
import type { PreparedAgentSessionFork } from './terminal-agent-session-fork'
import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
import { resolveProtectedMultilinePasteOptionsForPane } from './terminal-agent-paste-bracketing'
import { resolveTerminalInputHostPlatform } from './terminal-input-host-platform'
import { translate } from '@/i18n/i18n'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import { copyTerminalHandleForPane } from './terminal-handle-copy'
import { runCopyPaneId, runTerminalCopy } from './terminal-copy-rejection-guards'
import { copyTerminalSelection } from './terminal-selection-copy'
const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus'
export function recordContextMenuCreatedTerminalPaneSplit(
createdPane: unknown,
args: {
source: 'contextual_tour' | 'context_menu'
direction: 'vertical' | 'horizontal'
}
): boolean {
return recordCreatedTerminalPaneSplit(createdPane, args)
}
import { pasteTerminalPaneMenuClipboard } from './terminal-pane-menu-paste'
import {
copyTerminalPaneMenuPaneId,
copyTerminalPaneMenuSelection,
copyTerminalPaneMenuTerminalId
} from './terminal-pane-menu-copy-actions'
import {
continueAgentSessionFromMenuPane,
copyAgentSessionContextFromMenuPane,
forkAgentSessionFromMenuPane
} from './terminal-pane-menu-agent-session-actions'
import { useTerminalPaneSplitActions } from './use-terminal-pane-split-actions'
import { useTerminalContextMenuTrigger } from './use-terminal-context-menu-trigger'
type UseTerminalPaneContextMenuDeps = {
managerRef: React.RefObject<PaneManager | null>
@@ -131,20 +94,6 @@ export function useTerminalPaneContextMenu({
rightClickToPaste
}: UseTerminalPaneContextMenuDeps): TerminalMenuState {
const contextPaneIdRef = useRef<number | null>(null)
const menuOpenedAtRef = useRef(0)
const [open, setOpen] = useState(false)
const [point, setPoint] = useState({ x: 0, y: 0 })
useEffect(() => {
const closeMenu = (): void => {
if (Date.now() - menuOpenedAtRef.current < 100) {
return
}
setOpen(false)
}
window.addEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu)
}, [])
const resolveMenuPane = useCallback((): ManagedPane | null => {
const manager = managerRef.current
@@ -159,22 +108,53 @@ export function useTerminalPaneContextMenu({
return manager.getActivePane() ?? panes[0] ?? null
}, [managerRef])
const onCopy = async (): Promise<void> => {
const pane = resolveMenuPane()
if (!pane) {
return
}
await runTerminalCopy({
selection: pane.terminal.getSelection(),
writeClipboardText: window.api.ui.writeTerminalClipboardText,
// Why: Radix returns focus to the menu trigger (the pane container) on
// close, but xterm.js only accepts input when its own helper textarea is
// focused. Without this, the user has to click the pane again before
// typing works (see #592).
focus: () => pane.terminal.focus()
const pasteResolvedPane = async (
source: Extract<TerminalPasteSource, 'context-menu' | 'right-click'>
): Promise<void> =>
pasteTerminalPaneMenuClipboard(
{
managerRef,
paneTransportsRef,
tabId,
worktreeId,
forceBracketedMultilineTextPaste,
onPasteError
},
resolveMenuPane(),
source
)
const { open, setOpen, point, menuOpenedAtRef, onContextMenuCapture, onPaneTitleContextMenu } =
useTerminalContextMenuTrigger({
managerRef,
containerRef,
contextPaneIdRef,
rightClickToPaste,
pasteResolvedPane
})
const { onSplitRight, onSplitDown } = useTerminalPaneSplitActions({
managerRef,
paneTransportsRef,
paneCwdRef,
contextPaneIdRef,
tabId,
fallbackCwd,
resolveMenuPane
})
const agentSessionContext = {
paneCwdRef,
tabId,
worktreeId,
groupId,
fallbackCwd,
onAgentSessionForkReady,
onAgentSessionContinuationReady
}
const onCopy = async (): Promise<void> => copyTerminalPaneMenuSelection(resolveMenuPane())
const onSelectAll = (): void => {
const pane = resolveMenuPane()
if (pane) {
@@ -183,231 +163,14 @@ export function useTerminalPaneContextMenu({
}
}
const onCopyPaneId = async (): Promise<void> => {
const pane = resolveMenuPane()
if (!pane) {
return
}
await runCopyPaneId({
// Why: orchestration targets use ORCA_PANE_KEY, which survives renderer
// remounts; the numeric PaneManager id is only a local runtime handle.
paneKey: makePaneKey(tabId, pane.leafId),
writeClipboardText: window.api.ui.writeTerminalClipboardText,
onSuccess: () =>
toast.success(
translate(
'auto.components.terminal.pane.use.terminal.pane.context.menu.a29b9faa01',
'Pane ID copied'
)
),
// Why: claiming success after a failed write is the exact silent lie this
// fallback exists to remove, so report it the way Copy Terminal ID does.
onError: () =>
toast.error(
translate(
'auto.components.terminal.pane.use.terminal.pane.context.menu.pane.id.copy.failed',
'Unable to copy pane ID'
)
),
focus: () => pane.terminal.focus()
})
}
const onCopyPaneId = async (): Promise<void> =>
copyTerminalPaneMenuPaneId(resolveMenuPane(), tabId)
const getShortcutPlatform = (): NodeJS.Platform => {
if (navigator.userAgent.includes('Mac')) {
return 'darwin'
}
return navigator.userAgent.includes('Windows') ? 'win32' : 'linux'
}
const isPanePasteTargetMounted = (
pane: ManagedPane,
transport: PtyTransport | undefined,
ptyId: string | null
): boolean => {
return isTerminalPanePasteTargetCurrent({
manager: managerRef.current,
paneTransports: paneTransportsRef.current,
paneId: pane.id,
leafId: pane.leafId,
transport,
ptyId
})
}
const executeMenuPasteText = async (
pane: ManagedPane,
source: TerminalPasteSource,
text: string,
options?: TerminalPasteTextOptions
): Promise<boolean> => {
const connectionId = getConnectionId(worktreeId) ?? null
const transport = paneTransportsRef.current.get(pane.id)
const ptyId = transport?.getPtyId() ?? null
const shortcutPlatform = getShortcutPlatform()
const plan = await planTerminalPasteWithYield({
text,
source,
target: {
kind: 'terminal',
paneId: pane.id,
leafId: pane.leafId,
ptyId,
runtime: resolveTerminalPasteRuntime({
platform: shortcutPlatform,
ptyId,
connectionId,
remotePlatform: getTerminalPasteSshRemotePlatform(connectionId),
transport,
isWindowsConpty: forceBracketedMultilineTextPaste
})
},
forceBracketedPaste: options?.forceBracketedPaste,
forceBracketedPasteForMultiline: options?.forceBracketedPasteForMultiline,
windowsInputRecordNewline: options?.windowsInputRecordNewline,
terminalBracketedPasteMode: pane.terminal.modes.bracketedPasteMode
})
const execution = await executeTerminalPastePlan(plan, {
pasteText: (pasteText, pasteOptions) =>
pasteTerminalText(pane.terminal, pasteText, pasteOptions),
writePty: (data) => writeTerminalPastePtyInput(transport, data),
isTargetCurrent: () => isPanePasteTargetMounted(pane, transport, ptyId),
canContinue: () => isPanePasteTargetMounted(pane, transport, ptyId)
})
if (execution.status !== 'pasted') {
onPasteError(formatTerminalPasteExecutionError(execution.reason))
return false
}
if (text) {
recordTerminalUserInputForLeaf(tabId, pane.leafId)
}
if (options?.recoverImagePasteWebglAtlas) {
scheduleImagePasteWebglAtlasRecovery()
}
return true
}
const onCopyTerminalId = async (): Promise<void> => {
const pane = resolveMenuPane()
if (!pane) {
return
}
try {
await copyTerminalHandleForPane({
tabId,
leafId: pane.leafId,
callRuntime: window.api.runtime.call,
writeClipboardText: window.api.ui.writeTerminalClipboardText
})
toast.success(
translate(
'auto.components.terminal.pane.use.terminal.pane.context.menu.terminal.id.copied',
'Terminal ID copied'
)
)
} catch {
toast.error(
translate(
'auto.components.terminal.pane.use.terminal.pane.context.menu.terminal.id.copy.failed',
'Unable to copy terminal ID'
)
)
} finally {
pane.terminal.focus()
}
}
const pasteResolvedPane = async (
source: Extract<TerminalPasteSource, 'context-menu' | 'right-click'>
): Promise<void> => {
const pane = resolveMenuPane()
if (!pane) {
return
}
const connectionId = getConnectionId(worktreeId) ?? null
const state = useAppStore.getState()
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktreeId)
const transport = paneTransportsRef.current.get(pane.id) ?? null
const result = await pasteTerminalClipboard({
readClipboardText: window.api.ui.readClipboardText,
saveClipboardImageAsTempFile: window.api.ui.saveClipboardImageAsTempFile,
connectionId,
runtimeEnvironmentId,
protectedMultilineTextPasteOptions: resolveProtectedMultilinePasteOptionsForPane({
isWindowsClient: forceBracketedMultilineTextPaste,
hostPlatform: resolveTerminalInputHostPlatform({
clientPlatform: getShortcutPlatform(),
state,
worktreeId,
transport
}),
agentStatusByPaneKey: state.agentStatusByPaneKey,
paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey,
tabId,
leafId: pane.leafId
}),
pasteText: (text, options) => executeMenuPasteText(pane, source, text, options),
onTextPasteError: () =>
onPasteError('Paste failed: clipboard text is too large for a safe terminal paste.'),
onImagePasteError: (error) => {
const detail = error instanceof Error ? error.message : String(error)
onPasteError(`Image paste failed: ${detail}`)
}
})
if (result.status !== 'pasted') {
return
}
// Why: Radix returns focus to the menu trigger (the pane container) on
// close. Refocus only after a completed paste so rejected async targets
// do not steal focus from the user's new control.
pane.terminal.focus()
}
const onCopyTerminalId = async (): Promise<void> =>
copyTerminalPaneMenuTerminalId(resolveMenuPane(), tabId)
const onPaste = async (): Promise<void> => pasteResolvedPane('context-menu')
const splitWithInheritedCwd = useCallback(
(
direction: 'vertical' | 'horizontal',
source: 'contextual_tour' | 'context_menu' = 'context_menu'
): void => {
const pane = resolveMenuPane()
const manager = managerRef.current
if (!pane || !manager) {
return
}
splitTerminalPaneWithInheritedCwd({
manager,
getManager: () => managerRef.current,
paneTransports: paneTransportsRef.current,
paneCwdMap: paneCwdRef.current,
fallbackCwd,
pane,
direction,
source
})
},
[fallbackCwd, managerRef, paneCwdRef, paneTransportsRef, resolveMenuPane]
)
const onSplitRight = (): void => splitWithInheritedCwd('vertical')
const onSplitDown = (): void => splitWithInheritedCwd('horizontal')
useEffect(() => {
const onRequestSplit = (event: Event): void => {
const detail = (event as CustomEvent<RequestActiveTerminalPaneSplitDetail>).detail
if (detail?.tabId && detail.tabId !== tabId) {
return
}
contextPaneIdRef.current = null
splitWithInheritedCwd(detail?.direction ?? 'vertical', getRequestedSplitTelemetrySource())
}
window.addEventListener(REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, onRequestSplit)
return () =>
window.removeEventListener(REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, onRequestSplit)
// splitWithInheritedCwd closes over live refs; re-registering keeps the
// tour action aligned with the current focused pane and fallback cwd.
}, [tabId, splitWithInheritedCwd])
const onEqualizePaneSizes = (): void => {
const pane = resolveMenuPane()
const manager = managerRef.current
@@ -432,46 +195,14 @@ export function useTerminalPaneContextMenu({
}
}
const onForkAgentSession = async (): Promise<void> => {
const pane = resolveMenuPane()
if (!pane) {
return
}
const fork = prepareAgentSessionForkFromPane({ pane, tabId, worktreeId, groupId })
if (fork) {
onAgentSessionForkReady(fork)
}
}
const onForkAgentSession = async (): Promise<void> =>
forkAgentSessionFromMenuPane(agentSessionContext, resolveMenuPane())
const onContinueAgentSessionInNewSession = (): void => {
const pane = resolveMenuPane()
if (!pane) {
return
}
const initialCwd = paneCwdRef.current.get(pane.id)?.cwd || fallbackCwd
const request = prepareAgentSessionContinuationFromPane({
pane,
tabId,
worktreeId,
groupId,
workspacePath: fallbackCwd,
initialCwd
})
if (request) {
onAgentSessionContinuationReady(request)
}
}
const onContinueAgentSessionInNewSession = (): void =>
continueAgentSessionFromMenuPane(agentSessionContext, resolveMenuPane())
// Why: the captured session transcript is often wanted on its own — to paste
// into another tool — so copy the bounded transcript directly, without the
// fork prompt's framing or the fork dialog detour (issue #5020).
const onCopyAgentSessionContext = async (): Promise<void> => {
const pane = resolveMenuPane()
if (!pane) {
return
}
await copyAgentSessionContextFromPane(pane)
}
const onCopyAgentSessionContext = async (): Promise<void> =>
copyAgentSessionContextFromMenuPane(resolveMenuPane())
const onQuickCommand = (command: TerminalQuickCommand, historyId: string): void => {
if (isTerminalAgentQuickCommand(command)) {
@@ -524,77 +255,6 @@ export function useTerminalPaneContextMenu({
}
}
const openContextMenu = (
event: React.MouseEvent<HTMLElement>,
clickedPaneId: number | null,
boundsElement: HTMLElement
): void => {
event.preventDefault()
window.dispatchEvent(new Event(CLOSE_ALL_CONTEXT_MENUS_EVENT))
const manager = managerRef.current
if (!manager) {
contextPaneIdRef.current = null
return
}
const clickedPane =
clickedPaneId !== null
? (manager.getPanes().find((pane) => pane.id === clickedPaneId) ?? null)
: null
contextPaneIdRef.current = clickedPane?.id ?? null
// Why: when users opt into terminal-style right-click, a selection copies
// and no selection pastes. Ctrl+right-click keeps the app menu reachable.
if (rightClickToPaste && !event.ctrlKey) {
event.stopPropagation()
if (!clickedPane) {
return
}
if (clickedPane.terminal.getSelection()) {
void copyTerminalSelection({
terminal: clickedPane.terminal,
writeClipboardText: window.api.ui.writeTerminalClipboardText,
clearSelectionOnSuccess: true
}).catch(() => {
/* ignore clipboard write failures */
})
} else {
void pasteResolvedPane('right-click')
}
return
}
menuOpenedAtRef.current = Date.now()
const bounds = boundsElement.getBoundingClientRect()
setPoint({ x: event.clientX - bounds.left, y: event.clientY - bounds.top })
setOpen(true)
}
const onContextMenuCapture = (event: React.MouseEvent<HTMLDivElement>): void => {
const manager = managerRef.current
if (!manager) {
event.preventDefault()
contextPaneIdRef.current = null
return
}
const target = event.target
if (!(target instanceof Node)) {
event.preventDefault()
contextPaneIdRef.current = null
return
}
const clickedPane = manager.getPanes().find((pane) => pane.container.contains(target)) ?? null
openContextMenu(event, clickedPane?.id ?? null, event.currentTarget)
}
const onPaneTitleContextMenu = (event: React.MouseEvent<HTMLElement>, paneId: number): void => {
const boundsElement = containerRef.current
if (!boundsElement) {
event.preventDefault()
return
}
openContextMenu(event, paneId, boundsElement)
}
// Why: PaneManager.getPanes() allocates public pane wrappers. Closed menus
// do not need pane counts or target identity, so avoid that work on every
// render across hundreds of mounted terminal tabs.
@@ -630,9 +290,3 @@ export function useTerminalPaneContextMenu({
runForPane
}
}
function getRequestedSplitTelemetrySource(): 'contextual_tour' | 'context_menu' {
return useAppStore.getState().activeContextualTourId === 'workspace-agent-sessions'
? 'contextual_tour'
: 'context_menu'
}
@@ -0,0 +1,97 @@
import { useCallback, useEffect } from 'react'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import type { PaneCwdMap } from './resolve-split-cwd'
import {
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT,
type RequestActiveTerminalPaneSplitDetail
} from '@/constants/terminal'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
export function recordContextMenuCreatedTerminalPaneSplit(
createdPane: unknown,
args: {
source: 'contextual_tour' | 'context_menu'
direction: 'vertical' | 'horizontal'
}
): boolean {
return recordCreatedTerminalPaneSplit(createdPane, args)
}
type UseTerminalPaneSplitActionsDeps = {
managerRef: React.RefObject<PaneManager | null>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
paneCwdRef: React.RefObject<PaneCwdMap>
contextPaneIdRef: React.RefObject<number | null>
tabId: string
fallbackCwd: string
resolveMenuPane: () => ManagedPane | null
}
type TerminalPaneSplitActions = {
onSplitRight: () => void
onSplitDown: () => void
}
export function useTerminalPaneSplitActions({
managerRef,
paneTransportsRef,
paneCwdRef,
contextPaneIdRef,
tabId,
fallbackCwd,
resolveMenuPane
}: UseTerminalPaneSplitActionsDeps): TerminalPaneSplitActions {
const splitWithInheritedCwd = useCallback(
(
direction: 'vertical' | 'horizontal',
source: 'contextual_tour' | 'context_menu' = 'context_menu'
): void => {
const pane = resolveMenuPane()
const manager = managerRef.current
if (!pane || !manager) {
return
}
splitTerminalPaneWithInheritedCwd({
manager,
getManager: () => managerRef.current,
paneTransports: paneTransportsRef.current,
paneCwdMap: paneCwdRef.current,
fallbackCwd,
pane,
direction,
source
})
},
[fallbackCwd, managerRef, paneCwdRef, paneTransportsRef, resolveMenuPane]
)
const onSplitRight = (): void => splitWithInheritedCwd('vertical')
const onSplitDown = (): void => splitWithInheritedCwd('horizontal')
useEffect(() => {
const onRequestSplit = (event: Event): void => {
const detail = (event as CustomEvent<RequestActiveTerminalPaneSplitDetail>).detail
if (detail?.tabId && detail.tabId !== tabId) {
return
}
contextPaneIdRef.current = null
splitWithInheritedCwd(detail?.direction ?? 'vertical', getRequestedSplitTelemetrySource())
}
window.addEventListener(REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, onRequestSplit)
return () =>
window.removeEventListener(REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT, onRequestSplit)
// splitWithInheritedCwd closes over live refs; re-registering keeps the
// tour action aligned with the current focused pane and fallback cwd.
}, [tabId, splitWithInheritedCwd, contextPaneIdRef])
return { onSplitRight, onSplitDown }
}
function getRequestedSplitTelemetrySource(): 'contextual_tour' | 'context_menu' {
return useAppStore.getState().activeContextualTourId === 'workspace-agent-sessions'
? 'contextual_tour'
: 'context_menu'
}
+2 -1
View File
@@ -13,7 +13,8 @@ import {
parseGitHubIssueOrPRLink,
normalizeGitHubLinkQuery
} from '@/lib/github-links'
import { activateAndRevealWorktree, type AgentStartedTelemetry } from '@/lib/worktree-activation'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload'
import { runBackgroundWorktreeCreation } from '@/lib/worktree-creation-flow'
import {
findPendingLinkedWorkItemCreationId,
@@ -6,7 +6,7 @@ import {
buildAgentStartupPlan,
type AgentStartupPlan
} from '@/lib/tui-agent-startup'
import type { AgentStartedTelemetry } from '@/lib/worktree-activation'
import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload'
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
import type { LaunchSource } from '../../../shared/telemetry-events'
import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery'
@@ -5,7 +5,7 @@ import {
resolveTuiAgentLaunchArgs,
resolveTuiAgentLaunchEnv
} from '../../../shared/tui-agent-launch-defaults'
import type { AgentStartedTelemetry } from '@/lib/worktree-activation'
import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload'
import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery'
import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume'
import type { GlobalSettings } from '../../../shared/global-settings-types'
@@ -0,0 +1,33 @@
import type { DragReorderCallbacks } from './pane-drag-reorder'
import type { PaneManagerHost } from './pane-manager-host'
import { applyDividerStyles, applyPaneOpacity, createDivider } from './pane-divider'
import { refitPanesUnder, safeFit } from './pane-tree-ops'
export function createManagedPaneDivider(host: PaneManagerHost, isVertical: boolean): HTMLElement {
return createDivider(isVertical, host.getStyleOptions(), {
refitPanesUnder: (el) => refitPanesUnder(el, host.panes),
onLayoutChanged: host.options.onLayoutChanged,
onDragActiveChange: host.options.onPaneDragActiveChange
})
}
export function createPaneDragCallbacks(host: PaneManagerHost): DragReorderCallbacks {
return {
getPanes: () => host.panes,
getRoot: () => host.root,
getStyleOptions: () => host.getStyleOptions(),
isDestroyed: () => host.isDestroyed(),
safeFit,
applyPaneOpacity: () =>
applyPaneOpacity(host.panes.values(), host.getActivePaneId(), host.getStyleOptions()),
applyDividerStyles: () => applyDividerStyles(host.root, host.getStyleOptions()),
refitPanesUnder: (el: HTMLElement) => refitPanesUnder(el, host.panes),
requestPaneReparentFrame: (callback: FrameRequestCallback) => {
host.requestPaneReparentFrame(callback)
},
onLayoutChanged: host.options.onLayoutChanged,
onDragActiveChange: host.options.onPaneDragActiveChange,
resolveExternalDropTarget: host.options.resolveExternalPaneDropTarget,
onExternalPaneDrop: host.options.onExternalPaneDrop
}
}
@@ -0,0 +1,33 @@
import type {
ManagedPaneInternal,
PaneManagerOptions,
PaneStyleOptions
} from './pane-manager-types'
import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder'
import type { PaneIdentityRegistry } from './pane-identity-registry'
/** Accessor bundle the extracted PaneManager collaborators call back into.
* Why getters for activePaneId/styleOptions: both are reassigned during the
* manager's life, so capturing their values here would freeze stale state. */
export type PaneManagerHost = {
panes: Map<number, ManagedPaneInternal>
root: HTMLElement
identities: PaneIdentityRegistry
dragState: DragReorderState
options: PaneManagerOptions
getActivePaneId: () => number | null
getStyleOptions: () => PaneStyleOptions
isDestroyed: () => boolean
isRenderingSuspended: () => boolean
allocatePaneId: () => number
createPaneInternal: (leafIdHint?: string) => ManagedPaneInternal
createDivider: (isVertical: boolean) => HTMLElement
publishPaneCreated: (
pane: ManagedPaneInternal,
spawnHints?: Parameters<NonNullable<PaneManagerOptions['onPaneCreated']>>[1]
) => void
getDragCallbacks: () => DragReorderCallbacks
setActivePane: (paneId: number, opts?: { focus?: boolean }) => void
setActivePaneId: (paneId: number | null) => void
requestPaneReparentFrame: (callback: FrameRequestCallback) => void
}
@@ -0,0 +1,42 @@
import type { ManagedPaneInternal } from './pane-manager-types'
import { equalizePaneSplitSizes } from './pane-tree-ops'
import { fitRevealedPane } from './pane-reveal-fit'
// Why: a raw synchronous fit on reveal can apply a transient DOM<->WebGL
// cell-metric grid and reflow-garble diff-painting inline TUIs; see fitRevealedPane.
export function fitRevealedPanes(panes: Map<number, ManagedPaneInternal>): void {
for (const pane of panes.values()) {
fitRevealedPane(pane)
}
}
export function refreshAllPaneTerminals(panes: Map<number, ManagedPaneInternal>): void {
for (const pane of panes.values()) {
try {
if (pane.terminal.rows > 0) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
}
} catch {
// Why: restore-all repaint is best-effort while panes are mounting or tearing down.
}
}
}
export function equalizeManagedPaneSizes(
panes: Map<number, ManagedPaneInternal>,
root: HTMLElement,
onLayoutChanged?: () => void
): void {
if (panes.size < 2) {
return
}
const changed = equalizePaneSplitSizes(
root.firstElementChild instanceof HTMLElement ? root.firstElementChild : null
)
if (!changed) {
return
}
onLayoutChanged?.()
}
@@ -0,0 +1,89 @@
import type { ManagedPane, ManagedPaneInternal, PaneManagerOptions } from './pane-manager-types'
import type { PaneManagerHost } from './pane-manager-host'
import { applyPaneOpacity } from './pane-divider'
import { createPaneDOM, openTerminal } from './pane-lifecycle'
import { shouldFollowMouseFocus } from './focus-follows-mouse'
import { toPublicPane } from './pane-public-view'
export function createInitialManagedPane(
host: PaneManagerHost,
opts?: { focus?: boolean; leafId?: string }
): ManagedPane {
const pane = host.createPaneInternal(opts?.leafId)
Object.assign(pane.container.style, {
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden'
})
host.root.appendChild(pane.container)
openTerminal(pane)
host.setActivePaneId(pane.id)
applyPaneOpacity(host.panes.values(), host.getActivePaneId(), host.getStyleOptions())
if (opts?.focus !== false) {
pane.terminal.focus()
}
host.publishPaneCreated(pane)
return toPublicPane(pane)
}
export function createManagedPaneInternal(
host: PaneManagerHost,
leafIdHint?: string
): ManagedPaneInternal {
const id = host.allocatePaneId()
const leafId = host.identities.claimLeafId(leafIdHint)
const pane = createPaneDOM(
id,
leafId,
host.options,
host.dragState,
host.getDragCallbacks(),
// Why: always re-focus even if already active — after splits the
// browser's real textarea focus can lag the manager's activePaneId.
(paneId, options) => {
if (!host.isDestroyed()) {
host.setActivePane(paneId, { focus: options?.focusTerminal !== false })
}
},
(paneId, event) => {
handleManagedPaneMouseEnter(host, paneId, event)
}
)
pane.webglAttachmentDeferred = host.isRenderingSuspended()
host.panes.set(id, pane)
host.identities.register(id, leafId)
return pane
}
export function publishManagedPaneCreated(
host: PaneManagerHost,
pane: ManagedPaneInternal,
spawnHints?: Parameters<NonNullable<PaneManagerOptions['onPaneCreated']>>[1]
): void {
// Why: onPaneCreated wires PTY/status identity synchronously. After this
// point, replacing the leaf id would fork ORCA_PANE_KEY from layout state.
host.identities.markPublished(pane.id)
void host.options.onPaneCreated?.(toPublicPane(pane), spawnHints)
}
function handleManagedPaneMouseEnter(
host: PaneManagerHost,
paneId: number,
event: MouseEvent
): void {
if (
shouldFollowMouseFocus({
featureEnabled: host.getStyleOptions().focusFollowsMouse ?? false,
activePaneId: host.getActivePaneId(),
hoveredPaneId: paneId,
mouseButtons: event.buttons,
windowHasFocus: document.hasFocus(),
managerDestroyed: host.isDestroyed()
})
) {
host.setActivePane(paneId, { focus: true })
}
}
@@ -0,0 +1,111 @@
import type { ManagedPane } from './pane-manager-types'
import type { PaneManagerHost } from './pane-manager-host'
import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split'
import {
closeManagedPane,
detachManagedPaneForExternalMove,
retireManagedPanePreservingPty,
splitManagedPane
} from './pane-split-close'
import { splitPaneAroundMountedSubtree } from './pane-subtree-split'
export function splitPaneOnManager(
host: PaneManagerHost,
paneId: number,
direction: 'vertical' | 'horizontal',
opts?: { ratio?: number; cwd?: string; leafId?: string; ptyId?: string }
): ManagedPane | null {
return splitManagedPane({
paneId,
direction,
opts,
panes: host.panes,
root: host.root,
styleOptions: host.getStyleOptions(),
managerOptions: host.options,
createPaneInternal: (leafIdHint) => host.createPaneInternal(leafIdHint),
createDivider: (isVertical) => host.createDivider(isVertical),
publishPaneCreated: (pane, spawnHints) => host.publishPaneCreated(pane, spawnHints),
getDragCallbacks: () => host.getDragCallbacks(),
setActivePaneId: (id) => {
host.setActivePaneId(id)
},
isDestroyed: () => host.isDestroyed()
})
}
export function splitPaneAroundLeafIdsOnManager(
host: PaneManagerHost,
sourceLeafIds: readonly string[],
fallbackPaneId: number,
direction: 'vertical' | 'horizontal',
opts?: SplitPaneAroundLeafIdsOptions
): ManagedPane | null {
return splitPaneAroundMountedSubtree({
sourceLeafIds,
fallbackPaneId,
direction,
opts,
panes: host.panes,
root: host.root,
styleOptions: host.getStyleOptions(),
managerOptions: host.options,
getNumericIdForLeaf: (leafId) => host.identities.getNumericIdForLeaf(leafId),
createPaneInternal: (leafIdHint) => host.createPaneInternal(leafIdHint),
createDivider: (isVertical) => host.createDivider(isVertical),
publishPaneCreated: (pane, spawnHints) => host.publishPaneCreated(pane, spawnHints),
getDragCallbacks: () => host.getDragCallbacks(),
setActivePaneId: (id) => {
host.setActivePaneId(id)
},
isDestroyed: () => host.isDestroyed()
})
}
export function closePaneOnManager(host: PaneManagerHost, paneId: number): void {
closeManagedPane({
paneId,
activePaneId: host.getActivePaneId(),
panes: host.panes,
root: host.root,
styleOptions: host.getStyleOptions(),
managerOptions: host.options,
getDragCallbacks: () => host.getDragCallbacks(),
releasePaneIdentity: (numericPaneId) => host.identities.release(numericPaneId),
setActivePaneId: (id) => {
host.setActivePaneId(id)
}
})
}
export function detachPaneForExternalMoveOnManager(host: PaneManagerHost, paneId: number): boolean {
return detachManagedPaneForExternalMove({
paneId,
activePaneId: host.getActivePaneId(),
panes: host.panes,
root: host.root,
styleOptions: host.getStyleOptions(),
managerOptions: host.options,
getDragCallbacks: () => host.getDragCallbacks(),
releasePaneIdentity: (numericPaneId) => host.identities.release(numericPaneId),
setActivePaneId: (id) => {
host.setActivePaneId(id)
}
})
}
export function retirePanePreservingPtyOnManager(host: PaneManagerHost, paneId: number): boolean {
return retireManagedPanePreservingPty({
paneId,
activePaneId: host.getActivePaneId(),
panes: host.panes,
root: host.root,
styleOptions: host.getStyleOptions(),
managerOptions: host.options,
getDragCallbacks: () => host.getDragCallbacks(),
releasePaneIdentity: (numericPaneId) => host.identities.release(numericPaneId),
setActivePaneId: (id) => {
host.setActivePaneId(id)
}
})
}
+75 -262
View File
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Why: PaneManager keeps live pane lifecycle, drag, rendering, and identity callbacks under one owner. */
import type {
PaneManagerOptions,
PaneStyleOptions,
@@ -11,8 +10,8 @@ import type {
PaneExternalDropTarget
} from './pane-manager-types'
import type { SplitPaneAroundLeafIdsOptions } from './pane-subtree-split'
import type { PaneManagerHost } from './pane-manager-host'
import {
createDivider,
applyDividerStyles,
applyPaneOpacity,
applyRootBackground,
@@ -20,16 +19,9 @@ import {
} from './pane-divider'
import { cancelActivePaneDrag, createDragReorderState, handlePaneDrop } from './pane-drag-reorder'
import { beginPaneDragFromPointerDown } from './pane-drag-pointer'
import { createPaneDOM, openTerminal, setLigaturesEnabled, disposePane } from './pane-lifecycle'
import { shouldFollowMouseFocus } from './focus-follows-mouse'
import { getTerminalWebglAutoDecision } from './terminal-webgl-auto-policy'
import {
equalizePaneSplitSizes,
safeFit,
fitAllPanesInternal,
refitPanesUnder
} from './pane-tree-ops'
import { toPublicPane } from './pane-public-view'
import { setLigaturesEnabled, disposePane } from './pane-lifecycle'
import { fitAllPanesInternal } from './pane-tree-ops'
import { collectPublicPanes, toPublicPane } from './pane-public-view'
import { applyTerminalGpuAcceleration } from './pane-terminal-gpu-acceleration'
import { rebuildAttachedWebgl } from './pane-webgl-reattach'
import {
@@ -43,16 +35,28 @@ import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry'
import { releaseHiddenWebglRetention } from './terminal-webgl-hidden-retention'
import { schedulePaneRevealPresent, schedulePaneRevealRepaint } from './pane-reveal-repaint'
import { fitRevealedPane } from './pane-reveal-fit'
import { PaneIdentityRegistry } from './pane-identity-registry'
import { PaneReparentFrameTracker } from './pane-reparent-frame-tracker'
import {
closeManagedPane,
detachManagedPaneForExternalMove,
retireManagedPanePreservingPty,
splitManagedPane
} from './pane-split-close'
closePaneOnManager,
detachPaneForExternalMoveOnManager,
retirePanePreservingPtyOnManager,
splitPaneAroundLeafIdsOnManager,
splitPaneOnManager
} from './pane-manager-tree-mutations'
import {
createInitialManagedPane,
createManagedPaneInternal,
publishManagedPaneCreated
} from './pane-manager-pane-creation'
import { createManagedPaneDivider, createPaneDragCallbacks } from './pane-manager-drag-wiring'
import {
equalizeManagedPaneSizes,
fitRevealedPanes,
refreshAllPaneTerminals
} from './pane-manager-layout-sweeps'
import { collectPaneRenderingDiagnostics } from './pane-rendering-diagnostics'
import { FIRST_PANE_ID } from '../../../../shared/pane-key'
import { splitPaneAroundMountedSubtree } from './pane-subtree-split'
export type {
PaneManagerOptions,
@@ -75,40 +79,51 @@ export class PaneManager {
private renderingSuspended: boolean
private atlasRecoveryVisible: boolean
private identities = new PaneIdentityRegistry()
private pendingPaneReparentFrameIds = new Set<number>()
private reparentFrames = new PaneReparentFrameTracker(() => this.destroyed)
// Drag-to-reorder state
private dragState = createDragReorderState()
private host: PaneManagerHost
constructor(root: HTMLElement, options: PaneManagerOptions) {
this.root = root
this.options = options
this.renderingSuspended = options.initialRenderingSuspended === true
this.atlasRecoveryVisible = !this.renderingSuspended
this.host = {
panes: this.panes,
root: this.root,
identities: this.identities,
dragState: this.dragState,
options: this.options,
getActivePaneId: () => this.activePaneId,
getStyleOptions: () => this.styleOptions,
isDestroyed: () => this.destroyed,
isRenderingSuspended: () => this.renderingSuspended,
allocatePaneId: () => this.nextPaneId++,
createPaneInternal: (leafIdHint) => createManagedPaneInternal(this.host, leafIdHint),
createDivider: (isVertical) => createManagedPaneDivider(this.host, isVertical),
publishPaneCreated: (pane, spawnHints) =>
publishManagedPaneCreated(this.host, pane, spawnHints),
getDragCallbacks: () => createPaneDragCallbacks(this.host),
setActivePane: (paneId, opts) => {
this.setActivePane(paneId, opts)
},
setActivePaneId: (paneId) => {
this.activePaneId = paneId
},
requestPaneReparentFrame: (callback) => {
this.reparentFrames.request(callback)
}
}
// Why: atlas recovery must reach every live manager — see
// resetAllTerminalWebglAtlases for the shared-atlas rationale.
registerLivePaneManager(this)
}
createInitialPane(opts?: { focus?: boolean; leafId?: string }): ManagedPane {
const pane = this.createPaneInternal(opts?.leafId)
Object.assign(pane.container.style, {
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden'
})
this.root.appendChild(pane.container)
openTerminal(pane)
this.activePaneId = pane.id
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions)
if (opts?.focus !== false) {
pane.terminal.focus()
}
this.publishPaneCreated(pane)
return toPublicPane(pane)
return createInitialManagedPane(this.host, opts)
}
splitPane(
@@ -116,23 +131,7 @@ export class PaneManager {
direction: 'vertical' | 'horizontal',
opts?: { ratio?: number; cwd?: string; leafId?: string; ptyId?: string }
): ManagedPane | null {
return splitManagedPane({
paneId,
direction,
opts,
panes: this.panes,
root: this.root,
styleOptions: this.styleOptions,
managerOptions: this.options,
createPaneInternal: (leafIdHint) => this.createPaneInternal(leafIdHint),
createDivider: (isVertical) => this.createDividerWrapped(isVertical),
publishPaneCreated: (pane, spawnHints) => this.publishPaneCreated(pane, spawnHints),
getDragCallbacks: () => this.getDragCallbacks(),
setActivePaneId: (id) => {
this.activePaneId = id
},
isDestroyed: () => this.destroyed
})
return splitPaneOnManager(this.host, paneId, direction, opts)
}
splitPaneAroundLeafIds(
@@ -141,84 +140,29 @@ export class PaneManager {
direction: 'vertical' | 'horizontal',
opts?: SplitPaneAroundLeafIdsOptions
): ManagedPane | null {
return splitPaneAroundMountedSubtree({
return splitPaneAroundLeafIdsOnManager(
this.host,
sourceLeafIds,
fallbackPaneId,
direction,
opts,
panes: this.panes,
root: this.root,
styleOptions: this.styleOptions,
managerOptions: this.options,
getNumericIdForLeaf: (leafId) => this.identities.getNumericIdForLeaf(leafId),
createPaneInternal: (leafIdHint) => this.createPaneInternal(leafIdHint),
createDivider: (isVertical) => this.createDividerWrapped(isVertical),
publishPaneCreated: (pane, spawnHints) => this.publishPaneCreated(pane, spawnHints),
getDragCallbacks: () => this.getDragCallbacks(),
setActivePaneId: (id) => {
this.activePaneId = id
},
isDestroyed: () => this.destroyed
})
opts
)
}
closePane(paneId: number): void {
closeManagedPane({
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
}
})
closePaneOnManager(this.host, paneId)
}
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
}
})
return detachPaneForExternalMoveOnManager(this.host, paneId)
}
retirePanePreservingPty(paneId: number): boolean {
return retireManagedPanePreservingPty({
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
}
})
return retirePanePreservingPtyOnManager(this.host, paneId)
}
getPanes(limit = Number.POSITIVE_INFINITY): ManagedPane[] {
const panes: ManagedPane[] = []
for (const pane of this.panes.values()) {
if (panes.length >= limit) {
break
}
panes.push(toPublicPane(pane))
}
return panes
return collectPublicPanes(this.panes, limit)
}
/** Why separate from getPanes: the census runs on the crash path, where
@@ -231,39 +175,16 @@ export class PaneManager {
fitAllPanesInternal(this.panes)
}
// Why: a raw synchronous fit on reveal can apply a transient DOM<->WebGL
// cell-metric grid and reflow-garble diff-painting inline TUIs; see fitRevealedPane.
fitAllRevealedPanes(): void {
for (const pane of this.panes.values()) {
fitRevealedPane(pane)
}
fitRevealedPanes(this.panes)
}
refreshAllPanes(): void {
for (const pane of this.panes.values()) {
try {
if (pane.terminal.rows > 0) {
pane.terminal.refresh(0, pane.terminal.rows - 1)
}
} catch {
// Why: restore-all repaint is best-effort while panes are mounting or tearing down.
}
}
refreshAllPaneTerminals(this.panes)
}
equalizePaneSizes(): void {
if (this.panes.size < 2) {
return
}
const changed = equalizePaneSplitSizes(
this.root.firstElementChild instanceof HTMLElement ? this.root.firstElementChild : null
)
if (!changed) {
return
}
this.options.onLayoutChanged?.()
equalizeManagedPaneSizes(this.panes, this.root, this.options.onLayoutChanged)
}
getActivePane(): ManagedPane | null {
@@ -275,17 +196,7 @@ export class PaneManager {
}
getRenderingDiagnostics(): PaneRenderingDiagnostics[] {
return Array.from(this.panes.values()).map((pane) => ({
paneId: pane.id,
terminalGpuAcceleration: pane.terminalGpuAcceleration,
gpuRenderingEnabled: pane.gpuRenderingEnabled,
webglAttachmentDeferred: pane.webglAttachmentDeferred,
webglDisabledAfterContextLoss: pane.webglDisabledAfterContextLoss,
webglAttachFailedSinceRecovery: pane.webglAttachFailedSinceRecovery === true,
hasComplexScriptOutput: pane.hasComplexScriptOutput,
terminalWebglAutoDecision: getTerminalWebglAutoDecision(),
hasWebgl: Boolean(pane.webglAddon)
}))
return collectPaneRenderingDiagnostics(this.panes)
}
hasWebglRenderer(paneId: number): boolean {
@@ -404,11 +315,17 @@ export class PaneManager {
}
movePane(sourcePaneId: number, targetPaneId: number, zone: DropZone): void {
handlePaneDrop(sourcePaneId, targetPaneId, zone, this.dragState, this.getDragCallbacks())
handlePaneDrop(sourcePaneId, targetPaneId, zone, this.dragState, this.host.getDragCallbacks())
}
beginPaneDragFromPointerDown(paneId: number, handle: HTMLElement, event: PointerEvent): void {
beginPaneDragFromPointerDown(handle, paneId, this.dragState, this.getDragCallbacks(), event)
beginPaneDragFromPointerDown(
handle,
paneId,
this.dragState,
this.host.getDragCallbacks(),
event
)
}
destroy(): void {
@@ -416,7 +333,7 @@ export class PaneManager {
unregisterLivePaneManager(this)
releaseHiddenWebglRetention(this)
cancelActivePaneDrag(this.dragState)
this.cancelPendingPaneReparentFrames()
this.reparentFrames.cancelPending()
for (const pane of this.panes.values()) {
disposePane(pane, this.panes)
}
@@ -425,108 +342,4 @@ export class PaneManager {
this.root.innerHTML = ''
this.activePaneId = null
}
private createPaneInternal(leafIdHint?: string): ManagedPaneInternal {
const id = this.nextPaneId++
const leafId = this.identities.claimLeafId(leafIdHint)
const pane = createPaneDOM(
id,
leafId,
this.options,
this.dragState,
this.getDragCallbacks(),
// Why: always re-focus even if already active — after splits the
// browser's real textarea focus can lag the manager's activePaneId.
(paneId, options) => {
if (!this.destroyed) {
this.setActivePane(paneId, { focus: options?.focusTerminal !== false })
}
},
(paneId, event) => {
this.handlePaneMouseEnter(paneId, event)
}
)
pane.webglAttachmentDeferred = this.renderingSuspended
this.panes.set(id, pane)
this.identities.register(id, leafId)
return pane
}
private publishPaneCreated(
pane: ManagedPaneInternal,
spawnHints?: Parameters<NonNullable<PaneManagerOptions['onPaneCreated']>>[1]
): void {
// Why: onPaneCreated wires PTY/status identity synchronously. After this
// point, replacing the leaf id would fork ORCA_PANE_KEY from layout state.
this.identities.markPublished(pane.id)
void this.options.onPaneCreated?.(toPublicPane(pane), spawnHints)
}
private handlePaneMouseEnter(paneId: number, event: MouseEvent): void {
if (
shouldFollowMouseFocus({
featureEnabled: this.styleOptions.focusFollowsMouse ?? false,
activePaneId: this.activePaneId,
hoveredPaneId: paneId,
mouseButtons: event.buttons,
windowHasFocus: document.hasFocus(),
managerDestroyed: this.destroyed
})
) {
this.setActivePane(paneId, { focus: true })
}
}
private createDividerWrapped(isVertical: boolean): HTMLElement {
return createDivider(isVertical, this.styleOptions, {
refitPanesUnder: (el) => refitPanesUnder(el, this.panes),
onLayoutChanged: this.options.onLayoutChanged,
onDragActiveChange: this.options.onPaneDragActiveChange
})
}
private getDragCallbacks() {
return {
getPanes: () => this.panes,
getRoot: () => this.root,
getStyleOptions: () => this.styleOptions,
isDestroyed: () => this.destroyed,
safeFit,
applyPaneOpacity: () =>
applyPaneOpacity(this.panes.values(), this.activePaneId, this.styleOptions),
applyDividerStyles: () => applyDividerStyles(this.root, this.styleOptions),
refitPanesUnder: (el: HTMLElement) => refitPanesUnder(el, this.panes),
requestPaneReparentFrame: (callback: FrameRequestCallback) => {
this.requestPaneReparentFrame(callback)
},
onLayoutChanged: this.options.onLayoutChanged,
onDragActiveChange: this.options.onPaneDragActiveChange,
resolveExternalDropTarget: this.options.resolveExternalPaneDropTarget,
onExternalPaneDrop: this.options.onExternalPaneDrop
}
}
private requestPaneReparentFrame(callback: FrameRequestCallback): void {
let completed = false
let frameId: number | undefined
frameId = requestAnimationFrame((timestamp) => {
completed = true
if (frameId !== undefined) {
this.pendingPaneReparentFrameIds.delete(frameId)
}
if (!this.destroyed) {
callback(timestamp)
}
})
if (!completed) {
this.pendingPaneReparentFrameIds.add(frameId)
}
}
private cancelPendingPaneReparentFrames(): void {
for (const frameId of this.pendingPaneReparentFrameIds) {
cancelAnimationFrame(frameId)
}
this.pendingPaneReparentFrameIds.clear()
}
}
@@ -13,3 +13,17 @@ export function toPublicPane(pane: ManagedPaneInternal): ManagedPane {
serializeAddon: pane.serializeAddon
}
}
export function collectPublicPanes(
panes: Map<number, ManagedPaneInternal>,
limit: number
): ManagedPane[] {
const collected: ManagedPane[] = []
for (const pane of panes.values()) {
if (collected.length >= limit) {
break
}
collected.push(toPublicPane(pane))
}
return collected
}
@@ -0,0 +1,18 @@
import type { ManagedPaneInternal, PaneRenderingDiagnostics } from './pane-manager-types'
import { getTerminalWebglAutoDecision } from './terminal-webgl-auto-policy'
export function collectPaneRenderingDiagnostics(
panes: Map<number, ManagedPaneInternal>
): PaneRenderingDiagnostics[] {
return Array.from(panes.values()).map((pane) => ({
paneId: pane.id,
terminalGpuAcceleration: pane.terminalGpuAcceleration,
gpuRenderingEnabled: pane.gpuRenderingEnabled,
webglAttachmentDeferred: pane.webglAttachmentDeferred,
webglDisabledAfterContextLoss: pane.webglDisabledAfterContextLoss,
webglAttachFailedSinceRecovery: pane.webglAttachFailedSinceRecovery === true,
hasComplexScriptOutput: pane.hasComplexScriptOutput,
terminalWebglAutoDecision: getTerminalWebglAutoDecision(),
hasWebgl: Boolean(pane.webglAddon)
}))
}
@@ -0,0 +1,31 @@
/** Tracks the animation frames issued for drag-reparent work so destroy() can
* cancel every still-pending one, and skips the callback once the owner died. */
export class PaneReparentFrameTracker {
private pendingPaneReparentFrameIds = new Set<number>()
constructor(private readonly isDestroyed: () => boolean) {}
request(callback: FrameRequestCallback): void {
let completed = false
let frameId: number | undefined
frameId = requestAnimationFrame((timestamp) => {
completed = true
if (frameId !== undefined) {
this.pendingPaneReparentFrameIds.delete(frameId)
}
if (!this.isDestroyed()) {
callback(timestamp)
}
})
if (!completed) {
this.pendingPaneReparentFrameIds.add(frameId)
}
}
cancelPending(): void {
for (const frameId of this.pendingPaneReparentFrameIds) {
cancelAnimationFrame(frameId)
}
this.pendingPaneReparentFrameIds.clear()
}
}
@@ -11,7 +11,7 @@ import type {
WorkspaceStatus
} from '../../../shared/worktree/types'
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import type { AgentStartedTelemetry } from '@/lib/worktree-activation'
import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload'
import type { TaskSourceContext, WorkspaceRunContext } from '../../../shared/task-source-context'
/** Two-phase status reported by the main process while a worktree is created.
@@ -0,0 +1,60 @@
import { useAppStore } from '@/store'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import {
createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive,
isWebTerminalSurfaceTabId
} from '@/runtime/web-runtime-session'
import { getLastKnownHostTerminalTabCount } from '@/runtime/web-session-tabs-sync'
import {
beginWebRuntimeWakeTerminalRespawn,
endWebRuntimeWakeTerminalRespawn
} from '@/runtime/web-runtime-wake-terminal-respawn'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): void {
const state = useAppStore.getState()
const worktree = state.getKnownWorktreeById(worktreeId)
if (!worktree) {
return
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktree.id)
if (!runtimeEnvironmentId || !isWebRuntimeSessionActive(runtimeEnvironmentId)) {
return
}
const tabs = state.tabsByWorktree[worktreeId] ?? []
const hasLivePty = tabs.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id))
if (hasLivePty) {
return
}
const hasMirroredHostTabs = tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id))
if (hasMirroredHostTabs) {
// Why: the host session still owns these tabs — wait for the mirror to repopulate PTY handles instead of duplicating a terminal.
return
}
if (getLastKnownHostTerminalTabCount(runtimeEnvironmentId, worktreeId) > 0) {
return
}
const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId)
if (tabs.length > 0 && renderableTabCount === 0) {
return
}
if (!beginWebRuntimeWakeTerminalRespawn(worktreeId)) {
return
}
// Why: sleep keeps tab rows but terminal.stop clears host PTYs, so a woke workspace can have tab chrome but no surface.
void createWebRuntimeSessionTerminal({
worktreeId,
environmentId: runtimeEnvironmentId,
activate: true,
selectWorktree: false
}).finally(() => {
endWebRuntimeWakeTerminalRespawn(worktreeId)
})
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { ensureWorktreeHasInitialTerminal } from './worktree-activation'
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
import {
createMockStore,
registerWorktreeActivationReset
@@ -1,10 +1,8 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getDefaultSettings } from '../../../shared/constants'
import { useAppStore } from '@/store'
import {
activateAndRevealWorktree,
ensureWebRuntimeWorktreeTerminalAfterWake
} from './worktree-activation'
import { activateAndRevealWorktree } from './worktree-activation'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from './web-runtime-worktree-terminal-after-wake'
import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync'
import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn'
import {
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { ensureWorktreeHasInitialTerminal } from './worktree-activation'
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
import {
createMockStore,
registerWorktreeActivationReset
@@ -5,7 +5,7 @@ import type { Worktree } from '../../../shared/worktree/types'
import { resetWebRuntimeWakeTerminalRespawnForTests } from '@/runtime/web-runtime-wake-terminal-respawn'
import { resetWebSessionTabsSnapshotFreshnessForTests } from '@/runtime/web-session-tabs-sync'
import { useAppStore } from '@/store'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from './worktree-activation'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from './web-runtime-worktree-terminal-after-wake'
const initialAppStoreState = useAppStore.getState()
const WORKTREE_PATH = path.join('workspace', 'feature')
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { ensureWorktreeHasInitialTerminal } from './worktree-activation'
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
import {
createMockStore,
registerWorktreeActivationReset,
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV } from '../../../shared/setup-agent-sequencing'
import { ensureWorktreeHasInitialTerminal } from './worktree-activation'
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
import {
createMockStore,
registerWorktreeActivationReset,
@@ -0,0 +1,66 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { Tab } from '../../../shared/tab-types'
import type { TuiAgent } from '../../../shared/tui-agent'
import type { SetupSplitDirection } from '../../../shared/worktree/launch-types'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../../shared/agent-session-resume'
import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner'
import type { AgentStartedTelemetry } from '@/lib/worktree-startup-payload'
export type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
tabsByWorktree: Record<string, { id: string }[]>
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
createTab: (
worktreeId: string,
targetGroupId?: string,
shellOverride?: string,
options?: {
pendingActivationSpawn?: boolean
launchAgent?: TuiAgent
recordInteraction?: boolean
viewMode?: Tab['viewMode']
activate?: boolean
}
) => { id: string }
setActiveTab: (tabId: string) => void
setTabCustomTitle: (
tabId: string,
title: string | null,
opts?: { recordInteraction?: boolean }
) => void
setTabColor: (tabId: string, color: string | null) => void
markDefaultTerminalTabsApplied: (worktreeId: string) => void
reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number }
queueTabStartupCommand: (
tabId: string,
startup: {
command: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
draftPrompt?: string
initialAgentStatus?: { agent: TuiAgent; prompt: string }
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
}
) => void
queueTabSetupSplit: (
tabId: string,
startup: { command: string; env?: Record<string, string>; direction: SetupSplitDirection }
) => void
queueTabIssueCommandSplit: (
tabId: string,
startup: { command: string; env?: Record<string, string> }
) => void
queueTabInitialCwd: (tabId: string, cwd: string) => void
settings?: Pick<GlobalSettings, 'experimentalNativeChat' | 'openAgentTabsInChatByDefault'> | null
}
export type InitialTerminalOptions = {
activateCreatedTabs?: boolean
backendStartupTerminalSpawned?: boolean
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { ensureWorktreeHasInitialTerminal } from './worktree-activation'
import { ensureWorktreeHasInitialTerminal } from './worktree-initial-terminal-seeding'
import type { AppStoreState } from './worktree-activation-test-harness'
import {
createMockStore,
+8 -611
View File
@@ -1,49 +1,21 @@
/* eslint-disable max-lines -- Why: worktree activation is a single ordered flow spanning startup, setup, issue commands, and default tabs; splitting it would obscure sequencing guarantees. */
import type { FolderWorkspace } from '../../../shared/folder-workspace-types'
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { Tab } from '../../../shared/tab-types'
import type { TuiAgent } from '../../../shared/tui-agent'
import type {
SetupSplitDirection,
WorktreeDefaultTabsLaunch,
WorktreeSetupLaunch
} from '../../../shared/worktree/launch-types'
import type { EventProps } from '../../../shared/telemetry-events'
import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../../shared/agent-session-resume'
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
import { buildSetupRunnerCommand } from './setup-runner'
import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing'
import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command'
import { agentKindToTuiAgent } from '../../../shared/agent-kind'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { PendingSidebarWorktreeReveal } from '@/store/slices/ui'
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
import {
activateWebRuntimeSessionWorktree,
createWebRuntimeSessionTerminal,
isWebRuntimeSessionActive,
isWebTerminalSurfaceTabId
isWebRuntimeSessionActive
} from '@/runtime/web-runtime-session'
import { getLastKnownHostTerminalTabCount } from '@/runtime/web-session-tabs-sync'
import {
beginWebRuntimeWakeTerminalRespawn,
endWebRuntimeWakeTerminalRespawn
} from '@/runtime/web-runtime-wake-terminal-respawn'
import {
setWorktreeNavActivator,
setWorktreeNavViewActivator
} from '@/store/slices/worktree-nav-history'
import { resumeSleepingAgentSessionsForWorktree } from '@/lib/resume-sleeping-agent-session'
import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery'
import {
getRuntimeEnvironmentIdForWorktree,
type WorktreeRuntimeOwnerState
} from '@/lib/worktree-runtime-owner'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../shared/workspace-scope'
import {
folderWorkspaceActivationBlocked,
@@ -51,130 +23,14 @@ import {
getFolderWorkspacePathStatusTitle
} from './folder-workspace-path-status'
import { toast } from 'sonner'
import { initialAgentTabViewModeProps } from './native-chat-initial-view-mode'
import { getConnectionId } from '@/lib/connection-context'
import { isDetachedHeadWorkspace } from '@/components/sidebar/visible-worktrees'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
import type { SessionOptionValue } from '../../../shared/native-chat-session-options'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner'
/** Telemetry threaded from the launch site to `pty:spawn`; main fires `agent_started`
* only after the spawn succeeds. See telemetry-plan.md§Agent launch semantics. */
export type AgentStartedTelemetry = EventProps<'agent_started'>
/** Startup command threaded onto a worktree's first terminal at activation. */
export type WorktreeStartupPayload = {
command: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
draftPrompt?: string
/**
* The unsent launch context, for the initial view-mode decision ONLY.
*
* Deliberately separate from `draftPrompt`, which drives the bracketed paste
* in pty-connection: an argv-prefill launch already carries the draft inside
* `command`, so reusing `draftPrompt` here would paste it a second time.
* Set this on every draft launch; set `draftPrompt` only for paste delivery.
*/
launchDraftText?: string
startupCommandDelivery?: StartupCommandDelivery
initialAgentStatus?: { agent: TuiAgent; prompt: string }
sessionOptions?: Record<string, SessionOptionValue>
telemetry?: AgentStartedTelemetry
}
/**
* The unsent launch context a startup payload carries, whichever way the agent
* receives it: argv prefill sets only `launchDraftText`, post-ready paste sets
* `draftPrompt`. Gating on `draftPrompt` alone silently misses every
* argv-prefill launch.
*/
export function resolveStartupLaunchDraftText(
startup: Pick<WorktreeStartupPayload, 'draftPrompt' | 'launchDraftText'> | undefined
): string | undefined {
return startup?.draftPrompt ?? startup?.launchDraftText
}
/** Shared by both tab-creation sites so the draft gate can't drift between them. */
function draftViewModeProps(draftText: string | undefined): {
promptDelivery?: 'draft'
launchDraftText?: string
} {
return draftText == null ? {} : { promptDelivery: 'draft', launchDraftText: draftText }
}
// Why: accept either a main-generated runner script or a plain TaskPage command string, so callers needn't synthesize a runner file.
export type IssueCommandLaunch =
| WorktreeSetupLaunch
| { command: string; env?: Record<string, string> }
function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'windows' | 'posix' {
return getSetupRunnerCommandPlatformForPath(
setup.runnerScriptPath,
navigator.userAgent.includes('Windows') ? 'windows' : 'posix'
)
}
type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
tabsByWorktree: Record<string, { id: string }[]>
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
createTab: (
worktreeId: string,
targetGroupId?: string,
shellOverride?: string,
options?: {
pendingActivationSpawn?: boolean
launchAgent?: TuiAgent
recordInteraction?: boolean
viewMode?: Tab['viewMode']
activate?: boolean
}
) => { id: string }
setActiveTab: (tabId: string) => void
setTabCustomTitle: (
tabId: string,
title: string | null,
opts?: { recordInteraction?: boolean }
) => void
setTabColor: (tabId: string, color: string | null) => void
markDefaultTerminalTabsApplied: (worktreeId: string) => void
reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number }
queueTabStartupCommand: (
tabId: string,
startup: {
command: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
draftPrompt?: string
initialAgentStatus?: { agent: TuiAgent; prompt: string }
showSessionRestoredBanner?: boolean
telemetry?: AgentStartedTelemetry
}
) => void
queueTabSetupSplit: (
tabId: string,
startup: { command: string; env?: Record<string, string>; direction: SetupSplitDirection }
) => void
queueTabIssueCommandSplit: (
tabId: string,
startup: { command: string; env?: Record<string, string> }
) => void
queueTabInitialCwd: (tabId: string, cwd: string) => void
settings?: Pick<GlobalSettings, 'experimentalNativeChat' | 'openAgentTabsInChatByDefault'> | null
}
type InitialTerminalOptions = {
activateCreatedTabs?: boolean
backendStartupTerminalSpawned?: boolean
}
import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload'
import type { IssueCommandLaunch } from '@/lib/worktree-setup-issue-command-queue'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
import { ensureWebRuntimeWorktreeTerminalAfterWake } from '@/lib/web-runtime-worktree-terminal-after-wake'
import { applyWorktreeNavViewEntry } from '@/lib/worktree-nav-view-history-replay'
/**
* Shared activation sequence used by the worktree palette and add-repo/worktree dialogs.
@@ -385,362 +241,6 @@ export function activateAndRevealWorktree(
return { primaryTabId }
}
export function ensureWebRuntimeWorktreeTerminalAfterWake(worktreeId: string): void {
const state = useAppStore.getState()
const worktree = state.getKnownWorktreeById(worktreeId)
if (!worktree) {
return
}
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(state, worktree.id)
if (!runtimeEnvironmentId || !isWebRuntimeSessionActive(runtimeEnvironmentId)) {
return
}
const tabs = state.tabsByWorktree[worktreeId] ?? []
const hasLivePty = tabs.some((tab) => tabHasLivePty(state.ptyIdsByTabId, tab.id))
if (hasLivePty) {
return
}
const hasMirroredHostTabs = tabs.some((tab) => isWebTerminalSurfaceTabId(tab.id))
if (hasMirroredHostTabs) {
// Why: the host session still owns these tabs — wait for the mirror to repopulate PTY handles instead of duplicating a terminal.
return
}
if (getLastKnownHostTerminalTabCount(runtimeEnvironmentId, worktreeId) > 0) {
return
}
const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId)
if (tabs.length > 0 && renderableTabCount === 0) {
return
}
if (!beginWebRuntimeWakeTerminalRespawn(worktreeId)) {
return
}
// Why: sleep keeps tab rows but terminal.stop clears host PTYs, so a woke workspace can have tab chrome but no surface.
void createWebRuntimeSessionTerminal({
worktreeId,
environmentId: runtimeEnvironmentId,
activate: true,
selectWorktree: false
}).finally(() => {
endWebRuntimeWakeTerminalRespawn(worktreeId)
})
}
export function ensureWorktreeHasInitialTerminal(
store: WorktreeActivationStore,
worktreeId: string,
startup?: WorktreeStartupPayload,
setup?: WorktreeSetupLaunch,
issueCommand?: IssueCommandLaunch,
defaultTabs?: WorktreeDefaultTabsLaunch,
opts?: InitialTerminalOptions
): string | null {
const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId)
// Why: creating a terminal just because the legacy terminal slice is empty gives editor/browser-only worktrees an unexpected extra tab.
const ownerState =
store.settings !== undefined || store.repos !== undefined || store.worktreesByRepo !== undefined
? store
: useAppStore.getState()
let sequencedStartup = startup
let wrappedSetupCommandStr: string | undefined
if (startup && setup?.waitForAgentStartup === true) {
const platform = getSetupRunnerCommandPlatformForLaunch(setup)
const sequenced = createSequencedSetupAgentCommands({
runnerScriptPath: setup.runnerScriptPath,
startupCommand: startup.command,
platform,
shell: setup.shell
})
sequencedStartup = {
...startup,
command: sequenced.startupCommand,
...(sequenced.startupEnv ? { env: { ...startup.env, ...sequenced.startupEnv } } : {})
}
wrappedSetupCommandStr = sequenced.setupCommand
}
const backendStartupTerminalSpawned = opts?.backendStartupTerminalSpawned === true
// Why: explicit spawn evidence survives the new-worktree ownership race; active web sessions provide the same authority for later activations.
if (
backendStartupTerminalSpawned ||
isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId))
) {
const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id
if (existingTerminalTabId && (setup || issueCommand)) {
queueSetupAndIssueCommands(
store,
worktreeId,
existingTerminalTabId,
setup,
issueCommand,
wrappedSetupCommandStr,
opts
)
return existingTerminalTabId
}
if (existingTerminalTabId && backendStartupTerminalSpawned) {
return existingTerminalTabId
}
if (setup || issueCommand) {
// Why: runtime-owned worktrees mirror session tabs async, so hold commands for the first mirrored tab instead of dropping them.
queueHookCommandsForFirstWorktreeTab({
worktreeId,
deliver: (state, firstTerminalTabId) =>
queueSetupAndIssueCommands(
state,
worktreeId,
firstTerminalTabId,
setup,
issueCommand,
wrappedSetupCommandStr,
opts
)
})
}
return null
}
const hasExplicitLaunchWork = Boolean(sequencedStartup || setup || issueCommand)
const shouldAutoCreate = shouldAutoCreateInitialTerminal(
renderableTabCount,
Object.hasOwn(store.tabsByWorktree, worktreeId)
)
const shouldCreateForExplicitWork = renderableTabCount === 0 && hasExplicitLaunchWork
if (!shouldAutoCreate && !shouldCreateForExplicitWork) {
const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id
if (existingTerminalTabId && (setup || issueCommand)) {
// Why: main may have adopted the startup tab but failed to spawn setup; renderer must still launch the returned fallback setup.
queueSetupAndIssueCommands(
store,
worktreeId,
existingTerminalTabId,
setup,
issueCommand,
wrappedSetupCommandStr,
opts
)
return existingTerminalTabId
}
return null
}
const templatedTabId = applyDefaultTerminalTabs(
store,
worktreeId,
sequencedStartup,
setup,
issueCommand,
defaultTabs,
wrappedSetupCommandStr,
opts
)
if (templatedTabId) {
return templatedTabId
}
// Why: tag this activation-created tab so its PTY spawn doesn't count as activity and reshuffle the Recent sort.
// Why: stamp the seeded agent before hooks arrive so native chat and provider chrome can resolve it immediately.
const launchAgent =
sequencedStartup?.launchAgent ??
(sequencedStartup?.telemetry
? (agentKindToTuiAgent(sequencedStartup.telemetry.agent_kind) ?? undefined)
: undefined)
const terminalTab = store.createTab(worktreeId, undefined, undefined, {
pendingActivationSpawn: true,
...(launchAgent
? {
launchAgent,
...initialAgentTabViewModeProps(store.settings ?? null, {
agent: launchAgent,
// Why: argv-prefill launches carry the draft in `command` and set no
// draftPrompt, so gating on draftPrompt alone misses them entirely.
...draftViewModeProps(resolveStartupLaunchDraftText(sequencedStartup)),
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(
getConnectionId(worktreeId)
)
})
}
: {}),
...(opts?.activateCreatedTabs === false ? { activate: false } : {})
})
if (opts?.activateCreatedTabs !== false) {
store.setActiveTab(terminalTab.id)
}
// Why: queue the seeded startup on the initial pane so the terminal begins in the requested agent session instead of an idle shell.
if (sequencedStartup) {
if (launchAgent) {
seedNativeChatAppliedSessionOptions(
terminalTab.id,
launchAgent,
sequencedStartup.sessionOptions
)
}
store.queueTabStartupCommand(terminalTab.id, sequencedStartup)
}
queueSetupAndIssueCommands(
store,
worktreeId,
terminalTab.id,
setup,
issueCommand,
wrappedSetupCommandStr,
opts
)
return terminalTab.id
}
function applyDefaultTerminalTabs(
store: WorktreeActivationStore,
worktreeId: string,
startup: WorktreeStartupPayload | undefined,
setup: WorktreeSetupLaunch | undefined,
issueCommand: IssueCommandLaunch | undefined,
defaultTabs: WorktreeDefaultTabsLaunch | undefined,
wrappedSetupCommandStr: string | undefined,
opts: InitialTerminalOptions | undefined
): string | null {
if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) {
return null
}
store.markDefaultTerminalTabsApplied(worktreeId)
if (defaultTabs.tabs.length === 0) {
return null
}
let firstTabId: string | null = null
for (const [index, template] of defaultTabs.tabs.entries()) {
const isStartupTab = index === 0 && startup !== undefined
const launchAgent =
isStartupTab && startup?.launchAgent
? startup.launchAgent
: isStartupTab && startup?.telemetry
? (agentKindToTuiAgent(startup.telemetry.agent_kind) ?? undefined)
: undefined
const tab = store.createTab(worktreeId, undefined, undefined, {
pendingActivationSpawn: true,
recordInteraction: false,
...(launchAgent
? {
launchAgent,
...initialAgentTabViewModeProps(store.settings ?? null, {
agent: launchAgent,
...draftViewModeProps(
isStartupTab ? resolveStartupLaunchDraftText(startup) : undefined
),
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(
getConnectionId(worktreeId)
)
})
}
: {}),
...(opts?.activateCreatedTabs === false ? { activate: false } : {})
})
if (index === 0) {
firstTabId = tab.id
}
if (template.title) {
store.setTabCustomTitle(tab.id, template.title, { recordInteraction: false })
}
if (template.color) {
store.setTabColor(tab.id, template.color)
}
const templateCommand = template.command?.trim()
if (templateCommand && defaultTabs.runCommands && !(index === 0 && startup)) {
store.queueTabStartupCommand(tab.id, { command: templateCommand })
}
}
if (!firstTabId) {
return null
}
if (opts?.activateCreatedTabs !== false) {
store.setActiveTab(firstTabId)
}
if (startup) {
const startupAgent =
startup.launchAgent ??
(startup.telemetry
? (agentKindToTuiAgent(startup.telemetry.agent_kind) ?? undefined)
: undefined)
if (startupAgent) {
seedNativeChatAppliedSessionOptions(firstTabId, startupAgent, startup.sessionOptions)
}
store.queueTabStartupCommand(firstTabId, startup)
}
queueSetupAndIssueCommands(
store,
worktreeId,
firstTabId,
setup,
issueCommand,
wrappedSetupCommandStr,
opts
)
return firstTabId
}
function queueSetupAndIssueCommands(
store: WorktreeActivationStore,
worktreeId: string,
terminalTabId: string,
setup: WorktreeSetupLaunch | undefined,
issueCommand: IssueCommandLaunch | undefined,
wrappedSetupCommandStr: string | undefined,
opts: InitialTerminalOptions | undefined
): void {
// Why: setup launch location is user-configurable — 'new-tab' keeps setup output off the primary pane; splits keep it adjacent.
if (setup) {
const mode = useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab'
const setupCommand = {
command:
wrappedSetupCommandStr ??
setup.command ??
buildSetupRunnerCommand(setup.runnerScriptPath, setup.shell),
env: setup.envVars
}
if (mode === 'new-tab') {
const setupTab = store.createTab(worktreeId, undefined, undefined, {
recordInteraction: false,
...(opts?.activateCreatedTabs === false ? { activate: false } : {})
})
// Why: createTab auto-activates the new tab; revert so focus stays on the primary terminal while Setup runs in the background.
if (opts?.activateCreatedTabs !== false) {
store.setActiveTab(terminalTabId)
}
// Why: customTitle overrides the auto "Terminal N" label everywhere the tab renders, so it's the authoritative label source.
store.setTabCustomTitle(setupTab.id, 'Setup', { recordInteraction: false })
store.queueTabStartupCommand(setupTab.id, setupCommand)
} else {
store.queueTabSetupSplit(terminalTabId, {
...setupCommand,
direction: mode === 'split-horizontal' ? 'horizontal' : 'vertical'
})
}
}
// Why: issue automation runs in its own split, queued independently from setup so both can start in parallel (separate concerns).
if (issueCommand) {
// Why: WorktreeSetupLaunch carries a runner-script file to shell out to; the TaskPage variant is already an expanded command string.
const queuedIssueCommand =
'runnerScriptPath' in issueCommand
? {
command: buildSetupRunnerCommand(issueCommand.runnerScriptPath, issueCommand.shell),
env: issueCommand.envVars
}
: { command: issueCommand.command, env: issueCommand.env }
store.queueTabIssueCommandSplit(terminalTabId, queuedIssueCommand)
}
}
/**
* Activates a sidebar workspace id of either shape. Rendered sidebar order mixes
* plain worktree ids with `folder:` keys, so every caller that navigates by that
@@ -758,107 +258,4 @@ export function activateAndRevealWorkspace(workspaceId: string): ActivateAndReve
// Why: break the import cycle — nav-history slice (under @/store) can't import activation directly, so register the activator here.
setWorktreeNavActivator(activateAndRevealWorkspace)
// Why: page entries replay via setActiveView (not open*Page) so back/forward doesn't mutate previousViewBefore* or duplicate history (see navigateToIndex).
setWorktreeNavViewActivator((entry) => {
if (entry === 'automations') {
useAppStore.getState().setActiveView(entry)
return
}
if (entry === 'tasks') {
useAppStore.setState((state) => ({
activeView: 'tasks',
githubTaskDrawerWorkItem: null,
taskPageData: {
...state.taskPageData,
openGitHubWorkItem: undefined,
openGitHubSourceContext: undefined,
openGitHubInitialTab: undefined,
openGitLabWorkItem: undefined,
openGitLabSourceContext: undefined,
openLinearIssue: undefined,
openLinearSourceContext: undefined,
openJiraIssue: undefined,
openJiraSourceContext: undefined
}
}))
return
}
if (entry.source === 'github') {
useAppStore.setState((state) => ({
activeView: 'tasks',
taskPageData: {
...state.taskPageData,
taskSource: 'github',
preselectedRepoId: entry.workItem.repoId,
openGitHubWorkItem: entry.workItem,
openGitHubSourceContext: entry.sourceContext,
openGitHubInitialTab: entry.initialTab,
openGitLabWorkItem: undefined,
openGitLabSourceContext: undefined,
openLinearIssue: undefined,
openLinearSourceContext: undefined,
openJiraIssue: undefined,
openJiraSourceContext: undefined
}
}))
return
}
if (entry.source === 'gitlab') {
useAppStore.setState((state) => ({
activeView: 'tasks',
githubTaskDrawerWorkItem: null,
taskPageData: {
...state.taskPageData,
taskSource: 'gitlab',
preselectedRepoId: entry.workItem.repoId,
openGitHubWorkItem: undefined,
openGitHubSourceContext: undefined,
openGitHubInitialTab: undefined,
openGitLabWorkItem: entry.workItem,
openGitLabSourceContext: entry.sourceContext,
openLinearIssue: undefined,
openLinearSourceContext: undefined,
openJiraIssue: undefined,
openJiraSourceContext: undefined
}
}))
return
}
if (entry.source === 'jira') {
useAppStore.setState((state) => ({
activeView: 'tasks',
githubTaskDrawerWorkItem: null,
taskPageData: {
...state.taskPageData,
taskSource: 'jira',
openGitHubWorkItem: undefined,
openGitHubSourceContext: undefined,
openGitHubInitialTab: undefined,
openGitLabWorkItem: undefined,
openGitLabSourceContext: undefined,
openLinearIssue: undefined,
openLinearSourceContext: undefined,
openJiraIssue: entry.issue,
openJiraSourceContext: entry.sourceContext
}
}))
return
}
useAppStore.setState((state) => ({
activeView: 'tasks',
githubTaskDrawerWorkItem: null,
taskPageData: {
...state.taskPageData,
taskSource: 'linear',
openGitHubWorkItem: undefined,
openGitHubSourceContext: undefined,
openGitHubInitialTab: undefined,
openGitLabWorkItem: undefined,
openGitLabSourceContext: undefined,
openLinearIssue: entry.issue,
openLinearSourceContext: entry.sourceContext,
openJiraIssue: undefined,
openJiraSourceContext: undefined
}
}))
})
setWorktreeNavViewActivator(applyWorktreeNavViewEntry)
@@ -27,7 +27,10 @@ vi.mock('@/lib/browser-uuid', () => ({
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn(),
activateAndRevealWorktree: vi.fn()
}))
vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({
ensureWorktreeHasInitialTerminal: vi.fn()
}))
@@ -1,6 +1,6 @@
import { useAppStore } from '@/store'
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
import type { WorktreeStartupPayload } from '@/lib/worktree-activation'
import type { WorktreeStartupPayload } from '@/lib/worktree-startup-payload'
import type {
WorktreeCreationPhase,
WorktreeCreationRequest
@@ -59,7 +59,10 @@ vi.mock('@/lib/browser-uuid', () => ({
}))
vi.mock('@/lib/worktree-activation', () => ({
activateAndRevealWorktree: vi.fn(() => false),
activateAndRevealWorktree: vi.fn(() => false)
}))
vi.mock('@/lib/worktree-initial-terminal-seeding', () => ({
ensureWorktreeHasInitialTerminal: vi.fn()
}))
@@ -82,10 +85,8 @@ vi.mock('@/lib/ephemeral-vm-workspace-target', () => ({
}))
import { toast } from 'sonner'
import {
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal
} from '@/lib/worktree-activation'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
import {
beginBackgroundWorktreePreparation,
@@ -1,11 +1,8 @@
import { toast } from 'sonner'
import { useAppStore } from '@/store'
import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config'
import {
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal,
type ActivateAndRevealResult
} from '@/lib/worktree-activation'
import { activateAndRevealWorktree, type ActivateAndRevealResult } from '@/lib/worktree-activation'
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
import { ensureAgentStartupInTerminal } from '@/lib/new-workspace'
import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus'
import {
@@ -0,0 +1,112 @@
import type {
WorktreeDefaultTabsLaunch,
WorktreeSetupLaunch
} from '../../../shared/worktree/launch-types'
import { agentKindToTuiAgent } from '../../../shared/agent-kind'
import { initialAgentTabViewModeProps } from './native-chat-initial-view-mode'
import { getConnectionId } from '@/lib/connection-context'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
import type {
InitialTerminalOptions,
WorktreeActivationStore
} from '@/lib/worktree-activation-store-contract'
import {
draftViewModeProps,
resolveStartupLaunchDraftText,
type WorktreeStartupPayload
} from '@/lib/worktree-startup-payload'
import {
queueSetupAndIssueCommands,
type IssueCommandLaunch
} from '@/lib/worktree-setup-issue-command-queue'
export function applyDefaultTerminalTabs(
store: WorktreeActivationStore,
worktreeId: string,
startup: WorktreeStartupPayload | undefined,
setup: WorktreeSetupLaunch | undefined,
issueCommand: IssueCommandLaunch | undefined,
defaultTabs: WorktreeDefaultTabsLaunch | undefined,
wrappedSetupCommandStr: string | undefined,
opts: InitialTerminalOptions | undefined
): string | null {
if (!defaultTabs || store.defaultTerminalTabsAppliedByWorktreeId[worktreeId]) {
return null
}
store.markDefaultTerminalTabsApplied(worktreeId)
if (defaultTabs.tabs.length === 0) {
return null
}
let firstTabId: string | null = null
for (const [index, template] of defaultTabs.tabs.entries()) {
const isStartupTab = index === 0 && startup !== undefined
const launchAgent =
isStartupTab && startup?.launchAgent
? startup.launchAgent
: isStartupTab && startup?.telemetry
? (agentKindToTuiAgent(startup.telemetry.agent_kind) ?? undefined)
: undefined
const tab = store.createTab(worktreeId, undefined, undefined, {
pendingActivationSpawn: true,
recordInteraction: false,
...(launchAgent
? {
launchAgent,
...initialAgentTabViewModeProps(store.settings ?? null, {
agent: launchAgent,
...draftViewModeProps(
isStartupTab ? resolveStartupLaunchDraftText(startup) : undefined
),
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(
getConnectionId(worktreeId)
)
})
}
: {}),
...(opts?.activateCreatedTabs === false ? { activate: false } : {})
})
if (index === 0) {
firstTabId = tab.id
}
if (template.title) {
store.setTabCustomTitle(tab.id, template.title, { recordInteraction: false })
}
if (template.color) {
store.setTabColor(tab.id, template.color)
}
const templateCommand = template.command?.trim()
if (templateCommand && defaultTabs.runCommands && !(index === 0 && startup)) {
store.queueTabStartupCommand(tab.id, { command: templateCommand })
}
}
if (!firstTabId) {
return null
}
if (opts?.activateCreatedTabs !== false) {
store.setActiveTab(firstTabId)
}
if (startup) {
const startupAgent =
startup.launchAgent ??
(startup.telemetry
? (agentKindToTuiAgent(startup.telemetry.agent_kind) ?? undefined)
: undefined)
if (startupAgent) {
seedNativeChatAppliedSessionOptions(firstTabId, startupAgent, startup.sessionOptions)
}
store.queueTabStartupCommand(firstTabId, startup)
}
queueSetupAndIssueCommands(
store,
worktreeId,
firstTabId,
setup,
issueCommand,
wrappedSetupCommandStr,
opts
)
return firstTabId
}
@@ -0,0 +1,203 @@
import type {
WorktreeDefaultTabsLaunch,
WorktreeSetupLaunch
} from '../../../shared/worktree/launch-types'
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
import { createSequencedSetupAgentCommands } from '../../../shared/setup-agent-sequencing'
import { getSetupRunnerCommandPlatformForPath } from '../../../shared/setup-runner-command'
import { agentKindToTuiAgent } from '../../../shared/agent-kind'
import { useAppStore } from '@/store'
import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session'
import { queueHookCommandsForFirstWorktreeTab } from '@/lib/hook-command-delayed-delivery'
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
import { initialAgentTabViewModeProps } from './native-chat-initial-view-mode'
import { getConnectionId } from '@/lib/connection-context'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
import type {
InitialTerminalOptions,
WorktreeActivationStore
} from '@/lib/worktree-activation-store-contract'
import {
draftViewModeProps,
resolveStartupLaunchDraftText,
type WorktreeStartupPayload
} from '@/lib/worktree-startup-payload'
import {
queueSetupAndIssueCommands,
type IssueCommandLaunch
} from '@/lib/worktree-setup-issue-command-queue'
import { applyDefaultTerminalTabs } from '@/lib/worktree-default-terminal-tabs'
function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'windows' | 'posix' {
return getSetupRunnerCommandPlatformForPath(
setup.runnerScriptPath,
navigator.userAgent.includes('Windows') ? 'windows' : 'posix'
)
}
export function ensureWorktreeHasInitialTerminal(
store: WorktreeActivationStore,
worktreeId: string,
startup?: WorktreeStartupPayload,
setup?: WorktreeSetupLaunch,
issueCommand?: IssueCommandLaunch,
defaultTabs?: WorktreeDefaultTabsLaunch,
opts?: InitialTerminalOptions
): string | null {
const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId)
// Why: creating a terminal just because the legacy terminal slice is empty gives editor/browser-only worktrees an unexpected extra tab.
const ownerState =
store.settings !== undefined || store.repos !== undefined || store.worktreesByRepo !== undefined
? store
: useAppStore.getState()
let sequencedStartup = startup
let wrappedSetupCommandStr: string | undefined
if (startup && setup?.waitForAgentStartup === true) {
const platform = getSetupRunnerCommandPlatformForLaunch(setup)
const sequenced = createSequencedSetupAgentCommands({
runnerScriptPath: setup.runnerScriptPath,
startupCommand: startup.command,
platform,
shell: setup.shell
})
sequencedStartup = {
...startup,
command: sequenced.startupCommand,
...(sequenced.startupEnv ? { env: { ...startup.env, ...sequenced.startupEnv } } : {})
}
wrappedSetupCommandStr = sequenced.setupCommand
}
const backendStartupTerminalSpawned = opts?.backendStartupTerminalSpawned === true
// Why: explicit spawn evidence survives the new-worktree ownership race; active web sessions provide the same authority for later activations.
if (
backendStartupTerminalSpawned ||
isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(ownerState, worktreeId))
) {
const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id
if (existingTerminalTabId && (setup || issueCommand)) {
queueSetupAndIssueCommands(
store,
worktreeId,
existingTerminalTabId,
setup,
issueCommand,
wrappedSetupCommandStr,
opts
)
return existingTerminalTabId
}
if (existingTerminalTabId && backendStartupTerminalSpawned) {
return existingTerminalTabId
}
if (setup || issueCommand) {
// Why: runtime-owned worktrees mirror session tabs async, so hold commands for the first mirrored tab instead of dropping them.
queueHookCommandsForFirstWorktreeTab({
worktreeId,
deliver: (state, firstTerminalTabId) =>
queueSetupAndIssueCommands(
state,
worktreeId,
firstTerminalTabId,
setup,
issueCommand,
wrappedSetupCommandStr,
opts
)
})
}
return null
}
const hasExplicitLaunchWork = Boolean(sequencedStartup || setup || issueCommand)
const shouldAutoCreate = shouldAutoCreateInitialTerminal(
renderableTabCount,
Object.hasOwn(store.tabsByWorktree, worktreeId)
)
const shouldCreateForExplicitWork = renderableTabCount === 0 && hasExplicitLaunchWork
if (!shouldAutoCreate && !shouldCreateForExplicitWork) {
const existingTerminalTabId = store.tabsByWorktree[worktreeId]?.[0]?.id
if (existingTerminalTabId && (setup || issueCommand)) {
// Why: main may have adopted the startup tab but failed to spawn setup; renderer must still launch the returned fallback setup.
queueSetupAndIssueCommands(
store,
worktreeId,
existingTerminalTabId,
setup,
issueCommand,
wrappedSetupCommandStr,
opts
)
return existingTerminalTabId
}
return null
}
const templatedTabId = applyDefaultTerminalTabs(
store,
worktreeId,
sequencedStartup,
setup,
issueCommand,
defaultTabs,
wrappedSetupCommandStr,
opts
)
if (templatedTabId) {
return templatedTabId
}
// Why: tag this activation-created tab so its PTY spawn doesn't count as activity and reshuffle the Recent sort.
// Why: stamp the seeded agent before hooks arrive so native chat and provider chrome can resolve it immediately.
const launchAgent =
sequencedStartup?.launchAgent ??
(sequencedStartup?.telemetry
? (agentKindToTuiAgent(sequencedStartup.telemetry.agent_kind) ?? undefined)
: undefined)
const terminalTab = store.createTab(worktreeId, undefined, undefined, {
pendingActivationSpawn: true,
...(launchAgent
? {
launchAgent,
...initialAgentTabViewModeProps(store.settings ?? null, {
agent: launchAgent,
// Why: argv-prefill launches carry the draft in `command` and set no
// draftPrompt, so gating on draftPrompt alone misses them entirely.
...draftViewModeProps(resolveStartupLaunchDraftText(sequencedStartup)),
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(
getConnectionId(worktreeId)
)
})
}
: {}),
...(opts?.activateCreatedTabs === false ? { activate: false } : {})
})
if (opts?.activateCreatedTabs !== false) {
store.setActiveTab(terminalTab.id)
}
// Why: queue the seeded startup on the initial pane so the terminal begins in the requested agent session instead of an idle shell.
if (sequencedStartup) {
if (launchAgent) {
seedNativeChatAppliedSessionOptions(
terminalTab.id,
launchAgent,
sequencedStartup.sessionOptions
)
}
store.queueTabStartupCommand(terminalTab.id, sequencedStartup)
}
queueSetupAndIssueCommands(
store,
worktreeId,
terminalTab.id,
setup,
issueCommand,
wrappedSetupCommandStr,
opts
)
return terminalTab.id
}
@@ -0,0 +1,107 @@
import { useAppStore } from '@/store'
import type { WorktreeNavHistoryViewEntry } from '@/store/slices/worktree-nav-history'
// Why: page entries replay via setActiveView (not open*Page) so back/forward doesn't mutate previousViewBefore* or duplicate history (see navigateToIndex).
export function applyWorktreeNavViewEntry(entry: WorktreeNavHistoryViewEntry): void {
if (entry === 'automations') {
useAppStore.getState().setActiveView(entry)
return
}
if (entry === 'tasks') {
useAppStore.setState((state) => ({
activeView: 'tasks',
githubTaskDrawerWorkItem: null,
taskPageData: {
...state.taskPageData,
openGitHubWorkItem: undefined,
openGitHubSourceContext: undefined,
openGitHubInitialTab: undefined,
openGitLabWorkItem: undefined,
openGitLabSourceContext: undefined,
openLinearIssue: undefined,
openLinearSourceContext: undefined,
openJiraIssue: undefined,
openJiraSourceContext: undefined
}
}))
return
}
if (entry.source === 'github') {
useAppStore.setState((state) => ({
activeView: 'tasks',
taskPageData: {
...state.taskPageData,
taskSource: 'github',
preselectedRepoId: entry.workItem.repoId,
openGitHubWorkItem: entry.workItem,
openGitHubSourceContext: entry.sourceContext,
openGitHubInitialTab: entry.initialTab,
openGitLabWorkItem: undefined,
openGitLabSourceContext: undefined,
openLinearIssue: undefined,
openLinearSourceContext: undefined,
openJiraIssue: undefined,
openJiraSourceContext: undefined
}
}))
return
}
if (entry.source === 'gitlab') {
useAppStore.setState((state) => ({
activeView: 'tasks',
githubTaskDrawerWorkItem: null,
taskPageData: {
...state.taskPageData,
taskSource: 'gitlab',
preselectedRepoId: entry.workItem.repoId,
openGitHubWorkItem: undefined,
openGitHubSourceContext: undefined,
openGitHubInitialTab: undefined,
openGitLabWorkItem: entry.workItem,
openGitLabSourceContext: entry.sourceContext,
openLinearIssue: undefined,
openLinearSourceContext: undefined,
openJiraIssue: undefined,
openJiraSourceContext: undefined
}
}))
return
}
if (entry.source === 'jira') {
useAppStore.setState((state) => ({
activeView: 'tasks',
githubTaskDrawerWorkItem: null,
taskPageData: {
...state.taskPageData,
taskSource: 'jira',
openGitHubWorkItem: undefined,
openGitHubSourceContext: undefined,
openGitHubInitialTab: undefined,
openGitLabWorkItem: undefined,
openGitLabSourceContext: undefined,
openLinearIssue: undefined,
openLinearSourceContext: undefined,
openJiraIssue: entry.issue,
openJiraSourceContext: entry.sourceContext
}
}))
return
}
useAppStore.setState((state) => ({
activeView: 'tasks',
githubTaskDrawerWorkItem: null,
taskPageData: {
...state.taskPageData,
taskSource: 'linear',
openGitHubWorkItem: undefined,
openGitHubSourceContext: undefined,
openGitHubInitialTab: undefined,
openGitLabWorkItem: undefined,
openGitLabSourceContext: undefined,
openLinearIssue: entry.issue,
openLinearSourceContext: entry.sourceContext,
openJiraIssue: undefined,
openJiraSourceContext: undefined
}
}))
}
@@ -0,0 +1,65 @@
import type { WorktreeSetupLaunch } from '../../../shared/worktree/launch-types'
import { buildSetupRunnerCommand } from './setup-runner'
import { useAppStore } from '@/store'
import type {
InitialTerminalOptions,
WorktreeActivationStore
} from '@/lib/worktree-activation-store-contract'
// Why: accept either a main-generated runner script or a plain TaskPage command string, so callers needn't synthesize a runner file.
export type IssueCommandLaunch =
| WorktreeSetupLaunch
| { command: string; env?: Record<string, string> }
export function queueSetupAndIssueCommands(
store: WorktreeActivationStore,
worktreeId: string,
terminalTabId: string,
setup: WorktreeSetupLaunch | undefined,
issueCommand: IssueCommandLaunch | undefined,
wrappedSetupCommandStr: string | undefined,
opts: InitialTerminalOptions | undefined
): void {
// Why: setup launch location is user-configurable — 'new-tab' keeps setup output off the primary pane; splits keep it adjacent.
if (setup) {
const mode = useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab'
const setupCommand = {
command:
wrappedSetupCommandStr ??
setup.command ??
buildSetupRunnerCommand(setup.runnerScriptPath, setup.shell),
env: setup.envVars
}
if (mode === 'new-tab') {
const setupTab = store.createTab(worktreeId, undefined, undefined, {
recordInteraction: false,
...(opts?.activateCreatedTabs === false ? { activate: false } : {})
})
// Why: createTab auto-activates the new tab; revert so focus stays on the primary terminal while Setup runs in the background.
if (opts?.activateCreatedTabs !== false) {
store.setActiveTab(terminalTabId)
}
// Why: customTitle overrides the auto "Terminal N" label everywhere the tab renders, so it's the authoritative label source.
store.setTabCustomTitle(setupTab.id, 'Setup', { recordInteraction: false })
store.queueTabStartupCommand(setupTab.id, setupCommand)
} else {
store.queueTabSetupSplit(terminalTabId, {
...setupCommand,
direction: mode === 'split-horizontal' ? 'horizontal' : 'vertical'
})
}
}
// Why: issue automation runs in its own split, queued independently from setup so both can start in parallel (separate concerns).
if (issueCommand) {
// Why: WorktreeSetupLaunch carries a runner-script file to shell out to; the TaskPage variant is already an expanded command string.
const queuedIssueCommand =
'runnerScriptPath' in issueCommand
? {
command: buildSetupRunnerCommand(issueCommand.runnerScriptPath, issueCommand.shell),
env: issueCommand.envVars
}
: { command: issueCommand.command, env: issueCommand.env }
store.queueTabIssueCommandSplit(terminalTabId, queuedIssueCommand)
}
}
@@ -0,0 +1,56 @@
import type { TuiAgent } from '../../../shared/tui-agent'
import type { EventProps } from '../../../shared/telemetry-events'
import type { StartupCommandDelivery } from '../../../shared/codex-startup-delivery'
import type {
AgentProviderSessionMetadata,
SleepingAgentLaunchConfig
} from '../../../shared/agent-session-resume'
import type { SessionOptionValue } from '../../../shared/native-chat-session-options'
/** Telemetry threaded from the launch site to `pty:spawn`; main fires `agent_started`
* only after the spawn succeeds. See telemetry-plan.md§Agent launch semantics. */
export type AgentStartedTelemetry = EventProps<'agent_started'>
/** Startup command threaded onto a worktree's first terminal at activation. */
export type WorktreeStartupPayload = {
command: string
env?: Record<string, string>
launchConfig?: SleepingAgentLaunchConfig
resumeProviderSession?: AgentProviderSessionMetadata
launchToken?: string
launchAgent?: TuiAgent
draftPrompt?: string
/**
* The unsent launch context, for the initial view-mode decision ONLY.
*
* Deliberately separate from `draftPrompt`, which drives the bracketed paste
* in pty-connection: an argv-prefill launch already carries the draft inside
* `command`, so reusing `draftPrompt` here would paste it a second time.
* Set this on every draft launch; set `draftPrompt` only for paste delivery.
*/
launchDraftText?: string
startupCommandDelivery?: StartupCommandDelivery
initialAgentStatus?: { agent: TuiAgent; prompt: string }
sessionOptions?: Record<string, SessionOptionValue>
telemetry?: AgentStartedTelemetry
}
/**
* The unsent launch context a startup payload carries, whichever way the agent
* receives it: argv prefill sets only `launchDraftText`, post-ready paste sets
* `draftPrompt`. Gating on `draftPrompt` alone silently misses every
* argv-prefill launch.
*/
export function resolveStartupLaunchDraftText(
startup: Pick<WorktreeStartupPayload, 'draftPrompt' | 'launchDraftText'> | undefined
): string | undefined {
return startup?.draftPrompt ?? startup?.launchDraftText
}
/** Shared by both tab-creation sites so the draft gate can't drift between them. */
export function draftViewModeProps(draftText: string | undefined): {
promptDelivery?: 'draft'
launchDraftText?: string
} {
return draftText == null ? {} : { promptDelivery: 'draft', launchDraftText: draftText }
}
+1 -1
View File
@@ -46,7 +46,7 @@ import type { StartupCommandDelivery } from '../../../../shared/codex-startup-de
import type { SessionOptionValue } from '../../../../shared/native-chat-session-options'
import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../../shared/local-windows-terminal-runtime'
import { WINDOWS_GIT_BASH_SHELL } from '../../../../shared/windows-terminal-shell'
import type { AgentStartedTelemetry } from '../../lib/worktree-activation'
import type { AgentStartedTelemetry } from '../../lib/worktree-startup-payload'
import type { AiVaultSessionTitle } from '../../../../shared/ai-vault-session-title'
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity'