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 {