From 1ab6b87c7f8ade41e70ab939702f779ffc0e0aff Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:40:45 -0700 Subject: [PATCH] Add bounded terminal renderer retention (#4523) --- src/renderer/src/components/Terminal.tsx | 141 ++++++++- .../TerminalPaneOverlayLayer.tsx | 126 +++++++- .../terminal-pane/pty-connection.test.ts | 37 +++ .../terminal/terminal-tab-parking.test.ts | 147 +++++++++ .../terminal-worktree-parking.test.ts | 283 ++++++++++++++++++ .../terminal/terminal-worktree-parking.ts | 244 +++++++++++++++ 6 files changed, 976 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/terminal/terminal-tab-parking.test.ts create mode 100644 src/renderer/src/components/terminal/terminal-worktree-parking.test.ts create mode 100644 src/renderer/src/components/terminal/terminal-worktree-parking.ts diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 71f1cc9f0..74422ee4c 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -57,6 +57,11 @@ import { getEffectiveLayoutForWorktree as getEffectiveLayout, anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout } from './terminal/split-group-mount' +import { + getTerminalWorktreeColdParkRecheckDelayMs, + selectColdParkedTerminalWorktrees, + type TerminalWorktreeColdParkCandidate +} from './terminal/terminal-worktree-parking' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' import CodexRestartChip from './CodexRestartChip' @@ -103,6 +108,18 @@ const EDITOR_TAB_CONTENT_TYPES = new Set(['editor', 'diff', 'con type TerminalStoreSnapshot = ReturnType +function haveSameWorktreeIds(left: Set, right: Set): boolean { + if (left.size !== right.size) { + return false + } + for (const id of left) { + if (!right.has(id)) { + return false + } + } + return true +} + function findUnifiedTabByVisibleId( state: TerminalStoreSnapshot, worktreeId: string, @@ -181,11 +198,15 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext { function Terminal(): React.JSX.Element | null { const mountedWorktreeIdsRef = useRef(new Set()) const measurableBackgroundWorktreeIdsRef = useRef(new Set()) + const parkedTerminalWorktreeIdsRef = useRef(new Set()) + const terminalWorktreeHiddenSinceRef = useRef(new Map()) + const terminalWorktreeParkingTimersRef = useRef(new Map()) const allWorktrees = useAllWorktrees() const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const renderedActiveWorktreeId = activeWorktreeId const activeView = useAppStore((s) => s.activeView) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) const activeTabId = useAppStore((s) => s.activeTabId) const createTab = useAppStore((s) => s.createTab) const closeTab = useAppStore((s) => s.closeTab) @@ -651,7 +672,8 @@ function Terminal(): React.JSX.Element | null { // Only mount TerminalPanes for visited worktrees to prevent mass PTY // spawning when restoring a session with many saved worktree tabs. const measurableBackgroundWorktreeTimersRef = useRef(new Map()) - const [, setBackgroundMountRevision] = useState(0) + const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) + const [terminalParkingRevision, setTerminalParkingRevision] = useState(0) useEffect(() => { const timers = measurableBackgroundWorktreeTimersRef.current const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current @@ -701,6 +723,108 @@ function Terminal(): React.JSX.Element | null { closeDialogDebounceTimers.clear() } }, []) + + useEffect(() => { + const timers = terminalWorktreeParkingTimersRef.current + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } + }, []) + + useEffect(() => { + const parkingTimers = terminalWorktreeParkingTimersRef.current + for (const timer of parkingTimers.values()) { + window.clearTimeout(timer) + } + parkingTimers.clear() + + const nowMs = Date.now() + let changed = false + const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId)) + const currentWorktreeIds = new Set(allWorktrees.map((worktree) => worktree.id)) + for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) { + if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + } + } + + const retentionCandidates: TerminalWorktreeColdParkCandidate[] = [] + for (const worktree of allWorktrees) { + const worktreeId = worktree.id + if (!mountedWorktreeIdsRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + continue + } + const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId + const shouldMeasureHiddenWorktree = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId) + const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId) + if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + } else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs) + } + + retentionCandidates.push({ + worktreeId, + terminalTabs: tabsByWorktree[worktreeId] ?? [], + isVisible, + shouldMeasureHiddenWorktree, + hasActivityTerminalPortal, + hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null + }) + } + + const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({ + worktrees: retentionCandidates, + pendingStartupByTabId, + nowMs + }) + + if (!haveSameWorktreeIds(parkedTerminalWorktreeIdsRef.current, nextParkedTerminalWorktreeIds)) { + parkedTerminalWorktreeIdsRef.current = nextParkedTerminalWorktreeIds + changed = true + } + + for (const candidate of retentionCandidates) { + if ( + candidate.isVisible || + candidate.shouldMeasureHiddenWorktree || + candidate.hasActivityTerminalPortal || + nextParkedTerminalWorktreeIds.has(candidate.worktreeId) + ) { + continue + } + const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({ + hiddenSinceMs: candidate.hiddenSinceMs, + nowMs + }) + if (delayMs !== null && delayMs > 0) { + const worktreeId = candidate.worktreeId + const timer = window.setTimeout(() => { + parkingTimers.delete(worktreeId) + setTerminalParkingRevision((revision) => revision + 1) + }, delayMs) + parkingTimers.set(worktreeId, timer) + } + } + + if (changed) { + setTerminalParkingRevision((revision) => revision + 1) + } + }, [ + activeView, + activityTerminalPortals, + allWorktrees, + backgroundMountRevision, + pendingStartupByTabId, + renderedActiveWorktreeId, + tabsByWorktree, + terminalParkingRevision + ]) // Why: gated on workspaceSessionReady to prevent TerminalPane from mounting // before reconnectPersistedTerminals() has finished eagerly spawning PTYs. // Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId @@ -1665,6 +1789,10 @@ function Terminal(): React.JSX.Element | null { activeView === 'terminal' && worktree.id === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id) + const shouldColdParkTerminalPanes = + !isVisible && + !shouldMeasureHiddenWorktree && + parkedTerminalWorktreeIdsRef.current.has(worktree.id) return ( ) @@ -1724,6 +1853,10 @@ function Terminal(): React.JSX.Element | null { activeView === 'terminal' && worktree.id === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktree.id) + const shouldColdParkTerminalPanes = + !isVisible && + !shouldMeasureHiddenWorktree && + parkedTerminalWorktreeIdsRef.current.has(worktree.id) return (
diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 08a44186a..a4e6f9345 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useState } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useShallow } from 'zustand/react/shallow' import type { Tab, TabGroup, TerminalTab } from '../../../../shared/types' @@ -9,6 +9,11 @@ import { type ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal' import TerminalPane from './TerminalPane' +import { + getTerminalTabColdParkRecheckDelayMs, + selectColdParkedTerminalTabs, + type TerminalTabColdParkCandidate +} from '../terminal/terminal-worktree-parking' type TerminalOverlayAssignment = { groupId: string @@ -35,6 +40,18 @@ type TerminalOverlaySlotProps = { leaveWorktreeIfEmpty: () => void } +function haveSameTerminalTabIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const id of left) { + if (!right.has(id)) { + return false + } + } + return true +} + const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ terminalTabId, terminalGeneration, @@ -134,11 +151,13 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, worktreePath, isWorktreeActive, + coldParkTerminalPanes = false, activityTerminalPortals = EMPTY_ACTIVITY_PORTALS }: { worktreeId: string worktreePath: string isWorktreeActive: boolean + coldParkTerminalPanes?: boolean activityTerminalPortals?: ActivityTerminalPortalTarget[] }): React.JSX.Element | null { const { terminalTabs, unifiedTabs, groups, activeGroupId } = useAppStore( @@ -154,6 +173,13 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ const closeTab = useAppStore((state) => state.closeTab) const setActiveWorktree = useAppStore((state) => state.setActiveWorktree) const reconcileWorktreeTabModel = useAppStore((state) => state.reconcileWorktreeTabModel) + const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) + const terminalTabHiddenSinceRef = useRef(new Map()) + const terminalTabParkingTimersRef = useRef(new Map()) + const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0) + const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState>( + () => new Set() + ) // Why: legacy TabGroupPanel routed terminal closes through // commands.closeItem → leaveWorktreeIfEmpty, which deselected the worktree @@ -199,6 +225,98 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ return entries }, [groupActiveTabById, unifiedTabs]) + useEffect(() => { + const timers = terminalTabParkingTimersRef.current + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } + }, []) + + useEffect(() => { + const timers = terminalTabParkingTimersRef.current + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + + const nowMs = Date.now() + const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id)) + const portalTabIds = new Set( + activityTerminalPortals + .filter((portal) => portal.worktreeId === worktreeId) + .map((portal) => portal.tabId) + ) + for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) { + if (!currentTerminalTabIds.has(tabId)) { + terminalTabHiddenSinceRef.current.delete(tabId) + } + } + + const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id) + if (isVisible || hasActivityTerminalPortal) { + terminalTabHiddenSinceRef.current.delete(terminalTab.id) + } else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) { + terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs) + } + return { + id: terminalTab.id, + ptyId: terminalTab.ptyId, + pendingActivationSpawn: terminalTab.pendingActivationSpawn, + isVisible, + hasActivityTerminalPortal, + hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null + } + }) + + const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({ + worktreeId, + terminalTabs: candidates, + pendingStartupByTabId, + nowMs + }) + setColdParkedTerminalTabIds((current) => + haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds) + ? current + : nextColdParkedTerminalTabIds + ) + + for (const candidate of candidates) { + if ( + candidate.isVisible || + candidate.hasActivityTerminalPortal || + nextColdParkedTerminalTabIds.has(candidate.id) + ) { + continue + } + const delayMs = getTerminalTabColdParkRecheckDelayMs({ + hiddenSinceMs: candidate.hiddenSinceMs, + nowMs + }) + if (delayMs !== null && delayMs > 0) { + const tabId = candidate.id + const timer = window.setTimeout(() => { + timers.delete(tabId) + setTerminalTabParkingRevision((revision) => revision + 1) + }, delayMs) + timers.set(tabId, timer) + } + } + }, [ + activityTerminalPortals, + assignments, + isWorktreeActive, + pendingStartupByTabId, + terminalTabParkingRevision, + terminalTabs, + worktreeId + ]) + if (!worktreePath) { return null } @@ -213,6 +331,12 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, tabId: terminalTab.id }) + if ( + (coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) && + !activityTerminalPortal + ) { + return null + } return ( { expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-local-detached') }) + it('restores a detached local PTY from the reattach snapshot when remounted', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + if (sessionId) { + return { id: sessionId, snapshot: 'snapshot-after-parking' } + } + return null + }) + transportFactoryQueue.push(transport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'wt-1@@detached-local' }] + }, + settings: { + ...mockStoreState.settings + } + } as StoreState + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(20) + + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'wt-1@@detached-local' }) + ) + expect(transport.attach).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', expect.any(Function)) + expect(pane.terminal.write).toHaveBeenCalledWith('snapshot-after-parking', expect.any(Function)) + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'wt-1@@detached-local') + }) + it('attaches remote runtime PTY handles instead of creating a replacement terminal', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() diff --git a/src/renderer/src/components/terminal/terminal-tab-parking.test.ts b/src/renderer/src/components/terminal/terminal-tab-parking.test.ts new file mode 100644 index 000000000..29eebb016 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-parking.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_TAB_HOT_RETAIN_MS, + TERMINAL_WORKTREE_PARK_DELAY_MS, + getTerminalTabColdParkRecheckDelayMs, + selectColdParkedTerminalTabs +} from './terminal-worktree-parking' + +describe('selectColdParkedTerminalTabs', () => { + const nowMs = 500_000 + + function localTab(id: string, hiddenSinceMs: number) { + return { + id, + ptyId: `wt-1@@session-${id}`, + pendingActivationSpawn: false, + isVisible: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + } + } + + it('keeps visible and recent inactive terminal tabs mounted', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + { ...localTab('tab-visible', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), isVisible: true }, + localTab('tab-recent-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localTab('tab-recent-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1) + ], + pendingStartupByTabId: {}, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set()) + }) + + it('cold-parks the oldest inactive local tabs beyond the retain limit', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1), + localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2) + ], + pendingStartupByTabId: {}, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set(['tab-3'])) + }) + + it('cold-parks aged inactive local tabs even when under the retain limit', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS)], + pendingStartupByTabId: {}, + nowMs, + hotRetainLimit: 12 + }) + + expect(selected).toEqual(new Set(['tab-1'])) + }) + + it('does not cold-park inactive terminal tabs without local snapshot recovery', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-local', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + { + ...localTab('tab-ssh', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + ptyId: 'ssh:ssh-1@@pty-1' + }, + { + ...localTab('tab-remote', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + ptyId: 'remote:env-1@@terminal-1' + } + ], + pendingStartupByTabId: {}, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set(['tab-local'])) + }) + + it('keeps portaled, pending-startup, and pending-activation terminal tabs mounted', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + { + ...localTab('tab-portal', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + hasActivityTerminalPortal: true + }, + localTab('tab-startup', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + { + ...localTab('tab-activation', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + pendingActivationSpawn: true + } + ], + pendingStartupByTabId: { 'tab-startup': { command: 'echo pending' } }, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) +}) + +describe('getTerminalTabColdParkRecheckDelayMs', () => { + it('returns the next terminal-tab cold-park policy deadline', () => { + expect( + getTerminalTabColdParkRecheckDelayMs({ + hiddenSinceMs: null, + nowMs: 1_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + expect( + getTerminalTabColdParkRecheckDelayMs({ + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(50) + expect( + getTerminalTabColdParkRecheckDelayMs({ + hiddenSinceMs: 1_000, + nowMs: 1_100, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(900) + expect( + getTerminalTabColdParkRecheckDelayMs({ + hiddenSinceMs: 1_000, + nowMs: 2_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-worktree-parking.test.ts b/src/renderer/src/components/terminal/terminal-worktree-parking.test.ts new file mode 100644 index 000000000..935309e12 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-worktree-parking.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_WORKTREE_HOT_RETAIN_MS, + TERMINAL_WORKTREE_PARK_DELAY_MS, + canParkTerminalWorktreeRenderers, + getTerminalWorktreeColdParkRecheckDelayMs, + selectColdParkedTerminalWorktrees, + isSnapshotBackedTerminalPty +} from './terminal-worktree-parking' + +describe('isSnapshotBackedTerminalPty', () => { + it('allows local daemon sessions owned by the worktree', () => { + expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true) + expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true) + }) + + it('allows legacy local PTY IDs that reattach through the local session path', () => { + expect(isSnapshotBackedTerminalPty('pty-local-detached', 'repo::/worktree')).toBe(true) + }) + + it('rejects tabs that do not have a PTY yet', () => { + expect(isSnapshotBackedTerminalPty(null, 'repo::/worktree')).toBe(false) + }) + + it('rejects daemon sessions owned by another worktree', () => { + expect(isSnapshotBackedTerminalPty('repo::/other@@session-1', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('wt-2@@session-1', 'wt-1')).toBe(false) + }) + + it('rejects SSH and remote runtime PTY handles', () => { + expect(isSnapshotBackedTerminalPty('ssh:ssh-1@@pty-1', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('remote:env-1@@terminal-1', 'repo::/worktree')).toBe(false) + }) +}) + +describe('canParkTerminalWorktreeRenderers', () => { + const hiddenSinceMs = 1_000 + const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS + + it('parks hidden local terminal renderers after the idle delay', () => { + expect( + canParkTerminalWorktreeRenderers({ + worktreeId: 'repo::/worktree', + terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], + pendingStartupByTabId: {}, + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs, + nowMs + }) + ).toBe(true) + }) + + it('keeps renderers mounted while visible, measuring, portaled, or before the delay', () => { + const base = { + worktreeId: 'repo::/worktree', + terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], + pendingStartupByTabId: {}, + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs, + nowMs + } + + expect(canParkTerminalWorktreeRenderers({ ...base, isVisible: true })).toBe(false) + expect(canParkTerminalWorktreeRenderers({ ...base, shouldMeasureHiddenWorktree: true })).toBe( + false + ) + expect(canParkTerminalWorktreeRenderers({ ...base, hasActivityTerminalPortal: true })).toBe( + false + ) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS - 1 + }) + ).toBe(false) + }) + + it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => { + expect( + canParkTerminalWorktreeRenderers({ + worktreeId: 'repo::/worktree', + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }, + { id: 'tab-2', ptyId: 'ssh:ssh-1@@pty-1' } + ], + pendingStartupByTabId: {}, + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs, + nowMs + }) + ).toBe(false) + }) + + it('keeps renderers mounted while a tab has startup or activation work pending', () => { + const base = { + worktreeId: 'repo::/worktree', + terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], + pendingStartupByTabId: {}, + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs, + nowMs + } + + expect( + canParkTerminalWorktreeRenderers({ + ...base, + pendingStartupByTabId: { 'tab-1': { command: 'echo pending' } } + }) + ).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true } + ] + }) + ).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 } + ] + }) + ).toBe(false) + }) +}) + +describe('selectColdParkedTerminalWorktrees', () => { + const nowMs = 500_000 + + function localCandidate(worktreeId: string, hiddenSinceMs: number) { + return { + worktreeId, + terminalTabs: [{ id: `tab-${worktreeId}`, ptyId: `${worktreeId}@@session-1` }], + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + } + } + + it('keeps recent hidden local worktrees hot up to the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1) + ], + pendingStartupByTabId: {}, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set()) + }) + + it('cold-parks the oldest hidden local worktrees beyond the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1), + localCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2) + ], + pendingStartupByTabId: {}, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set(['wt-3'])) + }) + + it('cold-parks aged local worktrees even when under the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)], + pendingStartupByTabId: {}, + nowMs, + hotRetainLimit: 4 + }) + + expect(selected).toEqual(new Set(['wt-1'])) + }) + + it('does not cold-park terminals without local snapshot recovery', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-local', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + { + ...localCandidate('wt-ssh', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [{ id: 'tab-ssh', ptyId: 'ssh:ssh-1@@pty-1' }] + }, + { + ...localCandidate('wt-remote', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [{ id: 'tab-remote', ptyId: 'remote:env-1@@terminal-1' }] + } + ], + pendingStartupByTabId: {}, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set(['wt-local'])) + }) + + it('keeps visible, measuring, portaled, and pending terminals mounted', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + { + ...localCandidate('wt-visible', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + isVisible: true + }, + { + ...localCandidate('wt-measuring', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + shouldMeasureHiddenWorktree: true + }, + { + ...localCandidate('wt-portal', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + hasActivityTerminalPortal: true + }, + { + ...localCandidate('wt-activation', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [ + { + id: 'tab-activation', + ptyId: 'wt-activation@@session-1', + pendingActivationSpawn: true + } + ] + }, + localCandidate('wt-startup', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS) + ], + pendingStartupByTabId: { 'tab-wt-startup': { command: 'echo pending' } }, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) +}) + +describe('getTerminalWorktreeColdParkRecheckDelayMs', () => { + it('returns the next cold-park policy deadline', () => { + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + hiddenSinceMs: null, + nowMs: 1_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(50) + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + hiddenSinceMs: 1_000, + nowMs: 1_100, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(900) + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + hiddenSinceMs: 1_000, + nowMs: 2_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-worktree-parking.ts b/src/renderer/src/components/terminal/terminal-worktree-parking.ts new file mode 100644 index 000000000..58ea7edf8 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-worktree-parking.ts @@ -0,0 +1,244 @@ +import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../../shared/types' +import { isRemoteRuntimePtyId } from '../../runtime/runtime-terminal-inspection' + +export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4 +export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS +export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000 +export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12 + +export type ColdParkableTerminalTab = Pick + +export type TerminalWorktreeColdParkCandidate = { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null +} + +export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & { + isVisible: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null +} + +function getPendingActivationSpawnCount(value: boolean | number | undefined): number { + if (value === true) { + return 1 + } + return typeof value === 'number' && value > 0 ? value : 0 +} + +export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean { + if (!ptyId) { + return false + } + if (isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) { + return false + } + const separatorIdx = ptyId.lastIndexOf(PTY_SESSION_ID_SEPARATOR) + return separatorIdx === -1 || ptyId.slice(0, separatorIdx) === worktreeId +} + +export function canParkTerminalWorktreeRenderers(args: { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + pendingStartupByTabId: Readonly> + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number +}): boolean { + if ( + args.isVisible || + args.shouldMeasureHiddenWorktree || + args.hasActivityTerminalPortal || + args.hiddenSinceMs === null + ) { + return false + } + if ( + args.nowMs - args.hiddenSinceMs < + (args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS) + ) { + return false + } + return args.terminalTabs.every((tab) => { + if (args.pendingStartupByTabId[tab.id] !== undefined) { + return false + } + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + return false + } + return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId) + }) +} + +export function canParkTerminalTabRenderer(args: { + worktreeId: string + terminalTab: TerminalTabColdParkCandidate + pendingStartupByTabId: Readonly> + nowMs: number + coldParkDelayMs?: number +}): boolean { + const tab = args.terminalTab + if (tab.isVisible || tab.hasActivityTerminalPortal || tab.hiddenSinceMs === null) { + return false + } + if (args.nowMs - tab.hiddenSinceMs < (args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)) { + return false + } + if (args.pendingStartupByTabId[tab.id] !== undefined) { + return false + } + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + return false + } + return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId) +} + +export function selectColdParkedTerminalWorktrees(args: { + worktrees: readonly TerminalWorktreeColdParkCandidate[] + pendingStartupByTabId: Readonly> + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number + hotRetainLimit?: number +}): Set { + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS + const hotRetainMs = args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS + const hotRetainLimit = Math.max(0, args.hotRetainLimit ?? TERMINAL_WORKTREE_HOT_RETAIN_LIMIT) + const coldParkedWorktreeIds = new Set() + const retainedCandidates: { worktreeId: string; hiddenSinceMs: number }[] = [] + + for (const worktree of args.worktrees) { + if ( + !canParkTerminalWorktreeRenderers({ + ...worktree, + pendingStartupByTabId: args.pendingStartupByTabId, + nowMs: args.nowMs, + coldParkDelayMs + }) + ) { + continue + } + const hiddenSinceMs = worktree.hiddenSinceMs + if (hiddenSinceMs === null) { + continue + } + if (args.nowMs - hiddenSinceMs >= hotRetainMs) { + coldParkedWorktreeIds.add(worktree.worktreeId) + continue + } + retainedCandidates.push({ + worktreeId: worktree.worktreeId, + hiddenSinceMs + }) + } + + retainedCandidates.sort((a, b) => { + const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs + return recencyDelta === 0 ? a.worktreeId.localeCompare(b.worktreeId) : recencyDelta + }) + + for (const candidate of retainedCandidates.slice(hotRetainLimit)) { + coldParkedWorktreeIds.add(candidate.worktreeId) + } + + return coldParkedWorktreeIds +} + +export function selectColdParkedTerminalTabs(args: { + worktreeId: string + terminalTabs: readonly TerminalTabColdParkCandidate[] + pendingStartupByTabId: Readonly> + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number + hotRetainLimit?: number +}): Set { + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS + const hotRetainMs = args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS + const hotRetainLimit = Math.max(0, args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT) + const coldParkedTabIds = new Set() + const retainedCandidates: { tabId: string; hiddenSinceMs: number }[] = [] + + for (const tab of args.terminalTabs) { + if ( + !canParkTerminalTabRenderer({ + worktreeId: args.worktreeId, + terminalTab: tab, + pendingStartupByTabId: args.pendingStartupByTabId, + nowMs: args.nowMs, + coldParkDelayMs + }) + ) { + continue + } + const hiddenSinceMs = tab.hiddenSinceMs + if (hiddenSinceMs === null) { + continue + } + if (args.nowMs - hiddenSinceMs >= hotRetainMs) { + coldParkedTabIds.add(tab.id) + continue + } + retainedCandidates.push({ + tabId: tab.id, + hiddenSinceMs + }) + } + + retainedCandidates.sort((a, b) => { + const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs + return recencyDelta === 0 ? a.tabId.localeCompare(b.tabId) : recencyDelta + }) + + for (const candidate of retainedCandidates.slice(hotRetainLimit)) { + coldParkedTabIds.add(candidate.tabId) + } + + return coldParkedTabIds +} + +export function getTerminalWorktreeColdParkRecheckDelayMs(args: { + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number +}): number | null { + if (args.hiddenSinceMs === null) { + return null + } + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS + const hotRetainMs = args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS + const nextRecheckAtMs = [args.hiddenSinceMs + coldParkDelayMs, args.hiddenSinceMs + hotRetainMs] + .filter((deadlineMs) => deadlineMs > args.nowMs) + .sort((a, b) => a - b)[0] + return nextRecheckAtMs === undefined ? null : nextRecheckAtMs - args.nowMs +} + +export function getTerminalTabColdParkRecheckDelayMs(args: { + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number +}): number | null { + if (args.hiddenSinceMs === null) { + return null + } + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS + const hotRetainMs = args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS + const nextRecheckAtMs = [args.hiddenSinceMs + coldParkDelayMs, args.hiddenSinceMs + hotRetainMs] + .filter((deadlineMs) => deadlineMs > args.nowMs) + .sort((a, b) => a - b)[0] + return nextRecheckAtMs === undefined ? null : nextRecheckAtMs - args.nowMs +}