From 9cb42a3658f404f673ba8f4d5ff2a7da30194fa5 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sat, 12 Sep 2026 21:43:21 -0600 Subject: [PATCH 01/20] Fix terminal dimensions error after switching sessions --- .../src/components/panels/TerminalPanel.tsx | 6 +++ tests/terminal-blur-recovery.spec.ts | 54 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/frontend/src/components/panels/TerminalPanel.tsx b/frontend/src/components/panels/TerminalPanel.tsx index 56aa21d78..e29d4a8b1 100644 --- a/frontend/src/components/panels/TerminalPanel.tsx +++ b/frontend/src/components/panels/TerminalPanel.tsx @@ -767,13 +767,17 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv // width — resizing after getState would replay a stale-width snapshot, // and the normal-buffer path has no forced app redraw left to repair it. await resizePtyToFit(); + // Switching sessions can dispose this xterm while a resize reply is pending. + if (xtermRef.current !== terminal) return; const state = await window.electronAPI.invoke('terminal:getState', panel.id); + if (xtermRef.current !== terminal) return; if (state?.isAlternateScreen) { // Renderer refresh alone cannot repair an application frame that was // restored before the visible grid settled. Ask main for a forced resize // (single PTY row nudge) so the foreground app receives a real resize // notification and repaints at the settled grid. await resizePtyToFit(true); + if (xtermRef.current !== terminal) return; if (terminal.rows > 0) { terminal.refresh(0, terminal.rows - 1); } @@ -833,6 +837,8 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv const wasNearBottom = isNearBottomRef.current || distanceFromBottom <= NEAR_BOTTOM_THRESHOLD_ROWS; await resizePtyToFit(); + // A refocus refresh may finish after switching to another session's terminal. + if (xtermRef.current !== terminal) return; if (terminal.rows > 0) terminal.refresh(0, terminal.rows - 1); if (wasNearBottom) { diff --git a/tests/terminal-blur-recovery.spec.ts b/tests/terminal-blur-recovery.spec.ts index 50388313e..9b3d12d6a 100644 --- a/tests/terminal-blur-recovery.spec.ts +++ b/tests/terminal-blur-recovery.spec.ts @@ -94,6 +94,7 @@ interface BootFixtureResult { declare global { interface Window { __maskAppearances?: MaskAppearance[]; + __resumeTerminalRequest?: () => void; } } @@ -343,6 +344,59 @@ test('session remount retains full recovery', async ({ page }) => { expect(await maskAppearances(page, panel)).not.toEqual([]); }); +for (const pendingRequest of ['refocus-resize', 'forced-resize'] as const) { + test(`switching sessions during ${pendingRequest} does not produce a disposed-terminal error`, async ({ page }) => { + const { panel } = await bootFixture(page, 'performance', pendingRequest === 'forced-resize'); + const failures: string[] = []; + page.on('dialog', async (dialog) => { + failures.push(dialog.message()); + await dialog.dismiss(); + }); + page.on('pageerror', (error) => failures.push(error.message)); + page.on('console', (message) => { + if (message.text().includes('Failed to refresh terminal')) failures.push(message.text()); + }); + + // Hold one resize reply until switching sessions has disposed its caller's xterm. + await page.evaluate(({ panelId, pendingRequest }) => { + const invoke = window.electronAPI.invoke; + let held = false; + window.electronAPI.invoke = async (channel, ...args) => { + const matchesRequest = channel === 'terminal:resize' && (pendingRequest !== 'forced-resize' + || (args[3] instanceof Object && 'force' in args[3] && args[3].force === true)); + if (!held && args[0] === panelId && matchesRequest) { + held = true; + await new Promise((resolve) => { window.__resumeTerminalRequest = resolve; }); + } + return invoke(channel, ...args); + }; + }, { panelId: primaryPanel.id, pendingRequest }); + + if (pendingRequest === 'refocus-resize') { + await emitFocus(page, false); + await waitForWindowFocusState(page, false); + await emitFocus(page, true); + } else { + await panel.hover(); + await panel.getByTitle('Refresh terminal').click(); + } + await expect.poll(() => page.evaluate(() => window.__resumeTerminalRequest !== undefined)).toBe(true); + + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await expect(panel).toHaveCount(0); + const other = page.getByRole('tabpanel', { name: otherPanel.title }); + await expect(other.locator('.xterm-screen')).toBeVisible(); + await expect(other.getByTestId('terminal-activation-mask')).toHaveCount(0); + const before = await readSnapshot(other); + + await page.evaluate(() => { window.__resumeTerminalRequest?.(); }); + await advanceActivation(page); + + expect(failures).toEqual([]); + expect(await readSnapshot(other)).toEqual(before); + }); +} + test('WebGL context loss keeps content readable without an activation mask', async ({ page }) => { const { panel, webglLoaded } = await bootFixture(page); test.skip(!webglLoaded, 'WebGL renderer not attached; context-loss path is not reachable in this browser'); From f4bfa6d1f8561c7cf5b25bc864f1e4785db7ec50 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sat, 12 Sep 2026 22:57:36 -0600 Subject: [PATCH 02/20] Fix Claude and Codex live status detection --- .../services/agentStatus/manifests.test.ts | 38 ++++++++++++++ main/src/services/agentStatus/manifests.ts | 50 ++++++++++++------- 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/main/src/services/agentStatus/manifests.test.ts b/main/src/services/agentStatus/manifests.test.ts index d4fd41a73..fbb749648 100644 --- a/main/src/services/agentStatus/manifests.test.ts +++ b/main/src/services/agentStatus/manifests.test.ts @@ -22,6 +22,28 @@ describe('getManifestForAgent', () => { }); describe('CLAUDE_MANIFEST', () => { + it.each(['◐', '◑', '⠙'])('recognizes Claude working title %s', (glyph) => { + expect(detectAgentState(CLAUDE_MANIFEST, screen('', `${glyph} Building`)).state).toBe('working'); + }); + + it.each(['· Flowing…', '✶ Jitterbugging…', '✶ Jitterbugging… (2s · ↓ 10 tokens)'])('recognizes live status %s', (line) => { + expect(detectAgentState(CLAUDE_MANIFEST, screen(`${line}\n────────────\n❯ text\n────────────`)).state).toBe('working'); + }); + + it('does not mistake completed summaries or arbitrary titles for working', () => { + expect(detectAgentState(CLAUDE_MANIFEST, screen('✻ Brewed for 0s · done 10:28 PM', 'Project')).state).toBe('idle'); + }); + + it('prioritizes permission and trust prompts over working titles', () => { + for (const prompt of [ + 'Bash command\nDo you want to proceed?\n❯ 1. Yes\n2. No', + '────────────\nDo you want to proceed?\n❯ 1. Yes\n2. No (esc to cancel)', + 'Quick safety check: Is this a project you created or one you trust?\n❯ No, exit\nYes, I trust this folder\nEnter to confirm · Esc to cancel', + ]) { + expect(detectAgentState(CLAUDE_MANIFEST, screen(prompt, '◐ Building')).state).toBe('blocked'); + } + }); + it('classifies a bash permission prompt as blocked', () => { const s = [ '● I will run a command', @@ -109,6 +131,22 @@ describe('CLAUDE_MANIFEST', () => { }); describe('CODEX_MANIFEST', () => { + it.each([ + 'The script asks [y/n].\n› ', + 'Do you want to continue? Say yes.\n› ', + '› Explain this\nThe documentation says [y/n].\n› next question', + ])('ignores completed prose containing prompt text: %s', (text) => { + expect(detectAgentState(CODEX_MANIFEST, screen(text, 'Codex')).state).toBe('idle'); + }); + + it('recognizes wrapped working chrome', () => { + expect(detectAgentState(CODEX_MANIFEST, screen('• Working (5s • esc to\n interrupt)\n› ')).state).toBe('working'); + }); + + it.each(['q close', 'q to quit'])('holds transcript viewer state with %s', (close) => { + expect(detectAgentState(CODEX_MANIFEST, screen(`↑/↓ to scroll · pgup/pgdn to page · home/end to jump\n${close} · esc to edit prev`)).skipStateUpdate).toBe(true); + }); + it('classifies the Action Required title as blocked', () => { const r = detectAgentState(CODEX_MANIFEST, screen('working on it', 'Action Required · Codex')); expect(r.state).toBe('blocked'); diff --git a/main/src/services/agentStatus/manifests.ts b/main/src/services/agentStatus/manifests.ts index 2123c4684..cf89f759b 100644 --- a/main/src/services/agentStatus/manifests.ts +++ b/main/src/services/agentStatus/manifests.ts @@ -15,7 +15,7 @@ import type { AgentManifest } from './manifestEngine'; /** Braille spinner glyphs Claude/Codex animate in their OSC title / status line. */ -const SPINNER_TITLE = /^[\u{2800}-\u{28FF}] /u; +const SPINNER_TITLE = /^[◐◑\u{2800}-\u{28FF}] /u; const CODEX_SPINNER = /(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)/u; export const CLAUDE_MANIFEST: AgentManifest = { @@ -24,11 +24,28 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'osc_title_working', state: 'working', - priority: 1100, + priority: 990, region: 'osc_title', visibleWorking: true, regex: [SPINNER_TITLE], }, + { + id: 'folder_trust_prompt', + state: 'blocked', + priority: 1100, + region: 'whole_recent', + visibleBlocker: true, + contains: ['quick safety check:', 'yes, i trust this folder', 'enter to confirm', 'esc to cancel'], + lineRegex: [/^\s*❯?\s*No, exit\s*$/i], + }, + { + id: 'spinner_status_line', + state: 'working', + priority: 974, + region: 'bottom_non_empty_lines(8)', + visibleWorking: true, + lineRegex: [/^\s*[·✶✻✽✢✳]\s+[\p{L} -]+…(?:\s+\([^)]*\))?\s*$/u], + }, { id: 'btw_overlay_working', state: 'working', @@ -73,7 +90,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'live_blocked_form', state: 'blocked', - priority: 980, + priority: 1100, region: 'after_last_horizontal_rule', visibleBlocker: true, contains: ['enter to select', 'esc to cancel'], @@ -88,7 +105,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'dynamic_workflow_prompt', state: 'blocked', - priority: 980, + priority: 1100, region: 'whole_recent', visibleBlocker: true, contains: ['run a dynamic workflow?', 'esc to cancel'], @@ -120,7 +137,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'bash_permission_prompt', state: 'blocked', - priority: 850, + priority: 1100, region: 'whole_recent', visibleBlocker: true, contains: ['do you want to proceed?'], @@ -144,7 +161,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'generic_permission_prompt', state: 'blocked', - priority: 840, + priority: 1100, region: 'after_last_horizontal_rule', visibleBlocker: true, contains: ['do you want to proceed?', 'esc to cancel'], @@ -220,8 +237,11 @@ export const CODEX_MANIFEST: AgentManifest = { priority: 1000, region: 'after_last_prompt_marker', skipStateUpdate: true, - contains: ['↑/↓ to scroll', 'pgup/pgdn to', 'home/end to jump', 'q to quit'], - any: [{ contains: ['esc to edit prev'] }, { contains: ['esc/← to edit prev'] }], + contains: ['↑/↓ to scroll', 'pgup/pgdn to', 'home/end to jump'], + all: [ + { any: [{ contains: ['q to quit'] }, { contains: ['q close'] }] }, + { any: [{ contains: ['esc to edit prev'] }, { contains: ['esc/← to edit prev'] }] }, + ], }, { id: 'live_strong_blocker', @@ -240,21 +260,17 @@ export const CODEX_MANIFEST: AgentManifest = { id: 'weak_blocker', state: 'blocked', priority: 600, - region: 'whole_recent', - any: [ - { contains: ['[y/n]'] }, - { contains: ['yes (y)'] }, - { contains: ['do you want to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, - { contains: ['would you like to'], any: [{ contains: ['yes'] }, { contains: ['❯'] }] }, - ], + region: 'after_last_prompt_marker', + visibleBlocker: true, + regex: [/(?:^|\n)[^\n]*\?\s*(?:\[y\/n[^\]]*\]|yes \(y\))\s*:?\s*$/i], }, { id: 'screen_working_fallback', state: 'working', priority: 500, - region: 'bottom_non_empty_lines(3)', + region: 'bottom_non_empty_lines(6)', visibleWorking: true, - lineRegex: [/^[•◦]\s+Working \([^)]*esc to interrupt\)(?: · .*)?$/], + regex: [/^\s*[•◦]\s+Working \([^)]*esc\s+to\s+interrupt\)(?: · [^\n]*)?\s*$/m], not: [{ contains: ['■ Conversation interrupted'] }], }, { From 7950ded612d4b3add5a4a61a116e8e4c5b0667fc Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sat, 12 Sep 2026 23:00:24 -0600 Subject: [PATCH 03/20] Prioritize explicit agent status over redraw activity --- .../agentStatus/agentStatusMonitor.test.ts | 40 +++++- .../agentStatus/agentStatusMonitor.ts | 37 +++--- main/src/services/agentStatus/manifests.ts | 6 +- .../terminalPanelManager.status.test.ts | 125 ++++++++++++++++++ main/src/services/terminalPanelManager.ts | 7 +- 5 files changed, 186 insertions(+), 29 deletions(-) create mode 100644 main/src/services/terminalPanelManager.status.test.ts diff --git a/main/src/services/agentStatus/agentStatusMonitor.test.ts b/main/src/services/agentStatus/agentStatusMonitor.test.ts index 6de93bae4..3a1030fa2 100644 --- a/main/src/services/agentStatus/agentStatusMonitor.test.ts +++ b/main/src/services/agentStatus/agentStatusMonitor.test.ts @@ -21,8 +21,8 @@ describe('AgentStatusMonitor', () => { it('publishes working while PTY bytes are flowing', () => { const m = new AgentStatusMonitor(opts); m.register('p', 0); - m.noteActivity('p', 10); - expect(m.update('p', detection({ state: 'idle' }), 20)).toBe('working'); + m.noteActivity('p', 3010); + expect(m.update('p', detection({ state: 'idle' }), 3020)).toBe('working'); expect(m.getState('p')).toBe('working'); }); @@ -53,12 +53,15 @@ describe('AgentStatusMonitor', () => { expect(m.getState('p')).toBe('working'); }); - it('suppresses premature idle during the startup grace window', () => { + it('does not invent work during startup', () => { const m = new AgentStatusMonitor(opts); m.register('p', 0); - // No activity, idle detection, but still inside 3s grace -> not idle yet. - expect(m.update('p', detection({ state: 'idle' }), 500)).toBe('working'); - expect(m.getState('p')).toBe('working'); + // Empty shells and boot banners do not represent a completed task. + expect(m.update('p', detection({ state: 'idle' }), 500)).toBe('idle'); + m.noteActivity('p', 600); + m.noteActivity('p', 700); + expect(m.update('p', detection({}), 800)).toBeNull(); + expect(m.update('p', detection({}), 3100)).toBeNull(); }); it('emits only on change', () => { @@ -69,6 +72,31 @@ describe('AgentStatusMonitor', () => { expect(m.update('p', detection({ state: 'working' }), 4020)).toBeNull(); }); + it('settles immediately on reliable idle evidence and ignores redraw activity', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + expect(m.update('p', detection({ state: 'working', visibleWorking: true }), 10)).toBe('working'); + m.noteActivity('p', 4000); + const idle = detection({ visibleIdle: true, matchedRuleId: 'osc_title_idle' }); + expect(m.update('p', idle, 4010)).toBe('idle'); + m.noteActivity('p', 4020); + m.noteActivity('p', 4030); + expect(m.update('p', idle, 4040)).toBeNull(); + expect(m.update('p', detection({ state: 'working', visibleWorking: true }), 4050)).toBe('working'); + }); + + it('does not let a persistent prompt box complete work or wake on typing', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + const prompt = detection({ matchedRuleId: 'live_prompt_box' }); + expect(m.update('p', prompt, 4000)).toBe('idle'); + m.noteActivity('p', 4010); + m.noteActivity('p', 4020); + expect(m.update('p', prompt, 4030)).toBeNull(); + expect(m.update('p', detection({ visibleWorking: true }), 4040)).toBe('working'); + expect(m.update('p', prompt, 4050)).toBeNull(); + }); + it('ignores unregistered panels', () => { const m = new AgentStatusMonitor(opts); expect(m.update('ghost', detection({ state: 'working' }), 0)).toBeNull(); diff --git a/main/src/services/agentStatus/agentStatusMonitor.ts b/main/src/services/agentStatus/agentStatusMonitor.ts index 5ed4a0de7..b36e60953 100644 --- a/main/src/services/agentStatus/agentStatusMonitor.ts +++ b/main/src/services/agentStatus/agentStatusMonitor.ts @@ -3,15 +3,10 @@ * * Owns per-panel status trackers and arbitrates a published {@link AgentState} * from three signals: the screen/OSC {@link AgentDetectionResult}, recent PTY - * byte-activity (the "working" authority), and elapsed time. It is deliberately - * timer-free and clock-injectable — the caller re-evaluates on PTY output and on - * a short poll, so debounce/grace windows resolve purely from timestamps, which - * keeps the machine fully unit-testable. - * - * Arbitration precedence: a visible blocker wins immediately; otherwise recent - * activity (or a working detection) means working; otherwise idle. PTY activity - * stays authoritative for a measured settle window, and a single trailing chunk - * cannot wake an already-idle panel unless working chrome is visible. + * byte-activity, and elapsed time. It is timer-free and clock-injectable. + * Explicit blockers and working chrome win first, then reliable idle evidence. + * Activity is a fallback when there is no live chrome, or extends existing work + * behind a weak prompt (Claude keeps its composer visible during a turn). */ import type { AgentDetectionResult, AgentState } from '../../../../shared/types/agentStatus'; @@ -19,7 +14,7 @@ import type { AgentDetectionResult, AgentState } from '../../../../shared/types/ export interface AgentStatusMonitorOptions { /** How long PTY activity keeps a panel working before it may settle idle. */ idleSettleMs?: number; - /** Idle is suppressed for this long after a panel registers. */ + /** Ignore unclassified boot output for this long after registration. */ startupGraceMs?: number; } @@ -73,6 +68,10 @@ export class AgentStatusMonitor { const tracker = this.trackers.get(panelId); if (!tracker) return; + // Boot banners and shell prompt setup are not evidence of a task. Explicit + // agent working chrome still takes effect immediately, including at startup. + if (now - tracker.startedAt < this.options.startupGraceMs) return; + const startsNewBurst = tracker.lastActivityAt === undefined || now - tracker.lastActivityAt >= this.options.idleSettleMs; tracker.activityChunksInBurst = startsNewBurst ? 1 : tracker.activityChunksInBurst + 1; @@ -94,26 +93,28 @@ export class AgentStatusMonitor { // Agent-owned viewer (transcript/model picker): hold the known state. if (detection.skipStateUpdate) return null; - const { idleSettleMs, startupGraceMs } = this.options; + const { idleSettleMs } = this.options; const recentlyActive = tracker.lastActivityAt !== undefined && now - tracker.lastActivityAt < idleSettleMs; const activityCanPublishWorking = - tracker.published !== 'idle' || tracker.activityChunksInBurst >= 2; + (tracker.published === 'working' || detection.matchedRuleId === null) && + (tracker.published !== 'idle' || tracker.activityChunksInBurst >= 2); let candidate: AgentState; if (detection.state === 'blocked') { candidate = 'blocked'; - } else if (detection.visibleWorking || (recentlyActive && activityCanPublishWorking)) { + } else if (detection.visibleWorking) { + candidate = 'working'; + } else if (detection.visibleIdle) { + candidate = 'idle'; + tracker.lastActivityAt = undefined; + tracker.activityChunksInBurst = 0; + } else if (recentlyActive && activityCanPublishWorking) { candidate = 'working'; } else { candidate = 'idle'; } - // Startup grace: a freshly launched agent shouldn't flash idle before it boots. - if (candidate === 'idle' && now - tracker.startedAt < startupGraceMs) { - candidate = tracker.published ?? 'working'; - } - if (tracker.published === candidate) return null; tracker.published = candidate; return candidate; diff --git a/main/src/services/agentStatus/manifests.ts b/main/src/services/agentStatus/manifests.ts index cf89f759b..d38409162 100644 --- a/main/src/services/agentStatus/manifests.ts +++ b/main/src/services/agentStatus/manifests.ts @@ -14,7 +14,7 @@ import type { AgentManifest } from './manifestEngine'; -/** Braille spinner glyphs Claude/Codex animate in their OSC title / status line. */ +/** Observed working-title glyphs, including older braille animation. */ const SPINNER_TITLE = /^[◐◑\u{2800}-\u{28FF}] /u; const CODEX_SPINNER = /(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)/u; @@ -115,7 +115,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { state: 'idle', priority: 950, region: 'prompt_box_body', - visibleIdle: true, + // The composer remains visible mid-turn; it is not completion evidence. lineRegex: [/^\s*❯/], not: [ { contains: ['enter to select'] }, @@ -197,7 +197,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { { id: 'osc_title_idle', state: 'idle', - priority: 250, + priority: 960, region: 'osc_title', visibleIdle: true, regex: [/^\u{2733} /u], diff --git a/main/src/services/terminalPanelManager.status.test.ts b/main/src/services/terminalPanelManager.status.test.ts new file mode 100644 index 000000000..829c13065 --- /dev/null +++ b/main/src/services/terminalPanelManager.status.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { TerminalPanelManager } from './terminalPanelManager'; +import { TerminalStateEmulator } from './terminalStateEmulator'; +import { AgentStatusMonitor } from './agentStatus/agentStatusMonitor'; +import { WorkspaceJournal } from './workspaceJournal'; +import { resetPaneRuntimeForTests, setPaneRuntime } from '../core/runtime'; +import type { PaneEventArgument } from '../core/eventSink'; +import { createFlowControlRecord, disposeFlowControlRecord } from '../ptyHost/flowControl'; +import { panelManager } from '../test/setup'; + +// These tests exercise PTY/status lifetimes, not the SQLite persistence boundary. +vi.mock('./database', () => ({ databaseService: {} })); + +function createTerminal(agentType: 'claude' | 'codex' | undefined = 'codex') { + let onData: (data: string) => void = () => undefined; + let onExit: (exit: { exitCode: number; signal?: number }) => void = () => undefined; + const terminal = { + panelId: 'p', sessionId: 's', agentType, + pty: { + onData: (listener: typeof onData) => { onData = listener; }, + onExit: (listener: typeof onExit) => { onExit = listener; }, + write: vi.fn(), kill: vi.fn(), cols: 80, rows: 24, + }, + screenEmulator: new TerminalStateEmulator(80, 24), + scrollbackBuffer: '', alternateScreenBuffer: '', commandHistory: [], + currentCommand: '', lastActivity: new Date(), outputGeneration: 0, + flowControl: createFlowControlRecord(), outputBuffer: '', + outputFlushTimer: null as ReturnType | null, + isVisible: true, isAlternateScreen: false, inSyncBlock: false, + filterInAltScreen: false, agentSessionScrapeBuffer: '', + }; + return { terminal, data: (data: string) => onData(data), exit: () => onExit({ exitCode: 0 }) }; +} + +type TerminalFixture = ReturnType; +interface StatusAccess { + terminals: Map; + agentStatusMonitor: AgentStatusMonitor; + setupTerminalHandlers(terminal: TerminalFixture['terminal']): void; + pollAgentStatus(): Promise; +} + +let manager: TerminalPanelManager; +let access: StatusAccess; +let journal: WorkspaceJournal; +let fixtures: TerminalFixture[]; +let events: Array<{ channel: string; payload: PaneEventArgument }>; + +function attach(agentType?: 'claude' | 'codex', startedAt = 0) { + const fixture = createTerminal(agentType); + fixtures.push(fixture); + access.terminals.set('p', fixture.terminal); + access.agentStatusMonitor.register('p', startedAt); + access.setupTerminalHandlers(fixture.terminal); + return fixture; +} + +beforeEach(() => { + // xterm writes need real timers; only the status clock is controlled. + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(20_000); + fixtures = []; + events = []; + manager = new TerminalPanelManager(); + access = manager as unknown as StatusAccess; + journal = new WorkspaceJournal({ + resolvePane: paneId => ({ paneId, paneName: 'Pane' }), + resolvePanel: panelId => ({ panelId, paneId: 's', isCliPanel: true }), + }); + setPaneRuntime({ eventSink: { send(channel, payload) { + events.push({ channel, payload }); + journal.send(channel, payload); + } } }); + panelManager.emitPanelEvent.mockImplementation((panelId, type, data) => { + journal.send('panel:event', { type, source: { panelId, sessionId: 's' }, data }); + }); +}); + +afterEach(() => { + for (const { terminal } of fixtures) { + if (terminal.outputFlushTimer) clearTimeout(terminal.outputFlushTimer); + terminal.screenEmulator.dispose(); + disposeFlowControlRecord(terminal.flowControl); + } + journal.dispose(); + resetPaneRuntimeForTests(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('terminal status events', () => { + it.each(['claude', 'codex'] as const)('does not publish work or completion from %s typing and cursor redraws', async agent => { + const fixture = attach(agent); + const title = agent === 'claude' ? '✳ Project' : 'Project'; + fixture.data(`\x1b]2;${title}\x07Finished.\r\n› `); + await access.pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBe('idle'); + fixture.data('\x1b[?25l'); + fixture.data('\x1b[?25h'); + fixture.data('draft input'); + await access.pollAgentStatus(); + vi.setSystemTime(40_000); + await access.pollAgentStatus(); + expect(events.filter(event => event.channel === 'panel:agentStatus')).toEqual([ + { channel: 'panel:agentStatus', payload: { panelId: 'p', sessionId: 's', state: 'idle', reason: 'osc_title_idle' } }, + ]); + expect(journal.readAfter(0).entries).toEqual([]); + }); + + it('reports immediate real work and completion with coherent reasons', async () => { + const fixture = attach('claude', 20_000); + fixture.data('\x1b]2;✳ Project\x07'); + await access.pollAgentStatus(); + manager.writeToTerminal('p', 'go\r'); + fixture.data('\x1b]2;◐ Building\x07'); + await access.pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBe('working'); + fixture.data('\x1b]2;✳ Project\x07Done.'); + await access.pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBe('idle'); + expect(journal.readAfter(0).entries.map(entry => [entry.kind, entry.reason])).toEqual([ + ['agent.busy', 'osc_title_working'], ['agent.ready', 'osc_title_idle'], + ]); + }); +}); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index 195730891..fcaea78e6 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -1167,7 +1167,7 @@ export class TerminalPanelManager { terminal.lastOutputAt = outputAt; terminal.outputGeneration += 1; - // Feed PTY activity to the agent-status monitor (the "working" authority). + // Feed byte activity as a fallback behind explicit screen/title evidence. this.agentStatusMonitor.noteActivity(terminal.panelId, outputAt.getTime()); // Detect alternate screen buffer enter/exit for universal TUI detection @@ -1700,7 +1700,10 @@ export class TerminalPanelManager { oscProgress: emulator.getOscProgress(), }); const next = this.agentStatusMonitor.update(terminal.panelId, detection, Date.now()); - if (next) this.emitAgentStatus(terminal, next, detection.matchedRuleId); + if (next) { + const reason = next === detection.state ? detection.matchedRuleId : 'pty_activity'; + this.emitAgentStatus(terminal, next, reason ?? 'idle_settle'); + } } } catch (error) { console.error('[TerminalPanelManager] agent status poll failed:', error); From 218adf09b5186a1e1f40e81296dae71041e8910e Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sat, 12 Sep 2026 23:05:27 -0600 Subject: [PATCH 04/20] Guard terminal lifetimes and publish consistent teardown status --- .../terminalPanelManager.status.test.ts | 63 +++++++++ main/src/services/terminalPanelManager.ts | 129 +++++++++++------- main/src/services/workspaceJournal.test.ts | 10 ++ main/src/services/workspaceJournal.ts | 7 + 4 files changed, 158 insertions(+), 51 deletions(-) diff --git a/main/src/services/terminalPanelManager.status.test.ts b/main/src/services/terminalPanelManager.status.test.ts index 829c13065..a48d9016e 100644 --- a/main/src/services/terminalPanelManager.status.test.ts +++ b/main/src/services/terminalPanelManager.status.test.ts @@ -89,6 +89,69 @@ afterEach(() => { }); describe('terminal status events', () => { + it('publishes consistent idle status on exit and deduplicates repeated callbacks', async () => { + const fixture = attach('codex'); + fixture.data('\x1b]2;⠙ Codex\x07'); + await access.pollAgentStatus(); + fixture.exit(); + fixture.exit(); + expect(events.filter(event => event.channel === 'panel:activityStatus').at(-1)?.payload).toMatchObject({ status: 'idle' }); + expect(events.filter(event => event.channel === 'terminal:exited')).toHaveLength(1); + expect(manager.getAgentStatus('p')).toBeUndefined(); + expect(journal.readAfter(0).entries.filter(entry => entry.kind === 'panel.exited')).toHaveLength(1); + }); + + it('retires destroyed terminals before old exit and data callbacks can affect a replacement', async () => { + vi.spyOn(manager, 'saveTerminalState').mockResolvedValue(); + const old = attach('codex'); + old.data('\x1b]2;⠙ Codex\x07'); + await access.pollAgentStatus(); + manager.destroyTerminal('p'); + expect(events.filter(event => event.channel === 'panel:agentStatus').at(-1)?.payload).toMatchObject({ state: 'idle' }); + expect(journal.readAfter(0).entries.filter(entry => entry.kind === 'panel.exited')).toHaveLength(1); + const replacement = attach('codex'); + replacement.data('\x1b]2;⠙ Codex\x07'); + await access.pollAgentStatus(); + const count = events.length; + old.exit(); + old.data('old output'); + expect(events).toHaveLength(count); + expect(manager.isTerminalInitialized('p')).toBe(true); + expect(manager.getAgentStatus('p')).toBe('working'); + replacement.exit(); + expect(journal.readAfter(0).entries.filter(entry => entry.kind === 'panel.exited')).toHaveLength(2); + }); + + it('discards a poll resumed after the terminal was replaced', async () => { + const old = attach('codex'); + let release = () => undefined; + const pending = new Promise(resolve => { release = resolve; }); + vi.spyOn(old.terminal.screenEmulator, 'waitForIdle').mockReturnValue(pending); + const polling = access.pollAgentStatus(); + const replacement = attach('codex'); + replacement.data('\x1b]2;⠙ Codex\x07'); + release(); + await polling; + await access.pollAgentStatus(); + expect(events.filter(event => event.channel === 'panel:agentStatus').map(event => event.payload)).toEqual([ + { panelId: 'p', sessionId: 's', state: 'working', reason: 'osc_title_working' }, + ]); + }); + + it('retains tracking after a write error until actual exit evidence arrives', async () => { + const fixture = attach('codex'); + fixture.data('\x1b]2;⠙ Codex\x07'); + await access.pollAgentStatus(); + fixture.terminal.pty.write.mockImplementation(() => { throw new Error('write failed'); }); + manager.writeToTerminal('p', 'hello'); + expect(manager.isTerminalInitialized('p')).toBe(true); + fixture.data('\x1b]2;Codex\x07'); + await access.pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBe('idle'); + fixture.exit(); + expect(manager.getAgentStatus('p')).toBeUndefined(); + }); + it.each(['claude', 'codex'] as const)('does not publish work or completion from %s typing and cursor redraws', async agent => { const fixture = attach(agent); const title = agent === 'claude' ? '✳ Project' : 'Project'; diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index fcaea78e6..3efbcabc1 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -1003,6 +1003,12 @@ export class TerminalPanelManager { }); } + // Another initialization can finish while the ptyHost spawn is awaited. + if (this.terminals.has(panel.id)) { + ptyProcess.kill(); + return; + } + // Create terminal process object const terminalProcess: TerminalProcess = { pty: ptyProcess, @@ -1036,7 +1042,10 @@ export class TerminalPanelManager { // Store in map (ptyHost path: pid is already populated on the shim). this.terminals.set(panel.id, terminalProcess); - // Begin at-a-glance status detection for AI/CLI agent panels. + // Install lifetime guards before any launch-state persistence can yield. + this.setupTerminalHandlers(terminalProcess); + + // Begin at-a-glance status detection for every terminal panel. this.registerAgentStatusPanel(terminalProcess); // Tell the renderer which `ptyId` to subscribe to for this panel so @@ -1056,8 +1065,7 @@ export class TerminalPanelManager { const initialCommand = existingState?.initialCommand; const initialInput = existingState?.initialInput; - // If we have an initial command, set up the prompt detection listener BEFORE - // setupTerminalHandlers so we don't miss early shell output. + // Wait for the shell prompt before sending an initial command. let commandToRun: string | undefined; if (initialCommand) { const launchResolution = this.resolveCliLaunchCommand(panel.id, initialCommand, existingState || {}, shellType); @@ -1071,6 +1079,8 @@ export class TerminalPanelManager { }); } + if (this.terminals.get(panel.id) !== terminalProcess) return; + // Detect the interactive prompt before injecting the command. // Previous approaches (fixed 500ms delay, then fire-on-any-data + 300ms) failed // because shell init output (MINGW banner, .bashrc) fires before the prompt is ready. @@ -1078,6 +1088,7 @@ export class TerminalPanelManager { // so banner lines ending with % or > don't trigger a false positive. const panelId = panel.id; const injectCommand = () => { + if (this.terminals.get(panelId) !== terminalProcess) return; this.writeToTerminal(panelId, commandToRun! + '\r'); // For CLI tool terminals, signal the frontend when the CLI responds @@ -1087,7 +1098,7 @@ export class TerminalPanelManager { let onCliOutput: ReturnType | null = null; const signalCliReady = () => { - if (cliReadySignaled) return; + if (cliReadySignaled || this.terminals.get(panelId) !== terminalProcess) return; cliReadySignaled = true; if (onCliOutput) onCliOutput.dispose(); @@ -1124,18 +1135,19 @@ export class TerminalPanelManager { // Safety timeout: dismiss after 10s regardless setTimeout(signalCliReady, 10000); } else if (initialInput) { - setTimeout(() => this.sendInitialInputOnce(panelId), 1000); + setTimeout(() => { + if (this.terminals.get(panelId) === terminalProcess) this.sendInitialInputOnce(panelId); + }, 1000); } }; this.scheduleAfterShellPrompt(ptyProcess, injectCommand); } else if (initialInput) { - setTimeout(() => this.sendInitialInputOnce(panel.id), 1000); + setTimeout(() => { + if (this.terminals.get(panel.id) === terminalProcess) this.sendInitialInputOnce(panel.id); + }, 1000); } - // Set up event handlers - this.setupTerminalHandlers(terminalProcess); - // Update panel state const state = panel.state; state.customState = { @@ -1161,6 +1173,7 @@ export class TerminalPanelManager { private setupTerminalHandlers(terminal: TerminalProcess): void { // Handle terminal output terminal.pty.onData((data: string) => { + if (this.terminals.get(terminal.panelId) !== terminal) return; // Update last activity const outputAt = new Date(); terminal.lastActivity = outputAt; @@ -1263,28 +1276,9 @@ export class TerminalPanelManager { // Handle terminal exit terminal.pty.onExit((exitCode: { exitCode: number; signal?: number }) => { - // A finished agent is "done": settle its status to idle and stop tracking. - if (this.agentStatusMonitor.isTracked(terminal.panelId)) { - this.emitAgentStatus(terminal, 'idle', 'exit'); - this.agentStatusMonitor.unregister(terminal.panelId); - this.maybeStopAgentStatusPoll(); - } - - // Emit exit event - panelManager.emitPanelEvent( - terminal.panelId, - 'terminal:exit', - { - exitCode: exitCode.exitCode, - signal: exitCode.signal, - timestamp: new Date().toISOString() - } - ); - - // Clean up + if (this.terminals.get(terminal.panelId) !== terminal) return; + this.retireTerminal(terminal, exitCode); terminal.screenEmulator?.dispose(); - this.terminals.delete(terminal.panelId); - this.visibleViewersByPanel.delete(terminal.panelId); // Notify frontend (include signal for crash detection) this.sendRendererEvent('terminal:exited', { @@ -1344,10 +1338,8 @@ export class TerminalPanelManager { try { terminal.pty.write(data); } catch (err) { - // PTY may have exited between the map lookup and the write call + // A write failure alone does not prove process death; onExit owns cleanup. console.warn(`[TerminalPanelManager] Failed to write to terminal ${panelId}:`, err); - this.terminals.delete(panelId); - this.visibleViewersByPanel.delete(panelId); return; } terminal.lastActivity = new Date(); @@ -1396,12 +1388,14 @@ export class TerminalPanelManager { // Back-to-back TIOCSWINSZ calls can collapse into a single pending signal. await new Promise(resolve => setTimeout(resolve, FORCED_REDRAW_TRANSITION_MS)); } + if (this.terminals.get(panelId) !== terminal) return; terminal.pty.resize(cols, rows); terminal.screenEmulator?.resize(cols, rows); if (options.force) { // Let the final application redraw reach our output batch before the // renderer removes its activation mask. await new Promise(resolve => setTimeout(resolve, FORCED_REDRAW_SETTLE_MS)); + if (this.terminals.get(panelId) !== terminal) return; this.flushOutputBuffer(terminal); } } catch (err) { @@ -1433,6 +1427,7 @@ export class TerminalPanelManager { if (!panel) return; await terminal.screenEmulator?.waitForIdle(); + if (this.terminals.get(panelId) !== terminal) return; // Get current working directory (if possible) let cwd = (panel.state.customState && 'cwd' in panel.state.customState) ? panel.state.customState.cwd : undefined; @@ -1450,6 +1445,12 @@ export class TerminalPanelManager { console.warn(`[TerminalPanelManager] Could not get CWD for terminal ${panelId}:`, error); } + if (this.terminals.get(panelId) !== terminal || panelManager.getPanel(panelId) !== panel) return; + await this.persistTerminalState(terminal, panel, cwd); + } + + private async persistTerminalState(terminal: TerminalProcess, panel: ToolPanel, cwd: string): Promise { + const panelId = terminal.panelId; // Save state to panel const state = panel.state; const savedIsAlternateScreen = @@ -1538,6 +1539,7 @@ export class TerminalPanelManager { if (!terminal) return null; await terminal.screenEmulator?.waitForIdle(); + if (this.terminals.get(panelId) !== terminal) return null; const isAlternateScreen = terminal.screenEmulator?.isAlternateScreen ?? terminal.isAlternateScreen; // Normal-buffer restore content comes from the rendered emulator model, not @@ -1625,11 +1627,11 @@ export class TerminalPanelManager { return this.agentStatusMonitor.getState(panelId); } - private emitActivityStatus(terminal: TerminalProcess): void { + private emitActivityStatus(terminal: TerminalProcess, state: AgentState): void { this.sendRendererEvent('panel:activityStatus', { panelId: terminal.panelId, sessionId: terminal.sessionId, - status: this.deriveActivityStatus(terminal.panelId), + status: state === 'working' || state === 'blocked' ? 'active' : 'idle', lastActivityAt: terminal.lastActivity.toISOString() }); } @@ -1650,6 +1652,7 @@ export class TerminalPanelManager { */ private registerAgentStatusPanel(terminal: TerminalProcess): void { this.agentStatusMonitor.register(terminal.panelId, Date.now()); + this.emitAgentStatus(terminal, 'unknown', 'terminal_start'); this.ensureAgentStatusPoll(); } @@ -1661,7 +1664,7 @@ export class TerminalPanelManager { reason, }; this.sendRendererEvent('panel:agentStatus', payload); - this.emitActivityStatus(terminal); + this.emitActivityStatus(terminal, state); } private ensureAgentStatusPoll(): void { @@ -1694,6 +1697,7 @@ export class TerminalPanelManager { if (!emulator) continue; await emulator.waitForIdle(); + if (this.terminals.get(terminal.panelId) !== terminal) continue; const detection = detectAgentState(manifest, { screen: emulator.getScreenText(), oscTitle: emulator.getOscTitle(), @@ -1718,20 +1722,17 @@ export class TerminalPanelManager { return; } - // Save state before destroying. `saveTerminalState` is async, so a - // surrounding synchronous `try` could never observe its rejection — and - // `panelManager.updatePanel` writes to SQLite, which can reject. - this.saveTerminalState(panelId).catch((error) => { - console.error(`[TerminalPanelManager] Failed to save state for ${panelId}:`, error); - }); - - // Clear timers - if (terminal.outputFlushTimer) { - clearTimeout(terminal.outputFlushTimer); - terminal.outputFlushTimer = null; + // Capture the current model before disposal. An asynchronous state read here + // could otherwise persist a disposed terminal over a replacement's state. + const panel = panelManager.getPanel(panelId); + if (panel) { + const cwd = terminalCustomState(panel.state).cwd || process.cwd(); + this.persistTerminalState(terminal, panel, cwd).catch((error) => { + console.error(`[TerminalPanelManager] Failed to save state for ${panelId}:`, error); + }); } - disposeFlowControlRecord(terminal.flowControl); - this.flushOutputBuffer(terminal); + + this.retireTerminal(terminal); terminal.screenEmulator?.dispose(); // Kill the PTY process @@ -1749,12 +1750,38 @@ export class TerminalPanelManager { console.error(`[TerminalPanelManager] Error killing terminal ${panelId}:`, error); } - // Remove from maps + } + + /** Retire exactly one terminal lifetime, before kill can call back synchronously. */ + private retireTerminal(terminal: TerminalProcess, exit?: { exitCode: number; signal?: number }): void { + const panelId = terminal.panelId; + if (this.terminals.get(panelId) !== terminal) return; + if (terminal.outputFlushTimer) { + clearTimeout(terminal.outputFlushTimer); + terminal.outputFlushTimer = null; + } + disposeFlowControlRecord(terminal.flowControl); + this.flushOutputBuffer(terminal); + this.agentStatusMonitor.unregister(panelId); this.terminals.delete(panelId); this.visibleViewersByPanel.delete(panelId); this.serializedBuffers.delete(panelId); - this.agentStatusMonitor.unregister(panelId); this.maybeStopAgentStatusPoll(); + this.emitAgentStatus(terminal, 'idle', 'exit'); + + const data = { ...exit, timestamp: new Date().toISOString() }; + if (panelManager.getPanel(panelId)) { + void panelManager.emitPanelEvent(panelId, 'terminal:exit', data); + } else { + // The panel may already be deleted. Its terminal still owns enough + // identity to notify the journal and transport consumers of the exit. + this.sendRendererEvent('panel:event', { + type: 'terminal:exit', + source: { panelId, sessionId: terminal.sessionId, panelType: 'terminal' }, + data, + timestamp: data.timestamp, + }); + } } /** diff --git a/main/src/services/workspaceJournal.test.ts b/main/src/services/workspaceJournal.test.ts index 2dd3cffed..336c5d8d0 100644 --- a/main/src/services/workspaceJournal.test.ts +++ b/main/src/services/workspaceJournal.test.ts @@ -135,6 +135,16 @@ describe('WorkspaceJournal', () => { ]); }); + it('records exits for successive lifetimes even when the new terminal exits before its first poll', () => { + const journal = new WorkspaceJournal({ resolvePane: paneId => ({ paneId, paneName: 'Pane' }) }); + const exit = { type: 'terminal:exit', source: { panelId: 'p', sessionId: 's' }, data: { exitCode: 0 } }; + journal.send('panel:event', exit); + journal.send('panel:event', exit); + journal.send('panel:agentStatus', { panelId: 'p', sessionId: 's', state: 'unknown', reason: 'terminal_start' }); + journal.send('panel:event', exit); + expect(journal.readAfter(0).entries.map(entry => entry.kind)).toEqual(['panel.exited', 'panel.exited']); + }); + it('ignores agent-status events from ordinary shell panels', () => { let isCliPanel = false; const journal = new WorkspaceJournal({ diff --git a/main/src/services/workspaceJournal.ts b/main/src/services/workspaceJournal.ts index d7675e743..f5e0749fa 100644 --- a/main/src/services/workspaceJournal.ts +++ b/main/src/services/workspaceJournal.ts @@ -256,6 +256,13 @@ export class WorkspaceJournal implements PaneEventSink { reason?: string | null; }): void { const { panelId, sessionId: paneId, state } = payload; + if (payload.reason === 'terminal_start') { + this.exitedPanels.delete(panelId); + this.stateByPanel.delete(panelId); + this.readySinceByPanel.delete(panelId); + return; + } + if (payload.reason !== 'exit') this.exitedPanels.delete(panelId); const panel = this.resolvePanel?.(panelId); if (!panel?.isCliPanel) return; From 0aa0c0247effac26532195ad1f669c8bb82b09a1 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sat, 12 Sep 2026 23:11:42 -0600 Subject: [PATCH 05/20] Scope Claude blockers to live prompt regions --- main/src/services/agentStatus/manifests.test.ts | 6 ++++++ main/src/services/agentStatus/manifests.ts | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/main/src/services/agentStatus/manifests.test.ts b/main/src/services/agentStatus/manifests.test.ts index fbb749648..5a0658f59 100644 --- a/main/src/services/agentStatus/manifests.test.ts +++ b/main/src/services/agentStatus/manifests.test.ts @@ -44,6 +44,12 @@ describe('CLAUDE_MANIFEST', () => { } }); + it('ignores an answered permission above the current composer', () => { + const text = 'Bash command\nDo you want to proceed?\n❯ 1. Yes\n2. No\nDone.\n────────────\n❯ next task\n────────────'; + expect(detectAgentState(CLAUDE_MANIFEST, screen(text, '✳ Project')).state).toBe('idle'); + expect(detectAgentState(CLAUDE_MANIFEST, screen(text, '◐ Building')).state).toBe('working'); + }); + it('classifies a bash permission prompt as blocked', () => { const s = [ '● I will run a command', diff --git a/main/src/services/agentStatus/manifests.ts b/main/src/services/agentStatus/manifests.ts index d38409162..990fbb007 100644 --- a/main/src/services/agentStatus/manifests.ts +++ b/main/src/services/agentStatus/manifests.ts @@ -33,7 +33,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { id: 'folder_trust_prompt', state: 'blocked', priority: 1100, - region: 'whole_recent', + region: 'after_last_horizontal_rule', visibleBlocker: true, contains: ['quick safety check:', 'yes, i trust this folder', 'enter to confirm', 'esc to cancel'], lineRegex: [/^\s*❯?\s*No, exit\s*$/i], @@ -106,7 +106,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { id: 'dynamic_workflow_prompt', state: 'blocked', priority: 1100, - region: 'whole_recent', + region: 'after_last_horizontal_rule', visibleBlocker: true, contains: ['run a dynamic workflow?', 'esc to cancel'], }, @@ -138,7 +138,7 @@ export const CLAUDE_MANIFEST: AgentManifest = { id: 'bash_permission_prompt', state: 'blocked', priority: 1100, - region: 'whole_recent', + region: 'after_last_horizontal_rule', visibleBlocker: true, contains: ['do you want to proceed?'], any: [ From 1308cb3dd95609ccd4f219d524fa0cc82d3c4421 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sat, 12 Sep 2026 23:19:36 -0600 Subject: [PATCH 06/20] Discard initial input callbacks from replaced terminals --- .../terminalPanelManager.status.test.ts | 37 +++++++++++++++++-- main/src/services/terminalPanelManager.ts | 8 +++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/main/src/services/terminalPanelManager.status.test.ts b/main/src/services/terminalPanelManager.status.test.ts index a48d9016e..34152f890 100644 --- a/main/src/services/terminalPanelManager.status.test.ts +++ b/main/src/services/terminalPanelManager.status.test.ts @@ -8,9 +8,6 @@ import type { PaneEventArgument } from '../core/eventSink'; import { createFlowControlRecord, disposeFlowControlRecord } from '../ptyHost/flowControl'; import { panelManager } from '../test/setup'; -// These tests exercise PTY/status lifetimes, not the SQLite persistence boundary. -vi.mock('./database', () => ({ databaseService: {} })); - function createTerminal(agentType: 'claude' | 'codex' | undefined = 'codex') { let onData: (data: string) => void = () => undefined; let onExit: (exit: { exitCode: number; signal?: number }) => void = () => undefined; @@ -25,6 +22,7 @@ function createTerminal(agentType: 'claude' | 'codex' | undefined = 'codex') { scrollbackBuffer: '', alternateScreenBuffer: '', commandHistory: [], currentCommand: '', lastActivity: new Date(), outputGeneration: 0, flowControl: createFlowControlRecord(), outputBuffer: '', + // SAFETY: PTY output handlers assign timeout handles; fixtures start without one. outputFlushTimer: null as ReturnType | null, isVisible: true, isAlternateScreen: false, inSyncBlock: false, filterInAltScreen: false, agentSessionScrapeBuffer: '', @@ -38,6 +36,7 @@ interface StatusAccess { agentStatusMonitor: AgentStatusMonitor; setupTerminalHandlers(terminal: TerminalFixture['terminal']): void; pollAgentStatus(): Promise; + sendInitialInputOnce(panelId: string): void; } let manager: TerminalPanelManager; @@ -62,7 +61,8 @@ beforeEach(() => { fixtures = []; events = []; manager = new TerminalPanelManager(); - access = manager as unknown as StatusAccess; + // SAFETY: This seam mirrors the private members used by these PTY fixtures. + access = manager as StatusAccess; journal = new WorkspaceJournal({ resolvePane: paneId => ({ paneId, paneName: 'Pane' }), resolvePanel: panelId => ({ panelId, paneId: 's', isCliPanel: true }), @@ -86,6 +86,8 @@ afterEach(() => { resetPaneRuntimeForTests(); vi.useRealTimers(); vi.restoreAllMocks(); + panelManager.getPanel.mockReset(); + panelManager.updatePanel.mockReset(); }); describe('terminal status events', () => { @@ -138,6 +140,33 @@ describe('terminal status events', () => { ]); }); + it.each(['persistence', 'submit delay'])('does not send an old initial prompt after replacement during %s', async phase => { + vi.useRealTimers(); + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + vi.setSystemTime(20_000); + const old = attach('codex'); + let release: () => void = () => undefined; + const persisted = new Promise(resolve => { release = resolve; }); + panelManager.getPanel.mockReturnValue({ + id: 'p', sessionId: 's', type: 'terminal', title: 'Codex', + state: { isActive: true, customState: { initialInput: 'old task', initialInputSubmitStrategy: 'codex-ctrl-enter' } }, + metadata: { createdAt: '', lastActiveAt: '', position: 0 }, + }); + panelManager.updatePanel.mockReturnValue(persisted); + access.sendInitialInputOnce('p'); + if (phase === 'submit delay') { + release(); + await vi.advanceTimersByTimeAsync(0); + expect(old.terminal.pty.write).toHaveBeenCalledWith('old task'); + } + const replacement = attach('codex'); + release(); + await vi.advanceTimersByTimeAsync(500); + expect(replacement.terminal.pty.write).not.toHaveBeenCalled(); + panelManager.getPanel.mockReset(); + panelManager.updatePanel.mockReset(); + }); + it('retains tracking after a write error until actual exit evidence arrives', async () => { const fixture = attach('codex'); fixture.data('\x1b]2;⠙ Codex\x07'); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index 3efbcabc1..e54da1371 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -450,13 +450,16 @@ export class TerminalPanelManager { } private sendInitialInputOnce(panelId: string): void { + const terminal = this.terminals.get(panelId); + if (!terminal) return; this.markInitialInputSent(panelId).then((delivery) => { - if (!delivery) { + if (!delivery || this.terminals.get(panelId) !== terminal) { return; } this.writeInitialInput(panelId, delivery.input, delivery.submitStrategy); }).catch((error) => { + if (this.terminals.get(panelId) !== terminal) return; console.warn(`[TerminalPanelManager] Failed to send initial input for panel ${panelId}:`, error); this.markInitialInputError(panelId, error instanceof Error ? error.message : String(error)).catch(() => {}); }); @@ -481,9 +484,12 @@ export class TerminalPanelManager { input: string, submitStrategy: NonNullable, ): void { + const terminal = this.terminals.get(panelId); + if (!terminal) return; if (submitStrategy === 'codex-ctrl-enter') { this.writeToTerminal(panelId, input); setTimeout(() => { + if (this.terminals.get(panelId) !== terminal) return; this.writeToTerminal(panelId, '\x1b[13;5u\r'); }, 500); return; From 4fd6b4d5f100aa62d9de7e57c689a38de14c8124 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sat, 12 Sep 2026 23:24:20 -0600 Subject: [PATCH 07/20] Retain real startup work across transient redraws --- .../services/agentStatus/agentStatusMonitor.test.ts | 12 ++++++++++++ main/src/services/agentStatus/agentStatusMonitor.ts | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/main/src/services/agentStatus/agentStatusMonitor.test.ts b/main/src/services/agentStatus/agentStatusMonitor.test.ts index 3a1030fa2..17df3d55b 100644 --- a/main/src/services/agentStatus/agentStatusMonitor.test.ts +++ b/main/src/services/agentStatus/agentStatusMonitor.test.ts @@ -85,6 +85,18 @@ describe('AgentStatusMonitor', () => { expect(m.update('p', detection({ state: 'working', visibleWorking: true }), 4050)).toBe('working'); }); + it('retains activity fallback for real work that starts during startup grace', () => { + const m = new AgentStatusMonitor(opts); + m.register('p', 0); + m.noteActivity('p', 100); + expect(m.update('p', detection({ visibleWorking: true }), 110)).toBe('working'); + const weakPrompt = detection({ matchedRuleId: 'live_prompt_box' }); + expect(m.update('p', weakPrompt, 120)).toBeNull(); + m.noteActivity('p', 900); + expect(m.update('p', weakPrompt, 1800)).toBeNull(); + expect(m.update('p', weakPrompt, 1900)).toBe('idle'); + }); + it('does not let a persistent prompt box complete work or wake on typing', () => { const m = new AgentStatusMonitor(opts); m.register('p', 0); diff --git a/main/src/services/agentStatus/agentStatusMonitor.ts b/main/src/services/agentStatus/agentStatusMonitor.ts index b36e60953..5f80f29b9 100644 --- a/main/src/services/agentStatus/agentStatusMonitor.ts +++ b/main/src/services/agentStatus/agentStatusMonitor.ts @@ -70,7 +70,7 @@ export class AgentStatusMonitor { // Boot banners and shell prompt setup are not evidence of a task. Explicit // agent working chrome still takes effect immediately, including at startup. - if (now - tracker.startedAt < this.options.startupGraceMs) return; + if (now - tracker.startedAt < this.options.startupGraceMs && tracker.published !== 'working') return; const startsNewBurst = tracker.lastActivityAt === undefined || now - tracker.lastActivityAt >= this.options.idleSettleMs; @@ -105,6 +105,8 @@ export class AgentStatusMonitor { candidate = 'blocked'; } else if (detection.visibleWorking) { candidate = 'working'; + // Visible work is activity evidence too, even before boot output is trusted. + tracker.lastActivityAt = now; } else if (detection.visibleIdle) { candidate = 'idle'; tracker.lastActivityAt = undefined; From 5a75f82f4f625c5fd23b233a260b5fd4f29b5951 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sat, 12 Sep 2026 23:27:49 -0600 Subject: [PATCH 08/20] Reconcile renderer statuses on attach and reconnect --- docs/ADDING_NEW_CLI_TOOLS.md | 5 +- frontend/src/App.tsx | 34 +---- frontend/src/components/ui/AgentStatusDot.tsx | 2 +- .../src/components/ui/agentStatusVisual.ts | 2 +- frontend/src/hooks/useNotifications.ts | 6 +- frontend/src/services/panelStatusSync.test.ts | 125 ++++++++++++++++++ frontend/src/services/panelStatusSync.ts | 95 +++++++++++++ frontend/src/stores/panelStore.test.ts | 9 ++ frontend/src/stores/panelStore.ts | 10 ++ frontend/src/types/panelStore.ts | 1 + frontend/src/utils/agentStatus.ts | 4 +- main/src/ipc/daemonRegistryBindings.test.ts | 1 + main/src/ipc/panels.status.test.ts | 48 +++++++ main/src/ipc/panels.ts | 26 ++++ .../terminalPanelManager.status.test.ts | 5 + shared/types/agentStatus.ts | 2 +- tests/agent-status.spec.ts | 93 +++++++++++++ 17 files changed, 430 insertions(+), 38 deletions(-) create mode 100644 frontend/src/services/panelStatusSync.test.ts create mode 100644 frontend/src/services/panelStatusSync.ts create mode 100644 main/src/ipc/panels.status.test.ts create mode 100644 tests/agent-status.spec.ts diff --git a/docs/ADDING_NEW_CLI_TOOLS.md b/docs/ADDING_NEW_CLI_TOOLS.md index 2db4d25d5..76a21a908 100644 --- a/docs/ADDING_NEW_CLI_TOOLS.md +++ b/docs/ADDING_NEW_CLI_TOOLS.md @@ -62,7 +62,10 @@ add a ready detector like `createCursorReadyDetector` and gate `signalCliReady` fixtures and register it in `MANIFESTS_BY_AGENT`. Unknown agents fall back to `GENERIC_MANIFEST` (works, less precise). Keep blocker rules narrow and live-region gated so answered prompts in scrollback don't stick. Record the CLI version the -fixtures came from. +fixtures came from. Set `visibleIdle` only for reliable idle/completion evidence; +composers that remain visible mid-turn must not set it. The monitor prioritizes +blockers and working chrome, then reliable idle evidence, with byte activity as +a fallback. Unclassified boot output is ignored during the startup grace window. ## 5. Restart/auto-resume diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4410df376..782c9cd68 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,7 +28,7 @@ import { DISCORD_INVITE_URL } from './components/DiscordIcon'; import { ResumeSessionsDialog } from './components/ResumeSessionsDialog'; import { useErrorStore } from './stores/errorStore'; import { useSessionStore } from './stores/sessionStore'; -import { rollupSessionAgentState } from './utils/agentStatus'; +import { subscribePanelStatus } from './services/panelStatusSync'; import { useConfigStore } from './stores/configStore'; import { usePanelStore } from './stores/panelStore'; import { API } from './utils/api'; @@ -152,36 +152,8 @@ function App() { useIPCEvents(); const { showNotification } = useNotifications(); - // Global panel activity status listener - useEffect(() => { - const unsubscribe = window.electronAPI?.events?.onPanelActivityStatus?.((data) => { - usePanelStore.getState().setActivityStatus(data.panelId, data.status, data.lastActivityAt); - }); - return () => unsubscribe?.(); - }, []); - - // Global agent status listener (blocked / working / done) for terminal panels. - useEffect(() => { - const unsubscribe = window.electronAPI?.events?.onPanelAgentStatus?.((data) => { - const store = usePanelStore.getState(); - const prevState = store.agentStatus[data.panelId]; - store.setAgentStatus(data.panelId, data.sessionId, data.state); - - // A background agent finishing should read as done (blue) right away — - // mark unseen completion from the unified working -> idle transition - // instead of waiting for the legacy 30s activity flip. - if (prevState === 'working' && data.state === 'idle') { - const next = usePanelStore.getState(); - const activeSessionId = useSessionStore.getState().activeSessionId; - const sessionSettled = - rollupSessionAgentState(next.agentStatus, next.agentStatusSession, data.sessionId) === 'idle'; - if (sessionSettled && activeSessionId !== data.sessionId) { - next.markUnviewedCompletedActivity(data.sessionId); - } - } - }); - return () => unsubscribe?.(); - }, []); + // Subscribe to live statuses and reconcile the daemon baseline on attach/reconnect. + useEffect(() => subscribePanelStatus(), []); useEffect(() => { const clearViewedCompletedActivity = (event: Event) => { diff --git a/frontend/src/components/ui/AgentStatusDot.tsx b/frontend/src/components/ui/AgentStatusDot.tsx index 3d30ba82a..947c70d38 100644 --- a/frontend/src/components/ui/AgentStatusDot.tsx +++ b/frontend/src/components/ui/AgentStatusDot.tsx @@ -29,7 +29,7 @@ const containerSizeClasses = { /** * At-a-glance agent status indicator. Working renders as a blue spinner; blocked * (red), done (blue), and idle (green) render as a dot — the "dot + spinner" - * variation. Renders nothing for `unknown` so non-agent panels show no badge. + * variation. Renders nothing until a terminal has a known status. */ interface AgentActivityDotProps { active: boolean; diff --git a/frontend/src/components/ui/agentStatusVisual.ts b/frontend/src/components/ui/agentStatusVisual.ts index 63f09edc6..4b6e9e349 100644 --- a/frontend/src/components/ui/agentStatusVisual.ts +++ b/frontend/src/components/ui/agentStatusVisual.ts @@ -13,7 +13,7 @@ export interface AgentStatusVisual { * Single source of truth for how an {@link AgentDisplayStatus} looks: blocked is * red and pulses, working is the info blue (matching the label shimmer) and * pulses, a freshly finished agent is a blue - * "done" cue, a seen-idle agent is calm green. `unknown` (no agent / plain shell) + * "done" cue, a seen-idle agent is calm green. `unknown` (no detected terminal status) * returns null so callers render no badge. */ export function agentStatusVisual(status: AgentDisplayStatus): AgentStatusVisual | null { diff --git a/frontend/src/hooks/useNotifications.ts b/frontend/src/hooks/useNotifications.ts index 5be900330..0dc9bd3d3 100644 --- a/frontend/src/hooks/useNotifications.ts +++ b/frontend/src/hooks/useNotifications.ts @@ -238,7 +238,11 @@ export function useNotifications() { // detected on their next transition — and an agent already sitting blocked // when the app opens doesn't re-ping. prevAgentStatusRef.current = { ...usePanelStore.getState().agentStatus }; - const unsubscribe = usePanelStore.subscribe((state) => { + const unsubscribe = usePanelStore.subscribe((state, previousState) => { + if (state.agentStatusSnapshotVersion !== previousState.agentStatusSnapshotVersion) { + prevAgentStatusRef.current = { ...state.agentStatus }; + return; + } const agentStatus = state.agentStatus; const prev = prevAgentStatusRef.current; for (const [panelId, status] of Object.entries(agentStatus)) { diff --git a/frontend/src/services/panelStatusSync.test.ts b/frontend/src/services/panelStatusSync.test.ts new file mode 100644 index 000000000..0628bbc93 --- /dev/null +++ b/frontend/src/services/panelStatusSync.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PanelAgentStatusEvent } from '../../../shared/types/agentStatus'; +import { usePanelStore } from '../stores/panelStore'; +import { subscribePanelStatus } from './panelStatusSync'; +import { useSessionStore } from '../stores/sessionStore'; +import type { JsonValue } from '../../../shared/validation/boundaryDecoder'; + +let status: (event: PanelAgentStatusEvent) => void; +let deleted: (event: { panelId: string; sessionId: string }) => void; +let resync: () => void; +let replies: Array<(value: JsonValue) => void>; +let cleanup: () => void; + +const snapshot = (state: string) => ({ success: true, data: [{ sessionId: 's', panelId: 'p', state }] }); +const event = (state: PanelAgentStatusEvent['state']) => ({ panelId: 'p', sessionId: 's', state, reason: null }); +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + +beforeEach(() => { + usePanelStore.setState({ panels: {}, activityStatus: {}, agentStatus: {}, agentStatusSession: {}, unviewedCompletedActivity: {}, agentStatusSnapshotVersion: 0 }); + useSessionStore.setState({ activeSessionId: 'foreground' }); + replies = []; + vi.stubGlobal('window', { electronAPI: { + invoke: vi.fn(() => new Promise(resolve => replies.push(resolve))), + events: { + onPanelAgentStatus: (callback: typeof status) => { status = callback; return vi.fn(); }, + onPanelDeleted: (callback: typeof deleted) => { deleted = callback; return vi.fn(); }, + onRemoteDaemonResyncRequested: (callback: typeof resync) => { resync = callback; return vi.fn(); }, + }, + } }); + cleanup = subscribePanelStatus(); +}); + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +describe('panel status synchronization', () => { + it('hydrates an already-running background panel and repairs a missed transition on reconnect', async () => { + replies[0](snapshot('working')); + await flush(); + expect(usePanelStore.getState().getSessionAgentState('s')).toBe('working'); + expect(usePanelStore.getState().getPanelActivityStatus('p')).toBe('active'); + resync(); + replies[1](snapshot('idle')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('idle'); + expect(usePanelStore.getState().getSessionAgentState('s')).toBe('idle'); + expect(usePanelStore.getState().getPanelActivityStatus('p')).toBe('idle'); + expect(usePanelStore.getState().hasUnviewedCompletedActivity('s')).toBe(false); + expect(usePanelStore.getState().agentStatusSnapshotVersion).toBe(2); + }); + + it('keeps newer events over an older snapshot, including repeated same-state events', async () => { + status(event('working')); + replies[0](snapshot('idle')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('working'); + resync(); + status(event('working')); + replies[1](snapshot('blocked')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('working'); + }); + + it('removes a background panel and ignores its late exit and pending snapshot', async () => { + status(event('working')); + deleted({ panelId: 'p', sessionId: 's' }); + status({ ...event('idle'), reason: 'exit' }); + replies[0](snapshot('working')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBeUndefined(); + expect(usePanelStore.getState().getSessionAgentState('s')).toBe('unknown'); + expect(usePanelStore.getState().hasUnviewedCompletedActivity('s')).toBe(false); + }); + + it('prunes statuses missing from a successful snapshot', async () => { + replies[0](snapshot('working')); + await flush(); + resync(); + replies[1]({ success: true, data: [] }); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBeUndefined(); + }); + + it('ignores obsolete requests and replies after unsubscription', async () => { + resync(); + replies[1](snapshot('idle')); + await flush(); + replies[0](snapshot('working')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('idle'); + resync(); + cleanup(); + replies[2](snapshot('working')); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('idle'); + }); + + it('allows a new snapshot to restore an ID recreated while disconnected', async () => { + deleted({ panelId: 'p', sessionId: 's' }); + replies[0](snapshot('working')); + await flush(); + resync(); + replies[1](snapshot('idle')); + await flush(); + status(event('working')); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('working'); + }); + + it('keeps known status if snapshot validation fails', async () => { + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + status(event('working')); + replies[0]({ ok: false }); + await flush(); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('working'); + expect(error).toHaveBeenCalledOnce(); + error.mockRestore(); + }); + + it('marks real live completion unseen after hydration', async () => { + replies[0](snapshot('idle')); + await flush(); + status(event('working')); + status(event('idle')); + expect(usePanelStore.getState().hasUnviewedCompletedActivity('s')).toBe(true); + }); +}); diff --git a/frontend/src/services/panelStatusSync.ts b/frontend/src/services/panelStatusSync.ts new file mode 100644 index 000000000..a0dd31086 --- /dev/null +++ b/frontend/src/services/panelStatusSync.ts @@ -0,0 +1,95 @@ +import { boundary, decodeBoundary } from '../../../shared/validation/boundaryDecoder'; +import { usePanelStore } from '../stores/panelStore'; +import { useSessionStore } from '../stores/sessionStore'; +import { rollupSessionAgentState } from '../utils/agentStatus'; + +const statusSnapshotSchema = boundary.object({ + success: boundary.literal(true), + data: boundary.array(boundary.object({ + sessionId: boundary.string, + panelId: boundary.string, + state: boundary.enumeration('blocked', 'working', 'idle', 'unknown'), + })), +}); + +/** Subscribe before requesting the baseline; events during a read always win. */ +export function subscribePanelStatus(): () => void { + const api = window.electronAPI; + let disposed = false; + let requestId = 0; + let changedDuringRead = new Set(); + const deleted = new Set(); + + const unsubscribeStatus = api.events.onPanelAgentStatus?.(data => { + changedDuringRead.add(data.panelId); + if (deleted.has(data.panelId)) return; + const store = usePanelStore.getState(); + const prevState = store.agentStatus[data.panelId]; + store.setAgentStatus(data.panelId, data.sessionId, data.state); + if (prevState === 'working' && data.state === 'idle') { + const next = usePanelStore.getState(); + const activeSessionId = useSessionStore.getState().activeSessionId; + const sessionSettled = rollupSessionAgentState(next.agentStatus, next.agentStatusSession, data.sessionId) === 'idle'; + if (sessionSettled && activeSessionId !== data.sessionId) { + next.markUnviewedCompletedActivity(data.sessionId); + } + } + }); + const unsubscribeActivity = api.events.onPanelActivityStatus?.(data => { + if (!deleted.has(data.panelId)) { + usePanelStore.getState().setActivityStatus(data.panelId, data.status, data.lastActivityAt); + } + }); + const unsubscribeDeleted = api.events.onPanelDeleted?.(data => { + changedDuringRead.add(data.panelId); + deleted.add(data.panelId); + usePanelStore.getState().removePanel(data.sessionId, data.panelId); + }); + const unsubscribeCreated = api.events.onPanelCreated?.(panel => { + changedDuringRead.add(panel.id); + deleted.delete(panel.id); + }); + + const refresh = async () => { + const currentRequest = ++requestId; + changedDuringRead = new Set(); + try { + const response: unknown = await api.invoke('panels:agent-statuses'); + const snapshot = decodeBoundary(response, statusSnapshotSchema); + if (disposed || currentRequest !== requestId) return; + usePanelStore.setState(state => { + const agentStatus = { ...state.agentStatus }; + const agentStatusSession = { ...state.agentStatusSession }; + const activityStatus = { ...state.activityStatus }; + for (const panelId of Object.keys(agentStatus)) { + if (!changedDuringRead.has(panelId)) { + delete agentStatus[panelId]; + delete agentStatusSession[panelId]; + delete activityStatus[panelId]; + } + } + for (const panel of snapshot.data) { + if (changedDuringRead.has(panel.panelId)) continue; + deleted.delete(panel.panelId); + agentStatus[panel.panelId] = panel.state; + agentStatusSession[panel.panelId] = panel.sessionId; + activityStatus[panel.panelId] = panel.state === 'working' || panel.state === 'blocked' ? 'active' : 'idle'; + } + return { agentStatus, agentStatusSession, activityStatus, agentStatusSnapshotVersion: state.agentStatusSnapshotVersion + 1 }; + }); + } catch (error) { + if (!disposed && currentRequest === requestId) console.error('[panelStatusSync] Failed to refresh agent statuses:', error); + } + }; + const unsubscribeResync = api.events.onRemoteDaemonResyncRequested?.(() => { void refresh(); }); + void refresh(); + + return () => { + disposed = true; + unsubscribeStatus?.(); + unsubscribeActivity?.(); + unsubscribeDeleted?.(); + unsubscribeCreated?.(); + unsubscribeResync?.(); + }; +} diff --git a/frontend/src/stores/panelStore.test.ts b/frontend/src/stores/panelStore.test.ts index 591865b1d..2f0081158 100644 --- a/frontend/src/stores/panelStore.test.ts +++ b/frontend/src/stores/panelStore.test.ts @@ -45,6 +45,15 @@ describe('panelStore agent status', () => { expect(usePanelStore.getState().getSessionAgentState('s2')).toBe('unknown'); }); + it('prunes stale background status when panel lists are reloaded', () => { + const store = usePanelStore.getState(); + store.setAgentStatus('deleted', 'background', 'working'); + store.setAgentStatus('retained', 'background', 'idle'); + store.setPanels('background', [panel('retained', 'background')]); + expect(usePanelStore.getState().getPanelAgentState('deleted')).toBeUndefined(); + expect(usePanelStore.getState().getSessionAgentState('background')).toBe('idle'); + }); + it('clears agent status when a panel is removed', () => { const store = usePanelStore.getState(); store.setPanels('s1', [panel('a', 's1')]); diff --git a/frontend/src/stores/panelStore.ts b/frontend/src/stores/panelStore.ts index 2b6ab038f..ad1ec8ec8 100644 --- a/frontend/src/stores/panelStore.ts +++ b/frontend/src/stores/panelStore.ts @@ -12,6 +12,7 @@ export const usePanelStore = create()( activityStatus: {}, agentStatus: {}, agentStatusSession: {}, + agentStatusSnapshotVersion: 0, lastActivityAt: {}, unviewedCompletedActivity: {}, layouts: {}, @@ -20,6 +21,15 @@ export const usePanelStore = create()( // Pure synchronous state updates setPanels: (sessionId, panels) => { set((state) => { + const panelIds = new Set(panels.map(panel => panel.id)); + for (const [panelId, owner] of Object.entries(state.agentStatusSession)) { + if (owner === sessionId && !panelIds.has(panelId)) { + delete state.agentStatus[panelId]; + delete state.agentStatusSession[panelId]; + delete state.activityStatus[panelId]; + delete state.lastActivityAt[panelId]; + } + } // Replace panels array entirely to ensure React detects changes state.panels[sessionId] = panels; }); diff --git a/frontend/src/types/panelStore.ts b/frontend/src/types/panelStore.ts index 7fe16d56e..be5d8f37e 100644 --- a/frontend/src/types/panelStore.ts +++ b/frontend/src/types/panelStore.ts @@ -8,6 +8,7 @@ export interface PanelStore { activityStatus: Record; // panelId -> status agentStatus: Record; // panelId -> detected agent state (blocked/working/idle) agentStatusSession: Record; // panelId -> sessionId (so status rolls up without panels loaded) + agentStatusSnapshotVersion: number; // Snapshot hydration is silent for notification subscribers lastActivityAt: Record; // panelId -> last PTY output timestamp unviewedCompletedActivity: Record; // sessionId -> completion timestamp diff --git a/frontend/src/utils/agentStatus.ts b/frontend/src/utils/agentStatus.ts index 00c3c1e3a..148c687d3 100644 --- a/frontend/src/utils/agentStatus.ts +++ b/frontend/src/utils/agentStatus.ts @@ -2,8 +2,8 @@ import type { AgentDisplayStatus, AgentState } from '../../../shared/types/agent /** * Roll several panel {@link AgentState}s up into one, with precedence - * blocked > working > idle. Returns `unknown` when no agent panel is tracked - * (e.g. a session with only plain-shell panels), so callers can hide the badge. + * blocked > working > idle. Returns `unknown` before any terminal status is + * available. Plain shells participate through generic activity detection. */ export function rollupAgentState(states: Array): AgentState { let sawWorking = false; diff --git a/main/src/ipc/daemonRegistryBindings.test.ts b/main/src/ipc/daemonRegistryBindings.test.ts index 3863434a4..d6a25f388 100644 --- a/main/src/ipc/daemonRegistryBindings.test.ts +++ b/main/src/ipc/daemonRegistryBindings.test.ts @@ -98,6 +98,7 @@ const PANEL_CHANNELS = [ 'panels:set-active', 'panels:getActive', 'panels:initialize', + 'panels:agent-statuses', 'panels:checkInitialized', 'panels:emitEvent', 'panels:resize-terminal', diff --git a/main/src/ipc/panels.status.test.ts b/main/src/ipc/panels.status.test.ts new file mode 100644 index 000000000..ccd2c6f9f --- /dev/null +++ b/main/src/ipc/panels.status.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { IpcMain } from 'electron'; +import type { AppServices } from './types'; +import type { Session } from '../types/session'; +import type { ToolPanel } from '../../../shared/types/panels'; +import { PANE_CHAT_SESSION_ID } from '../../../shared/types/paneChat'; +import { PaneCommandRegistry } from '../daemon/commandRegistry'; +import { panelManager } from '../services/panelManager'; +import { terminalPanelManager } from '../services/terminalPanelManager'; +import { registerPanelHandlers } from './panels'; + +function partial(value: Partial): Contract { + // SAFETY: Each fixture supplies every member read by the status snapshot handler. + return value as Contract; +} +const panel = (id: string, sessionId: string): ToolPanel => ({ + id, sessionId, type: 'terminal', title: id, state: { isActive: false }, + metadata: { createdAt: '', lastActiveAt: '', position: 0 }, +}); + +afterEach(() => vi.restoreAllMocks()); + +describe('panel status snapshot IPC', () => { + it('includes background shells, stopped terminals, and hidden Pane Chat with current monitor states', async () => { + vi.spyOn(panelManager, 'getPanelsForSession').mockImplementation(sessionId => sessionId === PANE_CHAT_SESSION_ID + ? [panel('chat', sessionId)] + : [panel('agent', sessionId), panel('shell', sessionId), panel('stopped', sessionId)]); + vi.spyOn(terminalPanelManager, 'getAgentStatus').mockImplementation(panelId => { + if (panelId === 'agent') return 'working'; + if (panelId === 'chat') return 'blocked'; + return undefined; + }); + vi.spyOn(terminalPanelManager, 'isTerminalInitialized').mockImplementation(panelId => panelId !== 'stopped'); + const services = partial({ sessionManager: partial({ + getAllSessions: () => [partial({ id: 'background', archived: false })], + }) }); + const registry = new PaneCommandRegistry(); + const handle = vi.fn(); + registerPanelHandlers(partial({ handle }), services, registry); + expect(handle).toHaveBeenCalledWith('panels:agent-statuses', expect.any(Function)); + await expect(registry.invoke('panels:agent-statuses')).resolves.toEqual({ success: true, data: [ + { panelId: 'agent', sessionId: 'background', state: 'working', reason: null }, + { panelId: 'shell', sessionId: 'background', state: 'unknown', reason: null }, + { panelId: 'stopped', sessionId: 'background', state: 'idle', reason: null }, + { panelId: 'chat', sessionId: PANE_CHAT_SESSION_ID, state: 'blocked', reason: null }, + ] }); + }); +}); diff --git a/main/src/ipc/panels.ts b/main/src/ipc/panels.ts index d27bf860a..7ba83ed4b 100644 --- a/main/src/ipc/panels.ts +++ b/main/src/ipc/panels.ts @@ -12,6 +12,8 @@ import { terminalPanelManager } from '../services/terminalPanelManager'; import { databaseService } from '../services/database'; import { CreatePanelRequest, PanelEventType, SessionPanelLayout, ToolPanel, type PanelLayoutNode } from '../../../shared/types/panels'; import type { AppServices } from './types'; +import type { PanelAgentStatusEvent } from '../../../shared/types/agentStatus'; +import { PANE_CHAT_SESSION_ID } from '../../../shared/types/paneChat'; import { getAppSubdirectory } from '../utils/appDirectory'; import { sanitizeTerminalOutput } from '../utils/terminalOutputSanitizer'; import { getWSLHome, linuxToUNCPath, posixJoin } from '../utils/wslUtils'; @@ -369,6 +371,7 @@ const DAEMON_PANEL_CHANNELS = [ 'panels:delete', 'panels:update', 'panels:list', + 'panels:agent-statuses', 'panels:set-active', 'panels:getActive', 'panels:get-layout', @@ -447,6 +450,29 @@ export function registerPanelHandlers( } }); + // Renderer baseline includes hidden Pane Chat, which the public workspace + // snapshot deliberately omits. Read the same authoritative monitor state. + commandRegistry.register('panels:agent-statuses', () => { + const sessionIds = new Set(services.sessionManager.getAllSessions() + .filter(session => !session.archived) + .map(session => session.id)); + sessionIds.add(PANE_CHAT_SESSION_ID); + const statuses: PanelAgentStatusEvent[] = []; + for (const sessionId of sessionIds) { + for (const panel of panelManager.getPanelsForSession(sessionId)) { + if (panel.type !== 'terminal') continue; + statuses.push({ + panelId: panel.id, + sessionId, + state: terminalPanelManager.getAgentStatus(panel.id) + ?? (terminalPanelManager.isTerminalInitialized(panel.id) ? 'unknown' : 'idle'), + reason: null, + }); + } + } + return { success: true, data: statuses }; + }); + commandRegistry.register('panels:list', async (sessionId: string) => { try { const panels = panelManager.getPanelsForSession(sessionId); diff --git a/main/src/services/terminalPanelManager.status.test.ts b/main/src/services/terminalPanelManager.status.test.ts index 34152f890..f1b5928cc 100644 --- a/main/src/services/terminalPanelManager.status.test.ts +++ b/main/src/services/terminalPanelManager.status.test.ts @@ -7,6 +7,7 @@ import { resetPaneRuntimeForTests, setPaneRuntime } from '../core/runtime'; import type { PaneEventArgument } from '../core/eventSink'; import { createFlowControlRecord, disposeFlowControlRecord } from '../ptyHost/flowControl'; import { panelManager } from '../test/setup'; +import { formatWaitResult } from '../../../packages/runpane/src/watchLines'; function createTerminal(agentType: 'claude' | 'codex' | undefined = 'codex') { let onData: (data: string) => void = () => undefined; @@ -197,6 +198,7 @@ describe('terminal status events', () => { { channel: 'panel:agentStatus', payload: { panelId: 'p', sessionId: 's', state: 'idle', reason: 'osc_title_idle' } }, ]); expect(journal.readAfter(0).entries).toEqual([]); + expect(formatWaitResult({ epoch: journal.epoch, ...journal.readAfter(0) }, 'lines')).toEqual([]); }); it('reports immediate real work and completion with coherent reasons', async () => { @@ -213,5 +215,8 @@ describe('terminal status events', () => { expect(journal.readAfter(0).entries.map(entry => [entry.kind, entry.reason])).toEqual([ ['agent.busy', 'osc_title_working'], ['agent.ready', 'osc_title_idle'], ]); + expect(formatWaitResult({ epoch: journal.epoch, ...journal.readAfter(0) }, 'lines')).toEqual([ + 'BUSY Pane pane s panel p', 'READY Pane pane s panel p', + ]); }); }); diff --git a/shared/types/agentStatus.ts b/shared/types/agentStatus.ts index c578a7baa..d33cb043d 100644 --- a/shared/types/agentStatus.ts +++ b/shared/types/agentStatus.ts @@ -31,7 +31,7 @@ export interface AgentDetectionResult { visibleBlocker: boolean; /** The matched screen visibly shows live working chrome. */ visibleWorking: boolean; - /** The matched screen visibly shows live idle chrome. */ + /** The matched chrome reliably indicates idle; excludes composers also visible during work. */ visibleIdle: boolean; /** * The matched screen is an agent-owned viewer (transcript/history) rather than diff --git a/tests/agent-status.spec.ts b/tests/agent-status.spec.ts new file mode 100644 index 000000000..4e1d2f8ae --- /dev/null +++ b/tests/agent-status.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test'; +import { installElectronApiMock } from './electronApiMock'; +import type { AgentState, PanelAgentStatusEvent } from '../shared/types/agentStatus'; + +declare global { + interface Window { + __statusFixture: { + setSnapshot(state: AgentState): void; + emit(state: AgentState): void; + remove(): void; + reconnect(): void; + notifications: string[]; + }; + } +} + +const now = new Date(0).toISOString(); +const project = { id: 841, name: 'Status fixture', path: '/tmp/status-fixture', active: true, created_at: now, updated_at: now }; +const session = (id: string) => ({ + id, name: id, projectId: project.id, worktreePath: `${project.path}/${id}`, + prompt: '', status: 'stopped', createdAt: now, lastActivity: now, output: [], jsonMessages: [], + permissionMode: 'ignore', toolType: 'none', archived: false, isRunning: false, +}); +const panel = { + id: 'agent', sessionId: 'Agent pane', type: 'terminal', title: 'Codex', + state: { isActive: true, customState: { isCliPanel: true, agentType: 'codex', isInitialized: true, isCliReady: true } }, + metadata: { createdAt: now, lastActiveAt: now, position: 0 }, +}; + +test('sidebar and tabs reconcile status without announcing snapshot completions', async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [session('Agent pane'), session('Other pane')], initialPanels: [panel], + initialUiState: { expandedProjects: [project.id] }, initialConfig: { notifications: { enabled: true, playSound: false } }, + }); + await page.addInitScript(() => { + const api = window.electronAPI; + const originalInvoke = api.invoke; + let snapshotState: AgentState = 'working'; + const statuses = new Set<(data: PanelAgentStatusEvent) => void>(); + const deletions = new Set<(data: { panelId: string; sessionId: string }) => void>(); + const reconnects = new Set<() => void>(); + api.invoke = (channel: string, ...args: unknown[]) => { + if (channel === 'panels:agent-statuses') return Promise.resolve({ success: true, data: [ + { sessionId: 'Agent pane', panelId: 'agent', state: snapshotState }, + ] }); + return originalInvoke(channel, ...args); + }; + const originalEvents = api.events; + api.events = new Proxy(originalEvents, { get(target, key: keyof typeof originalEvents) { + if (key === 'onPanelAgentStatus') return (callback: (data: PanelAgentStatusEvent) => void) => { + statuses.add(callback); return () => statuses.delete(callback); + }; + if (key === 'onPanelDeleted') return (callback: (data: { panelId: string; sessionId: string }) => void) => { + deletions.add(callback); return () => deletions.delete(callback); + }; + if (key === 'onRemoteDaemonResyncRequested') return (callback: () => void) => { + reconnects.add(callback); return () => reconnects.delete(callback); + }; + return target[key]; + } }); + const notifications: string[] = []; + Object.defineProperty(window, 'Notification', { configurable: true, value: class { + static permission = 'granted'; + constructor(title: string) { notifications.push(title); } + } }); + api.window.isFocused = async () => false; + window.__statusFixture = { + setSnapshot: state => { snapshotState = state; }, + emit: state => { for (const listener of statuses) listener({ panelId: 'agent', sessionId: 'Agent pane', state, reason: 'fixture' }); }, + remove: () => { for (const listener of deletions) listener({ panelId: 'agent', sessionId: 'Agent pane' }); }, + reconnect: () => { for (const listener of reconnects) listener(); }, + notifications, + }; + }); + await page.goto('/'); + const sidebarPane = page.getByRole('button', { name: 'Agent pane', exact: true }); + await expect(sidebarPane).toBeVisible(); + await expect(sidebarPane.locator('..').locator('[aria-label="Agent working"]')).toBeVisible(); + await sidebarPane.click(); + const tab = page.getByRole('tab', { name: 'Codex', exact: true }).locator('..'); + await expect(tab.locator('[aria-label="Agent working"]')).toBeVisible(); + + await page.evaluate(() => { window.__statusFixture.setSnapshot('idle'); window.__statusFixture.reconnect(); }); + await expect(tab.locator('[aria-label="Agent idle"]')).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.__statusFixture.notifications.length)).toBe(0); + + await page.getByRole('button', { name: 'Other pane', exact: true }).click(); + await page.evaluate(() => { window.__statusFixture.emit('working'); window.__statusFixture.emit('idle'); }); + await expect(sidebarPane.locator('..').locator('[aria-label="Agent done"]')).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.__statusFixture.notifications)).toEqual(['Codex finished']); + await page.evaluate(() => { window.__statusFixture.emit('working'); window.__statusFixture.remove(); }); + await expect(sidebarPane.locator('..').locator('[aria-label="Agent working"]')).toHaveCount(0); +}); From f25b5db4cf1b336b8f812a3c65445a1b553f6f46 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sun, 13 Sep 2026 00:37:04 -0600 Subject: [PATCH 09/20] Keep terminal startup unknown until status detection is meaningful --- main/src/ipc/panels.status.test.ts | 2 +- main/src/ipc/panels.ts | 3 +-- .../agentStatus/agentStatusMonitor.test.ts | 6 ++++-- .../agentStatus/agentStatusMonitor.ts | 6 ++++++ .../terminalPanelManager.status.test.ts | 20 ++++++++++++++++++- main/src/services/terminalPanelManager.ts | 13 ++++++++++-- 6 files changed, 42 insertions(+), 8 deletions(-) diff --git a/main/src/ipc/panels.status.test.ts b/main/src/ipc/panels.status.test.ts index ccd2c6f9f..042d82dcd 100644 --- a/main/src/ipc/panels.status.test.ts +++ b/main/src/ipc/panels.status.test.ts @@ -41,7 +41,7 @@ describe('panel status snapshot IPC', () => { await expect(registry.invoke('panels:agent-statuses')).resolves.toEqual({ success: true, data: [ { panelId: 'agent', sessionId: 'background', state: 'working', reason: null }, { panelId: 'shell', sessionId: 'background', state: 'unknown', reason: null }, - { panelId: 'stopped', sessionId: 'background', state: 'idle', reason: null }, + { panelId: 'stopped', sessionId: 'background', state: 'unknown', reason: null }, { panelId: 'chat', sessionId: PANE_CHAT_SESSION_ID, state: 'blocked', reason: null }, ] }); }); diff --git a/main/src/ipc/panels.ts b/main/src/ipc/panels.ts index 7ba83ed4b..06d49283d 100644 --- a/main/src/ipc/panels.ts +++ b/main/src/ipc/panels.ts @@ -464,8 +464,7 @@ export function registerPanelHandlers( statuses.push({ panelId: panel.id, sessionId, - state: terminalPanelManager.getAgentStatus(panel.id) - ?? (terminalPanelManager.isTerminalInitialized(panel.id) ? 'unknown' : 'idle'), + state: terminalPanelManager.getAgentStatus(panel.id) ?? 'unknown', reason: null, }); } diff --git a/main/src/services/agentStatus/agentStatusMonitor.test.ts b/main/src/services/agentStatus/agentStatusMonitor.test.ts index 17df3d55b..41bffe212 100644 --- a/main/src/services/agentStatus/agentStatusMonitor.test.ts +++ b/main/src/services/agentStatus/agentStatusMonitor.test.ts @@ -57,11 +57,13 @@ describe('AgentStatusMonitor', () => { const m = new AgentStatusMonitor(opts); m.register('p', 0); // Empty shells and boot banners do not represent a completed task. - expect(m.update('p', detection({ state: 'idle' }), 500)).toBe('idle'); + expect(m.update('p', detection({ state: 'idle' }), 500)).toBeNull(); + expect(m.getState('p')).toBeUndefined(); m.noteActivity('p', 600); m.noteActivity('p', 700); expect(m.update('p', detection({}), 800)).toBeNull(); - expect(m.update('p', detection({}), 3100)).toBeNull(); + expect(m.getState('p')).toBeUndefined(); + expect(m.update('p', detection({}), 3100)).toBe('idle'); }); it('emits only on change', () => { diff --git a/main/src/services/agentStatus/agentStatusMonitor.ts b/main/src/services/agentStatus/agentStatusMonitor.ts index 5f80f29b9..db01962c0 100644 --- a/main/src/services/agentStatus/agentStatusMonitor.ts +++ b/main/src/services/agentStatus/agentStatusMonitor.ts @@ -100,6 +100,12 @@ export class AgentStatusMonitor { (tracker.published === 'working' || detection.matchedRuleId === null) && (tracker.published !== 'idle' || tracker.activityChunksInBurst >= 2); + // A blank boot screen is not yet an idle agent. Keep the initial unknown + // state until live chrome appears or the startup grace expires. + if (now - tracker.startedAt < this.options.startupGraceMs && + detection.matchedRuleId === null && !recentlyActive && + !detection.visibleWorking && !detection.visibleIdle && detection.state !== 'blocked') return null; + let candidate: AgentState; if (detection.state === 'blocked') { candidate = 'blocked'; diff --git a/main/src/services/terminalPanelManager.status.test.ts b/main/src/services/terminalPanelManager.status.test.ts index f1b5928cc..7e6f720be 100644 --- a/main/src/services/terminalPanelManager.status.test.ts +++ b/main/src/services/terminalPanelManager.status.test.ts @@ -13,7 +13,7 @@ function createTerminal(agentType: 'claude' | 'codex' | undefined = 'codex') { let onData: (data: string) => void = () => undefined; let onExit: (exit: { exitCode: number; signal?: number }) => void = () => undefined; const terminal = { - panelId: 'p', sessionId: 's', agentType, + panelId: 'p', sessionId: 's', agentType, pendingInitialCommand: false, pty: { onData: (listener: typeof onData) => { onData = listener; }, onExit: (listener: typeof onExit) => { onExit = listener; }, @@ -92,6 +92,24 @@ afterEach(() => { }); describe('terminal status events', () => { + it('keeps idle waits pending during boot and delayed command injection', async () => { + const fixture = attach('codex', 20_000); + vi.setSystemTime(20_500); + await access.pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBeUndefined(); + expect(manager.getTerminalSnapshot('p')?.activityStatus).toBe('active'); + fixture.terminal.pendingInitialCommand = true; + vi.setSystemTime(24_500); + await access.pollAgentStatus(); + expect(manager.getAgentStatus('p')).toBeUndefined(); + expect(manager.getTerminalSnapshot('p')?.activityStatus).toBe('active'); + fixture.terminal.pendingInitialCommand = false; + fixture.data('\x1b]2;Codex\x07'); + await access.pollAgentStatus(); + expect(manager.getTerminalSnapshot('p')?.activityStatus).toBe('idle'); + expect(journal.readAfter(0).entries).toEqual([]); + }); + it('publishes consistent idle status on exit and deduplicates repeated callbacks', async () => { const fixture = attach('codex'); fixture.data('\x1b]2;⠙ Codex\x07'); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index e54da1371..ab6778ac1 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -194,6 +194,8 @@ interface TerminalProcess { screenEmulator?: TerminalStateEmulator; commandHistory: string[]; currentCommand: string; + /** The requested tool has not yet been injected into its shell. */ + pendingInitialCommand?: boolean; lastActivity: Date; lastOutputAt?: Date; outputGeneration: number; @@ -1045,6 +1047,8 @@ export class TerminalPanelManager { agentSessionScrapeBuffer: '' }; + terminalProcess.pendingInitialCommand = Boolean(terminalCustomState(panel.state).initialCommand); + // Store in map (ptyHost path: pid is already populated on the shim). this.terminals.set(panel.id, terminalProcess); @@ -1095,6 +1099,8 @@ export class TerminalPanelManager { const panelId = panel.id; const injectCommand = () => { if (this.terminals.get(panelId) !== terminalProcess) return; + terminalProcess.pendingInitialCommand = false; + this.agentStatusMonitor.register(panelId, Date.now()); this.writeToTerminal(panelId, commandToRun! + '\r'); // For CLI tool terminals, signal the frontend when the CLI responds @@ -1626,7 +1632,10 @@ export class TerminalPanelManager { private deriveActivityStatus(panelId: string): 'active' | 'idle' { const state = this.agentStatusMonitor.getState(panelId); - return state === 'working' || state === 'blocked' ? 'active' : 'idle'; + // Legacy idle waits must not succeed while a tracked terminal is still booting. + return state === 'working' || state === 'blocked' || + (state === undefined && this.agentStatusMonitor.isTracked(panelId)) || + this.terminals.get(panelId)?.pendingInitialCommand ? 'active' : 'idle'; } getAgentStatus(panelId: string): AgentState | undefined { @@ -1697,7 +1706,7 @@ export class TerminalPanelManager { this.agentStatusPolling = true; try { for (const terminal of this.terminals.values()) { - if (!this.agentStatusMonitor.isTracked(terminal.panelId)) continue; + if (terminal.pendingInitialCommand || !this.agentStatusMonitor.isTracked(terminal.panelId)) continue; const manifest = getManifestForAgent(terminal.agentType); const emulator = terminal.screenEmulator; if (!emulator) continue; From 3960aa9a5c528306012843775663ad29bd283422 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sun, 13 Sep 2026 00:39:37 -0600 Subject: [PATCH 10/20] Do not announce terminal teardown as agent completion --- frontend/src/services/panelStatusSync.test.ts | 14 ++++++++++++++ frontend/src/services/panelStatusSync.ts | 10 ++++++++++ frontend/src/types/panelStore.ts | 2 +- main/src/daemon/mobilePushSender.test.ts | 8 ++++++++ main/src/daemon/mobilePushSender.ts | 3 ++- .../services/terminalPanelManager.status.test.ts | 7 ++++--- main/src/services/terminalPanelManager.ts | 2 +- main/src/services/workspaceJournal.ts | 11 +++++++++-- tests/agent-status.spec.ts | 9 +++++++-- 9 files changed, 56 insertions(+), 10 deletions(-) diff --git a/frontend/src/services/panelStatusSync.test.ts b/frontend/src/services/panelStatusSync.test.ts index 0628bbc93..a5fd2f3fb 100644 --- a/frontend/src/services/panelStatusSync.test.ts +++ b/frontend/src/services/panelStatusSync.test.ts @@ -115,6 +115,20 @@ describe('panel status synchronization', () => { error.mockRestore(); }); + it.each(['exit', 'destroyed'])('silently clears working state on %s before panel deletion', async reason => { + replies[0](snapshot('working')); + await flush(); + const baseline = usePanelStore.getState().agentStatusSnapshotVersion; + status({ ...event('idle'), reason }); + expect(usePanelStore.getState().getPanelAgentState('p')).toBe('idle'); + expect(usePanelStore.getState().hasUnviewedCompletedActivity('s')).toBe(false); + expect(usePanelStore.getState().agentStatusSnapshotVersion).toBe(baseline + 1); + // The next lifetime can still produce a real completion. + status(event('working')); + status(event('idle')); + expect(usePanelStore.getState().hasUnviewedCompletedActivity('s')).toBe(true); + }); + it('marks real live completion unseen after hydration', async () => { replies[0](snapshot('idle')); await flush(); diff --git a/frontend/src/services/panelStatusSync.ts b/frontend/src/services/panelStatusSync.ts index a0dd31086..8439f1f51 100644 --- a/frontend/src/services/panelStatusSync.ts +++ b/frontend/src/services/panelStatusSync.ts @@ -23,6 +23,16 @@ export function subscribePanelStatus(): () => void { const unsubscribeStatus = api.events.onPanelAgentStatus?.(data => { changedDuringRead.add(data.panelId); if (deleted.has(data.panelId)) return; + if (data.reason === 'exit' || data.reason === 'destroyed') { + // Rebaseline notification subscribers atomically, as with a snapshot. + // A stopped process must not become an unseen completed agent turn. + usePanelStore.setState(state => ({ + agentStatus: { ...state.agentStatus, [data.panelId]: data.state }, + agentStatusSession: { ...state.agentStatusSession, [data.panelId]: data.sessionId }, + agentStatusSnapshotVersion: state.agentStatusSnapshotVersion + 1, + })); + return; + } const store = usePanelStore.getState(); const prevState = store.agentStatus[data.panelId]; store.setAgentStatus(data.panelId, data.sessionId, data.state); diff --git a/frontend/src/types/panelStore.ts b/frontend/src/types/panelStore.ts index be5d8f37e..6a9a0bd49 100644 --- a/frontend/src/types/panelStore.ts +++ b/frontend/src/types/panelStore.ts @@ -8,7 +8,7 @@ export interface PanelStore { activityStatus: Record; // panelId -> status agentStatus: Record; // panelId -> detected agent state (blocked/working/idle) agentStatusSession: Record; // panelId -> sessionId (so status rolls up without panels loaded) - agentStatusSnapshotVersion: number; // Snapshot hydration is silent for notification subscribers + agentStatusSnapshotVersion: number; // Snapshots and terminal endings silently rebaseline notification subscribers lastActivityAt: Record; // panelId -> last PTY output timestamp unviewedCompletedActivity: Record; // sessionId -> completion timestamp diff --git a/main/src/daemon/mobilePushSender.test.ts b/main/src/daemon/mobilePushSender.test.ts index a537a8854..b99b07a46 100644 --- a/main/src/daemon/mobilePushSender.test.ts +++ b/main/src/daemon/mobilePushSender.test.ts @@ -82,6 +82,14 @@ describe('MobilePushSender', () => { expect(Buffer.from(requests[0]?.jwt.split('.')[2] ?? '', 'base64url')).toHaveLength(64); expect(manager.config.host.mobilePush.registrations[0]?.recentEventIds).toHaveLength(2); + for (const reason of ['exit', 'destroyed']) { + await sender.observeStatus({ sessionId: 'pane-1', panelId: 'panel-1', state: 'working', reason: 'working' }); + await sender.observeStatus({ sessionId: 'pane-1', panelId: 'panel-1', state: 'idle', reason }); + expect(manager.config.host.mobilePush.panelStates['panel-1']).toBe('idle'); + expect(manager.config.host.mobilePush.attentionSequence).toBe(2); + expect(requests).toHaveLength(2); + } + await sender.updateControls('client-1', 'ios', 'install-1', { completedEnabled: false, needsInputEnabled: false }); await expect(sender.register('client-1', { ...registration, token: 'rotated-token' })).resolves.toMatchObject({ registration: 'registered', completedEnabled: false, needsInputEnabled: false, diff --git a/main/src/daemon/mobilePushSender.ts b/main/src/daemon/mobilePushSender.ts index 29d0f2f2c..844b885ed 100644 --- a/main/src/daemon/mobilePushSender.ts +++ b/main/src/daemon/mobilePushSender.ts @@ -137,7 +137,8 @@ export class MobilePushSender { const config = this.config(); if (!config.host.mobilePush.registrations.some(item => !item.revokedAt && config.host.clients.some(client => client.id === item.clientId))) return; const previous = config.host.mobilePush.panelStates[event.panelId]; - const kind = event.state === 'blocked' && previous !== 'blocked' + const terminalEnded = event.reason === 'exit' || event.reason === 'destroyed'; + const kind = terminalEnded ? null : event.state === 'blocked' && previous !== 'blocked' ? 'needs-input' : previous === 'working' && event.state === 'idle' ? 'completed' diff --git a/main/src/services/terminalPanelManager.status.test.ts b/main/src/services/terminalPanelManager.status.test.ts index 7e6f720be..d6dd8b785 100644 --- a/main/src/services/terminalPanelManager.status.test.ts +++ b/main/src/services/terminalPanelManager.status.test.ts @@ -119,7 +119,7 @@ describe('terminal status events', () => { expect(events.filter(event => event.channel === 'panel:activityStatus').at(-1)?.payload).toMatchObject({ status: 'idle' }); expect(events.filter(event => event.channel === 'terminal:exited')).toHaveLength(1); expect(manager.getAgentStatus('p')).toBeUndefined(); - expect(journal.readAfter(0).entries.filter(entry => entry.kind === 'panel.exited')).toHaveLength(1); + expect(journal.readAfter(0).entries.map(entry => entry.kind)).toEqual(['agent.busy', 'panel.exited']); }); it('retires destroyed terminals before old exit and data callbacks can affect a replacement', async () => { @@ -128,8 +128,9 @@ describe('terminal status events', () => { old.data('\x1b]2;⠙ Codex\x07'); await access.pollAgentStatus(); manager.destroyTerminal('p'); - expect(events.filter(event => event.channel === 'panel:agentStatus').at(-1)?.payload).toMatchObject({ state: 'idle' }); - expect(journal.readAfter(0).entries.filter(entry => entry.kind === 'panel.exited')).toHaveLength(1); + expect(events.filter(event => event.channel === 'panel:agentStatus').at(-1)?.payload).toMatchObject({ state: 'idle', reason: 'destroyed' }); + expect(journal.readAfter(0).entries.map(entry => entry.kind)).toEqual(['agent.busy', 'panel.exited']); + expect(formatWaitResult({ epoch: journal.epoch, ...journal.readAfter(0) }, 'lines').some(line => line.startsWith('READY'))).toBe(false); const replacement = attach('codex'); replacement.data('\x1b]2;⠙ Codex\x07'); await access.pollAgentStatus(); diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index ab6778ac1..b34441825 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -1782,7 +1782,7 @@ export class TerminalPanelManager { this.visibleViewersByPanel.delete(panelId); this.serializedBuffers.delete(panelId); this.maybeStopAgentStatusPoll(); - this.emitAgentStatus(terminal, 'idle', 'exit'); + this.emitAgentStatus(terminal, 'idle', exit ? 'exit' : 'destroyed'); const data = { ...exit, timestamp: new Date().toISOString() }; if (panelManager.getPanel(panelId)) { diff --git a/main/src/services/workspaceJournal.ts b/main/src/services/workspaceJournal.ts index f5e0749fa..d3379b58f 100644 --- a/main/src/services/workspaceJournal.ts +++ b/main/src/services/workspaceJournal.ts @@ -262,7 +262,14 @@ export class WorkspaceJournal implements PaneEventSink { this.readySinceByPanel.delete(panelId); return; } - if (payload.reason !== 'exit') this.exitedPanels.delete(panelId); + // Ending the process is not a completed agent turn. terminal:exit records + // the lifecycle event separately, including user destruction and archive. + if (payload.reason === 'exit' || payload.reason === 'destroyed') { + this.stateByPanel.delete(panelId); + this.readySinceByPanel.delete(panelId); + return; + } + this.exitedPanels.delete(panelId); const panel = this.resolvePanel?.(panelId); if (!panel?.isCliPanel) return; @@ -290,7 +297,7 @@ export class WorkspaceJournal implements PaneEventSink { agentType: panel?.agentType, from: previous, to: state, - source: payload.reason === 'exit' ? 'exit' : 'agent', + source: 'agent', reason: payload.reason ?? null, settledMs, heldInput: kind === 'agent.ready' ? truncateHeldInput(panel?.heldInput) : undefined, diff --git a/tests/agent-status.spec.ts b/tests/agent-status.spec.ts index 4e1d2f8ae..262f7803f 100644 --- a/tests/agent-status.spec.ts +++ b/tests/agent-status.spec.ts @@ -6,7 +6,7 @@ declare global { interface Window { __statusFixture: { setSnapshot(state: AgentState): void; - emit(state: AgentState): void; + emit(state: AgentState, reason?: string): void; remove(): void; reconnect(): void; notifications: string[]; @@ -66,7 +66,7 @@ test('sidebar and tabs reconcile status without announcing snapshot completions' api.window.isFocused = async () => false; window.__statusFixture = { setSnapshot: state => { snapshotState = state; }, - emit: state => { for (const listener of statuses) listener({ panelId: 'agent', sessionId: 'Agent pane', state, reason: 'fixture' }); }, + emit: (state, reason = 'fixture') => { for (const listener of statuses) listener({ panelId: 'agent', sessionId: 'Agent pane', state, reason }); }, remove: () => { for (const listener of deletions) listener({ panelId: 'agent', sessionId: 'Agent pane' }); }, reconnect: () => { for (const listener of reconnects) listener(); }, notifications, @@ -85,6 +85,11 @@ test('sidebar and tabs reconcile status without announcing snapshot completions' await expect.poll(() => page.evaluate(() => window.__statusFixture.notifications.length)).toBe(0); await page.getByRole('button', { name: 'Other pane', exact: true }).click(); + for (const reason of ['exit', 'destroyed']) { + await page.evaluate(reason => { window.__statusFixture.emit('working'); window.__statusFixture.emit('idle', reason); }, reason); + await expect(sidebarPane.locator('..').locator('[aria-label="Agent idle"]')).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.__statusFixture.notifications)).toEqual([]); + } await page.evaluate(() => { window.__statusFixture.emit('working'); window.__statusFixture.emit('idle'); }); await expect(sidebarPane.locator('..').locator('[aria-label="Agent done"]')).toBeVisible(); await expect.poll(() => page.evaluate(() => window.__statusFixture.notifications)).toEqual(['Codex finished']); From b2b4618786800d92e2250674548878d38b56c893 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sun, 13 Sep 2026 00:44:05 -0600 Subject: [PATCH 11/20] Drain terminal snapshots before asynchronous teardown --- main/src/ipc/panels.ts | 2 +- main/src/ipc/session.ts | 2 +- main/src/services/paneChatManager.ts | 2 +- .../terminalPanelManager.persistence.test.ts | 6 +- .../terminalPanelManager.status.test.ts | 65 ++++++++++++++++- main/src/services/terminalPanelManager.ts | 69 ++++++++++--------- 6 files changed, 107 insertions(+), 39 deletions(-) diff --git a/main/src/ipc/panels.ts b/main/src/ipc/panels.ts index 06d49283d..6fe2e49b3 100644 --- a/main/src/ipc/panels.ts +++ b/main/src/ipc/panels.ts @@ -419,7 +419,7 @@ export function registerPanelHandlers( // Clean up terminal process if it's a terminal panel const panel = panelManager.getPanel(panelId); if (panel?.type === 'terminal') { - terminalPanelManager.destroyTerminal(panelId); + await terminalPanelManager.destroyTerminal(panelId); } await panelManager.deletePanel(panelId); diff --git a/main/src/ipc/session.ts b/main/src/ipc/session.ts index ff3722fb6..2878ddddc 100644 --- a/main/src/ipc/session.ts +++ b/main/src/ipc/session.ts @@ -393,7 +393,7 @@ export function registerSessionHandlers( for (const panel of panels) { try { if (panel.type === 'terminal') { - terminalPanelManager.destroyTerminal(panel.id); + await terminalPanelManager.destroyTerminal(panel.id); } } catch (panelError) { console.error(`[Session IPC] Failed to cleanup panel ${panel.id} (${panel.type}):`, panelError); diff --git a/main/src/services/paneChatManager.ts b/main/src/services/paneChatManager.ts index ca0cba4fd..cfb655d5b 100644 --- a/main/src/services/paneChatManager.ts +++ b/main/src/services/paneChatManager.ts @@ -116,7 +116,7 @@ export class PaneChatManager { const existingAgent = this.resolvePanelAgent(existingPanel) ?? agent; const needsRepair = existingAgent !== agent || this.needsLaunchStateRepair(existingPanel, existingAgent); if (needsRepair && terminalPanelManager.isTerminalInitialized(existingPanel.id)) { - terminalPanelManager.destroyTerminal(existingPanel.id); + await terminalPanelManager.destroyTerminal(existingPanel.id); } if (!terminalPanelManager.isTerminalInitialized(existingPanel.id) || needsRepair) { diff --git a/main/src/services/terminalPanelManager.persistence.test.ts b/main/src/services/terminalPanelManager.persistence.test.ts index a84abc5e9..45cd11cd9 100644 --- a/main/src/services/terminalPanelManager.persistence.test.ts +++ b/main/src/services/terminalPanelManager.persistence.test.ts @@ -148,9 +148,9 @@ describe('terminal panel persistence', () => { } }); - afterEach(() => { + afterEach(async () => { for (const manager of managers) { - for (const panelId of manager.getActiveTerminals()) manager.destroyTerminal(panelId); + for (const panelId of manager.getActiveTerminals()) await manager.destroyTerminal(panelId); } panelManagerMock.updatePanel.mockReset(); panelManagerMock.getPanel.mockReset(); @@ -234,7 +234,7 @@ describe('terminal panel persistence', () => { // routes the same write into panel_buffers. expect(lastPersisted).not.toBeNull(); expect(databaseService.updatePanel(panel.id, { state: lastPersisted ?? { isActive: false } })).toBe(true); - first.destroyTerminal(panel.id); + await first.destroyTerminal(panel.id); const second = new TerminalPanelManager(); managers.push(second); diff --git a/main/src/services/terminalPanelManager.status.test.ts b/main/src/services/terminalPanelManager.status.test.ts index d6dd8b785..30658f2ae 100644 --- a/main/src/services/terminalPanelManager.status.test.ts +++ b/main/src/services/terminalPanelManager.status.test.ts @@ -17,7 +17,7 @@ function createTerminal(agentType: 'claude' | 'codex' | undefined = 'codex') { pty: { onData: (listener: typeof onData) => { onData = listener; }, onExit: (listener: typeof onExit) => { onExit = listener; }, - write: vi.fn(), kill: vi.fn(), cols: 80, rows: 24, + write: vi.fn(), kill: vi.fn(), cols: 80, rows: 24, pid: process.pid, }, screenEmulator: new TerminalStateEmulator(80, 24), scrollbackBuffer: '', alternateScreenBuffer: '', commandHistory: [], @@ -38,6 +38,7 @@ interface StatusAccess { setupTerminalHandlers(terminal: TerminalFixture['terminal']): void; pollAgentStatus(): Promise; sendInitialInputOnce(panelId: string): void; + getProcessCwd(pid: number): Promise; } let manager: TerminalPanelManager; @@ -127,7 +128,7 @@ describe('terminal status events', () => { const old = attach('codex'); old.data('\x1b]2;⠙ Codex\x07'); await access.pollAgentStatus(); - manager.destroyTerminal('p'); + await manager.destroyTerminal('p'); expect(events.filter(event => event.channel === 'panel:agentStatus').at(-1)?.payload).toMatchObject({ state: 'idle', reason: 'destroyed' }); expect(journal.readAfter(0).entries.map(entry => entry.kind)).toEqual(['agent.busy', 'panel.exited']); expect(formatWaitResult({ epoch: journal.epoch, ...journal.readAfter(0) }, 'lines').some(line => line.startsWith('READY'))).toBe(false); @@ -144,6 +145,66 @@ describe('terminal status events', () => { expect(journal.readAfter(0).entries.filter(entry => entry.kind === 'panel.exited')).toHaveLength(2); }); + it('drains pending output and saves the live cwd before destroying the emulator', async () => { + const fixture = attach('codex'); + panelManager.getPanel.mockReturnValue({ + id: 'p', sessionId: 's', type: 'terminal', title: 'Codex', + state: { isActive: true, customState: { cwd: '/old' } }, + metadata: { createdAt: '', lastActiveAt: '', position: 0 }, + }); + vi.spyOn(access, 'getProcessCwd').mockResolvedValue('/live'); + fixture.data('last output before archive'); + const destroying = manager.destroyTerminal('p'); + expect(fixture.terminal.pty.kill).not.toHaveBeenCalled(); + expect(manager.destroyTerminal('p')).toBe(destroying); + // Closing callbacks cannot dispose the model before queued writes drain. + fixture.exit(); + fixture.data('output after teardown began'); + manager.writeToTerminal('p', 'late input'); + expect(fixture.terminal.pty.write).not.toHaveBeenCalled(); + await destroying; + expect(panelManager.updatePanel).toHaveBeenCalledWith('p', { state: expect.objectContaining({ + customState: expect.objectContaining({ cwd: '/live', scrollbackBuffer: expect.stringContaining('last output before archive') }), + }) }); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + expect(journal.readAfter(0).entries.map(entry => entry.kind)).toEqual(['panel.exited']); + }); + + it.each(['cwd read', 'emulator drain'])('does not persist or retire a replacement during teardown %s', async phase => { + const old = attach('codex'); + panelManager.getPanel.mockReturnValue({ + id: 'p', sessionId: 's', type: 'terminal', title: 'Codex', + state: { isActive: true }, metadata: { createdAt: '', lastActiveAt: '', position: 0 }, + }); + let release: () => void = () => undefined; + const pending = new Promise(resolve => { release = resolve; }); + vi.spyOn(access, 'getProcessCwd').mockImplementation(async () => { + if (phase === 'cwd read') await pending; + return '/live'; + }); + if (phase === 'emulator drain') vi.spyOn(old.terminal.screenEmulator, 'waitForIdle').mockReturnValue(pending); + const destroying = manager.destroyTerminal('p'); + await Promise.resolve(); + const replacement = attach('codex'); + replacement.data('\x1b]2;⠙ Codex\x07'); + release(); + await destroying; + await access.pollAgentStatus(); + expect(panelManager.updatePanel).not.toHaveBeenCalled(); + expect(replacement.terminal.pty.kill).not.toHaveBeenCalled(); + expect(manager.getAgentStatus('p')).toBe('working'); + }); + + it('still retires and kills the terminal when saving fails', async () => { + const fixture = attach('codex'); + vi.spyOn(manager, 'saveTerminalState').mockRejectedValue(new Error('persistence failed')); + const error = vi.spyOn(console, 'error').mockImplementation(() => undefined); + await manager.destroyTerminal('p'); + expect(error).toHaveBeenCalled(); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + expect(manager.isTerminalInitialized('p')).toBe(false); + }); + it('discards a poll resumed after the terminal was replaced', async () => { const old = attach('codex'); let release = () => undefined; diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index b34441825..591352860 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -196,6 +196,8 @@ interface TerminalProcess { currentCommand: string; /** The requested tool has not yet been injected into its shell. */ pendingInitialCommand?: boolean; + /** Coalesces teardown callers and prevents callbacks from reviving a closing terminal. */ + destroying?: Promise; lastActivity: Date; lastOutputAt?: Date; outputGeneration: number; @@ -453,15 +455,15 @@ export class TerminalPanelManager { private sendInitialInputOnce(panelId: string): void { const terminal = this.terminals.get(panelId); - if (!terminal) return; + if (!terminal || terminal.destroying) return; this.markInitialInputSent(panelId).then((delivery) => { - if (!delivery || this.terminals.get(panelId) !== terminal) { + if (!delivery || this.terminals.get(panelId) !== terminal || terminal.destroying) { return; } this.writeInitialInput(panelId, delivery.input, delivery.submitStrategy); }).catch((error) => { - if (this.terminals.get(panelId) !== terminal) return; + if (this.terminals.get(panelId) !== terminal || terminal.destroying) return; console.warn(`[TerminalPanelManager] Failed to send initial input for panel ${panelId}:`, error); this.markInitialInputError(panelId, error instanceof Error ? error.message : String(error)).catch(() => {}); }); @@ -487,11 +489,11 @@ export class TerminalPanelManager { submitStrategy: NonNullable, ): void { const terminal = this.terminals.get(panelId); - if (!terminal) return; + if (!terminal || terminal.destroying) return; if (submitStrategy === 'codex-ctrl-enter') { this.writeToTerminal(panelId, input); setTimeout(() => { - if (this.terminals.get(panelId) !== terminal) return; + if (this.terminals.get(panelId) !== terminal || terminal.destroying) return; this.writeToTerminal(panelId, '\x1b[13;5u\r'); }, 500); return; @@ -1089,7 +1091,7 @@ export class TerminalPanelManager { }); } - if (this.terminals.get(panel.id) !== terminalProcess) return; + if (this.terminals.get(panel.id) !== terminalProcess || terminalProcess.destroying) return; // Detect the interactive prompt before injecting the command. // Previous approaches (fixed 500ms delay, then fire-on-any-data + 300ms) failed @@ -1098,7 +1100,7 @@ export class TerminalPanelManager { // so banner lines ending with % or > don't trigger a false positive. const panelId = panel.id; const injectCommand = () => { - if (this.terminals.get(panelId) !== terminalProcess) return; + if (this.terminals.get(panelId) !== terminalProcess || terminalProcess.destroying) return; terminalProcess.pendingInitialCommand = false; this.agentStatusMonitor.register(panelId, Date.now()); this.writeToTerminal(panelId, commandToRun! + '\r'); @@ -1110,7 +1112,7 @@ export class TerminalPanelManager { let onCliOutput: ReturnType | null = null; const signalCliReady = () => { - if (cliReadySignaled || this.terminals.get(panelId) !== terminalProcess) return; + if (cliReadySignaled || this.terminals.get(panelId) !== terminalProcess || terminalProcess.destroying) return; cliReadySignaled = true; if (onCliOutput) onCliOutput.dispose(); @@ -1148,7 +1150,7 @@ export class TerminalPanelManager { setTimeout(signalCliReady, 10000); } else if (initialInput) { setTimeout(() => { - if (this.terminals.get(panelId) === terminalProcess) this.sendInitialInputOnce(panelId); + if (this.terminals.get(panelId) === terminalProcess && !terminalProcess.destroying) this.sendInitialInputOnce(panelId); }, 1000); } }; @@ -1156,7 +1158,7 @@ export class TerminalPanelManager { this.scheduleAfterShellPrompt(ptyProcess, injectCommand); } else if (initialInput) { setTimeout(() => { - if (this.terminals.get(panel.id) === terminalProcess) this.sendInitialInputOnce(panel.id); + if (this.terminals.get(panel.id) === terminalProcess && !terminalProcess.destroying) this.sendInitialInputOnce(panel.id); }, 1000); } @@ -1185,7 +1187,7 @@ export class TerminalPanelManager { private setupTerminalHandlers(terminal: TerminalProcess): void { // Handle terminal output terminal.pty.onData((data: string) => { - if (this.terminals.get(terminal.panelId) !== terminal) return; + if (this.terminals.get(terminal.panelId) !== terminal || terminal.destroying) return; // Update last activity const outputAt = new Date(); terminal.lastActivity = outputAt; @@ -1288,7 +1290,7 @@ export class TerminalPanelManager { // Handle terminal exit terminal.pty.onExit((exitCode: { exitCode: number; signal?: number }) => { - if (this.terminals.get(terminal.panelId) !== terminal) return; + if (this.terminals.get(terminal.panelId) !== terminal || terminal.destroying) return; this.retireTerminal(terminal, exitCode); terminal.screenEmulator?.dispose(); @@ -1347,6 +1349,7 @@ export class TerminalPanelManager { return; } + if (terminal.destroying) return; try { terminal.pty.write(data); } catch (err) { @@ -1364,6 +1367,7 @@ export class TerminalPanelManager { options: { force?: boolean } = {}, ): Promise { const terminal = this.terminals.get(panelId); + if (terminal?.destroying) return; if (!terminal) { console.warn(`[TerminalPanelManager] Terminal ${panelId} not found for resize`); return; @@ -1400,14 +1404,14 @@ export class TerminalPanelManager { // Back-to-back TIOCSWINSZ calls can collapse into a single pending signal. await new Promise(resolve => setTimeout(resolve, FORCED_REDRAW_TRANSITION_MS)); } - if (this.terminals.get(panelId) !== terminal) return; + if (this.terminals.get(panelId) !== terminal || terminal.destroying) return; terminal.pty.resize(cols, rows); terminal.screenEmulator?.resize(cols, rows); if (options.force) { // Let the final application redraw reach our output batch before the // renderer removes its activation mask. await new Promise(resolve => setTimeout(resolve, FORCED_REDRAW_SETTLE_MS)); - if (this.terminals.get(panelId) !== terminal) return; + if (this.terminals.get(panelId) !== terminal || terminal.destroying) return; this.flushOutputBuffer(terminal); } } catch (err) { @@ -1438,9 +1442,6 @@ export class TerminalPanelManager { const panel = panelManager.getPanel(panelId); if (!panel) return; - await terminal.screenEmulator?.waitForIdle(); - if (this.terminals.get(panelId) !== terminal) return; - // Get current working directory (if possible) let cwd = (panel.state.customState && 'cwd' in panel.state.customState) ? panel.state.customState.cwd : undefined; cwd = cwd || process.cwd(); @@ -1457,6 +1458,8 @@ export class TerminalPanelManager { console.warn(`[TerminalPanelManager] Could not get CWD for terminal ${panelId}:`, error); } + // Cwd lookup can yield while more bytes arrive; drain immediately before serialization. + await terminal.screenEmulator?.waitForIdle(); if (this.terminals.get(panelId) !== terminal || panelManager.getPanel(panelId) !== panel) return; await this.persistTerminalState(terminal, panel, cwd); } @@ -1706,13 +1709,13 @@ export class TerminalPanelManager { this.agentStatusPolling = true; try { for (const terminal of this.terminals.values()) { - if (terminal.pendingInitialCommand || !this.agentStatusMonitor.isTracked(terminal.panelId)) continue; + if (terminal.destroying || terminal.pendingInitialCommand || !this.agentStatusMonitor.isTracked(terminal.panelId)) continue; const manifest = getManifestForAgent(terminal.agentType); const emulator = terminal.screenEmulator; if (!emulator) continue; await emulator.waitForIdle(); - if (this.terminals.get(terminal.panelId) !== terminal) continue; + if (this.terminals.get(terminal.panelId) !== terminal || terminal.destroying) continue; const detection = detectAgentState(manifest, { screen: emulator.getScreenText(), oscTitle: emulator.getOscTitle(), @@ -1731,21 +1734,25 @@ export class TerminalPanelManager { } } - destroyTerminal(panelId: string): void { + destroyTerminal(panelId: string): Promise { const terminal = this.terminals.get(panelId); - if (!terminal) { - return; - } + if (!terminal) return Promise.resolve(); + terminal.destroying ??= this.finishDestroyTerminal(terminal); + return terminal.destroying; + } - // Capture the current model before disposal. An asynchronous state read here - // could otherwise persist a disposed terminal over a replacement's state. - const panel = panelManager.getPanel(panelId); - if (panel) { - const cwd = terminalCustomState(panel.state).cwd || process.cwd(); - this.persistTerminalState(terminal, panel, cwd).catch((error) => { - console.error(`[TerminalPanelManager] Failed to save state for ${panelId}:`, error); - }); + private async finishDestroyTerminal(terminal: TerminalProcess): Promise { + const panelId = terminal.panelId; + // Stop detection as soon as teardown begins, so output during the save + // cannot announce completion. Keep the emulator alive until its writes drain. + this.agentStatusMonitor.unregister(panelId); + this.maybeStopAgentStatusPoll(); + try { + await this.saveTerminalState(panelId); + } catch (error) { + console.error(`[TerminalPanelManager] Failed to save state for ${panelId}:`, error); } + if (this.terminals.get(panelId) !== terminal) return; this.retireTerminal(terminal); terminal.screenEmulator?.dispose(); From 18714018bc1265ace0f553966e4ec36ea952eb31 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sun, 13 Sep 2026 00:44:23 -0600 Subject: [PATCH 12/20] Recognize all Claude circle spinner title frames --- main/src/services/agentStatus/manifests.test.ts | 2 +- main/src/services/agentStatus/manifests.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/main/src/services/agentStatus/manifests.test.ts b/main/src/services/agentStatus/manifests.test.ts index 5a0658f59..4ac3da63e 100644 --- a/main/src/services/agentStatus/manifests.test.ts +++ b/main/src/services/agentStatus/manifests.test.ts @@ -22,7 +22,7 @@ describe('getManifestForAgent', () => { }); describe('CLAUDE_MANIFEST', () => { - it.each(['◐', '◑', '⠙'])('recognizes Claude working title %s', (glyph) => { + it.each(['◐', '◑', '◒', '◓', '⠙'])('recognizes Claude working title %s', (glyph) => { expect(detectAgentState(CLAUDE_MANIFEST, screen('', `${glyph} Building`)).state).toBe('working'); }); diff --git a/main/src/services/agentStatus/manifests.ts b/main/src/services/agentStatus/manifests.ts index 990fbb007..e67c1964c 100644 --- a/main/src/services/agentStatus/manifests.ts +++ b/main/src/services/agentStatus/manifests.ts @@ -15,7 +15,7 @@ import type { AgentManifest } from './manifestEngine'; /** Observed working-title glyphs, including older braille animation. */ -const SPINNER_TITLE = /^[◐◑\u{2800}-\u{28FF}] /u; +const SPINNER_TITLE = /^[◐◑◒◓\u{2800}-\u{28FF}] /u; const CODEX_SPINNER = /(?:^| )[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏](?: |$)/u; export const CLAUDE_MANIFEST: AgentManifest = { From 73cb19e8dce47d6cb430f865cc516d315dbb1916 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sun, 13 Sep 2026 00:47:48 -0600 Subject: [PATCH 13/20] Cover delayed tool startup and document drained teardown --- .../terminalPanelManager.persistence.test.ts | 17 +++++++++++++++++ main/src/services/terminalStateEmulator.ts | 5 ++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/main/src/services/terminalPanelManager.persistence.test.ts b/main/src/services/terminalPanelManager.persistence.test.ts index 45cd11cd9..08769dbc4 100644 --- a/main/src/services/terminalPanelManager.persistence.test.ts +++ b/main/src/services/terminalPanelManager.persistence.test.ts @@ -169,6 +169,23 @@ describe('terminal panel persistence', () => { return { manager, handle: ptyHost.latest() }; } + it('keeps a tool panel unknown until its delayed command is injected', async () => { + const panel = makePanel('panel-delayed-command'); + panel.state.customState = { initialCommand: 'codex', agentType: 'codex', isCliPanel: true }; + const { manager, handle } = await startTerminal(panel); + // No shell prompt arrives. The command must wait for the five-second + // fallback, beyond the ordinary three-second monitor startup grace. + await new Promise(resolve => setTimeout(resolve, 3500)); + expect(handle.written.some(data => data.includes('codex'))).toBe(false); + expect(manager.getAgentStatus(panel.id)).toBeUndefined(); + expect(manager.getTerminalSnapshot(panel.id)?.activityStatus).toBe('active'); + await vi.waitFor(() => expect(handle.written.some(data => data.includes('codex'))).toBe(true), { timeout: 2500 }); + expect(manager.getAgentStatus(panel.id)).toBeUndefined(); + handle.emit('\x1b]2;Codex\x07'); + await vi.waitFor(() => expect(manager.getAgentStatus(panel.id)).toBe('idle'), { timeout: 1500 }); + expect(manager.getTerminalSnapshot(panel.id)?.activityStatus).toBe('idle'); + }, 10_000); + it('streams 50 MB of newline-free alternate-screen frames without growing the persisted state', async () => { const panel = makePanel('panel-frames'); const { manager, handle } = await startTerminal(panel); diff --git a/main/src/services/terminalStateEmulator.ts b/main/src/services/terminalStateEmulator.ts index 95d909b6d..be52040b9 100644 --- a/main/src/services/terminalStateEmulator.ts +++ b/main/src/services/terminalStateEmulator.ts @@ -127,9 +127,8 @@ export class TerminalStateEmulator { dispose(): void { if (this.disposed) return; this.finalIsAlternateScreen = this.isAlternateScreen; - // Capture WITH scrollback: destroyTerminal fires saveTerminalState without - // awaiting it, so the save usually reads this snapshot after disposal — a - // viewport-only capture would silently drop the session's history. + // Preserve scrollback for reads that resume after natural exit or shutdown. + // Explicit destruction drains and saves the model before disposal. this.finalSerializedBuffer = this.serializeForRestore(true); this.finalScreenText = this.getScreenText(); this.disposed = true; From 8c0237606475da0767fdd435934b8ff571e6e28c Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sun, 13 Sep 2026 01:15:41 -0600 Subject: [PATCH 14/20] Skip terminal snapshot persistence before panel deletion --- main/src/ipc/panels.status.test.ts | 20 ++++++++++++++++++- main/src/ipc/panels.ts | 2 +- .../terminalPanelManager.status.test.ts | 10 ++++++++++ main/src/services/terminalPanelManager.ts | 10 +++++----- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/main/src/ipc/panels.status.test.ts b/main/src/ipc/panels.status.test.ts index 042d82dcd..adf3b566c 100644 --- a/main/src/ipc/panels.status.test.ts +++ b/main/src/ipc/panels.status.test.ts @@ -10,7 +10,7 @@ import { terminalPanelManager } from '../services/terminalPanelManager'; import { registerPanelHandlers } from './panels'; function partial(value: Partial): Contract { - // SAFETY: Each fixture supplies every member read by the status snapshot handler. + // SAFETY: Each fixture supplies every member read by the tested handlers. return value as Contract; } const panel = (id: string, sessionId: string): ToolPanel => ({ @@ -46,3 +46,21 @@ describe('panel status snapshot IPC', () => { ] }); }); }); + +describe('panel deletion IPC', () => { + it('awaits terminal teardown without saving a snapshot before deleting the row', async () => { + vi.spyOn(panelManager, 'getPanel').mockReturnValue(panel('p', 's')); + let release: () => void = () => undefined; + const pending = new Promise(resolve => { release = resolve; }); + const destroy = vi.spyOn(terminalPanelManager, 'destroyTerminal').mockReturnValue(pending); + const remove = vi.spyOn(panelManager, 'deletePanel').mockResolvedValue(); + const registry = new PaneCommandRegistry(); + registerPanelHandlers(partial({ handle: vi.fn() }), partial({}), registry); + const deleting = registry.invoke('panels:delete', 'p'); + expect(destroy).toHaveBeenCalledWith('p', { saveState: false }); + expect(remove).not.toHaveBeenCalled(); + release(); + await expect(deleting).resolves.toEqual({ success: true }); + expect(remove).toHaveBeenCalledWith('p'); + }); +}); diff --git a/main/src/ipc/panels.ts b/main/src/ipc/panels.ts index 6fe2e49b3..9055ee520 100644 --- a/main/src/ipc/panels.ts +++ b/main/src/ipc/panels.ts @@ -419,7 +419,7 @@ export function registerPanelHandlers( // Clean up terminal process if it's a terminal panel const panel = panelManager.getPanel(panelId); if (panel?.type === 'terminal') { - await terminalPanelManager.destroyTerminal(panelId); + await terminalPanelManager.destroyTerminal(panelId, { saveState: false }); } await panelManager.deletePanel(panelId); diff --git a/main/src/services/terminalPanelManager.status.test.ts b/main/src/services/terminalPanelManager.status.test.ts index 30658f2ae..9ad18a4aa 100644 --- a/main/src/services/terminalPanelManager.status.test.ts +++ b/main/src/services/terminalPanelManager.status.test.ts @@ -145,6 +145,16 @@ describe('terminal status events', () => { expect(journal.readAfter(0).entries.filter(entry => entry.kind === 'panel.exited')).toHaveLength(2); }); + it('skips snapshot persistence when the panel is being deleted', async () => { + const fixture = attach('codex'); + const save = vi.spyOn(manager, 'saveTerminalState'); + await manager.destroyTerminal('p', { saveState: false }); + expect(save).not.toHaveBeenCalled(); + expect(panelManager.updatePanel).not.toHaveBeenCalled(); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + expect(manager.isTerminalInitialized('p')).toBe(false); + }); + it('drains pending output and saves the live cwd before destroying the emulator', async () => { const fixture = attach('codex'); panelManager.getPanel.mockReturnValue({ diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index 591352860..adede592f 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -1734,21 +1734,21 @@ export class TerminalPanelManager { } } - destroyTerminal(panelId: string): Promise { + destroyTerminal(panelId: string, options: { saveState?: boolean } = {}): Promise { const terminal = this.terminals.get(panelId); if (!terminal) return Promise.resolve(); - terminal.destroying ??= this.finishDestroyTerminal(terminal); + terminal.destroying ??= this.finishDestroyTerminal(terminal, options.saveState !== false); return terminal.destroying; } - private async finishDestroyTerminal(terminal: TerminalProcess): Promise { + private async finishDestroyTerminal(terminal: TerminalProcess, saveState: boolean): Promise { const panelId = terminal.panelId; // Stop detection as soon as teardown begins, so output during the save - // cannot announce completion. Keep the emulator alive until its writes drain. + // cannot announce completion. Keep the emulator alive through any snapshot save. this.agentStatusMonitor.unregister(panelId); this.maybeStopAgentStatusPoll(); try { - await this.saveTerminalState(panelId); + if (saveState) await this.saveTerminalState(panelId); } catch (error) { console.error(`[TerminalPanelManager] Failed to save state for ${panelId}:`, error); } From 64c3dad72f130062818f287319838a4f86ec83fc Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sun, 13 Sep 2026 01:17:22 -0600 Subject: [PATCH 15/20] Preserve natural exit details during terminal teardown --- .../terminalPanelManager.status.test.ts | 20 ++++++++++++++----- main/src/services/terminalPanelManager.ts | 11 ++++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/main/src/services/terminalPanelManager.status.test.ts b/main/src/services/terminalPanelManager.status.test.ts index 9ad18a4aa..e43eeeedd 100644 --- a/main/src/services/terminalPanelManager.status.test.ts +++ b/main/src/services/terminalPanelManager.status.test.ts @@ -28,7 +28,7 @@ function createTerminal(agentType: 'claude' | 'codex' | undefined = 'codex') { isVisible: true, isAlternateScreen: false, inSyncBlock: false, filterInAltScreen: false, agentSessionScrapeBuffer: '', }; - return { terminal, data: (data: string) => onData(data), exit: () => onExit({ exitCode: 0 }) }; + return { terminal, data: (data: string) => onData(data), exit: (exit: { exitCode: number; signal?: number } = { exitCode: 0 }) => onExit(exit) }; } type TerminalFixture = ReturnType; @@ -155,7 +155,11 @@ describe('terminal status events', () => { expect(manager.isTerminalInitialized('p')).toBe(false); }); - it('drains pending output and saves the live cwd before destroying the emulator', async () => { + it.each([ + { label: 'live PTY', exit: undefined }, + { label: 'successful exit', exit: { exitCode: 0 } }, + { label: 'signaled exit', exit: { exitCode: 17, signal: 9 } }, + ])('drains and saves before disposal, preserving an exit during teardown ($label)', async ({ exit }) => { const fixture = attach('codex'); panelManager.getPanel.mockReturnValue({ id: 'p', sessionId: 's', type: 'terminal', title: 'Codex', @@ -168,7 +172,7 @@ describe('terminal status events', () => { expect(fixture.terminal.pty.kill).not.toHaveBeenCalled(); expect(manager.destroyTerminal('p')).toBe(destroying); // Closing callbacks cannot dispose the model before queued writes drain. - fixture.exit(); + if (exit) fixture.exit(exit); fixture.data('output after teardown began'); manager.writeToTerminal('p', 'late input'); expect(fixture.terminal.pty.write).not.toHaveBeenCalled(); @@ -176,8 +180,14 @@ describe('terminal status events', () => { expect(panelManager.updatePanel).toHaveBeenCalledWith('p', { state: expect.objectContaining({ customState: expect.objectContaining({ cwd: '/live', scrollbackBuffer: expect.stringContaining('last output before archive') }), }) }); - expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); - expect(journal.readAfter(0).entries.map(entry => entry.kind)).toEqual(['panel.exited']); + if (exit) expect(fixture.terminal.pty.kill).not.toHaveBeenCalled(); + else expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + expect(journal.readAfter(0).entries).toMatchObject([{ + kind: 'panel.exited', exitCode: exit?.exitCode, + reason: exit?.signal === undefined ? 'terminal:exit' : `signal:${exit.signal}`, + }]); + fixture.exit(); + expect(journal.readAfter(0).entries).toHaveLength(1); }); it.each(['cwd read', 'emulator drain'])('does not persist or retire a replacement during teardown %s', async phase => { diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index adede592f..920e10243 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -198,6 +198,7 @@ interface TerminalProcess { pendingInitialCommand?: boolean; /** Coalesces teardown callers and prevents callbacks from reviving a closing terminal. */ destroying?: Promise; + exitDuringDestroy?: { exitCode: number; signal?: number }; lastActivity: Date; lastOutputAt?: Date; outputGeneration: number; @@ -1290,7 +1291,12 @@ export class TerminalPanelManager { // Handle terminal exit terminal.pty.onExit((exitCode: { exitCode: number; signal?: number }) => { - if (this.terminals.get(terminal.panelId) !== terminal || terminal.destroying) return; + if (this.terminals.get(terminal.panelId) !== terminal) return; + if (terminal.destroying) { + // The save still owns the emulator; retain exit details until it drains. + terminal.exitDuringDestroy ??= exitCode; + return; + } this.retireTerminal(terminal, exitCode); terminal.screenEmulator?.dispose(); @@ -1754,8 +1760,9 @@ export class TerminalPanelManager { } if (this.terminals.get(panelId) !== terminal) return; - this.retireTerminal(terminal); + this.retireTerminal(terminal, terminal.exitDuringDestroy); terminal.screenEmulator?.dispose(); + if (terminal.exitDuringDestroy) return; // Kill the PTY process try { From cd520028b06f4bed50a8b06aceb535084989b0ff Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sun, 13 Sep 2026 13:31:16 -0600 Subject: [PATCH 16/20] Keep shell terminals docked and share the empty pane launcher --- CHANGELOG.md | 4 + frontend/src/components/ProjectView.tsx | 175 ++++++----- frontend/src/components/SessionView.tsx | 180 +++--------- .../src/components/panels/EmptyPanelStage.tsx | 58 ++++ .../src/components/panels/TerminalDock.tsx | 87 ++++++ frontend/src/utils/terminalDock.test.ts | 37 +++ frontend/src/utils/terminalDock.ts | 12 + tests/adaptive-panel-layout.spec.ts | 4 +- tests/electronApiMock.ts | 40 ++- tests/terminal-dock.spec.ts | 272 ++++++++++++++++++ tests/terminal-selection-popover.spec.ts | 1 + 11 files changed, 657 insertions(+), 213 deletions(-) create mode 100644 frontend/src/components/panels/EmptyPanelStage.tsx create mode 100644 frontend/src/components/panels/TerminalDock.tsx create mode 100644 frontend/src/utils/terminalDock.test.ts create mode 100644 frontend/src/utils/terminalDock.ts create mode 100644 tests/terminal-dock.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a8b0914..233f27c89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ All notable changes to Pane will be documented in this file. ### Changed - Custom-command keyboard shortcuts moved from `mod+alt+5..9` to `mod+alt+6..9` to make room for the Cursor slot. - Cursor Agent is now available inside WSL repositories. +- Repository main panes now show the default shell in the bottom dock. The dock defaults to expanded in both main and worktree panes, while preserving saved collapse preferences. + +### Fixed +- Agent terminals remain in working tabs after deleting the default shell and switching panes. Closing the dock also repairs tab selection when another shell takes its place. ## [1.1.123] - 2026-04-25 diff --git a/frontend/src/components/ProjectView.tsx b/frontend/src/components/ProjectView.tsx index 7b7c59d28..3f1fd8d33 100644 --- a/frontend/src/components/ProjectView.tsx +++ b/frontend/src/components/ProjectView.tsx @@ -4,6 +4,9 @@ import { useSessionStore } from '../stores/sessionStore'; import type { Session } from '../types/session'; import { PanelTabBar } from './panels/PanelTabBar'; import { PanelContainer } from './panels/PanelContainer'; +import { TerminalDock } from './panels/TerminalDock'; +import { EmptyPanelStage } from './panels/EmptyPanelStage'; +import { getDockTerminalPanel } from '../utils/terminalDock'; import { usePanelStore } from '../stores/panelStore'; import { panelApi } from '../services/panelApi'; import type { ToolPanel, ToolPanelType } from '../../../shared/types/panels'; @@ -28,6 +31,8 @@ interface ProjectViewProps { projectEnvironment: ProjectEnvironment | undefined; configuredIDECommand?: string | null; onConfigureIDE: () => void; + isTerminalCollapsed: boolean; + onToggleTerminal: () => void; } export const ProjectView: React.FC = ({ @@ -36,9 +41,12 @@ export const ProjectView: React.FC = ({ projectEnvironment, configuredIDECommand, onConfigureIDE, + isTerminalCollapsed, + onToggleTerminal, }) => { const [mainRepoSessionId, setMainRepoSessionId] = useState(null); const [mainRepoSession, setMainRepoSession] = useState(null); + const [restoredPanelSessionId, setRestoredPanelSessionId] = useState(null); const [branchState, setBranchState] = useState<{ projectId: number; worktreePath: string | null; @@ -89,6 +97,7 @@ export const ProjectView: React.FC = ({ const immersiveMode = useNavigationStore(s => s.immersiveMode); const projectContentBox = useObservedContentBox(); + const centerColumnBox = useObservedContentBox(); const detailResize = useOuterPanelResize({ config: OUTER_PANEL_CONFIGS.projectInspector, containerPx: projectContentBox.width, @@ -97,23 +106,30 @@ export const ProjectView: React.FC = ({ // Load panels when main repo session changes (no auto-creation, matches worktree session behavior) useEffect(() => { + setRestoredPanelSessionId(null); + let cancelled = false; if (mainRepoSessionId) { panelApi.loadPanelsForSession(mainRepoSessionId).then(async (loadedPanels) => { + if (cancelled) return; setPanels(mainRepoSessionId, loadedPanels); // Pick default active: the first working panel (Explorer and Review // live in the inspector, not the stage). - const fallback = loadedPanels.find(p => p.type !== 'diff' && p.type !== 'explorer'); + const dock = getDockTerminalPanel(loadedPanels); + const fallback = loadedPanels.find(p => p.type !== 'diff' && p.type !== 'explorer' && p.id !== dock?.id); const activePanel = await panelApi.getActivePanel(mainRepoSessionId); + if (cancelled) return; if (activePanel) { setActivePanelInStore(mainRepoSessionId, activePanel.id); } else if (fallback) { setActivePanelInStore(mainRepoSessionId, fallback.id); await panelApi.setActivePanel(mainRepoSessionId, fallback.id); } + if (!cancelled) setRestoredPanelSessionId(mainRepoSessionId); }); } + return () => { cancelled = true; }; }, [mainRepoSessionId, setPanels, setActivePanelInStore]); // Get panels for current main repo session @@ -124,9 +140,10 @@ export const ProjectView: React.FC = ({ const filesPanel = useMemo(() => sessionPanels.find(p => p.type === 'explorer'), [sessionPanels]); const changesPanel = useMemo(() => sessionPanels.find(p => p.type === 'diff'), [sessionPanels]); + const defaultTerminalPanel = useMemo(() => getDockTerminalPanel(sessionPanels), [sessionPanels]); const workingPanels = useMemo( - () => sessionPanels.filter(p => p.type !== 'explorer' && p.type !== 'diff'), - [sessionPanels] + () => sessionPanels.filter(p => p.type !== 'explorer' && p.type !== 'diff' && p.id !== defaultTerminalPanel?.id), + [sessionPanels, defaultTerminalPanel] ); const currentActivePanel = useMemo( @@ -134,24 +151,28 @@ export const ProjectView: React.FC = ({ [workingPanels, activePanels, mainRepoSessionId] ); - // A persisted active panel that now lives in the inspector opens that tab - // and hands the stage to the first working panel. + // Keep the stage selection and persisted active panel in agreement when + // the active panel moves into the inspector or dock, or is deleted. const staleActiveHandledRef = useRef(null); useEffect(() => { - if (!mainRepoSessionId) return; + // A missing selection during loading must not overwrite the saved tab. + if (!mainRepoSessionId || restoredPanelSessionId !== mainRepoSessionId) return; const activeId = activePanels[mainRepoSessionId]; const stale = activeId ? sessionPanels.find(p => p.id === activeId && (p.type === 'explorer' || p.type === 'diff')) : undefined; - if (!stale) return; - const key = `${mainRepoSessionId}:${stale.id}`; - if (staleActiveHandledRef.current === key) return; - staleActiveHandledRef.current = key; - setInspectorTab(stale.type === 'diff' ? 'changes' : 'files'); + if (stale) { + const key = `${mainRepoSessionId}:${stale.id}`; + if (staleActiveHandledRef.current !== key) { + staleActiveHandledRef.current = key; + setInspectorTab(stale.type === 'diff' ? 'changes' : 'files'); + } + } + if (workingPanels.some(p => p.id === activeId)) return; const next = workingPanels[0]; if (next) { setActivePanelInStore(mainRepoSessionId, next.id); void panelApi.setActivePanel(mainRepoSessionId, next.id); } - }, [mainRepoSessionId, activePanels, sessionPanels, workingPanels, setActivePanelInStore]); + }, [mainRepoSessionId, restoredPanelSessionId, activePanels, sessionPanels, workingPanels, setActivePanelInStore]); const detailSession = useMemo(() => { if (!activeMainRepoSession || !displayBranch) return activeMainRepoSession; @@ -199,15 +220,21 @@ export const ProjectView: React.FC = ({ async (panel: ToolPanel) => { if (!mainRepoSessionId) return; - // Activate the neighbouring working tab (never an inspector panel). - const panelIndex = workingPanels.findIndex(p => p.id === panel.id); - const nextPanel = workingPanels[panelIndex + 1] || workingPanels[panelIndex - 1]; - // Remove from store first for immediate UI update removePanel(mainRepoSessionId, panel.id); + // The dock isn't in workingPanels, and closing it may promote another + // shell. Choose from the tabs that remain after that promotion. + const remaining = usePanelStore.getState().panels[mainRepoSessionId] || []; + const dock = getDockTerminalPanel(remaining); + const remainingTabs = remaining.filter(p => p.type !== 'explorer' && p.type !== 'diff' && p.id !== dock?.id); + const panelIndex = workingPanels.findIndex(p => p.id === panel.id); + const nextPanel = panelIndex === -1 + ? remainingTabs[0] + : remainingTabs[Math.min(panelIndex, remainingTabs.length - 1)]; + // Set next active panel if available - if (nextPanel) { + if (nextPanel && activePanels[mainRepoSessionId] === panel.id) { setActivePanelInStore(mainRepoSessionId, nextPanel.id); await panelApi.setActivePanel(mainRepoSessionId, nextPanel.id); } @@ -215,7 +242,7 @@ export const ProjectView: React.FC = ({ // Delete on backend await panelApi.deletePanel(panel.id); }, - [mainRepoSessionId, workingPanels, removePanel, setActivePanelInStore] + [mainRepoSessionId, workingPanels, activePanels, removePanel, setActivePanelInStore] ); const handlePanelCreate = useCallback( @@ -243,9 +270,12 @@ export const ProjectView: React.FC = ({ // The panel:created event will also fire, but addPanel checks for duplicates addPanel(newPanel); setActivePanelInStore(mainRepoSessionId, newPanel.id); + if (isTerminalCollapsed && getDockTerminalPanel(usePanelStore.getState().panels[mainRepoSessionId] || [])?.id === newPanel.id) { + onToggleTerminal(); + } return newPanel; }, - [mainRepoSessionId, addPanel, setActivePanelInStore] + [mainRepoSessionId, addPanel, setActivePanelInStore, isTerminalCollapsed, onToggleTerminal] ); const handleOpenUrlInBrowser = useCallback(async (url: string, title: string) => { @@ -423,59 +453,64 @@ export const ProjectView: React.FC = ({ {/* Content area: center panels + right detail */}
- {/* Center: panel content */} -
- {isLoadingSession ? ( -
-
-
-
-
-
+
+ {/* Center: panel content */} +
+ {isLoadingSession ? ( +
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- ) : workingPanels.length > 0 && currentActivePanel ? ( - workingPanels.map(panel => { - const isActive = panel.id === currentActivePanel.id; - return ( -
- +
+
+
+
+
+
- ); - }) - ) : ( -
- -
+
+ ) : workingPanels.length > 0 && currentActivePanel ? ( + workingPanels.map(panel => { + const isActive = panel.id === currentActivePanel.id; + return ( +
+ +
+ ); + }) + ) : ( + + )} +
+ {defaultTerminalPanel && ( +
diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index bf9a78dcb..e18c92f60 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -28,6 +28,9 @@ import { panelApi } from '../services/panelApi'; import { setPendingViewCommit } from './panels/diff/pendingViewCommit'; import { PanelTabBar } from './panels/PanelTabBar'; import { PanelContainer } from './panels/PanelContainer'; +import { TerminalDock } from './panels/TerminalDock'; +import { EmptyPanelStage } from './panels/EmptyPanelStage'; +import { getDockTerminalPanel } from '../utils/terminalDock'; import { SplitLayout } from './panels/SplitLayout'; import { SessionProvider } from '../contexts/SessionContext'; import { ToolPanel, ToolPanelType, PANEL_CAPABILITIES, SessionPanelLayout, PanelGroupNode } from '../../../shared/types/panels'; @@ -50,15 +53,12 @@ import { mergeAllGroups, type DropZone, } from '../utils/panelLayout'; -import { Download, Upload, GitMerge, GitPullRequestArrow, Terminal, ChevronDown, ChevronUp, RefreshCw, Archive, ArchiveRestore, GitCommitHorizontal, TerminalSquare, Undo2 } from 'lucide-react'; -import { getCliBrandIcon } from './ui/brandIconRegistry'; +import { Download, Upload, GitMerge, GitPullRequestArrow, Terminal, RefreshCw, Archive, ArchiveRestore, GitCommitHorizontal, Undo2 } from 'lucide-react'; import { visibleAgentPresets } from '../utils/agentPresets'; import type { Project } from '../types/project'; import { devLog, renderLog } from '../utils/console'; import { useConfigStore } from '../stores/configStore'; import { cycleIndex } from '../utils/arrayUtils'; -import { formatKeyDisplay } from '../utils/hotkeyUtils'; -import { Kbd } from './ui/Kbd'; import type { InspectorTab } from './InspectorTabs'; import { useErrorStore } from '../stores/errorStore'; import ProjectSettings from './ProjectSettings'; @@ -176,6 +176,13 @@ export const SessionView = memo(() => { // repaired centrally instead of in each caller. A collapse that removed the // focused group falls back to the primary group; a dead zoom target clears. const applyLayout = useCallback((sessionId: string, next: SessionPanelLayout) => { + // Closing the dock can promote a working shell. Remove its old tab before + // repairing focus/zoom and persisting, for both local and backend deletes. + const dock = getDockTerminalPanel(usePanelStore.getState().panels[sessionId] || []); + if (dock && findGroupContainingPanel(next.root, dock.id)) { + const root = removePanelFromLayout(next.root, dock.id); + next = root ? { ...next, root } : createSingleGroupLayout([], null); + } let focusedGid = next.focusedGroupId; if (!focusedGid || !findGroup(next.root, focusedGid)) { focusedGid = primaryGroup(next.root).id; @@ -260,10 +267,10 @@ export const SessionView = memo(() => { } // --- Layout load + reconcile --- - // The pinned terminal (first terminal) is excluded from the layout tree + // The dock shell is excluded from the layout tree // and so are the inspector panels (Explorer / Review), which never // sit on the stage — otherwise a close could hand the group to one. - const pinned = loadedPanels.find(p => p.type === 'terminal'); + const pinned = getDockTerminalPanel(loadedPanels); const livePanels = loadedPanels.filter(p => p.id !== pinned?.id && !isInspectorPanelType(p.type)); // Sort for initial layout creation (explorer first, diff second, then position) @@ -285,7 +292,7 @@ export const SessionView = memo(() => { // but not in the loadedPanels snapshot. Reconciling against the // current store adopts them as orphans instead of dropping them. const nowPanels = usePanelStore.getState().panels[sid] || []; - const pinnedNow = nowPanels.find(p => p.type === 'terminal'); + const pinnedNow = getDockTerminalPanel(nowPanels); const liveIdsNow: string[] = []; for (const p of nowPanels) { if (p.id !== pinnedNow?.id && !isInspectorPanelType(p.type)) liveIdsNow.push(p.id); @@ -333,10 +340,10 @@ export const SessionView = memo(() => { if (!panelExists) { addPanel(panel); - // The pinned terminal (first terminal in the session) never enters + // The dock shell never enters // the layout tree const sessionPanelsList = usePanelStore.getState().panels[sid] || []; - const pinnedTerminal = sessionPanelsList.find(p => p.type === 'terminal'); + const pinnedTerminal = getDockTerminalPanel(sessionPanelsList); if (pinnedTerminal && panel.id === pinnedTerminal.id) { return; } @@ -403,9 +410,9 @@ export const SessionView = memo(() => { [panels, activeSession?.id] ); - // Bottom terminal panel (first terminal panel in session) + // The bottom dock owns a plain shell, never an agent or command panel. const defaultTerminalPanel = useMemo( - () => sessionPanels.find(p => p.type === 'terminal'), + () => getDockTerminalPanel(sessionPanels), [sessionPanels] ); @@ -979,12 +986,6 @@ export const SessionView = memo(() => { }; } - // Captured BEFORE the create: if the session has no terminal yet, the - // panel we are about to create becomes the pinned dock terminal and - // must never enter the layout tree. - const hadTerminalBefore = (usePanelStore.getState().panels[sid] || []) - .some(p => p.type === 'terminal'); - const newPanel = await panelApi.createPanel({ sessionId: sid, type, @@ -996,8 +997,11 @@ export const SessionView = memo(() => { addPanel(newPanel); setActivePanelInStore(sid, newPanel.id); - const becomesPinnedTerminal = type === 'terminal' && !hadTerminalBefore; - if (becomesPinnedTerminal) return newPanel; + const dock = getDockTerminalPanel(usePanelStore.getState().panels[sid] || []); + if (dock?.id === newPanel.id) { + setIsTerminalCollapsed(false); + return newPanel; + } // Add to layout (into the focused group, falling back to the primary // group if focus is stale). addPanelToGroup is idempotent, so racing @@ -1183,49 +1187,9 @@ export const SessionView = memo(() => { setDropZones(new Map()); }, [primaryGroupId, primaryGroupNode, activeSession, isSplitLayout, topBarPanels, applyLayout]); - const hotkeys = useHotkeyStore((s) => s.hotkeys); - const hotkeyDisplay = useCallback((id: string) => { - const keys = hotkeys.get(id)?.keys; - return keys ? formatKeyDisplay(keys) : null; - }, [hotkeys]); - - // The empty stage is the "+" menu laid out inline: one click (or the - // shortcut beside it) from a running tool, instead of a placeholder. const emptyStage = useMemo(() => ( -
-
-
Open
- {[ - { key: 'terminal', label: 'Terminal', icon: , hotkeyId: 'add-tool-terminal', onClick: () => handlePanelCreate('terminal') }, - ...agentPresets.map(preset => ({ - key: preset.id, - label: preset.title, - icon: getCliBrandIcon(preset.iconKey, 'h-3.5 w-3.5'), - hotkeyId: preset.hotkeyId, - onClick: () => handlePanelCreate('terminal', { initialCommand: preset.command, title: preset.title }), - })), - ...customCommands.map((cmd, index) => ({ - key: `custom-${index}`, - label: cmd.name, - icon: getCliBrandIcon(cmd.command, 'h-3.5 w-3.5') || , - hotkeyId: `add-tool-custom-${index}`, - onClick: () => handlePanelCreate('terminal', { initialCommand: cmd.command, title: cmd.name }), - })), - ].map(item => ( - - ))} -
-
- ), [agentPresets, customCommands, handlePanelCreate, hotkeyDisplay]); + + ), [activeProjectEnvironment, handlePanelCreate]); // --- Editor stage element (shared by both layouts) --- const editorStageElement = useMemo(() => { @@ -1498,7 +1462,7 @@ export const SessionView = memo(() => { // Unless the user has explicitly closed it previously const hasTriedCreatingTerminal = useRef(false); useEffect(() => { - if (!activeSession?.id || defaultTerminalPanel || hasTriedCreatingTerminal.current) return; + if (!activeSession?.id || sessionPanels.some(p => p.type === 'terminal') || hasTriedCreatingTerminal.current) return; // Only attempt once per session to avoid loops hasTriedCreatingTerminal.current = true; @@ -1517,7 +1481,7 @@ export const SessionView = memo(() => { console.error('[SessionView] Failed to auto-create terminal panel:', err); }); }); - }, [activeSession?.id, defaultTerminalPanel, addPanel]); + }, [activeSession?.id, sessionPanels, addPanel]); // Reset the flag when session changes useEffect(() => { @@ -1540,10 +1504,10 @@ export const SessionView = memo(() => { } }, [immersiveMode, swappedLayoutRendered, toggleDetailCollapse]); - // Terminal collapse state with localStorage persistence (collapsed by default) + // Share the dock preference across project and session views; expand on first use. const [isTerminalCollapsed, setIsTerminalCollapsed] = useState(() => { const stored = localStorage.getItem('pane-terminal-collapsed'); - return stored === null ? true : stored === 'true'; + return stored === 'true'; }); useEffect(() => { @@ -1561,18 +1525,6 @@ export const SessionView = memo(() => { containerPx: sessionContentBox.width, enabled: !swappedLayoutRendered && detailVisible && !immersiveMode, }); - const terminalResize = useOuterPanelResize({ - config: OUTER_PANEL_CONFIGS.bottomTerminal, - containerPx: centerColumnBox.height, - enabled: Boolean(defaultTerminalPanel) && !swappedLayoutRendered && !isTerminalCollapsed && !immersiveMode, - }); - const terminalDockHeight = immersiveMode - ? terminalResize.renderedPx - : isTerminalCollapsed - ? Math.min(32, centerColumnBox.height) - : terminalResize.renderedPx; - // A zero-height dock is invisible: its chrome must not stay reachable. - const terminalDockContentActive = terminalDockHeight > 0; const rightTerminalResize = useOuterPanelResize({ config: OUTER_PANEL_CONFIGS.rightTerminal, containerPx: sessionContentBox.width, @@ -1782,6 +1734,8 @@ export const SessionView = memo(() => { projectEnvironment={projectData.environment} configuredIDECommand={projectData.open_ide_command} onConfigureIDE={() => setShowProjectSettings(true)} + isTerminalCollapsed={isTerminalCollapsed} + onToggleTerminal={toggleTerminalCollapse} /> { {editorStageElement || emptyStage}
- {/* Bottom: persistent terminal (collapsible) */} {defaultTerminalPanel && ( -
0) - ? 'border-t border-border-primary' - : '' - }`} - style={{ height: `${terminalDockHeight}px` }} - > - {terminalResize.separatorVisible && ( - - )} -
- {/* Terminal tab header with collapse toggle and pill shortcuts */} -
- {/* Left: chevron + icon + label */} - - - Terminal - -
-
- {/* Terminal content (hidden when collapsed) */} - {!isTerminalCollapsed && ( -
- -
- )} -
-
+
diff --git a/frontend/src/components/panels/EmptyPanelStage.tsx b/frontend/src/components/panels/EmptyPanelStage.tsx new file mode 100644 index 000000000..67c337c0c --- /dev/null +++ b/frontend/src/components/panels/EmptyPanelStage.tsx @@ -0,0 +1,58 @@ +import { Terminal, TerminalSquare } from 'lucide-react'; +import type { PanelTabBarProps } from '../../types/panelComponents'; +import { useConfigStore } from '../../stores/configStore'; +import { useHotkeyStore } from '../../stores/hotkeyStore'; +import { visibleAgentPresets } from '../../utils/agentPresets'; +import { formatKeyDisplay } from '../../utils/hotkeyUtils'; +import { getCliBrandIcon } from '../ui/brandIconRegistry'; +import { Kbd } from '../ui/Kbd'; + +type EmptyPanelStageProps = Pick; + +// The empty stage is the "+" menu laid out inline, shared by repo and worktree views. +export function EmptyPanelStage({ projectEnvironment, onPanelCreate }: EmptyPanelStageProps) { + const config = useConfigStore((s) => s.config); + const customCommands = (config?.customCommands ?? []).filter(cmd => cmd?.name && cmd?.command); + const agentPresets = visibleAgentPresets(projectEnvironment); + const hotkeys = useHotkeyStore((s) => s.hotkeys); + const hotkeyDisplay = (id: string) => { + const keys = hotkeys.get(id)?.keys; + return keys ? formatKeyDisplay(keys) : null; + }; + + return ( +
+
+
Open
+ {[ + { key: 'terminal', label: 'Terminal', icon: , hotkeyId: 'add-tool-terminal', onClick: () => onPanelCreate('terminal') }, + ...agentPresets.map(preset => ({ + key: preset.id, + label: preset.title, + icon: getCliBrandIcon(preset.iconKey, 'h-3.5 w-3.5'), + hotkeyId: preset.hotkeyId, + onClick: () => onPanelCreate('terminal', { initialCommand: preset.command, title: preset.title }), + })), + ...customCommands.map((cmd, index) => ({ + key: `custom-${index}`, + label: cmd.name, + icon: getCliBrandIcon(cmd.command, 'h-3.5 w-3.5') || , + hotkeyId: `add-tool-custom-${index}`, + onClick: () => onPanelCreate('terminal', { initialCommand: cmd.command, title: cmd.name }), + })), + ].map(item => ( + + ))} +
+
+ ); +} diff --git a/frontend/src/components/panels/TerminalDock.tsx b/frontend/src/components/panels/TerminalDock.tsx new file mode 100644 index 000000000..decf75a82 --- /dev/null +++ b/frontend/src/components/panels/TerminalDock.tsx @@ -0,0 +1,87 @@ +import { ChevronDown, ChevronUp, Terminal, X } from 'lucide-react'; +import type { ToolPanel } from '../../../../shared/types/panels'; +import { useOuterPanelResize } from '../../hooks/useOuterPanelResize'; +import { OUTER_PANEL_CONFIGS } from '../../utils/outerPanelSizing'; +import { OuterResizeSeparator } from '../ui/OuterResizeSeparator'; +import { PanelContainer } from './PanelContainer'; + +interface TerminalDockProps { + panel: ToolPanel; + availableHeight: number; + collapsed: boolean; + hidden: boolean; + onToggle: () => void; + onClose: () => void; + isMainRepo: boolean; +} + +export function TerminalDock({ panel, availableHeight, collapsed, hidden, onToggle, onClose, isMainRepo }: TerminalDockProps) { + const resize = useOuterPanelResize({ + config: OUTER_PANEL_CONFIGS.bottomTerminal, + containerPx: availableHeight, + enabled: !collapsed && !hidden, + }); + const height = hidden ? 0 : collapsed ? Math.min(32, availableHeight) : resize.renderedPx; + const contentActive = height > 0; + + return ( +
0) ? 'border-t border-border-primary' : '' + }`} + style={{ height: `${height}px` }} + > + {resize.separatorVisible && ( + + )} +
+
+ + + Terminal +
+ {!panel.metadata.permanent && ( + + )} +
+ {!collapsed && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/utils/terminalDock.test.ts b/frontend/src/utils/terminalDock.test.ts new file mode 100644 index 000000000..fe663a424 --- /dev/null +++ b/frontend/src/utils/terminalDock.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import type { TerminalPanelState, ToolPanel } from '../../../shared/types/panels'; +import { getDockTerminalPanel } from './terminalDock'; + +const terminal = (id: string, customState: TerminalPanelState = {}): ToolPanel => ({ + id, sessionId: 'session', type: 'terminal', title: id, + state: { isActive: false, customState }, + metadata: { createdAt: '', lastActiveAt: '', position: 0 }, +}); + +describe('getDockTerminalPanel', () => { + it('keeps the first plain shell in the dock and leaves additional shells as tabs', () => { + const shell = terminal('shell'); + expect(getDockTerminalPanel([shell, terminal('extra')])).toBe(shell); + }); + + it.each([ + { initialCommand: 'codex --yolo' }, + { initialCommand: 'claude --dangerously-skip-permissions' }, + { initialCommand: 'pnpm dev' }, + { agentType: 'codex' as const }, + { agentType: 'claude' as const }, + { isCliPanel: true }, + ])('never promotes a tool after deleting its shell: %j', state => { + const agent = terminal('agent', state); + const shell = terminal('shell'); + expect(getDockTerminalPanel([agent, shell])).toBe(shell); + expect(getDockTerminalPanel([agent])).toBeUndefined(); + }); + + it('ignores inspector panels and supports legacy shells without custom state', () => { + const shell = { ...terminal('shell'), state: { isActive: false } }; + const inspector: ToolPanel = { ...terminal('files'), type: 'explorer' }; + expect(getDockTerminalPanel([inspector, shell])).toBe(shell); + expect(getDockTerminalPanel([])).toBeUndefined(); + }); +}); diff --git a/frontend/src/utils/terminalDock.ts b/frontend/src/utils/terminalDock.ts new file mode 100644 index 000000000..bc1a49df8 --- /dev/null +++ b/frontend/src/utils/terminalDock.ts @@ -0,0 +1,12 @@ +import type { TerminalPanelState, ToolPanel } from '../../../shared/types/panels'; + +/** Only a plain shell belongs in the dock; launched tools stay in working tabs. */ +export function getDockTerminalPanel(panels: readonly ToolPanel[]): ToolPanel | undefined { + return panels.find(panel => { + if (panel.type !== 'terminal') return false; + // SAFETY: The terminal discriminator determines the custom-state shape. + const state = panel.state.customState as TerminalPanelState | undefined; + // initialCommand is available before the process reports its agent metadata. + return !state?.initialCommand?.trim() && !state?.isCliPanel && !state?.agentType; + }); +} diff --git a/tests/adaptive-panel-layout.spec.ts b/tests/adaptive-panel-layout.spec.ts index 4810e5c31..67d7f8753 100644 --- a/tests/adaptive-panel-layout.spec.ts +++ b/tests/adaptive-panel-layout.spec.ts @@ -97,7 +97,7 @@ async function setImmersiveMode(page: Page, immersive: boolean): Promise { } test('all worktree surfaces use container bounds, accessible resizing, and durable intent', async ({ page }, testInfo) => { - await installFixture(page); + await installFixture(page, { 'pane-terminal-collapsed': 'true' }); await openWorktree(page); const inspector = page.locator('.pane-detail-panel-vertical'); @@ -779,7 +779,7 @@ test('a terminal arriving after the default branch renders switches the branch a }); test('an observed active terminal resize debounces through the existing xterm refit path', async ({ page }) => { - await installFixture(page); + await installFixture(page, { 'pane-terminal-collapsed': 'true' }); await openWorktree(page); await page.evaluate(() => { diff --git a/tests/electronApiMock.ts b/tests/electronApiMock.ts index 5658d1014..42b8b103a 100644 --- a/tests/electronApiMock.ts +++ b/tests/electronApiMock.ts @@ -244,6 +244,16 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc let mockProjects = clone(mockOptions.initialProjects ?? []); let mockSessions = clone(mockOptions.initialSessions ?? []); let mockPanels = clone(mockOptions.initialPanels ?? []); + const mockLayouts = new Map(); + const setActiveMockPanel = (sessionId: string, panelId: string | null) => { + for (const panel of mockPanels) { + // SAFETY: Panel fixtures and createPanel below supply ToolPanel-shaped state objects. + const state = panel.state as JsonObject | undefined; + if (panel.sessionId === sessionId && state) { + state.isActive = panel.id === panelId; + } + } + }; const uiState = { expandedProjects: [] satisfies number[], expandedFolders: [] satisfies string[], @@ -354,6 +364,9 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc if (prop === 'onPanelCreated') { return (callback: MockEventCallback) => subscribe('panel:created', callback); } + if (prop === 'onPanelDeleted') { + return (callback: MockEventCallback) => subscribe('panel:deleted', callback); + } return () => unsubscribe; }, }); @@ -367,7 +380,11 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc const key = args[0] === undefined ? undefined : String(args[0]); const value = args[1] === undefined ? undefined : String(args[1]); if (channel === 'panels:get-layout') { - return success(clone(mockOptions.initialLayout ?? null)); + return success(clone(key && mockLayouts.has(key) ? mockLayouts.get(key) : mockOptions.initialLayout ?? null)); + } + if (channel === 'panels:set-layout') { + if (key) mockLayouts.set(key, clone(args[1] ?? null)); + return success(); } if (channel === 'panels:shouldAutoCreate') { // Fixtures seed their own panels; the app must not grow a terminal. @@ -701,6 +718,25 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc getSessionPanels: (sessionId: string) => success( clone(mockPanels.filter((panel) => panel.sessionId === sessionId)), ), + deletePanel: (panelId: string) => { + const deleted = mockPanels.find(panel => panel.id === panelId); + mockPanels = mockPanels.filter(panel => panel.id !== panelId); + if (deleted) { + // SAFETY: The deleted record comes from the same ToolPanel-shaped mock collection. + const state = deleted.state as JsonObject | undefined; + if (state?.isActive) { + const remaining = mockPanels.filter(panel => panel.sessionId === deleted.sessionId); + const next = remaining.find(panel => panel.type !== 'explorer' && panel.type !== 'diff') ?? remaining[0]; + setActiveMockPanel(String(deleted.sessionId), next ? String(next.id) : null); + } + emit('panel:deleted', { panelId, sessionId: deleted.sessionId }); + } + return success(); + }, + setActivePanel: (sessionId: string, panelId: string) => { + setActiveMockPanel(sessionId, panelId); + return success(); + }, createPanel: (sessionId: string, type: string, title: string, initialState?: JsonObject) => { const now = new Date().toISOString(); const panel = { @@ -712,6 +748,8 @@ export async function installElectronApiMock(page: Page, options: ElectronApiMoc metadata: { createdAt: now, lastActiveAt: now, position: mockPanels.length }, }; mockPanels.push(panel); + setActiveMockPanel(sessionId, panel.id); + emit('panel:created', clone(panel)); return success(clone(panel)); }, shouldAutoCreate: () => success(false), diff --git a/tests/terminal-dock.spec.ts b/tests/terminal-dock.spec.ts new file mode 100644 index 000000000..9adf92b82 --- /dev/null +++ b/tests/terminal-dock.spec.ts @@ -0,0 +1,272 @@ +import { expect, test, type Page } from '@playwright/test'; +import { installElectronApiMock } from './electronApiMock'; + +const now = new Date(0).toISOString(); +const project = { id: 814, name: 'Dock fixture', path: '/tmp/dock-fixture', active: true, created_at: now, updated_at: now }; +const mainSession = { + id: 'dock-main', name: 'Dock fixture (Main)', projectId: project.id, + worktreePath: project.path, isMainRepo: true, prompt: '', status: 'stopped', + createdAt: now, lastActivity: now, output: [], jsonMessages: [], isRunning: false, + permissionMode: 'ignore', toolType: 'none', archived: false, displayOrder: 0, + gitStatus: { state: 'clean', ahead: 0, behind: 0, hasUncommittedChanges: false, hasUntrackedFiles: false, filesChanged: 0 }, +}; +const otherSession = { ...mainSession, id: 'dock-other', name: 'Other pane', isMainRepo: false, displayOrder: 1 }; +const shell = { + id: 'default-shell', sessionId: mainSession.id, type: 'terminal', title: 'Terminal', + state: { isActive: true, customState: {} }, + metadata: { createdAt: now, lastActiveAt: now, position: 0 }, +}; + +async function openMainProject(page: Page) { + await page.getByRole('button', { name: `Repository actions for ${project.name}`, exact: true }).click(); + await page.getByText('Open session on main', { exact: true }).click(); + await expect(page.locator('.pane-project-content')).toBeVisible(); + await expect(page.getByRole('status', { name: 'Loading main repository session' })).toHaveCount(0); +} + +async function expandRepository(page: Page) { + await expect(page.getByRole('button', { name: `Repository actions for ${project.name}`, exact: true })).toBeVisible(); + const expand = page.getByRole('button', { name: `Expand repository ${project.name}`, exact: true }); + if (await expand.isVisible()) await expand.click(); +} + +for (const withDock of [false, true]) { + for (const agent of ['Claude Code', 'Codex']) { + test(`an empty main repository ${withDock ? 'with' : 'without'} a dock offers and launches ${agent}`, async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession], + initialPanels: withDock ? [shell] : [], activeProjectId: project.id, + }); + await page.goto('/'); + await openMainProject(page); + const stage = page.locator('.pane-project-content .pane-center-column > div').first(); + for (const label of ['Terminal', 'Claude Code', 'Codex']) { + await expect(stage.getByRole('button', { name: new RegExp(`^${label}`) })).toBeVisible(); + } + await stage.getByRole('button', { name: new RegExp(`^${agent}`) }).click(); + await expect(page.getByRole('tab', { name: agent, exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect(stage.getByRole('button', { name: /^Codex/ })).toHaveCount(0); + const result = await page.evaluate(async id => window.electronAPI.panels.getSessionPanels(id), mainSession.id); + expect(result.data?.find(panel => panel.title === agent)?.state.customState).toMatchObject({ + initialCommand: agent === 'Codex' ? 'codex --yolo' : 'claude --dangerously-skip-permissions', + }); + await page.getByRole('button', { name: `Close ${agent}`, exact: true }).click(); + await expect(stage.getByRole('button', { name: /^Codex/ })).toBeVisible(); + await expect(page.locator('.pane-terminal-dock')).toHaveCount(withDock ? 1 : 0); + }); + } +} + +test('the main repository shell starts in an expanded bottom dock with no top tab', async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession, otherSession], + initialPanels: [shell], activeProjectId: project.id, + }); + await page.goto('/'); + await openMainProject(page); + await expect(page.locator('.pane-terminal-dock .pane-terminal-shell-body')).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Terminal', exact: true })).toHaveCount(0); + await expect(page.locator('.panel-tab-bar [role="tab"]')).toHaveCount(0); + + await expandRepository(page); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await page.getByRole('button', { name: mainSession.name, exact: true }).click(); + await expect(page.locator('.pane-terminal-dock .pane-terminal-shell-body')).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Terminal', exact: true })).toHaveCount(0); +}); + +for (const agent of ['Codex', 'Claude Code']) { + test(`${agent} stays a top tab after returning to a main pane whose shell was removed`, async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession, otherSession], + initialPanels: [shell], activeProjectId: project.id, + // Old persisted layouts omitted the agent when it was mistaken for the dock. + initialLayout: { + version: 1, focusedGroupId: 'empty', + root: { type: 'group', id: 'empty', panelIds: [], activePanelId: null }, + }, + }); + await page.addInitScript(() => localStorage.setItem('pane-terminal-collapsed', 'true')); + await page.goto('/'); + await openMainProject(page); + await page.getByRole('button', { name: 'Close terminal', exact: true }).click(); + await expect(page.locator('.pane-terminal-dock')).toHaveCount(0); + await page.getByRole('button', { name: 'Hide details', exact: true }).click(); + await page.getByRole('button', { name: 'Add tool', exact: true }).click(); + await page.getByRole('menuitem', { name: new RegExp(agent) }).click(); + const tab = page.getByRole('tab', { name: agent, exact: true }); + await expect(tab).toBeVisible(); + const tabId = await tab.getAttribute('id'); + + await expandRepository(page); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await page.getByRole('button', { name: mainSession.name, exact: true }).click(); + await expect(tab).toBeVisible(); + await expect(tab).toHaveAttribute('id', tabId!); + await expect(tab).toHaveAttribute('aria-selected', 'true'); + await expect(page.locator('.pane-terminal-dock')).toHaveCount(0); + + // New agents created directly in SessionView must also enter the layout. + await page.getByRole('button', { name: 'Add tool', exact: true }).click(); + await page.getByRole('menuitem', { name: /Cursor/ }).click(); + await expect(page.getByRole('tab', { name: 'Cursor', exact: true })).toBeVisible(); + + // Loading the same main session through the project entry must agree. + await openMainProject(page); + await expect(tab).toBeVisible(); + await expect(page.locator('.pane-terminal-dock')).toHaveCount(0); + + // Reopening a shell expands the dock, and closing it leaves the active agent alone. + await page.getByRole('button', { name: 'Add tool', exact: true }).click(); + await page.getByRole('menuitem', { name: /^Terminal/ }).click(); + await expect(page.locator('.pane-terminal-dock .pane-terminal-shell-body')).toBeVisible(); + const cursorTab = page.getByRole('tab', { name: 'Cursor', exact: true }); + await cursorTab.click(); + await page.getByRole('button', { name: 'Close terminal', exact: true }).click(); + await expect(cursorTab).toHaveAttribute('aria-selected', 'true'); + await expect(tab).toBeVisible(); + }); +} + +test('a restored agent identified only by runtime metadata stays out of the dock', async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession], activeProjectId: project.id, + initialPanels: [{ ...shell, id: 'restored-agent', title: 'Codex', state: { isActive: true, customState: { agentType: 'codex', isCliPanel: true } } }], + initialLayout: { + version: 1, focusedGroupId: 'empty', + root: { type: 'group', id: 'empty', panelIds: [], activePanelId: null }, + }, + }); + await page.goto('/'); + await expandRepository(page); + await page.getByRole('button', { name: mainSession.name, exact: true }).click(); + await expect(page.getByRole('tab', { name: 'Codex', exact: true })).toBeVisible(); + await expect(page.locator('.pane-terminal-dock')).toHaveCount(0); +}); + +test('a fresh worktree profile defaults to an expanded dock and preserves an explicit collapse', async ({ page }) => { + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [otherSession], activeProjectId: project.id, + initialPanels: [{ ...shell, sessionId: otherSession.id }], + }); + await page.goto('/'); + await expandRepository(page); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await expect(page.locator('.pane-terminal-shell-body')).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem('pane-terminal-collapsed'))).toBe('false'); + await page.getByRole('button', { name: 'Collapse terminal', exact: true }).click(); + await page.reload(); + await expandRepository(page); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await expect(page.getByRole('button', { name: 'Expand terminal', exact: true })).toBeVisible(); + await expect(page.locator('.pane-terminal-shell-body')).toHaveCount(0); +}); + +for (const { deletion, split } of [ + { deletion: 'dock button', split: false }, + { deletion: 'backend event', split: false }, + { deletion: 'dock button', split: true }, + { deletion: 'backend event', split: true }, +]) { + test(`promoting a shell via ${deletion} repairs the ${split ? 'split' : 'single'} layout and persists selection`, async ({ page }) => { + const extraShell = { ...shell, id: 'extra-shell', title: 'Extra shell', state: { isActive: true, customState: {} } }; + const agent = { ...shell, id: 'codex', title: 'Codex', state: { isActive: false, customState: { initialCommand: 'codex --yolo' } } }; + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession, otherSession], activeProjectId: project.id, + initialPanels: [{ ...shell, state: { isActive: false, customState: {} } }, extraShell, agent], + initialLayout: split ? { + version: 1, focusedGroupId: 'shell-group', zoomedGroupId: 'shell-group', + root: { + type: 'split', id: 'split', direction: 'row', sizes: [1, 1], + children: [ + { type: 'group', id: 'shell-group', panelIds: [extraShell.id], activePanelId: extraShell.id }, + { type: 'group', id: 'agent-group', panelIds: [agent.id], activePanelId: agent.id }, + ], + }, + } : { + version: 1, focusedGroupId: 'agent-group', + root: { type: 'group', id: 'agent-group', panelIds: [extraShell.id, agent.id], activePanelId: extraShell.id }, + }, + }); + await page.goto('/'); + await expandRepository(page); + await page.getByRole('button', { name: mainSession.name, exact: true }).click(); + await expect(page.getByRole('tab', { name: 'Extra shell', exact: true })).toBeVisible(); + + if (deletion === 'dock button') { + await page.getByRole('button', { name: 'Close terminal', exact: true }).click(); + } else { + await page.evaluate(id => window.electronAPI.panels.deletePanel(id), shell.id); + } + + const codexTab = page.getByRole('tab', { name: 'Codex', exact: true }); + await expect(codexTab).toBeVisible(); + await expect(codexTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('tabpanel', { name: 'Codex', exact: true })).toBeVisible(); + await expect(page.getByRole('tab', { name: 'Extra shell', exact: true })).toHaveCount(0); + await expect(page.locator('.pane-terminal-dock')).toBeVisible(); + + await expect.poll(() => page.evaluate(async id => { + return (await window.electronAPI.invoke('panels:get-layout', id)).data; + }, mainSession.id)).toEqual({ + version: 1, focusedGroupId: 'agent-group', zoomedGroupId: null, + root: { type: 'group', id: 'agent-group', panelIds: [agent.id], activePanelId: agent.id }, + }); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await page.getByRole('button', { name: mainSession.name, exact: true }).click(); + await expect(codexTab).toHaveAttribute('aria-selected', 'true'); + await expect(page.getByRole('tabpanel', { name: 'Codex', exact: true })).toBeVisible(); + }); +} + +test('opening a project preserves a non-first persisted active tab', async ({ page }) => { + const agents = ['Codex', 'Claude Code', 'Cursor'].map((title, index) => ({ + ...shell, id: `agent-${index}`, title, + state: { isActive: title === 'Cursor', customState: { initialCommand: title } }, + })); + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession, otherSession], activeProjectId: project.id, + initialPanels: [{ ...shell, state: { isActive: false, customState: {} } }, ...agents], + }); + await page.goto('/'); + await openMainProject(page); + const cursorTab = page.getByRole('tab', { name: 'Cursor', exact: true }); + const stage = page.locator('.pane-project-content .pane-center-column > div').first(); + await expect(cursorTab).toHaveAttribute('aria-selected', 'true'); + await expect(stage.locator('.xterm:visible')).toHaveCount(1); + await expect.poll(() => page.evaluate(async id => { + const result = await window.electronAPI.panels.getSessionPanels(id); + return result.data?.find(panel => panel.state.isActive)?.title; + }, mainSession.id)).toBe('Cursor'); + + await expandRepository(page); + await page.getByRole('button', { name: otherSession.name, exact: true }).click(); + await openMainProject(page); + await expect(cursorTab).toHaveAttribute('aria-selected', 'true'); + await expect(stage.locator('.xterm:visible')).toHaveCount(1); +}); + +test('project fallback selection agrees with the backend when the active panel belongs to the dock', async ({ page }) => { + const agent = { ...shell, id: 'codex', title: 'Codex', state: { isActive: false, customState: { initialCommand: 'codex --yolo' } } }; + const secondAgent = { ...agent, id: 'claude', title: 'Claude Code', state: { isActive: false, customState: { initialCommand: 'claude' } } }; + await installElectronApiMock(page, { + initialProjects: [project], initialSessions: [mainSession], activeProjectId: project.id, + initialPanels: [shell, agent, secondAgent], + }); + await page.goto('/'); + await openMainProject(page); + await expect(page.getByRole('tab', { name: 'Codex', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect.poll(() => page.evaluate(async id => { + const result = await window.electronAPI.panels.getSessionPanels(id); + return result.data?.find(panel => panel.state.isActive)?.id; + }, mainSession.id)).toBe(agent.id); + + // SessionView remains mounted around ProjectView and handles backend deletion events. + await page.evaluate(id => window.electronAPI.panels.deletePanel(id), agent.id); + await expect(page.getByRole('tab', { name: 'Codex', exact: true })).toHaveCount(0); + await expect(page.getByRole('tab', { name: 'Claude Code', exact: true })).toHaveAttribute('aria-selected', 'true'); + await expect.poll(() => page.evaluate(async id => { + const result = await window.electronAPI.panels.getSessionPanels(id); + return result.data?.find(panel => panel.state.isActive)?.id; + }, mainSession.id)).toBe(secondAgent.id); +}); diff --git a/tests/terminal-selection-popover.spec.ts b/tests/terminal-selection-popover.spec.ts index 230a9b389..37ced2b2d 100644 --- a/tests/terminal-selection-popover.spec.ts +++ b/tests/terminal-selection-popover.spec.ts @@ -120,6 +120,7 @@ async function selectFirstLine(page: Page, terminal: Locator): Promise { } test('selection popover works in restored bottom and tab terminals', async ({ page }, testInfo) => { + await page.addInitScript(() => localStorage.setItem('pane-terminal-collapsed', 'true')); await installClipboardMock(page); await installElectronApiMock(page, { initialProjects: [project], From 52af9eeda155a44a1f61936a84b17e0c02751aeb Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sun, 13 Sep 2026 14:45:35 -0600 Subject: [PATCH 17/20] Clear inherited startup titles and guarantee terminal teardown cleanup --- .../terminalPanelManager.persistence.test.ts | 17 +++++ .../terminalPanelManager.status.test.ts | 51 ++++++++++++++ main/src/services/terminalPanelManager.ts | 70 ++++++++++++------- 3 files changed, 113 insertions(+), 25 deletions(-) diff --git a/main/src/services/terminalPanelManager.persistence.test.ts b/main/src/services/terminalPanelManager.persistence.test.ts index 08769dbc4..6698e24f3 100644 --- a/main/src/services/terminalPanelManager.persistence.test.ts +++ b/main/src/services/terminalPanelManager.persistence.test.ts @@ -186,6 +186,23 @@ describe('terminal panel persistence', () => { expect(manager.getTerminalSnapshot(panel.id)?.activityStatus).toBe('idle'); }, 10_000); + it('ignores the launch shell title until Codex supplies its own status', async () => { + const panel = makePanel('panel-shell-title'); + panel.state.customState = { initialCommand: 'codex', agentType: 'codex', isCliPanel: true }; + const { manager, handle } = await startTerminal(panel); + handle.emit('\x1b]2;user@host: ~/project\x07user@host:~/project$ '); + await vi.waitFor(() => expect(handle.written.some(data => data.includes('codex'))).toBe(true), { timeout: 1500 }); + // Allow status polling to run within the startup grace window. + await new Promise(resolve => setTimeout(resolve, 800)); + expect(manager.getAgentStatus(panel.id)).toBeUndefined(); + expect(manager.getTerminalSnapshot(panel.id)?.activityStatus).toBe('active'); + + handle.emit('\x1b]2;Custom task title\x07'); + await vi.waitFor(() => expect(manager.getAgentStatus(panel.id)).toBe('idle'), { timeout: 1500 }); + handle.emit('\x1b]2;⠙ Custom task title\x07'); + await vi.waitFor(() => expect(manager.getAgentStatus(panel.id)).toBe('working'), { timeout: 1500 }); + }); + it('streams 50 MB of newline-free alternate-screen frames without growing the persisted state', async () => { const panel = makePanel('panel-frames'); const { manager, handle } = await startTerminal(panel); diff --git a/main/src/services/terminalPanelManager.status.test.ts b/main/src/services/terminalPanelManager.status.test.ts index e43eeeedd..d0d8c9243 100644 --- a/main/src/services/terminalPanelManager.status.test.ts +++ b/main/src/services/terminalPanelManager.status.test.ts @@ -225,6 +225,57 @@ describe('terminal status events', () => { expect(manager.isTerminalInitialized('p')).toBe(false); }); + it.each(['panel:agentStatus', 'terminal:output'])('cleans up even when %s delivery fails during teardown', async channel => { + const fixture = attach('codex'); + if (channel === 'terminal:output') fixture.data('pending output'); + const dispose = vi.spyOn(fixture.terminal.screenEmulator, 'dispose'); + setPaneRuntime({ eventSink: { send(sentChannel) { + if (sentChannel === channel) throw new Error('event delivery failed'); + } } }); + await expect(manager.destroyTerminal('p', { saveState: false })).rejects.toThrow('event delivery failed'); + expect(dispose).toHaveBeenCalledOnce(); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + expect(manager.isTerminalInitialized('p')).toBe(false); + expect(access.agentStatusMonitor.isTracked('p')).toBe(false); + await manager.destroyTerminal('p'); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + }); + + it('kills the PTY even when emulator disposal fails', async () => { + const fixture = attach('codex'); + vi.spyOn(fixture.terminal.screenEmulator, 'dispose').mockImplementationOnce(() => { throw new Error('dispose failed'); }); + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await manager.destroyTerminal('p', { saveState: false }); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + expect(manager.isTerminalInitialized('p')).toBe(false); + }); + + it('still kills a WSL terminal when its graceful exit write fails', async () => { + vi.useRealTimers(); + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }); + const fixture = attach('codex'); + Object.assign(fixture.terminal, { isWSL: true }); + fixture.terminal.pty.write.mockImplementation(() => { throw new Error('exit write failed'); }); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + await manager.destroyTerminal('p', { saveState: false }); + expect(manager.isTerminalInitialized('p')).toBe(false); + expect(fixture.terminal.pty.kill).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(500); + expect(fixture.terminal.pty.kill).toHaveBeenCalledOnce(); + }); + + it('disposes an exited terminal even when status delivery fails', () => { + const fixture = attach('codex'); + const dispose = vi.spyOn(fixture.terminal.screenEmulator, 'dispose'); + setPaneRuntime({ eventSink: { send(channel) { + if (channel === 'panel:agentStatus') throw new Error('event delivery failed'); + } } }); + expect(() => fixture.exit()).toThrow('event delivery failed'); + expect(dispose).toHaveBeenCalledOnce(); + expect(manager.isTerminalInitialized('p')).toBe(false); + expect(fixture.terminal.pty.kill).not.toHaveBeenCalled(); + }); + it('discards a poll resumed after the terminal was replaced', async () => { const old = attach('codex'); let release = () => undefined; diff --git a/main/src/services/terminalPanelManager.ts b/main/src/services/terminalPanelManager.ts index 920e10243..50a70d7b9 100644 --- a/main/src/services/terminalPanelManager.ts +++ b/main/src/services/terminalPanelManager.ts @@ -1102,6 +1102,11 @@ export class TerminalPanelManager { const panelId = panel.id; const injectCommand = () => { if (this.terminals.get(panelId) !== terminalProcess || terminalProcess.destroying) return; + if (isCliCommand) { + // Clear the shell's title in the headless model before trusting agent + // status. Queued shell writes drain before this reset and CLI output. + terminalProcess.screenEmulator?.write('\x1b]2;\x07'); + } terminalProcess.pendingInitialCommand = false; this.agentStatusMonitor.register(panelId, Date.now()); this.writeToTerminal(panelId, commandToRun! + '\r'); @@ -1297,8 +1302,11 @@ export class TerminalPanelManager { terminal.exitDuringDestroy ??= exitCode; return; } - this.retireTerminal(terminal, exitCode); - terminal.screenEmulator?.dispose(); + try { + this.retireTerminal(terminal, exitCode); + } finally { + terminal.screenEmulator?.dispose(); + } // Notify frontend (include signal for crash detection) this.sendRendererEvent('terminal:exited', { @@ -1760,25 +1768,34 @@ export class TerminalPanelManager { } if (this.terminals.get(panelId) !== terminal) return; - this.retireTerminal(terminal, terminal.exitDuringDestroy); - terminal.screenEmulator?.dispose(); - if (terminal.exitDuringDestroy) return; - - // Kill the PTY process try { - if (terminal.isWSL) { - terminal.pty.write('exit\r'); - // Give WSL a moment to gracefully exit - setTimeout(() => { - try { terminal.pty.kill(); } catch { /* already exited */ } - }, 500); - } else { - terminal.pty.kill(); + this.retireTerminal(terminal, terminal.exitDuringDestroy); + } finally { + // Event subscribers can throw; cleanup must still reclaim this lifetime. + try { + terminal.screenEmulator?.dispose(); + } catch (error) { + console.warn(`[TerminalPanelManager] Emulator dispose failed for ${panelId}:`, error); + } + if (!terminal.exitDuringDestroy) { + try { + if (terminal.isWSL) { + try { + terminal.pty.write('exit\r'); + } finally { + // Reclaim the PTY even if the graceful exit write failed. + setTimeout(() => { + try { terminal.pty.kill(); } catch { /* already exited */ } + }, 500); + } + } else { + terminal.pty.kill(); + } + } catch (error) { + console.error(`[TerminalPanelManager] Error killing terminal ${panelId}:`, error); + } } - } catch (error) { - console.error(`[TerminalPanelManager] Error killing terminal ${panelId}:`, error); } - } /** Retire exactly one terminal lifetime, before kill can call back synchronously. */ @@ -1789,13 +1806,16 @@ export class TerminalPanelManager { clearTimeout(terminal.outputFlushTimer); terminal.outputFlushTimer = null; } - disposeFlowControlRecord(terminal.flowControl); - this.flushOutputBuffer(terminal); - this.agentStatusMonitor.unregister(panelId); - this.terminals.delete(panelId); - this.visibleViewersByPanel.delete(panelId); - this.serializedBuffers.delete(panelId); - this.maybeStopAgentStatusPoll(); + try { + this.flushOutputBuffer(terminal); + } finally { + disposeFlowControlRecord(terminal.flowControl); + this.agentStatusMonitor.unregister(panelId); + this.terminals.delete(panelId); + this.visibleViewersByPanel.delete(panelId); + this.serializedBuffers.delete(panelId); + this.maybeStopAgentStatusPoll(); + } this.emitAgentStatus(terminal, 'idle', exit ? 'exit' : 'destroyed'); const data = { ...exit, timestamp: new Date().toISOString() }; From ca7ca2ea6ca8e4bd51007b2598b7c1d447743fd2 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Sun, 13 Sep 2026 14:45:40 -0600 Subject: [PATCH 18/20] Add fresh pane launcher screenshot for review --- screenshots/fresh-pane-launcher.png | Bin 0 -> 60602 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 screenshots/fresh-pane-launcher.png diff --git a/screenshots/fresh-pane-launcher.png b/screenshots/fresh-pane-launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..c6a77607023f8d7899ec84c69b1a8da2165a9bb8 GIT binary patch literal 60602 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 KelF{r5}E*ZOiaT7 literal 0 HcmV?d00001 From 48134e516b73b88db4fd5c4e91a11adbbc55d678 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Wed, 16 Sep 2026 07:32:06 -0600 Subject: [PATCH 19/20] Fix remote terminal input ordering --- docs/remote-daemon-lifecycle.md | 6 + .../runtime/remoteDaemonBrowserClient.ts | 19 +- .../daemon/client/remoteInputQueue.test.ts | 66 ++++++ main/src/daemon/client/remotePaneClient.ts | 11 + .../daemon/client/remoteTerminalInput.test.ts | 207 ++++++++++++++++++ shared/remoteInputQueue.ts | 102 +++++++++ 6 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 main/src/daemon/client/remoteInputQueue.test.ts create mode 100644 main/src/daemon/client/remoteTerminalInput.test.ts create mode 100644 shared/remoteInputQueue.ts diff --git a/docs/remote-daemon-lifecycle.md b/docs/remote-daemon-lifecycle.md index 19c86c97f..dda0b786c 100644 --- a/docs/remote-daemon-lifecycle.md +++ b/docs/remote-daemon-lifecycle.md @@ -31,3 +31,9 @@ This is the implementation checklist for Remote Pane setup, teardown, and runtim - Connected remote clients are runtime state, not saved client records. - Current Pane Data hosting is live only while that Pane app is running. - Isolated daemon data can install a background service; Current Pane Data should not. + +## Terminal Input + +Desktop and browser clients send at most one terminal input request per panel at a time. Keys typed while a request is pending are combined into the next request, preserving their order without a separate round trip for every buffered key. Different panels and other remote commands remain independent. + +Input requests are not retried. On failure, disconnect, or a ten-second input timeout, queued input is discarded and outstanding callers are rejected. An interrupted request may already have reached the host, so its input must not be replayed after reconnecting. This uses the existing HTTP API and requires no host protocol upgrade. diff --git a/frontend/src/remote/runtime/remoteDaemonBrowserClient.ts b/frontend/src/remote/runtime/remoteDaemonBrowserClient.ts index 7093a455b..76b0e243d 100644 --- a/frontend/src/remote/runtime/remoteDaemonBrowserClient.ts +++ b/frontend/src/remote/runtime/remoteDaemonBrowserClient.ts @@ -6,6 +6,7 @@ import { type RemotePaneConnectionProfile, type RemotePaneConnectionStatus, } from '../../../../shared/types/remoteDaemon'; +import { RemoteInputQueue } from '../../../../shared/remoteInputQueue'; type RemoteBrowserEvent = | { type: 'ready'; timestamp: string } @@ -50,6 +51,8 @@ export class RemoteDaemonBrowserClient { private reconnectAttempt = 0; private eventListeners = new Set(); private statusListeners = new Set(); + private readonly inputQueue = new RemoteInputQueue((channel, args, signal) => + this.invokeRequest(channel, args, signal)); private state: RemoteBrowserConnectionState = { status: 'local', lastError: null, @@ -74,6 +77,7 @@ export class RemoteDaemonBrowserClient { } async connect(): Promise { + this.inputQueue.cancel(new Error('Remote Pane reconnecting; pending terminal input was discarded')); this.clearReconnectTimer(); this.abortController?.abort(); this.closeEventSource(); @@ -86,6 +90,7 @@ export class RemoteDaemonBrowserClient { } disconnect(): void { + this.inputQueue.cancel(new Error('Remote Pane disconnected; pending terminal input was discarded')); this.clearReconnectTimer(); this.abortController?.abort(); this.closeEventSource(); @@ -95,9 +100,16 @@ export class RemoteDaemonBrowserClient { } async invoke(channel: string, args: unknown[] = []): Promise { + // SAFETY: The named IPC/API channel contract establishes this response payload type. + return this.inputQueue.invoke(channel, args) as Promise; + } + + private async invokeRequest(channel: string, args: unknown[], inputSignal?: AbortSignal): Promise { let lastError: Error | null = null; - const signal = this.abortController?.signal; - for (let attempt = 1; attempt <= INVOKE_ATTEMPTS; attempt += 1) { + const signal = inputSignal ?? this.abortController?.signal; + // Retrying a terminal write can duplicate input already applied by the host. + const attempts = inputSignal ? 1 : INVOKE_ATTEMPTS; + for (let attempt = 1; attempt <= attempts; attempt += 1) { try { const runtimeId = getRuntimeId(); const clientLabel = getClientLabel(); @@ -147,7 +159,7 @@ export class RemoteDaemonBrowserClient { lastError = error instanceof Error ? error : new Error('Remote request failed'); } - if (attempt < INVOKE_ATTEMPTS) { + if (attempt < attempts) { await delay(REQUEST_RETRY_DELAY_MS * attempt, signal); } } @@ -392,6 +404,7 @@ export class RemoteDaemonBrowserClient { } private scheduleReconnect(message: string): void { + this.inputQueue.cancel(new Error(message)); if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { this.setState({ status: 'error', lastError: message }); return; diff --git a/main/src/daemon/client/remoteInputQueue.test.ts b/main/src/daemon/client/remoteInputQueue.test.ts new file mode 100644 index 000000000..d446d0df3 --- /dev/null +++ b/main/src/daemon/client/remoteInputQueue.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { RemoteInputQueue } from '../../../../shared/remoteInputQueue'; + +afterEach(() => vi.useRealTimers()); + +describe('RemoteInputQueue lifecycle', () => { + it('ignores late completions after cancellation when new input is already pending', async () => { + const finish: Array<() => void> = []; + const send = vi.fn((_channel: string, _args: unknown[], _signal?: AbortSignal) => + new Promise(resolve => finish.push(resolve))); + const queue = new RemoteInputQueue(send); + const old = Promise.allSettled([ + queue.invoke('terminal:input', ['panel', 'old']), + queue.invoke('terminal:input', ['panel', 'discard']), + ]); + queue.cancel(new Error('Disconnected')); + const current = Promise.all([ + queue.invoke('terminal:input', ['panel', 'new']), + queue.invoke('terminal:input', ['panel', 'suffix']), + ]); + finish[0](); + await old; + expect(send).toHaveBeenCalledTimes(2); + finish[1](); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(3)); + expect(send.mock.calls[2]).toEqual(['terminal:input', ['panel', 'suffix'], expect.any(AbortSignal)]); + finish[2](); + await current; + }); + + it('aborts a stuck write and rejects pending input when its deadline expires', async () => { + vi.useFakeTimers(); + const send = vi.fn((_channel: string, _args: unknown[], _signal?: AbortSignal) => new Promise(() => {})); + const queue = new RemoteInputQueue(send); + const completed = Promise.allSettled([ + queue.invoke('terminal:input', ['panel', 'a']), + queue.invoke('terminal:input', ['panel', 'b']), + ]); + await vi.advanceTimersByTimeAsync(10_000); + expect((await completed).every(result => result.status === 'rejected' + && result.reason instanceof Error && result.reason.message.includes('timed out'))).toBe(true); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][2]?.aborted).toBe(true); + }); + + it('bounds combined batches without splitting individual input events', async () => { + const finish: Array<() => void> = []; + const send = vi.fn((_channel: string, _args: unknown[], _signal?: AbortSignal) => + new Promise(resolve => finish.push(resolve))); + const queue = new RemoteInputQueue(send); + const paste = 'x'.repeat(64 * 1024); + const completed = Promise.all([ + queue.invoke('terminal:input', ['panel', 'a']), + queue.invoke('terminal:input', ['panel', paste]), + queue.invoke('terminal:input', ['panel', '\r']), + ]); + finish[0](); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(2)); + expect(send.mock.calls[1][1]).toEqual(['panel', paste]); + finish[1](); + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(3)); + expect(send.mock.calls[2][1]).toEqual(['panel', '\r']); + finish[2](); + await completed; + }); +}); diff --git a/main/src/daemon/client/remotePaneClient.ts b/main/src/daemon/client/remotePaneClient.ts index 7acec8458..1c3563082 100644 --- a/main/src/daemon/client/remotePaneClient.ts +++ b/main/src/daemon/client/remotePaneClient.ts @@ -21,6 +21,7 @@ import type { RemoteDaemonEventEnvelope } from '../../../../shared/types/remoteD import { boundary, decodeBoundary } from '../../../../shared/validation/boundaryDecoder'; import type { BoundarySchema, JsonValue } from '../../../../shared/validation/boundaryDecoder'; import { PaneSseParser } from './sseParser'; +import { RemoteInputQueue } from '../../../../shared/remoteInputQueue'; interface RemoteConnectionStateMetadata { lastSeenAt?: string | null; @@ -135,6 +136,8 @@ export class RemotePaneClient { private consecutiveReconnectFailures = 0; private lastSeenAt: string | null = null; private closedByClient = false; + private readonly inputQueue = new RemoteInputQueue((channel, args, signal) => + this.invokeRequest(channel, args, signal)); constructor( readonly profile: RemotePaneConnectionProfile, @@ -181,6 +184,7 @@ export class RemotePaneClient { async disconnect(): Promise { this.closedByClient = true; + this.inputQueue.cancel(new Error('Remote Pane disconnected; pending terminal input was discarded')); this.clearReconnectTimer(); this.clearHeartbeatStaleTimer(); this.eventParser.reset(); @@ -197,11 +201,16 @@ export class RemotePaneClient { } async invoke(channel: string, args: unknown[]): Promise { + return this.inputQueue.invoke(channel, args); + } + + private async invokeRequest(channel: string, args: unknown[], signal?: AbortSignal): Promise { const endpoint = buildRemoteEndpoint(this.normalizedBaseUrl, 'invoke'); let response: JsonResponse; try { response = await requestJson(endpoint, this.buildRequestOptions(endpoint, { method: 'POST', + signal, headers: { Authorization: `Bearer ${this.profile.token}`, 'Content-Type': 'application/json; charset=utf-8', @@ -209,6 +218,7 @@ export class RemotePaneClient { }, }), JSON.stringify({ channel, args })); } catch (error) { + if (signal?.aborted) throw error; const message = getErrorMessage(error, 'Failed to invoke remote daemon'); this.handleUnexpectedDisconnect(message, true); throw error; @@ -406,6 +416,7 @@ export class RemotePaneClient { } private handleUnexpectedDisconnect(message: string, destroyStream: boolean): void { + this.inputQueue.cancel(new Error(message)); const activeResponse = this.eventResponse; const activeRequest = this.eventRequest; diff --git a/main/src/daemon/client/remoteTerminalInput.test.ts b/main/src/daemon/client/remoteTerminalInput.test.ts new file mode 100644 index 000000000..6d0de972e --- /dev/null +++ b/main/src/daemon/client/remoteTerminalInput.test.ts @@ -0,0 +1,207 @@ +import http, { type ServerResponse } from 'http'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RemoteDaemonBrowserClient } from '../../../../frontend/src/remote/runtime/remoteDaemonBrowserClient'; +import { boundary, decodeBoundary } from '../../../../shared/validation/boundaryDecoder'; +import { RemotePaneClient } from './remotePaneClient'; + +interface InputRequest { + channel: string; + args: unknown[]; + response: ServerResponse; +} + +const clients: Array = []; +const servers: http.Server[] = []; + +beforeEach(() => { + const storage = new Map(); + vi.stubGlobal('window', { + setTimeout, + clearTimeout, + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, value), + }, + }); + vi.stubGlobal('navigator', { platform: 'Test' }); +}); + +afterEach(async () => { + await Promise.all(clients.splice(0).map(client => client.disconnect())); + await Promise.all(servers.splice(0).map(server => new Promise(resolve => { + server.close(() => resolve()); + server.closeAllConnections(); + }))); + vi.unstubAllGlobals(); +}); + +describe.each([ + ['desktop', RemotePaneClient], + ['browser', RemoteDaemonBrowserClient], +] as const)('%s remote terminal input', (_name, Client) => { + async function setup() { + const requests: InputRequest[] = []; + let eventResponse: ServerResponse | undefined; + const server = http.createServer(async (request, response) => { + if (request.url?.startsWith('/health')) { + response.end(JSON.stringify({ ok: true })); + return; + } + if (request.url?.startsWith('/events')) { + eventResponse = response; + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.write(`event: ready\ndata: ${JSON.stringify({ + replay: 'none', resync: 'refetch-state-after-reconnect', timestamp: new Date().toISOString(), + })}\n\n`); + return; + } + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const parsed = decodeBoundary(JSON.parse(Buffer.concat(chunks).toString()), boundary.object({ + channel: boundary.string, + args: boundary.array(boundary.json), + })); + requests.push({ ...parsed, response }); + }); + servers.push(server); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = decodeBoundary(server.address(), boundary.object({ port: boundary.number })); + const client = new Client({ + id: 'input-test', label: 'Input test', token: 'test-token', transport: 'http+sse', + baseUrl: `http://127.0.0.1:${address.port}`, + }); + clients.push(client); + return { client, requests, getEventResponse: () => eventResponse }; + } + + it('keeps fast typing ordered and combines pending keys into the next request', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-1', 'b']), + client.invoke('terminal:input', ['panel-1', 'c']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(requests.map(request => request.args[1])).toEqual(['a']); + + reply(requests[0]); + await vi.waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].args).toEqual(['panel-1', 'bc']); + reply(requests[1]); + expect(await completed).toEqual([ + { status: 'fulfilled', value: undefined }, + { status: 'fulfilled', value: undefined }, + { status: 'fulfilled', value: undefined }, + ]); + }); + + it('preserves Unicode, paste boundaries, and control keys in the queued byte stream', async () => { + const { client, requests } = await setup(); + const keys = ['a', 'é🙂', '\x1b[200~pasted\ntext\x1b[201~', '\x7f', '\x1b[D', '\t', '\x03', '\r']; + const completed = Promise.allSettled(keys.map(key => client.invoke('terminal:input', ['panel-1', key]))); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + reply(requests[0]); + await vi.waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1].args[1]).toBe(keys.slice(1).join('')); + reply(requests[1]); + expect((await completed).every(result => result.status === 'fulfilled')).toBe(true); + }); + + it('does not block other panels or ordinary remote commands', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-2', 'b']), + client.invoke('sessions:get-all', []), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(3)); + for (const request of requests) reply(request); + expect((await completed).every(result => result.status === 'fulfilled')).toBe(true); + }); + + it('orders both terminal input channels together and preserves their results', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('panels:send-terminal-input', ['panel-1', 'b']), + client.invoke('panels:send-terminal-input', ['panel-1', 'c']), + client.invoke('terminal:input', ['panel-1', '\r']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + reply(requests[0]); + await vi.waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1]).toMatchObject({ channel: 'panels:send-terminal-input', args: ['panel-1', 'bc'] }); + requests[1].response.end(JSON.stringify({ ok: true, result: { success: true } })); + await vi.waitFor(() => expect(requests).toHaveLength(3)); + expect(requests[2]).toMatchObject({ channel: 'terminal:input', args: ['panel-1', '\r'] }); + reply(requests[2]); + expect(await completed).toEqual([ + { status: 'fulfilled', value: undefined }, + { status: 'fulfilled', value: { success: true } }, + { status: 'fulfilled', value: { success: true } }, + { status: 'fulfilled', value: undefined }, + ]); + }); + + it('discards pending input on an HTTP failure without retrying the write', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-1', 'b']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + reply(requests[0], 503); + expect(await completed).toEqual([ + { status: 'rejected', reason: new Error('Input failed') }, + { status: 'rejected', reason: new Error('Input failed') }, + ]); + expect(requests).toHaveLength(1); + }); + + it('does not replay input if the connection drops after the host receives it', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-1', 'b']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + requests[0].response.destroy(); + expect((await completed).every(result => result.status === 'rejected')).toBe(true); + expect(requests).toHaveLength(1); + }); + + it('cancels input on disconnect and never sends its buffered suffix', async () => { + const { client, requests } = await setup(); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-1', 'b']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + await client.disconnect(); + expect((await completed).every(result => result.status === 'rejected')).toBe(true); + await vi.waitFor(() => expect(requests[0].response.destroyed).toBe(true)); + expect(requests).toHaveLength(1); + }); + + it('discards queued input when the remote event connection is lost', async () => { + const { client, requests, getEventResponse } = await setup(); + await client.connect(); + await vi.waitFor(() => expect(getEventResponse()).toBeDefined()); + const completed = Promise.allSettled([ + client.invoke('terminal:input', ['panel-1', 'a']), + client.invoke('terminal:input', ['panel-1', 'b']), + ]); + await vi.waitFor(() => expect(requests).toHaveLength(1)); + getEventResponse()?.destroy(); + expect((await completed).every(result => result.status === 'rejected')).toBe(true); + expect(requests).toHaveLength(1); + }); +}); + +function reply(request: InputRequest, status = 200): void { + request.response.writeHead(status, { 'Content-Type': 'application/json' }); + request.response.end(JSON.stringify(status === 200 + ? { ok: true } + : { ok: false, error: { message: 'Input failed' } })); +} diff --git a/shared/remoteInputQueue.ts b/shared/remoteInputQueue.ts new file mode 100644 index 000000000..af97cc26c --- /dev/null +++ b/shared/remoteInputQueue.ts @@ -0,0 +1,102 @@ +import { boundary, decodeBoundary } from './validation/boundaryDecoder'; + +interface InputBatch { + channel: string; + data: string; + waiters: Array<{ resolve: (result: Result) => void; reject: (error: Error) => void }>; +} + +interface PanelInputQueue { + pending: InputBatch[]; + active?: { + batch: InputBatch; + controller: AbortController; + timeout: ReturnType; + }; +} + +// Bound merged requests without splitting a paste or terminal escape sequence. +const MAX_BATCH_CHARACTERS = 64 * 1024; +const INPUT_TIMEOUT_MS = 10_000; +const inputSchema = boundary.object({ panelId: boundary.nonEmptyString, data: boundary.string }); + +/** Serializes remote input per terminal, batching only while a request is in flight. */ +export class RemoteInputQueue { + private readonly panels = new Map>(); + + constructor(private readonly send: ( + channel: string, + args: unknown[], + signal?: AbortSignal, + ) => Promise) {} + + invoke(channel: string, args: unknown[]): Promise { + if (channel !== 'terminal:input' && channel !== 'panels:send-terminal-input') { + return this.send(channel, args); + } + const { panelId, data } = decodeBoundary({ panelId: args[0], data: args[1] }, inputSchema); + + let queue = this.panels.get(panelId); + if (!queue) { + queue = { pending: [] }; + this.panels.set(panelId, queue); + } + const result = new Promise((resolve, reject) => { + const last = queue.pending[queue.pending.length - 1]; + if (last && last.channel === channel && last.data.length + data.length <= MAX_BATCH_CHARACTERS) { + last.data += data; + last.waiters.push({ resolve, reject }); + } else { + queue.pending.push({ channel, data, waiters: [{ resolve, reject }] }); + } + }); + if (!queue.active) void this.drain(panelId, queue); + return result; + } + + cancel(error: Error): void { + for (const [panelId, queue] of this.panels) { + this.fail(panelId, queue, error); + } + } + + private async drain(panelId: string, queue: PanelInputQueue): Promise { + const batch = queue.pending.shift(); + if (!batch) { + this.panels.delete(panelId); + return; + } + const controller = new AbortController(); + const timeout = setTimeout(() => { + this.fail(panelId, queue, new Error('Remote terminal input timed out; pending input was discarded')); + }, INPUT_TIMEOUT_MS); + queue.active = { batch, controller, timeout }; + + try { + const result = await this.send(batch.channel, [panelId, batch.data], controller.signal); + // A disconnect may have canceled this queue and created a new one for the same panel. + if (this.panels.get(panelId) !== queue) return; + clearTimeout(timeout); + queue.active = undefined; + for (const waiter of batch.waiters) waiter.resolve(result); + void this.drain(panelId, queue); + } catch (error) { + this.fail(panelId, queue, error instanceof Error ? error : new Error(String(error))); + } + } + + private fail(panelId: string, queue: PanelInputQueue, error: Error): void { + if (this.panels.get(panelId) !== queue) return; + this.panels.delete(panelId); + const batches = queue.pending; + if (queue.active) { + clearTimeout(queue.active.timeout); + queue.active.controller.abort(); + batches.unshift(queue.active.batch); + } + // Delivery of an in-flight request is uncertain. Never replay it or send its queued suffix. + for (const batch of batches) { + for (const waiter of batch.waiters) waiter.reject(error); + } + } +} From b68ff50515727d8c97b4a45a4b453cf2f2ddba05 Mon Sep 17 00:00:00 2001 From: iamjakeirl Date: Thu, 24 Sep 2026 02:10:57 -0600 Subject: [PATCH 20/20] Preserve Escape boundaries and handle discarded terminal input --- docs/remote-daemon-lifecycle.md | 2 +- .../src/components/panels/TerminalPanel.tsx | 15 ++++----- frontend/src/utils/terminalInput.test.ts | 32 +++++++++++++++++++ frontend/src/utils/terminalInput.ts | 12 +++++++ .../daemon/client/remoteInputQueue.test.ts | 18 +++++++++++ .../daemon/client/remoteTerminalInput.test.ts | 9 ++++-- shared/remoteInputQueue.ts | 10 +++++- 7 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 frontend/src/utils/terminalInput.test.ts create mode 100644 frontend/src/utils/terminalInput.ts diff --git a/docs/remote-daemon-lifecycle.md b/docs/remote-daemon-lifecycle.md index dda0b786c..8ff42a122 100644 --- a/docs/remote-daemon-lifecycle.md +++ b/docs/remote-daemon-lifecycle.md @@ -34,6 +34,6 @@ This is the implementation checklist for Remote Pane setup, teardown, and runtim ## Terminal Input -Desktop and browser clients send at most one terminal input request per panel at a time. Keys typed while a request is pending are combined into the next request, preserving their order without a separate round trip for every buffered key. Different panels and other remote commands remain independent. +Desktop and browser clients send at most one terminal input request per panel at a time. Keys typed while a request is pending are combined into the next request, preserving their order without a separate round trip for every buffered key. A bare Escape always ends a combined request, because terminal apps read Escape followed by another key in the same write as an Alt shortcut. Different panels and other remote commands remain independent. Input requests are not retried. On failure, disconnect, or a ten-second input timeout, queued input is discarded and outstanding callers are rejected. An interrupted request may already have reached the host, so its input must not be replayed after reconnecting. This uses the existing HTTP API and requires no host protocol upgrade. diff --git a/frontend/src/components/panels/TerminalPanel.tsx b/frontend/src/components/panels/TerminalPanel.tsx index 1c0cdb2f1..2f4e68fc9 100644 --- a/frontend/src/components/panels/TerminalPanel.tsx +++ b/frontend/src/components/panels/TerminalPanel.tsx @@ -21,6 +21,7 @@ import { } from '../../utils/terminalKeyHandling'; import { isMac } from '../../utils/platformUtils'; import { copyTerminalText, isTerminalCopyShortcut } from '../../utils/terminalClipboard'; +import { sendTerminalInput } from '../../utils/terminalInput'; import { FileEdit, FolderOpen } from 'lucide-react'; import { useTerminalLinks } from '../terminal/hooks/useTerminalLinks'; import { TerminalLinkTooltip } from '../terminal/TerminalLinkTooltip'; @@ -1075,7 +1076,7 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv // passthrough, while ordinary TUIs still receive Shift+Enter directly. if (terminalKeyDecision.action === 'send-input') { if (e.type === 'keydown') { - window.electronAPI.invoke('terminal:input', panel.id, terminalKeyDecision.input); + sendTerminalInput(panel.id, terminalKeyDecision.input); } return false; } @@ -1387,11 +1388,7 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv if (!terminal) return; const shouldProtectMultilinePaste = isCliPanelRef.current && !tuiActiveRef.current && /[\r\n]/.test(text); if (shouldProtectMultilinePaste) { - window.electronAPI.invoke( - 'terminal:input', - panel.id, - text.replace(/\r\n|\r|\n/g, '\x1b\r'), - ); + sendTerminalInput(panel.id, text.replace(/\r\n|\r|\n/g, '\x1b\r')); return; } @@ -1688,7 +1685,7 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv // Create interceptor for @ mentions and future trigger handlers const interceptor = new TerminalInterceptor({ onStateChange: (state) => setInterceptorState(state.active ? state : null), - onFlush: (data) => window.electronAPI.invoke('terminal:input', panel.id, data), + onFlush: (data) => sendTerminalInput(panel.id, data), }); interceptorRef.current = interceptor; @@ -1778,12 +1775,12 @@ const TerminalPanel: React.FC = React.memo(({ panel, isActiv // Skip interception for AltGr-produced @ (e.g. German keyboard) if (skipNextInterceptRef.current) { skipNextInterceptRef.current = false; - window.electronAPI.invoke('terminal:input', panel.id, data); + sendTerminalInput(panel.id, data); return; } const result = interceptor.handleInput(data); if (!result.consumed) { - window.electronAPI.invoke('terminal:input', panel.id, data); + sendTerminalInput(panel.id, data); } }); diff --git a/frontend/src/utils/terminalInput.test.ts b/frontend/src/utils/terminalInput.test.ts new file mode 100644 index 000000000..6d831f85a --- /dev/null +++ b/frontend/src/utils/terminalInput.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sendTerminalInput } from './terminalInput'; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('sendTerminalInput', () => { + it('forwards input on the terminal input channel', () => { + const invoke = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('window', { electronAPI: { invoke } }); + sendTerminalInput('panel-1', '\x1b[A'); + expect(invoke).toHaveBeenCalledWith('terminal:input', 'panel-1', '\x1b[A'); + }); + + it('handles discarded input so it never becomes an unhandled rejection', async () => { + const error = new Error('Remote Pane disconnected; pending terminal input was discarded'); + vi.stubGlobal('window', { electronAPI: { invoke: vi.fn().mockRejectedValue(error) } }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const unhandled = vi.fn(); + process.on('unhandledRejection', unhandled); + try { + sendTerminalInput('panel-1', 'a'); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(unhandled).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith('[Terminal] Input was not delivered:', error); + } finally { + process.off('unhandledRejection', unhandled); + } + }); +}); diff --git a/frontend/src/utils/terminalInput.ts b/frontend/src/utils/terminalInput.ts new file mode 100644 index 000000000..275ce0332 --- /dev/null +++ b/frontend/src/utils/terminalInput.ts @@ -0,0 +1,12 @@ +/** + * Sends keyboard input to a terminal without waiting for delivery. + * + * Remote Pane rejects input it had to discard after a disconnect, a failed + * request, or a timeout. Handle that here so it never reaches the renderer's + * global unhandled-rejection alert, which would open a dialog per keystroke. + */ +export function sendTerminalInput(panelId: string, data: string): void { + void window.electronAPI.invoke('terminal:input', panelId, data).catch(error => { + console.warn('[Terminal] Input was not delivered:', error); + }); +} diff --git a/main/src/daemon/client/remoteInputQueue.test.ts b/main/src/daemon/client/remoteInputQueue.test.ts index d446d0df3..60a067904 100644 --- a/main/src/daemon/client/remoteInputQueue.test.ts +++ b/main/src/daemon/client/remoteInputQueue.test.ts @@ -43,6 +43,24 @@ describe('RemoteInputQueue lifecycle', () => { expect(send.mock.calls[0][2]?.aborted).toBe(true); }); + it('ends a combined batch at a bare Escape so the next key is not read as Alt+key', async () => { + const finish: Array<() => void> = []; + const send = vi.fn((_channel: string, _args: unknown[], _signal?: AbortSignal) => + new Promise(resolve => finish.push(resolve))); + const queue = new RemoteInputQueue(send); + // Typed text, then Esc, more text, then Esc, Esc, Up while the first key is in flight. + const completed = Promise.all(['a', 'b', '\x1b', 'c', '\x1b', '\x1b', '\x1b[A'].map(key => + queue.invoke('terminal:input', ['panel', key]))); + const expected = ['a', 'b\x1b', 'c\x1b', '\x1b', '\x1b[A']; + for (const [index, data] of expected.entries()) { + await vi.waitFor(() => expect(send).toHaveBeenCalledTimes(index + 1)); + expect(send.mock.calls[index][1]).toEqual(['panel', data]); + finish[index](); + } + await completed; + expect(send).toHaveBeenCalledTimes(expected.length); + }); + it('bounds combined batches without splitting individual input events', async () => { const finish: Array<() => void> = []; const send = vi.fn((_channel: string, _args: unknown[], _signal?: AbortSignal) => diff --git a/main/src/daemon/client/remoteTerminalInput.test.ts b/main/src/daemon/client/remoteTerminalInput.test.ts index 6d0de972e..6a375f4c2 100644 --- a/main/src/daemon/client/remoteTerminalInput.test.ts +++ b/main/src/daemon/client/remoteTerminalInput.test.ts @@ -96,15 +96,18 @@ describe.each([ ]); }); - it('preserves Unicode, paste boundaries, and control keys in the queued byte stream', async () => { + it('preserves Unicode, paste boundaries, and control keys, ending a write at a bare Escape', async () => { const { client, requests } = await setup(); - const keys = ['a', 'é🙂', '\x1b[200~pasted\ntext\x1b[201~', '\x7f', '\x1b[D', '\t', '\x03', '\r']; + const keys = ['a', 'é🙂', '\x1b[200~pasted\ntext\x1b[201~', '\x7f', '\x1b', '\x1b[D', '\t', '\x03', '\r']; const completed = Promise.allSettled(keys.map(key => client.invoke('terminal:input', ['panel-1', key]))); await vi.waitFor(() => expect(requests).toHaveLength(1)); reply(requests[0]); await vi.waitFor(() => expect(requests).toHaveLength(2)); - expect(requests[1].args[1]).toBe(keys.slice(1).join('')); + expect(requests[1].args[1]).toBe(keys.slice(1, 5).join('')); reply(requests[1]); + await vi.waitFor(() => expect(requests).toHaveLength(3)); + expect(requests[2].args[1]).toBe(keys.slice(5).join('')); + reply(requests[2]); expect((await completed).every(result => result.status === 'fulfilled')).toBe(true); }); diff --git a/shared/remoteInputQueue.ts b/shared/remoteInputQueue.ts index af97cc26c..5bcaf1087 100644 --- a/shared/remoteInputQueue.ts +++ b/shared/remoteInputQueue.ts @@ -18,6 +18,9 @@ interface PanelInputQueue { // Bound merged requests without splitting a paste or terminal escape sequence. const MAX_BATCH_CHARACTERS = 64 * 1024; const INPUT_TIMEOUT_MS = 10_000; +// A bare ESC must end its write. Terminal apps that parse by read boundary, such +// as Codex and Claude Code, treat ESC followed by a key in one read as Alt+key. +const ESCAPE = '\x1b'; const inputSchema = boundary.object({ panelId: boundary.nonEmptyString, data: boundary.string }); /** Serializes remote input per terminal, batching only while a request is in flight. */ @@ -43,7 +46,12 @@ export class RemoteInputQueue { } const result = new Promise((resolve, reject) => { const last = queue.pending[queue.pending.length - 1]; - if (last && last.channel === channel && last.data.length + data.length <= MAX_BATCH_CHARACTERS) { + if ( + last + && last.channel === channel + && !last.data.endsWith(ESCAPE) + && last.data.length + data.length <= MAX_BATCH_CHARACTERS + ) { last.data += data; last.waiters.push({ resolve, reject }); } else {