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:
parent
ee2e3a30a5
commit
7237c8e592
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ?? '')
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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.
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue