diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts index d0ca4bdae53..c1c7b1cab0e 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts index b7902660a19..27ca593d8f6 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index bd410ddc2a2..221569885a8 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -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. diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index b2f45a8a9a0..09d38865970 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -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 ]) } diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts index fbb2d93869a..093fdc8a3ec 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -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> + /** Process identity narrowed to this worktree's panes. */ + paneForegroundAgentByPaneKey: Record } const EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID: Record> = {} +export const EMPTY_PANE_FOREGROUND_AGENTS: Record = {} 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 @@ -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 } @@ -70,13 +77,15 @@ function getWorktreeAgentActivitySummaries( state: AgentActivityInput ): Map { 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, + next: Record +): 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>, next: Record> diff --git a/src/renderer/src/components/sidebar/worktree-agent-process-evidence.ts b/src/renderer/src/components/sidebar/worktree-agent-process-evidence.ts new file mode 100644 index 00000000000..e2a4559d702 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-process-evidence.ts @@ -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 | 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 + terminalLayoutsByTabId: Record + paneForegroundAgentByPaneKey: Record | undefined + seenPaneKeys: Set + 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 + } +} diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index b6c9d4d4e04..3161824b7f9 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -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 terminalLayoutsByTabId?: Record runtimeAgentOrchestrationByPaneKey?: Record + paneForegroundAgentByPaneKey?: Record now: number }): DashboardAgentRow[] { const rows: DashboardAgentRow[] = [] const seenPaneKeys = new Set() + const staleTerminalPaneKeys = new Set() const currentTabIds = new Set(args.tabs.map((tab) => tab.id)) const entriesByTabId = new Map() @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-title-agent-type.ts b/src/renderer/src/components/sidebar/worktree-title-agent-type.ts new file mode 100644 index 00000000000..7a7f5428d61 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-title-agent-type.ts @@ -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 = { + '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 = /(? { + 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')], diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 25d87c50801..bd7b63d26cc 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -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> = {} const EMPTY_LIVE_PTY_IDS: Record = {} const EMPTY_TERMINAL_LAYOUTS: Record = {} -const TITLE_AGENT_LABEL_TO_TYPE: Record = { - '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 = /(?> ptyIdsByTabId?: Record terminalLayoutsByTabId?: Record runtimeAgentOrchestrationByPaneKey?: Record + paneForegroundAgentByPaneKey?: Record seenPaneKeys: Set 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 + 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' diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index 9614d0bbffc..7b914122cd7 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts index f0a0492fdfe..b99ed1b272d 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts @@ -77,6 +77,7 @@ export function createAgentCompletionProcessMonitor({ state.consecutiveInspectionErrors = 0 const recognized = recognizeAgentProcess(result.foregroundProcess) if (recognized) { + options.onForegroundAgentInspected?.(recognized) handleRecognizedProcess(recognized) return true } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index f2c4290af76..2b3d9318df1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -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 }) } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index 0777dead431..1d11c3e1e89 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts index 3fff2df66fa..3c995a5c8d9 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts @@ -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() diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 4e234436958..849aaa2e549 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -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> terminalLayoutsByTabId?: Record terminalLayoutRootsByTabId?: Record + paneForegroundAgentByPaneKey?: Record + now?: number } const STATUS_LABELS: Record = { @@ -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, runtimePaneTitlesByTabId: Record>, status: 'permission' | 'working', - options: WorktreeStatusHeuristicOptions + options: WorktreeStatusHeuristicOptions, + ptyIdsByTabId: Record ): 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> terminalLayoutsByTabId?: Record terminalLayoutRootsByTabId?: Record + paneForegroundAgentByPaneKey?: Record + 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) { diff --git a/src/renderer/src/store/slices/pane-foreground-agent.ts b/src/renderer/src/store/slices/pane-foreground-agent.ts index 73685b6b88b..4cdb24ec4d3 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.ts @@ -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 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, tabPrefixes: readonly string[]