perf: reduce hidden terminal process polling

Avoid per-pane notification-setting store subscribers and keep hidden idle terminals off the agent-completion foreground-process polling cadence until they have agent evidence.
This commit is contained in:
Neil 2026-05-30 12:46:12 -07:00 committed by GitHub
parent 5dc6947fc6
commit c43092a63d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 136 additions and 4 deletions

View File

@ -12,6 +12,7 @@ export type AgentCompletionCoordinatorOptions = {
) => Promise<RuntimeTerminalProcessInspection>
dispatchCompletion: (title: string) => void
isLive: () => boolean
shouldPollProcessCadence?: () => boolean
}
export type AgentCompletionCoordinator = {

View File

@ -45,6 +45,47 @@ describe('agent completion coordinator', () => {
vi.restoreAllMocks()
})
it('does not schedule cadence process inspections for hidden idle panes', () => {
const inspectProcess = vi.fn(async () => processResult(null))
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess,
dispatchCompletion: vi.fn(),
isLive: () => true,
shouldPollProcessCadence: () => false
})
coordinator.startProcessTracking()
vi.advanceTimersByTime(10_000)
expect(inspectProcess).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it('keeps the process-exit backstop after hidden panes gain agent evidence', async () => {
const inspectProcess = vi.fn(async () => processResult('codex'))
const coordinator = createAgentCompletionCoordinator({
paneKey: 'tab-1:leaf-1',
getPtyId: () => 'pty-1',
getSettings: () => null,
inspectProcess,
dispatchCompletion: vi.fn(),
isLive: () => true,
shouldPollProcessCadence: () => false
})
coordinator.startProcessTracking()
expect(vi.getTimerCount()).toBe(0)
coordinator.observeTitle('Codex working')
vi.advanceTimersByTime(2_000)
await flushAsyncTicks()
expect(inspectProcess).toHaveBeenCalledTimes(1)
})
it('clears process evidence after agent exit so later non-agent spinner titles do not notify', async () => {
let foregroundProcess: string | null = 'codex'
const dispatchCompletion = vi.fn()

View File

@ -83,6 +83,7 @@ export function createAgentCompletionCoordinator(
function establishAgentEvidence(): void {
agentIdentityEstablished = true
hasAgentRunEvidence = true
scheduleNextPoll()
}
function clearAgentRunEvidence(): void {
@ -218,6 +219,9 @@ export function createAgentCompletionCoordinator(
if (disposed || inspectionInFlight || !options.isLive()) {
return
}
if (priority === 'cadence' && !shouldRunCadenceInspection()) {
return
}
const ptyId = options.getPtyId()
if (!ptyId) {
return
@ -276,6 +280,17 @@ export function createAgentCompletionCoordinator(
})
}
function shouldRunCadenceInspection(): boolean {
// Why: hidden idle terminals should not join the global process-inspection
// cadence. Once a pane has agent evidence, keep the backstop alive so an
// unannounced process exit can still produce/clear completion state.
return (
hasAgentRunEvidence ||
lastForegroundAgent !== null ||
options.shouldPollProcessCadence?.() !== false
)
}
function nextPollInterval(): number {
const base = lastForegroundAgent ? ACTIVE_POLL_INTERVAL_MS : IDLE_POLL_INTERVAL_MS
const backoff =
@ -290,6 +305,9 @@ export function createAgentCompletionCoordinator(
if (disposed || !options.isLive() || pollTimer !== null || pendingTitle) {
return
}
if (!shouldRunCadenceInspection()) {
return
}
const ptyId = options.getPtyId()
if (!ptyId) {
return

View File

@ -3753,6 +3753,31 @@ describe('connectPanePty', () => {
)
})
it('shares one raw store subscriber for agent-complete notification settings across panes', async () => {
const { connectPanePty } = await import('./pty-connection')
transportFactoryQueue.push(createMockTransport('pty-1'), createMockTransport('pty-2'))
vi.useFakeTimers()
const firstBinding = connectPanePty(
createPane(1) as never,
createManager(1) as never,
createDeps() as never
)
const secondBinding = connectPanePty(
createPane(2) as never,
createManager(1) as never,
createDeps() as never
)
await flushAsyncTicks()
expect(storeSubscribers).toHaveLength(1)
firstBinding.dispose()
expect(storeSubscribers).toHaveLength(1)
secondBinding.dispose()
expect(storeSubscribers).toHaveLength(0)
})
it('does not dispatch generic title completions when agent-complete notifications are disabled', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('pty-codex')

View File

@ -120,13 +120,53 @@ let codexRestartNoticePresence = false
export type PanePtyBinding = IDisposable & {
syncRendererOutputVisibility: () => void
syncProcessTracking: () => void
}
function isAgentTaskCompleteNotificationEnabled(): boolean {
const notifications = useAppStore.getState().settings?.notifications
return isAgentTaskCompleteNotificationEnabledFromState(useAppStore.getState())
}
function isAgentTaskCompleteNotificationEnabledFromState(
state: ReturnType<typeof useAppStore.getState>
): boolean {
const notifications = state.settings?.notifications
return notifications?.enabled !== false && notifications?.agentTaskComplete !== false
}
const agentTaskCompleteNotificationEnabledListeners = new Set<() => void>()
let agentTaskCompleteNotificationSettingsUnsubscribe: (() => void) | null = null
let agentTaskCompleteNotificationEnabledSnapshot: boolean | null = null
function subscribeAgentTaskCompleteNotificationEnabled(listener: () => void): () => void {
if (agentTaskCompleteNotificationSettingsUnsubscribe === null) {
agentTaskCompleteNotificationEnabledSnapshot = isAgentTaskCompleteNotificationEnabled()
agentTaskCompleteNotificationSettingsUnsubscribe = useAppStore.subscribe((state) => {
const enabled = isAgentTaskCompleteNotificationEnabledFromState(state)
if (enabled === agentTaskCompleteNotificationEnabledSnapshot) {
return
}
agentTaskCompleteNotificationEnabledSnapshot = enabled
for (const subscriber of Array.from(agentTaskCompleteNotificationEnabledListeners)) {
subscriber()
}
})
}
agentTaskCompleteNotificationEnabledListeners.add(listener)
return () => {
agentTaskCompleteNotificationEnabledListeners.delete(listener)
if (
agentTaskCompleteNotificationEnabledListeners.size === 0 &&
agentTaskCompleteNotificationSettingsUnsubscribe !== null
) {
agentTaskCompleteNotificationSettingsUnsubscribe()
agentTaskCompleteNotificationSettingsUnsubscribe = null
agentTaskCompleteNotificationEnabledSnapshot = null
}
}
}
function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean {
return Boolean(
entry &&
@ -560,6 +600,8 @@ export function connectPanePty(
getSettings: () => useAppStore.getState().settings,
inspectProcess: inspectRuntimeTerminalProcess,
dispatchCompletion: (title) => scheduleAgentTaskCompleteNotification(title),
shouldPollProcessCadence: () =>
isAgentTaskCompleteNotificationEnabled() && deps.isVisibleRef.current,
isLive: () => {
if (disposed) {
return false
@ -896,9 +938,9 @@ export function connectPanePty(
AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS
)
}
agentTaskCompleteSettingsUnsubscribe = useAppStore.subscribe((state, previousState) => {
if (state.settings?.notifications !== previousState?.settings?.notifications) {
syncAgentTaskCompleteNotificationEnabled()
agentTaskCompleteSettingsUnsubscribe = subscribeAgentTaskCompleteNotificationEnabled(() => {
if (syncAgentTaskCompleteNotificationEnabled()) {
agentCompletionCoordinator.startProcessTracking()
}
})
@ -2482,6 +2524,9 @@ export function connectPanePty(
syncRendererOutputVisibility() {
syncRendererOutputVisibility()
},
syncProcessTracking() {
agentCompletionCoordinator.startProcessTracking()
},
dispose() {
disposed = true
if (terminalKeyTargetSupportsEvents) {

View File

@ -1190,8 +1190,10 @@ export function useTerminalPaneLifecycle({
for (const panePtyBinding of panePtyBindingsRef.current.values()) {
const bindingWithVisibility = panePtyBinding as IDisposable & {
syncRendererOutputVisibility?: () => void
syncProcessTracking?: () => void
}
bindingWithVisibility.syncRendererOutputVisibility?.()
bindingWithVisibility.syncProcessTracking?.()
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- Why: visibility flips must notify existing PTY bindings even though the ref object identity is stable.
}, [isVisible, isVisibleRef, panePtyBindingsRef])