Reset agent sleep after terminal visits (#6450)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
e1f93238d1
commit
d18cdc693f
|
|
@ -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<string>()
|
||||
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] ?? []) : []),
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null)
|
||||
// Why: tracked in React state (not derived from managerRef.getPanes().length)
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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> = {}): 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import type { AgentHibernationCandidate } from './agent-hibernation-planner'
|
||||
|
||||
export type AgentHibernationConfirmationState = Record<string, string>
|
||||
|
||||
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 }
|
||||
}
|
||||
|
|
@ -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<T>(): {
|
|||
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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const MAX_AGENT_HIBERNATION_IDLE_MS = 24 * 60 * 60 * 1000
|
|||
export type AgentHibernationPlannerSnapshot = {
|
||||
settings: Pick<GlobalSettings, 'experimentalAgentHibernation' | 'agentHibernationIdleMs'> | null
|
||||
activeWorktreeId: string | null
|
||||
foregroundWorktreeIds: string[]
|
||||
foregroundTerminalTabIds: string[]
|
||||
tabsByWorktree: Record<string, TerminalTab[]>
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
ptyIdsByTabId: Record<string, string[] | undefined>
|
||||
|
|
@ -25,6 +25,7 @@ export type AgentHibernationPlannerSnapshot = {
|
|||
agentStatusByPaneKey: Record<string, AgentStatusEntry | undefined>
|
||||
sleepingAgentSessionsByPaneKey: Record<string, SleepingAgentSessionRecord | undefined>
|
||||
lastTerminalInputAtByPaneKey: Record<string, number | undefined>
|
||||
foregroundTerminalLastSeenAtByTabId: Record<string, number | undefined>
|
||||
now: number
|
||||
}
|
||||
|
||||
|
|
@ -40,13 +41,6 @@ export type AgentHibernationCandidate = {
|
|||
signature: string
|
||||
}
|
||||
|
||||
export type AgentHibernationConfirmationState = Record<string, string>
|
||||
|
||||
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<string>
|
||||
sleepingAgentSessionsByPaneKey: AgentHibernationPlannerSnapshot['sleepingAgentSessionsByPaneKey']
|
||||
lastTerminalInputAtByPaneKey: AgentHibernationPlannerSnapshot['lastTerminalInputAtByPaneKey']
|
||||
foregroundTerminalLastSeenAtByTabId: AgentHibernationPlannerSnapshot['foregroundTerminalLastSeenAtByTabId']
|
||||
mobileLockedPtyIds: Set<string>
|
||||
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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
let explicitForegroundTerminalTabIds = new Set<string>()
|
||||
const visibleTerminalTabClaimsByToken = new Map<symbol, string>()
|
||||
const foregroundTerminalTabLastSeenAtById = new Map<string, number>()
|
||||
|
||||
function normalizeTerminalTabIds(tabIds: Iterable<string | null | undefined>): Set<string> {
|
||||
return new Set(
|
||||
Array.from(tabIds).filter(
|
||||
(tabId): tabId is string => typeof tabId === 'string' && tabId.length > 0
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function setForegroundTerminalTabIds(tabIds: Iterable<string | null | undefined>): 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<string, number> {
|
||||
return Object.fromEntries(foregroundTerminalTabLastSeenAtById)
|
||||
}
|
||||
|
||||
export function resetForegroundTerminalTabIdsForTests(): void {
|
||||
explicitForegroundTerminalTabIds = new Set()
|
||||
visibleTerminalTabClaimsByToken.clear()
|
||||
foregroundTerminalTabLastSeenAtById.clear()
|
||||
}
|
||||
|
||||
function refreshExitedForegroundTerminalTabLastSeen(
|
||||
previousForegroundTerminalTabIds: Set<string>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
let explicitForegroundWorktreeIds = new Set<string>()
|
||||
const visibleTerminalClaimsByToken = new Map<symbol, string>()
|
||||
|
||||
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 () => {}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
Loading…
Reference in New Issue