mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user