diff --git a/src/renderer/src/components/sidebar/WorktreeCard.test.ts b/src/renderer/src/components/sidebar/WorktreeCard.test.ts index 35c351f3457..406bd5752b1 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeCard.test.ts @@ -53,7 +53,7 @@ describe('getWorktreeStatus', () => { ) ).toBe('permission') expect( - getWorktreeStatus([makeTerminalTab('mimo working')], [{ id: 'browser-1' }], livePtyIds) + getWorktreeStatus([makeTerminalTab('⠋ working - codex')], [{ id: 'browser-1' }], livePtyIds) ).toBe('working') }) }) diff --git a/src/renderer/src/components/sidebar/smart-attention-title-status.ts b/src/renderer/src/components/sidebar/smart-attention-title-status.ts new file mode 100644 index 00000000000..9d7c3b07598 --- /dev/null +++ b/src/renderer/src/components/sidebar/smart-attention-title-status.ts @@ -0,0 +1,19 @@ +import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter' +import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' +import type { TuiAgent } from '../../../../shared/tui-agent' +import type { AgentStatus } from '../../../../shared/agent-detection' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' + +/** Attribute title activity through the canonical identity ladder. */ +export function resolveAttributedTitleStatus( + title: string, + launchAgent?: TuiAgent | null +): AgentStatus | null { + return resolveCanonicalPaneAgentIdentity({ + launchAgent: launchAgent ?? null, + title, + uncoveredFallback: { agent: resolveExplicitTerminalTitleAgentType(title), titleOnly: true } + }).agent !== null + ? classifyTitleActivity(title) + : null +} diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 6249ad60d77..7b32ed40ddf 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -1,4 +1,4 @@ -import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' +import { isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { resolveDecayedAgentRowState } from '@/lib/agent-row-decay-state' @@ -15,6 +15,8 @@ import { type MigrationUnsupportedPtyEntry } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { resolveAttributedTitleStatus } from './smart-attention-title-status' /** * Ordinal class for the "Smart" sort. Lower number = more attention-demanding. @@ -291,7 +293,7 @@ export type TabPaneInputSources = { * stale working-pattern title can't leak through. */ export function collectTabPaneInputs( - tab: Pick, + tab: Pick & { launchAgent?: TuiAgent | null }, worktreeLastActivityAt: number, sources: TabPaneInputSources, now: number @@ -326,7 +328,7 @@ export function collectTabPaneInputs( // Why: unmounted tabs (restored-but-unvisited) expose only the legacy tab title. panes.push({ kind: 'title', - status: classifyTitleActivity(tab.title), + status: resolveAttributedTitleStatus(tab.title, tab.launchAgent), worktreeLastActivityAt }) } @@ -343,7 +345,11 @@ export function collectTabPaneInputs( if ((leafId !== null && hookLeafIds.has(leafId)) || hasSingleUnmappedHook) { continue } - panes.push({ kind: 'title', status: classifyTitleActivity(title), worktreeLastActivityAt }) + panes.push({ + kind: 'title', + status: resolveAttributedTitleStatus(title, tab.launchAgent), + worktreeLastActivityAt + }) } return panes } diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index bd410ddc2a2..5750ccde677 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -124,6 +124,25 @@ describe('buildWorktreeAgentRows', () => { expect(rows[0].agentType).toBe('claude') }) + it('does not resolve retained unknown rows from a bare free-text title', () => { + const retained = makeRetained(ORPHAN_PANE_KEY, 'wt-1', 1000, { + entry: makeEntry(ORPHAN_PANE_KEY, 1000, { + agentType: 'unknown', + terminalTitle: 'grok' + }), + tab: { ...makeTab('tab-orphan'), title: 'grok' }, + agentType: 'unknown' + }) + const rows = buildWorktreeAgentRows({ + tabs: [], + entries: [], + retained: [retained], + now: 2000 + }) + + expect(rows[0].agentType).toBe('unknown') + }) + it('resolves live unknown rows from the launched tab agent', () => { const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1', { launchAgent: 'codex', title: 'test-thing-2' })], @@ -140,7 +159,7 @@ describe('buildWorktreeAgentRows', () => { expect(rows[0].agentType).toBe('codex') }) - it('prefers an unrelated live title over the launched tab agent for unknown rows', () => { + it('keeps the launched tab agent over an unrelated live title for unknown rows', () => { const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1', { launchAgent: 'omp', title: '\u280b Codex' })], entries: [ @@ -153,7 +172,8 @@ describe('buildWorktreeAgentRows', () => { now: 2000 }) - expect(rows[0].agentType).toBe('codex') + // A launch record is a fact Orca owns; a title is a decoration channel. + expect(rows[0].agentType).toBe('omp') }) it('normalizes live Pi-compatible rows from the launched OMP tab agent', () => { diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-type.ts b/src/renderer/src/components/sidebar/worktree-agent-row-type.ts index 46dc5ca821f..ad8cf067e3d 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-type.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-type.ts @@ -1,30 +1,26 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' -import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' -import { resolveAgentTypeFromTerminalTitle } from './worktree-title-derived-agent-rows' +import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter' +import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' +import { agentTypeToIconAgent } from '@/lib/agent-status' /** * Resolves the sidebar row agent type, prioritizing launch agent configuration * and normalizing compatible agent kinds. */ export function resolveRowAgentType(entry: AgentStatusEntry, tab?: TerminalTab | null): AgentType { - const launchOwner = { ownerIsLaunch: Boolean(tab?.launchAgent) } - const entryAgentType = resolveCompatibleAgentTypeForOwner( - entry.agentType, - tab?.launchAgent, - launchOwner - ) - if (entryAgentType && entryAgentType !== 'unknown') { - return entryAgentType - } - return ( - resolveAgentTypeFromTerminalTitle( - entry.terminalTitle ?? tab?.title, - tab?.launchAgent, - launchOwner - ) ?? - tab?.launchAgent ?? - entryAgentType ?? - 'unknown' - ) + const entryAgent = agentTypeToIconAgent(entry.agentType) + const title = entry.terminalTitle ?? tab?.title + const canonical = resolveCanonicalPaneAgentIdentity({ + hookAgent: entry.state === 'done' ? null : entryAgent, + hookIsLive: true, + completedHookAgent: entry.state === 'done' ? entryAgent : null, + launchAgent: tab?.launchAgent ?? null, + title, + uncoveredFallback: { + agent: title ? resolveExplicitTerminalTitleAgentType(title) : null, + titleOnly: true + } + }) + return canonical.agent ?? tab?.launchAgent ?? entry.agentType ?? 'unknown' } diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index c5de2eb1813..02c5d465730 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -51,8 +51,8 @@ describe('buildTitleDerivedAgentRows', () => { retained: [], runtimePaneTitlesByTabId: { 'tab-1': { - 1: 'Antigravity', - 2: '⠋ Codex' + 1: 'idle - antigravity', + 2: '⠋ compiling - codex' } }, ptyIdsByTabId: { 'tab-1': ['pty-left', 'pty-right'] }, @@ -135,7 +135,7 @@ describe('buildTitleDerivedAgentRows', () => { entries: [], retained: [], runtimePaneTitlesByTabId: { - 'tab-parent': { 1: '⠋ Codex' }, + 'tab-parent': { 1: '⠋ parent task - codex' }, 'tab-child': { 1: '⠋ Claude Code' } }, ptyIdsByTabId: { @@ -222,7 +222,7 @@ describe('buildTitleDerivedAgentRows', () => { ).toEqual([['codex', 'working', 'Codex', '⠼ demo-repo']]) }) - it('keeps explicit title identity over the launched agent', () => { + it('keeps launch identity over a conflicting title', () => { const launchAgent: TuiAgent = 'claude' const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1', { launchAgent })], @@ -236,7 +236,7 @@ describe('buildTitleDerivedAgentRows', () => { now: 2000 }) - expect(rows.map((row) => [row.agentType, row.state])).toEqual([['codex', 'working']]) + expect(rows.map((row) => [row.agentType, row.state])).toEqual([['claude', 'working']]) }) it('produces no row for a spinner-only title when the tab has no launch identity', () => { @@ -361,10 +361,10 @@ describe('buildTitleDerivedAgentRows', () => { }) expect(rowsFor('⠋ Claude Code').map((row) => row.agentType)).toEqual(['claude']) - // Pane reuse: the user exited OpenCode and ran claude in the same pane. - expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['claude']) - // No owner to defend the pane: naming Claude stays the only available identity. - expect(rowsFor('⠋ use Claude Sonnet').map((row) => row.agentType)).toEqual(['claude']) + // Launch ownership remains stronger than the conflicting title. + expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['opencode']) + // A name inside task prose is free text, not identity. + expect(rowsFor('⠋ use Claude Sonnet')).toHaveLength(0) expect(rowsFor('zsh', 'opencode')).toHaveLength(0) }) diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 08c0d27b9e7..0270bf84916 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -21,7 +21,12 @@ import { type CompatibleAgentOwnerOptions } from '../../../../shared/agent-title-owner' import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' -import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type' +import { + isClaudeIdentityFrameTitle, + resolveExplicitTerminalTitleAgentType +} from '../../../../shared/terminal-title-agent-type' +import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter' +import type { TuiAgent } from '../../../../shared/tui-agent' /** Fixed, not per-process: title rows are a pure projection of the current title, so they are * comparable across restarts in a way a sequenced authority's rows are not. Ordering against @@ -93,6 +98,10 @@ export function buildTitleDerivedAgentRows(args: { leafId, title, ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId), + launchAgent: + paneTitleEntries.length === 1 && layout?.root?.type !== 'split' + ? tab.launchAgent + : undefined, now: args.now, runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey }) @@ -114,6 +123,7 @@ export function buildTitleDerivedAgentRows(args: { leafId, title: tab.title, ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId), + launchAgent: layout?.root?.type === 'leaf' ? tab.launchAgent : undefined, now: args.now, runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey }) @@ -136,6 +146,7 @@ function buildTitleDerivedAgentRow(args: { leafId: string title: string ownerAgentType: AgentType | null + launchAgent?: TuiAgent now: number runtimeAgentOrchestrationByPaneKey?: Record }): DashboardAgentRow | null { @@ -164,15 +175,26 @@ function buildTitleDerivedAgentRow(args: { } const paneKey = makePaneKey(args.tab.id, args.leafId) const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey] - const titleAgentType = isClaudeAgentsTitle - ? 'claude' - : resolveTitleDerivedAgentType(title, label, args.ownerAgentType) + const canonicalIdentity = resolveCanonicalPaneAgentIdentity({ + launchAgent: args.launchAgent ?? null, + title, + uncoveredFallback: { agent: resolveExplicitTerminalTitleAgentType(title), titleOnly: true } + }) + const titleAgentType = + canonicalIdentity.source === 'title' + ? (canonicalIdentity.agent as AgentType | null) + : isClaudeAgentsTitle + ? 'claude' + : null // Why: a status frame proves activity, not identity, so the resolver drops it. // Hook-less agents over SSH (Codex, #8711; OpenCode's '. '/'* ' frames, #8940) // surface only decorated task titles; fall back to the pane's known owner instead // of hiding the pane. Safe because the `!status || !label` gate above already // rejects plain shell titles — this path must never manufacture a row from one. - const agentType = titleAgentType ?? args.ownerAgentType + const agentType = + (canonicalIdentity.agent as AgentType | null) ?? + args.ownerAgentType ?? + (isClaudeAgentsTitle ? 'claude' : null) if (!agentType) { return null } diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts index 08b4b4bc9d9..c269a063778 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.test.ts @@ -342,7 +342,7 @@ describe('workspace space presentation helpers', () => { it('counts hookless title-derived running agents as active workspace usage', () => { const count = countWorkspaceSpaceActiveAgents({ worktreeId: 'wt', - tabs: [{ id: 'tab-1', title: 'Codex working' }], + tabs: [{ id: 'tab-1', title: '⠋ working - codex' }], agentStatusByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimePaneTitlesByTabId: {}, @@ -356,7 +356,7 @@ describe('workspace space presentation helpers', () => { it('does not count title-derived agents when the terminal has no live pty', () => { const count = countWorkspaceSpaceActiveAgents({ worktreeId: 'wt', - tabs: [{ id: 'tab-1', title: 'Codex working' }], + tabs: [{ id: 'tab-1', title: '⠋ working - codex' }], agentStatusByPaneKey: {}, migrationUnsupportedByPtyId: {}, runtimePaneTitlesByTabId: {}, diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts index 5edad9ec739..8c49b57e742 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -7,6 +7,9 @@ import { } from '../../../../shared/agent-status-types' import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' +import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter' +import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' import type { WorkspaceSpaceItem, @@ -39,7 +42,7 @@ export type WorkspaceSpaceDeleteReadiness = { export type WorkspaceSpaceAgentActivityInputs = { worktreeId: string - tabs: readonly Pick[] + tabs: readonly (Pick & { launchAgent?: TuiAgent | null })[] agentStatusByPaneKey: Record migrationUnsupportedByPtyId: Record runtimePaneTitlesByTabId: Record> @@ -72,7 +75,7 @@ function isActiveAgentState(entry: Pick): boolean { } function countTitleActiveAgentsForTab( - tab: Pick, + tab: Pick & { launchAgent?: TuiAgent | null }, runtimePaneTitlesByTabId: Record>, ptyIdsByTabId: Record ): number { @@ -84,12 +87,25 @@ function countTitleActiveAgentsForTab( if (paneTitles && Object.keys(paneTitles).length > 0) { return Object.values(paneTitles).filter((title) => { const status = classifyTitleActivity(title) - return status === 'working' || status === 'permission' + const identity = resolveCanonicalPaneAgentIdentity({ + launchAgent: tab.launchAgent ?? null, + title, + uncoveredFallback: { agent: resolveExplicitTerminalTitleAgentType(title), titleOnly: true } + }) + return (status === 'working' || status === 'permission') && identity.agent !== null }).length } const status = classifyTitleActivity(tab.title) - return status === 'working' || status === 'permission' ? 1 : 0 + const identity = resolveCanonicalPaneAgentIdentity({ + launchAgent: tab.launchAgent ?? null, + title: tab.title, + uncoveredFallback: { + agent: resolveExplicitTerminalTitleAgentType(tab.title), + titleOnly: true + } + }) + return (status === 'working' || status === 'permission') && identity.agent !== null ? 1 : 0 } export function countWorkspaceSpaceActiveAgents({ diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts index 38d6f2e267f..35e1bcb4b9a 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.test.ts @@ -28,31 +28,25 @@ function splitLayout(activeLeafId: string | null): TerminalLayoutSnapshot { } describe('selectTabAgentTypesByTabId', () => { - it('maps each tab to its first pane agent type, matching findTabAgentEntry', () => { + it('maps each tab through canonical sibling evidence when no layout is hydrated', () => { const map: Record = { 'tab-1:leaf-a': entry({ agentType: 'claude' }), 'tab-1:leaf-b': entry({ agentType: 'codex' }), 'tab-2:leaf-a': entry({ agentType: 'codex' }) } const projection = selectTabAgentTypesByTabId(map) - expect(projection).toEqual({ 'tab-1': 'claude', 'tab-2': 'codex' }) - - // Parity with the lookup it replaces, for every tab. - for (const tabId of ['tab-1', 'tab-2', 'tab-missing']) { - expect(projection[tabId] ?? null).toBe(findTabAgentEntry(map, tabId)?.agentType ?? null) - } + // Conflicting sibling agents are ambiguous and must not silently choose + // the first insertion-order entry. + expect(projection).toEqual({ 'tab-2': 'codex' }) + expect(findTabAgentEntry(map, 'tab-1')?.agentType).toBe('claude') }) - it('a first pane without an agentType yields null even if a later pane has one', () => { + it('uses a sole recognized sibling when the first pane has no agentType', () => { const map: Record = { 'tab-1:leaf-a': entry({ agentType: undefined }), 'tab-1:leaf-b': entry({ agentType: 'claude' }) } - // First matching pane wins (claims the tab) with no agentType -> null, exactly - // like findTabAgentEntry(...)?.agentType ?? null. - expect(selectTabAgentTypesByTabId(map)['tab-1'] ?? null).toBe( - findTabAgentEntry(map, 'tab-1')?.agentType ?? null - ) + expect(selectTabAgentTypesByTabId(map)['tab-1']).toBe('claude') }) it('uses the active split leaf regardless of pane-map insertion order', () => { diff --git a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts index 420949992d8..830be3f74cc 100644 --- a/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts +++ b/src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts @@ -1,9 +1,12 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' +import type { TuiAgent } from '../../../../shared/tui-agent' import { isNativeChatTabWideFallbackSafe, resolveNativeChatActiveLayoutLeafId } from '../native-chat/native-chat-leaf-routing' +import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter' +import { agentTypeToIconAgent } from '@/lib/agent-status' type TabBarAgentProjectionSelectorDependencies = { onStatusEntryVisited?: (paneKey: string) => void @@ -65,10 +68,17 @@ function projectTabAgentTypesByTabId( continue } const entry = agentStatusByPaneKey[`${tabId}:${activeLeafId}`] - if (entry?.agentType != null) { - byTabId[tabId] = entry.agentType + const entryAgent = agentTypeToIconAgent(entry?.agentType) + const identity = resolveCanonicalPaneAgentIdentity({ + hookAgent: entry?.state === 'done' ? null : entryAgent, + hookIsLive: true, + completedHookAgent: entry?.state === 'done' ? entryAgent : null + }) + if (identity.agent != null) { + byTabId[tabId] = identity.agent } } + const siblingAgentsByTabId = new Map() for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) { dependencies?.onStatusEntryVisited?.(paneKey) const colon = paneKey.indexOf(':') @@ -79,9 +89,21 @@ function projectTabAgentTypesByTabId( if (claimed.has(tabId)) { continue } - claimed.add(tabId) - if (entry.agentType != null) { - byTabId[tabId] = entry.agentType + const entryAgent = agentTypeToIconAgent(entry.agentType) + if (!entryAgent) { + continue + } + const agents = siblingAgentsByTabId.get(tabId) + if (agents) { + agents.push(entryAgent) + } else { + siblingAgentsByTabId.set(tabId, [entryAgent]) + } + } + for (const [tabId, siblingAgents] of siblingAgentsByTabId) { + const identity = resolveCanonicalPaneAgentIdentity({ siblingAgents, allowSibling: true }) + if (identity.agent != null) { + byTabId[tabId] = identity.agent } } return byTabId diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts index bf367f93667..b6209c7b446 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.test.ts @@ -144,7 +144,7 @@ describe('resolveTerminalTabActivityStatus', () => { vi.setSystemTime(31 * 60 * 1000) expect( resolveTerminalTabActivityStatus({ - tab: { id: TAB_ID, title: 'Codex working' }, + tab: { id: TAB_ID, title: '⠋ working - codex' }, agentStatusByPaneKey: { [stale.paneKey]: stale }, ptyIdsByTabId: LIVE_PTY }) @@ -155,7 +155,7 @@ describe('resolveTerminalTabActivityStatus', () => { const restored = entry(FIRST_LEAF_ID, 'working', { restoredUnconfirmed: true }) expect( resolveTerminalTabActivityStatus({ - tab: { id: TAB_ID, title: 'Codex working' }, + tab: { id: TAB_ID, title: '⠋ working - codex' }, agentStatusByPaneKey: { [restored.paneKey]: restored }, ptyIdsByTabId: LIVE_PTY }) @@ -168,7 +168,9 @@ describe('resolveTerminalTabActivityStatus', () => { resolveTerminalTabActivityStatus({ tab: TAB, agentStatusByPaneKey: { [restored.paneKey]: restored }, - runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working', 2: 'Claude working' } }, + runtimePaneTitlesByTabId: { + [TAB_ID]: { 1: '⠋ working - codex', 2: '⠋ working - claude' } + }, ptyIdsByTabId: LIVE_PTY, terminalLayout: { root: { @@ -214,8 +216,8 @@ describe('resolveTerminalTabActivityStatus', () => { it('does not treat a preserved title on a sleeping tab as activity', () => { expect( resolveTerminalTabActivityStatus({ - tab: { id: TAB_ID, title: 'Codex working' }, - runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'Codex working' } }, + tab: { id: TAB_ID, title: '⠋ working - codex' }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: '⠋ working - codex' } }, ptyIdsByTabId: { [TAB_ID]: [] } }) ).toBe('inactive') diff --git a/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts index caa14841166..c104055aa9b 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-spinner-launch-agent.test.ts @@ -30,7 +30,7 @@ describe('#9040 terminal tab dot attributes spinner titles to the launched agent // Control: the named-provider path this must stay at parity with. it('reports working for a named-provider title', () => { const status = resolveTerminalTabActivityStatus({ - tab: { id: 'tab-1', title: 'claude [working]' } as TerminalTab, + tab: { id: 'tab-1', title: '⠋ working - claude' } as TerminalTab, ptyIdsByTabId: { 'tab-1': ['pty-0'] } }) diff --git a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts index accbbaa1c80..0f095e8266c 100644 --- a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts +++ b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.test.ts @@ -12,7 +12,7 @@ describe('resolveNativeChatLeafTitleAgent', () => { resolveNativeChatLeafTitleAgent({ leafId: 'leaf-2', panes, - runtimePaneTitlesByPaneId: { 1: 'PowerShell', 2: 'Codex - working' }, + runtimePaneTitlesByPaneId: { 1: 'PowerShell', 2: '⠋ working - codex' }, tabLabel: 'PowerShell' }) ).toBe('codex') @@ -40,14 +40,37 @@ describe('resolveNativeChatLeafTitleAgent', () => { ).toBeNull() }) - it('falls back to the terminal title in a single pane', () => { + it('keeps launch identity over a conflicting single-pane terminal title', () => { expect( resolveNativeChatLeafTitleAgent({ leafId: 'leaf-1', panes: [panes[0]], runtimePaneTitlesByPaneId: {}, - terminalTitle: 'OpenClaude' + terminalTitle: 'Claude Code', + launchAgent: 'openclaude' }) ).toBe('openclaude') }) + + it('uses anchored title identity when no stronger evidence exists', () => { + expect( + resolveNativeChatLeafTitleAgent({ + leafId: 'leaf-1', + panes: [panes[0]], + runtimePaneTitlesByPaneId: {}, + terminalTitle: 'Claude Code' + }) + ).toBe('claude') + }) + + it('rejects a bare free-text title when no stronger evidence exists', () => { + expect( + resolveNativeChatLeafTitleAgent({ + leafId: 'leaf-1', + panes: [panes[0]], + runtimePaneTitlesByPaneId: {}, + terminalTitle: 'grok' + }) + ).toBeNull() + }) }) diff --git a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts index 4bf7b230ffa..194781374c8 100644 --- a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts +++ b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts @@ -1,5 +1,9 @@ import type { TuiAgent } from '../../../../shared/tui-agent' -import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' +import { + resolveCanonicalPaneAgentIdentity, + type ForegroundProcessProof +} from '../../../../shared/pane-agent-identity-adapter' +import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' export type NativeChatLeafTitlePane = { id: number @@ -12,6 +16,14 @@ export type NativeChatLeafTitleAgentInput = { runtimePaneTitlesByPaneId: Readonly> tabLabel?: string | null terminalTitle?: string | null + /** Optional pane-scoped evidence for callers that already hold it. */ + hookAgent?: TuiAgent | null + completedHookAgent?: TuiAgent | null + launchAgent?: TuiAgent | null + sleepingSessionAgent?: TuiAgent | null + siblingAgent?: TuiAgent | null + processAgent?: TuiAgent | null + processProof?: ForegroundProcessProof | null } export function resolveNativeChatLeafTitleAgent({ @@ -19,25 +31,41 @@ export function resolveNativeChatLeafTitleAgent({ panes, runtimePaneTitlesByPaneId, tabLabel, - terminalTitle + terminalTitle, + hookAgent, + completedHookAgent, + launchAgent, + sleepingSessionAgent, + siblingAgent, + processAgent, + processProof }: NativeChatLeafTitleAgentInput): TuiAgent | null { if (!leafId) { return null } const targetPane = panes.find((pane) => pane.leafId === leafId) - const paneAgent = targetPane - ? resolveCommittedTitleAgentType(runtimePaneTitlesByPaneId[targetPane.id] ?? '') - : null - if (paneAgent) { - return paneAgent - } - // Tab titles can lag pane focus in split layouts, so use them only when there - // is no sibling leaf they could accidentally describe. - if (panes.length > 1) { - return null - } - return ( - resolveCommittedTitleAgentType(tabLabel ?? '') ?? - resolveCommittedTitleAgentType(terminalTitle ?? '') - ) + const paneTitle = targetPane ? (runtimePaneTitlesByPaneId[targetPane.id] ?? '') : null + // Tab titles can lag pane focus, so only a single-leaf tab may use that fallback. + const title = paneTitle?.trim() + ? paneTitle + : panes.length > 1 + ? null + : (tabLabel ?? terminalTitle ?? null) + const resolved = resolveCanonicalPaneAgentIdentity({ + hookAgent, + hookIsLive: true, + completedHookAgent, + launchAgent, + processProof, + foregroundAgent: processAgent, + sleepingSessionAgent, + siblingAgent, + allowSibling: siblingAgent != null, + title, + uncoveredFallback: { + agent: title ? resolveExplicitTerminalTitleAgentType(title) : null, + titleOnly: true + } + }) + return resolved.agent } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index b3f6a59ba5c..395f172dab6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -15,6 +15,7 @@ import { parseAppSshPtyId } from '../../../../../shared/ssh-pty-id' import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' +import { resolveCanonicalPaneAgentIdentity } from '../../../../../shared/pane-agent-identity-adapter' import type { TuiAgent } from '../../../../../shared/tui-agent' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config' @@ -27,7 +28,14 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { // can't drift and silently reintroduce the icon bug this fix closes. session.paneHasLiveHookAgentIcon = (state: ReturnType): boolean => { const entry = state.agentStatusByPaneKey[session.cacheKey] - return entry?.state !== 'done' && Boolean(agentTypeToIconAgent(entry?.agentType)) + if (entry?.state === 'done') { + return false + } + const identity = resolveCanonicalPaneAgentIdentity({ + hookAgent: agentTypeToIconAgent(entry?.agentType), + hookIsLive: true + }) + return Boolean(identity.agent) } // Why: one ladder for both launch-agent signals; a second copy could drift. const resolveLaunchAgentCandidate = ( diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts index d1714b19ed8..d564accfded 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.test.ts @@ -82,7 +82,8 @@ describe('createTerminalTabAgentTypeSelector', () => { } } - expect(select({}, 'tab-1', foreground)).toEqual({ 'leaf-a': 'codex' }) + // A bare foreground name is only an uncovered hint; no icon is projected without a host proof. + expect(select({}, 'tab-1', foreground)).toEqual({}) expect(select({ 'tab-1:leaf-a': entry('claude') }, 'tab-1', foreground)).toEqual({ 'leaf-a': 'claude' }) @@ -97,4 +98,23 @@ describe('createTerminalTabAgentTypeSelector', () => { }) ).toEqual({}) }) + + it('lets a fresh foreground proof outrank a completed hook for the same pane', () => { + const select = createTerminalTabAgentTypeSelector() + expect( + select({ 'tab-1:leaf-a': entry('claude', 'done') }, 'tab-1', { + 'tab-1:leaf-a': { + agent: 'codex', + processProof: { + agent: 'codex', + processIncarnation: 'fixture-process', + authorityId: 'fixture-authority', + capturedAgeMs: 10, + validForMs: 1_000 + }, + shellForeground: false + } + }) + ).toEqual({ 'leaf-a': 'codex' }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts index f14e96763e7..f0737e131fe 100644 --- a/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts +++ b/src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts @@ -1,5 +1,10 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' +import { + resolveCanonicalPaneAgentIdentity, + type ForegroundProcessProof +} from '../../../../shared/pane-agent-identity-adapter' +import { agentTypeToIconAgent } from '@/lib/agent-status' export type TerminalTabAgentTypeState = Record export type TerminalTabAgentTypesByLeaf = Readonly> @@ -13,6 +18,13 @@ const EMPTY_FOREGROUND_AGENT_BY_PANE_KEY: Record + completedHookAgent?: ReturnType + processAgent?: PaneForegroundAgentEntry['agent'] + processProof?: ForegroundProcessProof | null +} + function reuseRecordIfEqual( previous: TerminalTabAgentTypesByLeaf | undefined, next: Record @@ -43,29 +55,41 @@ export function createTerminalTabAgentTypeSelector( // Zustand notifications skip the global scan entirely. if (state !== cachedState || foreground !== cachedForeground) { const previousByTabId = cachedByTabId - const nextByTabId = new Map>() + const evidenceByPaneKey = new Map() for (const [paneKey, entry] of Object.entries(state)) { dependencies.onEntryVisited?.(paneKey) - if (!entry.agentType) { + const entryAgent = agentTypeToIconAgent(entry.agentType) + if (!entryAgent) { continue } - const separator = paneKey.indexOf(':') - if (separator <= 0) { - continue - } - const entryTabId = paneKey.slice(0, separator) - const leafId = paneKey.slice(separator + 1) - const byLeaf = nextByTabId.get(entryTabId) - if (byLeaf) { - byLeaf[leafId] = entry.agentType - } else { - nextByTabId.set(entryTabId, { [leafId]: entry.agentType }) - } + evidenceByPaneKey.set( + paneKey, + entry.state === 'done' ? { completedHookAgent: entryAgent } : { hookAgent: entryAgent } + ) } for (const [paneKey, entry] of Object.entries(foreground)) { if (!entry.agent || entry.shellForeground || entry.routingRevoked) { continue } + const existing = evidenceByPaneKey.get(paneKey) ?? {} + evidenceByPaneKey.set(paneKey, { + ...existing, + processAgent: entry.agent, + processProof: entry.processProof + }) + } + const nextByTabId = new Map>() + for (const [paneKey, evidence] of evidenceByPaneKey) { + const identity = resolveCanonicalPaneAgentIdentity({ + hookAgent: evidence.hookAgent, + hookIsLive: evidence.hookAgent != null, + completedHookAgent: evidence.completedHookAgent, + foregroundAgent: evidence.processAgent, + processProof: evidence.processProof + }) + if (!identity.agent) { + continue + } const separator = paneKey.indexOf(':') if (separator <= 0) { continue @@ -74,9 +98,9 @@ export function createTerminalTabAgentTypeSelector( const leafId = paneKey.slice(separator + 1) const byLeaf = nextByTabId.get(entryTabId) if (byLeaf) { - byLeaf[leafId] ??= entry.agent + byLeaf[leafId] = identity.agent } else { - nextByTabId.set(entryTabId, { [leafId]: entry.agent }) + nextByTabId.set(entryTabId, { [leafId]: identity.agent }) } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts index 2fd75f3f093..1d2cb842272 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts @@ -119,7 +119,9 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController panes: managerRef.current?.getPanes() ?? [], runtimePaneTitlesByPaneId, tabLabel: hasSingleKnownLeaf ? unifiedTabLabel : null, - terminalTitle: hasSingleKnownLeaf ? terminalTab?.title : null + terminalTitle: hasSingleKnownLeaf ? terminalTab?.title : null, + launchAgent: terminalTab?.launchAgent ?? null, + sleepingSessionAgent: (structuredSessionAgent as TuiAgent | null) ?? null }) }, // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. @@ -127,6 +129,8 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController getNativeChatLeafIds, getTabWideAgentHintLeafId, runtimePaneTitlesByPaneId, + structuredSessionAgent, + terminalTab?.launchAgent, terminalTab?.title, unifiedTabLabel ] diff --git a/src/renderer/src/lib/open-tab-occupant-agent.test.ts b/src/renderer/src/lib/open-tab-occupant-agent.test.ts index 85ab0f4cb49..489fe70d040 100644 --- a/src/renderer/src/lib/open-tab-occupant-agent.test.ts +++ b/src/renderer/src/lib/open-tab-occupant-agent.test.ts @@ -110,8 +110,10 @@ describe('resolveOpenTabOccupantAgent', () => { ).toBe('grok') }) - it('uses the tab-strip title identity when hooks have not reported yet', () => { - expect(resolve({ title: 'grok' })).toBe('grok') + it('does not guess an occupant from a bare title name', () => { + // `grok` is free-text-only evidence; the canonical resolver requires an anchored owner + // suffix (for example `Task - grok`) before a title-only fallback can identify a pane. + expect(resolve({ title: 'grok' })).toBeNull() }) it('does not let a grok mention in the title steal a launched Claude pane', () => { diff --git a/src/renderer/src/lib/open-tab-occupant-agent.ts b/src/renderer/src/lib/open-tab-occupant-agent.ts index 5f99e0f7505..8d2bdff1298 100644 --- a/src/renderer/src/lib/open-tab-occupant-agent.ts +++ b/src/renderer/src/lib/open-tab-occupant-agent.ts @@ -3,7 +3,6 @@ import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-ag import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import type { AgentStatusEntry } from '../../../shared/agent-status-types' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' -import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' import { @@ -62,25 +61,20 @@ export function resolveOpenTabOccupantAgent({ const sleepingSessionAgent = focusedPaneKey ? (sleepingAgentSessionsByPaneKey[focusedPaneKey]?.agent ?? null) : null - const oscTitle = title?.trim() || '' - const explicitTitleAgent = resolveExplicitTerminalTitleAgentType(oscTitle) - const fallbackAgentSignal = launchAgent - ? explicitTitleAgent === launchAgent - : Boolean(explicitTitleAgent || siblingHookAgent) - return resolveTabAgentFromSignals({ hasObservedAgentSignal: Boolean( - hookAgent || focusedCompletedHookAgent || processAgent || fallbackAgentSignal + hookAgent || focusedCompletedHookAgent || processAgent || launchAgent || siblingHookAgent ), // Search does not observe OSC 133;D, so do not apply local-only exit clearing. isRemote: true, - title: oscTitle, + title: title?.trim() || '', defaultTitle, hookAgent, siblingHookAgent, focusedCompletedHookAgent, siblingCompletedHookAgent, processAgent, + processProof: process?.processProof, processShellForeground: Boolean(process?.shellForeground), sleepingSessionAgent, launchAgent diff --git a/src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts b/src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts index 59a218b2c9b..9d3c234a1d9 100644 --- a/src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts +++ b/src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts @@ -41,7 +41,7 @@ function titleRowObservation(now: number): AgentStatusObservation { tabs: [makeTab('tab-1')], entries: [], retained: [], - runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ Codex' } }, + runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ observing - codex' } }, ptyIdsByTabId: { 'tab-1': ['pty-1'] }, terminalLayoutsByTabId: { 'tab-1': makeSingleLayout() }, now diff --git a/src/renderer/src/lib/tab-agent-from-signals.ts b/src/renderer/src/lib/tab-agent-from-signals.ts index d4f5403b379..8c396d7f808 100644 --- a/src/renderer/src/lib/tab-agent-from-signals.ts +++ b/src/renderer/src/lib/tab-agent-from-signals.ts @@ -1,14 +1,10 @@ import { isShellProcess } from '../../../shared/agent-detection' import { - isClaudeIdentityFrameTitle, - resolveExplicitTerminalTitleAgentType -} from '../../../shared/terminal-title-agent-type' -import { - resolveCompatibleAgentTypeForOwner, - shareCompatibleTitleIdentityGroup -} from '../../../shared/agent-title-owner' -import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' -import { resolvePaneAgentOwnerRecord } from '../../../shared/pane-agent-owner' + resolveCanonicalPaneAgentIdentity, + type ForegroundProcessProof +} from '../../../shared/pane-agent-identity-adapter' +import type { PaneAgentRunKey } from '../../../shared/pane-agent-identity-resolver' +import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import type { TuiAgent } from '../../../shared/tui-agent' // A shell name or the tab's neutral default title (where inferred-interrupt reset parks it); blank titles are no evidence. @@ -17,21 +13,6 @@ function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim()) } -/** - * Resolves wrapper-compatible signal identity against the pane owner. - */ -function resolveSignalAgentForLaunchOwner( - signalAgent: TuiAgent | null | undefined, - ownerAgent: TuiAgent | null, - ownerIsLaunch = false -): TuiAgent | null { - if (!signalAgent) { - return null - } - return (resolveCompatibleAgentTypeForOwner(signalAgent, ownerAgent, { ownerIsLaunch }) ?? - signalAgent) as TuiAgent -} - /** * Probe-free evidence a launched agent exited: title shows no agent, no live * hook remains, and either the hook completed or observed activity vanished. @@ -62,8 +43,15 @@ export function resolveLaunchedAgentExitEvidence(args: { } /** - * Identity-first precedence: live hook > process > title > completed > sleeping - * > launch > sibling. Same-group titles (OMP wraps Pi) are not reuse evidence. + * Resolve the tab's display identity through the canonical pane ladder. + * + * This adapter only translates the tab's focused/sibling slots. A bare process name remains a + * hint until a host-stamped proof is supplied, and title parsing belongs to the canonical call. + * The source order is live-hook > process > launch > completed-hook > sleeping-session > sibling + * > title. Launch intentionally outranks completed-hook for now: a completed hook is newer + * evidence in the abstract, but run-key staleness is not wired (undefined run keys are eligible), + * so it can describe a previous occupant of a reused pane; launch is scoped to this pane's setup. + * Once production supplies run keys, the ordering must be revisited (see the expiry test). */ export function resolveTabAgentFromSignals(args: { hasObservedAgentSignal: boolean @@ -74,98 +62,50 @@ export function resolveTabAgentFromSignals(args: { siblingHookAgent?: TuiAgent | null focusedCompletedHookAgent?: TuiAgent | null siblingCompletedHookAgent?: TuiAgent | null + siblingAgents?: readonly TuiAgent[] processAgent?: TuiAgent | null + processProof?: ForegroundProcessProof | null processShellForeground?: boolean sleepingSessionAgent?: TuiAgent | null launchAgent?: TuiAgent + hookRun?: PaneAgentRunKey + completedHookRun?: PaneAgentRunKey + launchRun?: PaneAgentRunKey + sleepingRun?: PaneAgentRunKey + currentRun?: PaneAgentRunKey }): TuiAgent | null { - const launchAgent = args.launchAgent ?? null - // Durable focused-pane owner (launch intent → hook → session); focused-pane-scoped so a sibling can't re-own the focused title (would mislabel a Pi pane as OMP). - const ownerRecord = resolvePaneAgentOwnerRecord({ - launchAgent, - hookAgent: args.hookAgent, - completedHookAgent: args.focusedCompletedHookAgent, - sleepingSessionAgent: args.sleepingSessionAgent - }) - const owner = (ownerRecord?.agent ?? null) as TuiAgent | null - const ownerIsLaunch = ownerRecord?.ownerIsLaunch === true - - // The live/idle split governs title override; siblings normalize against launch intent only. - const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner, ownerIsLaunch) - const liveSiblingIdentity = resolveSignalAgentForLaunchOwner( + const siblingAgents = [ args.siblingHookAgent, - launchAgent, - Boolean(launchAgent) - ) - // Why: OSC 133;D proves this local pane returned to shell, so the idle identity is stale; remote titles lag runtime, so keep it there. - const processProvesShell = !args.isRemote && args.processShellForeground === true - const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null - const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle) - const idleIdentitySuppressed = - !args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook - const idleFocusedIdentity = idleIdentitySuppressed - ? null - : resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner, ownerIsLaunch) - // Why: idleIdentitySuppressed is the FOCUSED pane's exit evidence, so it must not clear a sibling's idle identity. - const idleSiblingIdentity = resolveSignalAgentForLaunchOwner( args.siblingCompletedHookAgent, - launchAgent, - Boolean(launchAgent) - ) - const sleepingSessionAgent = args.sleepingSessionAgent ?? null - - // Title carries identity only as a reuse override (names a DIFFERENT-group agent) or a legacy standalone id when no hook — same-group titles say nothing (OMP wraps Pi), so the record wins. - const rawTitleAgent = resolveExplicitTerminalTitleAgentType(args.title) - const explicitTitleAgent = resolveSignalAgentForLaunchOwner(rawTitleAgent, owner, ownerIsLaunch) - const priorIdentity = idleFocusedIdentity ?? launchAgent - const nativeOpenCodeTitle = explicitTitleAgent === 'opencode' && isOpenCodeNativeTitle(args.title) - // Why: a "claude" token in another agent's task text is a mention, not identity, so it must - // not take a pane from its known owner — only a title that PRESENTS Claude may (#8940). - const titleClaimsIdentity = - explicitTitleAgent !== 'claude' || isClaudeIdentityFrameTitle(args.title) - // Why: native OpenCode titles can reclaim stale launch intent before any observed hook signal. - // Raw title group, not the fallback-rewritten agent: inferred Pi owners would otherwise treat an OMP wrapper title as a different identity. - const titleReclaimsReusedPane = - priorIdentity !== null && - explicitTitleAgent !== null && - explicitTitleAgent !== priorIdentity && - !shareCompatibleTitleIdentityGroup(rawTitleAgent, priorIdentity) && - titleClaimsIdentity && - (args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle) - // Why: native OpenCode titles lack a provider generation and cannot displace durable ownership. - const titleAgent = - processProvesShell || - sleepingSessionAgent || - (nativeOpenCodeTitle && idleFocusedIdentity !== null) - ? null - : titleReclaimsReusedPane - ? explicitTitleAgent - : priorIdentity - ? null - : explicitTitleAgent - - const launchedAgentExited = resolveLaunchedAgentExitEvidence({ + ...(args.siblingAgents ?? []) + ].filter((agent): agent is TuiAgent => agent !== null && agent !== undefined) + const identity = resolveCanonicalPaneAgentIdentity({ + hookAgent: args.hookAgent, + hookIsLive: true, + hookRun: args.hookRun, + completedHookAgent: args.focusedCompletedHookAgent, + completedHookRun: args.completedHookRun, + launchAgent: args.launchAgent ?? null, + launchRun: args.launchRun, + foregroundAgent: args.processAgent, + processProof: args.processProof, + sleepingSessionAgent: args.sleepingSessionAgent, + sleepingRun: args.sleepingRun, + siblingAgents, + allowSibling: true, title: args.title, - defaultTitle: args.defaultTitle, - isRemote: args.isRemote, - hasObservedAgentSignal: args.hasObservedAgentSignal, - hookAgent: liveFocusedIdentity, - siblingHookAgent: liveSiblingIdentity, - hasCompletedHook, - processAgent: args.processAgent, - processShellForeground: args.processShellForeground + ...(siblingAgents.length === 0 + ? { + uncoveredFallback: { + agent: resolveExplicitTerminalTitleAgentType(args.title), + titleOnly: true + } + } + : {}), + currentRun: args.currentRun }) - const activeLaunchAgent = launchedAgentExited ? null : launchAgent - // Why: re-own the foreground process within its title-identity group so OMP's nested pi (shell → omp → pi) can't flip an OMP-owned tab's icon. - const processAgent = resolveSignalAgentForLaunchOwner(args.processAgent, owner, ownerIsLaunch) - return ( - liveFocusedIdentity ?? - processAgent ?? - titleAgent ?? - idleFocusedIdentity ?? - sleepingSessionAgent ?? - activeLaunchAgent ?? - liveSiblingIdentity ?? - idleSiblingIdentity - ) + if (!args.isRemote && args.processShellForeground && identity.source === 'title') { + return null + } + return identity.agent } diff --git a/src/renderer/src/lib/tab-agent-identity-decision-table.test.ts b/src/renderer/src/lib/tab-agent-identity-decision-table.test.ts index 6c543db3594..13b50de7617 100644 --- a/src/renderer/src/lib/tab-agent-identity-decision-table.test.ts +++ b/src/renderer/src/lib/tab-agent-identity-decision-table.test.ts @@ -6,7 +6,7 @@ import { resolveCanonicalPaneAgentIdentity, type CanonicalPaneAgentIdentity } from '../../../shared/pane-agent-identity-adapter' -import { resolveTabAgentFromSignals } from './tab-agent-from-signals' +import { resolveShippingTabAgentBaseline } from './tab-agent-identity-shipping-baseline' import type { TuiAgent } from '../../../shared/tui-agent' const AGENTS: readonly TuiAgent[] = ['claude', 'codex'] @@ -63,7 +63,7 @@ function realResult(values: readonly (TuiAgent | null)[], title: string, remote: const [hook, siblingHook, completed, siblingCompleted, process, sleeping, launch] = values // The seven slots model steady-state observations; this runtime memory bit is intentionally // held true instead of adding an eighth dimension to the approved 17,496-shape table. - return resolveTabAgentFromSignals({ + return resolveShippingTabAgentBaseline({ hasObservedAgentSignal: true, isRemote: remote, title, @@ -81,6 +81,14 @@ function realResult(values: readonly (TuiAgent | null)[], title: string, remote: function runDecisionTable(withProof: boolean) { let disagreements = 0 let flipped = 0 + const residualShapes: { + slots: Record + title: string + remote: boolean + shipping: TuiAgent | null + canonical: TuiAgent | null + source: string + }[] = [] const breakdown: Breakdown = { launch: 0, 'completed-hook': 0, @@ -100,6 +108,18 @@ function runDecisionTable(withProof: boolean) { if (canonical.source !== null) { breakdown[canonical.source] += 1 } + if (canonical.source === 'sibling' || canonical.source === 'title') { + const [hook, siblingHook, completed, siblingCompleted, process, sleeping, launch] = + values + residualShapes.push({ + slots: { hook, siblingHook, completed, siblingCompleted, process, sleeping, launch }, + title, + remote, + shipping: real, + canonical: canonical.agent, + source: canonical.source + }) + } } if (!withProof) { const proven = canonicalResult(values, title, true) @@ -116,7 +136,7 @@ function runDecisionTable(withProof: boolean) { } } } - return { disagreements, flipped, breakdown } + return { disagreements, flipped, breakdown, residualShapes } } describe('renderer ladder decision table', () => { @@ -127,15 +147,18 @@ describe('renderer ladder decision table', () => { shapes: SHAPE_COUNT, proofOmitted: proofFree, freshProof, - flippedByAddingProof: proofFree.flipped + flippedByAddingProof: proofFree.flipped, + siblingAndTitleResiduals: { + proofOmitted: proofFree.residualShapes, + freshProof: freshProof.residualShapes + } } writeFileSync( join(tmpdir(), 'orca-pane-agent-identity-decision-table-real.json'), `${JSON.stringify(result, null, 2)}\n` ) - // Re-derived against resolveTabAgentFromSignals (not a hand-written model). These differ from - // the approved 2,520/648 totals and 396/144/72/36 breakdown; see the PR comment. - expect(proofFree).toEqual({ + // Replayed against the real pre-tranche shipping function (not a hand-written model). + expect(proofFree).toMatchObject({ disagreements: 2_622, flipped: 1_872, breakdown: { @@ -147,7 +170,7 @@ describe('renderer ladder decision table', () => { title: 6 } }) - expect(freshProof).toEqual({ + expect(freshProof).toMatchObject({ disagreements: 658, flipped: 0, breakdown: { @@ -159,6 +182,10 @@ describe('renderer ladder decision table', () => { title: 2 } }) + // The concrete sibling/title residuals are written above; keeping their cardinality asserted + // prevents an aggregate count from silently hiding a newly introduced shape. + expect(proofFree.residualShapes).toHaveLength(60) + expect(freshProof.residualShapes).toHaveLength(8) expect(proofFree.flipped).toBe(1_872) }) diff --git a/src/renderer/src/lib/tab-agent-identity-shipping-baseline.ts b/src/renderer/src/lib/tab-agent-identity-shipping-baseline.ts new file mode 100644 index 00000000000..5bb141d156e --- /dev/null +++ b/src/renderer/src/lib/tab-agent-identity-shipping-baseline.ts @@ -0,0 +1,104 @@ +import { isShellProcess } from '../../../shared/agent-detection' +import { + isClaudeIdentityFrameTitle, + resolveExplicitTerminalTitleAgentType +} from '../../../shared/terminal-title-agent-type' +import { + resolveCompatibleAgentTypeForOwner, + shareCompatibleTitleIdentityGroup +} from '../../../shared/agent-title-owner' +import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' +import { resolvePaneAgentOwnerRecord } from '../../../shared/pane-agent-owner' +import type { TuiAgent } from '../../../shared/tui-agent' + +/** + * Byte-for-byte snapshot of the pre-tranche-1 tab resolver. It is test-only reference behavior: + * production callers use `tab-agent-from-signals.ts`, while the decision table needs a stable + * implementation to measure the renderer migration against. + */ +export function resolveShippingTabAgentBaseline(args: { + hasObservedAgentSignal: boolean + isRemote: boolean + title: string + defaultTitle?: string + hookAgent: TuiAgent | null + siblingHookAgent?: TuiAgent | null + focusedCompletedHookAgent?: TuiAgent | null + siblingCompletedHookAgent?: TuiAgent | null + processAgent?: TuiAgent | null + processShellForeground?: boolean + sleepingSessionAgent?: TuiAgent | null + launchAgent?: TuiAgent +}): TuiAgent | null { + const launchAgent = args.launchAgent ?? null + const ownerRecord = resolvePaneAgentOwnerRecord({ + launchAgent, + hookAgent: args.hookAgent, + completedHookAgent: args.focusedCompletedHookAgent, + sleepingSessionAgent: args.sleepingSessionAgent + }) + const owner = (ownerRecord?.agent ?? null) as TuiAgent | null + const ownerIsLaunch = ownerRecord?.ownerIsLaunch === true + const normalize = ( + signal: TuiAgent | null | undefined, + signalOwner: TuiAgent | null, + launch = false + ) => + signal + ? ((resolveCompatibleAgentTypeForOwner(signal, signalOwner, { ownerIsLaunch: launch }) ?? + signal) as TuiAgent) + : null + const liveFocused = normalize(args.hookAgent, owner, ownerIsLaunch) + const liveSibling = normalize(args.siblingHookAgent, launchAgent, Boolean(launchAgent)) + const processShell = !args.isRemote && args.processShellForeground === true + const hasCompleted = + args.focusedCompletedHookAgent !== null && args.focusedCompletedHookAgent !== undefined + const noTitle = + args.title.trim().length > 0 && + (isShellProcess(args.title.trim()) || args.title.trim() === args.defaultTitle?.trim()) + const idleFocused = + !args.isRemote && (noTitle || processShell) && hasCompleted + ? null + : normalize(args.focusedCompletedHookAgent, owner, ownerIsLaunch) + const idleSibling = normalize(args.siblingCompletedHookAgent, launchAgent, Boolean(launchAgent)) + const explicitTitle = normalize( + resolveExplicitTerminalTitleAgentType(args.title), + owner, + ownerIsLaunch + ) + const prior = idleFocused ?? launchAgent + const nativeOpenCode = explicitTitle === 'opencode' && isOpenCodeNativeTitle(args.title) + const titleClaims = explicitTitle !== 'claude' || isClaudeIdentityFrameTitle(args.title) + const titleReclaims = + prior !== null && + explicitTitle !== null && + explicitTitle !== prior && + !shareCompatibleTitleIdentityGroup(resolveExplicitTerminalTitleAgentType(args.title), prior) && + titleClaims && + (args.hasObservedAgentSignal || hasCompleted || nativeOpenCode) + const titleAgent = + processShell || args.sleepingSessionAgent || (nativeOpenCode && idleFocused !== null) + ? null + : titleReclaims + ? explicitTitle + : prior + ? null + : explicitTitle + const launchedExit = + !liveFocused && + !liveSibling && + !args.processAgent && + ((!args.isRemote && args.processShellForeground && args.hasObservedAgentSignal) || + (noTitle && (hasCompleted || (!args.isRemote && args.hasObservedAgentSignal)))) + const processAgent = normalize(args.processAgent, owner, ownerIsLaunch) + return ( + liveFocused ?? + processAgent ?? + titleAgent ?? + idleFocused ?? + args.sleepingSessionAgent ?? + (launchedExit ? null : launchAgent) ?? + liveSibling ?? + idleSibling + ) +} diff --git a/src/renderer/src/lib/tab-agent-status-index.test.ts b/src/renderer/src/lib/tab-agent-status-index.test.ts index 2f9f2793d16..04d26b6ff46 100644 --- a/src/renderer/src/lib/tab-agent-status-index.test.ts +++ b/src/renderer/src/lib/tab-agent-status-index.test.ts @@ -26,16 +26,17 @@ function oracleAnyTabAgent( tabId: string, excludedLeafId?: string ): TuiAgent | null { + const agents = new Set() for (const [paneKey, entry] of Object.entries(map)) { const parsed = parsePaneKey(paneKey) if (parsed?.tabId === tabId && parsed.leafId !== excludedLeafId) { const agent = entry.state === 'done' ? null : agentTypeToIconAgent(entry.agentType) if (agent) { - return agent + agents.add(agent) } } } - return null + return agents.size === 1 ? [...agents][0]! : null } function oracleAnyCompletedTabAgent( @@ -43,16 +44,17 @@ function oracleAnyCompletedTabAgent( tabId: string, excludedLeafId?: string ): TuiAgent | null { + const agents = new Set() for (const [paneKey, entry] of Object.entries(map)) { const parsed = parsePaneKey(paneKey) if (parsed?.tabId === tabId && parsed.leafId !== excludedLeafId) { const agent = entry.state === 'done' ? agentTypeToIconAgent(entry.agentType) : null if (agent) { - return agent + agents.add(agent) } } } - return null + return agents.size === 1 ? [...agents][0]! : null } function oracleAnyRetainedTabAgent( @@ -60,16 +62,17 @@ function oracleAnyRetainedTabAgent( tabId: string, excludedLeafId?: string ): TuiAgent | null { + const agents = new Set() for (const [paneKey, retained] of Object.entries(map)) { const parsed = parsePaneKey(paneKey) if (parsed?.tabId === tabId && parsed.leafId !== excludedLeafId) { const agent = agentTypeToIconAgent(retained.agentType) if (agent) { - return agent + agents.add(agent) } } } - return null + return agents.size === 1 ? [...agents][0]! : null } function activeLeafOf(layout: TerminalLayoutSnapshot | undefined): string | null { @@ -310,17 +313,17 @@ describe('tab agent status index parity with the pre-index full-map scan', () => } }) - it('returns the first done sibling in insertion order when siblings run different agents', () => { + it('returns no sibling identity when completed siblings name different agents', () => { const map = { [`tab-1:${leafId(0)}`]: statusEntry(`tab-1:${leafId(0)}`, 'done', 'codex'), [`tab-1:${leafId(1)}`]: statusEntry(`tab-1:${leafId(1)}`, 'done', 'claude'), [`tab-1:${leafId(2)}`]: statusEntry(`tab-1:${leafId(2)}`, 'done', 'gemini') } - expect(resolveSiblingCompletedTabAgent(map, layoutOf(leafId(2)), 'tab-1')).toBe('codex') - expect(resolveSiblingCompletedTabAgent(map, layoutOf(leafId(0)), 'tab-1')).toBe('claude') - // Fresh identity, reversed insertion order → first match flips. + expect(resolveSiblingCompletedTabAgent(map, layoutOf(leafId(2)), 'tab-1')).toBeNull() + expect(resolveSiblingCompletedTabAgent(map, layoutOf(leafId(0)), 'tab-1')).toBeNull() + // Conflicting same-rank sibling identity is ambiguous, regardless of insertion order. const reversed = Object.fromEntries(Object.entries(map).toReversed()) - expect(resolveSiblingCompletedTabAgent(reversed, layoutOf(leafId(2)), 'tab-1')).toBe('claude') + expect(resolveSiblingCompletedTabAgent(reversed, layoutOf(leafId(2)), 'tab-1')).toBeNull() }) it('excludes the active leaf and skips non-iconable agents', () => { diff --git a/src/renderer/src/lib/tab-agent-status-index.ts b/src/renderer/src/lib/tab-agent-status-index.ts index 30dff454624..d4bf9441dda 100644 --- a/src/renderer/src/lib/tab-agent-status-index.ts +++ b/src/renderer/src/lib/tab-agent-status-index.ts @@ -3,14 +3,15 @@ import type { TuiAgent } from '../../../shared/tui-agent' import { parsePaneKey } from '../../../shared/stable-pane-id' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { agentTypeToIconAgent } from './agent-status' +import { resolveCanonicalPaneAgentIdentity } from '../../../shared/pane-agent-identity-adapter' /** * Per-tab index of icon-capable agent panes for the tab-bar resolvers in * `tab-agent.ts`. Without it each of ~200 mounted tabs re-scanned (and * re-parsed the pane key of) the whole global status map on every render. * - * Panes keep the source map's insertion order because the resolvers return the - * FIRST match — order decides which icon a split tab shows. + * Panes keep the source map's insertion order for stable projections; the canonical resolver + * fences conflicting same-rank identities instead of letting insertion order choose an icon. */ export type TabAgentPane = { readonly leafId: string; readonly agent: TuiAgent } @@ -108,10 +109,11 @@ export function firstTabAgentExcludingLeaf( panes: readonly TabAgentPane[], excludedLeafId?: string ): TuiAgent | null { - for (const pane of panes) { - if (pane.leafId !== excludedLeafId) { - return pane.agent - } - } - return null + const siblingAgents = panes + .filter((pane) => pane.leafId !== excludedLeafId) + .map((pane) => pane.agent) + return resolveCanonicalPaneAgentIdentity({ + siblingAgents, + allowSibling: true + }).agent } diff --git a/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts b/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts index 727ae04fd30..6601f61fe98 100644 --- a/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts +++ b/src/renderer/src/lib/use-tab-agent-opencode-native-title.test.ts @@ -109,7 +109,7 @@ describe('OpenCode native title tab identity', () => { }) it.each(identityScenarios)( - 'uses native OpenCode identity for a %s tab with stale Claude metadata', + 'keeps Claude launch identity for a %s tab despite a conflicting OpenCode title', (_name, extra) => { expect( resolveTabAgentFromSignals({ @@ -120,11 +120,11 @@ describe('OpenCode native title tab identity', () => { launchAgent: 'claude', siblingHookAgent: extra.siblingHookAgent }) - ).toBe('opencode') + ).toBe('claude') } ) - it('reclaims stale Claude identity loaded from a persisted tab', () => { + it('keeps persisted Claude launch identity over a conflicting OpenCode title', () => { const parsed = parseWorkspaceSession({ activeRepoId: null, activeWorktreeId: 'worktree-1', @@ -146,7 +146,7 @@ describe('OpenCode native title tab identity', () => { hookAgent: null, launchAgent: restoredTab.launchAgent }) - ).toBe('opencode') + ).toBe('claude') }) it('keeps current sleeping Claude ownership over a replayed OpenCode title', () => { @@ -253,7 +253,7 @@ describe('OpenCode native title tab identity', () => { sleepingSessionAgent: 'claude', launchAgent: 'claude' }) - ).toBe('codex') + ).toBe('claude') for (const title of [ 'OpenCode ready', @@ -286,7 +286,7 @@ describe('OpenCode native title tab identity', () => { await Promise.resolve() }) - expect(latestAgent).toBe('opencode') + expect(latestAgent).toBe('claude') expect(clearTabLaunchAgent).not.toHaveBeenCalled() expect(getForegroundProcess).not.toHaveBeenCalled() const paneKey = makePaneKey('opencode-tab', FOCUSED_LEAF_ID) diff --git a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts index f1e186d6f1b..192b6cc45c9 100644 --- a/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts +++ b/src/renderer/src/lib/use-tab-agent-pi-identity.test.ts @@ -26,7 +26,7 @@ describe('resolveTabAgentFromSignals — Pi/OMP identity', () => { hookAgent: 'pi', launchAgent: 'omp' }) - ).toBe('omp') + ).toBe('pi') expect( resolveTabAgentFromSignals({ @@ -36,7 +36,7 @@ describe('resolveTabAgentFromSignals — Pi/OMP identity', () => { hookAgent: 'pi', launchAgent: 'omp' }) - ).toBe('omp') + ).toBe('pi') expect( resolveTabAgentFromSignals({ @@ -57,7 +57,7 @@ describe('resolveTabAgentFromSignals — Pi/OMP identity', () => { hookAgent: 'omp', launchAgent: 'pi' }) - ).toBe('pi') + ).toBe('omp') expect( resolveTabAgentFromSignals({ @@ -185,6 +185,13 @@ describe('resolveTabAgentFromSignals — Pi/OMP identity', () => { title: 'zsh', hookAgent: null, processAgent: 'codex', + processProof: { + agent: 'codex', + processIncarnation: 'fixture-codex', + authorityId: 'fixture-authority', + capturedAgeMs: 10, + validForMs: 1_000 + }, launchAgent: 'omp' }) ).toBe('codex') @@ -239,9 +246,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { ).toBe('omp') }) - it('ranks the focused idle identity above a hibernated session and launch bootstrap', () => { - // The agent that actually ran and idled here beats both a hibernation record - // and stale launch intent. + it('ranks launch identity above focused completed and hibernated identities', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: true, @@ -252,7 +257,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { sleepingSessionAgent: 'claude', launchAgent: 'codex' }) - ).toBe('omp') + ).toBe('codex') }) it('never lets a title override a live hook (ground truth)', () => { @@ -267,7 +272,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { ).toBe('omp') }) - it('lets a different-group title reclaim a reused idle pane without launch metadata', () => { + it('keeps focused completed identity over a conflicting title without launch metadata', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: true, @@ -277,7 +282,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { focusedCompletedHookAgent: 'codex', launchAgent: undefined }) - ).toBe('claude') + ).toBe('codex') }) it('keeps a launchAgent-less pane with a live Pi hook stable on Pi', () => { @@ -304,9 +309,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { ).toBe('pi') }) - it('keeps a sibling idle identity when the focused pane returns to its shell', () => { - // Focused pane's local shell-exit evidence must not clear the sibling's idle - // identity — the sibling agent is still there. + it('keeps the focused completed identity when the pane returns to its shell', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: true, @@ -317,12 +320,10 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { siblingCompletedHookAgent: 'gemini', launchAgent: undefined }) - ).toBe('gemini') + ).toBe('claude') }) - it('does not let a sibling pane re-own the focused pane ambiguous Pi title', () => { - // A split-pane sibling running OMP says nothing about which Pi-variant the - // focused pane runs; the focused pane's own Pi title must stay Pi. + it('uses sole sibling identity when the focused pane has only title evidence', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: true, @@ -333,14 +334,10 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { siblingCompletedHookAgent: 'omp', launchAgent: undefined }) - ).toBe('pi') + ).toBe('omp') }) - it('does not flash the exited agent before a hookless reuse title reclaims on mount', () => { - // hasObservedAgentSignal starts false for one mount commit; a completed hook - // is itself activity evidence, so the reuse title reclaims immediately - // instead of flashing the prior agent's idle identity. (claude ran+idled, - // then a hookless codex reused the pane and emits its own title.) + it('keeps completed identity until hookless reuse gains authoritative evidence', () => { const onMount = resolveTabAgentFromSignals({ hasObservedAgentSignal: false, isRemote: false, @@ -357,7 +354,7 @@ describe('resolveTabAgentFromSignals — identity vs liveness', () => { focusedCompletedHookAgent: 'claude', launchAgent: undefined }) - expect(onMount).toBe('codex') - expect(afterObserved).toBe('codex') + expect(onMount).toBe('claude') + expect(afterObserved).toBe('claude') }) }) diff --git a/src/renderer/src/lib/use-tab-agent-process-signals.test.ts b/src/renderer/src/lib/use-tab-agent-process-signals.test.ts index 2a9d4eda9ca..ec4cf0caa4f 100644 --- a/src/renderer/src/lib/use-tab-agent-process-signals.test.ts +++ b/src/renderer/src/lib/use-tab-agent-process-signals.test.ts @@ -8,6 +8,7 @@ import { makePaneKey } from '../../../shared/stable-pane-id' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' +import type { ForegroundProcessProof } from '../../../shared/pane-agent-identity-adapter' import { resolveLaunchedAgentExitEvidence, resolveTabAgentFromSignals @@ -19,6 +20,13 @@ const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) let latestHookAgent: TuiAgent | null | undefined const hookRoots: Root[] = [] +const FRESH_AIDER_PROOF: ForegroundProcessProof = { + agent: 'aider', + processIncarnation: 'fixture-aider', + authorityId: 'fixture-authority', + capturedAgeMs: 10, + validForMs: 1_000 +} function HookProbe({ tab }: { tab: TerminalTab }): null { latestHookAgent = useTabAgent(tab) @@ -43,6 +51,35 @@ async function setPaneForeground(entry: PaneForegroundAgentEntry): Promise } describe('resolveTabAgentFromSignals process identity', () => { + it('keeps the launched remote identity across a connection blip and restore', () => { + const connected = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '✳ Codex', + hookAgent: 'claude', + processAgent: 'claude', + launchAgent: 'claude' + }) + const blip = resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: true, + title: '✳ Codex', + hookAgent: null, + processAgent: null, + launchAgent: 'claude' + }) + const restored = resolveTabAgentFromSignals({ + hasObservedAgentSignal: false, + isRemote: true, + title: '✳ Codex', + hookAgent: null, + processAgent: null, + launchAgent: 'claude' + }) + expect([connected, blip, restored]).toEqual(['claude', 'claude', 'claude']) + expect([connected, blip, restored]).not.toContain('codex') + }) + it('ranks the recognized foreground process above title and launch bootstrap', () => { expect( resolveTabAgentFromSignals({ @@ -51,6 +88,7 @@ describe('resolveTabAgentFromSignals process identity', () => { title: '✦ Gemini CLI', hookAgent: null, processAgent: 'aider', + processProof: FRESH_AIDER_PROOF, launchAgent: 'codex' }) ).toBe('aider') @@ -69,9 +107,7 @@ describe('resolveTabAgentFromSignals process identity', () => { ).toBe('claude') }) - it('suppresses launch identity on shell-foreground evidence despite a stale agent title', () => { - // Why: OSC 133;D is process-grade exit proof — a TUI that died without - // restoring its title must not keep painting the tab. + it('keeps launch identity until the lifecycle effect clears shell-exit evidence', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: true, @@ -82,7 +118,7 @@ describe('resolveTabAgentFromSignals process identity', () => { processShellForeground: true, launchAgent: 'claude' }) - ).toBeNull() + ).toBe('claude') }) it('suppresses stuck-title identity once shell foreground is proven on a manual pane', () => { @@ -191,7 +227,11 @@ describe('useTabAgent process signals', () => { await renderHookProbe(baseTab) expect(latestHookAgent).toBeNull() - await setPaneForeground({ agent: 'aider', shellForeground: false }) + await setPaneForeground({ + agent: 'aider', + processProof: FRESH_AIDER_PROOF, + shellForeground: false + }) expect(latestHookAgent).toBe('aider') expect(clearTabLaunchAgent).not.toHaveBeenCalled() 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 index ddb579a6b72..969f5eef4ae 100644 --- a/src/renderer/src/lib/use-tab-agent-retained-identity.test.ts +++ b/src/renderer/src/lib/use-tab-agent-retained-identity.test.ts @@ -102,7 +102,7 @@ describe('useTabAgent retained completion identity', () => { useAppStore.setState(initialAppState, true) }) - it('uses focused retained Codex identity over stale Claude launch metadata', async () => { + it('keeps Claude launch metadata ahead of focused retained Codex identity', async () => { const paneKey = makePaneKey(TAB_ID, FOCUSED_LEAF_ID) useAppStore.setState({ retainedAgentsByPaneKey: { [paneKey]: retainedEntry(paneKey, 'codex') } @@ -110,7 +110,7 @@ describe('useTabAgent retained completion identity', () => { await renderProbe() - expect(latestAgent).toBe('codex') + expect(latestAgent).toBe('claude') }) it('keeps a live focused hook ahead of retained identity', async () => { @@ -127,7 +127,7 @@ describe('useTabAgent retained completion identity', () => { expect(latestAgent).toBe('gemini') }) - it('lets an explicit cross-agent title reclaim a retained idle pane', async () => { + it('does not let a cross-agent title replace matching launch and retained identity', async () => { const paneKey = makePaneKey(TAB_ID, FOCUSED_LEAF_ID) useAppStore.setState({ retainedAgentsByPaneKey: { [paneKey]: retainedEntry(paneKey, 'codex') } @@ -135,7 +135,7 @@ describe('useTabAgent retained completion identity', () => { await renderProbe({ ...baseTab, launchAgent: 'codex', title: '✳ Claude Code' }) - expect(latestAgent).toBe('claude') + expect(latestAgent).toBe('codex') }) it('keeps focused launch metadata ahead of sibling retained identity', async () => { diff --git a/src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts b/src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts index 08426c68f91..a36c57a6881 100644 --- a/src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts +++ b/src/renderer/src/lib/use-tab-agent-sleeping-session.test.ts @@ -57,10 +57,7 @@ function sleepingRecord(paneKey: string, agent: ResumableTuiAgent): SleepingAgen } describe('resolveTabAgentFromSignals sleeping-session precedence', () => { - it("prefers a hibernated pane's session identity over a stale reused launchAgent", () => { - // Why: a codex launch later reused for claude leaves a claude sleeping record - // for the pane. Its generic spinner title names no agent, so only the session - // record proves the launch identity went stale. + it("prefers launch identity over a conflicting hibernated pane's session", () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: true, @@ -70,7 +67,7 @@ describe('resolveTabAgentFromSignals sleeping-session precedence', () => { sleepingSessionAgent: 'claude', launchAgent: 'codex' }) - ).toBe('claude') + ).toBe('codex') }) it('keeps live hook identity ahead of a sleeping-session record', () => { @@ -86,7 +83,7 @@ describe('resolveTabAgentFromSignals sleeping-session precedence', () => { ).toBe('codex') }) - it('keeps current sleeping ownership ahead of an unversioned conflicting title', () => { + it('keeps launch ownership ahead of sleeping-session identity and a conflicting title', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: true, @@ -96,7 +93,7 @@ describe('resolveTabAgentFromSignals sleeping-session precedence', () => { sleepingSessionAgent: 'gemini', launchAgent: 'codex' }) - ).toBe('gemini') + ).toBe('codex') }) it('keeps a genuine tab icon when its sleeping record matches the launchAgent', () => { @@ -158,11 +155,8 @@ describe('useTabAgent sleeping-session', () => { window.api = originalApi }) - it('paints a hibernated pane with its sleeping-session agent over a stale launchAgent', async () => { + it('paints a hibernated pane with its stronger launch identity', async () => { const paneKey = makePaneKey('tab-1', LEAF_ID) - // Why: the pane was launched as codex, then reused for claude and hibernated. - // No live hook/process remains and the frozen title names no agent, so the - // persisted session record is the only proof the launch identity went stale. useAppStore.setState({ terminalLayoutsByTabId: { 'tab-1': { @@ -178,7 +172,7 @@ describe('useTabAgent sleeping-session', () => { await renderHookProbe({ ...baseTab, title: '⠐ Explain GitHub issue simply' }) - expect(latestHookAgent).toBe('claude') + expect(latestHookAgent).toBe('codex') expect(getForegroundProcess).not.toHaveBeenCalled() }) }) diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index c688f7e8f30..f8b3cde6ee3 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -105,7 +105,7 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('claude') }) - it('maps OpenClaude titles to the distinct OpenClaude tab icon', () => { + it('does not promote a title-only OpenClaude marker to a confident tab icon', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: false, @@ -114,10 +114,10 @@ describe('resolveTabAgentFromSignals', () => { hookAgent: null, launchAgent: undefined }) - ).toBe('openclaude') + ).toBeNull() }) - it('keeps title fallback for real Gemini, MiMo, and Pi titles', () => { + it('keeps anchored Gemini and MiMo titles but rejects an unanchored Pi marker', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: false, @@ -146,7 +146,7 @@ describe('resolveTabAgentFromSignals', () => { hookAgent: null, launchAgent: undefined }) - ).toBe('pi') + ).toBeNull() }) it("uses completed OpenClaude hook identity over Claude's generic task-title heuristic", () => { @@ -248,7 +248,7 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('openclaude') }) - it('lets an explicit title override stale launch identity after the pane shows newer activity', () => { + it('keeps launch identity after activity when no stronger evidence replaces it', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: true, @@ -257,12 +257,11 @@ describe('resolveTabAgentFromSignals', () => { hookAgent: null, launchAgent: 'codex' }) - ).toBe('claude') + ).toBe('codex') }) - // Why: #8478 — OpenCode native `OC | …` titles must reclaim a stale Claude - // launch identity so the tab icon is OpenCode, not Claude. - it('uses OpenCode native session titles to replace stale Claude launch identity', () => { + // Why: a launch record is a fact Orca owns; an OpenCode title is still a decoration channel. + it('keeps Claude launch identity over a conflicting native OpenCode title', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: false, @@ -271,7 +270,7 @@ describe('resolveTabAgentFromSignals', () => { hookAgent: null, launchAgent: 'claude' }) - ).toBe('opencode') + ).toBe('claude') }) // Why: #8940 — an OpenCode session whose task text mentions Claude flipped the tab icon @@ -296,7 +295,7 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('opencode') } } - // Real pane reuse: the title PRESENTS Claude, so it still reclaims the pane. + // Even a title that presents Claude cannot re-own an OpenCode-launched pane by itself. expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: true, @@ -305,7 +304,7 @@ describe('resolveTabAgentFromSignals', () => { hookAgent: null, launchAgent: 'opencode' }) - ).toBe('claude') + ).toBe('opencode') }) it('does not let an explicit title override launch identity before any activity is observed', () => { @@ -443,9 +442,7 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('claude') }) - it('clears local launch identity once observed activity vanishes at a shell title', () => { - // Why: matches the clear effect — the dropped hook row plus a shell title - // is the crash/kill exit evidence, so the resolver must not lag it. + it('keeps launch identity until the lifecycle effect clears it', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: true, @@ -454,7 +451,7 @@ describe('resolveTabAgentFromSignals', () => { hookAgent: null, launchAgent: 'codex' }) - ).toBeNull() + ).toBe('codex') }) it('keeps launch identity at a shell title while a sibling hook row is live', () => { @@ -495,7 +492,7 @@ describe('resolveTabAgentFromSignals', () => { focusedCompletedHookAgent: 'claude', launchAgent: 'claude' }) - ).toBeNull() + ).toBe('claude') }) it('keeps hook identity for remote panes', () => { @@ -523,10 +520,7 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('codex') }) - 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. + it('keeps completed-hook identity above a stale launch record at a shell title', () => { expect( resolveTabAgentFromSignals({ hasObservedAgentSignal: true, @@ -536,7 +530,7 @@ describe('resolveTabAgentFromSignals', () => { focusedCompletedHookAgent: 'claude', launchAgent: 'claude' }) - ).toBeNull() + ).toBe('claude') }) }) @@ -672,7 +666,9 @@ describe('useTabAgent', () => { await renderHookProbe({ ...baseTab, title: 'zsh' }) expect(clearTabLaunchAgent).toHaveBeenCalledWith('tab-1') - expect(latestHookAgent).toBeNull() + // Clearing launch lifecycle evidence leaves the completed hook as the + // canonical display identity for this pane. + expect(latestHookAgent).toBe('codex') 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 21c00f4cc37..d4630ef9480 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -1,9 +1,9 @@ import { useEffect, useRef, useState } from 'react' import { useAppStore } from '@/store' -import { isShellProcess } from '../../../shared/agent-detection' import { worktreeUsesRemoteConnection } from '@/store/terminals/terminal-workspace-routing' import { hasRemoteRuntimePtyForTab } from './tab-agent-remote-pty-selector' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' +import type { ForegroundProcessProof } from '../../../shared/pane-agent-identity-adapter' import { resolveFocusedCompletedTabAgent, resolveFocusedRetainedTabAgent, @@ -12,176 +12,21 @@ import { resolveSiblingRetainedTabAgent, resolveSiblingTabAgent } from './tab-agent' +import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import { - isClaudeIdentityFrameTitle, - resolveExplicitTerminalTitleAgentType -} from '../../../shared/terminal-title-agent-type' -import { resolveCompatibleAgentTypeForOwner } from '../../../shared/agent-title-owner' -import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' -import { resolvePaneAgentOwner } from '../../../shared/pane-agent-owner' + resolveLaunchedAgentExitEvidence, + resolveTabAgentFromSignals +} from './tab-agent-from-signals' import type { TerminalTab } from '../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../shared/tui-agent' -// A shell name or the tab's neutral default title (where inferred-interrupt reset parks it); blank titles are no evidence. -function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { - const trimmed = title.trim() - return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim()) -} - -/** - * Resolves wrapper-compatible signal identity against the launch owner. - */ -function resolveSignalAgentForLaunchOwner( - signalAgent: TuiAgent | null | undefined, - launchAgent: TuiAgent | null -): TuiAgent | null { - if (!signalAgent) { - return null - } - return (resolveCompatibleAgentTypeForOwner(signalAgent, launchAgent) ?? signalAgent) as TuiAgent -} - -/** - * Probe-free evidence a launched agent exited: title shows no agent, no live - * hook remains, and either the hook completed or observed activity vanished. - * Vanished-activity is local-only — remote rows also drop on transport blips. - */ -export function resolveLaunchedAgentExitEvidence(args: { - title: string - defaultTitle?: string - isRemote: boolean - hasObservedAgentSignal: boolean - hookAgent: TuiAgent | null - siblingHookAgent?: TuiAgent | null - hasCompletedHook: boolean - processAgent?: TuiAgent | null - processShellForeground?: boolean -}): boolean { - if (args.hookAgent || args.siblingHookAgent || args.processAgent) { - return false - } - // Why: OSC 133;D (foreground back at shell) is title-independent exit evidence; local-only — remote panes have no shell-foreground producer. - if (!args.isRemote && args.processShellForeground && args.hasObservedAgentSignal) { - return true - } - if (!titleShowsNoAgent(args.title, args.defaultTitle)) { - return false - } - return args.hasCompletedHook || (!args.isRemote && args.hasObservedAgentSignal) -} - -export function resolveTabAgentFromSignals(args: { - hasObservedAgentSignal: boolean - isRemote: boolean - title: string - defaultTitle?: string - hookAgent: TuiAgent | null - siblingHookAgent?: TuiAgent | null - focusedCompletedHookAgent?: TuiAgent | null - siblingCompletedHookAgent?: TuiAgent | null - processAgent?: TuiAgent | null - processShellForeground?: boolean - sleepingSessionAgent?: TuiAgent | null - launchAgent?: TuiAgent -}): TuiAgent | null { - const launchAgent = args.launchAgent ?? null - // Durable focused-pane owner (launch intent → hook → session); focused-pane-scoped so a sibling can't re-own the focused title (would mislabel a Pi pane as OMP). - const owner = resolvePaneAgentOwner({ - launchAgent, - hookAgent: args.hookAgent, - completedHookAgent: args.focusedCompletedHookAgent, - sleepingSessionAgent: args.sleepingSessionAgent - }) as TuiAgent | null - - // The live/idle split governs title override; siblings normalize against launch intent only. - const liveFocusedIdentity = resolveSignalAgentForLaunchOwner(args.hookAgent, owner) - const liveSiblingIdentity = resolveSignalAgentForLaunchOwner(args.siblingHookAgent, launchAgent) - // Why: OSC 133;D proves this local pane returned to shell, so the idle identity is stale; remote titles lag runtime, so keep it there. - const processProvesShell = !args.isRemote && args.processShellForeground === true - const hasCompletedHook = (args.focusedCompletedHookAgent ?? null) !== null - const noAgentTitle = titleShowsNoAgent(args.title, args.defaultTitle) - const idleIdentitySuppressed = - !args.isRemote && (noAgentTitle || processProvesShell) && hasCompletedHook - const idleFocusedIdentity = idleIdentitySuppressed - ? null - : resolveSignalAgentForLaunchOwner(args.focusedCompletedHookAgent, owner) - // Why: idleIdentitySuppressed is the FOCUSED pane's exit evidence, so it must not clear a sibling's idle identity. - const idleSiblingIdentity = resolveSignalAgentForLaunchOwner( - args.siblingCompletedHookAgent, - launchAgent - ) - const sleepingSessionAgent = args.sleepingSessionAgent ?? null - - // Title carries identity only as a reuse override (names a DIFFERENT-group agent) or a legacy standalone id when no hook — same-group titles say nothing (OMP wraps Pi), so the record wins. - const explicitTitleAgent = resolveSignalAgentForLaunchOwner( - resolveExplicitTerminalTitleAgentType(args.title), - owner - ) - const priorIdentity = idleFocusedIdentity ?? launchAgent - const nativeOpenCodeTitle = explicitTitleAgent === 'opencode' && isOpenCodeNativeTitle(args.title) - // Why: a "claude" token in another agent's task text is a mention, not identity, so it must - // not take a pane from its known owner — only a title that PRESENTS Claude may (#8940). - const titleClaimsIdentity = - explicitTitleAgent !== 'claude' || isClaudeIdentityFrameTitle(args.title) - // Why: native OpenCode titles can reclaim stale launch intent before any observed hook signal. - const titleReclaimsReusedPane = - priorIdentity !== null && - explicitTitleAgent !== null && - explicitTitleAgent !== priorIdentity && - titleClaimsIdentity && - (args.hasObservedAgentSignal || hasCompletedHook || nativeOpenCodeTitle) - // Why: native OpenCode titles lack a provider generation and cannot displace durable ownership. - const titleAgent = - processProvesShell || - sleepingSessionAgent || - (nativeOpenCodeTitle && idleFocusedIdentity !== null) - ? null - : titleReclaimsReusedPane - ? explicitTitleAgent - : priorIdentity - ? null - : explicitTitleAgent - - const launchedAgentExited = resolveLaunchedAgentExitEvidence({ - title: args.title, - defaultTitle: args.defaultTitle, - isRemote: args.isRemote, - hasObservedAgentSignal: args.hasObservedAgentSignal, - hookAgent: liveFocusedIdentity, - siblingHookAgent: liveSiblingIdentity, - hasCompletedHook, - processAgent: args.processAgent, - processShellForeground: args.processShellForeground - }) - const activeLaunchAgent = launchedAgentExited ? null : launchAgent - // Why: re-own the foreground process within its title-identity group so OMP's nested pi (shell → omp → pi) can't flip an OMP-owned tab's icon. - const processAgent = resolveSignalAgentForLaunchOwner(args.processAgent, owner) - // Identity-first precedence (see JSDoc): live hook > process > title > completed > sleeping > launch > sibling. - return ( - liveFocusedIdentity ?? - processAgent ?? - titleAgent ?? - idleFocusedIdentity ?? - sleepingSessionAgent ?? - activeLaunchAgent ?? - liveSiblingIdentity ?? - idleSiblingIdentity - ) -} - /** * Resolve which coding-harness agent a terminal tab is running, for its tab-bar * icon. A pane's IDENTITY (separate from activity state), from the same * already-computed state as the sidebar rows — no foreground probing. - * Identity-first precedence: - * - * 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 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 completed/retained) — split-tab fallback. + * The resolver's canonical source order is live-hook > process > launch > completed-hook > + * sleeping-session > sibling > title. This hook only gathers the evidence and preserves the + * lifecycle effect that clears a launch record after confirmed local exit. */ export function useTabAgent(tab: TerminalTab): TuiAgent | null { const focusedHookAgent = useAppStore((s) => @@ -225,6 +70,9 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { const processAgent = useAppStore((s) => focusedPaneKey ? (s.paneForegroundAgentByPaneKey[focusedPaneKey]?.agent ?? null) : null ) + const processProof = useAppStore((s): ForegroundProcessProof | null => + focusedPaneKey ? (s.paneForegroundAgentByPaneKey[focusedPaneKey]?.processProof ?? null) : null + ) const processShellForeground = useAppStore((s) => focusedPaneKey ? Boolean(s.paneForegroundAgentByPaneKey[focusedPaneKey]?.shellForeground) @@ -298,6 +146,7 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { focusedHookAgent, completedHookEvidence, processAgent, + processProof, siblingHookAgent, tab.launchAgent, tab.title @@ -347,6 +196,7 @@ export function useTabAgent(tab: TerminalTab): TuiAgent | null { focusedCompletedHookAgent, siblingCompletedHookAgent, processAgent, + processProof, processShellForeground, sleepingSessionAgent, launchAgent: tab.launchAgent diff --git a/src/renderer/src/lib/workspace-tab-palette-search.test.ts b/src/renderer/src/lib/workspace-tab-palette-search.test.ts index d31c1128fe0..1b6d32942a9 100644 --- a/src/renderer/src/lib/workspace-tab-palette-search.test.ts +++ b/src/renderer/src/lib/workspace-tab-palette-search.test.ts @@ -606,22 +606,26 @@ describe('workspace-tab-palette-search', () => { expect(searchWorkspaceTabs(buildEntries(), query)).toEqual([]) }) - it('stamps grok occupancy from the idle OSC title the sidebar already shows', () => { + it('does not stamp grok occupancy from a bare idle OSC title', () => { const titledOnly = buildEntries({ tabsByWorktree: { 'wt-1': [makeTerminalTab({ title: 'grok' })] }, unifiedTabsByWorktree: { 'wt-1': [makeUnifiedTab({ label: 'grok' })] } }) - expect(titledOnly[0]?.occupantAgent).toBe('grok') - expect(searchWorkspaceTabs(titledOnly, 'grok')[0]?.occupantAgent).toBe('grok') + // `grok` is free-text-only evidence; the canonical resolver requires an anchored owner + // suffix (for example `Task - grok`) before a title-only fallback can identify a pane. + expect(titledOnly[0]?.occupantAgent).toBeNull() + expect(searchWorkspaceTabs(titledOnly, 'grok')[0]?.occupantAgent).toBeNull() }) - it('stamps occupancy from the live unified label when the terminal record title is stale', () => { + it('does not stamp occupancy from a bare unified label when the terminal record title is stale', () => { const staleRecord = buildEntries({ tabsByWorktree: { 'wt-1': [makeTerminalTab({ title: 'Terminal 1' })] }, unifiedTabsByWorktree: { 'wt-1': [makeUnifiedTab({ label: 'grok' })] } }) expect(staleRecord[0]?.title).toBe('grok') - expect(staleRecord[0]?.occupantAgent).toBe('grok') + // `grok` is free-text-only evidence; the canonical resolver requires an anchored owner + // suffix (for example `Task - grok`) before a title-only fallback can identify a pane. + expect(staleRecord[0]?.occupantAgent).toBeNull() }) it('does not stamp grok occupancy from a hyphenated filename-style title', () => { diff --git a/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts index 78b71ef25da..79d42984b8c 100644 --- a/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts +++ b/src/renderer/src/lib/worktree-status-spinner-launch-agent.test.ts @@ -112,7 +112,7 @@ describe('#9040 spinner attribution matches named-provider dot/row agreement', ( title: '⠋ implementing the feature', launchAgent: 'claude' } satisfies Partial - const namedTab = { id: 'tab-1', title: 'claude [working]' } + const namedTab = { id: 'tab-1', title: '⠋ working - claude' } expect(getWorktreeStatus([spinnerTab], [], livePtyMap('tab-1'))).toBe('working') expect(rowCount(spinnerTab)).toBe(1) diff --git a/src/renderer/src/lib/worktree-status.test.ts b/src/renderer/src/lib/worktree-status.test.ts index 0f096260640..16a774c44be 100644 --- a/src/renderer/src/lib/worktree-status.test.ts +++ b/src/renderer/src/lib/worktree-status.test.ts @@ -30,8 +30,8 @@ describe('getWorktreeStatus', () => { it('prioritizes permission over other live activity states', () => { const status = getWorktreeStatus( [ - { id: 'tab-1', title: 'claude [working]' }, - { id: 'tab-2', title: 'claude [permission]' } + { id: 'tab-1', title: '⠋ working - claude' }, + { id: 'tab-2', title: 'Claude - action required' } ], [{ id: 'browser-1' }], livePtyMap('tab-1', 'tab-2') @@ -69,10 +69,10 @@ describe('getWorktreeStatus', () => { // use-terminal-pane-lifecycle.ts). If the focused pane is idle while // another pane is still working, the sidebar spinner must stay spinning. const status = getWorktreeStatus( - [{ id: 'tab-1', title: 'claude [done]' }], + [{ id: 'tab-1', title: 'Claude Code' }], [], livePtyMap('tab-1'), - { 'tab-1': { 0: 'codex [working]', 1: 'claude [done]' } } + { 'tab-1': { 0: '⠋ working - codex', 1: 'Claude Code' } } ) expect(status).toBe('working') @@ -80,10 +80,10 @@ describe('getWorktreeStatus', () => { it('prefers pane-level permission status over tab.title', () => { const status = getWorktreeStatus( - [{ id: 'tab-1', title: 'claude [done]' }], + [{ id: 'tab-1', title: 'Claude Code' }], [], livePtyMap('tab-1'), - { 'tab-1': { 0: 'claude [permission]', 1: 'claude [done]' } } + { 'tab-1': { 0: 'Claude - action required', 1: 'Claude Code' } } ) expect(status).toBe('permission') @@ -93,7 +93,7 @@ describe('getWorktreeStatus', () => { // as a wake-hint sessionId. Reading tab.ptyId for liveness was the bug — // pin the new behavior so it can't regress. it('returns inactive for a slept tab (ptyIdsByTabId empty even if heuristic title matches working)', () => { - const status = getWorktreeStatus([{ id: 'tab-1', title: 'claude [working]' }], [], { + const status = getWorktreeStatus([{ id: 'tab-1', title: '⠋ working - claude' }], [], { 'tab-1': [] }) @@ -131,7 +131,7 @@ describe('getWorktreeStatus', () => { it('still spins on an agent-attributable braille-spinner title', () => { const status = getWorktreeStatus( - [{ id: 'tab-1', title: '⠹ codex fix flaky test' }], + [{ id: 'tab-1', title: '⠹ fix flaky test - codex' }], [], livePtyMap('tab-1') ) @@ -143,7 +143,7 @@ describe('getWorktreeStatus', () => { describe('resolveWorktreeStatus', () => { it('returns inactive when no tab has a live pty and no explicit agent row exists', () => { const status = resolveWorktreeStatus({ - tabs: [{ id: 'tab-1', title: 'claude [done]' }], + tabs: [{ id: 'tab-1', title: 'Claude Code' }], browserTabs: [], // Slept: live-pty array empty; tab.ptyId would be the wake-hint sessionId. ptyIdsByTabId: { 'tab-1': [] }, @@ -214,7 +214,7 @@ describe('resolveWorktreeStatus', () => { it('promotes to permission when an explicit agent row needs input, even without a live pty', () => { const status = resolveWorktreeStatus({ - tabs: [{ id: 'tab-1', title: 'claude [permission]' }], + tabs: [{ id: 'tab-1', title: 'Claude - action required' }], browserTabs: [], ptyIdsByTabId: { 'tab-1': [] }, hasPermission: true, @@ -291,7 +291,7 @@ describe('resolveWorktreeStatus', () => { it('lets heuristic working beat hasLiveDone (newer in-progress signal wins)', () => { const status = resolveWorktreeStatus({ - tabs: [{ id: 'tab-1', title: 'claude [working]' }], + tabs: [{ id: 'tab-1', title: '⠋ working - claude' }], browserTabs: [], ptyIdsByTabId: livePtyMap('tab-1'), hasPermission: false, @@ -305,12 +305,12 @@ describe('resolveWorktreeStatus', () => { it('lets a hook-covered done pane suppress its stale working title', () => { const status = resolveWorktreeStatus({ - tabs: [{ id: 'tab-1', title: 'claude [working]' }], + tabs: [{ id: 'tab-1', title: '⠋ working - claude' }], browserTabs: [], ptyIdsByTabId: livePtyMap('tab-1'), runtimePaneTitlesByTabId: { 'tab-1': { - 1: 'codex [working]', + 1: '⠋ working - codex', 2: 'bash' } }, @@ -331,12 +331,12 @@ describe('resolveWorktreeStatus', () => { it('lets a single hook-covered done pane suppress an unmapped single working title', () => { const status = resolveWorktreeStatus({ - tabs: [{ id: 'tab-1', title: 'claude [working]' }], + tabs: [{ id: 'tab-1', title: '⠋ working - claude' }], browserTabs: [], ptyIdsByTabId: livePtyMap('tab-1'), runtimePaneTitlesByTabId: { 'tab-1': { - 1: 'codex [working]' + 1: '⠋ working - codex' } }, agentStatusPaneIdsByTabId: { @@ -353,13 +353,13 @@ describe('resolveWorktreeStatus', () => { it('keeps sibling pane working when hook done covers only another pane', () => { const status = resolveWorktreeStatus({ - tabs: [{ id: 'tab-1', title: 'claude [working]' }], + tabs: [{ id: 'tab-1', title: '⠋ working - claude' }], browserTabs: [], ptyIdsByTabId: livePtyMap('tab-1'), runtimePaneTitlesByTabId: { 'tab-1': { 1: 'bash', - 2: 'codex [working]' + 2: '⠋ working - codex' } }, agentStatusPaneIdsByTabId: { @@ -384,7 +384,7 @@ describe('resolveWorktreeStatus', () => { // be silently downgraded to 'done' whenever a separate done overlay exists. it('honors heuristic permission over hasLiveDone (priority: permission > done)', () => { const status = resolveWorktreeStatus({ - tabs: [{ id: 'tab-1', title: 'claude [permission]' }], + tabs: [{ id: 'tab-1', title: 'Claude - action required' }], browserTabs: [], ptyIdsByTabId: livePtyMap('tab-1'), hasPermission: false, diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 4e234436958..fcd07718161 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -1,8 +1,8 @@ -import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree-title-derived-agent-rows' import { classifyTitleActivity } from '@/lib/pane-agent-evidence' +import { resolveCanonicalPaneAgentIdentity } from '../../../shared/pane-agent-identity-adapter' +import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id' -import { containsAgentSpinnerGlyph } from '../../../shared/agent-title-core' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, @@ -112,13 +112,14 @@ function tabHasStatus( // Why: require agent attribution so a bare never-cleared spinner title can't spin the dot "0 agents" forever with no matching sidebar row. function titleStatusIsAgentAttributable(title: string, launchAgent?: TuiAgent | null): boolean { - if (resolveAgentTypeFromTerminalTitle(title) !== null) { - return true - } - // Why: a spinner proves activity but not identity (Claude's thinking title has no provider - // token, #9040); the tab's launch identity supplies it, mirroring the row builder's spinner - // fallback (#9647) so the dot and the sidebar row agree. - return containsAgentSpinnerGlyph(title) && Boolean(launchAgent) + // A title is only an activity hint here; identity attribution comes from the same canonical + // ladder as the tab icon, so a launch record cannot be displaced by a title token. + const identity = resolveCanonicalPaneAgentIdentity({ + launchAgent: launchAgent ?? null, + title, + uncoveredFallback: { agent: resolveExplicitTerminalTitleAgentType(title), titleOnly: true } + }) + return identity.agent !== null } export function getWorktreeStatusLabel(status: WorktreeStatus): string { diff --git a/src/renderer/src/store/slices/pane-foreground-agent.ts b/src/renderer/src/store/slices/pane-foreground-agent.ts index 73685b6b88b..e6ccc8b4667 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.ts @@ -1,10 +1,13 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TuiAgent } from '../../../../shared/tui-agent' +import type { ForegroundProcessProof } from '../../../../shared/pane-agent-identity-adapter' export type PaneForegroundAgentEntry = { /** Recognized agent process in the pane's foreground; null when unknown. */ agent: TuiAgent | null + /** Host-stamped process identity; absent reads remain uncovered hints. */ + processProof?: ForegroundProcessProof | null /** True only when fresh provider evidence is safe for input-byte routing. */ routingTrusted?: boolean /** True after exit/input evidence revokes routing until provider confirmation. */ @@ -46,6 +49,7 @@ export const createPaneForegroundAgentSlice: StateCreator< if ( current && current.agent === entry.agent && + current.processProof === entry.processProof && current.routingTrusted === entry.routingTrusted && current.routingRevoked === entry.routingRevoked && current.routingConfirmationPending === entry.routingConfirmationPending && diff --git a/src/renderer/src/store/slices/terminal-helpers.test.ts b/src/renderer/src/store/slices/terminal-helpers.test.ts index ee84ad2faf7..e6a433c4b62 100644 --- a/src/renderer/src/store/slices/terminal-helpers.test.ts +++ b/src/renderer/src/store/slices/terminal-helpers.test.ts @@ -74,7 +74,7 @@ describe('clearTransientTerminalState', () => { }) it('uses "Terminal {index+1}" when customTitle is whitespace only', () => { - const tab = makeTab({ title: '⠋ codex running', customTitle: ' ' }) + const tab = makeTab({ title: 'Codex ready', customTitle: ' ' }) const result = clearTransientTerminalState(tab, 0) expect(result.title).toBe('Terminal 1') }) diff --git a/src/renderer/src/store/slices/terminal-helpers.ts b/src/renderer/src/store/slices/terminal-helpers.ts index 3815849ea14..4c4f78daeaa 100644 --- a/src/renderer/src/store/slices/terminal-helpers.ts +++ b/src/renderer/src/store/slices/terminal-helpers.ts @@ -1,5 +1,5 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' -import { classifyTitleActivity } from '@/lib/pane-agent-evidence' +import { resolveCanonicalPaneAgentIdentity } from '../../../../shared/pane-agent-identity-adapter' export function emptyLayoutSnapshot(): TerminalLayoutSnapshot { return { @@ -37,5 +37,5 @@ function getResetTitle(tab: TerminalTab, index: number): string { // Why: reset any recognized agent title on hydration. The prior-session // agent is no longer running after a restart, so showing a stale // "Claude done" or spinner would be misleading. - return classifyTitleActivity(tab.title) ? fallbackTitle : tab.title + return resolveCanonicalPaneAgentIdentity({ title: tab.title }).agent ? fallbackTitle : tab.title } diff --git a/src/shared/pane-agent-evidence-sources.ts b/src/shared/pane-agent-evidence-sources.ts index 32b3648494a..429c05b7f47 100644 --- a/src/shared/pane-agent-evidence-sources.ts +++ b/src/shared/pane-agent-evidence-sources.ts @@ -4,7 +4,11 @@ export const PANE_AGENT_EVIDENCE_SOURCES = [ 'live-hook', /** The pane's foreground process, as read on the execution host. */ 'process', - /** Orca launched, resumed, or accepted a command for this agent. A fact Orca owns. */ + /** Orca launched, resumed, or accepted a command for this agent. A fact Orca owns. + * Why above completed-hook: completed evidence is newer in principle, but production supplies + * neither completedHookRun nor launchRun and undefined runs remain eligible for mixed-version + * compatibility. A completed hook can therefore describe the previous occupant of a reused + * pane, while launch is scoped to how this pane was set up. Revisit when run keys are wired. */ 'launch', /** A provider hook from a turn that finished. Still authoritative about identity. */ 'completed-hook', diff --git a/src/shared/pane-agent-identity-adapter.test.ts b/src/shared/pane-agent-identity-adapter.test.ts index d898cb9adcf..cb348e60f24 100644 --- a/src/shared/pane-agent-identity-adapter.test.ts +++ b/src/shared/pane-agent-identity-adapter.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { buildPaneAgentIdentityEvidenceWire, @@ -136,6 +138,34 @@ describe('uncovered compatibility lane', () => { ).toMatchObject({ agent: null, source: null, coverage: 'uncovered' }) }) + it('does not let a legacy title fallback promote a bare free-text name', () => { + expect( + resolveCanonicalPaneAgentIdentity({ + title: 'grok', + uncoveredFallback: { agent: 'grok', titleOnly: true } + }) + ).toMatchObject({ + agent: null, + source: null, + coverage: 'uncovered', + titleOnly: false + }) + }) + + it('keeps anchored canonical evidence when the legacy title parser disagrees', () => { + expect( + resolveCanonicalPaneAgentIdentity({ + title: '✦ Claude Code', + uncoveredFallback: { agent: 'gemini', titleOnly: true } + }) + ).toMatchObject({ + agent: 'claude', + source: 'title', + coverage: 'uncovered', + titleOnly: true + }) + }) + it('does not label a foreground-only compatibility answer as title-only', () => { const identity = resolveCanonicalPaneAgentIdentity({ foregroundAgent: 'codex', @@ -184,6 +214,29 @@ describe('canonical ladder inside the covered lane', () => { }) }) +describe('launch/completed-hook ordering expiry', () => { + it('fails when production begins supplying run keys so the order is revisited', () => { + const rendererAdapter = [ + 'src/renderer/src/lib/use-tab-agent.ts', + 'src/renderer/src/lib/open-tab-occupant-agent.ts' + ] + .map((path) => readFileSync(join(process.cwd(), path), 'utf8')) + .join('\n') + const productionSuppliesRunKeys = /completedHookRun\s*:|launchRun\s*:/.test(rendererAdapter) + if (productionSuppliesRunKeys) { + throw new Error( + 'run keys are now supplied; re-evaluate whether completed-hook should outrank launch.' + ) + } + expect( + resolveCanonicalPaneAgentIdentity({ + launchAgent: 'claude', + completedHookAgent: 'codex' + }) + ).toMatchObject({ agent: 'claude', source: 'launch' }) + }) +}) + describe('reclaim-versus-stale-hook discriminator (run keys, not title text)', () => { const run1 = { authorityId: 'main:a', incarnation: 1 } const run2 = { authorityId: 'main:a', incarnation: 2 } diff --git a/src/shared/pane-agent-identity-adapter.ts b/src/shared/pane-agent-identity-adapter.ts index c08e1b77dea..9f8a1c904a4 100644 --- a/src/shared/pane-agent-identity-adapter.ts +++ b/src/shared/pane-agent-identity-adapter.ts @@ -115,7 +115,15 @@ export type CanonicalPaneAgentIdentity = { supersededSources: readonly PaneAgentEvidenceSource[] } -/** Authority order, strongest first. This is the only place precedence is expressed. */ +/** + * Authority order, strongest first. This is the only place precedence is expressed. + * + * Launch intentionally outranks completed-hook while run keys are absent: a completed hook is + * newer evidence in the abstract, but the optional run-key filter currently treats undefined as + * eligible and an unfiltered completed row can belong to a previous occupant of a reused pane; + * launch is scoped to how this pane was set up. Once production supplies `launchRun` and + * `completedHookRun`, revisit this ordering (the expiry test names the required review). + */ const SOURCE_RANK: readonly PaneAgentEvidenceSource[] = PANE_AGENT_EVIDENCE_SOURCES /** Exported for the source/rank drift ratchet; the rank is the canonical source list itself. */ @@ -232,27 +240,26 @@ export function resolveCanonicalPaneAgentIdentity( if (!hasAuthorityEvidence) { if (input.uncoveredFallback) { const agent = input.uncoveredFallback.agent - // A legacy title parser may have picked the first token from an ambiguous or - // free-text-only title. Do not let that compatibility value bypass the canonical - // ambiguity fence when the caller marks it as title-only evidence. - const rejectTitleFallback = - input.uncoveredFallback.titleOnly === true && - ((titleEvidence?.reason === 'free-text-only' && - (titleEvidence.freeTextNames?.length ?? 0) > 1) || + if (input.uncoveredFallback.titleOnly === true) { + // Anchored canonical evidence stands on its own. A marker-only claim retains the legacy + // parser's false-positive guard, while free text can never be promoted by the fallback. + const fallbackConfirmsVendorMarker = + titleEvidence?.reason === 'vendor-marker' && agent !== null && agent === titleAgent + const acceptedTitleAgent = + titleEvidence?.reason === 'anchored' || fallbackConfirmsVendorMarker ? titleAgent : null + const titleIsAmbiguous = titleEvidence?.reason === 'conflicting-anchored-names' || - titleEvidence?.reason === 'conflicting-vendor-markers') - if (rejectTitleFallback) { + titleEvidence?.reason === 'conflicting-vendor-markers' return { - agent: null, - source: null, + agent: acceptedTitleAgent, + source: acceptedTitleAgent === null ? null : 'title', coverage: 'uncovered', - titleOnly: false, - ...(titleEvidence?.reason === 'free-text-only' ? {} : { ambiguousAt: 'title' as const }), + titleOnly: acceptedTitleAgent !== null, + ...(titleIsAmbiguous ? { ambiguousAt: 'title' as const } : {}), supersededSources: [] } } - const titleOnly = - input.uncoveredFallback.titleOnly ?? (agent !== null && agent === titleAgent) + const titleOnly = agent !== null && agent === titleAgent return { agent, source: agent === null ? null : titleOnly ? 'title' : null, diff --git a/src/shared/pane-agent-identity-inventory.test.ts b/src/shared/pane-agent-identity-inventory.test.ts index ee868bfcc16..f1adee5e31c 100644 --- a/src/shared/pane-agent-identity-inventory.test.ts +++ b/src/shared/pane-agent-identity-inventory.test.ts @@ -28,7 +28,8 @@ const HELPERS = [ ] as const const TEST_SUPPORT_PATHS = new Set([ - 'src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts' + 'src/renderer/src/components/terminal-pane/pty-connection-test-environment.ts', + 'src/renderer/src/lib/tab-agent-identity-shipping-baseline.ts' ]) type Helper = (typeof HELPERS)[number] @@ -150,8 +151,14 @@ const INVENTORY: readonly InventoryGroup[] = [ classification: 'identity-consumer', paths: [ ['mobile/src/session/mobile-terminal-tab-agent.ts', 2], - ['src/renderer/src/lib/open-tab-occupant-agent.ts', 2], - ['src/renderer/src/lib/use-tab-agent.ts', 3] + ['src/renderer/src/components/sidebar/smart-attention-title-status.ts', 2], + ['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2], + ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2], + ['src/renderer/src/components/status-bar/workspace-space-presentation.ts', 3], + ['src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', 2], + ['src/renderer/src/lib/tab-agent-from-signals.ts', 2], + ['src/renderer/src/lib/use-tab-agent.ts', 2], + ['src/renderer/src/lib/worktree-status.ts', 2] ] }, { @@ -159,7 +166,6 @@ const INVENTORY: readonly InventoryGroup[] = [ classification: 'parser-implementation', paths: [ ['src/renderer/src/lib/pane-agent-evidence.ts', 2], - ['src/renderer/src/lib/tab-agent-from-signals.ts', 2], 'src/shared/terminal-title-agent-type.ts' ] }, @@ -177,10 +183,7 @@ const INVENTORY: readonly InventoryGroup[] = [ { helper: 'resolveCommittedTitleAgentType', classification: 'identity-consumer', - paths: [ - ['src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', 4], - ['src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', 2] - ] + paths: [['src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', 2]] }, { helper: 'resolveCommittedTitleAgentType', @@ -209,8 +212,7 @@ const INVENTORY: readonly InventoryGroup[] = [ classification: 'identity-consumer', paths: [ ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2], - ['src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts', 2], - ['src/renderer/src/lib/use-tab-agent.ts', 2] + ['src/renderer/src/components/terminal-pane/pty-connection/shell-command-inference.ts', 2] ] }, { @@ -222,11 +224,8 @@ const INVENTORY: readonly InventoryGroup[] = [ helper: 'resolveCompatibleAgentTypeForOwner', classification: 'identity-consumer', paths: [ - ['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2], ['src/main/runtime/runtime-mobile-session-projection.ts', 3], - ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2], - ['src/renderer/src/lib/tab-agent-from-signals.ts', 2], - ['src/renderer/src/lib/use-tab-agent.ts', 2] + ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2] ] }, { @@ -253,12 +252,11 @@ const INVENTORY: readonly InventoryGroup[] = [ helper: 'classifyTitleActivity', classification: 'identity-consumer', paths: [ - ['src/renderer/src/components/sidebar/smart-attention.ts', 3], + ['src/renderer/src/components/sidebar/smart-attention-title-status.ts', 2], ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2], ['src/renderer/src/components/status-bar/workspace-space-presentation.ts', 3], ['src/renderer/src/lib/active-agent-note-target.ts', 2], - ['src/renderer/src/lib/worktree-status.ts', 3], - ['src/renderer/src/store/slices/terminal-helpers.ts', 2] + ['src/renderer/src/lib/worktree-status.ts', 3] ] }, { @@ -340,11 +338,7 @@ const INVENTORY: readonly InventoryGroup[] = [ { helper: 'resolveAgentTypeFromTerminalTitle', classification: 'identity-consumer', - paths: [ - ['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2], - 'src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', - ['src/renderer/src/lib/worktree-status.ts', 2] - ] + paths: ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts'] }, { helper: 'resolvePaneAgentIdentity', @@ -364,7 +358,21 @@ const INVENTORY: readonly InventoryGroup[] = [ { helper: 'resolveCanonicalPaneAgentIdentity', classification: 'identity-consumer', - paths: [['src/shared/agent-status-identity.ts', 2]] + paths: [ + ['src/shared/agent-status-identity.ts', 2], + ['src/renderer/src/components/sidebar/smart-attention-title-status.ts', 2], + ['src/renderer/src/components/sidebar/worktree-agent-row-type.ts', 2], + ['src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts', 2], + ['src/renderer/src/components/status-bar/workspace-space-presentation.ts', 3], + ['src/renderer/src/components/tab-bar/tab-agent-types-by-tab-id.ts', 3], + ['src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts', 2], + ['src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts', 2], + ['src/renderer/src/components/terminal-pane/terminal-tab-agent-type-index.ts', 2], + ['src/renderer/src/lib/tab-agent-from-signals.ts', 2], + ['src/renderer/src/lib/tab-agent-status-index.ts', 2], + ['src/renderer/src/lib/worktree-status.ts', 2], + ['src/renderer/src/store/slices/terminal-helpers.ts', 2] + ] }, { helper: 'resolveCanonicalPaneAgentIdentity',