From d58772e2657756392080ae2c96cfb71beb6a005b Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Thu, 2 Jul 2026 18:30:46 -0700 Subject: [PATCH] Unify tab agent identity with the agent-status pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Brennan's direction on this PR: tab icons must not run their own foreground-process probe. Identity now flows through the same already-computed state as the sidebar agent rows — hook entries (kept live by the OSC 133 command-finished drop machinery), explicit titles, and launch bootstrap. The probe's one unique local job, clearing stale launch identity after the launched agent exits, is replaced with probe-free evidence: a completed hook at a shell title, or a previously observed live hook row that was dropped (crash/kill path) while the title reads as a shell. Behavior trades, accepted with this direction: agents that never emit hooks resolve from title evidence or show no icon, and a done-but-open agent whose title reads as a shell shows no icon (matches the existing remote-pane behavior). Co-authored-by: Orca --- .../src/components/tab-bar/SortableTab.tsx | 2 +- src/renderer/src/lib/tab-agent.ts | 7 +- src/renderer/src/lib/use-tab-agent.test.ts | 287 +++++------------- src/renderer/src/lib/use-tab-agent.ts | 176 ++--------- 4 files changed, 116 insertions(+), 356 deletions(-) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index e19f1258059..84ddb244d23 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -93,7 +93,7 @@ export default function SortableTab({ // Older persisted tabs without this field fall back to the generic icon. const shellForIcon = tab.shellOverride - // Why: foreground process and hook status make the tab icon reflect the + // Why: hook status and title evidence make the tab icon reflect the // coding harness currently running in the pane, not just the launch command. const tabAgent = useTabAgent(tab) diff --git a/src/renderer/src/lib/tab-agent.ts b/src/renderer/src/lib/tab-agent.ts index 2879734fada..a23d3cde5cc 100644 --- a/src/renderer/src/lib/tab-agent.ts +++ b/src/renderer/src/lib/tab-agent.ts @@ -5,10 +5,9 @@ import { agentTypeToIconAgent } from './agent-status' /** * Resolve a terminal tab's agent from hook-reported status. This is the - * FALLBACK signal for the tab-bar icon — the live foreground process - * (see useTabAgent) is the primary, dev-friendly source. Hook status is what - * drives the icon for SSH/remote panes (where foreground polling is too - * costly) and during the brief window before the first foreground poll lands. + * PRIMARY identity signal for the tab-bar icon (see useTabAgent) — the same + * already-computed state that drives the sidebar agent rows, kept live by the + * OSC 133 command-finished machinery that drops entries when a process exits. * * Prefers the focused pane's agent so a split tab's icon tracks the pane in * view; falls back to any agent pane in the tab. Returns null when no pane diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 9880fb9ce85..a60d610cac3 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -79,27 +79,10 @@ function twoPaneLayout(): TerminalLayoutSnapshot { } describe('resolveTabAgentFromSignals', () => { - it('uses a recognized foreground agent as the live local source of truth', () => { - expect( - resolveTabAgentFromSignals({ - foreground: 'codex', - hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, - isRemote: false, - title: 'Terminal 1', - hookAgent: 'claude', - hasCompletedHook: false, - launchAgent: 'claude' - }) - ).toBe('codex') - }) - it('keeps launch intent during the pre-start shell window', () => { expect( resolveTabAgentFromSignals({ - foreground: null, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'Terminal 1', hookAgent: null, @@ -109,27 +92,23 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('claude') }) - it('lets shell foreground clear stale identity even when the title still names an agent', () => { + it('trusts live hook identity at a shell title until the hook row is dropped', () => { expect( resolveTabAgentFromSignals({ - foreground: null, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: true, isRemote: false, - title: '✳ Claude Code', + title: 'zsh', hookAgent: 'claude', hasCompletedHook: false, launchAgent: 'claude' }) - ).toBeNull() + ).toBe('claude') }) it('maps OpenClaude titles to the distinct OpenClaude tab icon', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '⠋ OpenClaude', hookAgent: null, @@ -142,9 +121,7 @@ describe('resolveTabAgentFromSignals', () => { it('keeps title fallback for real Gemini, MiMo, and Pi titles', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✦ Gemini CLI', hookAgent: null, @@ -155,9 +132,7 @@ describe('resolveTabAgentFromSignals', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'MiMo Code', hookAgent: null, @@ -168,9 +143,7 @@ describe('resolveTabAgentFromSignals', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'π - my-project', hookAgent: null, @@ -183,9 +156,7 @@ describe('resolveTabAgentFromSignals', () => { it("uses completed OpenClaude hook identity over Claude's generic task-title heuristic", () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Say hi', hookAgent: null, @@ -199,9 +170,7 @@ describe('resolveTabAgentFromSignals', () => { it('keeps launch identity over title identity while hooks have not arrived', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Say hi', hookAgent: null, @@ -214,9 +183,7 @@ describe('resolveTabAgentFromSignals', () => { it("keeps Codex launch intent over Claude's generic spinner title fallback", () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '⠸ codex-quarter-flash-202606191419', hookAgent: null, @@ -229,9 +196,7 @@ describe('resolveTabAgentFromSignals', () => { it('does not infer Claude identity from a generic spinner title without context', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '⠸ investigating startup', hookAgent: null, @@ -245,9 +210,7 @@ describe('resolveTabAgentFromSignals', () => { for (const title of ['. investigating startup', '* investigating startup', '✳ investigating']) { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title, hookAgent: null, @@ -261,9 +224,7 @@ describe('resolveTabAgentFromSignals', () => { it('keeps launch identity over explicit title identity until stronger signals arrive', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '⠸ Claude Code', hookAgent: null, @@ -276,9 +237,7 @@ describe('resolveTabAgentFromSignals', () => { it("uses Codex hook identity over Claude's generic task-title heuristic", () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ improve-pr-actions-customization', hookAgent: 'codex', @@ -288,12 +247,10 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('codex') }) - it('keeps launch identity over explicit Claude Code titles without hook or foreground evidence', () => { + it('keeps launch identity over explicit Claude Code titles without hook evidence', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Claude Code', hookAgent: null, @@ -306,9 +263,7 @@ describe('resolveTabAgentFromSignals', () => { it('lets an explicit title override stale launch identity after the pane shows newer activity', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Claude Code', hookAgent: null, @@ -321,9 +276,7 @@ describe('resolveTabAgentFromSignals', () => { it('does not let an explicit title override launch identity before any activity is observed', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Claude Code', hookAgent: null, @@ -333,42 +286,10 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('codex') }) - it('lets shell foreground clear the icon after an agent was observed running', () => { - expect( - resolveTabAgentFromSignals({ - foreground: null, - hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: true, - isRemote: false, - title: 'zsh', - hookAgent: 'claude', - hasCompletedHook: false, - launchAgent: 'claude' - }) - ).toBeNull() - }) - - it('does not let a pre-start shell sample suppress a later hook signal', () => { - expect( - resolveTabAgentFromSignals({ - foreground: null, - hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, - isRemote: false, - title: 'Terminal 1', - hookAgent: 'codex', - hasCompletedHook: false, - launchAgent: 'codex' - }) - ).toBe('codex') - }) - it('prefers explicit hook identity over a conflicting title mention', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Gemini CLI', hookAgent: 'claude', @@ -381,9 +302,7 @@ describe('resolveTabAgentFromSignals', () => { it('prefers explicit hook identity over ordinary non-Claude title identity', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✦ Gemini CLI', hookAgent: 'claude', @@ -396,9 +315,7 @@ describe('resolveTabAgentFromSignals', () => { it('lets focused-pane hook identity override launch metadata in split tabs', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'Terminal 1', hookAgent: 'claude', @@ -412,9 +329,7 @@ describe('resolveTabAgentFromSignals', () => { it('keeps unresolved launch metadata ahead of sibling-pane hook fallback', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'Terminal 1', hookAgent: null, @@ -428,9 +343,7 @@ describe('resolveTabAgentFromSignals', () => { it('uses sibling-pane hook fallback when no launch metadata exists', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'Terminal 1', hookAgent: null, @@ -441,12 +354,10 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('claude') }) - it('keeps launch identity over Claude-owned task text without hook or foreground evidence', () => { + it('keeps launch identity over Claude-owned task text without hook evidence', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Gemini CLI', hookAgent: null, @@ -459,9 +370,7 @@ describe('resolveTabAgentFromSignals', () => { it('keeps launch identity over Claude-owned punctuation-prefixed task text', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '. Compare Opencode Vs Orca', hookAgent: null, @@ -472,9 +381,7 @@ describe('resolveTabAgentFromSignals', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '* Review Codex behavior', hookAgent: null, @@ -487,9 +394,7 @@ describe('resolveTabAgentFromSignals', () => { it('treats Claude-prefixed title text as Claude only when it names Claude', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '✳ Claude Code', hookAgent: null, @@ -500,9 +405,7 @@ describe('resolveTabAgentFromSignals', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, isRemote: false, title: '. Claude Code compare Opencode', hookAgent: null, @@ -512,12 +415,10 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('claude') }) - it('keeps local launch identity when only a shell title suggests exit', () => { + it('keeps launch identity at a shell title until hook evidence proves exit', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'zsh', hookAgent: null, @@ -527,12 +428,10 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('codex') }) - it('skips local foreground authority for remote worktrees', () => { + it('keeps hook identity for remote panes', () => { expect( resolveTabAgentFromSignals({ - foreground: null, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: true, isRemote: true, title: 'Terminal 1', hookAgent: 'codex', @@ -545,9 +444,7 @@ describe('resolveTabAgentFromSignals', () => { it('keeps completed remote hook identity after the terminal title returns to a shell', () => { expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: true, title: 'zsh', hookAgent: null, @@ -558,19 +455,21 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('codex') }) - it('keeps local launch identity after a completed hook until foreground proves shell exit', () => { + it('clears local launch identity once a completed hook and shell title prove exit', () => { + // Why: without foreground probing, a completed hook plus the title back at + // a shell is the process-gone evidence — the same signals that clear the + // sidebar row — so stale launch identity must not keep painting the tab. expect( resolveTabAgentFromSignals({ - foreground: undefined, hasObservedAgentSignal: true, - shellForegroundAfterAgentSignal: false, isRemote: false, title: 'zsh', hookAgent: null, hasCompletedHook: true, + completedHookAgent: 'claude', launchAgent: 'claude' }) - ).toBe('claude') + ).toBeNull() }) }) @@ -619,103 +518,86 @@ describe('useTabAgent', () => { window.api = originalApi }) - it('uses unrecognized non-shell foreground as launch lifecycle evidence', async () => { - getForegroundProcess.mockResolvedValueOnce('node').mockResolvedValueOnce('zsh') + it('never probes the foreground process', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + }, + agentStatusByPaneKey: { + [paneKey]: workingAgentStatus(paneKey) + } + }) + + const root = await renderHookProbe(baseTab) + await rerenderHookProbe(root, { ...baseTab, title: '✳ Codex' }) + await rerenderHookProbe(root, { ...baseTab, title: 'zsh' }) + + expect(latestHookAgent).toBe('codex') + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + + it('does not clear launch identity while the live hook row persists at a shell title', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + }, + agentStatusByPaneKey: { + [paneKey]: workingAgentStatus(paneKey) + } + }) + + await renderHookProbe({ ...baseTab, title: 'zsh' }) + + expect(latestHookAgent).toBe('codex') + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + }) + + it('clears launch identity when a previously observed hook row drops at a shell title', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' } + } + }, + agentStatusByPaneKey: { + [paneKey]: workingAgentStatus(paneKey) + } + }) const root = await renderHookProbe(baseTab) expect(latestHookAgent).toBe('codex') expect(clearTabLaunchAgent).not.toHaveBeenCalled() + // Why: crash/kill exits never fire a completed hook — the OSC 133 + // command-finished machinery drops the live entry and the title returns + // to a shell. That already-computed evidence must clear launch intent. + await act(async () => { + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) await rerenderHookProbe(root, { ...baseTab, title: 'zsh' }) - expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1') - expect(latestHookAgent).toBeNull() - expect(getForegroundProcess).toHaveBeenCalledTimes(2) - }) - - it('retries helper foreground so daemon-derived agent beats stale launch identity', async () => { - vi.useFakeTimers() - getForegroundProcess.mockResolvedValueOnce('uv').mockResolvedValueOnce('claude') - - try { - await renderHookProbe({ ...baseTab, launchAgent: 'opencode' }) - - expect(latestHookAgent).toBe('opencode') - expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - - await act(async () => { - await vi.advanceTimersByTimeAsync(250) - }) - await flushHookEffects() - - expect(getForegroundProcess).toHaveBeenCalledTimes(2) - expect(latestHookAgent).toBe('claude') - } finally { - vi.clearAllTimers() - vi.useRealTimers() - } - }) - - it('does not retry unknown helper foreground without launch intent', async () => { - vi.useFakeTimers() - getForegroundProcess.mockResolvedValue('uv') - - try { - await renderHookProbe({ ...baseTab, launchAgent: undefined }) - - await act(async () => { - await vi.advanceTimersByTimeAsync(10_000) - }) - await flushHookEffects() - - expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(latestHookAgent).toBeNull() - } finally { - vi.clearAllTimers() - vi.useRealTimers() - } - }) - - it('keeps one post-throttle shell retry to observe daemon-derived launch identity', async () => { - vi.useFakeTimers() - getForegroundProcess - .mockResolvedValueOnce('zsh') - .mockResolvedValueOnce('zsh') - .mockResolvedValueOnce('zsh') - .mockResolvedValueOnce('zsh') - .mockResolvedValueOnce('claude') - - try { - await renderHookProbe({ ...baseTab, launchAgent: 'opencode' }) - - expect(latestHookAgent).toBe('opencode') - expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - - await act(async () => { - await vi.advanceTimersByTimeAsync(250 + 1250 + 3500) - }) - await flushHookEffects() - - expect(getForegroundProcess).toHaveBeenCalledTimes(4) - expect(latestHookAgent).toBe('opencode') - - await act(async () => { - await vi.advanceTimersByTimeAsync(750) - }) - await flushHookEffects() - - expect(getForegroundProcess).toHaveBeenCalledTimes(5) - expect(latestHookAgent).toBe('claude') - } finally { - vi.clearAllTimers() - vi.useRealTimers() - } + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1') }) it('uses completed local hook status as launch lifecycle evidence after remount', async () => { const paneKey = makePaneKey('tab-1', LEAF_ID) - getForegroundProcess.mockResolvedValueOnce('zsh') useAppStore.setState({ agentStatusByPaneKey: { [paneKey]: completedAgentStatus(paneKey) @@ -724,9 +606,9 @@ describe('useTabAgent', () => { await renderHookProbe({ ...baseTab, title: 'zsh' }) - expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1') + expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1') expect(latestHookAgent).toBeNull() - expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(getForegroundProcess).not.toHaveBeenCalled() }) it('treats paired runtime PTYs as remote-like for completed hook fallback', async () => { @@ -771,7 +653,6 @@ describe('useTabAgent', () => { it('does not use completed sibling hook status as focused launch lifecycle evidence', async () => { const siblingPaneKey = makePaneKey('tab-1', SECOND_LEAF_ID) - getForegroundProcess.mockResolvedValueOnce('zsh') useAppStore.setState({ ptyIdsByTabId: { 'tab-1': ['pty-focus', 'pty-sibling'] }, terminalLayoutsByTabId: { 'tab-1': twoPaneLayout() }, @@ -789,6 +670,6 @@ describe('useTabAgent', () => { expect(latestHookAgent).toBe('claude') expect(clearTabLaunchAgent).not.toHaveBeenCalled() - expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-focus') + expect(getForegroundProcess).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 0b24f252eb4..ee61749fc69 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -1,7 +1,5 @@ -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: tab agent foreground state is synchronized from PTY/remote agent signals and shell foreground events. */ import { useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' -import { recognizeAgentProcess } from '../../../shared/agent-process-recognition' import { isShellProcess } from '../../../shared/agent-detection' import { worktreeUsesRemoteConnection } from '@/store/slices/terminals' import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' @@ -16,31 +14,8 @@ import type { TerminalTab, TuiAgent } from '../../../shared/types' export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from '../../../shared/terminal-title-agent-type' -const HELPER_FOREGROUND_RETRY_DELAYS_MS = [250, 1250, 3500, 750] as const - -function getTitleForegroundKey(title: string, launchAgent?: TuiAgent): string { - const titleAgent = launchAgent ? null : resolveExplicitTerminalTitleAgentType(title) - if (titleAgent) { - return `agent:${titleAgent}` - } - if (isShellProcess(title)) { - return 'shell' - } - const stableTitle = title - .trim() - .toLowerCase() - // Why: unknown agents may still animate leading status glyphs. Include the - // stable title body so first launch from "Terminal 1" triggers one poll, - // without polling on every spinner frame. - .replace(/^(?:[✳✦⏲◇✋⠀-⣿]+|[.*]\s)\s*/, '') - .slice(0, 48) - return `unknown:${stableTitle}` -} - export function resolveTabAgentFromSignals(args: { - foreground: TuiAgent | null | undefined hasObservedAgentSignal: boolean - shellForegroundAfterAgentSignal: boolean isRemote: boolean title: string hookAgent: TuiAgent | null @@ -66,52 +41,36 @@ export function resolveTabAgentFromSignals(args: { ? null : explicitTitleAgent const titleLooksShell = isShellProcess(args.title) - // Why: remote panes cannot cheaply prove shell foreground after hook exit, - // so keep the last completed hook identity instead of flashing unknown. + // Why: remote pane titles can lag their runtime, so keep the last completed + // hook identity instead of flashing unknown when the title reads as a shell. const completedHookAgent = !args.isRemote && titleLooksShell && args.hasCompletedHook ? null : args.completedHookAgent const focusedHookAgent = args.hookAgent ?? null const fallbackHookAgent = args.siblingHookAgent ?? completedHookAgent ?? null - const localShellForegroundClearedLaunch = - !args.isRemote && args.foreground === null && args.shellForegroundAfterAgentSignal - const remoteCompletedHookAtShellTitle = args.isRemote && titleLooksShell && args.hasCompletedHook - const activeLaunchAgent = - localShellForegroundClearedLaunch || remoteCompletedHookAtShellTitle ? null : launchAgent - // Why: titleAgent now ranks ahead of launch/fallback hooks because, once the + // Why: a completed hook with the title back at a shell is this pipeline's + // process-gone evidence — the same signals that clear the sidebar row. + const completedHookAtShellTitle = titleLooksShell && args.hasCompletedHook + const activeLaunchAgent = completedHookAtShellTitle ? null : launchAgent + // Why: titleAgent ranks ahead of launch/fallback hooks because, once the // pane has shown activity, a live explicit title is the freshest identity // signal — it beats a launchAgent gone stale through pane reuse. Before any // activity, titleAgent is null while launchAgent exists, so launch bootstrap // still wins the startup window. - if (args.isRemote || args.foreground === undefined) { - return focusedHookAgent ?? titleAgent ?? activeLaunchAgent ?? fallbackHookAgent - } - if (args.foreground) { - return args.foreground - } - // Why: once a local pane has returned to a shell, a stale hook should not keep - // painting it as an agent tab. - if (args.shellForegroundAfterAgentSignal) { - return null - } return focusedHookAgent ?? titleAgent ?? activeLaunchAgent ?? fallbackHookAgent } /** * Resolve which coding-harness agent a terminal tab is running, for its tab-bar - * icon. Layered signals, most-authoritative first: + * icon. Identity flows through the same already-computed state as the sidebar + * agent rows — no foreground probing. Layered signals, most-authoritative + * first: * - * 1. Live foreground process — the ground truth for what's running *now*: the - * only signal that reverts to the terminal glyph when the agent exits to a - * shell, or flips when a different agent starts in the same pane. Checked - * event-driven (only when the tab's title changes — exactly when an agent - * starts/exits/takes a turn), never on an interval, and only for local panes - * (SSH foreground inspection is a 15s-timeout RPC). A recognized agent wins; - * a recognized shell authoritatively means "no agent". - * 2. Hook status — accurate provider identity from native integrations, and - * available for SSH/remote panes where foreground polling is too costly. - * 3. launchAgent — what Orca launched here; instant bootstrap before hooks or - * foreground polling arrive, and the owned identity for startup windows. - * 4. Title — legacy/unknown-session fallback, and the live override when a pane + * 1. Hook status — provider identity from native integrations; the live entry + * for the pane, dropped by the same OSC 133 command-finished machinery that + * clears the sidebar row when the process exits. + * 2. launchAgent — what Orca launched here; instant bootstrap before hooks + * arrive, cleared once hook/title evidence shows the launched agent exited. + * 3. Title — legacy/unknown-session fallback, and the live override when a pane * is reused: once the pane has shown activity, a title that explicitly names * a different agent than launchAgent wins over that stale launch identity. * Otherwise it is ignored while launchAgent exists, and generic spinner-only @@ -142,7 +101,8 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { const hasCompletedHook = focusedCompletedHookAgent !== null const clearTabLaunchAgent = useAppStore((s) => s.clearTabLaunchAgent) - // The focused pane's PTY (single-pane tabs have exactly one leaf). + // The focused pane's PTY (single-pane tabs have exactly one leaf). Only used + // to reset per-process-generation signals when the pane is respawned. const ptyId = useAppStore((s) => { const layout = s.terminalLayoutsByTabId[tab.id] const activeLeafId = layout?.activeLeafId @@ -151,8 +111,6 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { return leafPty } const ptyIds = s.ptyIdsByTabId[tab.id] ?? [] - // Why: without a focused leaf, a split tab's first PTY can be a sibling - // shell. Only single-PTY fallback foreground is authoritative. return ptyIds.length === 1 ? ptyIds[0]! : null }) const hasRemoteRuntimePty = useAppStore((s) => { @@ -166,128 +124,50 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { const isRemoteWorktree = useAppStore((s) => worktreeUsesRemoteConnection(s, tab.worktreeId)) const isRemoteLike = isRemoteWorktree || hasRemoteRuntimePty - // undefined = no conclusive local reading (defer to title/hook/launchAgent); - // null = foreground is a shell; TuiAgent = recognized agent process. - const [foreground, setForeground] = useState(undefined) const [hasObservedAgentSignal, setHasObservedAgentSignal] = useState(false) - const [shellForegroundAfterAgentSignal, setShellForegroundAfterAgentSignal] = useState(false) const hasObservedAgentSignalRef = useRef(false) - const titleForegroundKey = getTitleForegroundKey(tab.title, tab.launchAgent) useEffect(() => { - setForeground(undefined) setHasObservedAgentSignal(false) hasObservedAgentSignalRef.current = false - setShellForegroundAfterAgentSignal(false) }, [ptyId, isRemoteLike]) useEffect(() => { const fallbackAgentSignal = !tab.launchAgent && (resolveExplicitTerminalTitleAgentType(tab.title) || siblingHookAgent) - // Why: a completed structured hook proves a launched agent existed, but - // local launch cleanup still waits for current foreground-shell evidence. if (focusedHookAgent || hasCompletedHook || fallbackAgentSignal) { hasObservedAgentSignalRef.current = true setHasObservedAgentSignal(true) } }, [focusedHookAgent, hasCompletedHook, siblingHookAgent, tab.launchAgent, tab.title]) - useEffect(() => { - if (!ptyId || isRemoteLike) { - return - } - const localPtyId = ptyId - let cancelled = false - const helperForegroundRetryTimers: number[] = [] - // Why: re-runs when ptyId or tab.title changes — a title change is the event - // signalling a possible foreground transition (agent start, exit, or turn). - // One RPC per transition, not a timer; cancellation coalesces rapid churn. - function readForeground(retryIndex = 0): void { - window.api.pty - .getForegroundProcess(localPtyId) - .then((process) => { - applyForegroundProcess(process, retryIndex) - }) - .catch(() => { - if (!cancelled) { - setForeground(undefined) - } - }) - } - function scheduleHelperForegroundRetry(retryIndex: number): void { - const delay = HELPER_FOREGROUND_RETRY_DELAYS_MS[retryIndex] - if (delay === undefined) { - return - } - // Why: the daemon resolves shell/helper -> agent ancestry asynchronously - // after the first foreground read, so give its short cache a bounded re-read. - const timer = window.setTimeout(() => { - readForeground(retryIndex + 1) - }, delay) - helperForegroundRetryTimers.push(timer) - } - function applyForegroundProcess(process: string | null, retryIndex: number): void { - if (cancelled) { - return - } - const recognized = recognizeAgentProcess(process) - if (recognized) { - hasObservedAgentSignalRef.current = true - setHasObservedAgentSignal(true) - setForeground(recognized.agent) - } else if (process && isShellProcess(process)) { - setShellForegroundAfterAgentSignal(hasObservedAgentSignalRef.current) - setForeground(null) - if (tab.launchAgent && !hasObservedAgentSignalRef.current) { - scheduleHelperForegroundRetry(retryIndex) - } - } else { - if (process && tab.launchAgent) { - // Why: for Orca-owned launches, an unrecognized non-shell process - // is enough lifecycle evidence to clear launch intent when the pane - // later returns to a shell, without using title text as identity. - hasObservedAgentSignalRef.current = true - setHasObservedAgentSignal(true) - } - setForeground(undefined) - if (process && tab.launchAgent) { - scheduleHelperForegroundRetry(retryIndex) - } - } - } - readForeground() - return () => { - cancelled = true - helperForegroundRetryTimers.forEach((timer) => window.clearTimeout(timer)) - } - }, [ptyId, isRemoteLike, tab.launchAgent, titleForegroundKey]) - useEffect(() => { if (!tab.launchAgent) { return } const titleLooksShell = isShellProcess(tab.title) - const foregroundSawExitedAgent = - !isRemoteLike && foreground === null && shellForegroundAfterAgentSignal - const remoteHookCompletedAtShellTitle = isRemoteLike && hasCompletedHook && titleLooksShell - if (foregroundSawExitedAgent || remoteHookCompletedAtShellTitle) { + // Why: launched-agent exit evidence without probing — either the hook + // completed, or the pane's live hook row was dropped by command-finished + // after the pane had shown agent activity, while the title reads as a + // shell again. Crash/kill paths land in the second disjunct because the + // OSC 133 drop removes the live entry without a completed hook. + const launchedAgentExited = + titleLooksShell && (hasCompletedHook || (hasObservedAgentSignal && !focusedHookAgent)) + if (launchedAgentExited) { clearTabLaunchAgent(tab.id) } }, [ clearTabLaunchAgent, - foreground, + focusedHookAgent, hasCompletedHook, - isRemoteLike, - shellForegroundAfterAgentSignal, + hasObservedAgentSignal, tab.id, tab.launchAgent, tab.title ]) return resolveTabAgentFromSignals({ - foreground, hasObservedAgentSignal, - shellForegroundAfterAgentSignal, isRemote: isRemoteLike, title: tab.title, hookAgent: focusedHookAgent,