mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
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:
@@ -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: [],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user