Route title-status consumers through one pane-agent-evidence seam (title evidence plan, PR 2) (#7600)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson 2026-07-06 16:39:20 -07:00 committed by GitHub
parent ee2e3a30a5
commit 7237c8e592
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 359 additions and 279 deletions

View File

@ -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({

View File

@ -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)

View File

@ -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
})
}

View File

@ -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<TerminalTab, 'id' | 'ptyId' | 'title'>[]
browserTabs: { id: string }[]
worktreeAgentEntries: AgentStatusEntry[]
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
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<string, AgentStatusEntry[]>()
const explicitPaneIdsByTabId = new Map<string, Set<string>>()
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<string>()
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'
}

View File

@ -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),

View File

@ -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
}

View File

@ -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.

View File

@ -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
}

View File

@ -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 ?? '')
)
}

View File

@ -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()

View File

@ -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

View File

@ -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
}

View File

@ -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')
}
/**

View File

@ -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
}

View File

@ -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
}

View File

@ -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<string, TerminalTab[]>
@ -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<AgentStatusEntry, 'updatedAt'>,
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

View File

@ -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> = {}): 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')
})
})

View File

@ -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<AgentStatusEntry, 'updatedAt'>,
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.

View File

@ -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'

View File

@ -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 {

View File

@ -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 {

View File

@ -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
}

View File

@ -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<AppState, [], [], TerminalSlice>
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<AppState, [], [], TerminalSlice>
// 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<AppState, [], [], TerminalSlice>
// 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

View File

@ -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 {