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 <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-28 10:42:08 -07:00
committed by GitHub
co-authored by m4air
parent 49e53bd345
commit 40822e2c4e
27 changed files with 1292 additions and 70 deletions
@@ -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}
@@ -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}
>
<TabGroupSplitLayout
layout={layout}
@@ -67,6 +73,7 @@ export const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
worktreePath={worktreePath}
isVisible={isVisible}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
hiddenTerminalPrewarm={prewarmStage === 'mount' ? null : prewarmStage}
shouldColdParkTerminalPanes={shouldColdParkTerminalPanes}
isForceParked={isForceParked}
activityTerminalPortals={activityTerminalPortals}
@@ -4,6 +4,7 @@ import { RetainedBrowserPaneOverlayLayer } from './browser-pane/assemble-chrome/
import EmulatorPaneOverlayLayer from './emulator-pane/EmulatorPaneOverlayLayer'
import StructuredAgentSessionPaneOverlayLayer from './native-chat/StructuredAgentSessionPaneOverlayLayer'
import AiVaultSessionDropLayer from './tab-group/AiVaultSessionDropLayer'
import type { HiddenTerminalPrewarm } from './terminal-pane/terminal-pane-types'
/** Share retained pane hosts across group-tree hosts so retention, parking and browser paint have one lifecycle owner. */
export function WorkspacePaneOverlayLayers({
@@ -11,6 +12,7 @@ export function WorkspacePaneOverlayLayers({
worktreePath,
isVisible,
shouldMeasureHiddenWorktree,
hiddenTerminalPrewarm = null,
shouldColdParkTerminalPanes,
isForceParked,
activityTerminalPortals,
@@ -24,6 +26,7 @@ export function WorkspacePaneOverlayLayers({
worktreePath: string
isVisible: boolean
shouldMeasureHiddenWorktree: boolean
hiddenTerminalPrewarm?: HiddenTerminalPrewarm | null
shouldColdParkTerminalPanes: boolean
isForceParked: boolean
activityTerminalPortals: ActivityTerminalPortalTarget[]
@@ -43,6 +46,7 @@ export function WorkspacePaneOverlayLayers({
coldParkTerminalPanes={shouldColdParkTerminalPanes}
isForceParked={isForceParked}
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
hiddenTerminalPrewarm={hiddenTerminalPrewarm}
activityTerminalPortals={activityTerminalPortals}
backgroundMountTabIds={backgroundMountTabIds}
activationDeferredMountTabIds={activationDeferredMountTabIds}
@@ -1,4 +1,4 @@
import React, { useCallback, useLayoutEffect, useRef } from 'react'
import React, { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import { recordRendererCrashBreadcrumb } from '@/lib/crash-diagnostics'
@@ -13,6 +13,7 @@ import {
} from './worktree-card-dom-events'
import { canActivateWorktreeOnPress } from './worktree-press-activation-eligibility'
import { isSidebarPointerDragBlocked } from './worktree-sidebar-pointer-drag-dom'
import { noteWorktreeRowHoverEnd, noteWorktreeRowHoverStart } from './worktree-row-hover-prewarm'
import type { WorktreeCardProps } from './worktree-card-model'
import type { useWorktreeCardFoundation } from './use-worktree-card-foundation'
import type { useWorktreeCardLinkedDetails } from './use-worktree-card-linked-details'
@@ -174,6 +175,23 @@ export function useWorktreeCardActivationActions({
}
}, [])
const handlePointerEnter = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
// 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<void> => {
@@ -230,6 +248,8 @@ export function useWorktreeCardActivationActions({
handleClick,
handlePointerDown,
handleMouseDown,
handlePointerEnter,
handlePointerLeave,
handleRenameTitle,
handleDoubleClick,
handleToggleUnreadQuick
@@ -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}
@@ -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
}
@@ -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<LastActiveCycleWorkspace | null>(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<number | null>(null)
const lastRepeatAtRef = useRef<number | null>(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
}
}
@@ -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<string>()
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', []), [])
}
@@ -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,
@@ -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<typeof setTimeout> | null = null
let dwellWorktreeId: string | null = null
let graceTimer: ReturnType<typeof setTimeout> | 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)
}
@@ -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 = (
<TerminalPane
key={`${terminalTabId}-${terminalGeneration ?? 0}`}
@@ -63,6 +69,8 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
isVisible={isVisible || activityTerminalPortal !== null}
isWorktreeActive={isWorktreeActive || activityTerminalPortal !== null}
isolatedPaneKey={activityTerminalPortal?.paneKey ?? null}
measureWhileHidden={prewarm !== null}
warmRendererWhileHidden={prewarm === 'warm'}
onPtyExit={(ptyId, exitCode) => {
if (consumeSuppressedPtyExit(ptyId)) {
return
@@ -101,7 +109,7 @@ export const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
<RetainedPaneHost
groupId={groupId}
isVisible={isVisible}
measureWhileHidden={shouldMeasureHiddenStartup}
measureWhileHidden={shouldMeasureHiddenStartup || prewarm !== null}
fitTerminal
dimmed={dimmed}
data-terminal-overlay-tab-id={terminalTabId}
@@ -7,6 +7,7 @@ import {
findActivityTerminalPortal,
type ActivityTerminalPortalTarget
} from '../activity/activity-terminal-portal'
import type { HiddenTerminalPrewarm } from './terminal-pane-types'
import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount'
import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut'
import { RetainedPaneHost } from '../tab-group/RetainedPaneHost'
@@ -33,6 +34,7 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
coldParkTerminalPanes = false,
isForceParked = false,
shouldMeasureHiddenWorktree = false,
hiddenTerminalPrewarm = null,
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS,
backgroundMountTabIds = null,
activationDeferredMountTabIds = null,
@@ -45,6 +47,8 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
/** Retention-budget force-park keeps eviction-exempt tabs mounted. */
isForceParked?: boolean
shouldMeasureHiddenWorktree?: boolean
/** Hover intent: lay out (and later render) group-visible terminals while hidden. */
hiddenTerminalPrewarm?: HiddenTerminalPrewarm | null
activityTerminalPortals?: ActivityTerminalPortalTarget[]
/** Targeted mounts connect only these terminal tabs. */
backgroundMountTabIds?: ReadonlySet<string> | 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}
@@ -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.
@@ -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<PaneManager | null>
containerRef: React.RefObject<HTMLDivElement | null>
@@ -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<TerminalHiddenReason | null>(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,
@@ -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,
@@ -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<number, SessionRestoredBannerReason>
>(() => new Map())
@@ -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<typeof collectTerminalParkingPassCandidates>
@@ -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)
}
}
@@ -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<string> {
const revealTabIds = new Set<string>()
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<string> {
const tabIds = new Set<string>()
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] : []
)
}
@@ -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<string, ReadonlySet<string>>([['wt', leaseAllowed]])
const deferred = new Map<string, ReadonlySet<string>>([['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<string, ReadonlySet<string>>([['wt', leaseAllowed]])
const deferred = new Map<string, ReadonlySet<string>>([['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()
}
})
})
@@ -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<TerminalPrewarmClient, readonly string[]>()
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<TerminalPrewarmClient>
> {
const clientsByWorktree = new Map<string, Set<TerminalPrewarmClient>>()
for (const [client, worktreeIds] of worktreeIdsByClient) {
for (const worktreeId of worktreeIds) {
const clients = clientsByWorktree.get(worktreeId) ?? new Set<TerminalPrewarmClient>()
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<string> = new Set()
export function setHeldTerminalPrewarmWorktrees(worktreeIds: Iterable<string>): void {
heldWorktreeIds = new Set(worktreeIds)
}
export function getHeldTerminalPrewarmWorktrees(): ReadonlySet<string> {
return heldWorktreeIds
}
export function subscribeTerminalPrewarmWorktrees(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export type TerminalPrewarmTabMountPlan = {
allowedTabIds: Set<string>
deferredTabIds: Set<string>
}
/** 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<string>
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<string>()
const deferredTabIds = new Set<string>()
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<string>
deferredTabIds: ReadonlySet<string>
}
/**
* 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<string, ReadonlySet<string>>
deferredMountTabIdsByWorktree: Map<string, ReadonlySet<string>>
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<string> | 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<typeof setTimeout>; 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()
}
}
}
@@ -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<HTMLElement>('[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)
)
})
)
}
@@ -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<string, PrewarmLease>()
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<string, symbol>()
/** `confirmedTabIds` is undefined until the daemon has answered for the tabs this lease would reattach. */
const lease = (
worktreeId: string,
hovered: boolean,
confirmedPtyIdsByTabId?: ReadonlyMap<string, ReadonlySet<string>>
): 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([])
}
}, [])
}
@@ -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))
@@ -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<string, ReadonlySet<string>>())
const activationDeferredMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
const lastActivationWorktreeIdRef = useRef<string | null>(null)
const prewarmLeasesRef = useRef(new Map<string, TerminalPrewarmStage>())
const startupTerminalTabHoldRef = useRef<StartupTerminalTabHold | null>(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
}
@@ -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])
@@ -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<HTMLElement>('[data-slot="command-dialog-content"]')
const overlay = content?.previousElementSibling
for (const element of [content, overlay]) {
if (element instanceof HTMLElement) {
element.style.animation = 'none'
}
}
}
@@ -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)
}
}