From 6854cceb90ea0f9cd5f43aca7212fb22f6504bce Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:10:36 -0700 Subject: [PATCH] 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. --- config/max-lines-baseline.txt | 5 - .../folder-workspace-composer-submit.test.ts | 2 +- .../tab-group/tab-drag-drop-commit.ts | 179 +++++ .../tab-group/tab-drag-gesture-lifecycle.ts | 71 ++ .../tab-group/tab-drag-hover-preview.ts | 138 ++++ .../tab-drag-missed-end-listeners.ts | 31 + .../components/tab-group/useTabDragSplit.ts | 368 ++-------- .../useTabGroupActivationCommands.ts | 142 ++++ .../useTabGroupCloseScopeCommands.ts | 102 +++ .../tab-group/useTabGroupCreationCommands.ts | 169 +++++ .../tab-group/useTabGroupItemProjections.ts | 136 ++++ .../tab-group/useTabGroupTabCloseCommands.ts | 181 +++++ .../tab-group/useTabGroupWorkspaceModel.ts | 629 +----------------- ...erminal-pane-menu-agent-session-actions.ts | 67 ++ .../terminal-pane-menu-copy-actions.ts | 85 +++ .../terminal-pane/terminal-pane-menu-paste.ts | 156 +++++ .../terminal-pane-split-writer-paths.test.ts | 2 +- .../use-terminal-context-menu-trigger.ts | 121 ++++ .../use-terminal-pane-context-menu.ts | 486 ++------------ .../use-terminal-pane-split-actions.ts | 97 +++ src/renderer/src/hooks/useComposerState.ts | 3 +- .../src/lib/launch-work-item-direct-agent.ts | 2 +- .../lib/onboarding-folder-agent-startup.ts | 2 +- .../pane-manager/pane-manager-drag-wiring.ts | 33 + .../src/lib/pane-manager/pane-manager-host.ts | 33 + .../pane-manager-layout-sweeps.ts | 42 ++ .../pane-manager-pane-creation.ts | 89 +++ .../pane-manager-tree-mutations.ts | 111 ++++ .../src/lib/pane-manager/pane-manager.ts | 337 +++------- .../src/lib/pane-manager/pane-public-view.ts | 14 + .../pane-rendering-diagnostics.ts | 18 + .../pane-reparent-frame-tracker.ts | 31 + .../src/lib/pending-worktree-creation.ts | 2 +- ...eb-runtime-worktree-terminal-after-wake.ts | 60 ++ .../worktree-activation-agent-startup.test.ts | 2 +- .../worktree-activation-created-agent.test.ts | 6 +- .../worktree-activation-default-tabs.test.ts | 2 +- .../worktree-activation-empty-remote.test.ts | 2 +- .../worktree-activation-issue-command.test.ts | 2 +- .../worktree-activation-setup-script.test.ts | 2 +- .../lib/worktree-activation-store-contract.ts | 66 ++ .../worktree-activation-web-runtime.test.ts | 2 +- src/renderer/src/lib/worktree-activation.ts | 619 +---------------- .../lib/worktree-creation-flow-dedupe.test.ts | 5 +- .../src/lib/worktree-creation-flow-startup.ts | 2 +- .../src/lib/worktree-creation-flow.test.ts | 11 +- .../src/lib/worktree-creation-flow.ts | 7 +- .../src/lib/worktree-default-terminal-tabs.ts | 112 ++++ .../lib/worktree-initial-terminal-seeding.ts | 203 ++++++ .../lib/worktree-nav-view-history-replay.ts | 107 +++ .../lib/worktree-setup-issue-command-queue.ts | 65 ++ .../src/lib/worktree-startup-payload.ts | 56 ++ src/renderer/src/store/slices/terminals.ts | 2 +- 53 files changed, 2957 insertions(+), 2260 deletions(-) create mode 100644 src/renderer/src/components/tab-group/tab-drag-drop-commit.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-gesture-lifecycle.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-hover-preview.ts create mode 100644 src/renderer/src/components/tab-group/tab-drag-missed-end-listeners.ts create mode 100644 src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts create mode 100644 src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.ts create mode 100644 src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts create mode 100644 src/renderer/src/components/tab-group/useTabGroupItemProjections.ts create mode 100644 src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-menu-agent-session-actions.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-pane-split-actions.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-manager-drag-wiring.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-manager-host.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-manager-layout-sweeps.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-reparent-frame-tracker.ts create mode 100644 src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts create mode 100644 src/renderer/src/lib/worktree-activation-store-contract.ts create mode 100644 src/renderer/src/lib/worktree-default-terminal-tabs.ts create mode 100644 src/renderer/src/lib/worktree-initial-terminal-seeding.ts create mode 100644 src/renderer/src/lib/worktree-nav-view-history-replay.ts create mode 100644 src/renderer/src/lib/worktree-setup-issue-command-queue.ts create mode 100644 src/renderer/src/lib/worktree-startup-payload.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 36ef290fe3a..277b6394585 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 51432bc1e5f..749038df3e4 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.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 diff --git a/src/renderer/src/components/tab-group/tab-drag-drop-commit.ts b/src/renderer/src/components/tab-group/tab-drag-drop-commit.ts new file mode 100644 index 00000000000..599a0aa017c --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-drop-commit.ts @@ -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 + +/** 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 + 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 + ) +} diff --git a/src/renderer/src/components/tab-group/tab-drag-gesture-lifecycle.ts b/src/renderer/src/components/tab-group/tab-drag-gesture-lifecycle.ts new file mode 100644 index 00000000000..b9855d04b51 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-gesture-lifecycle.ts @@ -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 +}): { + 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 + } +} diff --git a/src/renderer/src/components/tab-group/tab-drag-hover-preview.ts b/src/renderer/src/components/tab-group/tab-drag-hover-preview.ts new file mode 100644 index 00000000000..75017595999 --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-hover-preview.ts @@ -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 + dragGeometryRef: RefObject + tabInsertion: ReturnType +}): { + clear: () => void + handleDragUpdate: (event: DragMoveEvent | DragOverEvent) => void + hoveredDropTarget: HoveredTabDropTarget | null +} { + const [hoveredDropTarget, setHoveredDropTarget] = useState(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 } +} diff --git a/src/renderer/src/components/tab-group/tab-drag-missed-end-listeners.ts b/src/renderer/src/components/tab-group/tab-drag-missed-end-listeners.ts new file mode 100644 index 00000000000..4e397b6e21d --- /dev/null +++ b/src/renderer/src/components/tab-group/tab-drag-missed-end-listeners.ts @@ -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) + } +} diff --git a/src/renderer/src/components/tab-group/useTabDragSplit.ts b/src/renderer/src/components/tab-group/useTabDragSplit.ts index a3c5c49ce9c..a3520174aed 100644 --- a/src/renderer/src/components/tab-group/useTabDragSplit.ts +++ b/src/renderer/src/components/tab-group/useTabDragSplit.ts @@ -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 @@ -123,15 +102,28 @@ export function useTabDragSplit({ const reorderUnifiedTabs = useAppStore((state) => state.reorderUnifiedTabs) const dropUnifiedTab = useAppStore((state) => state.dropUnifiedTab) const [activeDrag, setActiveDrag] = useState(null) - const [hoveredDropTarget, setHoveredDropTarget] = useState(null) - const releaseWebviewDragPassthroughRef = useRef<(() => void) | null>(null) const preDragActivationSnapshotRef = useRef(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(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 diff --git a/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts b/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts new file mode 100644 index 00000000000..0f28e730eab --- /dev/null +++ b/src/renderer/src/components/tab-group/useTabGroupActivationCommands.ts @@ -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 } +} diff --git a/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.ts b/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.ts new file mode 100644 index 00000000000..a2f18a74dae --- /dev/null +++ b/src/renderer/src/components/tab-group/useTabGroupCloseScopeCommands.ts @@ -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 } +} diff --git a/src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts b/src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts new file mode 100644 index 00000000000..961aba21ebf --- /dev/null +++ b/src/renderer/src/components/tab-group/useTabGroupCreationCommands.ts @@ -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) + })() + } + } +} diff --git a/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts new file mode 100644 index 00000000000..dd31114853b --- /dev/null +++ b/src/renderer/src/components/tab-group/useTabGroupItemProjections.ts @@ -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 + +export type TabGroupWorktreeSnapshot = { + groups: readonly TabGroup[] + unifiedTabs: readonly Tab[] + terminalTabs: readonly TerminalTab[] + openFiles: TabGroupAppState['openFiles'] + browserTabs: readonly BrowserTabState[] + expandedPaneByTabId: TabGroupAppState['expandedPaneByTabId'] + terminalLayoutsByTabId: NonNullable + 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( + () => + 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( + () => + 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( + () => + 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 } +} diff --git a/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts new file mode 100644 index 00000000000..9b6e85c2fcc --- /dev/null +++ b/src/renderer/src/components/tab-group/useTabGroupTabCloseCommands.ts @@ -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 } +} diff --git a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 6bb2f7a5992..34a75641430 100644 --- a/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -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['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( - () => - 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( - () => - 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( - () => - 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, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-agent-session-actions.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-agent-session-actions.ts new file mode 100644 index 00000000000..7c802dc4131 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-agent-session-actions.ts @@ -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 + 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 => { + 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 => { + if (!pane) { + return + } + await copyAgentSessionContextFromPane(pane) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts new file mode 100644 index 00000000000..62051f84f26 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts @@ -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 => { + 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 => { + 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 => { + 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() + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts new file mode 100644 index 00000000000..295b7e35c53 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-paste.ts @@ -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 + paneTransportsRef: React.RefObject> + 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 => { + 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 +): Promise => { + 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() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts index 1e18395b894..35303c42190 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-writer-paths.test.ts @@ -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(() => ({ diff --git a/src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts b/src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts new file mode 100644 index 00000000000..97e3bd7efd1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-context-menu-trigger.ts @@ -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 + containerRef: React.RefObject + contextPaneIdRef: React.RefObject + rightClickToPaste: boolean + pasteResolvedPane: ( + source: Extract + ) => Promise +} + +type TerminalContextMenuTrigger = { + open: boolean + setOpen: React.Dispatch> + point: { x: number; y: number } + menuOpenedAtRef: React.RefObject + onContextMenuCapture: (event: React.MouseEvent) => void + onPaneTitleContextMenu: (event: React.MouseEvent, 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, + 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): 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, paneId: number): void => { + const boundsElement = containerRef.current + if (!boundsElement) { + event.preventDefault() + return + } + openContextMenu(event, paneId, boundsElement) + } + + return { open, setOpen, point, menuOpenedAtRef, onContextMenuCapture, onPaneTitleContextMenu } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 437ee512f56..101169b4304 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -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 @@ -131,20 +94,6 @@ export function useTerminalPaneContextMenu({ rightClickToPaste }: UseTerminalPaneContextMenuDeps): TerminalMenuState { const contextPaneIdRef = useRef(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 => { - 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 + ): Promise => + 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 => copyTerminalPaneMenuSelection(resolveMenuPane()) + const onSelectAll = (): void => { const pane = resolveMenuPane() if (pane) { @@ -183,231 +163,14 @@ export function useTerminalPaneContextMenu({ } } - const onCopyPaneId = async (): Promise => { - 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 => + 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 => { - 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 => { - 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 - ): Promise => { - 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 => + copyTerminalPaneMenuTerminalId(resolveMenuPane(), tabId) const onPaste = async (): Promise => 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).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 => { - const pane = resolveMenuPane() - if (!pane) { - return - } - const fork = prepareAgentSessionForkFromPane({ pane, tabId, worktreeId, groupId }) - if (fork) { - onAgentSessionForkReady(fork) - } - } + const onForkAgentSession = async (): Promise => + 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 => { - const pane = resolveMenuPane() - if (!pane) { - return - } - await copyAgentSessionContextFromPane(pane) - } + const onCopyAgentSessionContext = async (): Promise => + copyAgentSessionContextFromMenuPane(resolveMenuPane()) const onQuickCommand = (command: TerminalQuickCommand, historyId: string): void => { if (isTerminalAgentQuickCommand(command)) { @@ -524,77 +255,6 @@ export function useTerminalPaneContextMenu({ } } - const openContextMenu = ( - event: React.MouseEvent, - 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): 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, 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' -} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-split-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-split-actions.ts new file mode 100644 index 00000000000..beaf2dc7b6e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-split-actions.ts @@ -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 + paneTransportsRef: React.RefObject> + paneCwdRef: React.RefObject + contextPaneIdRef: React.RefObject + 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).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' +} diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index 2993d6dc4d4..ec91d1f969a 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -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, diff --git a/src/renderer/src/lib/launch-work-item-direct-agent.ts b/src/renderer/src/lib/launch-work-item-direct-agent.ts index 6073ef5e180..5fa1f3f349a 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent.ts @@ -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' diff --git a/src/renderer/src/lib/onboarding-folder-agent-startup.ts b/src/renderer/src/lib/onboarding-folder-agent-startup.ts index adb1a8bfbbf..5dbc893ecbd 100644 --- a/src/renderer/src/lib/onboarding-folder-agent-startup.ts +++ b/src/renderer/src/lib/onboarding-folder-agent-startup.ts @@ -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' diff --git a/src/renderer/src/lib/pane-manager/pane-manager-drag-wiring.ts b/src/renderer/src/lib/pane-manager/pane-manager-drag-wiring.ts new file mode 100644 index 00000000000..ce7fc6e1574 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-manager-drag-wiring.ts @@ -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 + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-manager-host.ts b/src/renderer/src/lib/pane-manager/pane-manager-host.ts new file mode 100644 index 00000000000..65d6997c5d6 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-manager-host.ts @@ -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 + 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>[1] + ) => void + getDragCallbacks: () => DragReorderCallbacks + setActivePane: (paneId: number, opts?: { focus?: boolean }) => void + setActivePaneId: (paneId: number | null) => void + requestPaneReparentFrame: (callback: FrameRequestCallback) => void +} diff --git a/src/renderer/src/lib/pane-manager/pane-manager-layout-sweeps.ts b/src/renderer/src/lib/pane-manager/pane-manager-layout-sweeps.ts new file mode 100644 index 00000000000..c7e60bb1118 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-manager-layout-sweeps.ts @@ -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): void { + for (const pane of panes.values()) { + fitRevealedPane(pane) + } +} + +export function refreshAllPaneTerminals(panes: Map): 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, + root: HTMLElement, + onLayoutChanged?: () => void +): void { + if (panes.size < 2) { + return + } + + const changed = equalizePaneSplitSizes( + root.firstElementChild instanceof HTMLElement ? root.firstElementChild : null + ) + if (!changed) { + return + } + + onLayoutChanged?.() +} diff --git a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts new file mode 100644 index 00000000000..11efdff06c0 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts @@ -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>[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 }) + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts b/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts new file mode 100644 index 00000000000..a211dc95443 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-manager-tree-mutations.ts @@ -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) + } + }) +} diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index f6d59594d01..8a99a00563e 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -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() + 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>[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() - } } diff --git a/src/renderer/src/lib/pane-manager/pane-public-view.ts b/src/renderer/src/lib/pane-manager/pane-public-view.ts index 578f69a49fc..abda535d956 100644 --- a/src/renderer/src/lib/pane-manager/pane-public-view.ts +++ b/src/renderer/src/lib/pane-manager/pane-public-view.ts @@ -13,3 +13,17 @@ export function toPublicPane(pane: ManagedPaneInternal): ManagedPane { serializeAddon: pane.serializeAddon } } + +export function collectPublicPanes( + panes: Map, + limit: number +): ManagedPane[] { + const collected: ManagedPane[] = [] + for (const pane of panes.values()) { + if (collected.length >= limit) { + break + } + collected.push(toPublicPane(pane)) + } + return collected +} diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts b/src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts new file mode 100644 index 00000000000..15faad3136c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-rendering-diagnostics.ts @@ -0,0 +1,18 @@ +import type { ManagedPaneInternal, PaneRenderingDiagnostics } from './pane-manager-types' +import { getTerminalWebglAutoDecision } from './terminal-webgl-auto-policy' + +export function collectPaneRenderingDiagnostics( + panes: Map +): 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) + })) +} diff --git a/src/renderer/src/lib/pane-manager/pane-reparent-frame-tracker.ts b/src/renderer/src/lib/pane-manager/pane-reparent-frame-tracker.ts new file mode 100644 index 00000000000..09101257161 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-reparent-frame-tracker.ts @@ -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() + + 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() + } +} diff --git a/src/renderer/src/lib/pending-worktree-creation.ts b/src/renderer/src/lib/pending-worktree-creation.ts index 173b4e19947..42ec2f2c392 100644 --- a/src/renderer/src/lib/pending-worktree-creation.ts +++ b/src/renderer/src/lib/pending-worktree-creation.ts @@ -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. diff --git a/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts new file mode 100644 index 00000000000..bf1664dc57c --- /dev/null +++ b/src/renderer/src/lib/web-runtime-worktree-terminal-after-wake.ts @@ -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) + }) +} diff --git a/src/renderer/src/lib/worktree-activation-agent-startup.test.ts b/src/renderer/src/lib/worktree-activation-agent-startup.test.ts index 244b8fd929f..5f450b99978 100644 --- a/src/renderer/src/lib/worktree-activation-agent-startup.test.ts +++ b/src/renderer/src/lib/worktree-activation-agent-startup.test.ts @@ -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 diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index cc2af6e7d7c..dd6eaa2ac31 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -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 { diff --git a/src/renderer/src/lib/worktree-activation-default-tabs.test.ts b/src/renderer/src/lib/worktree-activation-default-tabs.test.ts index c5ab769d0f8..3b990948340 100644 --- a/src/renderer/src/lib/worktree-activation-default-tabs.test.ts +++ b/src/renderer/src/lib/worktree-activation-default-tabs.test.ts @@ -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 diff --git a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts index ffa5369a845..9326be93960 100644 --- a/src/renderer/src/lib/worktree-activation-empty-remote.test.ts +++ b/src/renderer/src/lib/worktree-activation-empty-remote.test.ts @@ -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') diff --git a/src/renderer/src/lib/worktree-activation-issue-command.test.ts b/src/renderer/src/lib/worktree-activation-issue-command.test.ts index e3b741b4d2a..b84e7569238 100644 --- a/src/renderer/src/lib/worktree-activation-issue-command.test.ts +++ b/src/renderer/src/lib/worktree-activation-issue-command.test.ts @@ -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, diff --git a/src/renderer/src/lib/worktree-activation-setup-script.test.ts b/src/renderer/src/lib/worktree-activation-setup-script.test.ts index 52262876c39..ff9bc0874b8 100644 --- a/src/renderer/src/lib/worktree-activation-setup-script.test.ts +++ b/src/renderer/src/lib/worktree-activation-setup-script.test.ts @@ -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, diff --git a/src/renderer/src/lib/worktree-activation-store-contract.ts b/src/renderer/src/lib/worktree-activation-store-contract.ts new file mode 100644 index 00000000000..d6cac74b1f0 --- /dev/null +++ b/src/renderer/src/lib/worktree-activation-store-contract.ts @@ -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 & { + tabsByWorktree: Record + defaultTerminalTabsAppliedByWorktreeId: Record + 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 + 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; direction: SetupSplitDirection } + ) => void + queueTabIssueCommandSplit: ( + tabId: string, + startup: { command: string; env?: Record } + ) => void + queueTabInitialCwd: (tabId: string, cwd: string) => void + settings?: Pick | null +} + +export type InitialTerminalOptions = { + activateCreatedTabs?: boolean + backendStartupTerminalSpawned?: boolean +} diff --git a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts index 62b2174867d..98a7679ae6d 100644 --- a/src/renderer/src/lib/worktree-activation-web-runtime.test.ts +++ b/src/renderer/src/lib/worktree-activation-web-runtime.test.ts @@ -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, diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index 28d73b5dffc..36d092b2dd7 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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 - 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 - 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 | 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 } - -function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'windows' | 'posix' { - return getSetupRunnerCommandPlatformForPath( - setup.runnerScriptPath, - navigator.userAgent.includes('Windows') ? 'windows' : 'posix' - ) -} - -type WorktreeActivationStore = Partial & { - tabsByWorktree: Record - defaultTerminalTabsAppliedByWorktreeId: Record - 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 - 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; direction: SetupSplitDirection } - ) => void - queueTabIssueCommandSplit: ( - tabId: string, - startup: { command: string; env?: Record } - ) => void - queueTabInitialCwd: (tabId: string, cwd: string) => void - settings?: Pick | 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) diff --git a/src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts b/src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts index 5271779a2bd..3a24e77fd4e 100644 --- a/src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow-dedupe.test.ts @@ -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() })) diff --git a/src/renderer/src/lib/worktree-creation-flow-startup.ts b/src/renderer/src/lib/worktree-creation-flow-startup.ts index 4c946e0cf63..c1268d34a29 100644 --- a/src/renderer/src/lib/worktree-creation-flow-startup.ts +++ b/src/renderer/src/lib/worktree-creation-flow-startup.ts @@ -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 diff --git a/src/renderer/src/lib/worktree-creation-flow.test.ts b/src/renderer/src/lib/worktree-creation-flow.test.ts index f48591a8b2f..84ac3a61b32 100644 --- a/src/renderer/src/lib/worktree-creation-flow.test.ts +++ b/src/renderer/src/lib/worktree-creation-flow.test.ts @@ -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, diff --git a/src/renderer/src/lib/worktree-creation-flow.ts b/src/renderer/src/lib/worktree-creation-flow.ts index 93cd5cee2e4..d99a9792282 100644 --- a/src/renderer/src/lib/worktree-creation-flow.ts +++ b/src/renderer/src/lib/worktree-creation-flow.ts @@ -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 { diff --git a/src/renderer/src/lib/worktree-default-terminal-tabs.ts b/src/renderer/src/lib/worktree-default-terminal-tabs.ts new file mode 100644 index 00000000000..5a9d48723f0 --- /dev/null +++ b/src/renderer/src/lib/worktree-default-terminal-tabs.ts @@ -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 +} diff --git a/src/renderer/src/lib/worktree-initial-terminal-seeding.ts b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts new file mode 100644 index 00000000000..80b68c08a28 --- /dev/null +++ b/src/renderer/src/lib/worktree-initial-terminal-seeding.ts @@ -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 +} diff --git a/src/renderer/src/lib/worktree-nav-view-history-replay.ts b/src/renderer/src/lib/worktree-nav-view-history-replay.ts new file mode 100644 index 00000000000..bec19d864e1 --- /dev/null +++ b/src/renderer/src/lib/worktree-nav-view-history-replay.ts @@ -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 + } + })) +} diff --git a/src/renderer/src/lib/worktree-setup-issue-command-queue.ts b/src/renderer/src/lib/worktree-setup-issue-command-queue.ts new file mode 100644 index 00000000000..3c474d98c6a --- /dev/null +++ b/src/renderer/src/lib/worktree-setup-issue-command-queue.ts @@ -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 } + +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) + } +} diff --git a/src/renderer/src/lib/worktree-startup-payload.ts b/src/renderer/src/lib/worktree-startup-payload.ts new file mode 100644 index 00000000000..afb29b980dd --- /dev/null +++ b/src/renderer/src/lib/worktree-startup-payload.ts @@ -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 + 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 + 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 | 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 } +} diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 1c7cdddb62c..b33c3f9ad99 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -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'