From 40822e2c4e07b87e9bc26dd341a57650404da9c4 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 10:42:08 -0700 Subject: [PATCH] perf(switch): prewarm hovered and neighbor workspaces; coalesce held step keys (#35222) * fix(terminal): don't count a PTY reattach as workspace activity A remount (hidden prewarm, background mount) reattaching the tab's own live PTY bumped lastActivityAt and sortEpoch, re-sorting the sidebar on hover. Also keep store map identity on that no-op path so subscribers skip re-rendering. * perf(terminal): prewarm hovered and keyboard-neighbor workspaces before the switch A row hovered for 120 ms, and the previous/next workspaces in sidebar order (local hosts, 500 ms after a switch settles), take a prewarm lease: hover holds one, neighbors two. A lease mounts only each group's visible snapshot-restorable terminal plus live tabs, never wakes sleeping agents, and lays out and warms WebGL in separate idle tasks. Hover stays laid out until it ends; a neighbor hides again once warm. Held leases are exempt from ordinary cold parking (the retention budget still applies), individually parked sibling tabs stay unmounted, a narrowed live mount is handed back on release, and prewarm is off when hidden-view parking is disabled. * perf(sidebar): coalesce held workspace-step keys Auto-repeated Cmd+Shift+Up/Down and list arrows move only the sidebar highlight (and the listbox's active descendant); the workspace under it switches once the repeat stops, on keyup or a missed repeat since macOS withholds keyup while Cmd is held. Single presses still switch synchronously. Keyboard navigation also publishes its sidebar neighbors to the prewarm lease. * perf(palette): close Cmd+J without the exit fade after a selection A selection already moved the user to the new surface; the 150 ms overlay fade only dimmed it. Escape and outside dismissals keep the fade. * fix(terminal): undo a prewarm narrowing only while the lease still owns it Releasing a lease after its workspace was activated (or after another mount replaced the restriction) stripped the deferred tab ids that activation now owns, so over-cap tabs stayed on the restoring placeholder and lost parked watchers. The undo now runs only when the lease's restriction is still installed and the workspace was not activated. Simulator workspaces also get no restriction on any unrestricted mount, parked included, so the emulator overlay stays mounted. * fix(terminal): prewarm never reattaches a session the daemon has not confirmed live After a daemon loss a parked shell tab keeps its PTY id and snapshot capability, and its reattach spawns a replacement process. A lease now asks main (pty:hasPty, authoritative true only) for the revealable tabs it would reattach and admits only confirmed sessions; unknown or remote-runtime answers decline. Workspaces with a simulator tab never take a lease, since any restriction unmounts the emulator overlay and its local state. * fix(terminal): confirm every split leaf and keep parked siblings out after a lease ends A split tab reconnects each leaf on mount, so prewarm now confirms every resolved leaf session (re-resolved after the answer; any change fails) instead of only the tab's first PTY. Releasing a narrowed lease ends its measurement, which starts tab cold parking's 30 s cool-down with an empty park set; the narrowing now outlasts that cool-down so individually parked siblings stay unmounted after an abandoned hover. Prewarm target selection moves to its own file. * fix(terminal): hold a prewarm narrowing through any newer measurement cool-down A mount that measures the worktree after the lease ended starts its own tab cold-park cool-down; lifting the narrowing on the original deadline would remount parked siblings while parking is still off. The undo now re-waits while the worktree is measuring or inside its park cool-down. Teardown cancels pending undos after the final releases, which would otherwise schedule timers past the unmount. --------- Co-authored-by: m4air --- .../TerminalSplitWorkspaceSurfaces.tsx | 6 + .../TerminalWorktreeSplitSurface.tsx | 9 +- .../components/WorkspacePaneOverlayLayers.tsx | 4 + .../use-worktree-card-activation-actions.ts | 22 +- .../sidebar/worktree-card-surface.tsx | 4 + .../sidebar/worktree-keyboard-cycle.ts | 38 +++ .../worktree-list/navigation/use-keyboard.ts | 182 +++++++--- .../navigation/use-neighbor-prewarm.ts | 57 ++++ .../viewport/VirtualizedWorktreeViewport.tsx | 14 +- .../sidebar/worktree-row-hover-prewarm.ts | 80 +++++ .../terminal-pane/TerminalOverlaySlot.tsx | 10 +- .../TerminalPaneOverlayLayer.tsx | 5 + .../terminal-pane/terminal-pane-types.ts | 7 + .../use-terminal-pane-global-effects.ts | 28 ++ .../use-terminal-pane-global-listeners.ts | 2 + .../use-terminal-pane-store-bindings.ts | 6 +- .../terminal-parking-pass-candidates.ts | 8 +- .../terminal-worktree-prewarm-targets.ts | 49 +++ .../terminal-worktree-prewarm.test.ts | 186 ++++++++++ .../terminal/terminal-worktree-prewarm.ts | 187 ++++++++++ .../terminal-worktree-retained-frames.ts | 66 ++++ .../terminal/use-terminal-worktree-prewarm.ts | 319 ++++++++++++++++++ .../src/components/use-terminal-controller.ts | 2 + .../use-terminal-parking-foundation.ts | 11 + ...worktree-jump-palette-selection-actions.ts | 2 + .../worktree-jump-palette-exit-animation.ts | 10 + .../store/terminals/terminal-pty-bindings.ts | 48 ++- 27 files changed, 1292 insertions(+), 70 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/navigation/use-neighbor-prewarm.ts create mode 100644 src/renderer/src/components/sidebar/worktree-row-hover-prewarm.ts create mode 100644 src/renderer/src/components/terminal/terminal-worktree-prewarm-targets.ts create mode 100644 src/renderer/src/components/terminal/terminal-worktree-prewarm.test.ts create mode 100644 src/renderer/src/components/terminal/terminal-worktree-prewarm.ts create mode 100644 src/renderer/src/components/terminal/terminal-worktree-retained-frames.ts create mode 100644 src/renderer/src/components/terminal/use-terminal-worktree-prewarm.ts create mode 100644 src/renderer/src/components/worktree-jump-palette-exit-animation.ts diff --git a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx index 6a9e8d448f3..49d1dde2006 100644 --- a/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx +++ b/src/renderer/src/components/TerminalSplitWorkspaceSurfaces.tsx @@ -19,6 +19,7 @@ export function TerminalSplitWorkspaceSurfaces({ effectiveParkedTerminalWorktreeIds, forceParkedTerminalWorktreeIds, getEffectiveLayoutForWorktree, + prewarmLeasesRef, measurableBackgroundWorktreeIdsRef, mountedWorktreeIdsRef, renderedActiveWorktreeId, @@ -63,6 +64,11 @@ export function TerminalSplitWorkspaceSurfaces({ focusedGroupId={activeGroupIdByWorktree[workspace.id]} isVisible={isVisible} shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree} + prewarmStage={ + shouldMeasureHiddenWorktree + ? (prewarmLeasesRef.current.get(workspace.id) ?? null) + : null + } shouldColdParkTerminalPanes={shouldColdParkTerminalPanes} isForceParked={forceParkedTerminalWorktreeIds.has(workspace.id)} activityTerminalPortals={activityTerminalPortals} diff --git a/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx b/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx index 03a3a472703..b3658ec582f 100644 --- a/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx +++ b/src/renderer/src/components/TerminalWorktreeSplitSurface.tsx @@ -11,6 +11,7 @@ import { } from './browser-pane/host-guest/browser-worktree-surface-paintability' import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout' import { WorkspacePaneOverlayLayers } from './WorkspacePaneOverlayLayers' +import type { TerminalPrewarmStage } from './terminal/terminal-worktree-prewarm' export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ worktreeId, @@ -19,6 +20,7 @@ export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ focusedGroupId, isVisible, shouldMeasureHiddenWorktree, + prewarmStage, shouldColdParkTerminalPanes, isForceParked, activityTerminalPortals, @@ -31,6 +33,8 @@ export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ focusedGroupId?: string isVisible: boolean shouldMeasureHiddenWorktree: boolean + /** Hover intent: mount, then lay out, then render the revealable terminals while hidden. */ + prewarmStage: TerminalPrewarmStage | null shouldColdParkTerminalPanes: boolean isForceParked: boolean activityTerminalPortals: ActivityTerminalPortalTarget[] @@ -40,7 +44,8 @@ export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ const browserPageIds = useWorktreeBrowserPageIds(worktreeId) const needsBrowserGuestPaint = useBrowserGuestPaintRetention(browserPageIds) const shouldKeepPaintable = shouldKeepHiddenWorktreeSurfacePaintable({ - shouldMeasureHiddenWorktree, + // A mount-stage prewarm stays display:none so its style/layout cost lands in a later task. + shouldMeasureHiddenWorktree: shouldMeasureHiddenWorktree && prewarmStage !== 'mount', needsBrowserGuestPaint }) @@ -55,6 +60,7 @@ export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ } inert={!isVisible} aria-hidden={!isVisible} + data-workspace-surface-id={worktreeId} > ) => { + // Touch and pen have no hover intent; a disconnected SSH host cannot reattach a PTY. + if (event.pointerType !== 'mouse' || isActive || isDeleting || isSshDisconnected) { + return + } + noteWorktreeRowHoverStart(worktree.id) + }, + [isActive, isDeleting, isSshDisconnected, worktree.id] + ) + + const handlePointerLeave = useCallback(() => { + noteWorktreeRowHoverEnd(worktree.id) + }, [worktree.id]) + // A row unmounted under the pointer never receives pointerleave. + useEffect(() => () => noteWorktreeRowHoverEnd(worktree.id), [worktree.id]) + const handleRenameTitle = useCallback( // Store logging/refetch reverts failed optimistic renames; inline rename has no separate error surface. async (displayName: string): Promise => { @@ -230,6 +248,8 @@ export function useWorktreeCardActivationActions({ handleClick, handlePointerDown, handleMouseDown, + handlePointerEnter, + handlePointerLeave, handleRenameTitle, handleDoubleClick, handleToggleUnreadQuick diff --git a/src/renderer/src/components/sidebar/worktree-card-surface.tsx b/src/renderer/src/components/sidebar/worktree-card-surface.tsx index ad6da0b6232..3e647c5bdb4 100644 --- a/src/renderer/src/components/sidebar/worktree-card-surface.tsx +++ b/src/renderer/src/components/sidebar/worktree-card-surface.tsx @@ -34,6 +34,8 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): deleteLabel, handleClick, handlePointerDown, + handlePointerEnter, + handlePointerLeave, handleMouseDown, handleDoubleClick, handleDragStart, @@ -80,6 +82,8 @@ export function WorktreeCardSurface({ card }: { card: WorktreeCardController }): data-worktree-lineage-drop-target={isLineageDropTarget || undefined} onClick={handleClick} onPointerDown={handlePointerDown} + onPointerEnter={affiliateListMode ? undefined : handlePointerEnter} + onPointerLeave={affiliateListMode ? undefined : handlePointerLeave} onMouseDown={handleMouseDown} onDoubleClick={affiliateListMode ? undefined : handleDoubleClick} draggable={!affiliateListMode && nativeDragEnabled && !isDeleting && !titleRenaming} diff --git a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.ts b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.ts index 36bf4e80a5b..b6560c6b2df 100644 --- a/src/renderer/src/components/sidebar/worktree-keyboard-cycle.ts +++ b/src/renderer/src/components/sidebar/worktree-keyboard-cycle.ts @@ -132,3 +132,41 @@ export function resolveCycledWorktreeId(args: { const nextIndex = (currentIndex + step + worktreeIds.length) % worktreeIds.length return worktreeIds[nextIndex] ?? null } + +/** The rendered row one keyboard step lands on, in exact sidebar order. */ +export function resolveCycledWorkspaceRow(args: { + rows: readonly HostSectionRow[] + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + activeWorktreeId: string | null + activeWorkspaceExecutionHostId: ExecutionHostId | null + lastActiveWorkspace: LastActiveCycleWorkspace | null + navHistory: readonly WorktreeNavHistoryEntry[] + navHistoryIndex: number + direction: 'up' | 'down' +}): RenderedWorkspaceRow | null { + const worktreeRows = getCyclableWorktreeRows(args.rows, args.pinnedDisplayPolicy) + const anchorWorktreeId = resolveCycleAnchorWorktreeId({ + activeWorktreeId: args.activeWorktreeId, + navHistory: args.navHistory, + navHistoryIndex: args.navHistoryIndex, + worktreeIds: worktreeRows.map((row) => row.worktree.id) + }) + const nextIdentity = resolveCycledWorktreeId({ + worktreeIds: worktreeRows.map(getCyclableRowIdentity), + activeWorktreeId: resolveActiveCycleIdentity({ + rows: worktreeRows, + activeWorktreeId: anchorWorktreeId, + activeWorkspaceExecutionHostId: resolveCycleAnchorExecutionHostId({ + anchorWorktreeId, + activeWorktreeId: args.activeWorktreeId, + activeWorkspaceExecutionHostId: args.activeWorkspaceExecutionHostId, + lastActiveWorkspace: args.lastActiveWorkspace + }) + }), + direction: args.direction + }) + if (nextIdentity === null) { + return null + } + return worktreeRows.find((row) => getCyclableRowIdentity(row) === nextIdentity) ?? null +} diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts index c4579647b24..76e086bafa2 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-keyboard.ts @@ -1,5 +1,5 @@ import { textFieldClaimsActionKey } from '@/lib/file-search-shortcut-policy' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type React from 'react' import type { Virtualizer } from '@tanstack/react-virtual' import { useAppStore } from '@/store' @@ -12,17 +12,34 @@ import { keybindingMatchesAction } from '../../../../../../shared/keybindings' import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows' import type { PinnedWorktreeDisplayPolicy } from '../../../../../../shared/sidebar-list/row-types' import type { RenderRow } from '../listing/render-row' +import type { Worktree } from '../../../../../../shared/worktree/types' import { - getCyclableRowIdentity, - getCyclableWorktreeRows, - resolveActiveCycleIdentity, - resolveCycleAnchorExecutionHostId, - resolveCycleAnchorWorktreeId, - resolveCycledWorktreeId, + resolveCycledWorkspaceRow, type LastActiveCycleWorkspace } from '../../worktree-keyboard-cycle' import { findPreferredRenderRowIndexForWorktreeIdentity } from './render-row-lookup' import { addAppKeyListener, removeAppKeyListener } from '@/lib/app-keyboard-router' +import { useWorkspaceNeighborPrewarm } from './use-neighbor-prewarm' + +const PREVIEW_COMMIT_FIRST_REPEAT_MS = 120 +const PREVIEW_COMMIT_MIN_MS = 50 +const PREVIEW_COMMIT_MAX_MS = 200 + +/** A held arrow previews its row as active before the workspace switch commits. */ +export function withKeyboardPreview< + P extends { + activeWorktreeId: string | null + activeWorkspaceExecutionHostId: ExecutionHostId | null + } +>(props: P, preview: LastActiveCycleWorkspace | null): P { + return preview + ? { + ...props, + activeWorktreeId: preview.worktreeId, + activeWorkspaceExecutionHostId: preview.executionHostId + } + : props +} export function useWorktreeListKeyboardNavigation(args: { rows: HostSectionRow[] @@ -47,6 +64,12 @@ export function useWorktreeListKeyboardNavigation(args: { markDirectScrollInput } = args const keybindings = useAppStore((s) => s.keybindings) + useWorkspaceNeighborPrewarm({ + rows, + pinnedDisplayPolicy, + activeWorktreeId, + activeWorkspaceExecutionHostId + }) const lastActiveWorkspaceRef = useRef(null) useEffect(() => { // Closing the last tab clears both selection and host; history retains only the id. @@ -58,42 +81,88 @@ export function useWorktreeListKeyboardNavigation(args: { } }, [activeWorktreeId, activeWorkspaceExecutionHostId]) + // Held arrows move only the sidebar highlight; the workspace under it switches once the repeat stops. + // fromWorktreeId: another input switching workspaces mid-hold invalidates the preview, so it never pulls the user back. + const previewRef = useRef<{ worktree: Worktree; fromWorktreeId: string | null } | null>(null) + const [keyboardPreview, setKeyboardPreview] = useState< + (LastActiveCycleWorkspace & { fromWorktreeId: string | null }) | null + >(null) + const previewCommitTimerRef = useRef(null) + const lastRepeatAtRef = useRef(null) + const clearPreviewCommitTimer = useCallback(() => { + if (previewCommitTimerRef.current !== null) { + window.clearTimeout(previewCommitTimerRef.current) + previewCommitTimerRef.current = null + } + }, []) + const dropPreview = useCallback(() => { + clearPreviewCommitTimer() + lastRepeatAtRef.current = null + if (previewRef.current !== null) { + previewRef.current = null + setKeyboardPreview(null) + } + }, [clearPreviewCommitTimer]) + const commitPreview = useCallback(() => { + const preview = previewRef.current + dropPreview() + if (preview && preview.fromWorktreeId === useAppStore.getState().activeWorktreeId) { + void activateWorktreeFromSidebar(preview.worktree.id, preview.worktree.hostId) + } + }, [dropPreview]) + useEffect(() => clearPreviewCommitTimer, [clearPreviewCommitTimer]) + const navigateWorktree = useCallback( - (direction: 'up' | 'down') => { - // Cycle actual rendered rows so collapse, pins, hosts and folder workspaces stay aligned with the screen. - const worktreeRows = getCyclableWorktreeRows(rows, pinnedDisplayPolicy) + (direction: 'up' | 'down', isRepeat: boolean) => { const { worktreeNavHistory, worktreeNavHistoryIndex } = useAppStore.getState() - const anchorWorktreeId = resolveCycleAnchorWorktreeId({ - activeWorktreeId, + const previewState = previewRef.current + const preview = + previewState && previewState.fromWorktreeId === activeWorktreeId + ? previewState.worktree + : null + // Cycle actual rendered rows so collapse, pins, hosts and folder workspaces stay aligned with the screen. + const nextWorktree = resolveCycledWorkspaceRow({ + rows, + pinnedDisplayPolicy, + activeWorktreeId: preview ? preview.id : activeWorktreeId, + activeWorkspaceExecutionHostId: preview + ? (preview.hostId ?? null) + : activeWorkspaceExecutionHostId, + lastActiveWorkspace: lastActiveWorkspaceRef.current, navHistory: worktreeNavHistory, navHistoryIndex: worktreeNavHistoryIndex, - worktreeIds: worktreeRows.map((row) => row.worktree.id) - }) - const nextWorktreeIdentity = resolveCycledWorktreeId({ - worktreeIds: worktreeRows.map(getCyclableRowIdentity), - activeWorktreeId: resolveActiveCycleIdentity({ - rows: worktreeRows, - activeWorktreeId: anchorWorktreeId, - activeWorkspaceExecutionHostId: resolveCycleAnchorExecutionHostId({ - anchorWorktreeId, - activeWorktreeId, - activeWorkspaceExecutionHostId, - lastActiveWorkspace: lastActiveWorkspaceRef.current - }) - }), direction - }) - if (nextWorktreeIdentity === null) { - return - } - const nextWorktree = worktreeRows.find( - (row) => getCyclableRowIdentity(row) === nextWorktreeIdentity - )?.worktree + })?.worktree if (!nextWorktree) { return } - void activateWorktreeFromSidebar(nextWorktree.id, nextWorktree.hostId) + if (isRepeat) { + previewRef.current = { worktree: nextWorktree, fromWorktreeId: activeWorktreeId } + setKeyboardPreview({ + worktreeId: nextWorktree.id, + executionHostId: nextWorktree.hostId ?? null, + fromWorktreeId: activeWorktreeId + }) + const now = performance.now() + const repeatIntervalMs = + lastRepeatAtRef.current === null ? null : now - lastRepeatAtRef.current + lastRepeatAtRef.current = now + clearPreviewCommitTimer() + // Commit once repeats stop; macOS drops keyup while Cmd is held, so a missed repeat is the stop signal. + previewCommitTimerRef.current = window.setTimeout( + commitPreview, + repeatIntervalMs === null + ? PREVIEW_COMMIT_FIRST_REPEAT_MS + : Math.min( + PREVIEW_COMMIT_MAX_MS, + Math.max(PREVIEW_COMMIT_MIN_MS, repeatIntervalMs * 1.5) + ) + ) + } else { + dropPreview() + void activateWorktreeFromSidebar(nextWorktree.id, nextWorktree.hostId) + } const rowIndex = findPreferredRenderRowIndexForWorktreeIdentity( renderRows, @@ -110,7 +179,10 @@ export function useWorktreeListKeyboardNavigation(args: { activeWorktreeId, activeWorkspaceExecutionHostId, virtualizer, - pinnedDisplayPolicy + pinnedDisplayPolicy, + clearPreviewCommitTimer, + commitPreview, + dropPreview ] ) @@ -143,22 +215,42 @@ export function useWorktreeListKeyboardNavigation(args: { if (direction && !textFieldClaimsActionKey(e, directionAction) && !hasVisibleOverlay()) { endListNavigation() markDirectScrollInput() - navigateWorktree(direction) + navigateWorktree(direction, e.repeat) e.preventDefault() // Why: a user-bound chord fired from a field must not also reach the field's own handler. e.stopPropagation() } } + const handleKeyUp = () => { + if (previewRef.current !== null) { + commitPreview() + } + } + const handlePointerDown = () => { + endListNavigation() + dropPreview() + } + addAppKeyListener('keydown', handleKeyDown) - window.addEventListener('pointerdown', endListNavigation, { capture: true }) + window.addEventListener('keyup', handleKeyUp, { capture: true }) + window.addEventListener('pointerdown', handlePointerDown, { capture: true }) window.addEventListener('focusout', endListNavigation, { capture: true }) return () => { removeAppKeyListener('keydown', handleKeyDown) - window.removeEventListener('pointerdown', endListNavigation, { capture: true }) + window.removeEventListener('keyup', handleKeyUp, { capture: true }) + window.removeEventListener('pointerdown', handlePointerDown, { capture: true }) window.removeEventListener('focusout', endListNavigation, { capture: true }) } - }, [activeModal, keybindings, markDirectScrollInput, navigateWorktree, scrollRef]) + }, [ + activeModal, + commitPreview, + dropPreview, + keybindings, + markDirectScrollInput, + navigateWorktree, + scrollRef + ]) const handleContainerKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -174,9 +266,12 @@ export function useWorktreeListKeyboardNavigation(args: { // The focused DOM node owns navigation, including while a new terminal mounts. e.currentTarget.setAttribute('data-keyboard-navigation', '') markDirectScrollInput() - navigateWorktree(e.key === 'ArrowUp' ? 'up' : 'down') + navigateWorktree(e.key === 'ArrowUp' ? 'up' : 'down', e.repeat) e.preventDefault() } else if (e.key === 'Enter') { + if (previewRef.current !== null) { + commitPreview() + } const { activeView, activeWorktreeId, activeTabType, activeTabId, tabsByWorktree } = useAppStore.getState() if ( @@ -194,8 +289,11 @@ export function useWorktreeListKeyboardNavigation(args: { markDirectScrollInput() } }, - [activeModal, markDirectScrollInput, navigateWorktree] + [activeModal, commitPreview, markDirectScrollInput, navigateWorktree] ) - return { handleContainerKeyDown } + return { + handleContainerKeyDown, + keyboardPreview: keyboardPreview?.fromWorktreeId === activeWorktreeId ? keyboardPreview : null + } } diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/use-neighbor-prewarm.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/use-neighbor-prewarm.ts new file mode 100644 index 00000000000..eb4da230112 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/use-neighbor-prewarm.ts @@ -0,0 +1,57 @@ +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { setTerminalPrewarmWorktrees } from '@/components/terminal/terminal-worktree-prewarm' +import { + getWorktreeExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../../../../../shared/execution-host' +import type { HostSectionRow } from '../../../../../../shared/sidebar-list/host-section-rows' +import type { PinnedWorktreeDisplayPolicy } from '../../../../../../shared/sidebar-list/row-types' +import { resolveCycledWorkspaceRow } from '../../worktree-keyboard-cycle' + +// Settle first so prewarm never competes with the switch that just happened or a key-repeat scan. +const NEIGHBOR_PREWARM_DELAY_MS = 500 + +/** Keep the workspaces one keyboard step away warm, so stepping lands on a live surface. */ +export function useWorkspaceNeighborPrewarm(args: { + rows: HostSectionRow[] + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + activeWorktreeId: string | null + activeWorkspaceExecutionHostId: ExecutionHostId | null +}): void { + const { rows, pinnedDisplayPolicy, activeWorktreeId, activeWorkspaceExecutionHostId } = args + useEffect(() => { + if (activeWorktreeId === null) { + setTerminalPrewarmWorktrees('neighbor', []) + return + } + const timer = window.setTimeout(() => { + const { worktreeNavHistory, worktreeNavHistoryIndex } = useAppStore.getState() + const neighborIds = new Set() + for (const direction of ['up', 'down'] as const) { + const row = resolveCycledWorkspaceRow({ + rows, + pinnedDisplayPolicy, + activeWorktreeId, + activeWorkspaceExecutionHostId, + lastActiveWorkspace: null, + navHistory: worktreeNavHistory, + navHistoryIndex: worktreeNavHistoryIndex, + direction + }) + // Remote remounts replay snapshots over the network; a speculative neighbor stays local-only. + if ( + row && + row.worktree.id !== activeWorktreeId && + parseExecutionHostId(getWorktreeExecutionHostId(row.worktree, row.repo))?.kind === 'local' + ) { + neighborIds.add(row.worktree.id) + } + } + setTerminalPrewarmWorktrees('neighbor', [...neighborIds]) + }, NEIGHBOR_PREWARM_DELAY_MS) + return () => window.clearTimeout(timer) + }, [rows, pinnedDisplayPolicy, activeWorktreeId, activeWorkspaceExecutionHostId]) + useEffect(() => () => setTerminalPrewarmWorktrees('neighbor', []), []) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx index e458b25d6b7..e430ae535cb 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx @@ -19,7 +19,7 @@ import { useWorktreeDragRuntime } from '../drag/use-runtime' import { useWorktreeDragSession } from '../drag/use-session' import { useWorktreeDocumentDrop } from '../drag/use-document-drop' import { useWorktreeLineageDropCommit } from '../drag/use-lineage-drop-commit' -import { useWorktreeListKeyboardNavigation } from '../navigation/use-keyboard' +import { useWorktreeListKeyboardNavigation, withKeyboardPreview } from '../navigation/use-keyboard' import { useWorktreeListVirtualizer } from './use-virtualizer' import { useWorktreeNativeDrag } from '../drag/use-native-drag' import { useWorktreePointerDrag } from '../drag/use-pointer-drag' @@ -227,7 +227,7 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr toggleGroup }) - const { handleContainerKeyDown } = useWorktreeListKeyboardNavigation({ + const { handleContainerKeyDown, keyboardPreview } = useWorktreeListKeyboardNavigation({ rows, renderRows, activeWorktreeId, @@ -337,8 +337,12 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr markScrollMovement() }, [markScrollMovement]) + const rowProps = useMemo( + () => withKeyboardPreview(props, keyboardPreview), + [keyboardPreview, props] + ) const rowContext = buildWorktreeVirtualRowContext({ - props, + props: rowProps, renderRows, firstHeaderIndex, virtualization, @@ -378,8 +382,8 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr aria-orientation="vertical" aria-multiselectable="true" aria-activedescendant={getActiveDescendantOptionId({ - activeWorktreeId, - activeWorkspaceExecutionHostId: props.activeWorkspaceExecutionHostId, + activeWorktreeId: rowProps.activeWorktreeId, + activeWorkspaceExecutionHostId: rowProps.activeWorkspaceExecutionHostId, primaryActiveRowKey: primaryActive.primaryActiveWorktreeRow?.rowKey, pinnedDisplayPolicy, renderRows, diff --git a/src/renderer/src/components/sidebar/worktree-row-hover-prewarm.ts b/src/renderer/src/components/sidebar/worktree-row-hover-prewarm.ts new file mode 100644 index 00000000000..6687a90c768 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-row-hover-prewarm.ts @@ -0,0 +1,80 @@ +import { setTerminalPrewarmWorktrees } from '../terminal/terminal-worktree-prewarm' + +// Long enough that sweeping or scrolling past rows never mounts them; short enough to finish before a typical press. +export const HOVER_PREWARM_DWELL_MS = 120 +// Brief exits (e.g. toward a row action) must not drop the warm mount and pay it again. +export const HOVER_PREWARM_GRACE_MS = 1500 + +let dwellTimer: ReturnType | null = null +let dwellWorktreeId: string | null = null +let graceTimer: ReturnType | null = null +let prewarmedWorktreeId: string | null = null +let wheelListenerInstalled = false + +function clearDwell(): void { + if (dwellTimer !== null) { + clearTimeout(dwellTimer) + } + dwellTimer = null + dwellWorktreeId = null +} + +function clearGrace(): void { + if (graceTimer !== null) { + clearTimeout(graceTimer) + } + graceTimer = null +} + +function armDwell(worktreeId: string): void { + clearDwell() + dwellWorktreeId = worktreeId + dwellTimer = setTimeout(() => { + dwellTimer = null + dwellWorktreeId = null + clearGrace() + prewarmedWorktreeId = worktreeId + setTerminalPrewarmWorktrees('hover', [worktreeId]) + }, HOVER_PREWARM_DWELL_MS) +} + +// Rows scrolling under a resting pointer fire pointerenter; restart the dwell so scrolling never prewarms. +function installWheelListener(): void { + if (wheelListenerInstalled || typeof window === 'undefined') { + return + } + wheelListenerInstalled = true + window.addEventListener( + 'wheel', + () => { + if (dwellWorktreeId !== null) { + armDwell(dwellWorktreeId) + } + }, + { capture: true, passive: true } + ) +} + +export function noteWorktreeRowHoverStart(worktreeId: string): void { + installWheelListener() + if (prewarmedWorktreeId === worktreeId) { + clearDwell() + clearGrace() + return + } + armDwell(worktreeId) +} + +export function noteWorktreeRowHoverEnd(worktreeId: string): void { + if (dwellWorktreeId === worktreeId) { + clearDwell() + } + if (prewarmedWorktreeId !== worktreeId || graceTimer !== null) { + return + } + graceTimer = setTimeout(() => { + graceTimer = null + prewarmedWorktreeId = null + setTerminalPrewarmWorktrees('hover', []) + }, HOVER_PREWARM_GRACE_MS) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx index 95cee07d9d3..ca49adf1f2e 100644 --- a/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalOverlaySlot.tsx @@ -5,6 +5,7 @@ import { isProvenProcessExit } from '../../../../shared/terminal-exit-cause' import { RetainedPaneHost } from '../tab-group/RetainedPaneHost' import type { ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal' import TerminalPane from './TerminalPane' +import type { HiddenTerminalPrewarm } from './terminal-pane-types' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers' @@ -18,6 +19,8 @@ type TerminalOverlaySlotProps = { isWorktreeActive: boolean isVisible: boolean isActive: boolean + /** Hover prewarm of a hidden reveal target. */ + hiddenPrewarm?: HiddenTerminalPrewarm | null /** Visible on the unfocused side of a split. */ dimmed?: boolean activityTerminalPortal: ActivityTerminalPortalTarget | null @@ -37,6 +40,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ isWorktreeActive, isVisible, isActive, + hiddenPrewarm = null, dimmed = false, activityTerminalPortal, onFocusOwningGroup, @@ -52,6 +56,8 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ } }, [isVisible, shouldMeasureHiddenStartup]) + // Drop with the reveal in one render, or a later lease release re-runs resume and steals focus. + const prewarm = isVisible || activityTerminalPortal !== null ? null : hiddenPrewarm const terminalPane = ( { if (consumeSuppressedPtyExit(ptyId)) { return @@ -101,7 +109,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ | null @@ -166,6 +170,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ isWorktreeActive={isWorktreeActive} isVisible={isVisible} isActive={isActive} + hiddenPrewarm={assignment?.isActiveInGroup === true ? hiddenTerminalPrewarm : null} dimmed={groups.length > 1 && isVisible && !isActive} activityTerminalPortal={activityTerminalPortal} onFocusOwningGroup={focusOwningGroup} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-types.ts b/src/renderer/src/components/terminal-pane/terminal-pane-types.ts index f2fc1773890..2426008ae21 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-types.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-types.ts @@ -1,3 +1,6 @@ +/** 'layout' fits the hidden pane at real size; 'warm' also keeps its renderer live. */ +export type HiddenTerminalPrewarm = 'layout' | 'warm' + export type TerminalPaneProps = { tabId: string worktreeId: string @@ -5,6 +8,10 @@ export type TerminalPaneProps = { isActive: boolean isVisible?: boolean isWorktreeActive?: boolean + /** Lay out while hidden (like a hidden startup) so a likely reveal skips first fit and style. */ + measureWhileHidden?: boolean + /** Keep WebGL attached in the laid-out hidden host so a likely reveal skips context and atlas setup. */ + warmRendererWhileHidden?: boolean // Activity portals can isolate one split without changing expanded state or persistence. isolatedPaneKey?: string | null // Why: one-off command terminals keep split shortcuts but hide the prominent header button. diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 7246fecc3ea..7df10dd6e03 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -36,6 +36,8 @@ type UseTerminalPaneGlobalEffectsArgs = { isChatViewMode?: boolean isWorktreeActive?: boolean isSyncFitEnabled: boolean + /** Keep WebGL attached in a laid-out hidden host so a likely reveal skips context and atlas setup. */ + warmRendererWhileHidden?: boolean paneCount: number managerRef: React.RefObject containerRef: React.RefObject @@ -67,6 +69,7 @@ export function useTerminalPaneGlobalEffects({ isChatViewMode = false, isWorktreeActive = isVisible, isSyncFitEnabled, + warmRendererWhileHidden = false, paneCount, managerRef, containerRef, @@ -82,6 +85,7 @@ export function useTerminalPaneGlobalEffects({ const hasCompletedVisibleResumeRef = useRef(false) const renderingSuspendedByVisibilityRef = useRef(false) const hiddenReasonRef = useRef(null) + const warmedWhileHiddenRef = useRef(false) const rendererVisible = isVisible && isWorktreeActive // Watch leaf → PTY rebinds even without activity/visibility changes so main grants the live PTY its interactive reserve. const activeLeafPtyId = useAppStore((state) => { @@ -158,9 +162,32 @@ export function useTerminalPaneGlobalEffects({ wasWorktreeActiveRef.current = isWorktreeActive hasCompletedVisibleResumeRef.current = true hiddenReasonRef.current = null + warmedWhileHiddenRef.current = false applyPendingFollowOutputRequests() return } + if (warmRendererWhileHidden && isWorktreeActive === false) { + if (wasVisible) { + captureViewportPositions(false) + } + manager.resumeRendering() + warmedWhileHiddenRef.current = true + renderingSuspendedByVisibilityRef.current = false + hiddenReasonRef.current = 'surface' + wasVisibleRef.current = false + wasWorktreeActiveRef.current = isWorktreeActive + return + } + if (warmedWhileHiddenRef.current) { + // Warm-up ended without a reveal; release the context like any hidden worktree. + warmedWhileHiddenRef.current = false + manager.suspendRendering() + renderingSuspendedByVisibilityRef.current = true + hiddenReasonRef.current = 'surface' + wasVisibleRef.current = false + wasWorktreeActiveRef.current = isWorktreeActive + return + } const hiddenState = hideTerminalVisibility({ manager, wasVisible, @@ -179,6 +206,7 @@ export function useTerminalPaneGlobalEffects({ isChatViewMode, isWorktreeActive, rendererVisible, + warmRendererWhileHidden, applyPendingFollowOutputRequests, captureViewportPositions, containerRef, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-listeners.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-listeners.ts index 994e0cfbb0f..e8620b05824 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-listeners.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-listeners.ts @@ -52,6 +52,7 @@ export function useTerminalPaneGlobalListeners(controller: TerminalPaneCloseCont settings, settingsRef, shouldMeasureHiddenStartup, + warmRendererWhileHidden, tabId, toggleExpandPane, worktreeId @@ -96,6 +97,7 @@ export function useTerminalPaneGlobalListeners(controller: TerminalPaneCloseCont isChatViewMode: effectiveChatViewMode, isWorktreeActive, isSyncFitEnabled: isRendererVisible || shouldMeasureHiddenStartup, + warmRendererWhileHidden: warmRendererWhileHidden === true, paneCount, managerRef, containerRef, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-store-bindings.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-store-bindings.ts index 1847e453f98..2b351a218a7 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-store-bindings.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-store-bindings.ts @@ -7,7 +7,7 @@ import { useTerminalPaneStoreActions } from './use-terminal-pane-store-actions' import type { TerminalPaneChatController } from './use-terminal-pane-chat-state' export function useTerminalPaneStoreBindings(controller: TerminalPaneChatController) { - const { expectedLayoutLeafIds, isVisible, restoredLayout, tabId } = controller + const { expectedLayoutLeafIds, isVisible, measureWhileHidden, restoredLayout, tabId } = controller const { clearRuntimePaneTitle, clearTabPtyId, @@ -32,9 +32,11 @@ export function useTerminalPaneStoreBindings(controller: TerminalPaneChatControl const rightClickToPaste = settings?.terminalRightClickToPaste ?? isWindowsUserAgent() const forceBracketedMultilineTextPaste = isWindowsUserAgent() const [startup] = useState(() => useAppStore.getState().pendingStartupByTabId[tabId]) - const [shouldMeasureHiddenStartup, setShouldMeasureHiddenStartup] = useState( + const [shouldMeasureHiddenStartupState, setShouldMeasureHiddenStartup] = useState( () => startup !== undefined && !isVisible ) + const shouldMeasureHiddenStartup = + shouldMeasureHiddenStartupState || (measureWhileHidden === true && !isVisible) const [sessionRestoredBannerPaneIds, setSessionRestoredBannerPaneIds] = useState< Map >(() => new Map()) diff --git a/src/renderer/src/components/terminal-parking-pass-candidates.ts b/src/renderer/src/components/terminal-parking-pass-candidates.ts index 98f9ff5e5cd..95c5439f929 100644 --- a/src/renderer/src/components/terminal-parking-pass-candidates.ts +++ b/src/renderer/src/components/terminal-parking-pass-candidates.ts @@ -7,6 +7,7 @@ import { } from './terminal-pane/terminal-hidden-view-parking' import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides' import { canWatcherCoverParkedTerminalTab } from './terminal-pane/terminal-parked-tab-watchers' +import { getHeldTerminalPrewarmWorktrees } from './terminal/terminal-worktree-prewarm' import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' export type TerminalParkingPassCandidates = ReturnType @@ -113,8 +114,13 @@ export function collectTerminalParkingPassCandidates(controller: TerminalParking watcherCoverageByTabId.set(tab.id, covered) return covered }) + // Leased prewarm worktrees (hover, keyboard neighbors) stay mounted; the retention budget still applies. + const keepWarmWorktreeIds = getHeldTerminalPrewarmWorktrees() for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) { - if (!worktreeTabsAreWatcherCovered(worktreeId, tabsByWorktree[worktreeId] ?? [])) { + if ( + keepWarmWorktreeIds.has(worktreeId) || + !worktreeTabsAreWatcherCovered(worktreeId, tabsByWorktree[worktreeId] ?? []) + ) { nextParkedTerminalWorktreeIds.delete(worktreeId) } } diff --git a/src/renderer/src/components/terminal/terminal-worktree-prewarm-targets.ts b/src/renderer/src/components/terminal/terminal-worktree-prewarm-targets.ts new file mode 100644 index 00000000000..31113e95ad5 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-worktree-prewarm-targets.ts @@ -0,0 +1,49 @@ +import type { AppState } from '@/store/types' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { getSleepingRecordTabId } from '@/lib/sleeping-agent-wake-requests' +import { resolveParkedTerminalPaneCandidates } from '../terminal-pane/terminal-parked-tab-watchers' + +export function selectRevealTerminalTabIds(state: AppState, worktreeId: string): Set { + const revealTabIds = new Set() + const unifiedTabById = new Map( + (state.unifiedTabsByWorktree[worktreeId] ?? []).map((tab) => [tab.id, tab]) + ) + for (const group of state.groupsByWorktree[worktreeId] ?? []) { + const tab = group.activeTabId ? unifiedTabById.get(group.activeTabId) : undefined + if (tab?.contentType === 'terminal') { + revealTabIds.add(tab.entityId) + } + } + const rememberedTabId = state.activeTabIdByWorktree[worktreeId] + if (revealTabIds.size === 0 && rememberedTabId) { + revealTabIds.add(rememberedTabId) + } + return revealTabIds +} + +export function selectSleepingTabIds(state: AppState, worktreeId: string): Set { + const tabIds = new Set() + for (const record of Object.values(state.sleepingAgentSessionsByPaneKey)) { + const tabId = record.worktreeId === worktreeId ? getSleepingRecordTabId(record) : null + if (tabId) { + tabIds.add(tabId) + } + } + return tabIds +} + +// Each stage waits for an idle slot after the previous commit; the timeout keeps it ready before a typical press. +export function scheduleTerminalPrewarmStage(callback: () => void): () => void { + if (typeof requestIdleCallback !== 'function') { + const timer = setTimeout(callback, 16) + return () => clearTimeout(timer) + } + const handle = requestIdleCallback(callback, { timeout: 60 }) + return () => cancelIdleCallback(handle) +} + +export function resolveReattachPtyIds(tab: TerminalTab, state: AppState): string[] { + return resolveParkedTerminalPaneCandidates(tab, state).flatMap((pane) => + pane.ptyId ? [pane.ptyId] : [] + ) +} diff --git a/src/renderer/src/components/terminal/terminal-worktree-prewarm.test.ts b/src/renderer/src/components/terminal/terminal-worktree-prewarm.test.ts new file mode 100644 index 00000000000..58fea7abd24 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-worktree-prewarm.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { revealActivationDeferredTabs } from './background-terminal-worktree-mount' +import { + areTabSessionsConfirmed, + createDeferredNarrowingUndo, + planTerminalPrewarmTabMount, + undoTerminalPrewarmNarrowing +} from './terminal-worktree-prewarm' + +function tab(id: string, ptyId: string | null): TerminalTab { + return { + id, + ptyId, + worktreeId: 'wt', + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +const plan = ( + tabs: TerminalTab[], + revealTabIds: string[], + options: { live?: string[]; sleeping?: string[]; lostSessions?: string[] } = {} +) => + planTerminalPrewarmTabMount({ + tabs, + revealTabIds: new Set(revealTabIds), + isTabLive: (tabId) => options.live?.includes(tabId) === true, + isTabRestorable: (candidate) => candidate.ptyId !== null, + isTabSleeping: (tabId) => options.sleeping?.includes(tabId) === true, + isSessionConfirmedLive: (candidate) => options.lostSessions?.includes(candidate.id) !== true + }) + +describe('planTerminalPrewarmTabMount', () => { + it('mounts only the revealable tab and keeps restorable siblings parked', () => { + expect(plan([tab('a', 'pty-a'), tab('b', 'pty-b'), tab('c', null)], ['a'])).toEqual({ + allowedTabIds: new Set(['a']), + deferredTabIds: new Set(['b']) + }) + }) + + it('admits a live revealable tab without spawning its unrestorable siblings', () => { + expect(plan([tab('a', 'pty-a'), tab('c', null)], ['a'], { live: ['a'] })).toEqual({ + allowedTabIds: new Set(['a']), + deferredTabIds: new Set() + }) + }) + + it('never mounts a revealable tab that would spawn a shell or wake a sleeping agent', () => { + expect(plan([tab('a', null)], ['a'])).toBeNull() + expect(plan([tab('a', 'pty-a')], ['a'], { sleeping: ['a'] })).toBeNull() + }) + + it('never reattaches a revealable tab whose session the daemon did not confirm alive', () => { + // A reattach to a lost session spawns a replacement process, so the plan must decline it. + expect(plan([tab('a', 'pty-a'), tab('b', 'pty-b')], ['a'], { lostSessions: ['a'] })).toBeNull() + }) +}) + +describe('undoTerminalPrewarmNarrowing', () => { + it('leaves an activated workspace able to admit its deferred tabs after the lease releases', () => { + const leaseAllowed = new Set(['live']) + const restrictions = new Map>([['wt', leaseAllowed]]) + const deferred = new Map>([['wt', new Set(['p1', 'p2'])]]) + // Activation installs its own restriction while keeping the same deferred tabs. + restrictions.set('wt', new Set(['live'])) + + undoTerminalPrewarmNarrowing({ + restrictions, + deferredMountTabIdsByWorktree: deferred, + worktreeId: 'wt', + narrowing: { allowedTabIds: leaseAllowed, deferredTabIds: new Set(['p1', 'p2']) }, + isActivated: true + }) + revealActivationDeferredTabs({ + restrictions, + deferredMountTabIdsByWorktree: deferred, + worktreeId: 'wt', + allTabIds: ['live', 'p1', 'p2'], + immediateTabIds: new Set(['p1']) + }) + + expect(restrictions.get('wt')).toEqual(new Set(['live', 'p1'])) + expect(deferred.get('wt')).toEqual(new Set(['p2'])) + }) + + it('hands back a narrowing the lease still owns, keeping deferrals it did not add', () => { + const leaseAllowed = new Set(['live']) + const restrictions = new Map>([['wt', leaseAllowed]]) + const deferred = new Map>([['wt', new Set(['earlier', 'p1'])]]) + + undoTerminalPrewarmNarrowing({ + restrictions, + deferredMountTabIdsByWorktree: deferred, + worktreeId: 'wt', + narrowing: { allowedTabIds: leaseAllowed, deferredTabIds: new Set(['p1']) }, + isActivated: false + }) + + expect(restrictions.has('wt')).toBe(false) + expect(deferred.get('wt')).toEqual(new Set(['earlier'])) + }) +}) + +describe('areTabSessionsConfirmed', () => { + it('rejects a split tab whose sibling leaf session was not confirmed live', () => { + expect(areTabSessionsConfirmed(['pty-a', 'pty-b'], new Set(['pty-a']))).toBe(false) + }) + + it('rejects confirmation for a leaf set that changed while the check was pending', () => { + expect(areTabSessionsConfirmed(['pty-a', 'pty-c'], new Set(['pty-a', 'pty-b']))).toBe(false) + }) + + it('admits a tab only when every leaf session was confirmed', () => { + expect(areTabSessionsConfirmed(['pty-a', 'pty-b'], new Set(['pty-a', 'pty-b']))).toBe(true) + expect(areTabSessionsConfirmed([], new Set())).toBe(false) + }) +}) + +describe('createDeferredNarrowingUndo', () => { + const narrowing = { allowedTabIds: new Set(['live']), deferredTabIds: new Set(['p1']) } + + it('keeps a released narrowing until the tab cold-park cool-down has passed', () => { + vi.useFakeTimers() + try { + const undo = vi.fn() + const undos = createDeferredNarrowingUndo({ + delayMs: 31_000, + undo, + remainingHoldMs: () => null + }) + undos.schedule('wt', narrowing) + vi.advanceTimersByTime(30_000) + expect(undo).not.toHaveBeenCalled() + vi.advanceTimersByTime(1_000) + expect(undo).toHaveBeenCalledWith('wt', narrowing) + } finally { + vi.useRealTimers() + } + }) + + it('waits out a newer measurement cool-down that would still leave tab parking off', () => { + vi.useFakeTimers() + try { + const undo = vi.fn() + vi.setSystemTime(0) + // Another mount measured mid-delay, so its cool-down runs 22 s past the original deadline. + const holdUntil = 53_000 + const undos = createDeferredNarrowingUndo({ + delayMs: 31_000, + undo, + remainingHoldMs: () => holdUntil - Date.now() + }) + undos.schedule('wt', narrowing) + vi.advanceTimersByTime(31_000) + expect(undo).not.toHaveBeenCalled() + vi.advanceTimersByTime(22_000) + expect(undo).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('applies a pending undo at once when a new lease takes the worktree', () => { + vi.useFakeTimers() + try { + const undo = vi.fn() + const undos = createDeferredNarrowingUndo({ + delayMs: 31_000, + undo, + remainingHoldMs: () => null + }) + undos.schedule('wt', narrowing) + undos.flush('wt') + expect(undo).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(31_000) + expect(undo).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-worktree-prewarm.ts b/src/renderer/src/components/terminal/terminal-worktree-prewarm.ts new file mode 100644 index 00000000000..a41cce6d405 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-worktree-prewarm.ts @@ -0,0 +1,187 @@ +import type { TerminalTab } from '../../../../shared/terminal-tab-types' + +/** Each stage is its own task so a slow pointer sweep never stacks mount, style/layout and WebGL into one long frame. */ +export type TerminalPrewarmStage = 'mount' | 'layout' | 'warm' + +/** Hover warms the row under the pointer; neighbor keeps the workspaces one keyboard step away warm. */ +export type TerminalPrewarmClient = 'hover' | 'neighbor' + +// Each client holds a small fixed budget (hover 1, neighbor 2), so at most three extra worktrees stay mounted. +const worktreeIdsByClient = new Map() +const listeners = new Set<() => void>() + +export function setTerminalPrewarmWorktrees( + client: TerminalPrewarmClient, + worktreeIds: readonly string[] +): void { + const current = worktreeIdsByClient.get(client) ?? [] + if ( + current.length === worktreeIds.length && + current.every((worktreeId, index) => worktreeId === worktreeIds[index]) + ) { + return + } + worktreeIdsByClient.set(client, [...worktreeIds]) + for (const listener of listeners) { + listener() + } +} + +/** Every prewarmed worktree with the clients holding it. */ +export function getTerminalPrewarmWorktrees(): ReadonlyMap< + string, + ReadonlySet +> { + const clientsByWorktree = new Map>() + for (const [client, worktreeIds] of worktreeIdsByClient) { + for (const worktreeId of worktreeIds) { + const clients = clientsByWorktree.get(worktreeId) ?? new Set() + clients.add(client) + clientsByWorktree.set(worktreeId, clients) + } + } + return clientsByWorktree +} + +// Worktrees whose lease actually took (a tab plan admitted their visible terminal); only these skip parking. +let heldWorktreeIds: ReadonlySet = new Set() + +export function setHeldTerminalPrewarmWorktrees(worktreeIds: Iterable): void { + heldWorktreeIds = new Set(worktreeIds) +} + +export function getHeldTerminalPrewarmWorktrees(): ReadonlySet { + return heldWorktreeIds +} + +export function subscribeTerminalPrewarmWorktrees(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +export type TerminalPrewarmTabMountPlan = { + allowedTabIds: Set + deferredTabIds: Set +} + +/** Admit the revealable tabs plus already-live panes; restorable siblings stay parked-watched and every other tab stays unmounted. */ +export function planTerminalPrewarmTabMount(args: { + tabs: readonly TerminalTab[] + revealTabIds: ReadonlySet + isTabLive: (tabId: string) => boolean + isTabRestorable: (tab: TerminalTab) => boolean + isTabSleeping: (tabId: string) => boolean + /** Mounting reattaches the PTY, and a reattach to a lost session spawns a new one; admit only daemon-confirmed sessions. */ + isSessionConfirmedLive: (tab: TerminalTab) => boolean +}): TerminalPrewarmTabMountPlan | null { + const allowedTabIds = new Set() + const deferredTabIds = new Set() + let admitsRevealTab = false + for (const tab of args.tabs) { + const live = args.isTabLive(tab.id) + // Prewarm must never spawn a shell or wake an agent: only reattach PTYs a snapshot can restore. + const restorable = !live && args.isTabRestorable(tab) && !args.isTabSleeping(tab.id) + if (live || (restorable && args.revealTabIds.has(tab.id) && args.isSessionConfirmedLive(tab))) { + allowedTabIds.add(tab.id) + admitsRevealTab ||= args.revealTabIds.has(tab.id) + } else if (restorable) { + deferredTabIds.add(tab.id) + } + } + return admitsRevealTab ? { allowedTabIds, deferredTabIds } : null +} + +export type TerminalPrewarmNarrowing = { + allowedTabIds: ReadonlySet + deferredTabIds: ReadonlySet +} + +/** + * Hand a narrowed live mount back on lease release, but only while the lease still owns it: once + * activation or another mount replaced the restriction, the deferred ids belong to that owner. + */ +export function undoTerminalPrewarmNarrowing(args: { + restrictions: Map> + deferredMountTabIdsByWorktree: Map> + worktreeId: string + narrowing: TerminalPrewarmNarrowing + isActivated: boolean +}): void { + const { restrictions, deferredMountTabIdsByWorktree, worktreeId, narrowing } = args + if (args.isActivated || restrictions.get(worktreeId) !== narrowing.allowedTabIds) { + return + } + restrictions.delete(worktreeId) + const remaining = [...(deferredMountTabIdsByWorktree.get(worktreeId) ?? [])].filter( + (tabId) => !narrowing.deferredTabIds.has(tabId) + ) + if (remaining.length > 0) { + deferredMountTabIdsByWorktree.set(worktreeId, new Set(remaining)) + } else { + deferredMountTabIdsByWorktree.delete(worktreeId) + } +} + +/** A reattach reconnects every leaf of a tab; it is safe only when exactly these sessions were each confirmed live. */ +export function areTabSessionsConfirmed( + candidatePtyIds: readonly string[], + confirmedPtyIds: ReadonlySet | undefined +): boolean { + return ( + confirmedPtyIds !== undefined && + candidatePtyIds.length > 0 && + new Set(candidatePtyIds).size === confirmedPtyIds.size && + candidatePtyIds.every((ptyId) => confirmedPtyIds.has(ptyId)) + ) +} + +/** + * Ending a lease's measurement starts tab cold parking's cool-down, during which individually + * parked siblings would remount; keep the narrowing until that cool-down has passed. + */ +export function createDeferredNarrowingUndo(args: { + delayMs: number + undo: (worktreeId: string, narrowing: TerminalPrewarmNarrowing) => void + /** Milliseconds to wait again when a newer measurement or its cool-down still holds tab parking off. */ + remainingHoldMs: (worktreeId: string) => number | null +}) { + const pending = new Map< + string, + { timer: ReturnType; narrowing: TerminalPrewarmNarrowing } + >() + const flush = (worktreeId: string): void => { + const entry = pending.get(worktreeId) + if (!entry) { + return + } + clearTimeout(entry.timer) + pending.delete(worktreeId) + args.undo(worktreeId, entry.narrowing) + } + return { + schedule(worktreeId: string, narrowing: TerminalPrewarmNarrowing): void { + flush(worktreeId) + const arm = (delayMs: number): void => { + const timer = setTimeout(() => { + const holdMs = args.remainingHoldMs(worktreeId) + if (holdMs !== null && holdMs > 0) { + arm(holdMs) + return + } + pending.delete(worktreeId) + args.undo(worktreeId, narrowing) + }, delayMs) + pending.set(worktreeId, { timer, narrowing }) + } + arm(args.delayMs) + }, + /** A new lease on the same worktree replaces the pending narrowing with its own. */ + flush, + cancelAll(): void { + for (const entry of pending.values()) { + clearTimeout(entry.timer) + } + pending.clear() + } + } +} diff --git a/src/renderer/src/components/terminal/terminal-worktree-retained-frames.ts b/src/renderer/src/components/terminal/terminal-worktree-retained-frames.ts new file mode 100644 index 00000000000..e50a6a1fd06 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-worktree-retained-frames.ts @@ -0,0 +1,66 @@ +import { useAppStore } from '@/store' + +// The renderer's main canvas is the only unclassed one; its overlay layers carry an xterm-*-layer class. +function hasPresentableWebglFrame(screen: Element): boolean { + const canvas = screen.querySelector(':scope > canvas:not([class])') + if (!(canvas instanceof HTMLCanvasElement) || canvas.width === 0 || canvas.height === 0) { + return false + } + // Context loss clears the compositor texture; getContext returns the existing context, never a new one. + const gl = canvas.getContext('webgl2') + if (!gl || gl.isContextLost()) { + return false + } + // A backing store sized for another device pixel ratio would show one mis-scaled frame. + const cssWidth = Number.parseFloat(canvas.style.width) + return cssWidth > 0 && Math.abs(canvas.width / cssWidth - window.devicePixelRatio) < 0.05 +} + +// A hidden xterm keeps its last frame while its WebGL texture survives; DOM rows count if rendered. +function hasRetainedTerminalFrame(terminalContainer: Element): boolean { + const terminals = terminalContainer.querySelectorAll('.xterm') + if (terminals.length === 0) { + return false + } + for (const terminal of terminals) { + const screen = terminal.querySelector('.xterm-screen') + if ( + !screen || + (!hasPresentableWebglFrame(screen) && + !screen.querySelector('.xterm-rows')?.textContent?.trim()) + ) { + return false + } + } + return true +} + +/** True when each group's visible terminal in a hidden workspace still holds a painted frame, so switching to it stays hot. */ +export function hasRetainedWorkspaceTerminalFrames(worktreeId: string): boolean { + const surface = [...document.querySelectorAll('[data-workspace-surface-id]')].find( + (candidate) => candidate.getAttribute('data-workspace-surface-id') === worktreeId + ) + if (!surface) { + return false + } + const state = useAppStore.getState() + const groups = state.groupsByWorktree[worktreeId] ?? [] + const tabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + return ( + groups.length > 0 && + groups.every((group) => { + const tab = tabs.find((candidate) => candidate.id === group.activeTabId) + if (tab?.contentType !== 'terminal') { + return false + } + const container = surface.querySelector( + `[data-terminal-tab-id="${CSS.escape(tab.entityId)}"]` + ) + return ( + container instanceof HTMLElement && + container.dataset.terminalChatView !== 'true' && + hasRetainedTerminalFrame(container) + ) + }) + ) +} diff --git a/src/renderer/src/components/terminal/use-terminal-worktree-prewarm.ts b/src/renderer/src/components/terminal/use-terminal-worktree-prewarm.ts new file mode 100644 index 00000000000..58052c75adb --- /dev/null +++ b/src/renderer/src/components/terminal/use-terminal-worktree-prewarm.ts @@ -0,0 +1,319 @@ +import { useEffect, useLayoutEffect, useRef } from 'react' +import { useAppStore } from '@/store' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { hasRegisteredRuntimeTerminalTab } from '../../runtime/mounted-terminal-tab-registry' +import { canWatcherCoverParkedTerminalTab } from '../terminal-pane/terminal-parked-tab-watchers' +import { canAdmitTerminalTabsForStartup } from './background-terminal-worktree-mount' +import { + type TerminalPrewarmStage, + getTerminalPrewarmWorktrees, + setHeldTerminalPrewarmWorktrees, + planTerminalPrewarmTabMount, + subscribeTerminalPrewarmWorktrees, + undoTerminalPrewarmNarrowing, + areTabSessionsConfirmed, + createDeferredNarrowingUndo, + type TerminalPrewarmNarrowing +} from './terminal-worktree-prewarm' +import { TERMINAL_TAB_COLD_PARK_DELAY_MS } from '../terminal-pane/terminal-hidden-view-parking' +import type { TerminalColdActivationController } from '../terminal-cold-activation' +import { hasRetainedWorkspaceTerminalFrames } from './terminal-worktree-retained-frames' +import { + resolveReattachPtyIds, + scheduleTerminalPrewarmStage, + selectRevealTerminalTabIds, + selectSleepingTabIds +} from './terminal-worktree-prewarm-targets' + +// Lets the parking pass re-park siblings once the cool-down ends, before the narrowing lifts. +const NARROWING_UNDO_MARGIN_MS = 1000 + +type PrewarmLease = { + cancelNextStage: (() => void) | null + // Hovered leases stay laid out for the press; neighbor-only leases settle hidden once warm. + hovered: boolean + /** Set when the lease narrowed a live, unrestricted mount; release hands those panes back. */ + narrowedLiveMount: TerminalPrewarmNarrowing | null +} + +/** + * Hold prewarmed workspaces' revealable terminals mounted so a switch reveals them at hot cost. + * Hover keeps its target laid out; a neighbor is mounted, laid out and warmed once, then hidden but kept unparked. + */ +export function useTerminalWorktreePrewarm(controller: TerminalColdActivationController): void { + const controllerRef = useRef(controller) + useLayoutEffect(() => { + controllerRef.current = controller + }) + + useEffect(() => { + const leases = new Map() + const narrowingUndo = createDeferredNarrowingUndo({ + // Outlast the tab cold-park cool-down that ending this lease's measurement starts. + delayMs: TERMINAL_TAB_COLD_PARK_DELAY_MS + NARROWING_UNDO_MARGIN_MS, + undo: (worktreeId, narrowing) => { + const c = controllerRef.current + undoTerminalPrewarmNarrowing({ + restrictions: c.backgroundMountTabIdsByWorktreeRef.current, + deferredMountTabIdsByWorktree: c.activationDeferredMountTabIdsByWorktreeRef.current, + worktreeId, + narrowing, + isActivated: + c.renderedActiveWorktreeId === worktreeId || + c.lastActivationWorktreeIdRef.current === worktreeId + }) + bumpRevision() + }, + remainingHoldMs: (worktreeId) => { + const c = controllerRef.current + // Another mount's measurement ends with its own cool-down, during which tab parking is off. + if ( + c.measurableBackgroundWorktreeIdsRef.current.has(worktreeId) || + c.measuringTerminalWorktreeIdsRef.current.has(worktreeId) + ) { + return TERMINAL_TAB_COLD_PARK_DELAY_MS + NARROWING_UNDO_MARGIN_MS + } + const cooldownUntilMs = c.terminalWorktreeParkCooldownUntilRef.current.get(worktreeId) + return cooldownUntilMs === undefined + ? null + : cooldownUntilMs + NARROWING_UNDO_MARGIN_MS - Date.now() + } + }) + const bumpRevision = (): void => + controllerRef.current.setBackgroundMountRevision((revision) => revision + 1) + + const stopMeasuring = (worktreeId: string): void => { + const c = controllerRef.current + c.prewarmLeasesRef.current.delete(worktreeId) + if (!c.measurableBackgroundWorktreeTimersRef.current.has(worktreeId)) { + c.measurableBackgroundWorktreeIdsRef.current.delete(worktreeId) + // Skip the post-measure cool-down: ordinary parking (or the neighbor exemption) decides from here. + c.measuringTerminalWorktreeIdsRef.current.delete(worktreeId) + } + bumpRevision() + } + + const advance = ( + worktreeId: string, + lease: PrewarmLease, + stage: TerminalPrewarmStage + ): void => { + controllerRef.current.prewarmLeasesRef.current.set(worktreeId, stage) + bumpRevision() + const next = stage === 'mount' ? 'layout' : stage === 'layout' ? 'warm' : null + if (next === null && lease.hovered) { + return + } + lease.cancelNextStage = scheduleTerminalPrewarmStage(() => { + lease.cancelNextStage = null + if (next) { + advance(worktreeId, lease, next) + } else { + stopMeasuring(worktreeId) + } + }) + } + + const startMeasuring = ( + worktreeId: string, + lease: PrewarmLease, + stage: TerminalPrewarmStage + ) => { + lease.cancelNextStage?.() + controllerRef.current.measurableBackgroundWorktreeIdsRef.current.add(worktreeId) + advance(worktreeId, lease, stage) + } + + const release = (worktreeId: string, lease: PrewarmLease): void => { + lease.cancelNextStage?.() + leases.delete(worktreeId) + if (lease.narrowedLiveMount) { + narrowingUndo.schedule(worktreeId, lease.narrowedLiveMount) + } + stopMeasuring(worktreeId) + } + + // Liveness checks in flight; a newer request or a release for the worktree invalidates the older answer. + const livenessChecks = new Map() + + /** `confirmedTabIds` is undefined until the daemon has answered for the tabs this lease would reattach. */ + const lease = ( + worktreeId: string, + hovered: boolean, + confirmedPtyIdsByTabId?: ReadonlyMap> + ): void => { + const c = controllerRef.current + if ( + // Without hidden-view parking nothing unmounts a released lease, so prewarm would pin every target. + !c.terminalParkingEnabled || + worktreeId === c.renderedActiveWorktreeId || + !c.workspaceSurfaceIdSet.has(worktreeId) || + // Until startup publishes PTY ownership, a hidden mount could duplicate shells. + !canAdmitTerminalTabsForStartup(c) + ) { + return + } + narrowingUndo.flush(worktreeId) + const state = useAppStore.getState() + // Restricting the surface drops its emulator overlay and loses the simulator's local state. + if ( + (state.unifiedTabsByWorktree[worktreeId] ?? []).some( + (tab) => tab.contentType === 'simulator' + ) + ) { + return + } + const mounted = c.mountedWorktreeIdsRef.current.has(worktreeId) + const restrictions = c.backgroundMountTabIdsByWorktreeRef.current + const parked = c.effectiveParkedTerminalWorktreeIds.has(worktreeId) + // A retained surface can hold individually parked tabs; measuring bypasses tab parking, so admit only what the reveal needs. + const hasNonLiveTab = (state.tabsByWorktree[worktreeId] ?? []).some( + (tab) => !hasRegisteredRuntimeTerminalTab(tab.id, worktreeId) + ) + const needsTabPlan = !mounted || restrictions.has(worktreeId) || parked || hasNonLiveTab + // Narrowing a live unrestricted mount is temporary (undone on release). + const narrowsLiveMount = mounted && !restrictions.has(worktreeId) && !parked && hasNonLiveTab + let narrowedLiveMount: PrewarmLease['narrowedLiveMount'] = null + if (needsTabPlan) { + const sleepingTabIds = selectSleepingTabIds(state, worktreeId) + const tabs = state.tabsByWorktree[worktreeId] ?? [] + const revealTabIds = selectRevealTerminalTabIds(state, worktreeId) + const isTabLive = (tabId: string): boolean => + hasRegisteredRuntimeTerminalTab(tabId, worktreeId) + const isTabRestorable = (tab: TerminalTab): boolean => + canWatcherCoverParkedTerminalTab(worktreeId, tab) + if (confirmedPtyIdsByTabId === undefined) { + const reattachTabs = tabs.filter( + (tab) => + revealTabIds.has(tab.id) && + tab.ptyId !== null && + !isTabLive(tab.id) && + !sleepingTabIds.has(tab.id) && + isTabRestorable(tab) + ) + if (reattachTabs.length > 0) { + confirmSessionsThenLease(worktreeId, reattachTabs) + return + } + } + const plan = planTerminalPrewarmTabMount({ + tabs, + revealTabIds, + isTabLive, + isTabRestorable, + isTabSleeping: (tabId) => sleepingTabIds.has(tabId), + isSessionConfirmedLive: (tab) => + areTabSessionsConfirmed( + resolveReattachPtyIds(tab, state), + confirmedPtyIdsByTabId?.get(tab.id) + ) + }) + if (!plan) { + return + } + restrictions.set(worktreeId, plan.allowedTabIds) + const deferred = c.activationDeferredMountTabIdsByWorktreeRef.current + if (narrowsLiveMount) { + const alreadyDeferred = deferred.get(worktreeId) + narrowedLiveMount = { + allowedTabIds: plan.allowedTabIds, + deferredTabIds: new Set( + [...plan.deferredTabIds].filter((tabId) => !alreadyDeferred?.has(tabId)) + ) + } + } + const nextDeferred = new Set([...(deferred.get(worktreeId) ?? []), ...plan.deferredTabIds]) + for (const tabId of plan.allowedTabIds) { + nextDeferred.delete(tabId) + } + if (nextDeferred.size > 0) { + deferred.set(worktreeId, nextDeferred) + } else { + deferred.delete(worktreeId) + } + c.mountedWorktreeIdsRef.current.add(worktreeId) + } + const next: PrewarmLease = { cancelNextStage: null, hovered, narrowedLiveMount } + leases.set(worktreeId, next) + if (needsTabPlan) { + startMeasuring(worktreeId, next, 'mount') + } else if (hovered || !hasRetainedWorkspaceTerminalFrames(worktreeId)) { + startMeasuring(worktreeId, next, 'layout') + } + // A neighbor already holding revealable frames only needs the parking exemption. + } + + const confirmSessionsThenLease = (worktreeId: string, tabs: readonly TerminalTab[]): void => { + const token = Symbol(worktreeId) + livenessChecks.set(worktreeId, token) + const state = useAppStore.getState() + void Promise.all( + tabs.map(async (tab) => { + // Mounting reconnects every leaf, so each leaf's session must be live, not just the tab's. + const ptyIds = resolveReattachPtyIds(tab, state) + const answers = await Promise.all( + ptyIds.map((ptyId) => window.api.pty.hasPty(ptyId).catch(() => null)) + ) + // Only an authoritative true counts: null (unknown, remote runtime) must not authorize a reattach. + const confirmed = new Set(ptyIds.filter((_ptyId, index) => answers[index] === true)) + return [tab.id, confirmed] as const + }) + ).then((confirmedByTab) => { + if (livenessChecks.get(worktreeId) !== token) { + return + } + livenessChecks.delete(worktreeId) + const clients = getTerminalPrewarmWorktrees().get(worktreeId) + if (!clients || leases.has(worktreeId)) { + return + } + // The second pass re-resolves each tab's leaves; any change since the check fails confirmation. + lease(worktreeId, clients.has('hover'), new Map(confirmedByTab)) + setHeldTerminalPrewarmWorktrees(leases.keys()) + controllerRef.current.setTerminalParkingRevision((revision) => revision + 1) + }) + } + + const sync = (): void => { + const wanted = getTerminalPrewarmWorktrees() + for (const [worktreeId, existing] of Array.from(leases)) { + if (!wanted.has(worktreeId)) { + release(worktreeId, existing) + } + } + for (const worktreeId of Array.from(livenessChecks.keys())) { + if (!wanted.has(worktreeId)) { + livenessChecks.delete(worktreeId) + } + } + for (const [worktreeId, clients] of wanted) { + const hovered = clients.has('hover') + const existing = leases.get(worktreeId) + if (!existing) { + lease(worktreeId, hovered) + } else if (hovered !== existing.hovered) { + existing.hovered = hovered + if (hovered) { + startMeasuring(worktreeId, existing, 'layout') + } else if (existing.cancelNextStage === null) { + stopMeasuring(worktreeId) + } + } + } + // Read by the parking pass this sync's revision bump schedules. + setHeldTerminalPrewarmWorktrees(leases.keys()) + } + sync() + const unsubscribe = subscribeTerminalPrewarmWorktrees(sync) + return () => { + unsubscribe() + livenessChecks.clear() + for (const [worktreeId, existing] of Array.from(leases)) { + release(worktreeId, existing) + } + // After the releases above: they schedule undos against refs this unmount is discarding. + narrowingUndo.cancelAll() + setHeldTerminalPrewarmWorktrees([]) + } + }, []) +} diff --git a/src/renderer/src/components/use-terminal-controller.ts b/src/renderer/src/components/use-terminal-controller.ts index 29d5bdfebe8..42408505f50 100644 --- a/src/renderer/src/components/use-terminal-controller.ts +++ b/src/renderer/src/components/use-terminal-controller.ts @@ -9,6 +9,7 @@ import { useTerminalParkingPass } from './use-terminal-parking-pass' import { useTerminalBrowserRetention } from './use-terminal-browser-retention' import { applyTerminalColdActivation } from './terminal-cold-activation' import { useActivationDeferredTabAdmission } from './terminal/use-activation-deferred-tab-admission' +import { useTerminalWorktreePrewarm } from './terminal/use-terminal-worktree-prewarm' import { useTerminalWatcherEffects } from './use-terminal-watcher-effects' import { useTerminalCreateActions } from './use-terminal-create-actions' import { useTerminalCloseActions } from './use-terminal-close-actions' @@ -30,6 +31,7 @@ export function useTerminalController() { const coldActivation = Object.assign(parking, applyTerminalColdActivation(parking)) useTerminalWatcherEffects(coldActivation) useActivationDeferredTabAdmission(coldActivation) + useTerminalWorktreePrewarm(coldActivation) const create = Object.assign(coldActivation, useTerminalCreateActions(coldActivation)) const close = Object.assign(create, useTerminalCloseActions(create)) const bulkClose = Object.assign(close, useTerminalBulkCloseActions(close)) diff --git a/src/renderer/src/components/use-terminal-parking-foundation.ts b/src/renderer/src/components/use-terminal-parking-foundation.ts index e6f6f18f938..25d4b964bd0 100644 --- a/src/renderer/src/components/use-terminal-parking-foundation.ts +++ b/src/renderer/src/components/use-terminal-parking-foundation.ts @@ -13,6 +13,10 @@ import { takePendingBackgroundTerminalWorktreeMount } from './terminal/background-terminal-worktree-mount' import type { StartupTerminalTabHold } from './terminal/startup-terminal-tab-hold' +import { + subscribeTerminalPrewarmWorktrees, + type TerminalPrewarmStage +} from './terminal/terminal-worktree-prewarm' import { combineTerminalWorktreeParkIds, useManualTerminalWorktreeParking @@ -69,6 +73,7 @@ export function useTerminalParkingFoundation(controller: TerminalEditorCloseCont const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map>()) const lastActivationWorktreeIdRef = useRef(null) + const prewarmLeasesRef = useRef(new Map()) const startupTerminalTabHoldRef = useRef(null) // Cold activation runs during render; return its count into a ref instead of triggering a render-phase state update. const activationDeferralPlanRevisionRef = useRef(0) @@ -136,6 +141,11 @@ export function useTerminalParkingFoundation(controller: TerminalEditorCloseCont } }, [closeDialogDebounceTimersRef, measurableBackgroundWorktreeIdsRef, mountedWorktreeIdsRef]) + useEffect( + () => subscribeTerminalPrewarmWorktrees(() => setTerminalParkingRevision((r) => r + 1)), + [] + ) + useEffect(() => { const timers = terminalWorktreeParkingTimersRef.current return () => { @@ -166,6 +176,7 @@ export function useTerminalParkingFoundation(controller: TerminalEditorCloseCont backgroundMountTabIdsByWorktreeRef, activationDeferredMountTabIdsByWorktreeRef, lastActivationWorktreeIdRef, + prewarmLeasesRef, startupTerminalTabHoldRef, activationDeferralPlanRevisionRef } diff --git a/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts b/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts index 27b3baed81a..afa845c8cd4 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-selection-actions.ts @@ -1,3 +1,4 @@ +import { skipPaletteExitAnimation } from './worktree-jump-palette-exit-animation' import { useWorktreeJumpPaletteTaskSelection } from './use-worktree-jump-palette-task-selection' import type { WorktreeJumpPaletteTaskSearch } from './use-worktree-jump-palette-task-search' import { useCallback } from 'react' @@ -65,6 +66,7 @@ export function useWorktreeJumpPaletteSelectionActions({ // Why: the selection moves focus itself, so closing must not restore the prior target. const dismissForSelection = useCallback(() => { skipRestoreFocusRef.current = true + skipPaletteExitAnimation() closeModal() setSelectedItemId('') }, [closeModal, setSelectedItemId, skipRestoreFocusRef]) diff --git a/src/renderer/src/components/worktree-jump-palette-exit-animation.ts b/src/renderer/src/components/worktree-jump-palette-exit-animation.ts new file mode 100644 index 00000000000..b31da5055b6 --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-exit-animation.ts @@ -0,0 +1,10 @@ +// The selection already moved on; a fading overlay would only dim the surface it revealed. Escape keeps the fade. +export function skipPaletteExitAnimation(): void { + const content = document.querySelector('[data-slot="command-dialog-content"]') + const overlay = content?.previousElementSibling + for (const element of [content, overlay]) { + if (element instanceof HTMLElement) { + element.style.animation = 'none' + } + } +} diff --git a/src/renderer/src/store/terminals/terminal-pty-bindings.ts b/src/renderer/src/store/terminals/terminal-pty-bindings.ts index bca3e66598f..a645a24004e 100644 --- a/src/renderer/src/store/terminals/terminal-pty-bindings.ts +++ b/src/renderer/src/store/terminals/terminal-pty-bindings.ts @@ -25,9 +25,11 @@ export function createTerminalPtyBindingActions( } let worktreeId: string | null = null let wasActivationSpawn = false + let wasAlreadyBound = false const isRemoteRuntimeMirror = isRemoteRuntimePtyId(ptyId) set((s) => { const existingPtyIds = s.ptyIdsByTabId[tabId] ?? [] + wasAlreadyBound = !replacedPtyId && existingPtyIds.includes(ptyId) const remote = parseRemoteRuntimePtyId(ptyId) const legacyRemotePtyId = remote?.environmentId ? toRemoteRuntimePtyId(remote.handle) : null const hasLegacyPtyBinding = legacyRemotePtyId @@ -110,14 +112,22 @@ export function createTerminalPtyBindingActions( (s.suppressedPtyExitIds[ptyId] || (replacementPtyId && s.suppressedPtyExitIds[replacementPtyId])) ) - const nextSuppressedPtyExitIds = { ...s.suppressedPtyExitIds } - delete nextSuppressedPtyExitIds[ptyId] - if (replacementPtyId) { - delete nextSuppressedPtyExitIds[replacementPtyId] - } - if (shouldRetainSuppressedExit) { - // Why: handle rotation keeps the same terminal lifecycle; an intentional exit racing the rotation must stay suppressed once. - nextSuppressedPtyExitIds[ptyId] = true + const touchesSuppressedExit = + ptyId in s.suppressedPtyExitIds || + (replacementPtyId !== null && replacementPtyId in s.suppressedPtyExitIds) + // Keep identity on a plain reattach so subscribers don't re-render for an unchanged map. + const nextSuppressedPtyExitIds = touchesSuppressedExit + ? { ...s.suppressedPtyExitIds } + : s.suppressedPtyExitIds + if (touchesSuppressedExit) { + delete nextSuppressedPtyExitIds[ptyId] + if (replacementPtyId) { + delete nextSuppressedPtyExitIds[replacementPtyId] + } + if (shouldRetainSuppressedExit) { + // Why: handle rotation keeps the same terminal lifecycle; an intentional exit racing the rotation must stay suppressed once. + nextSuppressedPtyExitIds[ptyId] = true + } } const nextUnverifiedPtyLossTabIds = s.unverifiedPtyLossTabIds[tabId] ? omitUnverifiedPtyLossTabIds(s.unverifiedPtyLossTabIds, [tabId]) @@ -168,14 +178,17 @@ export function createTerminalPtyBindingActions( ...(nextTabsByWorktree !== s.tabsByWorktree ? { tabsByWorktree: nextTabsByWorktree } : {}), - ptyIdsByTabId: { - ...s.ptyIdsByTabId, - [tabId]: nextPtyIds - }, - lastKnownRelayPtyIdByTabId: { - ...s.lastKnownRelayPtyIdByTabId, - [tabId]: ptyId - }, + ...(nextPtyIds !== existingPtyIds || !(tabId in s.ptyIdsByTabId) + ? { ptyIdsByTabId: { ...s.ptyIdsByTabId, [tabId]: nextPtyIds } } + : {}), + ...(s.lastKnownRelayPtyIdByTabId[tabId] !== ptyId + ? { + lastKnownRelayPtyIdByTabId: { + ...s.lastKnownRelayPtyIdByTabId, + [tabId]: ptyId + } + } + : {}), ...(nextUnverifiedPtyLossTabIds !== s.unverifiedPtyLossTabIds ? { unverifiedPtyLossTabIds: nextUnverifiedPtyLossTabIds } : {}), @@ -195,7 +208,8 @@ export function createTerminalPtyBindingActions( // Why: a bound PTY means the workspace is awake by any route (CLI, automation, client wake), not only activation. clearWorktreeSleepIntent(worktreeId) // Why: activation spawns come from clicking a worktree, not work in it — skip the lastActivityAt stamp and sortEpoch bump; other spawn reasons still bump. - if (worktreeId && !wasActivationSpawn && !isRemoteRuntimeMirror) { + // A remount reattaching its own live PTY (e.g. a hidden prewarm) is not new work either. + if (worktreeId && !wasActivationSpawn && !wasAlreadyBound && !isRemoteRuntimeMirror) { get().bumpWorktreeActivity(worktreeId) } }