diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 3c7424de1..34417220c 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -64,7 +64,7 @@ import { anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout } from './terminal/split-group-mount' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' -import { setForegroundTerminalWorktreeIds } from '@/lib/foreground-terminal-worktrees' +import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' import { setWindowCloseRequestHandler } from './window-close-request-coordinator' import CodexRestartChip from './CodexRestartChip' @@ -279,23 +279,23 @@ function Terminal(): React.JSX.Element | null { const activityTerminalPortals: ActivityTerminalPortalTarget[] = useActivityTerminalPortals( activeView === 'activity' ) - const foregroundTerminalWorktreeIds = useMemo(() => { + const foregroundTerminalTabIds = useMemo(() => { const ids = new Set() - if (activeView === 'terminal' && renderedActiveWorktreeId) { - ids.add(renderedActiveWorktreeId) + if (activeView === 'terminal' && activeTabType === 'terminal' && activeTabId) { + ids.add(activeTabId) } for (const portal of activityTerminalPortals) { - ids.add(portal.worktreeId) + ids.add(portal.tabId) } return Array.from(ids) - }, [activeView, activityTerminalPortals, renderedActiveWorktreeId]) + }, [activeTabId, activeTabType, activeView, activityTerminalPortals]) useEffect(() => { // Why: hibernation must treat terminals portaled into foreground surfaces - // as visible even when they are not the singular active worktree. - setForegroundTerminalWorktreeIds(foregroundTerminalWorktreeIds) - return () => setForegroundTerminalWorktreeIds([]) - }, [foregroundTerminalWorktreeIds]) + // as visible even when they are not the singular active terminal tab. + setForegroundTerminalTabIds(foregroundTerminalTabIds) + return () => setForegroundTerminalTabIds([]) + }, [foregroundTerminalTabIds]) const tabs = useMemo( () => (renderedActiveWorktreeId ? (tabsByWorktree[renderedActiveWorktreeId] ?? []) : []), diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 4cd9b5eef..27615fced 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -117,7 +117,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' +import { useVisibleTerminalTabClaim } from './use-visible-terminal-tab-claim' // Why: registry lives in a leaf module so the store slice can import it // without re-entering the `slice → TerminalPane → store → slice` cycle @@ -271,7 +271,7 @@ export default function TerminalPane({ const isVisibleRef = useRef(isVisible) isVisibleRef.current = isVisible - useVisibleTerminalWorktreeClaim({ isVisible, worktreeId }) + useVisibleTerminalTabClaim({ isVisible, tabId }) const [expandedPaneId, setExpandedPaneId] = useState(null) // Why: tracked in React state (not derived from managerRef.getPanes().length) 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-tab-claim.test.ts similarity index 61% rename from src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.test.ts rename to src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.test.ts index 79c17b4fc..252c44288 100644 --- a/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.test.ts +++ b/src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.test.ts @@ -1,10 +1,10 @@ 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' + getForegroundTerminalTabIds, + resetForegroundTerminalTabIdsForTests +} from '@/lib/foreground-terminal-tabs' +import { useVisibleTerminalTabClaim } from './use-visible-terminal-tab-claim' const reactEffects = vi.hoisted(() => ({ layoutEffects: [] as (() => void | (() => void))[], @@ -25,30 +25,30 @@ vi.mock('react', async (importOriginal) => { }) afterEach(() => { - resetForegroundTerminalWorktreeIdsForTests() + resetForegroundTerminalTabIdsForTests() reactEffects.layoutEffects = [] reactEffects.passiveEffects = [] }) -describe('useVisibleTerminalWorktreeClaim', () => { +describe('useVisibleTerminalTabClaim', () => { it('registers visible panes through a layout effect', () => { - useVisibleTerminalWorktreeClaim({ isVisible: true, worktreeId: 'wt-visible' }) + useVisibleTerminalTabClaim({ isVisible: true, tabId: 'tab-visible' }) expect(reactEffects.passiveEffects).toHaveLength(0) expect(reactEffects.layoutEffects).toHaveLength(1) const cleanup = reactEffects.layoutEffects[0]() - expect(getForegroundTerminalWorktreeIds()).toEqual(['wt-visible']) + expect(getForegroundTerminalTabIds()).toEqual(['tab-visible']) cleanup?.() - expect(getForegroundTerminalWorktreeIds()).toEqual([]) + expect(getForegroundTerminalTabIds()).toEqual([]) }) it('does not claim hidden panes', () => { - useVisibleTerminalWorktreeClaim({ isVisible: false, worktreeId: 'wt-hidden' }) + useVisibleTerminalTabClaim({ isVisible: false, tabId: 'tab-hidden' }) reactEffects.layoutEffects[0]() - expect(getForegroundTerminalWorktreeIds()).toEqual([]) + expect(getForegroundTerminalTabIds()).toEqual([]) }) }) diff --git a/src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.ts b/src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.ts new file mode 100644 index 000000000..9484b7f9d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-visible-terminal-tab-claim.ts @@ -0,0 +1,21 @@ +import { useLayoutEffect } from 'react' +import { registerVisibleTerminalTab } from '@/lib/foreground-terminal-tabs' + +type VisibleTerminalTabClaimOptions = { + isVisible: boolean + tabId: string +} + +export function useVisibleTerminalTabClaim({ + isVisible, + tabId +}: VisibleTerminalTabClaimOptions): 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 registerVisibleTerminalTab(tabId) + }, [isVisible, tabId]) +} 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 deleted file mode 100644 index 9cb69bf84..000000000 --- a/src/renderer/src/components/terminal-pane/use-visible-terminal-worktree-claim.ts +++ /dev/null @@ -1,21 +0,0 @@ -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-confirmation.test.ts b/src/renderer/src/lib/agent-hibernation-confirmation.test.ts new file mode 100644 index 000000000..f24c8dec7 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-confirmation.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import type { AgentHibernationCandidate } from './agent-hibernation-planner' +import { confirmAgentHibernationCandidates } from './agent-hibernation-confirmation' + +function candidate(overrides: Partial = {}): AgentHibernationCandidate { + return { + id: 'wt-bg|tab-1:leaf-1', + worktreeId: 'wt-bg', + paneKey: 'tab-1:leaf-1', + tabId: 'tab-1', + leafId: 'leaf-1', + paneKeys: ['tab-1:leaf-1'], + targetPtyIds: ['pty-1'], + expectedRuntimePtyIds: ['pty-1'], + signature: 'stable-signature', + ...overrides + } +} + +describe('agent sleep confirmation', () => { + it('requires two stable ticks and resets on signature changes', () => { + const firstCandidate = candidate() + const first = confirmAgentHibernationCandidates({}, [firstCandidate]) + expect(first.candidates).toEqual([]) + expect( + confirmAgentHibernationCandidates(first.confirmationState, [firstCandidate]).candidates + ).toEqual([firstCandidate]) + + const changed = candidate({ signature: 'changed-signature' }) + expect( + confirmAgentHibernationCandidates(first.confirmationState, [changed]).candidates + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/agent-hibernation-confirmation.ts b/src/renderer/src/lib/agent-hibernation-confirmation.ts new file mode 100644 index 000000000..cd00381e8 --- /dev/null +++ b/src/renderer/src/lib/agent-hibernation-confirmation.ts @@ -0,0 +1,23 @@ +import type { AgentHibernationCandidate } from './agent-hibernation-planner' + +export type AgentHibernationConfirmationState = Record + +export type AgentHibernationPlan = { + candidates: AgentHibernationCandidate[] + confirmationState: AgentHibernationConfirmationState +} + +export function confirmAgentHibernationCandidates( + previous: AgentHibernationConfirmationState, + candidates: AgentHibernationCandidate[] +): AgentHibernationPlan { + const confirmationState: AgentHibernationConfirmationState = {} + const confirmed: AgentHibernationCandidate[] = [] + for (const candidate of candidates) { + confirmationState[candidate.id] = candidate.signature + if (previous[candidate.id] === candidate.signature) { + confirmed.push(candidate) + } + } + return { candidates: confirmed, confirmationState } +} diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts index 297eb1b08..2a3bc5d7d 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.test.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.test.ts @@ -5,14 +5,15 @@ import { useAppStore } from '@/store' import { DEFAULT_AGENT_HIBERNATION_IDLE_MS } from './agent-hibernation-planner' import { resetAgentHibernationCoordinatorForTests, + runAgentHibernationTick, startAgentHibernationCoordinator } from './agent-hibernation-coordinator' import { hydrateDrivers, setDriverForPty } from './pane-manager/mobile-driver-state' import { - registerVisibleTerminalWorktree, - resetForegroundTerminalWorktreeIdsForTests, - setForegroundTerminalWorktreeIds -} from './foreground-terminal-worktrees' + registerVisibleTerminalTab, + resetForegroundTerminalTabIdsForTests, + setForegroundTerminalTabIds +} from './foreground-terminal-tabs' import { recordAgentHibernationPaneOutput, resetAgentHibernationOutputActivityForTests @@ -163,7 +164,7 @@ function deferred(): { afterEach(() => { resetAgentHibernationCoordinatorForTests() clearRuntimeCompatibilityCacheForTests() - resetForegroundTerminalWorktreeIdsForTests() + resetForegroundTerminalTabIdsForTests() resetAgentHibernationOutputActivityForTests() hydrateDrivers([]) mockRuntimeEnvironmentCall.mockReset() @@ -229,10 +230,10 @@ describe('agent sleep coordinator', () => { expect(shutdown).not.toHaveBeenCalled() }) - it('does not hibernate a foreground worktree that is not the active worktree', async () => { + it('does not hibernate a foreground terminal tab that is not in the active worktree', async () => { vi.useFakeTimers() const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined)) - setForegroundTerminalWorktreeIds(['wt-bg']) + setForegroundTerminalTabIds(['tab-1']) startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) await vi.advanceTimersByTimeAsync(3000) @@ -240,21 +241,27 @@ describe('agent sleep coordinator', () => { expect(shutdown).not.toHaveBeenCalled() }) - it('does not hibernate a worktree with a visible mounted terminal pane', async () => { + it('does not hibernate a visible mounted terminal tab', async () => { vi.useFakeTimers() + vi.setSystemTime(NOW) const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined)) - const unregister = registerVisibleTerminalWorktree('wt-bg') - startAgentHibernationCoordinator({ intervalMs: 1000, now: () => NOW }) + const unregister = registerVisibleTerminalTab('tab-1') - await vi.advanceTimersByTimeAsync(3000) + await runAgentHibernationTick() expect(shutdown).not.toHaveBeenCalled() + vi.setSystemTime(NOW + 1_000) unregister() - // Why: the coordinator requires one tick to confirm a stable candidate - // and a second tick to revalidate before shutdown. - await vi.advanceTimersByTimeAsync(1000) - await vi.advanceTimersByTimeAsync(1000) + await runAgentHibernationTick() + expect(shutdown).not.toHaveBeenCalled() + vi.setSystemTime(NOW + 1_000 + DEFAULT_AGENT_HIBERNATION_IDLE_MS + 1) + await runAgentHibernationTick() + expect(shutdown).not.toHaveBeenCalled() + + await runAgentHibernationTick() + await Promise.resolve() + await Promise.resolve() expect(shutdown).toHaveBeenCalledWith('wt-bg', { paneKey: `tab-1:${LEAF}`, tabId: 'tab-1', @@ -292,6 +299,37 @@ describe('agent sleep coordinator', () => { expect(shutdown).not.toHaveBeenCalled() }) + it('restarts confirmation when a foreground terminal visit refreshes idle state', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined)) + + await runAgentHibernationTick() + expect(shutdown).not.toHaveBeenCalled() + + vi.setSystemTime(NOW + 1_999) + setForegroundTerminalTabIds(['tab-1']) + vi.setSystemTime(NOW + 2_000) + setForegroundTerminalTabIds([]) + + await runAgentHibernationTick() + expect(shutdown).not.toHaveBeenCalled() + + vi.setSystemTime(NOW + 2_000 + DEFAULT_AGENT_HIBERNATION_IDLE_MS + 1) + await runAgentHibernationTick() + expect(shutdown).not.toHaveBeenCalled() + + await runAgentHibernationTick() + await Promise.resolve() + await Promise.resolve() + expect(shutdown).toHaveBeenCalledWith('wt-bg', { + paneKey: `tab-1:${LEAF}`, + tabId: 'tab-1', + leafId: LEAF, + ptyId: 'pty-1' + }) + }) + it('blocks shutdown when terminal input arrives between confirmation ticks', async () => { vi.useFakeTimers() const shutdown = installEligibleState(vi.fn().mockResolvedValue(undefined)) diff --git a/src/renderer/src/lib/agent-hibernation-coordinator.ts b/src/renderer/src/lib/agent-hibernation-coordinator.ts index b6d4c12a5..d410a1278 100644 --- a/src/renderer/src/lib/agent-hibernation-coordinator.ts +++ b/src/renderer/src/lib/agent-hibernation-coordinator.ts @@ -1,14 +1,19 @@ import { useAppStore } from '@/store' import { - confirmAgentHibernationCandidates, planAgentHibernationCandidates, type AgentHibernationCandidate, - type AgentHibernationConfirmationState, type AgentHibernationPlannerSnapshot } from './agent-hibernation-planner' +import { + confirmAgentHibernationCandidates, + type AgentHibernationConfirmationState +} from './agent-hibernation-confirmation' import type { AppState } from '@/store/types' import { getAllDrivers } from './pane-manager/mobile-driver-state' -import { getForegroundTerminalWorktreeIds } from './foreground-terminal-worktrees' +import { + getForegroundTerminalTabIds, + getForegroundTerminalTabLastSeenAtById +} from './foreground-terminal-tabs' import { getAgentHibernationOutputSignature } from './agent-hibernation-output-activity' import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' @@ -56,7 +61,7 @@ function snapshotFromState( return { settings: state.settings, activeWorktreeId: state.activeWorktreeId, - foregroundWorktreeIds: getForegroundTerminalWorktreeIds(), + foregroundTerminalTabIds: getForegroundTerminalTabIds(), tabsByWorktree: state.tabsByWorktree, terminalLayoutsByTabId: state.terminalLayoutsByTabId, ptyIdsByTabId: state.ptyIdsByTabId, @@ -68,6 +73,7 @@ function snapshotFromState( agentStatusByPaneKey: state.agentStatusByPaneKey, sleepingAgentSessionsByPaneKey: state.sleepingAgentSessionsByPaneKey, lastTerminalInputAtByPaneKey: state.lastTerminalInputAtByPaneKey, + foregroundTerminalLastSeenAtByTabId: getForegroundTerminalTabLastSeenAtById(), now } } diff --git a/src/renderer/src/lib/agent-hibernation-planner.test.ts b/src/renderer/src/lib/agent-hibernation-planner.test.ts index 1c6290534..79064f312 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.test.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.test.ts @@ -5,7 +5,6 @@ import { DEFAULT_AGENT_HIBERNATION_IDLE_MS, MAX_AGENT_HIBERNATION_IDLE_MS, MIN_AGENT_HIBERNATION_IDLE_MS, - confirmAgentHibernationCandidates, getEffectiveAgentHibernationIdleMs, planAgentHibernationCandidates, type AgentHibernationPlannerSnapshot @@ -65,7 +64,7 @@ function snapshot( agentHibernationIdleMs: DEFAULT_AGENT_HIBERNATION_IDLE_MS }, activeWorktreeId: 'wt-active', - foregroundWorktreeIds: ['wt-active'], + foregroundTerminalTabIds: [], tabsByWorktree: { 'wt-bg': [tab()] }, terminalLayoutsByTabId: { 'tab-1': layout() }, ptyIdsByTabId: { 'tab-1': ['pty-1'] }, @@ -73,6 +72,7 @@ function snapshot( agentStatusByPaneKey: { [agentEntry.paneKey]: agentEntry }, sleepingAgentSessionsByPaneKey: {}, lastTerminalInputAtByPaneKey: {}, + foregroundTerminalLastSeenAtByTabId: {}, now: NOW, ...overrides } @@ -99,9 +99,7 @@ describe('agent sleep planner', () => { ) ).toEqual([]) expect(plannedWorktrees(snapshot({ activeWorktreeId: 'wt-bg' }))).toEqual([]) - expect(plannedWorktrees(snapshot({ foregroundWorktreeIds: ['wt-active', 'wt-bg'] }))).toEqual( - [] - ) + expect(plannedWorktrees(snapshot({ foregroundTerminalTabIds: ['tab-1'] }))).toEqual([]) }) it('requires done resumable provider-session entries', () => { @@ -136,6 +134,89 @@ describe('agent sleep planner', () => { ).toEqual(['wt-bg']) }) + it('uses foreground terminal tab last-seen as the idle baseline when it is newer', () => { + expect( + plannedWorktrees( + snapshot({ + foregroundTerminalLastSeenAtByTabId: { + 'tab-1': NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS + 1 + } + }) + ) + ).toEqual([]) + expect( + plannedWorktrees( + snapshot({ + foregroundTerminalLastSeenAtByTabId: { + 'tab-1': NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1 + } + }) + ) + ).toEqual(['wt-bg']) + expect( + plannedWorktrees( + snapshot({ + foregroundTerminalLastSeenAtByTabId: { + 'tab-1': NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1 + }, + lastTerminalInputAtByPaneKey: { [`tab-1:${LEAF}`]: OLD + 1 } + }) + ) + ).toEqual([]) + }) + + it('does not let one foreground terminal tab reset a sibling tab in the same worktree', () => { + const siblingEntry = entry({ + paneKey: `tab-2:${OTHER_LEAF}`, + tabId: 'tab-2', + providerSession: { key: 'session_id', id: 'session-2' } + }) + + expect( + plannedPaneKeys( + snapshot({ + foregroundTerminalTabIds: ['tab-1'], + foregroundTerminalLastSeenAtByTabId: { + 'tab-1': NOW + }, + tabsByWorktree: { 'wt-bg': [tab('tab-1'), tab('tab-2')] }, + terminalLayoutsByTabId: { + 'tab-1': layout(), + 'tab-2': layout(OTHER_LEAF, 'pty-2') + }, + ptyIdsByTabId: { + 'tab-1': ['pty-1'], + 'tab-2': ['pty-2'] + }, + agentStatusByPaneKey: { + [`tab-1:${LEAF}`]: entry(), + [siblingEntry.paneKey]: siblingEntry + } + }) + ) + ).toEqual([`tab-2:${OTHER_LEAF}`]) + }) + + it('includes the effective idle start in the candidate signature', () => { + const oldEntry = entry({ + updatedAt: NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 10_000, + stateStartedAt: NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 10_000 + }) + const [withoutVisit] = planAgentHibernationCandidates( + snapshot({ agentStatusByPaneKey: { [oldEntry.paneKey]: oldEntry } }) + ) + const [withVisit] = planAgentHibernationCandidates( + snapshot({ + agentStatusByPaneKey: { [oldEntry.paneKey]: oldEntry }, + foregroundTerminalLastSeenAtByTabId: { + 'tab-1': NOW - DEFAULT_AGENT_HIBERNATION_IDLE_MS - 1 + } + }) + ) + + expect(withoutVisit.signature).not.toEqual(withVisit.signature) + }) + it('emits a pane candidate when a sibling shell PTY is live', () => { expect( planAgentHibernationCandidates( @@ -309,19 +390,6 @@ describe('agent sleep planner', () => { ).toEqual([`tab-1:${LEAF}`, `tab-1:${OTHER_LEAF}`]) }) - it('requires two stable ticks and resets on signature changes', () => { - const [candidate] = planAgentHibernationCandidates(snapshot()) - const first = confirmAgentHibernationCandidates({}, [candidate]) - expect(first.candidates).toEqual([]) - expect( - confirmAgentHibernationCandidates(first.confirmationState, [candidate]).candidates - ).toEqual([candidate]) - const changed = { ...candidate, signature: `${candidate.signature}:changed` } - expect( - confirmAgentHibernationCandidates(first.confirmationState, [changed]).candidates - ).toEqual([]) - }) - it('clamps corrupt or out-of-range idle durations to the default', () => { expect(getEffectiveAgentHibernationIdleMs(0)).toBe(DEFAULT_AGENT_HIBERNATION_IDLE_MS) expect(getEffectiveAgentHibernationIdleMs(Number.NaN)).toBe(DEFAULT_AGENT_HIBERNATION_IDLE_MS) diff --git a/src/renderer/src/lib/agent-hibernation-planner.ts b/src/renderer/src/lib/agent-hibernation-planner.ts index 43f41bd57..a83943dd2 100644 --- a/src/renderer/src/lib/agent-hibernation-planner.ts +++ b/src/renderer/src/lib/agent-hibernation-planner.ts @@ -15,7 +15,7 @@ export const MAX_AGENT_HIBERNATION_IDLE_MS = 24 * 60 * 60 * 1000 export type AgentHibernationPlannerSnapshot = { settings: Pick | null activeWorktreeId: string | null - foregroundWorktreeIds: string[] + foregroundTerminalTabIds: string[] tabsByWorktree: Record terminalLayoutsByTabId: Record ptyIdsByTabId: Record @@ -25,6 +25,7 @@ export type AgentHibernationPlannerSnapshot = { agentStatusByPaneKey: Record sleepingAgentSessionsByPaneKey: Record lastTerminalInputAtByPaneKey: Record + foregroundTerminalLastSeenAtByTabId: Record now: number } @@ -40,13 +41,6 @@ export type AgentHibernationCandidate = { signature: string } -export type AgentHibernationConfirmationState = Record - -export type AgentHibernationPlan = { - candidates: AgentHibernationCandidate[] - confirmationState: AgentHibernationConfirmationState -} - type EligiblePane = { paneKey: string tabId: string @@ -56,6 +50,7 @@ type EligiblePane = { providerSessionId: string state: AgentStatusEntry['state'] updatedAt: number + effectiveIdleStart: number inputAt: number } @@ -120,6 +115,7 @@ function getEligiblePane(args: { livePtyIds: Set sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey'] lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey'] + foregroundTerminalLastSeenAtByTabId: AgentHibernationPlannerSnapshot['foregroundTerminalLastSeenAtByTabId'] mobileLockedPtyIds: Set now: number idleMs: number @@ -131,6 +127,7 @@ function getEligiblePane(args: { livePtyIds, sleepingAgentSessionsByPaneKey, lastTerminalInputAtByPaneKey, + foregroundTerminalLastSeenAtByTabId, mobileLockedPtyIds } = args if ( @@ -152,7 +149,16 @@ function getEligiblePane(args: { if (!getAgentResumeArgv(entry.agentType, entry.providerSession)) { return null } - if (args.now - entry.updatedAt < args.idleMs) { + // Why: returning to the containing terminal tab should restart sleep even + // without pane input; sibling tabs in the worktree should keep their age. + const foregroundLastSeenAt = foregroundTerminalLastSeenAtByTabId[tab.id] + const effectiveIdleStart = Math.max( + entry.updatedAt, + typeof foregroundLastSeenAt === 'number' && Number.isFinite(foregroundLastSeenAt) + ? foregroundLastSeenAt + : 0 + ) + if (args.now - effectiveIdleStart < args.idleMs) { return null } const inputAt = lastTerminalInputAtByPaneKey[entry.paneKey] @@ -177,6 +183,7 @@ function getEligiblePane(args: { providerSessionId: entry.providerSession.id, state: entry.state, updatedAt: entry.updatedAt, + effectiveIdleStart, inputAt: typeof inputAt === 'number' && Number.isFinite(inputAt) ? inputAt : 0 } } @@ -187,7 +194,7 @@ function signatureFor(worktreeId: string, panes: EligiblePane[]): string { .sort((a, b) => a.paneKey.localeCompare(b.paneKey)) .map( (pane) => - `${pane.paneKey}:${pane.ptyId}:${pane.runtimePtyId}:${pane.providerSessionId}:${pane.state}:${pane.updatedAt}:${pane.inputAt}` + `${pane.paneKey}:${pane.ptyId}:${pane.runtimePtyId}:${pane.providerSessionId}:${pane.state}:${pane.updatedAt}:${pane.effectiveIdleStart}:${pane.inputAt}` ) return `${worktreeId}|${parts.join('|')}` } @@ -226,19 +233,14 @@ export function planAgentHibernationCandidates( } const idleMs = getEffectiveAgentHibernationIdleMs(snapshot.settings.agentHibernationIdleMs) const mobileLockedPtyIds = new Set(snapshot.mobileLockedPtyIds.map(toRuntimePtyId)) - const foregroundWorktreeIds = new Set(snapshot.foregroundWorktreeIds) + const foregroundTerminalTabIds = new Set(snapshot.foregroundTerminalTabIds) const runtimeLivenessRequiredWorktreeIds = new Set( snapshot.runtimeLivenessRequiredWorktreeIds ?? [] ) const agentEntriesByTabId = getAgentEntriesByTabId(snapshot.agentStatusByPaneKey) const candidates: AgentHibernationCandidate[] = [] for (const [worktreeId, tabs] of Object.entries(snapshot.tabsByWorktree)) { - if ( - !worktreeId || - worktreeId === snapshot.activeWorktreeId || - foregroundWorktreeIds.has(worktreeId) || - tabs.length === 0 - ) { + if (!worktreeId || worktreeId === snapshot.activeWorktreeId || tabs.length === 0) { continue } if ( @@ -251,6 +253,9 @@ export function planAgentHibernationCandidates( continue } for (const tab of tabs) { + if (foregroundTerminalTabIds.has(tab.id)) { + continue + } const tabLivePtyIds = getLivePtyIdsForTab( tab, snapshot.ptyIdsByTabId, @@ -269,6 +274,7 @@ export function planAgentHibernationCandidates( livePtyIds: new Set(tabLivePtyIds), sleepingAgentSessionsByPaneKey: snapshot.sleepingAgentSessionsByPaneKey, lastTerminalInputAtByPaneKey: snapshot.lastTerminalInputAtByPaneKey, + foregroundTerminalLastSeenAtByTabId: snapshot.foregroundTerminalLastSeenAtByTabId, mobileLockedPtyIds, now: snapshot.now, idleMs @@ -293,18 +299,3 @@ export function planAgentHibernationCandidates( (a, b) => a.worktreeId.localeCompare(b.worktreeId) || a.paneKey.localeCompare(b.paneKey) ) } - -export function confirmAgentHibernationCandidates( - previous: AgentHibernationConfirmationState, - candidates: AgentHibernationCandidate[] -): AgentHibernationPlan { - const confirmationState: AgentHibernationConfirmationState = {} - const confirmed: AgentHibernationCandidate[] = [] - for (const candidate of candidates) { - confirmationState[candidate.id] = candidate.signature - if (previous[candidate.id] === candidate.signature) { - confirmed.push(candidate) - } - } - return { candidates: confirmed, confirmationState } -} diff --git a/src/renderer/src/lib/foreground-terminal-tabs.test.ts b/src/renderer/src/lib/foreground-terminal-tabs.test.ts new file mode 100644 index 000000000..5b829fdf1 --- /dev/null +++ b/src/renderer/src/lib/foreground-terminal-tabs.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + getForegroundTerminalTabIds, + getForegroundTerminalTabLastSeenAtById, + registerVisibleTerminalTab, + resetForegroundTerminalTabIdsForTests, + setForegroundTerminalTabIds +} from './foreground-terminal-tabs' + +afterEach(() => { + resetForegroundTerminalTabIdsForTests() + vi.useRealTimers() +}) + +describe('foreground terminal tabs', () => { + it('returns the union of explicit foreground ids and visible terminal claims', () => { + setForegroundTerminalTabIds(['tab-explicit', null, '', undefined]) + const unregister = registerVisibleTerminalTab('tab-visible') + + expect(getForegroundTerminalTabIds().sort()).toEqual(['tab-explicit', 'tab-visible']) + + unregister() + expect(getForegroundTerminalTabIds()).toEqual(['tab-explicit']) + }) + + it('keeps duplicate visible terminal tab claims until every token unregisters', () => { + const unregisterFirst = registerVisibleTerminalTab('tab-visible') + const unregisterSecond = registerVisibleTerminalTab('tab-visible') + + expect(getForegroundTerminalTabIds()).toEqual(['tab-visible']) + + unregisterFirst() + expect(getForegroundTerminalTabIds()).toEqual(['tab-visible']) + + unregisterSecond() + expect(getForegroundTerminalTabIds()).toEqual([]) + }) + + it('records last-seen timestamps for explicit foreground entries and clears them in tests', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + + setForegroundTerminalTabIds(['tab-explicit', null, '', undefined]) + + expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-explicit': 1_000 }) + + resetForegroundTerminalTabIdsForTests() + expect(getForegroundTerminalTabLastSeenAtById()).toEqual({}) + }) + + it('refreshes last-seen when explicit foreground ids leave the combined foreground set', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + setForegroundTerminalTabIds(['tab-old']) + + vi.setSystemTime(2_000) + setForegroundTerminalTabIds(['tab-new']) + + expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ + 'tab-old': 2_000, + 'tab-new': 2_000 + }) + }) + + it('does not refresh an explicit foreground removal while a visible claim remains', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + setForegroundTerminalTabIds(['tab-combined']) + + vi.setSystemTime(2_000) + const unregister = registerVisibleTerminalTab('tab-combined') + + vi.setSystemTime(3_000) + setForegroundTerminalTabIds([]) + + expect(getForegroundTerminalTabIds()).toEqual(['tab-combined']) + expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-combined': 2_000 }) + + vi.setSystemTime(4_000) + unregister() + + expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-combined': 4_000 }) + }) + + it('refreshes visible-claim last-seen only when the last claim leaves', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const unregisterFirst = registerVisibleTerminalTab('tab-visible') + + vi.setSystemTime(2_000) + const unregisterSecond = registerVisibleTerminalTab('tab-visible') + + vi.setSystemTime(3_000) + unregisterFirst() + + expect(getForegroundTerminalTabIds()).toEqual(['tab-visible']) + expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-visible': 2_000 }) + + vi.setSystemTime(4_000) + unregisterSecond() + + expect(getForegroundTerminalTabIds()).toEqual([]) + expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-visible': 4_000 }) + }) + + it('keeps visible-claim cleanup idempotent for last-seen timestamps', () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const unregister = registerVisibleTerminalTab('tab-visible') + + vi.setSystemTime(2_000) + unregister() + + vi.setSystemTime(3_000) + unregister() + + expect(getForegroundTerminalTabLastSeenAtById()).toEqual({ 'tab-visible': 2_000 }) + }) +}) diff --git a/src/renderer/src/lib/foreground-terminal-tabs.ts b/src/renderer/src/lib/foreground-terminal-tabs.ts new file mode 100644 index 000000000..7824598db --- /dev/null +++ b/src/renderer/src/lib/foreground-terminal-tabs.ts @@ -0,0 +1,75 @@ +let explicitForegroundTerminalTabIds = new Set() +const visibleTerminalTabClaimsByToken = new Map() +const foregroundTerminalTabLastSeenAtById = new Map() + +function normalizeTerminalTabIds(tabIds: Iterable): Set { + return new Set( + Array.from(tabIds).filter( + (tabId): tabId is string => typeof tabId === 'string' && tabId.length > 0 + ) + ) +} + +export function setForegroundTerminalTabIds(tabIds: Iterable): void { + const previousForegroundTerminalTabIds = new Set(getForegroundTerminalTabIds()) + explicitForegroundTerminalTabIds = normalizeTerminalTabIds(tabIds) + const now = Date.now() + for (const tabId of explicitForegroundTerminalTabIds) { + foregroundTerminalTabLastSeenAtById.set(tabId, now) + } + refreshExitedForegroundTerminalTabLastSeen(previousForegroundTerminalTabIds, now) +} + +export function registerVisibleTerminalTab(tabId: string | null | undefined): () => void { + const normalized = normalizeTerminalTabIds([tabId]) + const id = Array.from(normalized)[0] + if (!id) { + return () => {} + } + + // Why: multiple visible panes can belong to one terminal tab; tokenized claims + // let each pane clean up without dropping sibling foreground protection. + const token = Symbol(id) + visibleTerminalTabClaimsByToken.set(token, id) + foregroundTerminalTabLastSeenAtById.set(id, Date.now()) + return () => { + if (!visibleTerminalTabClaimsByToken.delete(token)) { + return + } + if (!getForegroundTerminalTabIds().includes(id)) { + // Why: keep the sleep timer anchored to the end of the full foreground visit. + foregroundTerminalTabLastSeenAtById.set(id, Date.now()) + } + } +} + +export function getForegroundTerminalTabIds(): string[] { + // Why: hibernation already reasons by terminal tab, so visible pane claims + // join the page-level foreground set instead of adding pane rules. + return Array.from( + new Set([...explicitForegroundTerminalTabIds, ...visibleTerminalTabClaimsByToken.values()]) + ) +} + +export function getForegroundTerminalTabLastSeenAtById(): Record { + return Object.fromEntries(foregroundTerminalTabLastSeenAtById) +} + +export function resetForegroundTerminalTabIdsForTests(): void { + explicitForegroundTerminalTabIds = new Set() + visibleTerminalTabClaimsByToken.clear() + foregroundTerminalTabLastSeenAtById.clear() +} + +function refreshExitedForegroundTerminalTabLastSeen( + previousForegroundTerminalTabIds: Set, + now: number +): void { + const currentForegroundTerminalTabIds = new Set(getForegroundTerminalTabIds()) + for (const tabId of previousForegroundTerminalTabIds) { + if (!currentForegroundTerminalTabIds.has(tabId)) { + // Why: visible panes can keep a terminal tab foreground after explicit ids change. + foregroundTerminalTabLastSeenAtById.set(tabId, now) + } + } +} diff --git a/src/renderer/src/lib/foreground-terminal-worktrees.test.ts b/src/renderer/src/lib/foreground-terminal-worktrees.test.ts deleted file mode 100644 index dcffad497..000000000 --- a/src/renderer/src/lib/foreground-terminal-worktrees.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 338ec55ba..000000000 --- a/src/renderer/src/lib/foreground-terminal-worktrees.ts +++ /dev/null @@ -1,45 +0,0 @@ -let explicitForegroundWorktreeIds = new Set() -const visibleTerminalClaimsByToken = new Map() - -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 () => {} - } - - // Why: multiple visible panes can belong to one worktree; tokenized claims - // let each pane clean up without dropping sibling foreground protection. - const token = Symbol(id) - visibleTerminalClaimsByToken.set(token, id) - return () => { - visibleTerminalClaimsByToken.delete(token) - } -} - -export function getForegroundTerminalWorktreeIds(): string[] { - // Why: hibernation already gates by foreground worktree, so visible pane - // claims join the page-level foreground set instead of adding pane rules. - return Array.from( - new Set([...explicitForegroundWorktreeIds, ...visibleTerminalClaimsByToken.values()]) - ) -} - -export function resetForegroundTerminalWorktreeIdsForTests(): void { - explicitForegroundWorktreeIds = new Set() - visibleTerminalClaimsByToken.clear() -}