diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index cb5a1f7a3..5ae84dde5 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -2,7 +2,7 @@ import { useEffect } from 'react' import { useAppStore } from '../../store' import type { AgentType } from '../../../../shared/agent-status-types' import type { TerminalPaneLayoutNode } from '../../../../shared/types' -import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { canToggleNativeChat } from './native-chat-availability' import { isMacPlatform, matchesNativeChatToggleShortcut } from './native-chat-shortcut' @@ -72,8 +72,8 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive agentStatusByPaneKey: state.agentStatusByPaneKey }) const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root) - ? (resolveTabAgentFromTitle(tab.label ?? '') ?? - (terminalTab ? resolveTabAgentFromTitle(terminalTab.title) : null)) + ? (resolveCommittedTitleAgentType(tab.label ?? '') ?? + (terminalTab ? resolveCommittedTitleAgentType(terminalTab.title) : null)) : null if ( !canToggleNativeChat({ diff --git a/src/renderer/src/components/sidebar/WorktreeCard.test.ts b/src/renderer/src/components/sidebar/WorktreeCard.test.ts index 36ac370db..abc03a3c4 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeCard.test.ts @@ -20,7 +20,6 @@ vi.mock('@/lib/agent-status', () => ({ import { getWorktreeStatus } from '@/lib/worktree-status' import { shouldBeginWorktreeRename } from './WorktreeCard' -import { deriveWorktreeCardStatus } from './worktree-card-status' function makeTerminalTab(title: string): TerminalTab { return { @@ -35,22 +34,6 @@ function makeTerminalTab(title: string): TerminalTab { } } -function makeAgentStatusEntry(args: { - paneKey: string - state: AgentStatusEntry['state'] - updatedAt?: number -}): AgentStatusEntry { - const updatedAt = args.updatedAt ?? 1_000 - return { - paneKey: args.paneKey, - state: args.state, - prompt: '', - updatedAt, - stateStartedAt: updatedAt, - stateHistory: [] - } -} - describe('getWorktreeStatus', () => { it('treats browser-only worktrees as active', () => { expect(getWorktreeStatus([], [{ id: 'browser-1' }], {})).toBe('active') @@ -59,67 +42,22 @@ describe('getWorktreeStatus', () => { it('keeps terminal agent states higher priority than browser presence', () => { // Why: liveness gate now requires ptyIdsByTabId, not tab.ptyId. Pass a // populated live-pty map so this assertion exercises the live-tab branch. + // Titles are real classifiable shapes: getWorktreeStatus reads the shared + // classifier through pane-agent-evidence, which this file does not mock. const livePtyIds = { 'tab-1': ['pty-1'] } expect( - getWorktreeStatus([makeTerminalTab('permission needed')], [{ id: 'browser-1' }], livePtyIds) + getWorktreeStatus( + [makeTerminalTab('Claude - action required')], + [{ id: 'browser-1' }], + livePtyIds + ) ).toBe('permission') expect( - getWorktreeStatus([makeTerminalTab('working hard')], [{ id: 'browser-1' }], livePtyIds) + getWorktreeStatus([makeTerminalTab('mimo working')], [{ id: 'browser-1' }], livePtyIds) ).toBe('working') }) }) -describe('deriveWorktreeCardStatus', () => { - it('keeps split-pane heuristics for panes without fresh explicit status', () => { - const status = deriveWorktreeCardStatus({ - tabs: [makeTerminalTab('claude [done]')], - browserTabs: [], - worktreeAgentEntries: [makeAgentStatusEntry({ paneKey: 'tab-1:1', state: 'done' })], - runtimePaneTitlesByTabId: { - 'tab-1': { - 1: 'claude [done]', - 2: 'codex [working]' - } - }, - now: 1_000 - }) - - expect(status).toBe('working') - }) - - it('lets fresh explicit status win over the matching pane title heuristic', () => { - const status = deriveWorktreeCardStatus({ - tabs: [makeTerminalTab('codex [working]')], - browserTabs: [], - worktreeAgentEntries: [makeAgentStatusEntry({ paneKey: 'tab-1:2', state: 'done' })], - runtimePaneTitlesByTabId: { - 'tab-1': { - 2: 'codex [working]' - } - }, - now: 1_000 - }) - - expect(status).toBe('done') - }) - - it('stays active when the only live terminal signal is the Claude agents screen', () => { - const status = deriveWorktreeCardStatus({ - tabs: [makeTerminalTab('claude agents')], - browserTabs: [], - worktreeAgentEntries: [], - runtimePaneTitlesByTabId: { - 'tab-1': { - 1: 'claude agents' - } - }, - now: 1_000 - }) - - expect(status).toBe('active') - }) -}) - describe('shouldBeginWorktreeRename', () => { it('matches unscoped legacy rename requests by worktree id', () => { expect(shouldBeginWorktreeRename({ worktreeId: 'wt-1' }, 'wt-1', 'all:wt-1')).toBe(true) diff --git a/src/renderer/src/components/sidebar/smart-attention.ts b/src/renderer/src/components/sidebar/smart-attention.ts index 4ca27a7c4..ae0d7542d 100644 --- a/src/renderer/src/components/sidebar/smart-attention.ts +++ b/src/renderer/src/components/sidebar/smart-attention.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' @@ -308,7 +308,7 @@ export function buildAttentionByWorktree( } panes.push({ kind: 'title', - status: detectAgentStatusFromTitle(title), + status: classifyTitleActivity(title), worktreeLastActivityAt: worktree.lastActivityAt }) } @@ -318,7 +318,7 @@ export function buildAttentionByWorktree( // titles or hook entries exist for this tab. panes.push({ kind: 'title', - status: detectAgentStatusFromTitle(tab.title), + status: classifyTitleActivity(tab.title), worktreeLastActivityAt: worktree.lastActivityAt }) } diff --git a/src/renderer/src/components/sidebar/worktree-card-status.ts b/src/renderer/src/components/sidebar/worktree-card-status.ts deleted file mode 100644 index 61daeb74c..000000000 --- a/src/renderer/src/components/sidebar/worktree-card-status.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' -import type { WorktreeStatus } from '@/lib/worktree-status' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry -} from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' - -type WorktreeCardStatusInput = { - tabs: Pick[] - browserTabs: { id: string }[] - worktreeAgentEntries: AgentStatusEntry[] - runtimePaneTitlesByTabId: Record> - now?: number -} - -export function deriveWorktreeCardStatus({ - tabs, - browserTabs, - worktreeAgentEntries, - runtimePaneTitlesByTabId, - now = Date.now() -}: WorktreeCardStatusInput): WorktreeStatus { - const liveTabs = tabs.filter((tab) => tab.ptyId) - // Why: browser-only worktrees are still active from the user's point of - // view even when they have no PTY-backed terminal. The sidebar filter - // already treats them as active, so every navigation surface must reuse - // that rule instead of showing a misleading inactive dot. - const hasTerminals = liveTabs.length > 0 || browserTabs.length > 0 - if (!hasTerminals) { - return 'inactive' - } - - const freshByTabId = new Map() - const explicitPaneIdsByTabId = new Map>() - for (const entry of worktreeAgentEntries) { - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { - continue - } - const colonIdx = entry.paneKey.indexOf(':') - // Why: paneKey must be `${tabId}:${paneId}`. Skip malformed entries (no - // colon or leading colon) rather than bucketing under "". - if (colonIdx <= 0) { - continue - } - const tabId = entry.paneKey.slice(0, colonIdx) - const paneId = entry.paneKey.slice(colonIdx + 1) - const bucket = freshByTabId.get(tabId) - if (bucket) { - bucket.push(entry) - } else { - freshByTabId.set(tabId, [entry]) - } - const explicitPaneIds = explicitPaneIdsByTabId.get(tabId) ?? new Set() - explicitPaneIds.add(paneId) - explicitPaneIdsByTabId.set(tabId, explicitPaneIds) - } - - let hasPermission = false - let hasWorking = false - let hasDone = false - for (const tab of liveTabs) { - const fresh = freshByTabId.get(tab.id) - if (fresh && fresh.length > 0) { - if (fresh.some((e) => e.state === 'blocked' || e.state === 'waiting')) { - hasPermission = true - } else if (fresh.some((e) => e.state === 'working')) { - hasWorking = true - } else if (fresh.some((e) => e.state === 'done')) { - hasDone = true - } - } - - const explicitPaneIds = explicitPaneIdsByTabId.get(tab.id) - const paneTitles = runtimePaneTitlesByTabId[tab.id] - if (paneTitles && Object.keys(paneTitles).length > 0) { - for (const [paneId, title] of Object.entries(paneTitles)) { - // Why: explicit hook status only supersedes the matching pane. Other - // split panes still need the title heuristic when hooks are absent. - if (explicitPaneIds?.has(paneId)) { - continue - } - const heuristic = detectAgentStatusFromTitle(title) - if (heuristic === 'permission') { - hasPermission = true - break - } - if (heuristic === 'working') { - hasWorking = true - } - } - continue - } - - if (fresh && fresh.length > 0) { - continue - } - - const heuristic = detectAgentStatusFromTitle(tab.title) - if (heuristic === 'permission') { - hasPermission = true - } else if (heuristic === 'working') { - hasWorking = true - } - } - - if (hasPermission) { - return 'permission' - } - if (hasWorking) { - return 'working' - } - // Why: surface 'done' as its own status so the sidebar dot turns blue - // (sky-500/80) — matching the dashboard's done color. A completed agent - // still has a live terminal, so 'inactive' would be misleading; calling - // it 'done' keeps the two surfaces in agreement on what the agent is. - if (hasDone) { - return 'done' - } - return 'active' -} diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 2b5efddeb..1c862b705 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -1,9 +1,6 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' -import { - detectAgentStatusFromTitle, - getAgentLabel, - isClaudeManagementTitle -} from '@/lib/agent-status' +import { isClaudeManagementTitle } from '@/lib/agent-status' +import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import type { AgentStatusEntry, @@ -135,8 +132,8 @@ function buildTitleDerivedAgentRow(args: { // Why: `claude agents` is a live Claude Code Agent Teams surface, but the // shared detector keeps it neutral so runtime liveness probes do not treat // the management/list screen as active work. - const status = isClaudeAgentsTitle ? 'idle' : detectAgentStatusFromTitle(title) - const label = isClaudeAgentsTitle ? 'Claude Code' : getAgentLabel(title) + const status = isClaudeAgentsTitle ? 'idle' : classifyTitleActivity(title) + const label = isClaudeAgentsTitle ? 'Claude Code' : resolveTitleActivityLabel(title) if (!status || !label) { return null } @@ -199,7 +196,7 @@ export function resolveAgentTypeFromTerminalTitle( return null } const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType) - const label = getAgentLabel(normalizedTitle) + const label = resolveTitleActivityLabel(normalizedTitle) return label ? (resolveCompatibleAgentTypeForOwner( resolveTitleDerivedAgentType(normalizedTitle, label), diff --git a/src/renderer/src/components/status-bar/workspace-space-presentation.ts b/src/renderer/src/components/status-bar/workspace-space-presentation.ts index 85274483d..ad09532a6 100644 --- a/src/renderer/src/components/status-bar/workspace-space-presentation.ts +++ b/src/renderer/src/components/status-bar/workspace-space-presentation.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { AGENT_STATUS_STALE_AFTER_MS, @@ -82,12 +82,12 @@ function countTitleActiveAgentsForTab( const paneTitles = runtimePaneTitlesByTabId[tab.id] if (paneTitles && Object.keys(paneTitles).length > 0) { return Object.values(paneTitles).filter((title) => { - const status = detectAgentStatusFromTitle(title) + const status = classifyTitleActivity(title) return status === 'working' || status === 'permission' }).length } - const status = detectAgentStatusFromTitle(tab.title) + const status = classifyTitleActivity(tab.title) return status === 'working' || status === 'permission' ? 1 : 0 } diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 194174a59..0e3f79e60 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -79,7 +79,7 @@ import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll' import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility' import { canToggleNativeChat } from '../native-chat/native-chat-availability' import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry' -import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' const isWindows = navigator.userAgent.includes('Windows') const isMacOs = navigator.userAgent.includes('Mac') @@ -1103,8 +1103,8 @@ function TabBarInner({ // Carry the agent *identity* (not just "an agent exists") so the // native-chat gate can reject unsupported agents like Grok. const resolvedAgent = - resolveTabAgentFromTitle(unifiedTabForItem?.label ?? '') ?? - resolveTabAgentFromTitle(terminalTab.title) + resolveCommittedTitleAgentType(unifiedTabForItem?.label ?? '') ?? + resolveCommittedTitleAgentType(terminalTab.title) // Key the live-agent lookup by the backing terminal tab id — // agent-status pane keys are `${terminalTab.id}:${leafId}`, and // the unified tab id can differ from it. diff --git a/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts b/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts index 6f788af3e..7b1d16522 100644 --- a/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts +++ b/src/renderer/src/components/terminal-pane/cache-timer-seeding.ts @@ -1,4 +1,5 @@ -import { detectAgentStatusFromTitle, isClaudeAgent } from '@/lib/agent-status' +import { isClaudeAgent } from '@/lib/agent-status' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' export function shouldSeedCacheTimerOnInitialTitle(args: { rawTitle: string @@ -12,7 +13,7 @@ export function shouldSeedCacheTimerOnInitialTitle(args: { return false } - const status = detectAgentStatusFromTitle(rawTitle) + const status = classifyTitleActivity(rawTitle) if (status === null || status === 'working') { return false } diff --git a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts index e3527bf11..20f24fb69 100644 --- a/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts +++ b/src/renderer/src/components/terminal-pane/native-chat-leaf-title-agent.ts @@ -1,5 +1,5 @@ import type { TuiAgent } from '../../../../shared/types' -import { resolveTabAgentFromTitle } from '@/lib/use-tab-agent' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' export type NativeChatLeafTitlePane = { id: number @@ -26,7 +26,7 @@ export function resolveNativeChatLeafTitleAgent({ } const targetPane = panes.find((pane) => pane.leafId === leafId) const paneAgent = targetPane - ? resolveTabAgentFromTitle(runtimePaneTitlesByPaneId[targetPane.id] ?? '') + ? resolveCommittedTitleAgentType(runtimePaneTitlesByPaneId[targetPane.id] ?? '') : null if (paneAgent) { return paneAgent @@ -36,5 +36,8 @@ export function resolveNativeChatLeafTitleAgent({ if (panes.length > 1) { return null } - return resolveTabAgentFromTitle(tabLabel ?? '') ?? resolveTabAgentFromTitle(terminalTitle ?? '') + return ( + resolveCommittedTitleAgentType(tabLabel ?? '') ?? + resolveCommittedTitleAgentType(terminalTitle ?? '') + ) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index ffe958ddb..8cefac661 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -161,7 +161,7 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' -import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { isExpectedAgentProcess, recognizeAgentProcessFromCommandLine @@ -1810,7 +1810,7 @@ export function connectPanePty( (entry) => entry.id === deps.tabId ) const title = currentTitle ?? tab?.title - if (!title || resolveExplicitTerminalTitleAgentType(title) === null) { + if (!title || resolveCommittedTitleAgentType(title) === null) { return } const neutralTitle = neutralTerminalTitle() diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index d1a97b33a..2a0c2f4f3 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { useAppStore } from '@/store' -import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' import { playDesktopNotificationSound } from '@/lib/desktop-notification-sound' import { buildAgentNotificationId } from '../../../../shared/agent-notification-id' @@ -52,7 +52,7 @@ export function dispatchTerminalNotification( // not lend its prompt/agentType or timing id to this notification. const explicitTitleAgentType = event.source === 'agent-task-complete' && event.terminalTitle - ? resolveExplicitTerminalTitleAgentType(event.terminalTitle) + ? resolveCommittedTitleAgentType(event.terminalTitle) : null const storedAgentStatus = event.source === 'agent-task-complete' && event.paneKey diff --git a/src/renderer/src/lib/active-agent-note-target.ts b/src/renderer/src/lib/active-agent-note-target.ts index 5bbc1253a..9ee0dfc51 100644 --- a/src/renderer/src/lib/active-agent-note-target.ts +++ b/src/renderer/src/lib/active-agent-note-target.ts @@ -15,10 +15,10 @@ import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector' import { isTerminalLeafId, makePaneKey } from '../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot } from '../../../shared/types' import { - detectAgentStatusFromTitle, - getAgentLabel, - isExplicitAgentStatusFresh -} from './agent-status' + classifyTitleActivity, + isExplicitAgentStatusFresh, + resolveTitleActivityLabel +} from '@/lib/pane-agent-evidence' import { resolveRuntimePaneTitleForLeaf } from './runtime-pane-title-leaf-id' const ACTIVE_AGENT_PROBE_RPC_TIMEOUT_MS = 3000 @@ -271,5 +271,5 @@ function getFocusedRuntimePaneTitle( } function isRecognizedAgentTitle(title: string): boolean { - return detectAgentStatusFromTitle(title) !== null && getAgentLabel(title) !== null + return classifyTitleActivity(title) !== null && resolveTitleActivityLabel(title) !== null } diff --git a/src/renderer/src/lib/agent-ready-wait.ts b/src/renderer/src/lib/agent-ready-wait.ts index 70cbb4ade..8bd7cb867 100644 --- a/src/renderer/src/lib/agent-ready-wait.ts +++ b/src/renderer/src/lib/agent-ready-wait.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle } from '../../../shared/agent-detection' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' import { isShellProcess } from './tui-agent-startup' import { useAppStore } from '@/store' @@ -51,7 +51,7 @@ function titleSuggestsReady(tabId: string): boolean { } } } - return titles.some((title) => detectAgentStatusFromTitle(title) === 'idle') + return titles.some((title) => classifyTitleActivity(title) === 'idle') } /** diff --git a/src/renderer/src/lib/agent-send-title-status.ts b/src/renderer/src/lib/agent-send-title-status.ts index 11f949fde..9ae502936 100644 --- a/src/renderer/src/lib/agent-send-title-status.ts +++ b/src/renderer/src/lib/agent-send-title-status.ts @@ -1,4 +1,5 @@ -import { type AgentStatus, detectAgentStatusFromTitle, getAgentLabel } from './agent-status' +import type { AgentStatus } from './agent-status' +import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence' const EXPLICIT_IDLE_SEND_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i const CLAUDE_IDLE_PREFIX = '\u2733' @@ -6,11 +7,11 @@ const GEMINI_IDLE_PREFIX = '\u25c7' const PI_IDLE_PREFIX = '\u03c0 - ' export function detectAgentSendTitleStatus(title: string | null | undefined): AgentStatus | null { - if (!title || getAgentLabel(title) === null) { + if (!title || resolveTitleActivityLabel(title) === null) { return null } - const status = detectAgentStatusFromTitle(title) + const status = classifyTitleActivity(title) if (status !== 'idle') { return status } diff --git a/src/renderer/src/lib/agent-status-terminal-title.ts b/src/renderer/src/lib/agent-status-terminal-title.ts index d3162d526..c5c7bca56 100644 --- a/src/renderer/src/lib/agent-status-terminal-title.ts +++ b/src/renderer/src/lib/agent-status-terminal-title.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle } from '../../../shared/agent-detection' +import { classifyTitleActivity } from './pane-agent-evidence' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { getSyntheticAgentTerminalTitle, @@ -26,7 +26,7 @@ function shouldReplaceCurrentTitle( if (!currentTitle?.trim()) { return true } - const currentStatus = detectAgentStatusFromTitle(currentTitle) + const currentStatus = classifyTitleActivity(currentTitle) if (currentStatus === 'working') { return true } diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 396181851..141da5c2a 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -1,9 +1,5 @@ import type { TerminalTab, TuiAgent, Worktree } from '../../../shared/types' -import type { - AgentStatusEntry, - AgentStatusState, - AgentType -} from '../../../shared/agent-status-types' +import type { AgentStatusState, AgentType } from '../../../shared/agent-status-types' import { tabHasLivePty } from './tab-has-live-pty' import type { WorktreeStatus } from './worktree-status' import { tuiAgentToAgentKind } from '../../../shared/agent-kind' @@ -23,11 +19,8 @@ export { isClaudeManagementTitle, getAgentLabel } from '../../../shared/agent-detection' -import { - type AgentStatus, - detectAgentStatusFromTitle, - getAgentLabel -} from '../../../shared/agent-detection' +import type { AgentStatus } from '../../../shared/agent-detection' +import { classifyTitleActivity, resolveTitleActivityLabel } from './pane-agent-evidence' type AgentQueryArgs = { tabsByWorktree: Record @@ -84,8 +77,8 @@ export function getWorkingAgentsPerWorktree({ const paneTitles = runtimePaneTitlesByTabId[tab.id] if (paneTitles && Object.keys(paneTitles).length > 0) { for (const [paneIdStr, title] of Object.entries(paneTitles)) { - if (detectAgentStatusFromTitle(title) === 'working') { - const label = getAgentLabel(title) + if (classifyTitleActivity(title) === 'working') { + const label = resolveTitleActivityLabel(title) if (label) { agents.push({ label, @@ -96,8 +89,8 @@ export function getWorkingAgentsPerWorktree({ } } } - } else if (detectAgentStatusFromTitle(tab.title) === 'working') { - const label = getAgentLabel(tab.title) + } else if (classifyTitleActivity(tab.title) === 'working') { + const label = resolveTitleActivityLabel(tab.title) if (label) { agents.push({ label, status: 'working', tabId: tab.id, paneId: null }) } @@ -210,16 +203,9 @@ export function agentKindForAgentType(agentType: AgentType | null | undefined): return tuiAgent ? tuiAgentToAgentKind(tuiAgent) : 'other' } -// Why: explicit agent status entries (from hook-based reports) can go stale if -// the agent process exits without sending a final update. This helper lets -// callers decide whether to trust the entry based on a configurable TTL. -export function isExplicitAgentStatusFresh( - entry: Pick, - now: number, - staleAfterMs: number -): boolean { - return now - entry.updatedAt <= staleAfterMs -} +// Why: the freshness gate moved into the pane-agent-evidence resolvers; the +// re-export keeps this module's many existing importers unchanged. +export { isExplicitAgentStatusFresh } from './pane-agent-evidence' /** * Map an explicit AgentStatusState to the visual Status used by @@ -297,13 +283,13 @@ function countWorkingAgentsForTab( // (for example restored-but-unvisited worktrees). if (paneTitles && Object.keys(paneTitles).length > 0) { for (const title of Object.values(paneTitles)) { - if (detectAgentStatusFromTitle(title) === 'working') { + if (classifyTitleActivity(title) === 'working') { count += 1 } } return count } - if (detectAgentStatusFromTitle(tab.title) === 'working') { + if (classifyTitleActivity(tab.title) === 'working') { count += 1 } return count diff --git a/src/renderer/src/lib/pane-agent-evidence.test.ts b/src/renderer/src/lib/pane-agent-evidence.test.ts new file mode 100644 index 000000000..0bd618e29 --- /dev/null +++ b/src/renderer/src/lib/pane-agent-evidence.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry +} from '../../../shared/agent-status-types' +import { + classifyTitleActivity, + isExplicitAgentStatusFresh, + resolveCommittedTitleAgentType, + resolvePaneAgentActivity, + resolveTitleActivityLabel +} from './pane-agent-evidence' + +const NOW = 1_700_000_000_000 + +function entry(overrides: Partial = {}): AgentStatusEntry { + return { + paneKey: 'tab-1:leaf-1', + agentType: 'claude', + state: 'working', + prompt: '', + updatedAt: NOW, + stateStartedAt: NOW, + stateHistory: [], + ...overrides + } as AgentStatusEntry +} + +describe('isExplicitAgentStatusFresh', () => { + it('accepts an entry exactly at the staleness boundary', () => { + expect( + isExplicitAgentStatusFresh( + { updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS }, + NOW, + AGENT_STATUS_STALE_AFTER_MS + ) + ).toBe(true) + }) + + it('rejects an entry past the staleness boundary', () => { + expect( + isExplicitAgentStatusFresh( + { updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 }, + NOW, + AGENT_STATUS_STALE_AFTER_MS + ) + ).toBe(false) + }) +}) + +describe('classifyTitleActivity', () => { + it('classifies working, permission, idle, and unclassifiable titles', () => { + expect(classifyTitleActivity('mimo working')).toBe('working') + expect(classifyTitleActivity('Claude - action required')).toBe('permission') + expect(classifyTitleActivity('✳ Claude Code ready')).toBe('idle') + expect(classifyTitleActivity('vim')).toBe(null) + }) +}) + +describe('title agent identity facets', () => { + it('splits the bare Claude spinner into activity label without committed identity', () => { + expect(resolveTitleActivityLabel('⠋ compiling everything')).toBe('Claude Code') + expect(resolveCommittedTitleAgentType('⠋ compiling everything')).toBe(null) + }) + + it('commits identity when the title names the agent explicitly', () => { + expect(resolveTitleActivityLabel('✳ Claude Code working')).toBe('Claude Code') + expect(resolveCommittedTitleAgentType('✳ Claude Code working')).toBe('claude') + }) + + it('returns neither facet for a plain shell title', () => { + expect(resolveTitleActivityLabel('zsh')).toBe(null) + expect(resolveCommittedTitleAgentType('zsh')).toBe(null) + }) +}) + +describe('resolvePaneAgentActivity', () => { + it('reports a fresh hook row as the authoritative source and keeps the title layer visible', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: entry({ state: 'waiting' }), + liveTitle: '⠋ running the tests', + hasLivePty: true, + now: NOW + }) + expect(decision).toEqual({ + hookState: 'waiting', + hookAgentType: 'claude', + titleStatus: 'working', + source: 'hook', + confidence: 'authoritative', + livePtyRequired: false + }) + }) + + it('treats a stale hook row as absent and falls back to the title', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: entry({ updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1 }), + liveTitle: '⠋ running the tests', + hasLivePty: true, + now: NOW + }) + expect(decision.hookState).toBe(null) + expect(decision.source).toBe('title') + expect(decision.confidence).toBe('fallback') + expect(decision.titleStatus).toBe('working') + expect(decision.livePtyRequired).toBe(false) + }) + + it('flags title-only evidence without a live PTY so liveness-gated consumers drop it', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: undefined, + liveTitle: '⠋ running the tests', + hasLivePty: false, + now: NOW + }) + expect(decision.source).toBe('title') + expect(decision.livePtyRequired).toBe(true) + }) + + it('reports none when there is no fresh hook and the title carries no status', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: undefined, + liveTitle: 'bash', + hasLivePty: true, + now: NOW + }) + expect(decision).toEqual({ + hookState: null, + hookAgentType: undefined, + titleStatus: null, + source: 'none', + confidence: 'authoritative', + livePtyRequired: false + }) + }) + + it('passes hook state through raw, including done', () => { + const decision = resolvePaneAgentActivity({ + explicitEntry: entry({ state: 'done' }), + liveTitle: null, + hasLivePty: true, + now: NOW + }) + expect(decision.hookState).toBe('done') + expect(decision.source).toBe('hook') + }) +}) diff --git a/src/renderer/src/lib/pane-agent-evidence.ts b/src/renderer/src/lib/pane-agent-evidence.ts new file mode 100644 index 000000000..4d6775895 --- /dev/null +++ b/src/renderer/src/lib/pane-agent-evidence.ts @@ -0,0 +1,121 @@ +import type { AgentStatus } from '../../../shared/agent-detection' +import { detectAgentStatusFromTitle, getAgentLabel } from '../../../shared/agent-detection' +import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' +import type { TuiAgent } from '../../../shared/types' +import { + AGENT_STATUS_STALE_AFTER_MS, + type AgentStatusEntry, + type AgentStatusState, + type AgentType +} from '../../../shared/agent-status-types' + +// Why: explicit agent status entries (from hook-based reports) can go stale if +// the agent process exits without sending a final update. This helper lets +// callers decide whether to trust the entry based on a configurable TTL. +// (Moved here from agent-status.ts so the evidence resolvers below and the +// aggregate consumers share one gate without an import cycle.) +export function isExplicitAgentStatusFresh( + entry: Pick, + now: number, + staleAfterMs: number +): boolean { + return now - entry.updatedAt <= staleAfterMs +} + +/** + * Title-only activity classification for consumers whose product rule really + * is "what does this title say" (timer seeding, ready-wait polling, sort-epoch + * comparison, synthetic-title writing) — they combine no hook/liveness + * evidence, so routing them through the pane resolver would misstate intent. + */ +export function classifyTitleActivity(title: string): AgentStatus | null { + return detectAgentStatusFromTitle(title) +} + +/** + * The two facets of title-derived agent identity. The ACTIVITY LABEL treats + * Claude's bare status prefixes (spinner/`✳`/`. `/`* `) as Claude activity; + * COMMITTED identity rejects them — evidence that something runs is not proof + * of who. Consumers historically split on this by accident; pick the facet + * that matches the product rule, never both out of habit. + */ +export function resolveTitleActivityLabel(title: string): string | null { + return getAgentLabel(title) +} + +/** See resolveTitleActivityLabel — the strict facet for identity decisions. */ +export function resolveCommittedTitleAgentType(title: string): TuiAgent | null { + return resolveExplicitTerminalTitleAgentType(title) +} + +/** + * Combined pane activity evidence for aggregate consumers. The hook and title + * layers are exposed separately on purpose: consumers legitimately combine + * them differently (send targets let a live permission title override a fresh + * working hook; the worktree dot suppresses titles on hook-covered panes), so + * a single merged status would silently change behavior. + */ +export type AgentActivityDecision = { + /** Fresh (within AGENT_STATUS_STALE_AFTER_MS), pane-scoped hook state; null when absent or stale. */ + hookState: AgentStatusState | null + /** Agent identity from the fresh hook row, when one exists. */ + hookAgentType: AgentType | undefined + /** Title-derived status of the pane's live title, independent of hook state. */ + titleStatus: AgentStatus | null + /** Which evidence layer holds the strongest current claim. */ + source: 'hook' | 'title' | 'none' + confidence: 'authoritative' | 'fallback' + /** True when the only claim is a title without live-PTY proof — liveness-gated consumers must treat it as absent. */ + livePtyRequired: boolean +} + +export type ResolvePaneAgentActivityInput = { + explicitEntry: AgentStatusEntry | undefined + liveTitle: string | null + hasLivePty: boolean + now: number +} + +export function resolvePaneAgentActivity( + input: ResolvePaneAgentActivityInput +): AgentActivityDecision { + const freshEntry = + input.explicitEntry && + isExplicitAgentStatusFresh(input.explicitEntry, input.now, AGENT_STATUS_STALE_AFTER_MS) + ? input.explicitEntry + : null + const titleStatus = input.liveTitle !== null ? detectAgentStatusFromTitle(input.liveTitle) : null + if (freshEntry) { + return { + hookState: freshEntry.state, + hookAgentType: freshEntry.agentType, + titleStatus, + source: 'hook', + confidence: 'authoritative', + livePtyRequired: false + } + } + if (titleStatus !== null) { + return { + hookState: null, + hookAgentType: undefined, + titleStatus, + source: 'title', + confidence: 'fallback', + livePtyRequired: !input.hasLivePty + } + } + return { + hookState: null, + hookAgentType: undefined, + titleStatus: null, + source: 'none', + confidence: 'authoritative', + livePtyRequired: false + } +} + +// Deliberately absent: a resolvePaneAgentOwner precedence resolver. The only +// Phase 2 identity consumer (native-chat toggle) reads hook identity without a +// freshness gate, so a gated owner resolver would change its behavior; the +// owner resolver lands with its first real consumer in a later slice. diff --git a/src/renderer/src/lib/running-agent-targets.ts b/src/renderer/src/lib/running-agent-targets.ts index 332f419db..eedc06e36 100644 --- a/src/renderer/src/lib/running-agent-targets.ts +++ b/src/renderer/src/lib/running-agent-targets.ts @@ -1,11 +1,8 @@ import type { AppState } from '@/store/types' -import { - AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry -} from '../../../shared/agent-status-types' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' import type { TerminalTab } from '../../../shared/types' import { parsePaneKey } from '../../../shared/stable-pane-id' -import { isExplicitAgentStatusFresh } from './agent-status' +import { resolvePaneAgentActivity } from '@/lib/pane-agent-evidence' import { detectAgentSendTitleStatus } from './agent-send-title-status' import { resolveRuntimePaneTitleLeafResolution } from './runtime-pane-title-leaf-id' @@ -58,12 +55,23 @@ export function deriveRunningAgentSendTargets( : null let disabledReason: string | undefined + // Why: the shared resolver gates hook freshness; a null hookState means the + // entry is stale (entries here always exist), and otherwise carries the + // fresh entry.state. The live-title layer stays local because it needs the + // send-gated detector (label + strict idle-send gate), which the resolver's + // raw titleStatus does not reproduce. + const decision = resolvePaneAgentActivity({ + explicitEntry: entry, + liveTitle: null, + hasLivePty: ptyId !== null, + now + }) // Why: hook-backed rows can go stale while the same PTY is still a live // agent; live titles are the runtime proof that the row remains targetable. const liveTitleStatus = ptyId ? detectLiveAgentPaneStatus(state, parsed.tabId, parsed.leafId, tab.title) : null - if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { + if (decision.hookState === null) { if (liveTitleStatus === 'permission') { disabledReason = 'Agent needs permission' } else if (liveTitleStatus === null) { @@ -71,7 +79,7 @@ export function deriveRunningAgentSendTargets( } } else if (!ptyId) { disabledReason = 'Terminal is no longer available' - } else if (entry.state === 'blocked' || entry.state === 'waiting') { + } else if (decision.hookState === 'blocked' || decision.hookState === 'waiting') { disabledReason = 'Agent needs permission' } else if (liveTitleStatus === 'permission') { disabledReason = 'Agent needs permission' diff --git a/src/renderer/src/lib/use-tab-agent.ts b/src/renderer/src/lib/use-tab-agent.ts index 6253ced22..40bf92d6f 100644 --- a/src/renderer/src/lib/use-tab-agent.ts +++ b/src/renderer/src/lib/use-tab-agent.ts @@ -13,8 +13,6 @@ import { import { resolveExplicitTerminalTitleAgentType } from '../../../shared/terminal-title-agent-type' import type { TerminalTab, TuiAgent } from '../../../shared/types' -export { resolveExplicitTerminalTitleAgentType as resolveTabAgentFromTitle } from '../../../shared/terminal-title-agent-type' - // A shell name, or the tab's neutral default title — where Orca's // inferred-interrupt reset parks it. Blank titles are no evidence either way. function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 5018c7310..0c3c87bf0 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -1,4 +1,4 @@ -import { detectAgentStatusFromTitle } from '@/lib/agent-status' +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 type { @@ -91,7 +91,7 @@ function tabHasStatus( ) { continue } - if (detectAgentStatusFromTitle(title) === status) { + if (classifyTitleActivity(title) === status) { return true } } @@ -103,7 +103,7 @@ function tabHasStatus( if (agentStatusPaneIds && agentStatusPaneIds.size > 0) { return false } - return detectAgentStatusFromTitle(tab.title) === status + return classifyTitleActivity(tab.title) === status } export function getWorktreeStatusLabel(status: WorktreeStatus): string { diff --git a/src/renderer/src/store/slices/terminal-helpers.ts b/src/renderer/src/store/slices/terminal-helpers.ts index 397660556..7f42c049a 100644 --- a/src/renderer/src/store/slices/terminal-helpers.ts +++ b/src/renderer/src/store/slices/terminal-helpers.ts @@ -1,5 +1,5 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' -import { detectAgentStatusFromTitle } from '@/lib/agent-status' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' export function emptyLayoutSnapshot(): TerminalLayoutSnapshot { return { @@ -37,5 +37,5 @@ function getResetTitle(tab: TerminalTab, index: number): string { // Why: reset any recognized agent title on hydration. The prior-session // agent is no longer running after a restart, so showing a stale // "Claude done" or spinner would be misleading. - return detectAgentStatusFromTitle(tab.title) ? fallbackTitle : tab.title + return classifyTitleActivity(tab.title) ? fallbackTitle : tab.title } diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 260e642c7..9cf68efd6 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -40,7 +40,8 @@ import type { AgentStartedTelemetry } from '../../lib/worktree-activation' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' import { clearTransientTerminalState, emptyLayoutSnapshot } from './terminal-helpers' -import { isClaudeAgent, detectAgentStatusFromTitle } from '@/lib/agent-status' +import { isClaudeAgent } from '@/lib/agent-status' +import { classifyTitleActivity } from '@/lib/pane-agent-evidence' import { buildOrphanTerminalCleanupPatch, getOrphanTerminalIds } from './terminal-orphan-helpers' import { dedupeTabOrder, @@ -872,7 +873,7 @@ export const createTerminalSlice: StateCreator if (!tab.title || !isClaudeAgent(tab.title)) { continue } - const status = detectAgentStatusFromTitle(tab.title) + const status = classifyTitleActivity(tab.title) if (status === null || status === 'working') { continue } @@ -1631,7 +1632,7 @@ export const createTerminalSlice: StateCreator // event fires. Bumping only on classification change keeps incidental // title noise (spinner frame, prompt suffix) from churning the sidebar. const classificationChanged = - detectAgentStatusFromTitle(prevTitle ?? '') !== detectAgentStatusFromTitle(title) + classifyTitleActivity(prevTitle ?? '') !== classifyTitleActivity(title) // Why: locate the owning worktree so we can suppress the sortEpoch // bump when the changing pane lives in the active worktree. Title // changes there are side-effects of the user's click (PTY remount on @@ -1674,7 +1675,7 @@ export const createTerminalSlice: StateCreator // Why: clearing a 'working'/'permission'-classified title back to none // changes the title-heuristic verdict for that pane, so the smart sort // needs a re-sort. See setRuntimePaneTitle for the rationale. - const hadClassification = detectAgentStatusFromTitle(prevTitle ?? '') !== null + const hadClassification = classifyTitleActivity(prevTitle ?? '') !== null // Why: same active-worktree gate as setRuntimePaneTitle — clears that // fire as a side-effect of a click-driven PTY teardown in the active // worktree must not re-rank the sidebar. Skip bumping when no owner is diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index 357e4e74a..502cead19 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -21,7 +21,7 @@ import { type WorkspaceCleanupScanProgress, type WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' -import { detectAgentStatusFromTitle, isExplicitAgentStatusFresh } from '@/lib/agent-status' +import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { translate } from '@/i18n/i18n' export type WorkspaceCleanupFailure = { @@ -737,7 +737,7 @@ function hasWorkingTitleAgent(state: AppState, tabs: { id: string; title: string const titles = paneTitles && Object.keys(paneTitles).length > 0 ? Object.values(paneTitles) : [tab.title] for (const title of titles) { - const status = detectAgentStatusFromTitle(title) + const status = classifyTitleActivity(title) if (status === 'working' || status === 'permission') { return true } @@ -812,7 +812,7 @@ function hasIdleAgentTitleForPty( } function isIdleAgentTitle(title: string): boolean { - return detectAgentStatusFromTitle(title) === 'idle' + return classifyTitleActivity(title) === 'idle' } function getPaneKeyTabId(paneKey: AgentStatusEntry['paneKey']): string {