diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index d49b48c0fa7..bda9d525d18 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -111,6 +111,7 @@ import { keybindingMatchesAction } from '../../../../shared/keybindings' import { pasteTerminalClipboard } from './terminal-clipboard-paste' import { scheduleImagePasteWebglAtlasRecovery } from './terminal-webgl-paste-recovery' import { restoreTerminalFitToDesktop, restoreTerminalFitsToDesktop } from './terminal-fit-restore' +import { useVisibleTerminalWorktreeClaim } from './use-visible-terminal-worktree-claim' // Why: registry lives in a leaf module so the store slice can import it // without re-entering the `slice → TerminalPane → store → slice` cycle @@ -261,6 +262,8 @@ export default function TerminalPane({ const isVisibleRef = useRef(isVisible) isVisibleRef.current = isVisible + useVisibleTerminalWorktreeClaim({ isVisible, worktreeId }) + const [expandedPaneId, setExpandedPaneId] = useState(null) // Why: tracked in React state (not derived from managerRef.getPanes().length) // so the render containing the portal map (which reads the imperative pane diff --git a/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.test.ts b/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.test.ts new file mode 100644 index 00000000000..79c17b4fcca --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.test.ts @@ -0,0 +1,54 @@ +import type * as ReactModule from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getForegroundTerminalWorktreeIds, + resetForegroundTerminalWorktreeIdsForTests +} from '@/lib/foreground-terminal-worktrees' +import { useVisibleTerminalWorktreeClaim } from './use-visible-terminal-worktree-claim' + +const reactEffects = vi.hoisted(() => ({ + layoutEffects: [] as (() => void | (() => void))[], + passiveEffects: [] as (() => void | (() => void))[] +})) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useEffect: (effect: () => void | (() => void)) => { + reactEffects.passiveEffects.push(effect) + }, + useLayoutEffect: (effect: () => void | (() => void)) => { + reactEffects.layoutEffects.push(effect) + } + } +}) + +afterEach(() => { + resetForegroundTerminalWorktreeIdsForTests() + reactEffects.layoutEffects = [] + reactEffects.passiveEffects = [] +}) + +describe('useVisibleTerminalWorktreeClaim', () => { + it('registers visible panes through a layout effect', () => { + useVisibleTerminalWorktreeClaim({ isVisible: true, worktreeId: 'wt-visible' }) + + expect(reactEffects.passiveEffects).toHaveLength(0) + expect(reactEffects.layoutEffects).toHaveLength(1) + + const cleanup = reactEffects.layoutEffects[0]() + expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-visible']) + + cleanup?.() + expect(getForegroundTerminalWorktreeIds()).toEqual([]) + }) + + it('does not claim hidden panes', () => { + useVisibleTerminalWorktreeClaim({ isVisible: false, worktreeId: 'wt-hidden' }) + + reactEffects.layoutEffects[0]() + + expect(getForegroundTerminalWorktreeIds()).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.ts b/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.ts new file mode 100644 index 00000000000..9cb69bf84af --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.ts @@ -0,0 +1,21 @@ +import { useLayoutEffect } from 'react' +import { registerVisibleTerminalWorktree } from '@/lib/foreground-terminal-worktrees' + +type VisibleTerminalWorktreeClaimOptions = { + isVisible: boolean + worktreeId: string +} + +export function useVisibleTerminalWorktreeClaim({ + isVisible, + worktreeId +}: VisibleTerminalWorktreeClaimOptions): void { + useLayoutEffect(() => { + if (!isVisible) { + return + } + // Why: agent sleep must fail closed before paint for any pane the user can + // see, even when global active-worktree state is between views. + return registerVisibleTerminalWorktree(worktreeId) + }, [isVisible, worktreeId]) +} diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index 811b8ed48a4..f6e4f8f2a37 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -9,6 +9,7 @@ import { } from './agent-hibernation-coordinator' import { hydrateDrivers, setDriverForPty } from './pane-manager/mobile-driver-state' import { + registerVisibleTerminalWorktree, resetForegroundTerminalWorktreeIdsForTests, setForegroundTerminalWorktreeIds } from './foreground-terminal-worktrees' @@ -239,6 +240,27 @@ describe('agent sleep coordinator', () => { expect(shutdown).not.toHaveBeenCalled() }) + it('does not hibernate a worktree with a visible mounted terminal pane', async () => { + vi.useFakeTimers() + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined)) + const unregister = registerVisibleTerminalWorktree('wt-bg') + startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + + await vi.advanceTimersByTimeAsync(3000) + expect(shutdown).not.toHaveBeenCalled() + + unregister() + await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(1000) + + expect(shutdown).toHaveBeenCalledWith('wt-bg', { + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1' + }) + }) + it('requires the same candidate signature during final revalidation', async () => { vi.useFakeTimers() const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined)) diff --git a/src/renderer/src/lib/foreground-terminal-worktrees.test.ts b/src/renderer/src/lib/foreground-terminal-worktrees.test.ts new file mode 100644 index 00000000000..dcffad49739 --- /dev/null +++ b/src/renderer/src/lib/foreground-terminal-worktrees.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + getForegroundTerminalWorktreeIds, + registerVisibleTerminalWorktree, + resetForegroundTerminalWorktreeIdsForTests, + setForegroundTerminalWorktreeIds +} from './foreground-terminal-worktrees' + +afterEach(() => { + resetForegroundTerminalWorktreeIdsForTests() +}) + +describe('foreground terminal worktrees', () => { + it('returns the union of explicit foreground ids and visible terminal claims', () => { + setForegroundTerminalWorktreeIds(['wt-explicit', null, '', undefined]) + const unregister = registerVisibleTerminalWorktree('wt-visible') + + expect(getForegroundTerminalWorktreeIds().sort()).toEqual(['wt-explicit', 'wt-visible']) + + unregister() + expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-explicit']) + }) + + it('keeps duplicate visible worktree claims until every token unregisters', () => { + const unregisterFirst = registerVisibleTerminalWorktree('wt-visible') + const unregisterSecond = registerVisibleTerminalWorktree('wt-visible') + + expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-visible']) + + unregisterFirst() + expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-visible']) + + unregisterSecond() + expect(getForegroundTerminalWorktreeIds()).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/foreground-terminal-worktrees.ts b/src/renderer/src/lib/foreground-terminal-worktrees.ts index 6b1fa4943b1..c270f7830dd 100644 --- a/src/renderer/src/lib/foreground-terminal-worktrees.ts +++ b/src/renderer/src/lib/foreground-terminal-worktrees.ts @@ -1,19 +1,41 @@ -let foregroundWorktreeIds = new Set() +let explicitForegroundWorktreeIds = new Set() +const visibleTerminalClaimsByToken = new Map() -export function setForegroundTerminalWorktreeIds( - worktreeIds: Iterable -): void { - foregroundWorktreeIds = new Set( +function normalizeWorktreeIds(worktreeIds: Iterable): Set { + return new Set( Array.from(worktreeIds).filter( (worktreeId): worktreeId is string => typeof worktreeId === 'string' && worktreeId.length > 0 ) ) } +export function setForegroundTerminalWorktreeIds( + worktreeIds: Iterable +): void { + explicitForegroundWorktreeIds = normalizeWorktreeIds(worktreeIds) +} + +export function registerVisibleTerminalWorktree(worktreeId: string | null | undefined): () => void { + const normalized = normalizeWorktreeIds([worktreeId]) + const id = Array.from(normalized)[0] + if (!id) { + return () => {} + } + + const token = Symbol(id) + visibleTerminalClaimsByToken.set(token, id) + return () => { + visibleTerminalClaimsByToken.delete(token) + } +} + export function getForegroundTerminalWorktreeIds(): string[] { - return Array.from(foregroundWorktreeIds) + return Array.from( + new Set([...explicitForegroundWorktreeIds, ...visibleTerminalClaimsByToken.values()]) + ) } export function resetForegroundTerminalWorktreeIdsForTests(): void { - foregroundWorktreeIds = new Set() + explicitForegroundWorktreeIds = new Set() + visibleTerminalClaimsByToken.clear() }