diff --git a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx index 3cc6a9077dd..6ea718f9635 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.test.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.test.tsx @@ -6,7 +6,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { TooltipProvider } from '@/components/ui/tooltip' import type { AppState } from '@/store/types' -import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentStatusState +} from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { @@ -132,6 +136,53 @@ describe('palette live status', () => { }) } + // Why: Orca injects its own " - action required" OSC title on a blocked/waiting hook and + // classifies that title back as evidence. Once the pane's row aged out it stopped registering its + // identity, so the self-authored title outranked the pane's own `done` row and the palette dot + // claimed a question nobody was asking. + it.each(['worktree', 'recent'] as const)( + 'does not paint a stale self-authored title as a live %s question', + async (surface) => { + const staleAt = Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 + useAppStore.setState((s) => ({ + tabsByWorktree: { + 'wt-a': [{ ...makeTerminalTab('term-a', 'wt-a'), title: 'Codex - action required' }] + }, + agentStatusByPaneKey: { + [makePaneKey('term-a', LEAF)]: makeAgentEntry('term-a', 'done', { + updatedAt: staleAt, + stateStartedAt: staleAt + }) + }, + agentStatusEpoch: s.agentStatusEpoch + 1 + })) + + if (surface === 'worktree') { + await render() + } else { + await act(async () => { + testRoot.render( + + } + /> + + ) + }) + expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() + } + + expect(dotLabels()).not.toContain('Needs permission') + } + ) + it('updates a worktree dot when the agent transitions', async () => { setAgentState('working') await render() @@ -144,6 +195,53 @@ describe('palette live status', () => { expect(dotLabels()).toEqual(['Needs permission']) }) + it('attributes stale permission titles to their split pane without hiding a live sibling', async () => { + const otherLeaf = '22222222-2222-4222-8222-222222222222' + const staleAt = Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1 + setAgentState('done', { updatedAt: staleAt, stateStartedAt: staleAt }) + useAppStore.setState({ + terminalLayoutsByTabId: { + 'term-a': { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF }, + second: { type: 'leaf', leafId: otherLeaf } + }, + activeLeafId: otherLeaf, + expandedLeafId: null + } + }, + runtimePaneTitlesByTabId: { + 'term-a': { 1: 'Codex - action required', 2: 'shell' } + } + }) + + await render() + expect(dotLabels()).toEqual(['Active']) + + await act(async () => { + useAppStore.setState({ + runtimePaneTitlesByTabId: { 'term-a': { 2: 'Codex - action required' } } + }) + }) + expect(dotLabels()).toEqual(['Needs permission']) + + await act(async () => { + useAppStore.setState({ + runtimePaneTitlesByTabId: { + 'term-a': { 1: 'Codex - action required', 2: '⠹ codex working' } + } + }) + }) + expect(dotLabels()).toEqual(['Working']) + + await act(async () => { + setAgentState('blocked') + }) + expect(dotLabels()).toEqual(['Needs permission']) + }) + it('shows monitoring when a covered pane retains a working title', async () => { setAgentState('working', { workingMode: 'monitoring' }) useAppStore.setState({ diff --git a/src/renderer/src/components/cmd-j/palette-live-status.tsx b/src/renderer/src/components/cmd-j/palette-live-status.tsx index da59663490f..34840d50cc5 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -41,6 +41,7 @@ import { useNow } from '@/hooks/use-now' type PaletteLiveStatus = { liveAgentStatusByWorktreeId: ReadonlyMap agentStatusPaneIdsByTabId: Record> + stalePaneIdsByTabId: Record> paneSources: TabPaneInputSources tabsByWorktree: Record browserTabsByWorktree: Record @@ -98,13 +99,15 @@ export function PaletteLiveStatusProvider({ agentStatusByPaneKey, migrationUnsupportedByPtyId ) + const livePaneIds = buildLiveAgentStatusPaneIdsByTabId(entriesByTabId, now) return { liveAgentStatusByWorktreeId: getLiveAgentStatusByWorktreeId( agentStatusByPaneKey, tabsByWorktree, now ), - agentStatusPaneIdsByTabId: buildLiveAgentStatusPaneIdsByTabId(entriesByTabId, now), + agentStatusPaneIdsByTabId: livePaneIds.paneIdsByTabId, + stalePaneIdsByTabId: livePaneIds.stalePaneIdsByTabId, paneSources: { entriesByTabId, ptyIdsByTabId, @@ -137,30 +140,41 @@ export function PaletteLiveStatusProvider({ ) } +/** Fresh rows suppress all title heuristics; stale rows suppress generated permission labels. */ function buildLiveAgentStatusPaneIdsByTabId( entriesByTabId: ReadonlyMap, now: number -): Record> { +): { + paneIdsByTabId: Record> + stalePaneIdsByTabId: Record> +} { const paneIdsByTabId: Record> = {} + const stalePaneIdsByTabId: Record> = {} for (const [tabId, entries] of entriesByTabId) { const paneIds = new Set() + const stalePaneIds = new Set() for (const entry of entries) { + const paneId = parsePaneKey(entry.paneKey)?.leafId + if (!paneId) { + continue + } if ( entry.restoredUnconfirmed !== true && !isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS) ) { + stalePaneIds.add(paneId) continue } - const paneId = parsePaneKey(entry.paneKey)?.leafId - if (paneId) { - paneIds.add(paneId) - } + paneIds.add(paneId) } if (paneIds.size > 0) { paneIdsByTabId[tabId] = paneIds } + if (stalePaneIds.size > 0) { + stalePaneIdsByTabId[tabId] = stalePaneIds + } } - return paneIdsByTabId + return { paneIdsByTabId, stalePaneIdsByTabId } } const EMPTY_LIVE_INPUTS = Object.freeze({ @@ -199,7 +213,9 @@ export function PaletteWorktreeStatusDot({ live.paneSources.runtimePaneTitlesByTabId, { liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id), - agentStatusPaneIdsByTabId: live.agentStatusPaneIdsByTabId + agentStatusPaneIdsByTabId: live.agentStatusPaneIdsByTabId, + stalePaneIdsByTabId: live.stalePaneIdsByTabId, + terminalLayoutsByTabId: live.paneSources.terminalLayoutsByTabId } ) return ( diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 6249ad60d77..481dd0c2010 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -3,6 +3,7 @@ import { agentEntryCompletionAt } from '../../../../shared/agent-completion-time import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { resolveDecayedAgentRowState } from '@/lib/agent-row-decay-state' import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { isSyntheticAgentPermissionTitle } from '../../../../shared/synthetic-agent-title' import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' import type { AgentStatus } from '../../../../shared/agent-detection' import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types' @@ -300,8 +301,14 @@ export function collectTabPaneInputs( const hasLivePty = tabHasLivePty(sources.ptyIdsByTabId, tab.id) // Why: leaves covered by a hook entry skip the title fallback so we don't double-count them. const hookLeafIds = new Set() + // Stale hooks still suppress one-shot permission titles, matching worktree and tab status dots. + const permissionHookLeafIds = new Set() for (const entry of sources.entriesByTabId.get(tab.id) ?? []) { panes.push({ kind: 'hook', entry, hasLivePty }) + const leafId = leafIdFromPaneKey(entry.paneKey) + if (leafId !== null) { + permissionHookLeafIds.add(leafId) + } // Why: restored rows own their co-restored title without asserting live state. if ( !entry.restoredUnconfirmed && @@ -309,7 +316,6 @@ export function collectTabPaneInputs( ) { continue } - const leafId = leafIdFromPaneKey(entry.paneKey) if (leafId !== null) { hookLeafIds.add(leafId) } @@ -322,7 +328,10 @@ export function collectTabPaneInputs( const paneTitles = sources.runtimePaneTitlesByTabId[tab.id] if (!paneTitles || Object.keys(paneTitles).length === 0) { - if (hookLeafIds.size === 0) { + const coveredLeafIds = isSyntheticAgentPermissionTitle(tab.title) + ? permissionHookLeafIds + : hookLeafIds + if (coveredLeafIds.size === 0) { // Why: unmounted tabs (restored-but-unvisited) expose only the legacy tab title. panes.push({ kind: 'title', @@ -337,10 +346,13 @@ export function collectTabPaneInputs( const tabLayout = sources.terminalLayoutsByTabId?.[tab.id] const paneTitleEntries = Object.entries(paneTitles) for (const [runtimePaneId, title] of paneTitleEntries) { + const coveredLeafIds = isSyntheticAgentPermissionTitle(title) + ? permissionHookLeafIds + : hookLeafIds const leafId = resolveRuntimePaneTitleLeafId(tabLayout, runtimePaneId) const hasSingleUnmappedHook = - leafId === null && hookLeafIds.size === 1 && paneTitleEntries.length === 1 - if ((leafId !== null && hookLeafIds.has(leafId)) || hasSingleUnmappedHook) { + leafId === null && coveredLeafIds.size === 1 && paneTitleEntries.length === 1 + if ((leafId !== null && coveredLeafIds.has(leafId)) || hasSingleUnmappedHook) { continue } panes.push({ kind: 'title', status: classifyTitleActivity(title), worktreeLastActivityAt }) diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts index d0ca4bdae53..d6325f675a6 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -29,7 +29,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { hasInterrupted, hasLiveDone, hasRetainedDone, - agentStatusPaneIdsByTabId + agentStatusPaneIdsByTabId, + stalePaneIdsByTabId } = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId))) // Why: compact and detailed cards need the same status-dot semantics: @@ -43,6 +44,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { ptyIdsByTabId: ptyIdsForWorktree, runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree, agentStatusPaneIdsByTabId, + stalePaneIdsByTabId, terminalLayoutRootsByTabId, hasPermission, hasLiveWorking, @@ -57,6 +59,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { ptyIdsForWorktree, runtimePaneTitlesForWorktree, agentStatusPaneIdsByTabId, + stalePaneIdsByTabId, terminalLayoutRootsByTabId, hasPermission, hasLiveWorking, diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts index b7902660a19..d62573307a1 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts @@ -37,7 +37,8 @@ export function selectWorktreeActivityStatuses( hasInterrupted, hasLiveDone, hasRetainedDone, - agentStatusPaneIdsByTabId + agentStatusPaneIdsByTabId, + stalePaneIdsByTabId } = selectWorktreeAgentActivitySummary(statusInputs, worktreeId) statuses.set( worktreeId, @@ -47,6 +48,7 @@ export function selectWorktreeActivityStatuses( ptyIdsByTabId: selectLivePtyIdsForWorktree(statusInputs, worktreeId), runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(statusInputs, worktreeId), agentStatusPaneIdsByTabId, + stalePaneIdsByTabId, terminalLayoutRootsByTabId: selectTerminalLayoutRootsForWorktree(statusInputs, worktreeId), hasPermission, hasLiveWorking, diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts index 69ec75aa41a..7b3bdd42849 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.test.ts @@ -1,7 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { shallow } from 'zustand/shallow' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' +import { resolveWorktreeStatus } from '@/lib/worktree-status' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { selectWorktreeAgentActivitySummary, @@ -440,4 +444,63 @@ describe('selectWorktreeAgentActivitySummary', () => { const summary = selectWorktreeAgentActivitySummary(state, 'repo::/wt-1') expect(summary.agentStatusPaneIdsByTabId['tab-parent']).toEqual(new Set([LEAF_ID])) }) + + // Why: Orca injects its own " - action required" OSC title on a blocked/waiting hook, + // then classifies that title back as evidence. If a pane stopped registering its identity once + // its row aged out, that self-authored title outranked the pane's own `done` row and pinned the + // workspace card to the question icon with no agent asking anything. + it('records a stale entry pane id separately so permission titles stay suppressed', () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + const entry = makeAgentStatusEntry({ paneKey, state: 'done', worktreeId: 'repo::/wt-1' }) + vi.spyOn(Date, 'now').mockReturnValue(entry.updatedAt + AGENT_STATUS_STALE_AFTER_MS + 1) + const state: AgentActivityInput = { + tabsByWorktree: { 'repo::/wt-1': [makeTab('tab-1', 'repo::/wt-1')] }, + agentStatusEpoch: 0, + agentStatusByPaneKey: { [paneKey]: entry }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + } + + const summary = selectWorktreeAgentActivitySummary(state, 'repo::/wt-1') + + expect(summary.stalePaneIdsByTabId['tab-1']).toEqual(new Set([LEAF_ID])) + // Staleness still ends the row's authority: no fresh pane id, no liveness flag. + expect(summary.agentStatusPaneIdsByTabId['tab-1']).toBeUndefined() + expect(summary.hasLiveDone).toBe(false) + }) + + // Reproduces the reported card: a Codex pane parked at its composer, its only agent row `done` + // and ~2h old, and the workspace still painting the amber question icon. `permission` outranks + // `hasLiveDone` in resolveWorktreeStatus, so the pane's stale self-authored title decided the + // card. With no fresh evidence the honest answer is `active`, never a question nobody asked. + it('does not paint a stale self-authored action-required title as a live question', () => { + const paneKey = makePaneKey('tab-1', LEAF_ID) + const entry = makeAgentStatusEntry({ paneKey, state: 'done', worktreeId: 'repo::/wt-1' }) + vi.spyOn(Date, 'now').mockReturnValue(entry.updatedAt + AGENT_STATUS_STALE_AFTER_MS + 1) + const tab = { ...makeTab('tab-1', 'repo::/wt-1'), title: 'Codex - action required' } + const state: AgentActivityInput = { + tabsByWorktree: { 'repo::/wt-1': [tab] }, + agentStatusEpoch: 0, + agentStatusByPaneKey: { [paneKey]: entry }, + migrationUnsupportedByPtyId: {}, + runtimeAgentOrchestrationByPaneKey: {}, + retainedAgentsByPaneKey: {} + } + const summary = selectWorktreeAgentActivitySummary(state, 'repo::/wt-1') + + const status = resolveWorktreeStatus({ + tabs: [tab], + browserTabs: [], + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + agentStatusPaneIdsByTabId: summary.agentStatusPaneIdsByTabId, + stalePaneIdsByTabId: summary.stalePaneIdsByTabId, + hasPermission: summary.hasPermission, + hasLiveWorking: summary.hasLiveWorking, + hasLiveDone: summary.hasLiveDone, + hasRetainedDone: summary.hasRetainedDone + }) + + expect(status).toBe('active') + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index fbb2d93869a..589b7c46854 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -21,6 +21,8 @@ export type WorktreeAgentActivitySummary = { hasLiveDone: boolean hasRetainedDone: boolean agentStatusPaneIdsByTabId: Record> + /** Stale rows suppress generated permission labels while preserving native title fallback. */ + stalePaneIdsByTabId: Record> } const EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID: Record> = {} @@ -32,7 +34,8 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = { hasInterrupted: false, hasLiveDone: false, hasRetainedDone: false, - agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID + agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID, + stalePaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID } type AgentActivityTabsByWorktree = Record @@ -121,6 +124,10 @@ function getWorktreeAgentActivitySummaries( continue } if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + // Why: staleness ends this row's authority but not the pane's identity — see + // `stalePaneIdsByTabId`. Dropping both let Orca's self-authored permission title outlive + // the row it came from and pin the card to a question nobody was asking. + addStalePaneId(summary, paneIdentity.tabId, paneIdentity.paneId) continue } addAgentStatusPaneId(summary, paneIdentity.tabId, paneIdentity.paneId) @@ -189,7 +196,8 @@ function summariesEqual( agentStatusPaneIdsByTabIdEqual( previous.agentStatusPaneIdsByTabId, next.agentStatusPaneIdsByTabId - ) + ) && + agentStatusPaneIdsByTabIdEqual(previous.stalePaneIdsByTabId, next.stalePaneIdsByTabId) ) } @@ -244,15 +252,31 @@ function addAgentStatusPaneId( tabId: string, paneId: string ): void { - if (summary.agentStatusPaneIdsByTabId === EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID) { - summary.agentStatusPaneIdsByTabId = {} - } - let paneIds = summary.agentStatusPaneIdsByTabId[tabId] as Set | undefined + summary.agentStatusPaneIdsByTabId = withPaneId(summary.agentStatusPaneIdsByTabId, tabId, paneId) +} + +function addStalePaneId( + summary: WorktreeAgentActivitySummary, + tabId: string, + paneId: string +): void { + summary.stalePaneIdsByTabId = withPaneId(summary.stalePaneIdsByTabId, tabId, paneId) +} + +function withPaneId( + byTabId: Record>, + tabId: string, + paneId: string +): Record> { + // Why: the shared empty record is the frozen default for every summary; copy on first write. + const next = byTabId === EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID ? {} : byTabId + let paneIds = next[tabId] as Set | undefined if (!paneIds) { paneIds = new Set() - summary.agentStatusPaneIdsByTabId[tabId] = paneIds + next[tabId] = paneIds } paneIds.add(paneId) + return next } function worktreeIdForPaneKey( 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..6e013ec2208 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 @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../../shared/agent-status-types' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { hasUnreadAgentCompletionForTerminalTab, @@ -54,6 +57,24 @@ afterEach(() => { }) describe('resolveTerminalTabActivityStatus', () => { + // Why: Orca injects its own " - action required" OSC title on a blocked/waiting hook and + // classifies that title back as evidence. Once the pane's row aged past the freshness window it + // stopped registering its identity, so the self-authored title outranked the pane's own `done` + // row and the tab glyph claimed a question nobody was asking. + it('does not paint a stale self-authored action-required title as a live question', () => { + const done = entry(FIRST_LEAF_ID, 'done', { + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1, + stateStartedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: 'Codex - action required' }, + agentStatusByPaneKey: { [done.paneKey]: done }, + ptyIdsByTabId: LIVE_PTY + }) + ).toBe('active') + }) + it('reports a fresh hook working state', () => { const working = entry(FIRST_LEAF_ID, 'working') expect( @@ -65,6 +86,24 @@ describe('resolveTerminalTabActivityStatus', () => { ).toBe('working') }) + it.each(['tab', 'pane'] as const)( + 'keeps native permission %s titles after hook freshness expires', + (surface) => { + const stale = entry(FIRST_LEAF_ID, 'working', { + agentType: 'gemini', + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 + }) + expect( + resolveTerminalTabActivityStatus({ + tab: { id: TAB_ID, title: '✋ Gemini CLI' }, + agentStatusByPaneKey: { [stale.paneKey]: stale }, + ptyIdsByTabId: LIVE_PTY, + runtimePaneTitlesByTabId: surface === 'pane' ? { [TAB_ID]: { 1: '✋ Gemini CLI' } } : {} + }) + ).toBe('permission') + } + ) + it('reports monitoring without hiding active or actionable siblings', () => { const monitoring = entry(FIRST_LEAF_ID, 'working', { workingMode: 'monitoring' }) const working = entry(SECOND_LEAF_ID, 'working') diff --git a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts index a801855a588..858cf024da2 100644 --- a/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts +++ b/src/renderer/src/components/tab-bar/terminal-tab-activity-status.ts @@ -23,6 +23,8 @@ type TerminalTabActivityFlags = { hasInterrupted: boolean hasLiveDone: boolean paneIds: Set + /** Panes whose row went stale; suppress generated permission labels only. */ + stalePaneIds: Set } type FlagsCache = { @@ -69,6 +71,10 @@ function getTerminalTabActivityFlags( // Why: stale hook entries (>30m) are not authority; a slept/abandoned pane // must not keep a tab spinning. Same freshness gate as the sidebar. if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + // Stale identity suppresses Orca's one-shot permission label without suppressing native titles. + getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId).stalePaneIds.add( + identity.paneId + ) continue } @@ -106,7 +112,8 @@ function getOrCreateTerminalTabActivityFlags( hasLiveMonitoring: false, hasInterrupted: false, hasLiveDone: false, - paneIds: new Set() + paneIds: new Set(), + stalePaneIds: new Set() } flagsByTabId.set(tabId, flags) } @@ -162,6 +169,7 @@ export function resolveTerminalTabActivityStatus({ ptyIdsByTabId: ptyIdsByTabId ?? {}, runtimePaneTitlesByTabId: runtimePaneTitlesByTabId ?? {}, agentStatusPaneIdsByTabId: { [tab.id]: flags?.paneIds ?? EMPTY_PANE_IDS }, + stalePaneIdsByTabId: { [tab.id]: flags?.stalePaneIds ?? EMPTY_PANE_IDS }, terminalLayoutsByTabId: terminalLayout ? { [tab.id]: terminalLayout } : undefined, hasPermission: flags?.hasPermission ?? false, hasLiveWorking: flags?.hasLiveWorking ?? false, diff --git a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts index 65a5718f3ac..a4872822252 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-event-applicator.ts @@ -69,7 +69,8 @@ export function createAgentStatusEventApplicator(args: { repoConnectionId, repoConnectionResolved, owningWorktreeId, - titleUsesTabTitle + titleUsesTabTitle, + tabTitle } = resolvePaneKeyFromRoutingIndex(routingIndex, paneKey) const projectedTitles = titleUsesTabTitle && ownerTabId @@ -79,6 +80,7 @@ export function createAgentStatusEventApplicator(args: { title = projectedTitles.title identityTitle = projectedTitles.identityTitle } + tabTitle = options?.batch?.tabTitlesByTabId.get(ownerTabId ?? '') ?? tabTitle if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { const fallbackOwnership = resolveWorktreeConnectionFromRoutingIndex( routingIndex, @@ -266,14 +268,13 @@ export function createAgentStatusEventApplicator(args: { options.batch.notificationEffects.push(applyPostCommitNotification) if ( terminalTitle && - shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, title, terminalTitle) + shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, tabTitle, terminalTitle) ) { - const tabId = parsePaneKey(paneKey)?.tabId - if (tabId) { - options.batch.tabTitlesByTabId.set(tabId, terminalTitle) + if (ownerTabId) { + options.batch.tabTitlesByTabId.set(ownerTabId, terminalTitle) if (titleUsesTabTitle) { const titleChanges = !title || !isDecorativeAgentTitleFrameChange(title, terminalTitle) - options.batch.projectedTitlesByTabId.set(tabId, { + options.batch.projectedTitlesByTabId.set(ownerTabId, { title: titleChanges ? terminalTitle : title, identityTitle: titleChanges ? terminalTitle : identityTitle }) @@ -289,7 +290,7 @@ export function createAgentStatusEventApplicator(args: { update.routing, update.metadata ) - applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, title, terminalTitle) + applyResolvedAgentTerminalTitleToTab(useAppStore.getState(), paneKey, tabTitle, terminalTitle) applyPostCommitNotification() } return 'applied' diff --git a/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts index 156789caad1..af08f4d6ccc 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-pane-routing-index.ts @@ -12,6 +12,7 @@ type AgentStatusPaneResolution = { repoConnectionResolved: boolean owningWorktreeId: string | undefined titleUsesTabTitle: boolean + tabTitle: string | undefined } type AgentStatusWorktreeConnectionResolution = { @@ -186,7 +187,8 @@ export function resolvePaneKeyFromRoutingIndex( repoConnectionId: null, repoConnectionResolved: false, owningWorktreeId: undefined, - titleUsesTabTitle: false + titleUsesTabTitle: false, + tabTitle: undefined } } const { tabId, leafId } = parsed @@ -199,7 +201,8 @@ export function resolvePaneKeyFromRoutingIndex( repoConnectionId: null, repoConnectionResolved: false, owningWorktreeId: undefined, - titleUsesTabTitle: false + titleUsesTabTitle: false, + tabTitle: undefined } } const connection = resolveWorktreeConnectionFromRoutingIndex(index, tab.owningWorktreeId) @@ -219,7 +222,8 @@ export function resolvePaneKeyFromRoutingIndex( repoConnectionId: connection.repoConnectionId, repoConnectionResolved: connection.repoConnectionResolved, owningWorktreeId: tab.owningWorktreeId, - titleUsesTabTitle: false + titleUsesTabTitle: false, + tabTitle: undefined } } } @@ -233,6 +237,7 @@ export function resolvePaneKeyFromRoutingIndex( repoConnectionId: connection.repoConnectionId, repoConnectionResolved: connection.repoConnectionResolved, owningWorktreeId: tab.owningWorktreeId, - titleUsesTabTitle: paneTitle === undefined + titleUsesTabTitle: paneTitle === undefined, + tabTitle: tab.title } } diff --git a/src/renderer/src/hooks/ipc-events/agent-status-routing.ts b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts index 1aa80a282ef..7cf8d47a22d 100644 --- a/src/renderer/src/hooks/ipc-events/agent-status-routing.ts +++ b/src/renderer/src/hooks/ipc-events/agent-status-routing.ts @@ -40,12 +40,12 @@ export function tryMakePaneKey(tabId: string, leafId: string): string | null { export function applyResolvedAgentTerminalTitleToTab( store: ReturnType, paneKey: string, - previousTitle: string | undefined, + currentTabTitle: string | undefined, nextTitle: string | undefined ): void { if ( !nextTitle || - !shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, previousTitle, nextTitle) + !shouldApplyResolvedAgentTerminalTitleToTab(store, paneKey, currentTabTitle, nextTitle) ) { return } @@ -57,13 +57,22 @@ export function applyResolvedAgentTerminalTitleToTab( store.updateTabTitle(parsed.tabId, nextTitle) } +/** + * `currentTabTitle` must be the TAB record's title, not the pane's layout slot. This path writes + * `tab.title` and nothing else, so comparing against `titlesByLeafId` — which only a mounted pane + * updates — skipped the write whenever the two slots had diverged, stranding a self-authored + * " - action required" label on the tab after the agent had already reported done. + * + * Inside a batch, pass the staged `tabTitlesByTabId` value when one exists: the batch flushes tab + * titles at the end, so an earlier event's staged write is what a later event actually overwrites. + */ export function shouldApplyResolvedAgentTerminalTitleToTab( store: ReturnType, paneKey: string, - previousTitle: string | undefined, + currentTabTitle: string | undefined, nextTitle: string | undefined ): boolean { - if (!nextTitle || nextTitle === previousTitle) { + if (!nextTitle || nextTitle === currentTabTitle) { return false } const parsed = parsePaneKey(paneKey) @@ -92,6 +101,8 @@ export function resolvePaneKey( repoConnectionResolved: boolean owningWorktreeId: string | undefined titleUsesTabTitle: boolean + /** The tab record's own title, which is the slot the hook-driven tab write actually overwrites. */ + tabTitle: string | undefined } { const parsed = parsePaneKey(paneKey) if (!parsed) { @@ -102,7 +113,8 @@ export function resolvePaneKey( repoConnectionId: null, repoConnectionResolved: false, owningWorktreeId: undefined, - titleUsesTabTitle: false + titleUsesTabTitle: false, + tabTitle: undefined } } const { tabId, leafId } = parsed @@ -149,7 +161,8 @@ export function resolvePaneKey( repoConnectionId, repoConnectionResolved, owningWorktreeId, - titleUsesTabTitle: false + titleUsesTabTitle: false, + tabTitle: undefined } } // Why: an empty layout snapshot from a worktree switch (tab/PTY still live) counts as missing metadata; a non-empty layout lacking the leaf still means closed. @@ -162,7 +175,8 @@ export function resolvePaneKey( repoConnectionId, repoConnectionResolved, owningWorktreeId, - titleUsesTabTitle: false + titleUsesTabTitle: false, + tabTitle: undefined } } // Why: inactive worktrees can have a durable tab and live PTY while the layout is unmounted; hook state must still land there. @@ -177,7 +191,8 @@ export function resolvePaneKey( repoConnectionId, repoConnectionResolved, owningWorktreeId, - titleUsesTabTitle: paneTitle === undefined + titleUsesTabTitle: paneTitle === undefined, + tabTitle } } diff --git a/src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts b/src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts new file mode 100644 index 00000000000..0be0d6465ac --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/agent-status-terminal-title-tab-write.test.ts @@ -0,0 +1,206 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { useAppStore } from '@/store' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' +import type { AgentStatusIpcPayload } from '../../../../shared/agent-status-types' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/terminal-tab-types' +import { buildWindowApi } from '../ipc-events-agent-status-window-test-fixtures' +import type { AgentStatusSetData } from '../ipc-events-agent-status-store-test-fixtures' +import { resolvePaneKey, shouldApplyResolvedAgentTerminalTitleToTab } from './agent-status-routing' + +vi.mock('../agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification: vi.fn(), + syncAgentHookCompletionNotificationsForStoreUpdate: vi.fn() +})) + +const TAB_ID = 'tab-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const WORKTREE_ID = 'repo-1::/wt-1' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) + +/** + * The two title slots this path straddles: `tab.title` (what it writes) and the layout's + * `titlesByLeafId` (what only a mounted pane updates). They diverge whenever a hook-driven write + * lands while the pane is unmounted. + */ +function storeWithDivergedTitleSlots(args: { + tabTitle: string + paneSlotTitle: string +}): ReturnType { + const tab: TerminalTab = { + id: TAB_ID, + ptyId: `pty-${TAB_ID}`, + worktreeId: WORKTREE_ID, + title: args.tabTitle, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + return { + tabsByWorktree: { [WORKTREE_ID]: [tab] }, + unifiedTabsByWorktree: {}, + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + titlesByLeafId: { [LEAF_ID]: args.paneSlotTitle } + } + }, + worktreesByRepo: {}, + repos: [] + } as unknown as ReturnType +} + +describe('hook-driven tab title writes', () => { + it('exposes the tab record title separately from the pane slot title', () => { + const store = storeWithDivergedTitleSlots({ + tabTitle: 'Codex - action required', + paneSlotTitle: 'Codex ready' + }) + + const resolved = resolvePaneKey(store, PANE_KEY) + + expect(resolved.title).toBe('Codex ready') + expect(resolved.tabTitle).toBe('Codex - action required') + }) + + // Why: Orca writes "Codex - action required" itself on a blocked/waiting hook, into `tab.title` + // only. When `done` arrived, the no-op guard compared the resolved title against the PANE slot — + // which still read "Codex ready" — so the write was skipped and the tab kept asserting a question + // the agent had already finished asking, for as long as the pane stayed unmounted. + it('rewrites a stale action-required tab title once the agent reports done', () => { + const store = storeWithDivergedTitleSlots({ + tabTitle: 'Codex - action required', + paneSlotTitle: 'Codex ready' + }) + const resolved = resolvePaneKey(store, PANE_KEY) + const nextTitle = resolveAgentStatusTerminalTitle( + { agentType: 'codex', state: 'done' }, + resolved.title + ) + + expect(nextTitle).toBe('Codex ready') + // Comparing against the pane slot is what skipped the write. + expect( + shouldApplyResolvedAgentTerminalTitleToTab(store, PANE_KEY, resolved.title, nextTitle) + ).toBe(false) + // The tab record is the slot this path overwrites, so it is the one that decides. + expect( + shouldApplyResolvedAgentTerminalTitleToTab(store, PANE_KEY, resolved.tabTitle, nextTitle) + ).toBe(true) + }) + + it('still skips the write when the tab record already holds the resolved title', () => { + const store = storeWithDivergedTitleSlots({ + tabTitle: 'Codex ready', + paneSlotTitle: 'Codex ready' + }) + const resolved = resolvePaneKey(store, PANE_KEY) + + expect( + shouldApplyResolvedAgentTerminalTitleToTab(store, PANE_KEY, resolved.tabTitle, 'Codex ready') + ).toBe(false) + }) +}) + +describe('hook-driven tab title IPC integration', () => { + afterEach(() => { + vi.doUnmock('../../store') + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it.each([ + { mode: 'live', states: ['done'], title: 'Codex - action required', expected: 'Codex ready' }, + { + mode: 'snapshot', + states: ['waiting', 'done'], + title: 'Codex ready', + expected: 'Codex ready' + }, + { + mode: 'snapshot', + states: ['done', 'waiting'], + title: 'Codex ready', + expected: 'Codex - action required' + }, + { + mode: 'inactive-pane', + states: ['done'], + title: 'Codex - action required', + expected: 'Codex - action required' + } + ] as const)( + 'applies $mode $states against the tab title slot', + async ({ mode, states, title, expected }) => { + vi.resetModules() + const store = createTestStore() + const seeded = storeWithDivergedTitleSlots({ tabTitle: title, paneSlotTitle: 'Codex ready' }) + const otherLeaf = '22222222-2222-4222-8222-222222222222' + if (mode === 'inactive-pane') { + seeded.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: otherLeaf } + }, + activeLeafId: otherLeaf, + expandedLeafId: null, + titlesByLeafId: { [LEAF_ID]: 'Codex ready', [otherLeaf]: title } + } + } + store.setState({ ...seeded, workspaceSessionReady: true, activeWorktreeId: null }) + const events = states.map((state, index): AgentStatusIpcPayload & AgentStatusSetData => ({ + paneKey: PANE_KEY, + worktreeId: WORKTREE_ID, + connectionId: null, + state, + agentType: 'codex', + prompt: 'Title clearing test', + receivedAt: Date.now() + index, + stateStartedAt: Date.now() + index + })) + let onSet: (payload: AgentStatusSetData) => void = () => { + throw new Error('listener missing') + } + vi.doMock('../../store', () => ({ useAppStore: store })) + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot: async () => (mode === 'snapshot' ? events : []), + onSet: (callback) => { + onSet = callback + return () => {} + } + }) + ) + const { registerAgentStatusIpcBridge } = await import('./agent-status-ipc-bridge') + const updateTitle = vi.spyOn(store.getState(), 'updateTabTitle') + const updateTitles = vi.spyOn(store.getState(), 'updateTabTitles') + const unsubs: (() => void)[] = [] + const bridge = registerAgentStatusIpcBridge(unsubs) + try { + if (mode !== 'snapshot') { + onSet(events[0]) + } + await vi.waitFor(() => { + expect(store.getState().agentStatusByPaneKey[PANE_KEY]?.state).toBe(states.at(-1)) + }) + expect(store.getState().tabsByWorktree[WORKTREE_ID][0].title).toBe(expected) + expect(store.getState().agentStatusByPaneKey[PANE_KEY].terminalTitle).toBe( + states.at(-1) === 'done' ? 'Codex ready' : 'Codex - action required' + ) + expect(updateTitle).toHaveBeenCalledTimes(mode === 'live' ? 1 : 0) + expect(updateTitles).toHaveBeenCalledTimes(mode === 'snapshot' ? 1 : 0) + } finally { + bridge.disposeAsyncState() + bridge.unsubscribeStore() + unsubs.forEach((unsubscribe) => unsubscribe()) + } + } + ) +}) diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts index ba7ead3bd90..b0488309bf3 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts @@ -5,7 +5,11 @@ import { type RecentWorkspaceTabRow } from './recent-workspace-tab-rows' import type { TabPaneInputSources } from '@/components/sidebar/smart-attention' -import type { AgentStatusEntry, AgentStatusState } from '../../../shared/agent-status-types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentStatusState +} from '../../../shared/agent-status-types' const NOW = 1_700_000_000_000 const LEAF_ID = '11111111-2222-4333-8444-555555555555' @@ -106,6 +110,54 @@ describe('orderRecentWorkspaceTabs', () => { }) describe('resolveRecentWorkspaceTabStatus', () => { + it.each(['tab', 'pane'] as const)( + 'suppresses a stale done pane permission %s title', + (surface) => { + const title = 'Codex - action required' + const stale = entry('stale', 'done', NOW - AGENT_STATUS_STALE_AFTER_MS - 1) + const paneSources = sources([stale], { + ptyIdsByTabId: { stale: ['pty-1'] }, + runtimePaneTitlesByTabId: surface === 'pane' ? { stale: { 1: title } } : {} + }) + expect( + resolveRecentWorkspaceTabStatus( + row('stale', { terminalTab: { id: 'stale', title } }), + paneSources, + NOW + ) + ).toBe('active') + + stale.updatedAt = NOW + stale.state = 'blocked' + expect(resolveRecentWorkspaceTabStatus(row('stale'), paneSources, NOW)).toBe('permission') + } + ) + + it('keeps stale-pane spinner fallback and permission on an uncovered split sibling', () => { + const stale = entry('split', 'done', NOW - AGENT_STATUS_STALE_AFTER_MS - 1) + const paneSources = sources([stale], { + ptyIdsByTabId: { split: ['pty-1', 'pty-2'] }, + terminalLayoutsByTabId: { + split: { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: '22222222-2222-4222-8222-222222222222' } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null + } + }, + runtimePaneTitlesByTabId: { split: { 1: 'Codex - action required', 2: 'zsh' } } + }) + expect(resolveRecentWorkspaceTabStatus(row('split'), paneSources, NOW)).toBe('active') + paneSources.runtimePaneTitlesByTabId.split = { 1: '⠹ codex working', 2: 'zsh' } + expect(resolveRecentWorkspaceTabStatus(row('split'), paneSources, NOW)).toBe('working') + paneSources.runtimePaneTitlesByTabId.split = { 2: 'Codex - action required' } + expect(resolveRecentWorkspaceTabStatus(row('split'), paneSources, NOW)).toBe('permission') + }) + it('surfaces an interrupted outcome without promoting its sort class', () => { const interrupted = entry('interrupted', 'done', NOW - 1_000, { interrupted: true }) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 4e234436958..cbd6c3c82f3 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -3,6 +3,7 @@ import { classifyTitleActivity } from '@/lib/pane-agent-evidence' 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 { isSyntheticAgentPermissionTitle } from '../../../shared/synthetic-agent-title' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, @@ -23,6 +24,8 @@ export type WorktreeStatus = type WorktreeStatusHeuristicOptions = { liveAgentStatus?: LiveAgentWorktreeStatus agentStatusPaneIdsByTabId?: Record> + /** Stale rows suppress Orca's generated permission labels; native title fallback stays live. */ + stalePaneIdsByTabId?: Record> terminalLayoutsByTabId?: Record terminalLayoutRootsByTabId?: Record } @@ -73,13 +76,18 @@ function tabHasStatus( status: 'permission' | 'working', options: WorktreeStatusHeuristicOptions ): boolean { - const agentStatusPaneIds = options.agentStatusPaneIdsByTabId?.[tab.id] + const freshPaneIds = options.agentStatusPaneIdsByTabId?.[tab.id] + const permissionPaneIds = suppressingPaneIds(tab.id, status, options) const paneTitles = runtimePaneTitlesByTabId[tab.id] if (paneTitles && Object.keys(paneTitles).length > 0) { const tabLayoutRoot = options.terminalLayoutRootsByTabId?.[tab.id] ?? options.terminalLayoutsByTabId?.[tab.id]?.root const paneTitleEntries = Object.entries(paneTitles) for (const [runtimePaneId, title] of paneTitleEntries) { + const agentStatusPaneIds = + status === 'permission' && isSyntheticAgentPermissionTitle(title) + ? permissionPaneIds + : freshPaneIds const leafId = resolveRuntimePaneTitleLeafIdFromRoot(tabLayoutRoot, runtimePaneId) // Why: runtime titles can precede layout hydration (SSH/replay); with one title and one agent row, prefer that row over a stale spinner. const hasSingleUnmappedAgentStatusPane = @@ -101,6 +109,10 @@ function tabHasStatus( return false } // Why: a tab title can't identify its pane; once an agent row owns one, prefer the row over a completed pane's stale "working" title. + const agentStatusPaneIds = + status === 'permission' && isSyntheticAgentPermissionTitle(tab.title) + ? permissionPaneIds + : freshPaneIds if (agentStatusPaneIds && agentStatusPaneIds.size > 0) { return false } @@ -110,6 +122,30 @@ function tabHasStatus( ) } +/** + * Pane ids whose title must not drive `status` for this tab. Fresh rows suppress every heuristic; + * stale rows suppress synthetic permission labels only. Returns the fresh set itself + * when there is nothing to add, so the common path allocates nothing. + */ +function suppressingPaneIds( + tabId: string, + status: 'permission' | 'working', + options: WorktreeStatusHeuristicOptions +): ReadonlySet | undefined { + const fresh = options.agentStatusPaneIdsByTabId?.[tabId] + if (status !== 'permission') { + return fresh + } + const stale = options.stalePaneIdsByTabId?.[tabId] + if (!stale || stale.size === 0) { + return fresh + } + if (!fresh || fresh.size === 0) { + return stale + } + return new Set([...fresh, ...stale]) +} + // 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) { @@ -139,6 +175,7 @@ export function resolveWorktreeStatus(args: { ptyIdsByTabId: Record runtimePaneTitlesByTabId?: Record> agentStatusPaneIdsByTabId?: Record> + stalePaneIdsByTabId?: Record> terminalLayoutsByTabId?: Record terminalLayoutRootsByTabId?: Record hasPermission: boolean @@ -155,6 +192,7 @@ export function resolveWorktreeStatus(args: { args.runtimePaneTitlesByTabId ?? {}, { agentStatusPaneIdsByTabId: args.agentStatusPaneIdsByTabId, + stalePaneIdsByTabId: args.stalePaneIdsByTabId, terminalLayoutsByTabId: args.terminalLayoutsByTabId, terminalLayoutRootsByTabId: args.terminalLayoutRootsByTabId } diff --git a/src/shared/synthetic-agent-title.test.ts b/src/shared/synthetic-agent-title.test.ts index f05b92901e9..3bf30f9e4d9 100644 --- a/src/shared/synthetic-agent-title.test.ts +++ b/src/shared/synthetic-agent-title.test.ts @@ -1,10 +1,28 @@ import { describe, expect, it } from 'vitest' import { getSyntheticAgentTerminalTitle, + isSyntheticAgentPermissionTitle, shouldDriveSyntheticAgentTitleFromHook } from './synthetic-agent-title' describe('synthetic agent titles', () => { + it.each(['Codex - action required', ' Pi - action required ', 'OMP - action required'])( + 'recognizes the generated permission label %s', + (title) => { + expect(isSyntheticAgentPermissionTitle(title)).toBe(true) + } + ) + + it.each([ + '✋ Gemini CLI', + 'π ! approve command', + 'OpenCode - action required', + 'Codex ready', + 'Codex - action required for deployment' + ])('keeps native and contextual titles outside generated permission suppression: %s', (title) => { + expect(isSyntheticAgentPermissionTitle(title)).toBe(false) + }) + it('provides terminal-state titles for Codex hook completion', () => { expect(getSyntheticAgentTerminalTitle('codex', 'done')).toBe('Codex ready') expect(getSyntheticAgentTerminalTitle('codex', 'waiting')).toBe('Codex - action required') diff --git a/src/shared/synthetic-agent-title.ts b/src/shared/synthetic-agent-title.ts index 6f88f7f03e1..1e862718215 100644 --- a/src/shared/synthetic-agent-title.ts +++ b/src/shared/synthetic-agent-title.ts @@ -78,6 +78,16 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record = new Set( + Object.values(SYNTHETIC_AGENT_TITLE_PROFILES) + .filter((profile) => profile.synthesizeTerminalTitle !== false) + .map((profile) => profile.permissionLabel.toLowerCase()) +) + +export function isSyntheticAgentPermissionTitle(title: string): boolean { + return SYNTHETIC_PERMISSION_TITLES.has(title.trim().toLowerCase()) +} + export function getSyntheticAgentTitleProfile( agentType: AgentType | null | undefined ): SyntheticAgentTitleProfile | null {