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) } }