Fix stale agent icons in terminal tabs (#11484)

* fix(tabs): prefer retained agent identity for icons

* test(tab-bar): include retained agent store state
This commit is contained in:
Brennan Benson
2026-07-29 20:27:47 -07:00
committed by GitHub
parent 0c861d79b5
commit f908ba38bc
5 changed files with 280 additions and 15 deletions
@@ -10,6 +10,7 @@ const storeState = vi.hoisted(
agentStatusByPaneKey: Record<string, unknown>
clearTabLaunchAgent: ReturnType<typeof vi.fn>
ptyIdsByTabId: Record<string, string[]>
retainedAgentsByPaneKey: Record<string, unknown>
renamingTabId: string | null
keybindings: Record<string, unknown>
repos: unknown[]
@@ -21,6 +22,7 @@ const storeState = vi.hoisted(
agentStatusByPaneKey: {},
clearTabLaunchAgent: vi.fn(),
ptyIdsByTabId: {} as Record<string, string[]>,
retainedAgentsByPaneKey: {},
renamingTabId: null as string | null,
keybindings: {},
repos: [],
+53 -1
View File
@@ -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()
+47
View File
@@ -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<string, RetainedAgentEntry>,
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<string, RetainedAgentEntry>,
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<string, RetainedAgentEntry>,
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)
}
@@ -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<void> {
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')
})
})
+28 -14
View File
@@ -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)