mirror of
https://github.com/stablyai/orca.git
synced 2026-10-13 16:02:49 +00:00
Fix visible agent status after interrupts
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -62,6 +62,7 @@ type StoreState = {
|
||||
removeDeferredSshSessionId: ReturnType<typeof vi.fn>
|
||||
consumePendingColdRestore: ReturnType<typeof vi.fn>
|
||||
consumePendingSnapshot: ReturnType<typeof vi.fn>
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
|
||||
agentStatusByPaneKey: Record<string, unknown>
|
||||
removeAgentStatus: ReturnType<typeof vi.fn>
|
||||
dropAgentStatus: ReturnType<typeof vi.fn>
|
||||
@@ -360,6 +361,7 @@ describe('connectPanePty', () => {
|
||||
removeDeferredSshSessionId: vi.fn(),
|
||||
consumePendingColdRestore: vi.fn(() => null),
|
||||
consumePendingSnapshot: vi.fn(() => null),
|
||||
runtimePaneTitlesByTabId: {},
|
||||
agentStatusByPaneKey: {},
|
||||
removeAgentStatus: vi.fn(),
|
||||
dropAgentStatus: vi.fn()
|
||||
@@ -538,6 +540,130 @@ describe('connectPanePty', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('clears stale working pane title after inferred interrupt applies', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const { resolveWorktreeStatus } = await import('@/lib/worktree-status')
|
||||
vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true)
|
||||
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 visible spinner',
|
||||
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)
|
||||
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, createManager(1) 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, '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')
|
||||
})
|
||||
|
||||
it('removes agent status and pane title on PTY exit after inferred interrupt', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true)
|
||||
const transport = createMockTransport('tab-pty')
|
||||
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 then 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)
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, createManager(1) 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()
|
||||
const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined
|
||||
if (!onPtyExit) {
|
||||
throw new Error('expected onPtyExit callback to be registered')
|
||||
}
|
||||
|
||||
onPtyExit('tab-pty')
|
||||
|
||||
expect(deps.clearRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1)
|
||||
expect(deps.clearTabPtyId).toHaveBeenCalledWith('tab-1', 'tab-pty')
|
||||
expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(paneKey, null)
|
||||
expect(mockStoreState.removeAgentStatus).toHaveBeenCalledWith(paneKey)
|
||||
})
|
||||
|
||||
it('ignores repeated and modified terminal interrupt keydowns', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
|
||||
@@ -24,6 +24,7 @@ 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'
|
||||
@@ -194,14 +195,35 @@ 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 clearInferredInterruptWorkingTitle = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
|
||||
const statusTitle = state.agentStatusByPaneKey[cacheKey]?.terminalTitle
|
||||
const title = currentTitle ?? statusTitle
|
||||
if (!title) {
|
||||
return
|
||||
}
|
||||
// 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))
|
||||
}
|
||||
const interruptInference = createAgentInterruptInference({
|
||||
paneKey: cacheKey,
|
||||
getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey],
|
||||
inferInterrupt: (request) => {
|
||||
return window.api.agentStatus.inferInterrupt(request).catch((err) => {
|
||||
console.warn('[agent-interrupt] inferInterrupt failed:', err)
|
||||
return false
|
||||
})
|
||||
return window.api.agentStatus
|
||||
.inferInterrupt(request)
|
||||
.then((applied) => {
|
||||
if (applied) {
|
||||
clearInferredInterruptWorkingTitle()
|
||||
}
|
||||
return applied
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[agent-interrupt] inferInterrupt failed:', err)
|
||||
return false
|
||||
})
|
||||
}
|
||||
})
|
||||
const dropCommandFinishedStatusIfUnchanged = (entry: AgentStatusEntry | undefined): void => {
|
||||
|
||||
Reference in New Issue
Block a user