From f908ba38bc38c4b4e6cfebba911f2741d292a5ce Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:27:47 -0700 Subject: [PATCH] Fix stale agent icons in terminal tabs (#11484) * fix(tabs): prefer retained agent identity for icons * test(tab-bar): include retained agent store state --- .../SortableTab.rename-shortcut.test.tsx | 2 + src/renderer/src/lib/tab-agent.test.ts | 54 ++++++- src/renderer/src/lib/tab-agent.ts | 47 ++++++ .../use-tab-agent-retained-identity.test.ts | 150 ++++++++++++++++++ src/renderer/src/lib/use-tab-agent.ts | 42 +++-- 5 files changed, 280 insertions(+), 15 deletions(-) create mode 100644 src/renderer/src/lib/use-tab-agent-retained-identity.test.ts diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index 0a5444f9798..fd275935f77 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -10,6 +10,7 @@ const storeState = vi.hoisted( agentStatusByPaneKey: Record clearTabLaunchAgent: ReturnType ptyIdsByTabId: Record + retainedAgentsByPaneKey: Record renamingTabId: string | null keybindings: Record repos: unknown[] @@ -21,6 +22,7 @@ const storeState = vi.hoisted( agentStatusByPaneKey: {}, clearTabLaunchAgent: vi.fn(), ptyIdsByTabId: {} as Record, + retainedAgentsByPaneKey: {}, renamingTabId: null as string | null, keybindings: {}, repos: [], diff --git a/src/renderer/src/lib/tab-agent.test.ts b/src/renderer/src/lib/tab-agent.test.ts index 770e5df9912..4d1f48837f1 100644 --- a/src/renderer/src/lib/tab-agent.test.ts +++ b/src/renderer/src/lib/tab-agent.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest' import { resolveFocusedCompletedTabAgent, + resolveFocusedRetainedTabAgent, resolveFocusedTabAgent, resolveSiblingCompletedTabAgent, + resolveSiblingRetainedTabAgent, resolveSiblingTabAgent } from './tab-agent' import type { AgentStatusEntry, AgentType } from '../../../shared/agent-status-types' -import type { TerminalLayoutSnapshot, TuiAgent } from '../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab, TuiAgent } from '../../../shared/types' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' // Composed exactly the way useTabAgent layers the resolvers: focused pane // first, then any sibling agent pane in the tab. @@ -37,6 +40,27 @@ function layout(activeLeafId: string | null): TerminalLayoutSnapshot { return { root: null, activeLeafId, expandedLeafId: null } } +function retainedEntry(paneKey: string, agentType: AgentType): RetainedAgentEntry { + const tabId = paneKey.slice(0, paneKey.indexOf(':')) + const tab: TerminalTab = { + id: tabId, + ptyId: null, + worktreeId: 'wt-1', + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + return { + entry: { ...entry(paneKey, agentType), state: 'done' }, + worktreeId: tab.worktreeId, + tab, + agentType, + startedAt: 0 + } +} + describe('resolveTabAgent', () => { it('returns null for a plain terminal (no agent entries)', () => { expect(resolveTabAgent({}, layout(LEAF_A), 'tab-1')).toBeNull() @@ -124,6 +148,34 @@ describe('resolveTabAgent', () => { expect(resolveSiblingCompletedTabAgent(map, layout(LEAF_A), 'tab-1')).toBe('codex') }) + it('resolves retained completion identity for the focused pane and siblings separately', () => { + const retained = { + [`tab-1:${LEAF_A}`]: retainedEntry(`tab-1:${LEAF_A}`, 'codex'), + [`tab-1:${LEAF_B}`]: retainedEntry(`tab-1:${LEAF_B}`, 'claude') + } + + expect(resolveFocusedRetainedTabAgent(retained, layout(LEAF_A), 'tab-1')).toBe('codex') + expect(resolveSiblingRetainedTabAgent(retained, layout(LEAF_A), 'tab-1')).toBe('claude') + }) + + it('treats a same-tab retained completion as focused while layout is unavailable', () => { + const retained = { + [`tab-1:${LEAF_A}`]: retainedEntry(`tab-1:${LEAF_A}`, 'codex') + } + + expect(resolveFocusedRetainedTabAgent(retained, undefined, 'tab-1')).toBe('codex') + expect(resolveSiblingRetainedTabAgent(retained, undefined, 'tab-1')).toBeNull() + }) + + it('does not leak retained identity from another tab', () => { + const retained = { + [`tab-2:${LEAF_A}`]: retainedEntry(`tab-2:${LEAF_A}`, 'codex') + } + + expect(resolveFocusedRetainedTabAgent(retained, layout(LEAF_A), 'tab-1')).toBeNull() + expect(resolveSiblingRetainedTabAgent(retained, layout(LEAF_A), 'tab-1')).toBeNull() + }) + 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 38b6a2dac12..3fa2da2a62f 100644 --- a/src/renderer/src/lib/tab-agent.ts +++ b/src/renderer/src/lib/tab-agent.ts @@ -1,6 +1,7 @@ import type { AgentStatusEntry } from '../../../shared/agent-status-types' import type { TerminalLayoutSnapshot, TuiAgent } from '../../../shared/types' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../shared/stable-pane-id' +import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { agentTypeToIconAgent } from './agent-status' /** @@ -110,3 +111,49 @@ function completedAgentFromStatusEntry(entry: AgentStatusEntry | undefined): Tui } return agentTypeToIconAgent(entry.agentType) } + +export function resolveFocusedRetainedTabAgent( + retainedAgentsByPaneKey: Record, + layout: TerminalLayoutSnapshot | undefined, + tabId: string +): TuiAgent | null { + const activeLeafId = layout?.activeLeafId + if (activeLeafId && isTerminalLeafId(activeLeafId)) { + return agentFromRetainedEntry(retainedAgentsByPaneKey[makePaneKey(tabId, activeLeafId)]) + } + return resolveAnyRetainedTabAgent(retainedAgentsByPaneKey, tabId) +} + +export function resolveSiblingRetainedTabAgent( + retainedAgentsByPaneKey: Record, + layout: TerminalLayoutSnapshot | undefined, + tabId: string +): TuiAgent | null { + const activeLeafId = + layout?.activeLeafId && isTerminalLeafId(layout.activeLeafId) ? layout.activeLeafId : null + if (!activeLeafId) { + return null + } + return resolveAnyRetainedTabAgent(retainedAgentsByPaneKey, tabId, activeLeafId) +} + +function resolveAnyRetainedTabAgent( + retainedAgentsByPaneKey: Record, + tabId: string, + excludedLeafId?: string +): TuiAgent | null { + for (const [paneKey, retained] of Object.entries(retainedAgentsByPaneKey)) { + const parsedPaneKey = parsePaneKey(paneKey) + if (parsedPaneKey?.tabId === tabId && parsedPaneKey.leafId !== excludedLeafId) { + const agent = agentFromRetainedEntry(retained) + if (agent) { + return agent + } + } + } + return null +} + +function agentFromRetainedEntry(entry: RetainedAgentEntry | undefined): TuiAgent | null { + return agentTypeToIconAgent(entry?.agentType) +} diff --git a/src/renderer/src/lib/use-tab-agent-retained-identity.test.ts b/src/renderer/src/lib/use-tab-agent-retained-identity.test.ts new file mode 100644 index 00000000000..cccc3e662fe --- /dev/null +++ b/src/renderer/src/lib/use-tab-agent-retained-identity.test.ts @@ -0,0 +1,150 @@ +// @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 { RetainedAgentEntry } from '@/store/slices/agent-status' +import type { AgentStatusEntry, AgentType } from '../../../shared/agent-status-types' +import { makePaneKey } from '../../../shared/stable-pane-id' +import type { TerminalLayoutSnapshot, TerminalTab, TuiAgent } from '../../../shared/types' +import { useTabAgent } from './use-tab-agent' + +const initialAppState = useAppStore.getInitialState() +const FOCUSED_LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SIBLING_LEAF_ID = '22222222-2222-4222-8222-222222222222' +const TAB_ID = 'tab-1' +const WORKTREE_ID = 'wt-1' +let latestAgent: TuiAgent | null | undefined +let root: Root | null = null + +const baseTab: TerminalTab = { + id: TAB_ID, + ptyId: 'pty-focused', + worktreeId: WORKTREE_ID, + title: 'Terminal 1', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + launchAgent: 'claude' +} + +function HookProbe({ tab }: { tab: TerminalTab }): null { + latestAgent = useTabAgent(tab) + return null +} + +function statusEntry(paneKey: string, agentType: AgentType, state: AgentStatusEntry['state']) { + return { + paneKey, + agentType, + state, + prompt: '', + updatedAt: 1, + stateStartedAt: 1, + stateHistory: [] + } satisfies AgentStatusEntry +} + +function retainedEntry(paneKey: string, agentType: AgentType): RetainedAgentEntry { + return { + entry: statusEntry(paneKey, agentType, 'done'), + worktreeId: WORKTREE_ID, + tab: baseTab, + agentType, + startedAt: 1 + } +} + +function layout(): TerminalLayoutSnapshot { + return { + root: null, + activeLeafId: FOCUSED_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { + [FOCUSED_LEAF_ID]: 'pty-focused', + [SIBLING_LEAF_ID]: 'pty-sibling' + } + } +} + +async function renderProbe(tab: TerminalTab = baseTab): Promise { + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + await act(async () => { + root?.render(createElement(HookProbe, { tab })) + await Promise.resolve() + }) +} + +describe('useTabAgent retained completion identity', () => { + beforeEach(() => { + latestAgent = undefined + useAppStore.setState(initialAppState, true) + useAppStore.setState({ + ptyIdsByTabId: { [TAB_ID]: ['pty-focused', 'pty-sibling'] }, + terminalLayoutsByTabId: { [TAB_ID]: layout() }, + agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + clearTabLaunchAgent: vi.fn() + }) + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + root = null + } + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + it('uses focused retained Codex identity over stale Claude launch metadata', async () => { + const paneKey = makePaneKey(TAB_ID, FOCUSED_LEAF_ID) + useAppStore.setState({ + retainedAgentsByPaneKey: { [paneKey]: retainedEntry(paneKey, 'codex') } + }) + + await renderProbe() + + expect(latestAgent).toBe('codex') + }) + + it('keeps a live focused hook ahead of retained identity', async () => { + const paneKey = makePaneKey(TAB_ID, FOCUSED_LEAF_ID) + useAppStore.setState({ + agentStatusByPaneKey: { + [paneKey]: statusEntry(paneKey, 'gemini', 'working') + }, + retainedAgentsByPaneKey: { [paneKey]: retainedEntry(paneKey, 'codex') } + }) + + await renderProbe() + + expect(latestAgent).toBe('gemini') + }) + + it('lets an explicit cross-agent title reclaim a retained idle pane', async () => { + const paneKey = makePaneKey(TAB_ID, FOCUSED_LEAF_ID) + useAppStore.setState({ + retainedAgentsByPaneKey: { [paneKey]: retainedEntry(paneKey, 'codex') } + }) + + await renderProbe({ ...baseTab, launchAgent: 'codex', title: '✳ Claude Code' }) + + expect(latestAgent).toBe('claude') + }) + + it('keeps focused launch metadata ahead of sibling retained identity', async () => { + const paneKey = makePaneKey(TAB_ID, SIBLING_LEAF_ID) + useAppStore.setState({ + retainedAgentsByPaneKey: { [paneKey]: retainedEntry(paneKey, 'codex') } + }) + + await renderProbe() + + expect(latestAgent).toBe('claude') + }) +}) diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 4d4bc66fce4..711f0f106d1 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -6,8 +6,10 @@ import { parseRemoteRuntimePtyId } from '@/runtime/runtime-terminal-stream' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import { resolveFocusedCompletedTabAgent, + resolveFocusedRetainedTabAgent, resolveFocusedTabAgent, resolveSiblingCompletedTabAgent, + resolveSiblingRetainedTabAgent, resolveSiblingTabAgent } from './tab-agent' import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' @@ -167,10 +169,10 @@ export function resolveTabAgentFromSignals(args: { * 1. Live focused hook — ground truth while the agent works; never title-overridden. * 2. Process identity — recognized foreground process (local only); re-owned within its title-identity group so OMP's nested `pi` (shell → omp → pi) can't flip the icon. * 3. Title — only a reuse override or legacy standalone identity; native OpenCode titles cannot displace durable ownership. - * 4. Idle focused identity — the pane's own completed hook; suppressed locally once OSC 133;D proves exit. + * 4. Idle focused identity — the pane's completed hook or sidebar-retained completion; suppressed locally once OSC 133;D proves exit. * 5. Sleeping session identity — current provider-session ownership. * 6. launchAgent — bootstrap before any hook/process signal; cleared once exit evidence shows it left. - * 7. Sibling-pane identity (live, then idle) — split-tab fallback. + * 7. Sibling-pane identity (live, then completed/retained) — split-tab fallback. */ export function useTabAgent(tab: TerminalTab): TuiAgent | null { const focusedHookAgent = useAppStore((s) => @@ -179,19 +181,31 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { const siblingHookAgent = useAppStore((s) => resolveSiblingTabAgent(s.agentStatusByPaneKey, s.terminalLayoutsByTabId[tab.id], tab.id) ) - const focusedCompletedHookAgent = useAppStore((s) => - resolveFocusedCompletedTabAgent( - s.agentStatusByPaneKey, - s.terminalLayoutsByTabId[tab.id], - tab.id - ) + const focusedCompletedHookAgent = useAppStore( + (s) => + resolveFocusedCompletedTabAgent( + s.agentStatusByPaneKey, + s.terminalLayoutsByTabId[tab.id], + tab.id + ) ?? + resolveFocusedRetainedTabAgent( + s.retainedAgentsByPaneKey, + s.terminalLayoutsByTabId[tab.id], + tab.id + ) ) - const siblingCompletedHookAgent = useAppStore((s) => - resolveSiblingCompletedTabAgent( - s.agentStatusByPaneKey, - s.terminalLayoutsByTabId[tab.id], - tab.id - ) + const siblingCompletedHookAgent = useAppStore( + (s) => + resolveSiblingCompletedTabAgent( + s.agentStatusByPaneKey, + s.terminalLayoutsByTabId[tab.id], + tab.id + ) ?? + resolveSiblingRetainedTabAgent( + s.retainedAgentsByPaneKey, + s.terminalLayoutsByTabId[tab.id], + tab.id + ) ) const hasCompletedHook = focusedCompletedHookAgent !== null const clearTabLaunchAgent = useAppStore((s) => s.clearTabLaunchAgent)