From 2f90e4cd758cb1ea2dd5dba7edc13e311936b072 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 7 Sep 2026 00:39:54 -0700 Subject: [PATCH] fix(agent-status): preserve native permissions and cover palette fallbacks --- .../cmd-j/palette-live-status.test.tsx | 104 ++++++++++++++--- .../components/cmd-j/palette-live-status.tsx | 10 +- .../src/components/sidebar/smart-attention.ts | 20 +++- .../worktree-agent-activity-summary.ts | 7 +- .../terminal-tab-activity-status.test.ts | 18 +++ .../tab-bar/terminal-tab-activity-status.ts | 7 +- ...nt-status-terminal-title-tab-write.test.ts | 110 +++++++++++++++++- .../src/lib/recent-workspace-tab-rows.test.ts | 54 ++++++++- src/renderer/src/lib/worktree-status.ts | 21 ++-- src/shared/synthetic-agent-title.test.ts | 18 +++ src/shared/synthetic-agent-title.ts | 10 ++ 11 files changed, 330 insertions(+), 49 deletions(-) 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 59da13ec35b..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 @@ -140,25 +140,48 @@ describe('palette live status', () => { // 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('does not paint a stale self-authored action-required title as a live question', async () => { - 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 + 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( + + } + /> + + ) }) - }, - agentStatusEpoch: s.agentStatusEpoch + 1 - })) + expect(testContainer.querySelector('[data-fallback]')).not.toBeNull() + } - await render() - - expect(dotLabels()).not.toContain('Needs permission') - }) + expect(dotLabels()).not.toContain('Needs permission') + } + ) it('updates a worktree dot when the agent transitions', async () => { setAgentState('working') @@ -172,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 dc3821d19f6..34840d50cc5 100644 --- a/src/renderer/src/components/cmd-j/palette-live-status.tsx +++ b/src/renderer/src/components/cmd-j/palette-live-status.tsx @@ -140,12 +140,7 @@ export function PaletteLiveStatusProvider({ ) } -/** - * Split each tab's explicit rows into fresh and stale pane ids. Fresh rows suppress every title - * heuristic; stale rows suppress `permission` only, because Orca writes its own one-shot - * " - action required" title and never refreshes it — a stale row that registered nothing - * let that string outlive the row and assert a question the agent had stopped asking. - */ +/** Fresh rows suppress all title heuristics; stale rows suppress generated permission labels. */ function buildLiveAgentStatusPaneIdsByTabId( entriesByTabId: ReadonlyMap, now: number @@ -219,7 +214,8 @@ export function PaletteWorktreeStatusDot({ { liveAgentStatus: live.liveAgentStatusByWorktreeId.get(worktree.id), agentStatusPaneIdsByTabId: live.agentStatusPaneIdsByTabId, - stalePaneIdsByTabId: live.stalePaneIdsByTabId + 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/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index cea15bef444..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,12 +21,7 @@ export type WorktreeAgentActivitySummary = { hasLiveDone: boolean hasRetainedDone: boolean agentStatusPaneIdsByTabId: Record> - /** - * Panes whose explicit row exists but has gone stale. A stale row is not authority for a status, - * but it still proves the pane HAS an agent — which must keep Orca's own one-shot - * " - action required" title from re-asserting a question the agent stopped asking. - * Suppresses `permission` titles only; a working spinner re-renders, so it stays live evidence. - */ + /** Stale rows suppress generated permission labels while preserving native title fallback. */ stalePaneIdsByTabId: Record> } 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 dc9f06bb157..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 @@ -86,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 dad1400ed3d..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,7 +23,7 @@ type TerminalTabActivityFlags = { hasInterrupted: boolean hasLiveDone: boolean paneIds: Set - /** Panes whose row went stale; suppress `permission` titles only. */ + /** Panes whose row went stale; suppress generated permission labels only. */ stalePaneIds: Set } @@ -71,10 +71,7 @@ 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)) { - // Why: the row loses authority but the pane keeps its identity, which suppresses - // `permission` titles — Orca writes its own one-shot " - action required" title and - // never refreshes it, so a stale row let that string assert a question nobody was asking. - // Working titles still fall back here: a spinner re-renders, so it stays live evidence. + // Stale identity suppresses Orca's one-shot permission label without suppressing native titles. getOrCreateTerminalTabActivityFlags(flagsByTabId, identity.tabId).stalePaneIds.add( identity.paneId ) 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 index 79340595c88..0be0d6465ac 100644 --- 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 @@ -1,10 +1,19 @@ -import { describe, expect, it } from 'vitest' +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' @@ -96,3 +105,102 @@ describe('hook-driven tab title writes', () => { ).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 cd456673965..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,12 +24,7 @@ export type WorktreeStatus = type WorktreeStatusHeuristicOptions = { liveAgentStatus?: LiveAgentWorktreeStatus agentStatusPaneIdsByTabId?: Record> - /** - * Panes whose explicit row has gone stale. They suppress `permission` titles only: Orca writes - * its own " - action required" title once per blocked/waiting hook and never refreshes it, - * so a stale row leaves that one-shot string asserting a question the agent stopped asking. A - * working title keeps its stale-row fallback — a spinner re-renders, so it is live evidence. - */ + /** Stale rows suppress Orca's generated permission labels; native title fallback stays live. */ stalePaneIdsByTabId?: Record> terminalLayoutsByTabId?: Record terminalLayoutRootsByTabId?: Record @@ -80,13 +76,18 @@ function tabHasStatus( status: 'permission' | 'working', options: WorktreeStatusHeuristicOptions ): boolean { - const agentStatusPaneIds = suppressingPaneIds(tab.id, status, options) + 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 = @@ -108,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 } @@ -119,7 +124,7 @@ function tabHasStatus( /** * Pane ids whose title must not drive `status` for this tab. Fresh rows suppress every heuristic; - * stale rows suppress `permission` only (see `stalePaneIdsByTabId`). Returns the fresh set itself + * 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( 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 {