mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(agent-status): preserve native permissions and cover palette fallbacks
This commit is contained in:
@@ -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(
|
||||
<PaletteLiveStatusProvider active>
|
||||
<PaletteRecentTabStatusDot
|
||||
row={{
|
||||
id: 'recent',
|
||||
worktreeId: 'wt-a',
|
||||
unifiedTabId: null,
|
||||
terminalTab: { id: 'term-a', title: 'Codex - action required' },
|
||||
worktreeLastActivityAt: 0
|
||||
}}
|
||||
fallback={<span data-fallback="true" />}
|
||||
/>
|
||||
</PaletteLiveStatusProvider>
|
||||
)
|
||||
})
|
||||
},
|
||||
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({
|
||||
|
||||
@@ -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
|
||||
* "<Agent> - 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<string, readonly AgentStatusEntry[]>,
|
||||
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 (
|
||||
|
||||
@@ -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<string>()
|
||||
// Stale hooks still suppress one-shot permission titles, matching worktree and tab status dots.
|
||||
const permissionHookLeafIds = new Set<string>()
|
||||
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 })
|
||||
|
||||
@@ -21,12 +21,7 @@ export type WorktreeAgentActivitySummary = {
|
||||
hasLiveDone: boolean
|
||||
hasRetainedDone: boolean
|
||||
agentStatusPaneIdsByTabId: Record<string, ReadonlySet<string>>
|
||||
/**
|
||||
* 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
|
||||
* "<Agent> - 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<string, ReadonlySet<string>>
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -23,7 +23,7 @@ type TerminalTabActivityFlags = {
|
||||
hasInterrupted: boolean
|
||||
hasLiveDone: boolean
|
||||
paneIds: Set<string>
|
||||
/** Panes whose row went stale; suppress `permission` titles only. */
|
||||
/** Panes whose row went stale; suppress generated permission labels only. */
|
||||
stalePaneIds: Set<string>
|
||||
}
|
||||
|
||||
@@ -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 "<Agent> - 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
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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<string, ReadonlySet<string>>
|
||||
/**
|
||||
* Panes whose explicit row has gone stale. They suppress `permission` titles only: Orca writes
|
||||
* its own "<Agent> - 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<string, ReadonlySet<string>>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -78,6 +78,16 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record<string, SyntheticAgentTitleP
|
||||
}
|
||||
}
|
||||
|
||||
const SYNTHETIC_PERMISSION_TITLES: ReadonlySet<string> = 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 {
|
||||
|
||||
Reference in New Issue
Block a user