mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
Keep visible agent terminals awake
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user