From 202827ecfaac26a2c393be9206e3317133ecfa7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:07:16 -0700 Subject: [PATCH] Prevent tab icons from briefly showing the wrong agent (#5860) --- src/renderer/src/lib/tab-agent.test.ts | 42 +- src/renderer/src/lib/tab-agent.ts | 91 +++- src/renderer/src/lib/use-tab-agent.test.ts | 404 ++++++++++++++++-- src/renderer/src/lib/use-tab-agent.ts | 161 +++++-- .../src/runtime/web-session-tabs-sync.test.ts | 6 +- .../src/runtime/web-session-tabs-sync.ts | 9 +- 6 files changed, 629 insertions(+), 84 deletions(-) diff --git a/src/renderer/src/lib/tab-agent.test.ts b/src/renderer/src/lib/tab-agent.test.ts index 29252a0245b..e14583269df 100644 --- a/src/renderer/src/lib/tab-agent.test.ts +++ b/src/renderer/src/lib/tab-agent.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest' -import { hasCompletedTabAgent, resolveCompletedTabAgent, resolveTabAgent } from './tab-agent' +import { + hasCompletedTabAgent, + resolveCompletedTabAgent, + resolveFocusedCompletedTabAgent, + resolveFocusedTabAgent, + resolveSiblingCompletedTabAgent, + resolveSiblingTabAgent, + resolveTabAgent +} from './tab-agent' import type { AgentStatusEntry, AgentType } from '../../../shared/agent-status-types' import type { TerminalLayoutSnapshot } from '../../../shared/types' @@ -40,6 +48,15 @@ describe('resolveTabAgent', () => { expect(resolveTabAgent(map, layout(LEAF_B), 'tab-1')).toBe('codex') }) + it('exposes focused and sibling hook identity separately', () => { + const map = { + [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'claude'), + [`tab-1:${LEAF_B}`]: entry(`tab-1:${LEAF_B}`, 'codex') + } + expect(resolveFocusedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('claude') + expect(resolveSiblingTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('codex') + }) + it('falls back to any agent pane when the focused pane is a plain terminal', () => { // Focused leaf A has no entry (it's a shell); the split sibling runs Codex. const map = { [`tab-1:${LEAF_B}`]: entry(`tab-1:${LEAF_B}`, 'codex') } @@ -51,6 +68,13 @@ describe('resolveTabAgent', () => { expect(resolveTabAgent(map, undefined, 'tab-1')).toBe('droid') }) + it('treats same-tab hook identity as focused when the layout is missing', () => { + const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'codex') } + + expect(resolveFocusedTabAgent(map, undefined, 'tab-1')).toBe('codex') + expect(resolveSiblingTabAgent(map, undefined, 'tab-1')).toBeNull() + }) + it("keeps the terminal glyph for an agent that didn't identify itself", () => { const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'unknown') } expect(resolveTabAgent(map, layout(LEAF_A), 'tab-1')).toBeNull() @@ -77,6 +101,22 @@ describe('resolveTabAgent', () => { expect(resolveCompletedTabAgent(map, 'tab-1')).toBe('openclaude') }) + it('exposes focused and sibling completed hook identity separately', () => { + const map = { + [`tab-1:${LEAF_A}`]: { + ...entry(`tab-1:${LEAF_A}`, 'claude'), + state: 'done' as const + }, + [`tab-1:${LEAF_B}`]: { + ...entry(`tab-1:${LEAF_B}`, 'codex'), + state: 'done' as const + } + } + + expect(resolveFocusedCompletedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('claude') + expect(resolveSiblingCompletedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('codex') + }) + it('keeps the terminal glyph for an agent Orca has no icon for', () => { const map = { [`tab-1:${LEAF_A}`]: entry(`tab-1:${LEAF_A}`, 'totally-custom-agent') } expect(resolveTabAgent(map, layout(LEAF_A), 'tab-1')).toBeNull() diff --git a/src/renderer/src/lib/tab-agent.ts b/src/renderer/src/lib/tab-agent.ts index 6b1e9ad650b..2879734fada 100644 --- a/src/renderer/src/lib/tab-agent.ts +++ b/src/renderer/src/lib/tab-agent.ts @@ -18,16 +18,48 @@ export function resolveTabAgent( agentStatusByPaneKey: Record, layout: TerminalLayoutSnapshot | undefined, tabId: string +): TuiAgent | null { + return ( + resolveFocusedTabAgent(agentStatusByPaneKey, layout, tabId) ?? + resolveSiblingTabAgent(agentStatusByPaneKey, layout, tabId) + ) +} + +export function resolveFocusedTabAgent( + agentStatusByPaneKey: Record, + layout: TerminalLayoutSnapshot | undefined, + tabId: string ): TuiAgent | null { const activeLeafId = layout?.activeLeafId if (activeLeafId && isTerminalLeafId(activeLeafId)) { - const focused = agentFromStatusEntry(agentStatusByPaneKey[makePaneKey(tabId, activeLeafId)]) - if (focused) { - return focused - } + return agentFromStatusEntry(agentStatusByPaneKey[makePaneKey(tabId, activeLeafId)]) } + // Why: hook events can arrive while the terminal layout is temporarily + // unmounted; with no focused leaf to compare, same-tab hook status is primary. + return resolveAnyTabAgent(agentStatusByPaneKey, tabId) +} + +export function resolveSiblingTabAgent( + agentStatusByPaneKey: Record, + layout: TerminalLayoutSnapshot | undefined, + tabId: string +): TuiAgent | null { + const activeLeafId = + layout?.activeLeafId && isTerminalLeafId(layout.activeLeafId) ? layout.activeLeafId : null + if (!activeLeafId) { + return null + } + return resolveAnyTabAgent(agentStatusByPaneKey, tabId, activeLeafId) +} + +function resolveAnyTabAgent( + agentStatusByPaneKey: Record, + tabId: string, + excludedLeafId?: string +): TuiAgent | null { for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { - if (parsePaneKey(paneKey)?.tabId === tabId) { + const parsedPaneKey = parsePaneKey(paneKey) + if (parsedPaneKey?.tabId === tabId && parsedPaneKey.leafId !== excludedLeafId) { const agent = agentFromStatusEntry(entry) if (agent) { return agent @@ -53,11 +85,49 @@ export function hasCompletedTabAgent( export function resolveCompletedTabAgent( agentStatusByPaneKey: Record, + tabId: string, + layout?: TerminalLayoutSnapshot +): TuiAgent | null { + return ( + resolveFocusedCompletedTabAgent(agentStatusByPaneKey, layout, tabId) ?? + resolveSiblingCompletedTabAgent(agentStatusByPaneKey, layout, tabId) + ) +} + +export function resolveFocusedCompletedTabAgent( + agentStatusByPaneKey: Record, + layout: TerminalLayoutSnapshot | undefined, tabId: string +): TuiAgent | null { + const activeLeafId = layout?.activeLeafId + if (activeLeafId && isTerminalLeafId(activeLeafId)) { + return completedAgentFromStatusEntry(agentStatusByPaneKey[makePaneKey(tabId, activeLeafId)]) + } + return resolveAnyCompletedTabAgent(agentStatusByPaneKey, tabId) +} + +export function resolveSiblingCompletedTabAgent( + agentStatusByPaneKey: Record, + layout: TerminalLayoutSnapshot | undefined, + tabId: string +): TuiAgent | null { + const activeLeafId = + layout?.activeLeafId && isTerminalLeafId(layout.activeLeafId) ? layout.activeLeafId : null + if (!activeLeafId) { + return null + } + return resolveAnyCompletedTabAgent(agentStatusByPaneKey, tabId, activeLeafId) +} + +function resolveAnyCompletedTabAgent( + agentStatusByPaneKey: Record, + tabId: string, + excludedLeafId?: string ): TuiAgent | null { for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { - if (entry.state === 'done' && parsePaneKey(paneKey)?.tabId === tabId) { - const agent = agentTypeToIconAgent(entry.agentType) + const parsedPaneKey = parsePaneKey(paneKey) + if (parsedPaneKey?.tabId === tabId && parsedPaneKey.leafId !== excludedLeafId) { + const agent = completedAgentFromStatusEntry(entry) if (agent) { return agent } @@ -65,3 +135,10 @@ export function resolveCompletedTabAgent( } return null } + +function completedAgentFromStatusEntry(entry: AgentStatusEntry | undefined): TuiAgent | null { + if (!entry || entry.state !== 'done') { + return null + } + return agentTypeToIconAgent(entry.agentType) +} diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 51185ed93a6..4706ba32c81 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -1,5 +1,82 @@ -import { describe, expect, it } from 'vitest' -import { resolveTabAgentFromSignals } from './use-tab-agent' +// @vitest-environment happy-dom + +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { makePaneKey } from '../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot, TerminalTab, TuiAgent } from '../../../shared/types' +import { resolveTabAgentFromSignals, useTabAgent } from './use-tab-agent' + +const initialAppState = useAppStore.getInitialState() +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' +let latestHookAgent: TuiAgent | null | undefined +const hookRoots: Root[] = [] + +function HookProbe({ tab }: { tab: TerminalTab }): null { + latestHookAgent = useTabAgent(tab) + return null +} + +async function renderHookProbe(tab: TerminalTab): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + hookRoots.push(root) + await act(async () => { + root.render(createElement(HookProbe, { tab })) + }) + await flushHookEffects() + return root +} + +async function rerenderHookProbe(root: Root, tab: TerminalTab): Promise { + await act(async () => { + root.render(createElement(HookProbe, { tab })) + }) + await flushHookEffects() +} + +async function flushHookEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +function agentStatus(paneKey: string, state: AgentStatusEntry['state']): AgentStatusEntry { + return { + state, + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + agentType: 'codex', + paneKey, + stateHistory: [] + } +} + +function completedAgentStatus(paneKey: string): AgentStatusEntry { + return agentStatus(paneKey, 'done') +} + +function workingAgentStatus(paneKey: string): AgentStatusEntry { + return agentStatus(paneKey, 'working') +} + +function twoPaneLayout(): TerminalLayoutSnapshot { + return { + root: null, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { + [LEAF_ID]: 'pty-focus', + [SECOND_LEAF_ID]: 'pty-sibling' + } + } +} describe('resolveTabAgentFromSignals', () => { it('uses a recognized foreground agent as the live local source of truth', () => { @@ -57,7 +134,7 @@ describe('resolveTabAgentFromSignals', () => { title: '⠋ OpenClaude', hookAgent: null, hasCompletedHook: false, - launchAgent: 'claude' + launchAgent: undefined }) ).toBe('openclaude') }) @@ -106,7 +183,7 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('openclaude') }) - it('uses Claude-owned title identity before OpenClaude launch intent when hooks have not arrived', () => { + it('keeps launch identity over title identity while hooks have not arrived', () => { expect( resolveTabAgentFromSignals({ foreground: undefined, @@ -118,7 +195,69 @@ describe('resolveTabAgentFromSignals', () => { hasCompletedHook: false, launchAgent: 'openclaude' }) - ).toBe('claude') + ).toBe('openclaude') + }) + + 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, + hasCompletedHook: false, + launchAgent: 'codex' + }) + ).toBe('codex') + }) + + 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, + hasCompletedHook: false, + launchAgent: undefined + }) + ).toBeNull() + }) + + it('does not infer Claude identity from generic dot or star status titles', () => { + for (const title of ['. investigating startup', '* investigating startup', '✳ investigating']) { + expect( + resolveTabAgentFromSignals({ + foreground: undefined, + hasObservedAgentSignal: false, + shellForegroundAfterAgentSignal: false, + isRemote: false, + title, + hookAgent: null, + hasCompletedHook: false, + launchAgent: undefined + }) + ).toBeNull() + } + }) + + 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, + hasCompletedHook: false, + launchAgent: 'codex' + }) + ).toBe('codex') }) it("uses Codex hook identity over Claude's generic task-title heuristic", () => { @@ -136,7 +275,7 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('codex') }) - it('keeps explicit Claude Code titles authoritative over stale OpenClaude launch intent', () => { + it('keeps launch identity over explicit Claude Code titles without hook or foreground evidence', () => { expect( resolveTabAgentFromSignals({ foreground: undefined, @@ -148,7 +287,7 @@ describe('resolveTabAgentFromSignals', () => { hasCompletedHook: false, launchAgent: 'openclaude' }) - ).toBe('claude') + ).toBe('openclaude') }) it('lets shell foreground clear the icon after an agent was observed running', () => { @@ -211,7 +350,55 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('claude') }) - it('does not let launch intent turn Claude-owned task text into Gemini', () => { + 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', + siblingHookAgent: 'gemini', + hasCompletedHook: false, + launchAgent: 'codex' + }) + ).toBe('claude') + }) + + 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, + siblingHookAgent: 'claude', + hasCompletedHook: false, + launchAgent: 'codex' + }) + ).toBe('codex') + }) + + 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, + siblingHookAgent: 'claude', + hasCompletedHook: false, + launchAgent: undefined + }) + ).toBe('claude') + }) + + it('keeps launch identity over Claude-owned task text without hook or foreground evidence', () => { expect( resolveTabAgentFromSignals({ foreground: undefined, @@ -223,10 +410,10 @@ describe('resolveTabAgentFromSignals', () => { hasCompletedHook: false, launchAgent: 'gemini' }) - ).toBe('claude') + ).toBe('gemini') }) - it('does not let launch intent turn Claude-owned task text into OpenCode', () => { + it('keeps launch identity over Claude-owned punctuation-prefixed task text', () => { expect( resolveTabAgentFromSignals({ foreground: undefined, @@ -238,7 +425,7 @@ describe('resolveTabAgentFromSignals', () => { hasCompletedHook: false, launchAgent: 'opencode' }) - ).toBe('claude') + ).toBe('opencode') expect( resolveTabAgentFromSignals({ @@ -251,35 +438,50 @@ describe('resolveTabAgentFromSignals', () => { hasCompletedHook: false, launchAgent: 'codex' }) + ).toBe('codex') + }) + + 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, + hasCompletedHook: false, + launchAgent: undefined + }) + ).toBe('claude') + + expect( + resolveTabAgentFromSignals({ + foreground: undefined, + hasObservedAgentSignal: false, + shellForegroundAfterAgentSignal: false, + isRemote: false, + title: '. Claude Code compare Opencode', + hookAgent: null, + hasCompletedHook: false, + launchAgent: undefined + }) ).toBe('claude') }) - it('treats Claude-prefixed task text as Claude before launch intent when no hook arrived', () => { + it('keeps local launch identity when only a shell title suggests exit', () => { expect( resolveTabAgentFromSignals({ foreground: undefined, - hasObservedAgentSignal: false, + hasObservedAgentSignal: true, shellForegroundAfterAgentSignal: false, isRemote: false, - title: '✳ Gemini CLI', + title: 'zsh', hookAgent: null, hasCompletedHook: false, - launchAgent: undefined + launchAgent: 'codex' }) - ).toBe('claude') - - expect( - resolveTabAgentFromSignals({ - foreground: undefined, - hasObservedAgentSignal: false, - shellForegroundAfterAgentSignal: false, - isRemote: false, - title: '. Compare Opencode Vs Orca', - hookAgent: null, - hasCompletedHook: false, - launchAgent: undefined - }) - ).toBe('claude') + ).toBe('codex') }) it('skips local foreground authority for remote worktrees', () => { @@ -313,7 +515,7 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('codex') }) - it('suppresses stale local launch intent after a completed hook and shell title', () => { + it('keeps local launch identity after a completed hook until foreground proves shell exit', () => { expect( resolveTabAgentFromSignals({ foreground: undefined, @@ -325,6 +527,146 @@ describe('resolveTabAgentFromSignals', () => { hasCompletedHook: true, launchAgent: 'claude' }) - ).toBeNull() + ).toBe('claude') + }) +}) + +describe('useTabAgent', () => { + const originalApi = window.api + const getForegroundProcess = vi.fn() + const clearTabLaunchAgent = vi.fn() + const baseTab: TerminalTab = { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'codex' + } + + beforeEach(() => { + latestHookAgent = undefined + getForegroundProcess.mockReset() + clearTabLaunchAgent.mockReset() + useAppStore.setState(initialAppState, true) + useAppStore.setState({ + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + agentStatusByPaneKey: {}, + terminalLayoutsByTabId: {}, + clearTabLaunchAgent + }) + window.api = { + ...originalApi, + pty: { + ...originalApi?.pty, + getForegroundProcess + } + } as typeof window.api + }) + + afterEach(() => { + hookRoots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + window.api = originalApi + }) + + it('uses unrecognized non-shell foreground as launch lifecycle evidence', async () => { + getForegroundProcess.mockResolvedValueOnce('node').mockResolvedValueOnce('zsh') + + const root = await renderHookProbe(baseTab) + + expect(latestHookAgent).toBe('codex') + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + + await rerenderHookProbe(root, { ...baseTab, title: 'zsh' }) + + expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1') + expect(latestHookAgent).toBeNull() + expect(getForegroundProcess).toHaveBeenCalledTimes(2) + }) + + 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) + } + }) + + await renderHookProbe({ ...baseTab, title: 'zsh' }) + + expect(clearTabLaunchAgent).toHaveBeenCalledExactlyOnceWith('tab-1') + expect(latestHookAgent).toBeNull() + expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + }) + + it('treats paired runtime PTYs as remote-like for completed hook fallback', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + ptyIdsByTabId: { 'tab-1': ['remote:web-env-1@@terminal-1'] }, + agentStatusByPaneKey: { + [paneKey]: completedAgentStatus(paneKey) + } + }) + + await renderHookProbe({ + ...baseTab, + ptyId: 'remote:web-env-1@@terminal-1', + title: 'zsh', + launchAgent: undefined + }) + + expect(latestHookAgent).toBe('codex') + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + + it('does not let a split-tab fallback PTY suppress missing-layout hook identity', async () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + useAppStore.setState({ + ptyIdsByTabId: { 'tab-1': ['pty-shell', 'pty-agent'] }, + terminalLayoutsByTabId: {}, + agentStatusByPaneKey: { + [paneKey]: workingAgentStatus(paneKey) + } + }) + + await renderHookProbe({ + ...baseTab, + title: 'zsh', + launchAgent: 'claude' + }) + + expect(latestHookAgent).toBe('codex') + expect(getForegroundProcess).not.toHaveBeenCalled() + }) + + 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() }, + agentStatusByPaneKey: { + [siblingPaneKey]: completedAgentStatus(siblingPaneKey) + } + }) + + await renderHookProbe({ + ...baseTab, + ptyId: 'pty-focus', + title: 'zsh', + launchAgent: 'claude' + }) + + expect(latestHookAgent).toBe('claude') + expect(clearTabLaunchAgent).not.toHaveBeenCalled() + expect(getForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-focus') }) }) diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 8bbcf11fa86..dd1c55a43a5 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -2,9 +2,15 @@ import { useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' import { recognizeAgentProcess } from '../../../shared/agent-process-recognition' -import { isShellProcess, getAgentLabel } from '../../../shared/agent-detection' +import { isShellProcess, getAgentLabel, titleHasAgentName } from '../../../shared/agent-detection' import { worktreeUsesRemoteConnection } from '@/store/slices/terminals' -import { resolveCompletedTabAgent, resolveTabAgent } from './tab-agent' +import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' +import { + resolveFocusedCompletedTabAgent, + resolveFocusedTabAgent, + resolveSiblingCompletedTabAgent, + resolveSiblingTabAgent +} from './tab-agent' import type { TerminalTab, TuiAgent } from '../../../shared/types' // Maps getAgentLabel()'s product labels to TuiAgent ids — the fallback for @@ -33,8 +39,47 @@ function agentFromTitle(title: string): TuiAgent | null { return label ? (TITLE_LABEL_TO_AGENT[label] ?? null) : null } -function getTitleForegroundKey(title: string): string { +function containsBrailleSpinner(title: string): boolean { + for (const char of title) { + const codePoint = char.codePointAt(0) + if (codePoint !== undefined && codePoint >= 0x2800 && codePoint <= 0x28ff) { + return true + } + } + return false +} + +function hasGenericClaudeStatusPrefix(title: string): boolean { + return ( + containsBrailleSpinner(title) || + title.startsWith('✳ ') || + title === '✳' || + title.startsWith('. ') || + title.startsWith('* ') + ) +} + +function isGenericClaudeStatusClaim(title: string, titleAgent: TuiAgent | null): boolean { + return ( + titleAgent === 'claude' && + hasGenericClaudeStatusPrefix(title) && + !titleHasAgentName(title, 'claude') + ) +} + +function agentFromTabTitle(title: string): TuiAgent | null { const titleAgent = agentFromTitle(title) + if (isGenericClaudeStatusClaim(title, titleAgent)) { + // Why: bare Claude status prefixes are activity evidence, not identity. + // Keep them out of tab icons so task/worktree titles cannot become Claude + // without a hook, launch intent, foreground process, or explicit name. + return null + } + return titleAgent +} + +function getTitleForegroundKey(title: string, launchAgent?: TuiAgent): string { + const titleAgent = launchAgent ? null : agentFromTabTitle(title) if (titleAgent) { return `agent:${titleAgent}` } @@ -59,23 +104,27 @@ export function resolveTabAgentFromSignals(args: { isRemote: boolean title: string hookAgent: TuiAgent | null + siblingHookAgent?: TuiAgent | null hasCompletedHook: boolean completedHookAgent?: TuiAgent | null launchAgent?: TuiAgent }): TuiAgent | null { - const titleAgent = agentFromTitle(args.title) + const launchAgent = args.launchAgent ?? null + const titleAgent = launchAgent ? null : agentFromTabTitle(args.title) 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. const completedHookAgent = !args.isRemote && titleLooksShell && args.hasCompletedHook ? null : args.completedHookAgent - const hookAgent = args.hookAgent ?? completedHookAgent ?? null - const launchAgent = - args.hasCompletedHook || (titleLooksShell && args.hasObservedAgentSignal) - ? null - : (args.launchAgent ?? null) + 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 if (args.isRemote || args.foreground === undefined) { - return hookAgent ?? titleAgent ?? launchAgent + return focusedHookAgent ?? activeLaunchAgent ?? fallbackHookAgent ?? titleAgent } if (args.foreground) { return args.foreground @@ -85,7 +134,7 @@ export function resolveTabAgentFromSignals(args: { if (args.shellForegroundAfterAgentSignal) { return null } - return hookAgent ?? titleAgent ?? launchAgent + return focusedHookAgent ?? activeLaunchAgent ?? fallbackHookAgent ?? titleAgent } /** @@ -101,18 +150,34 @@ export function resolveTabAgentFromSignals(args: { * 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. Title — catches agents whose process name isn't self-identifying (Claude - * runs as `node`; its "✳ Claude Code" title still identifies it). - * 4. launchAgent — what Orca launched here; instant bootstrap before any check. + * 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 only. It is ignored while + * launchAgent exists, and generic spinner-only titles do not identify an agent. */ export function useTabAgent(tab: TerminalTab): TuiAgent | null { - const hookAgent = useAppStore((s) => - resolveTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id) + const focusedHookAgent = useAppStore((s) => + resolveFocusedTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id) ) - const completedHookAgent = useAppStore((s) => - resolveCompletedTabAgent(s.agentStatusByPaneKey, tab.id) + const siblingHookAgent = useAppStore((s) => + resolveSiblingTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id) ) - const hasCompletedHook = completedHookAgent !== null + const focusedCompletedHookAgent = useAppStore((s) => + resolveFocusedCompletedTabAgent( + s.agentStatusByPaneKey, + s.terminalLayoutsByTabId[tab.id], + tab.id + ) + ) + const siblingCompletedHookAgent = useAppStore((s) => + resolveSiblingCompletedTabAgent( + s.agentStatusByPaneKey, + s.terminalLayoutsByTabId[tab.id], + tab.id + ) + ) + const completedHookAgent = focusedCompletedHookAgent ?? siblingCompletedHookAgent + const hasCompletedHook = focusedCompletedHookAgent !== null const clearTabLaunchAgent = useAppStore((s) => s.clearTabLaunchAgent) // The focused pane's PTY (single-pane tabs have exactly one leaf). @@ -120,9 +185,24 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { const layout = s.terminalLayoutsByTabId[tab.id] const activeLeafId = layout?.activeLeafId const leafPty = activeLeafId ? layout?.ptyIdsByLeafId?.[activeLeafId] : undefined - return leafPty ?? s.ptyIdsByTabId[tab.id]?.[0] ?? null + if (leafPty) { + 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 isRemote = useAppStore((s) => worktreeUsesRemoteConnection(s, tab.worktreeId)) + const hasRemoteRuntimePty = useAppStore((s) => { + const layout = s.terminalLayoutsByTabId[tab.id] + const ptyIds = new Set(s.ptyIdsByTabId[tab.id] ?? []) + for (const ptyId of Object.values(layout?.ptyIdsByLeafId ?? {})) { + ptyIds.add(ptyId) + } + return [...ptyIds].some((ptyId) => parseRemoteRuntimePtyId(ptyId) !== 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. @@ -130,24 +210,28 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { const [hasObservedAgentSignal, setHasObservedAgentSignal] = useState(false) const [shellForegroundAfterAgentSignal, setShellForegroundAfterAgentSignal] = useState(false) const hasObservedAgentSignalRef = useRef(false) - const titleForegroundKey = getTitleForegroundKey(tab.title) + const titleForegroundKey = getTitleForegroundKey(tab.title, tab.launchAgent) useEffect(() => { setForeground(undefined) setHasObservedAgentSignal(false) hasObservedAgentSignalRef.current = false setShellForegroundAfterAgentSignal(false) - }, [ptyId, isRemote]) + }, [ptyId, isRemoteLike]) useEffect(() => { - if (agentFromTitle(tab.title) || hookAgent) { + const fallbackAgentSignal = + !tab.launchAgent && (agentFromTabTitle(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) } - }, [hookAgent, tab.title]) + }, [focusedHookAgent, hasCompletedHook, siblingHookAgent, tab.launchAgent, tab.title]) useEffect(() => { - if (!ptyId || isRemote) { + if (!ptyId || isRemoteLike) { return } let cancelled = false @@ -169,6 +253,13 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { setShellForegroundAfterAgentSignal(hasObservedAgentSignalRef.current) setForeground(null) } 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) } }) @@ -180,27 +271,24 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { return () => { cancelled = true } - }, [ptyId, isRemote, titleForegroundKey]) + }, [ptyId, isRemoteLike, tab.launchAgent, titleForegroundKey]) useEffect(() => { if (!tab.launchAgent) { return } const titleLooksShell = isShellProcess(tab.title) - const titleAgent = agentFromTitle(tab.title) const foregroundSawExitedAgent = - !isRemote && foreground === null && shellForegroundAfterAgentSignal && !titleAgent - const titleSawExitedAgent = titleLooksShell && hasObservedAgentSignal - const remoteHookCompletedAtShellTitle = isRemote && hasCompletedHook && titleLooksShell - if (foregroundSawExitedAgent || titleSawExitedAgent || remoteHookCompletedAtShellTitle) { + !isRemoteLike && foreground === null && shellForegroundAfterAgentSignal + const remoteHookCompletedAtShellTitle = isRemoteLike && hasCompletedHook && titleLooksShell + if (foregroundSawExitedAgent || remoteHookCompletedAtShellTitle) { clearTabLaunchAgent(tab.id) } }, [ clearTabLaunchAgent, foreground, hasCompletedHook, - hasObservedAgentSignal, - isRemote, + isRemoteLike, shellForegroundAfterAgentSignal, tab.id, tab.launchAgent, @@ -211,9 +299,10 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { foreground, hasObservedAgentSignal, shellForegroundAfterAgentSignal, - isRemote, + isRemote: isRemoteLike, title: tab.title, - hookAgent, + hookAgent: focusedHookAgent, + siblingHookAgent, hasCompletedHook, completedHookAgent, launchAgent: tab.launchAgent diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index bd7b9e7c92b..eb2c314a598 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -629,7 +629,7 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.activeTabIdByWorktree?.[WT]).toBe(mirroredId) }) - it('preserves mirrored launch intent when a later host snapshot omits it', () => { + it('drops mirrored launch intent when a later host snapshot omits it', () => { const existingTab: TerminalTab = { id: toWebTerminalSurfaceTabId('host-tab-1'), ptyId: 'remote:web-env-1@@terminal-1', @@ -666,9 +666,9 @@ describe('applyWebSessionTabsSnapshot', () => { expect(patch.tabsByWorktree?.[WT]?.[0]).toMatchObject({ id: existingTab.id, - title: 'zsh', - launchAgent: 'codex' + title: 'zsh' }) + expect(patch.tabsByWorktree?.[WT]?.[0]?.launchAgent).toBeUndefined() }) it('preserves quick command labels from host terminal surfaces', () => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index b0833485067..b0866941db4 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -507,9 +507,7 @@ function buildMirroredTerminalTabs( surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() || existing?.quickCommandLabel?.trim() const launchAgent = - activeSurface.launchAgent ?? - surfaces.find((surface) => surface.launchAgent)?.launchAgent ?? - existing?.launchAgent + activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent // Why: tab color/pin echo back through host snapshots, so prefer the client's // own record (kept authoritative in tabsByWorktree by the pin/color setters) // and fall back to the host value only when this client has no prior tab — @@ -533,9 +531,8 @@ function buildMirroredTerminalTabs( isPinned, sortOrder: sortOffset + index, createdAt: existing?.createdAt ?? now + index, - // Why: runtime snapshots can omit launchAgent after the process settles; - // keep the client-side launch intent so completed remote tabs do not - // briefly lose their provider icon between host status snapshots. + // Why: launchAgent is host-owned lifecycle metadata. If the host stops + // publishing it, mirrored clients must not resurrect stale startup intent. ...(launchAgent ? { launchAgent } : {}) }, hostTabId: parentTabId,