mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
Fix interrupt cleanup for exited agent TUIs
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -122,9 +122,15 @@ vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
...actual,
|
||||
isGeminiTerminalTitle: vi.fn(() => false),
|
||||
isClaudeAgent: vi.fn(() => false),
|
||||
detectAgentStatusFromTitle: vi.fn((title: string) =>
|
||||
/Claude (working|done)/.test(title) ? (/working/.test(title) ? 'working' : 'idle') : null
|
||||
)
|
||||
detectAgentStatusFromTitle: vi.fn((title: string) => {
|
||||
if (/Claude (working|done)/.test(title)) {
|
||||
return /working/.test(title) ? 'working' : 'idle'
|
||||
}
|
||||
if (/Codex( working)?/.test(title)) {
|
||||
return /working/.test(title) ? 'working' : 'idle'
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -572,6 +578,229 @@ describe('connectPanePty', () => {
|
||||
onDataHandler = handler
|
||||
return { dispose: vi.fn() }
|
||||
}) as typeof pane.terminal.onData)
|
||||
const manager = createManager(1)
|
||||
manager.getActivePane.mockReturnValue({ id: 1 })
|
||||
const deps = createDeps({
|
||||
setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => {
|
||||
mockStoreState.runtimePaneTitlesByTabId = {
|
||||
...mockStoreState.runtimePaneTitlesByTabId,
|
||||
[tabId]: {
|
||||
...mockStoreState.runtimePaneTitlesByTabId[tabId],
|
||||
[paneId]: title
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
if (!onDataHandler) {
|
||||
throw new Error('expected onData handler to be registered')
|
||||
}
|
||||
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
|
||||
;(onDataHandler as unknown as (data: string) => void)('\x03')
|
||||
await flushAsyncTicks()
|
||||
vi.advanceTimersByTime(500)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Terminal')
|
||||
expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Terminal')
|
||||
expect(
|
||||
resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'Codex working' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: { 'tab-1': ['tab-pty'] },
|
||||
runtimePaneTitlesByTabId: mockStoreState.runtimePaneTitlesByTabId,
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: true,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
).toBe('done')
|
||||
})
|
||||
|
||||
it('infers exact Ctrl+C terminal input when keydown capture misses the press', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_100)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
state: 'working',
|
||||
prompt: 'stop from real terminal byte',
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 900,
|
||||
agentType: 'codex',
|
||||
paneKey,
|
||||
terminalTitle: 'Codex working',
|
||||
stateHistory: []
|
||||
}
|
||||
const pane = createPane(1)
|
||||
let onDataHandler: ((data: string) => void) | null = null
|
||||
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
|
||||
onDataHandler = handler
|
||||
return { dispose: vi.fn() }
|
||||
}) as typeof pane.terminal.onData)
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
if (!onDataHandler) {
|
||||
throw new Error('expected onData handler to be registered')
|
||||
}
|
||||
;(onDataHandler as unknown as (data: string) => void)('\x03')
|
||||
await flushAsyncTicks()
|
||||
vi.advanceTimersByTime(500)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({
|
||||
paneKey,
|
||||
baselineUpdatedAt: 1_000,
|
||||
baselineStateStartedAt: 900,
|
||||
baselinePrompt: 'stop from real terminal byte',
|
||||
baselineAgentType: 'codex',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
})
|
||||
|
||||
it('infers captured Ctrl+C even when xterm emits an enhanced keyboard sequence', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_100)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
state: 'working',
|
||||
prompt: 'stop enhanced keyboard input',
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 900,
|
||||
agentType: 'codex',
|
||||
paneKey,
|
||||
terminalTitle: 'Codex working',
|
||||
stateHistory: []
|
||||
}
|
||||
const terminalTarget = createKeyboardEventTarget()
|
||||
const pane = createPane(1)
|
||||
;(pane.terminal as { element?: unknown }).element = terminalTarget.target
|
||||
let onDataHandler: ((data: string) => void) | null = null
|
||||
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
|
||||
onDataHandler = handler
|
||||
return { dispose: vi.fn() }
|
||||
}) as typeof pane.terminal.onData)
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
if (!onDataHandler) {
|
||||
throw new Error('expected onData handler to be registered')
|
||||
}
|
||||
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
|
||||
;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u')
|
||||
await flushAsyncTicks()
|
||||
vi.advanceTimersByTime(500)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x1b[99;5u')
|
||||
expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({
|
||||
paneKey,
|
||||
baselineUpdatedAt: 1_000,
|
||||
baselineStateStartedAt: 900,
|
||||
baselinePrompt: 'stop enhanced keyboard input',
|
||||
baselineAgentType: 'codex',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
})
|
||||
|
||||
it('drops stale working status instead of inferring done when the title is already non-agent', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_100)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState.runtimePaneTitlesByTabId = {
|
||||
'tab-1': {
|
||||
1: 'Codex working'
|
||||
}
|
||||
}
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
state: 'working',
|
||||
prompt: 'stop after process exit',
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 900,
|
||||
agentType: 'codex',
|
||||
paneKey,
|
||||
terminalTitle: 'Codex working',
|
||||
stateHistory: []
|
||||
}
|
||||
const terminalTarget = createKeyboardEventTarget()
|
||||
const pane = createPane(1)
|
||||
;(pane.terminal as { element?: unknown }).element = terminalTarget.target
|
||||
let onDataHandler: ((data: string) => void) | null = null
|
||||
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
|
||||
onDataHandler = handler
|
||||
return { dispose: vi.fn() }
|
||||
}) as typeof pane.terminal.onData)
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
if (!onDataHandler) {
|
||||
throw new Error('expected onData handler to be registered')
|
||||
}
|
||||
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
|
||||
;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u')
|
||||
await flushAsyncTicks()
|
||||
mockStoreState.runtimePaneTitlesByTabId = {
|
||||
'tab-1': {
|
||||
1: 'Terminal 1'
|
||||
}
|
||||
}
|
||||
vi.advanceTimersByTime(500)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey)
|
||||
})
|
||||
|
||||
it('drops inferred interrupted status after the pane settles on a non-agent title', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_100)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState.runtimePaneTitlesByTabId = {
|
||||
'tab-1': {
|
||||
1: 'Codex working'
|
||||
}
|
||||
}
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
state: 'working',
|
||||
prompt: 'stop and leave shell',
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 900,
|
||||
agentType: 'codex',
|
||||
paneKey,
|
||||
terminalTitle: 'Codex working',
|
||||
stateHistory: []
|
||||
}
|
||||
vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => {
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
state: 'done',
|
||||
prompt: 'stop and leave shell',
|
||||
interrupted: true,
|
||||
updatedAt: 1_100,
|
||||
stateStartedAt: 1_100,
|
||||
agentType: 'codex',
|
||||
paneKey,
|
||||
terminalTitle: 'Terminal 1'
|
||||
}
|
||||
return true
|
||||
})
|
||||
const terminalTarget = createKeyboardEventTarget()
|
||||
const pane = createPane(1)
|
||||
;(pane.terminal as { element?: unknown }).element = terminalTarget.target
|
||||
let onDataHandler: ((data: string) => void) | null = null
|
||||
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
|
||||
onDataHandler = handler
|
||||
return { dispose: vi.fn() }
|
||||
}) as typeof pane.terminal.onData)
|
||||
const deps = createDeps({
|
||||
setRuntimePaneTitle: vi.fn((tabId: string, paneId: number, title: string) => {
|
||||
mockStoreState.runtimePaneTitlesByTabId = {
|
||||
@@ -589,24 +818,54 @@ describe('connectPanePty', () => {
|
||||
throw new Error('expected onData handler to be registered')
|
||||
}
|
||||
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
|
||||
;(onDataHandler as unknown as (data: string) => void)('\x03')
|
||||
;(onDataHandler as unknown as (data: string) => void)('\x1b[99;5u')
|
||||
await flushAsyncTicks()
|
||||
vi.advanceTimersByTime(500)
|
||||
await flushAsyncTicks()
|
||||
expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalled()
|
||||
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
|
||||
|
||||
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex')
|
||||
expect(
|
||||
resolveWorktreeStatus({
|
||||
tabs: [{ id: 'tab-1', title: 'Codex working' }],
|
||||
browserTabs: [],
|
||||
ptyIdsByTabId: { 'tab-1': ['tab-pty'] },
|
||||
runtimePaneTitlesByTabId: mockStoreState.runtimePaneTitlesByTabId,
|
||||
hasPermission: false,
|
||||
hasLiveWorking: false,
|
||||
hasLiveDone: true,
|
||||
hasRetainedDone: false
|
||||
})
|
||||
).toBe('done')
|
||||
vi.advanceTimersByTime(750)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey)
|
||||
})
|
||||
|
||||
it('does not infer exact interrupt input when the transport cannot acknowledge writes', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
delete transport.sendInputAccepted
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_100)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
state: 'working',
|
||||
prompt: 'ssh style write',
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 900,
|
||||
agentType: 'codex',
|
||||
paneKey,
|
||||
terminalTitle: 'Codex working',
|
||||
stateHistory: []
|
||||
}
|
||||
const pane = createPane(1)
|
||||
let onDataHandler: ((data: string) => void) | null = null
|
||||
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
|
||||
onDataHandler = handler
|
||||
return { dispose: vi.fn() }
|
||||
}) as typeof pane.terminal.onData)
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
if (!onDataHandler) {
|
||||
throw new Error('expected onData handler to be registered')
|
||||
}
|
||||
;(onDataHandler as unknown as (data: string) => void)('\x03')
|
||||
vi.advanceTimersByTime(500)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(transport.sendInput).toHaveBeenCalledWith('\x03')
|
||||
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes agent status and pane title on PTY exit after inferred interrupt', async () => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/* oxlint-disable max-lines */
|
||||
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import { isGeminiTerminalTitle, isClaudeAgent } from '@/lib/agent-status'
|
||||
import {
|
||||
detectAgentStatusFromTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isClaudeAgent
|
||||
} from '@/lib/agent-status'
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { PtyConnectResult } from './pty-transport'
|
||||
@@ -24,7 +28,6 @@ import {
|
||||
waitForTerminalOutputParsed,
|
||||
writeTerminalOutput
|
||||
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
|
||||
import { clearWorkingIndicators } from '../../../../shared/agent-detection'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
@@ -44,6 +47,7 @@ const PTY_CONNECT_DIAG_LIMIT = 200
|
||||
const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250
|
||||
const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1000
|
||||
const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
|
||||
const INFERRED_INTERRUPT_NEUTRAL_TITLE_DROP_MS = 750
|
||||
|
||||
function isAgentTaskCompleteNotificationEnabled(): boolean {
|
||||
const notifications = useAppStore.getState().settings?.notifications
|
||||
@@ -174,6 +178,7 @@ export function connectPanePty(
|
||||
let agentTaskCompleteNotificationGraceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteNotificationMaxTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteStatusUnsubscribe: (() => void) | null = null
|
||||
let inferredInterruptNeutralTitleDropTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let terminalBellNotificationTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingTerminalBellNotification = false
|
||||
// Why: passphrase-gate waits register a teardown here so dispose() can
|
||||
@@ -195,6 +200,43 @@ export function connectPanePty(
|
||||
// Use the stable layout leaf UUID, not the renderer-local numeric pane id.
|
||||
const cacheKey = makePaneKey(deps.tabId, pane.leafId)
|
||||
const pendingSpawnKey = cacheKey
|
||||
const neutralTerminalTitle = (): string => {
|
||||
const state = useAppStore.getState()
|
||||
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === deps.tabId
|
||||
)
|
||||
return tab?.defaultTitle?.trim() || 'Terminal'
|
||||
}
|
||||
const inferredInterruptTargetStillLooksAgent = (): boolean => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
|
||||
const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === deps.tabId
|
||||
)?.title
|
||||
const statusTitle = state.agentStatusByPaneKey[cacheKey]?.terminalTitle
|
||||
const title = currentTitle ?? tabTitle ?? statusTitle
|
||||
return title ? detectAgentStatusFromTitle(title) !== null : true
|
||||
}
|
||||
const clearInferredInterruptNeutralTitleDropTimer = (): void => {
|
||||
if (inferredInterruptNeutralTitleDropTimer !== null) {
|
||||
clearTimeout(inferredInterruptNeutralTitleDropTimer)
|
||||
inferredInterruptNeutralTitleDropTimer = null
|
||||
}
|
||||
}
|
||||
const scheduleInferredInterruptNeutralTitleDrop = (): void => {
|
||||
clearInferredInterruptNeutralTitleDropTimer()
|
||||
inferredInterruptNeutralTitleDropTimer = setTimeout(() => {
|
||||
inferredInterruptNeutralTitleDropTimer = null
|
||||
const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey]
|
||||
if (
|
||||
entry?.state === 'done' &&
|
||||
entry.interrupted === true &&
|
||||
!inferredInterruptTargetStillLooksAgent()
|
||||
) {
|
||||
useAppStore.getState().dropAgentStatus(cacheKey)
|
||||
}
|
||||
}, INFERRED_INTERRUPT_NEUTRAL_TITLE_DROP_MS)
|
||||
}
|
||||
const clearInferredInterruptWorkingTitle = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
|
||||
@@ -203,20 +245,30 @@ export function connectPanePty(
|
||||
if (!title) {
|
||||
return
|
||||
}
|
||||
const neutralTitle = neutralTerminalTitle()
|
||||
// Why: inferred interrupts update the explicit hook row, but many CLIs leave
|
||||
// their OSC title stuck on a working spinner. Clear that pane-local title so
|
||||
// the sidebar/worktree status reflects the inferred done state users see.
|
||||
deps.setRuntimePaneTitle(deps.tabId, pane.id, clearWorkingIndicators(title))
|
||||
// their OSC title stuck on a working spinner. Replace only this fallback
|
||||
// title signal with a neutral terminal label so the existing process tracker
|
||||
// can still decide whether an agent TUI is truly alive.
|
||||
deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle)
|
||||
if (manager.getActivePane()?.id === pane.id) {
|
||||
deps.updateTabTitle(deps.tabId, neutralTitle)
|
||||
}
|
||||
}
|
||||
const interruptInference = createAgentInterruptInference({
|
||||
paneKey: cacheKey,
|
||||
getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey],
|
||||
inferInterrupt: (request) => {
|
||||
if (!inferredInterruptTargetStillLooksAgent()) {
|
||||
useAppStore.getState().dropAgentStatus(cacheKey)
|
||||
return false
|
||||
}
|
||||
return window.api.agentStatus
|
||||
.inferInterrupt(request)
|
||||
.then((applied) => {
|
||||
if (applied) {
|
||||
clearInferredInterruptWorkingTitle()
|
||||
scheduleInferredInterruptNeutralTitleDrop()
|
||||
}
|
||||
return applied
|
||||
})
|
||||
@@ -265,6 +317,15 @@ export function connectPanePty(
|
||||
(intent === 'plain-escape' && data === '\x1b') || (intent === 'ctrl-c' && data === '\x03')
|
||||
)
|
||||
}
|
||||
const inferIntentFromExactTerminalInput = (data: string): AgentInterruptInputIntent | null => {
|
||||
if (data === '\x03') {
|
||||
return 'ctrl-c'
|
||||
}
|
||||
if (data === '\x1b') {
|
||||
return 'plain-escape'
|
||||
}
|
||||
return null
|
||||
}
|
||||
const observeSentTerminalInputIntent = (
|
||||
data: string,
|
||||
intent = pendingTerminalInputIntent
|
||||
@@ -705,13 +766,18 @@ export function connectPanePty(
|
||||
deps.clearTerminalTabUnread(deps.tabId)
|
||||
deps.clearWorktreeUnread(deps.worktreeId)
|
||||
const intent = pendingTerminalInputIntent
|
||||
if (intent && transport.sendInputAccepted) {
|
||||
// Why: real xterm can deliver the terminal byte even when our DOM keydown
|
||||
// listener missed the press. Exact Ctrl+C/Escape bytes are still safe to
|
||||
// infer for local/remote acknowledged writes; SSH fire-and-forget remains
|
||||
// excluded because those transports do not expose sendInputAccepted.
|
||||
const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data)
|
||||
if (acknowledgedIntent && transport.sendInputAccepted) {
|
||||
clearPendingTerminalInputIntent()
|
||||
const writePromise = transport
|
||||
.sendInputAccepted(data)
|
||||
.then((accepted) => {
|
||||
if (accepted) {
|
||||
observeSentTerminalInputIntent(data, intent)
|
||||
interruptInference.observeInputIntent(acknowledgedIntent)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -1584,6 +1650,7 @@ export function connectPanePty(
|
||||
clearPendingTerminalInputIntent()
|
||||
pendingTerminalInputWrite = null
|
||||
interruptInference.dispose()
|
||||
clearInferredInterruptNeutralTitleDropTimer()
|
||||
// Why: actively resolve any in-flight passphrase-gate waits so their
|
||||
// zustand subscribers + async IIFEs don't hang for the rest of the
|
||||
// session when the pane is torn down before SSH state changes.
|
||||
|
||||
Reference in New Issue
Block a user