Keep visible agent terminals awake

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-06-24 00:42:45 -07:00
co-authored by Orca
parent d993336f90
commit faaa1bf4d3
6 changed files with 165 additions and 7 deletions
@@ -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<number | null>(null)
// Why: tracked in React state (not derived from managerRef.getPanes().length)
// so the render containing the portal map (which reads the imperative pane
@@ -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<typeof ReactModule>()
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([])
})
})
@@ -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])
}
@@ -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))
@@ -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([])
})
})
@@ -1,19 +1,41 @@
let foregroundWorktreeIds = new Set<string>()
let explicitForegroundWorktreeIds = new Set<string>()
const visibleTerminalClaimsByToken = new Map<symbol, string>()
export function setForegroundTerminalWorktreeIds(
worktreeIds: Iterable<string | null | undefined>
): void {
foregroundWorktreeIds = new Set(
function normalizeWorktreeIds(worktreeIds: Iterable<string | null | undefined>): Set<string> {
return new Set(
Array.from(worktreeIds).filter(
(worktreeId): worktreeId is string => typeof worktreeId === 'string' && worktreeId.length > 0
)
)
}
export function setForegroundTerminalWorktreeIds(
worktreeIds: Iterable<string | null | undefined>
): 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()
}