mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
fix(agent-status): reconcile fresh pane identity
This commit is contained in:
@@ -29,7 +29,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone,
|
||||
agentStatusPaneIdsByTabId
|
||||
agentStatusPaneIdsByTabId,
|
||||
paneForegroundAgentByPaneKey
|
||||
} = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId)))
|
||||
|
||||
// Why: compact and detailed cards need the same status-dot semantics:
|
||||
@@ -44,6 +45,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
runtimePaneTitlesByTabId: runtimePaneTitlesForWorktree,
|
||||
agentStatusPaneIdsByTabId,
|
||||
terminalLayoutRootsByTabId,
|
||||
paneForegroundAgentByPaneKey,
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
@@ -58,6 +60,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus {
|
||||
runtimePaneTitlesForWorktree,
|
||||
agentStatusPaneIdsByTabId,
|
||||
terminalLayoutRootsByTabId,
|
||||
paneForegroundAgentByPaneKey,
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
|
||||
@@ -37,7 +37,8 @@ export function selectWorktreeActivityStatuses(
|
||||
hasInterrupted,
|
||||
hasLiveDone,
|
||||
hasRetainedDone,
|
||||
agentStatusPaneIdsByTabId
|
||||
agentStatusPaneIdsByTabId,
|
||||
paneForegroundAgentByPaneKey
|
||||
} = selectWorktreeAgentActivitySummary(statusInputs, worktreeId)
|
||||
statuses.set(
|
||||
worktreeId,
|
||||
@@ -48,6 +49,7 @@ export function selectWorktreeActivityStatuses(
|
||||
runtimePaneTitlesByTabId: selectRuntimePaneTitlesForWorktree(statusInputs, worktreeId),
|
||||
agentStatusPaneIdsByTabId,
|
||||
terminalLayoutRootsByTabId: selectTerminalLayoutRootsForWorktree(statusInputs, worktreeId),
|
||||
paneForegroundAgentByPaneKey,
|
||||
hasPermission,
|
||||
hasLiveWorking,
|
||||
hasLiveMonitoring,
|
||||
|
||||
@@ -267,6 +267,24 @@ describe('buildWorktreeAgentRows', () => {
|
||||
expect(done?.state).toBe('done')
|
||||
})
|
||||
|
||||
it('lets fresh pane activity replace a stale done row for the same pane', () => {
|
||||
const staleAt = 1_000
|
||||
const now = staleAt + AGENT_STATUS_STALE_AFTER_MS + 1
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { title: 'zsh' })],
|
||||
entries: [makeEntry(PANE_KEY_1, staleAt, { state: 'done', updatedAt: staleAt })],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: { 'tab-1': { 1: '⠋ Claude Code' } },
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-claude'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSinglePaneLayout(LEAF_ID_1) },
|
||||
now
|
||||
})
|
||||
|
||||
expect(rows.map((row) => [row.paneKey, row.state, row.agentType])).toEqual([
|
||||
[PANE_KEY_1, 'working', 'claude']
|
||||
])
|
||||
})
|
||||
|
||||
it('decays a restored-unconfirmed working entry to idle even while recent', () => {
|
||||
// Why: a hydrated nonterminal row may describe a turn that ended while no
|
||||
// receiver was up; it must never render as confirmed working, however new.
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
createWorktreeAgentFreshnessSelector,
|
||||
EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE
|
||||
} from './worktree-agent-freshness-selector'
|
||||
import {
|
||||
EMPTY_PANE_FOREGROUND_AGENTS,
|
||||
selectWorktreeAgentActivitySummary
|
||||
} from './worktree-agent-activity-summary'
|
||||
|
||||
export { buildWorktreeAgentRows } from './worktree-agent-rows'
|
||||
export {
|
||||
@@ -74,6 +78,13 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
|
||||
const runtimeAgentOrchestrationByPaneKey = useAppStore(
|
||||
useShallow((s) => (active ? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId) : {}))
|
||||
)
|
||||
// Reuse the worktree-narrowed process identity projection so hookless live
|
||||
// panes can still appear without subscribing every card to the global map.
|
||||
const paneForegroundAgentByPaneKey = useAppStore((s) =>
|
||||
active
|
||||
? selectWorktreeAgentActivitySummary(s, worktreeId).paneForegroundAgentByPaneKey
|
||||
: EMPTY_PANE_FOREGROUND_AGENTS
|
||||
)
|
||||
const agentFreshnessSignature = useAppStore((s) =>
|
||||
active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE
|
||||
)
|
||||
@@ -104,6 +115,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
|
||||
ptyIdsByTabId,
|
||||
terminalLayoutsByTabId,
|
||||
runtimeAgentOrchestrationByPaneKey,
|
||||
paneForegroundAgentByPaneKey,
|
||||
now
|
||||
})
|
||||
)
|
||||
@@ -118,6 +130,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa
|
||||
ptyIdsByTabId,
|
||||
terminalLayoutsByTabId,
|
||||
runtimeAgentOrchestrationByPaneKey,
|
||||
paneForegroundAgentByPaneKey,
|
||||
agentFreshnessSignature
|
||||
])
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type AgentStatusEntry,
|
||||
type AgentStatusOrchestrationContext
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
|
||||
export type WorktreeAgentActivitySummary = {
|
||||
hasPermission: boolean
|
||||
@@ -21,9 +22,12 @@ export type WorktreeAgentActivitySummary = {
|
||||
hasLiveDone: boolean
|
||||
hasRetainedDone: boolean
|
||||
agentStatusPaneIdsByTabId: Record<string, ReadonlySet<string>>
|
||||
/** Process identity narrowed to this worktree's panes. */
|
||||
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
|
||||
}
|
||||
|
||||
const EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID: Record<string, ReadonlySet<string>> = {}
|
||||
export const EMPTY_PANE_FOREGROUND_AGENTS: Record<string, PaneForegroundAgentEntry> = {}
|
||||
|
||||
const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
|
||||
hasPermission: false,
|
||||
@@ -32,7 +36,8 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = {
|
||||
hasInterrupted: false,
|
||||
hasLiveDone: false,
|
||||
hasRetainedDone: false,
|
||||
agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID
|
||||
agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID,
|
||||
paneForegroundAgentByPaneKey: EMPTY_PANE_FOREGROUND_AGENTS
|
||||
}
|
||||
|
||||
type AgentActivityTabsByWorktree = Record<string, readonly { id: string }[]>
|
||||
@@ -46,6 +51,7 @@ export type AgentActivityInput = Pick<
|
||||
> & {
|
||||
tabsByWorktree: AgentActivityTabsByWorktree
|
||||
runtimeAgentOrchestrationByPaneKey?: AppState['runtimeAgentOrchestrationByPaneKey']
|
||||
paneForegroundAgentByPaneKey?: AppState['paneForegroundAgentByPaneKey']
|
||||
}
|
||||
|
||||
type AgentActivityCache = {
|
||||
@@ -54,6 +60,7 @@ type AgentActivityCache = {
|
||||
migrationUnsupportedByPtyId: AppState['migrationUnsupportedByPtyId']
|
||||
retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey']
|
||||
runtimeAgentOrchestrationByPaneKey: AppState['runtimeAgentOrchestrationByPaneKey'] | undefined
|
||||
paneForegroundAgentByPaneKey: AppState['paneForegroundAgentByPaneKey'] | undefined
|
||||
summaries: Map<string, WorktreeAgentActivitySummary>
|
||||
}
|
||||
|
||||
@@ -70,13 +77,15 @@ function getWorktreeAgentActivitySummaries(
|
||||
state: AgentActivityInput
|
||||
): Map<string, WorktreeAgentActivitySummary> {
|
||||
const runtimeAgentOrchestrationByPaneKey = state.runtimeAgentOrchestrationByPaneKey
|
||||
const paneForegroundAgentByPaneKey = state.paneForegroundAgentByPaneKey
|
||||
if (
|
||||
agentActivityCache &&
|
||||
agentActivityCache.tabsByWorktree === state.tabsByWorktree &&
|
||||
agentActivityCache.agentStatusEpoch === state.agentStatusEpoch &&
|
||||
agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId &&
|
||||
agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey &&
|
||||
agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey
|
||||
agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey &&
|
||||
agentActivityCache.paneForegroundAgentByPaneKey === paneForegroundAgentByPaneKey
|
||||
) {
|
||||
return agentActivityCache.summaries
|
||||
}
|
||||
@@ -138,6 +147,23 @@ function getWorktreeAgentActivitySummaries(
|
||||
}
|
||||
}
|
||||
|
||||
// Narrow process identity to each worktree so sidebar rows can attribute
|
||||
// title-derived activity without subscribing to every pane globally.
|
||||
for (const [paneKey, entry] of Object.entries(paneForegroundAgentByPaneKey ?? {})) {
|
||||
if (!entry.agent) {
|
||||
continue
|
||||
}
|
||||
const worktreeId = worktreeIdForPaneKey(paneKey, tabIdToWorktreeId)
|
||||
if (!worktreeId) {
|
||||
continue
|
||||
}
|
||||
const summary = summaryForWorktree(worktreeId)
|
||||
if (summary.paneForegroundAgentByPaneKey === EMPTY_PANE_FOREGROUND_AGENTS) {
|
||||
summary.paneForegroundAgentByPaneKey = {}
|
||||
}
|
||||
summary.paneForegroundAgentByPaneKey[paneKey] = entry
|
||||
}
|
||||
|
||||
for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) {
|
||||
const summary = summaryForWorktree(retained.worktreeId)
|
||||
summary.hasRetainedDone = true
|
||||
@@ -170,6 +196,7 @@ function getWorktreeAgentActivitySummaries(
|
||||
migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId,
|
||||
retainedAgentsByPaneKey: state.retainedAgentsByPaneKey,
|
||||
runtimeAgentOrchestrationByPaneKey,
|
||||
paneForegroundAgentByPaneKey,
|
||||
summaries
|
||||
}
|
||||
return summaries
|
||||
@@ -189,10 +216,28 @@ function summariesEqual(
|
||||
agentStatusPaneIdsByTabIdEqual(
|
||||
previous.agentStatusPaneIdsByTabId,
|
||||
next.agentStatusPaneIdsByTabId
|
||||
) &&
|
||||
paneForegroundAgentsEqual(
|
||||
previous.paneForegroundAgentByPaneKey,
|
||||
next.paneForegroundAgentByPaneKey
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function paneForegroundAgentsEqual(
|
||||
previous: Record<string, PaneForegroundAgentEntry>,
|
||||
next: Record<string, PaneForegroundAgentEntry>
|
||||
): boolean {
|
||||
if (previous === next) {
|
||||
return true
|
||||
}
|
||||
const previousKeys = Object.keys(previous)
|
||||
if (previousKeys.length !== Object.keys(next).length) {
|
||||
return false
|
||||
}
|
||||
return previousKeys.every((paneKey) => previous[paneKey] === next[paneKey])
|
||||
}
|
||||
|
||||
function agentStatusPaneIdsByTabIdEqual(
|
||||
previous: Record<string, ReadonlySet<string>>,
|
||||
next: Record<string, ReadonlySet<string>>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData'
|
||||
import { formatAgentTypeLabel } from '@/lib/agent-status'
|
||||
import {
|
||||
resolveFreshPaneForegroundAgent,
|
||||
type PaneForegroundAgentEntry
|
||||
} from '@/store/slices/pane-foreground-agent'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
|
||||
/** Identity-only observations need a distinct authority from title snapshots. */
|
||||
export const PROCESS_DERIVED_AGENT_ROW_AUTHORITY_ID = 'renderer-process-projection'
|
||||
|
||||
export function freshProcessAgentForLeaf(args: {
|
||||
tabId: string
|
||||
leafId: string
|
||||
layout: TerminalLayoutSnapshot | undefined
|
||||
livePtyIds: readonly string[] | undefined
|
||||
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry> | undefined
|
||||
now: number
|
||||
}): TuiAgent | null {
|
||||
if (!args.paneForegroundAgentByPaneKey || !isTerminalLeafId(args.leafId)) {
|
||||
return null
|
||||
}
|
||||
return resolveFreshPaneForegroundAgent(
|
||||
args.paneForegroundAgentByPaneKey[makePaneKey(args.tabId, args.leafId)],
|
||||
{
|
||||
now: args.now,
|
||||
paneBoundPtyId: args.layout?.ptyIdsByLeafId?.[args.leafId],
|
||||
liveTabPtyIds: args.livePtyIds
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Append identity-only rows for fresh process observations that have no hook/title row. */
|
||||
export function appendProcessDerivedAgentRows(args: {
|
||||
tabs: TerminalTab[]
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry> | undefined
|
||||
seenPaneKeys: Set<string>
|
||||
rows: DashboardAgentRow[]
|
||||
now: number
|
||||
}): void {
|
||||
for (const [paneKey] of Object.entries(args.paneForegroundAgentByPaneKey ?? {})) {
|
||||
if (args.seenPaneKeys.has(paneKey)) {
|
||||
continue
|
||||
}
|
||||
const parsed = parsePaneKey(paneKey)
|
||||
if (!parsed) {
|
||||
continue
|
||||
}
|
||||
const tab = args.tabs.find((candidate) => candidate.id === parsed.tabId)
|
||||
if (!tab || !tabHasLivePty(args.ptyIdsByTabId, tab.id)) {
|
||||
continue
|
||||
}
|
||||
const processAgent = freshProcessAgentForLeaf({
|
||||
tabId: tab.id,
|
||||
leafId: parsed.leafId,
|
||||
layout: args.terminalLayoutsByTabId[tab.id],
|
||||
livePtyIds: args.ptyIdsByTabId[tab.id],
|
||||
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
|
||||
now: args.now
|
||||
})
|
||||
if (!processAgent) {
|
||||
continue
|
||||
}
|
||||
args.rows.push(
|
||||
buildProcessDerivedAgentRow({
|
||||
tab,
|
||||
leafId: parsed.leafId,
|
||||
agent: processAgent,
|
||||
now: args.now
|
||||
})
|
||||
)
|
||||
args.seenPaneKeys.add(paneKey)
|
||||
}
|
||||
}
|
||||
|
||||
function buildProcessDerivedAgentRow(args: {
|
||||
tab: TerminalTab
|
||||
leafId: string
|
||||
agent: TuiAgent
|
||||
now: number
|
||||
}): DashboardAgentRow {
|
||||
const paneKey = makePaneKey(args.tab.id, args.leafId)
|
||||
const label = formatAgentTypeLabel(args.agent)
|
||||
const entry: AgentStatusEntry = {
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: label,
|
||||
updatedAt: args.now,
|
||||
stateStartedAt: args.now,
|
||||
stateHistory: [],
|
||||
agentType: args.agent,
|
||||
terminalTitle: args.tab.title,
|
||||
lastAssistantMessage: 'Idle',
|
||||
observation: {
|
||||
origin: 'process',
|
||||
authorityId: PROCESS_DERIVED_AGENT_ROW_AUTHORITY_ID,
|
||||
incarnation: 0,
|
||||
revision: args.now,
|
||||
observedAt: args.now,
|
||||
kind: 'snapshot'
|
||||
}
|
||||
}
|
||||
return {
|
||||
paneKey,
|
||||
entry,
|
||||
tab: args.tab,
|
||||
agentType: args.agent,
|
||||
rowSource: 'live',
|
||||
state: 'idle',
|
||||
startedAt: args.now
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from './worktree-agent-row-fallback-tab'
|
||||
import { resolveRowAgentType } from './worktree-agent-row-type'
|
||||
import { entryWithRuntimeOrchestration } from './worktree-agent-row-orchestration'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
|
||||
function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number {
|
||||
if (!node) {
|
||||
@@ -144,10 +145,12 @@ export function buildWorktreeAgentRows(args: {
|
||||
ptyIdsByTabId?: Record<string, string[]>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
|
||||
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
|
||||
now: number
|
||||
}): DashboardAgentRow[] {
|
||||
const rows: DashboardAgentRow[] = []
|
||||
const seenPaneKeys = new Set<string>()
|
||||
const staleTerminalPaneKeys = new Set<string>()
|
||||
const currentTabIds = new Set(args.tabs.map((tab) => tab.id))
|
||||
|
||||
const entriesByTabId = new Map<string, AgentStatusEntry[]>()
|
||||
@@ -186,6 +189,9 @@ export function buildWorktreeAgentRows(args: {
|
||||
})
|
||||
rows.push(...buildSubagentChildRows({ parentEntry: rowEntry, tab, parentIsFresh: isFresh }))
|
||||
seenPaneKeys.add(rowEntry.paneKey)
|
||||
if (!isFresh && rowEntry.state === 'done') {
|
||||
staleTerminalPaneKeys.add(rowEntry.paneKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +204,30 @@ export function buildWorktreeAgentRows(args: {
|
||||
seenPaneKeys
|
||||
})
|
||||
|
||||
rows.push(...buildTitleDerivedAgentRows({ ...args, seenPaneKeys }))
|
||||
// Terminal outcomes are retained as history, but must yield to a fresh
|
||||
// title/process observation for the same pane (e.g. a stale `done` row while
|
||||
// the agent is visibly working again). Keep the history row when no newer
|
||||
// evidence exists.
|
||||
const titleSeenPaneKeys = new Set(seenPaneKeys)
|
||||
for (const paneKey of staleTerminalPaneKeys) {
|
||||
titleSeenPaneKeys.delete(paneKey)
|
||||
}
|
||||
const titleRows = buildTitleDerivedAgentRows({ ...args, seenPaneKeys: titleSeenPaneKeys })
|
||||
if (staleTerminalPaneKeys.size > 0 && titleRows.length > 0) {
|
||||
const replacedPaneKeys = new Set(titleRows.map((row) => row.paneKey))
|
||||
for (let index = rows.length - 1; index >= 0; index -= 1) {
|
||||
if (
|
||||
staleTerminalPaneKeys.has(rows[index].paneKey) &&
|
||||
replacedPaneKeys.has(rows[index].paneKey)
|
||||
) {
|
||||
rows.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.push(...titleRows)
|
||||
for (const paneKey of titleSeenPaneKeys) {
|
||||
seenPaneKeys.add(paneKey)
|
||||
}
|
||||
|
||||
// Why: orchestration workers can be attributed to a worktree by main before
|
||||
// their tab is present in this renderer. Keep those live rows visible in the
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
resolveCompatibleAgentTypeForOwner,
|
||||
type CompatibleAgentOwnerOptions
|
||||
} from '../../../../shared/agent-title-owner'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
|
||||
import { resolveTitleActivityLabel } from '@/lib/pane-agent-evidence'
|
||||
import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner'
|
||||
|
||||
const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
|
||||
'Claude Code': 'claude',
|
||||
OpenClaude: 'openclaude',
|
||||
Codex: 'codex',
|
||||
'Gemini CLI': 'gemini',
|
||||
'GitHub Copilot': 'copilot',
|
||||
Grok: 'grok',
|
||||
Devin: 'devin',
|
||||
Antigravity: 'antigravity',
|
||||
OpenCode: 'opencode',
|
||||
Aider: 'aider',
|
||||
Cursor: 'cursor',
|
||||
Droid: 'droid',
|
||||
Hermes: 'hermes',
|
||||
Pi: 'pi',
|
||||
OMP: 'omp'
|
||||
}
|
||||
|
||||
const CLAUDE_AGENT_TOKEN_RE = /(?<![\w./\\-])claude(?![\w./\\-])/i
|
||||
|
||||
export function resolveTitleDerivedAgentType(
|
||||
title: string,
|
||||
label: string,
|
||||
ownerAgentType?: AgentType | null,
|
||||
processAgent?: AgentType | null
|
||||
): AgentType | null {
|
||||
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
|
||||
if (agentType !== 'claude') {
|
||||
return agentType
|
||||
}
|
||||
// Claude's spinner has no provider identity; a process observation may
|
||||
// supply it when the title itself is neutral.
|
||||
if (!CLAUDE_AGENT_TOKEN_RE.test(title) && processAgent !== 'claude') {
|
||||
return null
|
||||
}
|
||||
const owner = ownerAgentType && ownerAgentType !== 'unknown' ? ownerAgentType : null
|
||||
if (owner && owner !== 'claude' && !isClaudeIdentityFrameTitle(title)) {
|
||||
return null
|
||||
}
|
||||
return agentType
|
||||
}
|
||||
|
||||
export function resolveAgentTypeFromTerminalTitle(
|
||||
title: string | null | undefined,
|
||||
ownerAgentType?: AgentType | null,
|
||||
options?: CompatibleAgentOwnerOptions,
|
||||
processAgent?: AgentType | null
|
||||
): AgentType | null {
|
||||
if (!title) {
|
||||
return null
|
||||
}
|
||||
const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType, options)
|
||||
const label = resolveTitleActivityLabel(normalizedTitle)
|
||||
return label
|
||||
? (resolveCompatibleAgentTypeForOwner(
|
||||
resolveTitleDerivedAgentType(normalizedTitle, label, ownerAgentType, processAgent),
|
||||
ownerAgentType,
|
||||
options
|
||||
) ?? null)
|
||||
: null
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage'
|
||||
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
import { buildWorktreeAgentRows } from './worktree-agent-rows'
|
||||
|
||||
const LEAF_ID_1 = '77777777-7777-4777-8777-777777777777'
|
||||
@@ -44,6 +45,73 @@ function makeSingleLayout(leafId: string): TerminalLayoutSnapshot {
|
||||
}
|
||||
|
||||
describe('buildTitleDerivedAgentRows', () => {
|
||||
it('adds an idle identity row for a fresh live process with no hook or title', () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID_1)
|
||||
const foreground: PaneForegroundAgentEntry = {
|
||||
agent: 'claude',
|
||||
shellForeground: false,
|
||||
observedAt: 1_000,
|
||||
ptyId: 'pty-claude'
|
||||
}
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { title: 'zsh' })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-claude'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
paneForegroundAgentByPaneKey: { [paneKey]: foreground },
|
||||
now: 1_000 + 1_000
|
||||
})
|
||||
|
||||
expect(rows.map((row) => [row.paneKey, row.agentType, row.state])).toEqual([
|
||||
[paneKey, 'claude', 'idle']
|
||||
])
|
||||
})
|
||||
|
||||
it('does not attribute process identity to a pane after its PTY is rebound', () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID_1)
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { title: 'zsh' })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-new'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
paneForegroundAgentByPaneKey: {
|
||||
[paneKey]: {
|
||||
agent: 'claude',
|
||||
shellForeground: false,
|
||||
observedAt: 1_000,
|
||||
ptyId: 'pty-old'
|
||||
}
|
||||
},
|
||||
now: 2_000
|
||||
})
|
||||
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('expires process identity after the observation TTL', () => {
|
||||
const paneKey = makePaneKey('tab-1', LEAF_ID_1)
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { title: 'zsh' })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-claude'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
paneForegroundAgentByPaneKey: {
|
||||
[paneKey]: {
|
||||
agent: 'claude',
|
||||
shellForeground: false,
|
||||
observedAt: 1_000,
|
||||
ptyId: 'pty-claude'
|
||||
}
|
||||
},
|
||||
now: 31_001
|
||||
})
|
||||
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('adds title-derived rows for live agent panes that have no hook status yet', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1')],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { formatAgentTypeLabel, isClaudeManagementTitle } from '@/lib/agent-statu
|
||||
import { isCursorAgentTitle } from '../../../../shared/agent-title-core'
|
||||
import { classifyTitleActivity, resolveTitleActivityLabel } from '@/lib/pane-agent-evidence'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
import type {
|
||||
AgentStatusEntry,
|
||||
AgentStatusOrchestrationContext,
|
||||
@@ -15,13 +16,19 @@ import type {
|
||||
TerminalPaneLayoutNode,
|
||||
TerminalTab
|
||||
} from '../../../../shared/terminal-tab-types'
|
||||
import {
|
||||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner,
|
||||
type CompatibleAgentOwnerOptions
|
||||
} from '../../../../shared/agent-title-owner'
|
||||
import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner'
|
||||
import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner'
|
||||
import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
import {
|
||||
appendProcessDerivedAgentRows,
|
||||
freshProcessAgentForLeaf
|
||||
} from './worktree-agent-process-evidence'
|
||||
export { freshProcessAgentForLeaf } from './worktree-agent-process-evidence'
|
||||
export {
|
||||
resolveAgentTypeFromTerminalTitle,
|
||||
resolveTitleDerivedAgentType
|
||||
} from './worktree-title-agent-type'
|
||||
import { resolveTitleDerivedAgentType } from './worktree-title-agent-type'
|
||||
|
||||
/** Fixed, not per-process: title rows are a pure projection of the current title, so they are
|
||||
* comparable across restarts in a way a sequenced authority's rows are not. Ordering against
|
||||
@@ -32,32 +39,13 @@ const EMPTY_RUNTIME_TITLES: Record<string, Record<number, string>> = {}
|
||||
const EMPTY_LIVE_PTY_IDS: Record<string, string[]> = {}
|
||||
const EMPTY_TERMINAL_LAYOUTS: Record<string, TerminalLayoutSnapshot | undefined> = {}
|
||||
|
||||
const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
|
||||
'Claude Code': 'claude',
|
||||
OpenClaude: 'openclaude',
|
||||
Codex: 'codex',
|
||||
'Gemini CLI': 'gemini',
|
||||
'GitHub Copilot': 'copilot',
|
||||
Grok: 'grok',
|
||||
Devin: 'devin',
|
||||
Antigravity: 'antigravity',
|
||||
OpenCode: 'opencode',
|
||||
Aider: 'aider',
|
||||
Cursor: 'cursor',
|
||||
Droid: 'droid',
|
||||
Hermes: 'hermes',
|
||||
Pi: 'pi',
|
||||
OMP: 'omp'
|
||||
}
|
||||
|
||||
const CLAUDE_AGENT_TOKEN_RE = /(?<![\w./\\-])claude(?![\w./\\-])/i
|
||||
|
||||
export function buildTitleDerivedAgentRows(args: {
|
||||
tabs: TerminalTab[]
|
||||
runtimePaneTitlesByTabId?: Record<string, Record<number, string>>
|
||||
ptyIdsByTabId?: Record<string, string[]>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
|
||||
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
|
||||
seenPaneKeys: Set<string>
|
||||
now: number
|
||||
}): DashboardAgentRow[] {
|
||||
@@ -94,7 +82,15 @@ export function buildTitleDerivedAgentRows(args: {
|
||||
title,
|
||||
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
|
||||
now: args.now,
|
||||
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
|
||||
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey,
|
||||
processAgent: freshProcessAgentForLeaf({
|
||||
tabId: tab.id,
|
||||
leafId,
|
||||
layout,
|
||||
livePtyIds: ptyIdsByTabId[tab.id],
|
||||
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
|
||||
now: args.now
|
||||
})
|
||||
})
|
||||
if (!row || args.seenPaneKeys.has(row.paneKey)) {
|
||||
continue
|
||||
@@ -115,7 +111,15 @@ export function buildTitleDerivedAgentRows(args: {
|
||||
title: tab.title,
|
||||
ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId),
|
||||
now: args.now,
|
||||
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey
|
||||
runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey,
|
||||
processAgent: freshProcessAgentForLeaf({
|
||||
tabId: tab.id,
|
||||
leafId,
|
||||
layout,
|
||||
livePtyIds: ptyIdsByTabId[tab.id],
|
||||
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
|
||||
now: args.now
|
||||
})
|
||||
})
|
||||
if (!row || args.seenPaneKeys.has(row.paneKey)) {
|
||||
continue
|
||||
@@ -124,6 +128,16 @@ export function buildTitleDerivedAgentRows(args: {
|
||||
args.seenPaneKeys.add(row.paneKey)
|
||||
}
|
||||
|
||||
appendProcessDerivedAgentRows({
|
||||
tabs: args.tabs,
|
||||
ptyIdsByTabId,
|
||||
terminalLayoutsByTabId,
|
||||
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
|
||||
seenPaneKeys: args.seenPaneKeys,
|
||||
rows,
|
||||
now: args.now
|
||||
})
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
@@ -138,6 +152,7 @@ function buildTitleDerivedAgentRow(args: {
|
||||
ownerAgentType: AgentType | null
|
||||
now: number
|
||||
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
|
||||
processAgent?: TuiAgent | null
|
||||
}): DashboardAgentRow | null {
|
||||
// Why launchAgent, not ownerAgentType: this only rewrites a title within its own identity
|
||||
// group (OMP wraps Pi and emits Pi frames), which stays correct in a split. Pane ownership
|
||||
@@ -166,7 +181,7 @@ function buildTitleDerivedAgentRow(args: {
|
||||
const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey]
|
||||
const titleAgentType = isClaudeAgentsTitle
|
||||
? 'claude'
|
||||
: resolveTitleDerivedAgentType(title, label, args.ownerAgentType)
|
||||
: resolveTitleDerivedAgentType(title, label, args.ownerAgentType, args.processAgent)
|
||||
// Why: a status frame proves activity, not identity, so the resolver drops it.
|
||||
// Hook-less agents over SSH (Codex, #8711; OpenCode's '. '/'* ' frames, #8940)
|
||||
// surface only decorated task titles; fall back to the pane's known owner instead
|
||||
@@ -219,30 +234,6 @@ function buildTitleDerivedAgentRow(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTitleDerivedAgentType(
|
||||
title: string,
|
||||
label: string,
|
||||
ownerAgentType?: AgentType | null
|
||||
): AgentType | null {
|
||||
const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown'
|
||||
if (agentType !== 'claude') {
|
||||
return agentType
|
||||
}
|
||||
// Why: Claude's task-title spinner heuristic has no provider identity. In
|
||||
// split panes it can match arbitrary terminal spinners, so sidebar rows only
|
||||
// accept Claude when the title itself names Claude.
|
||||
if (!CLAUDE_AGENT_TOKEN_RE.test(title)) {
|
||||
return null
|
||||
}
|
||||
// Why: a "claude" word inside another agent's task text is a mention, not identity.
|
||||
// Only a title that PRESENTS Claude may take a pane away from its known owner (#8940).
|
||||
const owner = ownerAgentType && ownerAgentType !== 'unknown' ? ownerAgentType : null
|
||||
if (owner && owner !== 'claude' && !isClaudeIdentityFrameTitle(title)) {
|
||||
return null
|
||||
}
|
||||
return agentType
|
||||
}
|
||||
|
||||
function resolveTitleDerivedPaneOwner(
|
||||
tab: TerminalTab,
|
||||
layout: TerminalLayoutSnapshot | undefined,
|
||||
@@ -260,24 +251,6 @@ function resolveTitleDerivedPaneOwner(
|
||||
* Determines the agent type from a terminal title, normalising Pi-compatible
|
||||
* agents to their authoritative owner if specified.
|
||||
*/
|
||||
export function resolveAgentTypeFromTerminalTitle(
|
||||
title: string | null | undefined,
|
||||
ownerAgentType?: AgentType | null,
|
||||
options?: CompatibleAgentOwnerOptions
|
||||
): AgentType | null {
|
||||
if (!title) {
|
||||
return null
|
||||
}
|
||||
const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType, options)
|
||||
const label = resolveTitleActivityLabel(normalizedTitle)
|
||||
return label
|
||||
? (resolveCompatibleAgentTypeForOwner(
|
||||
resolveTitleDerivedAgentType(normalizedTitle, label, ownerAgentType),
|
||||
ownerAgentType,
|
||||
options
|
||||
) ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
function titleStatusToRowState(
|
||||
status: 'working' | 'permission' | 'idle'
|
||||
|
||||
@@ -38,6 +38,8 @@ export type AgentCompletionCoordinatorOptions = {
|
||||
replacement: RecognizedAgentProcess
|
||||
) => boolean
|
||||
shouldSuppressConfirmedProcessExitCompletion?: (exited: RecognizedAgentProcess) => boolean
|
||||
/** Re-arm renderer process identity freshness when a cadence inspection sees an agent. */
|
||||
onForegroundAgentInspected?: (process: RecognizedAgentProcess) => void
|
||||
isLive: () => boolean
|
||||
shouldPollProcessCadence?: () => boolean
|
||||
// Why: on hosts where one inspection forks a whole-process-table scan (local
|
||||
|
||||
@@ -77,6 +77,7 @@ export function createAgentCompletionProcessMonitor({
|
||||
state.consecutiveInspectionErrors = 0
|
||||
const recognized = recognizeAgentProcess(result.foregroundProcess)
|
||||
if (recognized) {
|
||||
options.onForegroundAgentInspected?.(recognized)
|
||||
handleRecognizedProcess(recognized)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -155,7 +155,14 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
isTrackablePtyId: session.isForegroundTrackingAllowed,
|
||||
readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id),
|
||||
confirmForegroundProcess: (id) => window.api.pty.confirmForegroundProcess(id),
|
||||
publish: (entry) => useAppStore.getState().setPaneForegroundAgent(session.cacheKey, entry),
|
||||
// Bind observations to the PTY that was inspected; a pane key may survive
|
||||
// a rebind while the old process identity is still in the store.
|
||||
publish: (entry) =>
|
||||
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
|
||||
...entry,
|
||||
observedAt: Date.now(),
|
||||
ptyId: session.transport.getPtyId() ?? undefined
|
||||
}),
|
||||
hasKnownAgentIdentity: session.paneHasKnownAgentIdentity,
|
||||
onConfirmedShellForeground: (reason) => {
|
||||
session.clearStaleAgentTabTitleOnConfirmedShell()
|
||||
@@ -192,7 +199,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
|
||||
agent: foreground.agent,
|
||||
routingRevoked: true,
|
||||
shellForeground: foreground.shellForeground
|
||||
shellForeground: foreground.shellForeground,
|
||||
ptyId: session.transport.getPtyId() ?? undefined
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -290,7 +298,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
|
||||
agent: foreground.agent,
|
||||
routingRevoked: true,
|
||||
shellForeground: false
|
||||
shellForeground: false,
|
||||
ptyId: session.transport.getPtyId() ?? undefined
|
||||
})
|
||||
session.visibleForegroundSamplePending = false
|
||||
session.visibleForegroundSampleSettled = false
|
||||
@@ -302,7 +311,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void {
|
||||
agent: foreground.agent,
|
||||
routingRevoked: true,
|
||||
shellForeground: false,
|
||||
routingConfirmationPending: true
|
||||
routingConfirmationPending: true,
|
||||
ptyId: session.transport.getPtyId() ?? undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,8 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi
|
||||
// closed. Use it to request confirmation, never as current byte authority.
|
||||
useAppStore.getState().setPaneForegroundAgent(session.cacheKey, {
|
||||
agent: metadata.launchAgent,
|
||||
shellForeground: false
|
||||
shellForeground: false,
|
||||
ptyId: session.transport.getPtyId() ?? undefined
|
||||
})
|
||||
}
|
||||
return
|
||||
|
||||
@@ -204,6 +204,17 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void
|
||||
currentAgentForExited !== exited.agent
|
||||
)
|
||||
},
|
||||
onForegroundAgentInspected: (process) => {
|
||||
const inspectedPtyId = session.transport.getPtyId()
|
||||
// Process identity is local-only evidence; never publish an SSH/WSL
|
||||
// inspection into the renderer's pane identity cache.
|
||||
if (!inspectedPtyId || !session.isForegroundTrackingAllowed(inspectedPtyId)) {
|
||||
return
|
||||
}
|
||||
useAppStore
|
||||
.getState()
|
||||
.refreshPaneForegroundAgentObservation(session.cacheKey, process.agent, inspectedPtyId)
|
||||
},
|
||||
dispatchCompletion: (title, meta) => {
|
||||
if (meta?.source === 'process-exit') {
|
||||
session.clearSuppressedTitleSideEffects()
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { resolveAgentTypeFromTerminalTitle } from '@/components/sidebar/worktree-title-derived-agent-rows'
|
||||
import {
|
||||
freshProcessAgentForLeaf,
|
||||
resolveAgentTypeFromTerminalTitle
|
||||
} from '@/components/sidebar/worktree-title-derived-agent-rows'
|
||||
import { classifyTitleActivity } from '@/lib/pane-agent-evidence'
|
||||
import { tabHasLivePty } from '@/lib/tab-has-live-pty'
|
||||
import { resolveRuntimePaneTitleLeafIdFromRoot } from '@/lib/runtime-pane-title-leaf-id'
|
||||
import { containsAgentSpinnerGlyph } from '../../../shared/agent-title-core'
|
||||
import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent'
|
||||
import type {
|
||||
TerminalLayoutSnapshot,
|
||||
TerminalPaneLayoutNode,
|
||||
@@ -25,6 +29,8 @@ type WorktreeStatusHeuristicOptions = {
|
||||
agentStatusPaneIdsByTabId?: Record<string, ReadonlySet<string>>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
|
||||
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
|
||||
now?: number
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<WorktreeStatus, string> = {
|
||||
@@ -49,7 +55,9 @@ export function getWorktreeStatus(
|
||||
|
||||
// Why: tab.title tracks only the most-recently-focused pane (onActivePaneChange in use-terminal-pane-lifecycle.ts); consult per-pane titles so the spinner reflects aggregate tab state.
|
||||
const hasStatus = (status: 'permission' | 'working'): boolean =>
|
||||
liveTabs.some((tab) => tabHasStatus(tab, runtimePaneTitlesByTabId, status, options))
|
||||
liveTabs.some((tab) =>
|
||||
tabHasStatus(tab, runtimePaneTitlesByTabId, status, options, ptyIdsByTabId)
|
||||
)
|
||||
|
||||
if (options.liveAgentStatus === 'permission' || hasStatus('permission')) {
|
||||
return 'permission'
|
||||
@@ -71,9 +79,21 @@ function tabHasStatus(
|
||||
tab: Pick<TerminalTab, 'id' | 'title' | 'launchAgent'>,
|
||||
runtimePaneTitlesByTabId: Record<string, Record<number, string>>,
|
||||
status: 'permission' | 'working',
|
||||
options: WorktreeStatusHeuristicOptions
|
||||
options: WorktreeStatusHeuristicOptions,
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
): boolean {
|
||||
const agentStatusPaneIds = options.agentStatusPaneIdsByTabId?.[tab.id]
|
||||
const processAgentForLeaf = (leafId: string | null): TuiAgent | null =>
|
||||
leafId === null
|
||||
? null
|
||||
: freshProcessAgentForLeaf({
|
||||
tabId: tab.id,
|
||||
leafId,
|
||||
layout: options.terminalLayoutsByTabId?.[tab.id],
|
||||
livePtyIds: ptyIdsByTabId[tab.id],
|
||||
paneForegroundAgentByPaneKey: options.paneForegroundAgentByPaneKey,
|
||||
now: options.now ?? Date.now()
|
||||
})
|
||||
const paneTitles = runtimePaneTitlesByTabId[tab.id]
|
||||
if (paneTitles && Object.keys(paneTitles).length > 0) {
|
||||
const tabLayoutRoot =
|
||||
@@ -93,7 +113,7 @@ function tabHasStatus(
|
||||
}
|
||||
if (
|
||||
classifyTitleActivity(title) === status &&
|
||||
titleStatusIsAgentAttributable(title, tab.launchAgent)
|
||||
titleStatusIsAgentAttributable(title, processAgentForLeaf(leafId), tab.launchAgent)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
@@ -106,13 +126,29 @@ function tabHasStatus(
|
||||
}
|
||||
return (
|
||||
classifyTitleActivity(tab.title) === status &&
|
||||
titleStatusIsAgentAttributable(tab.title, tab.launchAgent)
|
||||
// A tab title can borrow process identity only when the layout proves it
|
||||
// contains one pane; split tabs must not brand a sibling.
|
||||
titleStatusIsAgentAttributable(
|
||||
tab.title,
|
||||
processAgentForLeaf(resolveSoleLeafId(tab.id, options)),
|
||||
tab.launchAgent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveSoleLeafId(tabId: string, options: WorktreeStatusHeuristicOptions): string | null {
|
||||
const root =
|
||||
options.terminalLayoutRootsByTabId?.[tabId] ?? options.terminalLayoutsByTabId?.[tabId]?.root
|
||||
return root?.type === 'leaf' ? root.leafId : null
|
||||
}
|
||||
|
||||
// Why: require agent attribution so a bare never-cleared spinner title can't spin the dot "0 agents" forever with no matching sidebar row.
|
||||
function titleStatusIsAgentAttributable(title: string, launchAgent?: TuiAgent | null): boolean {
|
||||
if (resolveAgentTypeFromTerminalTitle(title) !== null) {
|
||||
function titleStatusIsAgentAttributable(
|
||||
title: string,
|
||||
processAgent?: TuiAgent | null,
|
||||
launchAgent?: TuiAgent | null
|
||||
): boolean {
|
||||
if (resolveAgentTypeFromTerminalTitle(title, undefined, undefined, processAgent) !== null) {
|
||||
return true
|
||||
}
|
||||
// Why: a spinner proves activity but not identity (Claude's thinking title has no provider
|
||||
@@ -141,6 +177,8 @@ export function resolveWorktreeStatus(args: {
|
||||
agentStatusPaneIdsByTabId?: Record<string, ReadonlySet<string>>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot | undefined>
|
||||
terminalLayoutRootsByTabId?: Record<string, TerminalPaneLayoutNode | null | undefined>
|
||||
paneForegroundAgentByPaneKey?: Record<string, PaneForegroundAgentEntry>
|
||||
now?: number
|
||||
hasPermission: boolean
|
||||
hasLiveWorking: boolean
|
||||
hasLiveMonitoring?: boolean
|
||||
@@ -156,7 +194,9 @@ export function resolveWorktreeStatus(args: {
|
||||
{
|
||||
agentStatusPaneIdsByTabId: args.agentStatusPaneIdsByTabId,
|
||||
terminalLayoutsByTabId: args.terminalLayoutsByTabId,
|
||||
terminalLayoutRootsByTabId: args.terminalLayoutRootsByTabId
|
||||
terminalLayoutRootsByTabId: args.terminalLayoutRootsByTabId,
|
||||
paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey,
|
||||
now: args.now
|
||||
}
|
||||
)
|
||||
if (args.hasPermission) {
|
||||
|
||||
@@ -2,6 +2,11 @@ import type { StateCreator } from 'zustand'
|
||||
import type { AppState } from '../types'
|
||||
import type { TuiAgent } from '../../../../shared/tui-agent'
|
||||
|
||||
// Process identity is attribution evidence, not a permanent status row. Keep
|
||||
// it fresh enough for sidebar projections while bounded when observations stop.
|
||||
export const PANE_FOREGROUND_AGENT_EVIDENCE_TTL_MS = 30_000
|
||||
const OBSERVATION_REFRESH_QUANTUM_MS = 5_000
|
||||
|
||||
export type PaneForegroundAgentEntry = {
|
||||
/** Recognized agent process in the pane's foreground; null when unknown. */
|
||||
agent: TuiAgent | null
|
||||
@@ -16,6 +21,10 @@ export type PaneForegroundAgentEntry = {
|
||||
/** True once the foreground is proven back at the shell (OSC 133;D) —
|
||||
* process-grade launched-agent exit evidence, independent of titles. */
|
||||
shellForeground: boolean
|
||||
/** Main-clock timestamp for the process observation. */
|
||||
observedAt?: number
|
||||
/** PTY from which the process observation was read. */
|
||||
ptyId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,6 +35,7 @@ export type PaneForegroundAgentEntry = {
|
||||
export type PaneForegroundAgentSlice = {
|
||||
paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
|
||||
setPaneForegroundAgent: (paneKey: string, entry: PaneForegroundAgentEntry) => void
|
||||
refreshPaneForegroundAgentObservation: (paneKey: string, agent: TuiAgent, ptyId?: string) => void
|
||||
clearPaneForegroundAgent: (paneKey: string) => void
|
||||
/** Wholesale teardown sweeps (tab close, worktree sleep/remove) retire pane
|
||||
* keys without per-pane close events — clear their entries too. */
|
||||
@@ -49,12 +59,74 @@ export const createPaneForegroundAgentSlice: StateCreator<
|
||||
current.routingTrusted === entry.routingTrusted &&
|
||||
current.routingRevoked === entry.routingRevoked &&
|
||||
current.routingConfirmationPending === entry.routingConfirmationPending &&
|
||||
current.shellForeground === entry.shellForeground
|
||||
current.shellForeground === entry.shellForeground &&
|
||||
current.ptyId === entry.ptyId
|
||||
) {
|
||||
return s
|
||||
// Test/legacy callers may provide identity-only entries. Preserve the
|
||||
// value-bail semantics for those records; only observed process
|
||||
// evidence opts into the bounded freshness clock.
|
||||
if (current.observedAt === undefined && entry.observedAt === undefined) {
|
||||
return s
|
||||
}
|
||||
const now = Date.now()
|
||||
if (
|
||||
current.observedAt !== undefined &&
|
||||
now - current.observedAt < OBSERVATION_REFRESH_QUANTUM_MS
|
||||
) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
paneForegroundAgentByPaneKey: {
|
||||
...s.paneForegroundAgentByPaneKey,
|
||||
[paneKey]: { ...current, observedAt: now }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
paneForegroundAgentByPaneKey: { ...s.paneForegroundAgentByPaneKey, [paneKey]: entry }
|
||||
paneForegroundAgentByPaneKey: {
|
||||
...s.paneForegroundAgentByPaneKey,
|
||||
[paneKey]: entry
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
refreshPaneForegroundAgentObservation: (paneKey, agent, ptyId) => {
|
||||
set((s) => {
|
||||
const now = Date.now()
|
||||
const current = s.paneForegroundAgentByPaneKey[paneKey]
|
||||
if (current) {
|
||||
if (current.agent !== agent) {
|
||||
return {
|
||||
paneForegroundAgentByPaneKey: {
|
||||
...s.paneForegroundAgentByPaneKey,
|
||||
[paneKey]: {
|
||||
agent,
|
||||
shellForeground: false,
|
||||
observedAt: now,
|
||||
ptyId: ptyId ?? current.ptyId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
current.observedAt !== undefined &&
|
||||
now - current.observedAt < OBSERVATION_REFRESH_QUANTUM_MS
|
||||
) {
|
||||
return s
|
||||
}
|
||||
return {
|
||||
paneForegroundAgentByPaneKey: {
|
||||
...s.paneForegroundAgentByPaneKey,
|
||||
[paneKey]: { ...current, observedAt: now }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Coordinator inspection proves identity but not input-routing authority.
|
||||
return {
|
||||
paneForegroundAgentByPaneKey: {
|
||||
...s.paneForegroundAgentByPaneKey,
|
||||
[paneKey]: { agent, shellForeground: false, observedAt: now }
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -89,6 +161,26 @@ export const createPaneForegroundAgentSlice: StateCreator<
|
||||
}
|
||||
})
|
||||
|
||||
/** Return process identity only while the evidence and its PTY are current. */
|
||||
export function resolveFreshPaneForegroundAgent(
|
||||
entry: PaneForegroundAgentEntry | undefined,
|
||||
args: { now: number; paneBoundPtyId?: string; liveTabPtyIds?: readonly string[] }
|
||||
): TuiAgent | null {
|
||||
if (!entry?.agent || entry.shellForeground || entry.observedAt === undefined) {
|
||||
return null
|
||||
}
|
||||
if (args.now - entry.observedAt > PANE_FOREGROUND_AGENT_EVIDENCE_TTL_MS) {
|
||||
return null
|
||||
}
|
||||
if (args.paneBoundPtyId !== undefined) {
|
||||
return entry.ptyId === undefined || entry.ptyId === args.paneBoundPtyId ? entry.agent : null
|
||||
}
|
||||
if (entry.ptyId !== undefined) {
|
||||
return args.liveTabPtyIds?.includes(entry.ptyId) === true ? entry.agent : null
|
||||
}
|
||||
return (args.liveTabPtyIds?.length ?? 0) > 0 ? entry.agent : null
|
||||
}
|
||||
|
||||
export function buildPaneForegroundAgentTabPrefixClearPatch(
|
||||
entries: Record<string, PaneForegroundAgentEntry>,
|
||||
tabPrefixes: readonly string[]
|
||||
|
||||
Reference in New Issue
Block a user