From ed5c485b8d913426283a4c8e24e032df94bfcbe2 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Mon, 31 Aug 2026 13:06:16 -0700 Subject: [PATCH] fix(agent-status): keep process evidence enumerable --- .../sidebar/use-worktree-activity-status.ts | 5 +- .../sidebar/use-worktree-activity-statuses.ts | 8 +- .../sidebar/useWorktreeAgentRows.ts | 8 + ...orktree-agent-activity-summary-equality.ts | 39 ++++ .../worktree-agent-activity-summary.ts | 97 ++++------ .../worktree-agent-process-evidence.ts | 11 +- .../components/sidebar/worktree-agent-rows.ts | 6 +- .../sidebar/worktree-agent-state-flags.ts | 22 +++ .../worktree-title-derived-agent-rows.test.ts | 25 ++- .../worktree-title-derived-agent-rows.ts | 9 +- ...nnection-foreground-agent-sampling.test.ts | 47 +++-- .../pty-connection/pane-agent-identity.ts | 60 ++---- .../pty-connection/sleeping-record-access.ts | 14 +- src/renderer/src/lib/worktree-status.test.ts | 7 +- src/renderer/src/lib/worktree-status.ts | 9 +- .../src/runtime/web-session-tabs-sync.ts | 2 + src/renderer/src/store/slices/agent-status.ts | 9 + .../slices/pane-foreground-agent.test.ts | 25 ++- .../src/store/slices/pane-foreground-agent.ts | 181 ++++++++++-------- .../retired-terminal-tab-state-sweep.ts | 3 +- .../teardown/worktree-purge-state.ts | 3 + 21 files changed, 352 insertions(+), 238 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-agent-activity-summary-equality.ts create mode 100644 src/renderer/src/components/sidebar/worktree-agent-state-flags.ts 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 a1c647a3889..ed11139877e 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -30,7 +30,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { hasLiveDone, hasRetainedDone, agentStatusPaneIdsByTabId, - paneForegroundAgentByPaneKey + paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey } = useAppStore(useShallow((s) => selectWorktreeAgentActivitySummary(s, worktreeId))) // Why: compact and detailed cards need the same status-dot semantics: @@ -46,6 +47,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { agentStatusPaneIdsByTabId, terminalLayoutsByTabId, paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey, hasPermission, hasLiveWorking, hasLiveMonitoring, @@ -61,6 +63,7 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { agentStatusPaneIdsByTabId, terminalLayoutsByTabId, paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey, 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 117cf7c9fac..09d2dcc83be 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.ts @@ -23,7 +23,9 @@ type WorktreeActivityStatusState = Pick< | 'retainedAgentsByPaneKey' | 'runtimeAgentOrchestrationByPaneKey' | 'paneForegroundAgentByPaneKey' -> +> & { + paneForegroundAgentObservationByPaneKey?: AppState['paneForegroundAgentObservationByPaneKey'] +} export function selectWorktreeActivityStatuses( statusInputs: WorktreeActivityStatusState, @@ -39,7 +41,8 @@ export function selectWorktreeActivityStatuses( hasLiveDone, hasRetainedDone, agentStatusPaneIdsByTabId, - paneForegroundAgentByPaneKey + paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey } = selectWorktreeAgentActivitySummary(statusInputs, worktreeId) statuses.set( worktreeId, @@ -51,6 +54,7 @@ export function selectWorktreeActivityStatuses( agentStatusPaneIdsByTabId, terminalLayoutsByTabId: selectTerminalLayoutsForWorktree(statusInputs, worktreeId), paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey, hasPermission, hasLiveWorking, hasLiveMonitoring, diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 09d38865970..6abecb142b8 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -22,6 +22,7 @@ import { } from './worktree-agent-freshness-selector' import { EMPTY_PANE_FOREGROUND_AGENTS, + EMPTY_PANE_FOREGROUND_OBSERVATIONS, selectWorktreeAgentActivitySummary } from './worktree-agent-activity-summary' @@ -85,6 +86,11 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa ? selectWorktreeAgentActivitySummary(s, worktreeId).paneForegroundAgentByPaneKey : EMPTY_PANE_FOREGROUND_AGENTS ) + const paneForegroundAgentObservationByPaneKey = useAppStore((s) => + active + ? selectWorktreeAgentActivitySummary(s, worktreeId).paneForegroundAgentObservationByPaneKey + : EMPTY_PANE_FOREGROUND_OBSERVATIONS + ) const agentFreshnessSignature = useAppStore((s) => active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE ) @@ -116,6 +122,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa terminalLayoutsByTabId, runtimeAgentOrchestrationByPaneKey, paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey, now }) ) @@ -131,6 +138,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa terminalLayoutsByTabId, runtimeAgentOrchestrationByPaneKey, paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey, agentFreshnessSignature ]) } diff --git a/src/renderer/src/components/sidebar/worktree-agent-activity-summary-equality.ts b/src/renderer/src/components/sidebar/worktree-agent-activity-summary-equality.ts new file mode 100644 index 00000000000..72037e36fb4 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary-equality.ts @@ -0,0 +1,39 @@ +export 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]) +} + +export function agentStatusPaneIdsByTabIdEqual( + previous: Record>, + next: Record> +): boolean { + if (previous === next) { + return true + } + const previousKeys = Object.keys(previous) + if (previousKeys.length !== Object.keys(next).length) { + return false + } + for (const tabId of previousKeys) { + const previousPaneIds = previous[tabId] + const nextPaneIds = next[tabId] + if (!nextPaneIds || previousPaneIds.size !== nextPaneIds.size) { + return false + } + for (const paneId of previousPaneIds) { + if (!nextPaneIds.has(paneId)) { + return false + } + } + } + return true +} 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 093fdc8a3ec..b498bc54d84 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-activity-summary.ts @@ -8,10 +8,17 @@ import { } from '@/lib/agent-status-worktree-attribution' import { AGENT_STATUS_STALE_AFTER_MS, - type AgentStatusEntry, type AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' -import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' +import type { + PaneForegroundAgentEntry, + PaneForegroundAgentObservation +} from '@/store/slices/pane-foreground-agent' +import { + agentStatusPaneIdsByTabIdEqual, + paneForegroundAgentsEqual +} from './worktree-agent-activity-summary-equality' +import { applyLiveAgentState } from './worktree-agent-state-flags' export type WorktreeAgentActivitySummary = { hasPermission: boolean @@ -24,10 +31,12 @@ export type WorktreeAgentActivitySummary = { agentStatusPaneIdsByTabId: Record> /** Process identity narrowed to this worktree's panes. */ paneForegroundAgentByPaneKey: Record + paneForegroundAgentObservationByPaneKey: Record } const EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID: Record> = {} export const EMPTY_PANE_FOREGROUND_AGENTS: Record = {} +export const EMPTY_PANE_FOREGROUND_OBSERVATIONS: Record = {} const EMPTY_SUMMARY: WorktreeAgentActivitySummary = { hasPermission: false, @@ -37,7 +46,8 @@ const EMPTY_SUMMARY: WorktreeAgentActivitySummary = { hasLiveDone: false, hasRetainedDone: false, agentStatusPaneIdsByTabId: EMPTY_AGENT_STATUS_PANE_IDS_BY_TAB_ID, - paneForegroundAgentByPaneKey: EMPTY_PANE_FOREGROUND_AGENTS + paneForegroundAgentByPaneKey: EMPTY_PANE_FOREGROUND_AGENTS, + paneForegroundAgentObservationByPaneKey: EMPTY_PANE_FOREGROUND_OBSERVATIONS } type AgentActivityTabsByWorktree = Record @@ -52,6 +62,7 @@ export type AgentActivityInput = Pick< tabsByWorktree: AgentActivityTabsByWorktree runtimeAgentOrchestrationByPaneKey?: AppState['runtimeAgentOrchestrationByPaneKey'] paneForegroundAgentByPaneKey?: AppState['paneForegroundAgentByPaneKey'] + paneForegroundAgentObservationByPaneKey?: AppState['paneForegroundAgentObservationByPaneKey'] } type AgentActivityCache = { @@ -61,6 +72,9 @@ type AgentActivityCache = { retainedAgentsByPaneKey: AppState['retainedAgentsByPaneKey'] runtimeAgentOrchestrationByPaneKey: AppState['runtimeAgentOrchestrationByPaneKey'] | undefined paneForegroundAgentByPaneKey: AppState['paneForegroundAgentByPaneKey'] | undefined + paneForegroundAgentObservationByPaneKey: + | AppState['paneForegroundAgentObservationByPaneKey'] + | undefined summaries: Map } @@ -78,6 +92,7 @@ function getWorktreeAgentActivitySummaries( ): Map { const runtimeAgentOrchestrationByPaneKey = state.runtimeAgentOrchestrationByPaneKey const paneForegroundAgentByPaneKey = state.paneForegroundAgentByPaneKey + const paneForegroundAgentObservationByPaneKey = state.paneForegroundAgentObservationByPaneKey if ( agentActivityCache && agentActivityCache.tabsByWorktree === state.tabsByWorktree && @@ -85,7 +100,9 @@ function getWorktreeAgentActivitySummaries( agentActivityCache.migrationUnsupportedByPtyId === state.migrationUnsupportedByPtyId && agentActivityCache.retainedAgentsByPaneKey === state.retainedAgentsByPaneKey && agentActivityCache.runtimeAgentOrchestrationByPaneKey === runtimeAgentOrchestrationByPaneKey && - agentActivityCache.paneForegroundAgentByPaneKey === paneForegroundAgentByPaneKey + agentActivityCache.paneForegroundAgentByPaneKey === paneForegroundAgentByPaneKey && + agentActivityCache.paneForegroundAgentObservationByPaneKey === + paneForegroundAgentObservationByPaneKey ) { return agentActivityCache.summaries } @@ -111,7 +128,7 @@ function getWorktreeAgentActivitySummaries( } const now = Date.now() - for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { + for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey ?? {})) { const paneIdentity = parseAgentStatusPaneIdentity(paneKey) if (!paneIdentity) { continue @@ -160,8 +177,13 @@ function getWorktreeAgentActivitySummaries( const summary = summaryForWorktree(worktreeId) if (summary.paneForegroundAgentByPaneKey === EMPTY_PANE_FOREGROUND_AGENTS) { summary.paneForegroundAgentByPaneKey = {} + summary.paneForegroundAgentObservationByPaneKey = {} } summary.paneForegroundAgentByPaneKey[paneKey] = entry + const observation = paneForegroundAgentObservationByPaneKey?.[paneKey] + if (observation) { + summary.paneForegroundAgentObservationByPaneKey[paneKey] = observation + } } for (const retained of Object.values(state.retainedAgentsByPaneKey ?? {})) { @@ -197,6 +219,7 @@ function getWorktreeAgentActivitySummaries( retainedAgentsByPaneKey: state.retainedAgentsByPaneKey, runtimeAgentOrchestrationByPaneKey, paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey, summaries } return summaries @@ -220,70 +243,14 @@ function summariesEqual( paneForegroundAgentsEqual( previous.paneForegroundAgentByPaneKey, next.paneForegroundAgentByPaneKey + ) && + paneForegroundAgentsEqual( + previous.paneForegroundAgentObservationByPaneKey, + next.paneForegroundAgentObservationByPaneKey ) ) } -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> -): boolean { - if (previous === next) { - return true - } - const previousKeys = Object.keys(previous) - if (previousKeys.length !== Object.keys(next).length) { - return false - } - for (const tabId of previousKeys) { - const previousPaneIds = previous[tabId] - const nextPaneIds = next[tabId] - if (!nextPaneIds || previousPaneIds.size !== nextPaneIds.size) { - return false - } - for (const paneId of previousPaneIds) { - if (!nextPaneIds.has(paneId)) { - return false - } - } - } - return true -} - -function applyLiveAgentState( - summary: WorktreeAgentActivitySummary, - entry: Pick -): void { - if (entry.state === 'blocked' || entry.state === 'waiting') { - summary.hasPermission = true - } else if (entry.interrupted === true) { - // Interrupted is encoded as done, so it must be checked first. - summary.hasInterrupted = true - } else if (entry.state === 'working') { - if (entry.workingMode === 'monitoring') { - summary.hasLiveMonitoring = true - } else { - summary.hasLiveWorking = true - } - } else if (entry.state === 'done') { - summary.hasLiveDone = true - } -} - function addAgentStatusPaneId( summary: WorktreeAgentActivitySummary, tabId: string, diff --git a/src/renderer/src/components/sidebar/worktree-agent-process-evidence.ts b/src/renderer/src/components/sidebar/worktree-agent-process-evidence.ts index e2a4559d702..f0eb11f1ef6 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-process-evidence.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-process-evidence.ts @@ -2,7 +2,8 @@ import type { DashboardAgentRow } from '@/components/dashboard/useDashboardData' import { formatAgentTypeLabel } from '@/lib/agent-status' import { resolveFreshPaneForegroundAgent, - type PaneForegroundAgentEntry + type PaneForegroundAgentEntry, + type PaneForegroundAgentObservation } from '@/store/slices/pane-foreground-agent' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../../../shared/stable-pane-id' @@ -19,6 +20,9 @@ export function freshProcessAgentForLeaf(args: { layout: TerminalLayoutSnapshot | undefined livePtyIds: readonly string[] | undefined paneForegroundAgentByPaneKey: Record | undefined + paneForegroundAgentObservationByPaneKey: + | Record + | undefined now: number }): TuiAgent | null { if (!args.paneForegroundAgentByPaneKey || !isTerminalLeafId(args.leafId)) { @@ -26,6 +30,7 @@ export function freshProcessAgentForLeaf(args: { } return resolveFreshPaneForegroundAgent( args.paneForegroundAgentByPaneKey[makePaneKey(args.tabId, args.leafId)], + args.paneForegroundAgentObservationByPaneKey?.[makePaneKey(args.tabId, args.leafId)], { now: args.now, paneBoundPtyId: args.layout?.ptyIdsByLeafId?.[args.leafId], @@ -40,6 +45,9 @@ export function appendProcessDerivedAgentRows(args: { ptyIdsByTabId: Record terminalLayoutsByTabId: Record paneForegroundAgentByPaneKey: Record | undefined + paneForegroundAgentObservationByPaneKey: + | Record + | undefined seenPaneKeys: Set rows: DashboardAgentRow[] now: number @@ -62,6 +70,7 @@ export function appendProcessDerivedAgentRows(args: { layout: args.terminalLayoutsByTabId[tab.id], livePtyIds: args.ptyIdsByTabId[tab.id], paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey: args.paneForegroundAgentObservationByPaneKey, now: args.now }) if (!processAgent) { diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index 3161824b7f9..6108232afbe 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -26,7 +26,10 @@ 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' +import type { + PaneForegroundAgentEntry, + PaneForegroundAgentObservation +} from '@/store/slices/pane-foreground-agent' function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number { if (!node) { @@ -146,6 +149,7 @@ export function buildWorktreeAgentRows(args: { terminalLayoutsByTabId?: Record runtimeAgentOrchestrationByPaneKey?: Record paneForegroundAgentByPaneKey?: Record + paneForegroundAgentObservationByPaneKey?: Record now: number }): DashboardAgentRow[] { const rows: DashboardAgentRow[] = [] diff --git a/src/renderer/src/components/sidebar/worktree-agent-state-flags.ts b/src/renderer/src/components/sidebar/worktree-agent-state-flags.ts new file mode 100644 index 00000000000..6297d33843d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-agent-state-flags.ts @@ -0,0 +1,22 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { WorktreeAgentActivitySummary } from './worktree-agent-activity-summary' + +export function applyLiveAgentState( + summary: WorktreeAgentActivitySummary, + entry: Pick +): void { + if (entry.state === 'blocked' || entry.state === 'waiting') { + summary.hasPermission = true + } else if (entry.interrupted === true) { + // Interrupted is encoded as done, so it must be checked first. + summary.hasInterrupted = true + } else if (entry.state === 'working') { + if (entry.workingMode === 'monitoring') { + summary.hasLiveMonitoring = true + } else { + summary.hasLiveWorking = true + } + } else if (entry.state === 'done') { + summary.hasLiveDone = true + } +} diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index 9df7da73da9..ea2141e92e3 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -3,7 +3,10 @@ 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 type { + PaneForegroundAgentEntry, + PaneForegroundAgentObservation +} from '@/store/slices/pane-foreground-agent' import { buildWorktreeAgentRows } from './worktree-agent-rows' const LEAF_ID_1 = '77777777-7777-4777-8777-777777777777' @@ -49,10 +52,9 @@ describe('buildTitleDerivedAgentRows', () => { const paneKey = makePaneKey('tab-1', LEAF_ID_1) const foreground: PaneForegroundAgentEntry = { agent: 'claude', - shellForeground: false, - observedAt: 1_000, - ptyId: 'pty-claude' + shellForeground: false } + const observation: PaneForegroundAgentObservation = { observedAt: 1_000, ptyId: 'pty-claude' } const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1', { title: 'zsh' })], entries: [], @@ -60,6 +62,7 @@ describe('buildTitleDerivedAgentRows', () => { ptyIdsByTabId: { 'tab-1': ['pty-claude'] }, terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, paneForegroundAgentByPaneKey: { [paneKey]: foreground }, + paneForegroundAgentObservationByPaneKey: { [paneKey]: observation }, now: 1_000 + 1_000 }) @@ -79,11 +82,12 @@ describe('buildTitleDerivedAgentRows', () => { paneForegroundAgentByPaneKey: { [paneKey]: { agent: 'claude', - shellForeground: false, - observedAt: 1_000, - ptyId: 'pty-old' + shellForeground: false } }, + paneForegroundAgentObservationByPaneKey: { + [paneKey]: { observedAt: 1_000, ptyId: 'pty-old' } + }, now: 2_000 }) @@ -101,11 +105,12 @@ describe('buildTitleDerivedAgentRows', () => { paneForegroundAgentByPaneKey: { [paneKey]: { agent: 'claude', - shellForeground: false, - observedAt: 1_000, - ptyId: 'pty-claude' + shellForeground: false } }, + paneForegroundAgentObservationByPaneKey: { + [paneKey]: { observedAt: 1_000, ptyId: 'pty-claude' } + }, now: 31_001 }) 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 bd7b63d26cc..8a299c2d010 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,7 +3,10 @@ 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 { + PaneForegroundAgentEntry, + PaneForegroundAgentObservation +} from '@/store/slices/pane-foreground-agent' import type { AgentStatusEntry, AgentStatusOrchestrationContext, @@ -46,6 +49,7 @@ export function buildTitleDerivedAgentRows(args: { terminalLayoutsByTabId?: Record runtimeAgentOrchestrationByPaneKey?: Record paneForegroundAgentByPaneKey?: Record + paneForegroundAgentObservationByPaneKey?: Record seenPaneKeys: Set now: number }): DashboardAgentRow[] { @@ -89,6 +93,7 @@ export function buildTitleDerivedAgentRows(args: { layout, livePtyIds: ptyIdsByTabId[tab.id], paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey: args.paneForegroundAgentObservationByPaneKey, now: args.now }) }) @@ -118,6 +123,7 @@ export function buildTitleDerivedAgentRows(args: { layout, livePtyIds: ptyIdsByTabId[tab.id], paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey: args.paneForegroundAgentObservationByPaneKey, now: args.now }) }) @@ -133,6 +139,7 @@ export function buildTitleDerivedAgentRows(args: { ptyIdsByTabId, terminalLayoutsByTabId, paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey: args.paneForegroundAgentObservationByPaneKey, seenPaneKeys: args.seenPaneKeys, rows, now: args.now diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts index 50e1b4a0f8b..af1172059bd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts @@ -272,10 +272,17 @@ describe('connectPanePty', () => { await advanceVisibleForegroundRead() expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) - expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { - agent: 'codex', - shellForeground: false - }) + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith( + cacheKey, + { + agent: 'codex', + shellForeground: false + }, + { + observedAt: expect.any(Number), + ptyId + } + ) }) it('does not sample hidden restored PTYs', async () => { @@ -758,10 +765,17 @@ describe('connectPanePty', () => { await advanceVisibleForegroundRead() - expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { - agent: null, - shellForeground: true - }) + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith( + cacheKey, + { + agent: null, + shellForeground: true + }, + { + observedAt: expect.any(Number), + ptyId + } + ) expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) }) @@ -807,11 +821,18 @@ describe('connectPanePty', () => { agent: null, shellForeground: true }) - expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { - agent: 'droid', - routingTrusted: true, - shellForeground: false - }) + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith( + cacheKey, + { + agent: 'droid', + routingTrusted: true, + shellForeground: false + }, + { + observedAt: expect.any(Number), + ptyId + } + ) }) it('never probes the foreground for a visible remote/SSH restored pane', async () => { 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 b0a5787d3a5..494c95475c3 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 @@ -15,7 +15,6 @@ import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import type { TuiAgent } from '../../../../../shared/tui-agent' import { isTuiAgent, TUI_AGENT_CONFIG } from '../../../../../shared/tui-agent-config' -import { createPaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' import type { ConnectPanePtySession } from './connect-pane-pty-session' @@ -159,13 +158,10 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { // 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, - createPaneForegroundAgentEntry(entry, { - observedAt: Date.now(), - ptyId: session.transport.getPtyId() ?? undefined - }) - ), + useAppStore.getState().setPaneForegroundAgent(session.cacheKey, entry, { + observedAt: Date.now(), + ptyId: session.transport.getPtyId() ?? undefined + }), hasKnownAgentIdentity: session.paneHasKnownAgentIdentity, onConfirmedShellForeground: (reason) => { session.clearStaleAgentTabTitleOnConfirmedShell() @@ -199,17 +195,11 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { if (foreground?.routingConfirmationPending !== true) { return } - useAppStore.getState().setPaneForegroundAgent( - session.cacheKey, - createPaneForegroundAgentEntry( - { - agent: foreground.agent, - routingRevoked: true, - shellForeground: foreground.shellForeground - }, - { ptyId: session.transport.getPtyId() ?? undefined } - ) - ) + useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { + agent: foreground.agent, + routingRevoked: true, + shellForeground: foreground.shellForeground + }) } }) // Why: one command-finished policy whether the signal arrives as bytes @@ -303,33 +293,23 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { } // Why: cmd.exe and Git Bash have no OSC command boundaries. Keep the icon // as a hint, but revoke bytes until one current provider confirmation lands. - useAppStore - .getState() - .setPaneForegroundAgent( - session.cacheKey, - createPaneForegroundAgentEntry( - { agent: foreground.agent, routingRevoked: true, shellForeground: false }, - { ptyId: session.transport.getPtyId() ?? undefined } - ) - ) + useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { + agent: foreground.agent, + routingRevoked: true, + shellForeground: false + }) session.visibleForegroundSamplePending = false session.visibleForegroundSampleSettled = false // Why: hook rows can suppress display-only sampling, but cannot restore // byte authority after this function explicitly revoked routing trust. session.sampleVisiblePaneForegroundAgent(true) if (session.paneForegroundAgentTracker.hasReadInFlight()) { - useAppStore.getState().setPaneForegroundAgent( - session.cacheKey, - createPaneForegroundAgentEntry( - { - agent: foreground.agent, - routingRevoked: true, - shellForeground: false, - routingConfirmationPending: true - }, - { ptyId: session.transport.getPtyId() ?? undefined } - ) - ) + useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { + agent: foreground.agent, + routingRevoked: true, + shellForeground: false, + routingConfirmationPending: true + }) } } session.commandLifecycle = createTerminalCommandLifecycle({ 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 1e6f6996944..0777dead431 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 @@ -18,7 +18,6 @@ import { import type { ConnectPanePtySession } from './connect-pane-pty-session' import { installCommandInferredPaneAgent } from './command-inferred-pane-agent' -import { createPaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' export function installSleepingRecordAccess(session: ConnectPanePtySession): void { session.getSleepingRecordForPane = ( @@ -160,15 +159,10 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi if (metadata?.launchAgent) { // Why: daemon launch identity can outlive the process while Orca is // closed. Use it to request confirmation, never as current byte authority. - useAppStore - .getState() - .setPaneForegroundAgent( - session.cacheKey, - createPaneForegroundAgentEntry( - { agent: metadata.launchAgent, shellForeground: false }, - { ptyId: session.transport.getPtyId() ?? undefined } - ) - ) + useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { + agent: metadata.launchAgent, + shellForeground: false + }) } return } diff --git a/src/renderer/src/lib/worktree-status.test.ts b/src/renderer/src/lib/worktree-status.test.ts index f8528380007..77a56ec8dfa 100644 --- a/src/renderer/src/lib/worktree-status.test.ts +++ b/src/renderer/src/lib/worktree-status.test.ts @@ -158,11 +158,12 @@ describe('getWorktreeStatus', () => { paneForegroundAgentByPaneKey: { [makePaneKey('tab-1', LEAF_ID_1)]: { agent: 'claude', - shellForeground: false, - observedAt: 1_000, - ptyId: 'pty-old' + shellForeground: false } }, + paneForegroundAgentObservationByPaneKey: { + [makePaneKey('tab-1', LEAF_ID_1)]: { observedAt: 1_000, ptyId: 'pty-old' } + }, now: 2_000 } ) diff --git a/src/renderer/src/lib/worktree-status.ts b/src/renderer/src/lib/worktree-status.ts index 849aaa2e549..0ff7d4a90a6 100644 --- a/src/renderer/src/lib/worktree-status.ts +++ b/src/renderer/src/lib/worktree-status.ts @@ -6,7 +6,10 @@ 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 { + PaneForegroundAgentEntry, + PaneForegroundAgentObservation +} from '@/store/slices/pane-foreground-agent' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, @@ -30,6 +33,7 @@ type WorktreeStatusHeuristicOptions = { terminalLayoutsByTabId?: Record terminalLayoutRootsByTabId?: Record paneForegroundAgentByPaneKey?: Record + paneForegroundAgentObservationByPaneKey?: Record now?: number } @@ -92,6 +96,7 @@ function tabHasStatus( layout: options.terminalLayoutsByTabId?.[tab.id], livePtyIds: ptyIdsByTabId[tab.id], paneForegroundAgentByPaneKey: options.paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey: options.paneForegroundAgentObservationByPaneKey, now: options.now ?? Date.now() }) const paneTitles = runtimePaneTitlesByTabId[tab.id] @@ -178,6 +183,7 @@ export function resolveWorktreeStatus(args: { terminalLayoutsByTabId?: Record terminalLayoutRootsByTabId?: Record paneForegroundAgentByPaneKey?: Record + paneForegroundAgentObservationByPaneKey?: Record now?: number hasPermission: boolean hasLiveWorking: boolean @@ -196,6 +202,7 @@ export function resolveWorktreeStatus(args: { terminalLayoutsByTabId: args.terminalLayoutsByTabId, terminalLayoutRootsByTabId: args.terminalLayoutRootsByTabId, paneForegroundAgentByPaneKey: args.paneForegroundAgentByPaneKey, + paneForegroundAgentObservationByPaneKey: args.paneForegroundAgentObservationByPaneKey, now: args.now } ) diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 83e5297cd6d..389d0f59553 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -286,6 +286,7 @@ export type WebSessionTabsSyncState = Pick< | 'automaticAgentResumeClaimsByTabId' | 'migrationUnsupportedByPtyId' | 'paneForegroundAgentByPaneKey' + | 'paneForegroundAgentObservationByPaneKey' | 'pendingStartupByTabId' | 'recentlyClosedAgentStatusTabIds' | 'recentlyRetiredAgentStatusPaneKeys' @@ -1400,6 +1401,7 @@ function buildRetractedMirroredTabSweepPatch( agentStatusEpoch: agentStatusPatch?.agentStatusEpoch ?? state.agentStatusEpoch, migrationUnsupportedByPtyId: state.migrationUnsupportedByPtyId ?? {}, paneForegroundAgentByPaneKey: state.paneForegroundAgentByPaneKey ?? {}, + paneForegroundAgentObservationByPaneKey: state.paneForegroundAgentObservationByPaneKey ?? {}, recentlyClosedAgentStatusTabIds: state.recentlyClosedAgentStatusTabIds ?? {}, recentlyRetiredAgentStatusPaneKeys: state.recentlyRetiredAgentStatusPaneKeys ?? {}, retainedAgentsByPaneKey: state.retainedAgentsByPaneKey ?? {}, diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 5a6293ae5a4..c5cebb2d92d 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -1675,6 +1675,10 @@ export const createAgentStatusSlice: StateCreator { - it('keeps process freshness metadata out of the public identity shape', () => { + it('keeps process freshness metadata in an explicit observation map', () => { const store = createTestStore() - store.getState().setPaneForegroundAgent('tab-1:leaf-1', { - agent: 'codex', - shellForeground: false, - observedAt: 1_000, - ptyId: 'pty-1' - }) + store.getState().setPaneForegroundAgent( + 'tab-1:leaf-1', + { + agent: 'codex', + shellForeground: false + }, + { + observedAt: 1_000, + ptyId: 'pty-1' + } + ) const entry = store.getState().paneForegroundAgentByPaneKey['tab-1:leaf-1'] expect(entry).toMatchObject({ agent: 'codex', shellForeground: false }) - expect(entry?.observedAt).toBe(1_000) - expect(entry?.ptyId).toBe('pty-1') expect(Object.keys(entry ?? {})).toEqual(['agent', 'shellForeground']) + expect(store.getState().paneForegroundAgentObservationByPaneKey['tab-1:leaf-1']).toEqual({ + observedAt: 1_000, + ptyId: 'pty-1' + }) }) it('sets, value-bails, and clears entries per pane key', () => { diff --git a/src/renderer/src/store/slices/pane-foreground-agent.ts b/src/renderer/src/store/slices/pane-foreground-agent.ts index 112a957ed81..ac8293b73d1 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.ts @@ -21,33 +21,12 @@ 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 } -/** Keep freshness metadata out of the long-standing identity record shape. */ -export function createPaneForegroundAgentEntry( - entry: PaneForegroundAgentEntry, - metadata: Pick = entry -): PaneForegroundAgentEntry { - const { observedAt: _ignoredObservedAt, ptyId: _ignoredPtyId, ...publicEntry } = entry - Object.defineProperties(publicEntry, { - observedAt: { - configurable: true, - enumerable: false, - value: metadata.observedAt, - writable: true - }, - ptyId: { - configurable: true, - enumerable: false, - value: metadata.ptyId, - writable: true - } - }) - return publicEntry +/** PTY-bound, time-bounded process evidence kept separate from the public identity record. */ +export type PaneForegroundAgentObservation = { + observedAt: number + ptyId?: string } /** @@ -57,7 +36,12 @@ export function createPaneForegroundAgentEntry( */ export type PaneForegroundAgentSlice = { paneForegroundAgentByPaneKey: Record - setPaneForegroundAgent: (paneKey: string, entry: PaneForegroundAgentEntry) => void + paneForegroundAgentObservationByPaneKey: Record + setPaneForegroundAgent: ( + paneKey: string, + entry: PaneForegroundAgentEntry, + observation?: PaneForegroundAgentObservation + ) => void refreshPaneForegroundAgentObservation: (paneKey: string, agent: TuiAgent, ptyId?: string) => void clearPaneForegroundAgent: (paneKey: string) => void /** Wholesale teardown sweeps (tab close, worktree sleep/remove) retire pane @@ -73,47 +57,54 @@ export const createPaneForegroundAgentSlice: StateCreator< PaneForegroundAgentSlice > = (set) => ({ paneForegroundAgentByPaneKey: {}, - setPaneForegroundAgent: (paneKey, entry) => { + paneForegroundAgentObservationByPaneKey: {}, + setPaneForegroundAgent: (paneKey, entry, observation) => { set((s) => { - const normalizedEntry = createPaneForegroundAgentEntry(entry) const current = s.paneForegroundAgentByPaneKey[paneKey] if ( current && - current.agent === normalizedEntry.agent && - current.routingTrusted === normalizedEntry.routingTrusted && - current.routingRevoked === normalizedEntry.routingRevoked && - current.routingConfirmationPending === normalizedEntry.routingConfirmationPending && - current.shellForeground === normalizedEntry.shellForeground && - current.ptyId === normalizedEntry.ptyId + current.agent === entry.agent && + current.routingTrusted === entry.routingTrusted && + current.routingRevoked === entry.routingRevoked && + current.routingConfirmationPending === entry.routingConfirmationPending && + current.shellForeground === entry.shellForeground ) { - // 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 (normalizedEntry.observedAt === undefined) { + if (!observation) { return s } const now = Date.now() + const currentObservation = s.paneForegroundAgentObservationByPaneKey[paneKey] if ( - current.observedAt !== undefined && - now - current.observedAt < OBSERVATION_REFRESH_QUANTUM_MS + currentObservation && + currentObservation.ptyId === observation.ptyId && + now - currentObservation.observedAt < OBSERVATION_REFRESH_QUANTUM_MS ) { return s } return { - paneForegroundAgentByPaneKey: { - ...s.paneForegroundAgentByPaneKey, - [paneKey]: createPaneForegroundAgentEntry(current, { - observedAt: now, - ptyId: current.ptyId - }) + paneForegroundAgentObservationByPaneKey: { + ...s.paneForegroundAgentObservationByPaneKey, + [paneKey]: { ...observation, observedAt: now } } } } + const nextObservation = observation + ? { ...observation } + : entry.agent !== current?.agent || entry.shellForeground + ? undefined + : s.paneForegroundAgentObservationByPaneKey[paneKey] + const nextObservations = { ...s.paneForegroundAgentObservationByPaneKey } + if (nextObservation) { + nextObservations[paneKey] = nextObservation + } else { + delete nextObservations[paneKey] + } return { paneForegroundAgentByPaneKey: { ...s.paneForegroundAgentByPaneKey, - [paneKey]: normalizedEntry - } + [paneKey]: entry + }, + paneForegroundAgentObservationByPaneKey: nextObservations } }) }, @@ -126,26 +117,25 @@ export const createPaneForegroundAgentSlice: StateCreator< return { paneForegroundAgentByPaneKey: { ...s.paneForegroundAgentByPaneKey, - [paneKey]: createPaneForegroundAgentEntry( - { agent, shellForeground: false }, - { observedAt: now, ptyId: ptyId ?? current.ptyId } - ) + [paneKey]: { agent, shellForeground: false } + }, + paneForegroundAgentObservationByPaneKey: { + ...s.paneForegroundAgentObservationByPaneKey, + [paneKey]: { observedAt: now, ptyId } } } } + const currentObservation = s.paneForegroundAgentObservationByPaneKey[paneKey] if ( - current.observedAt !== undefined && - now - current.observedAt < OBSERVATION_REFRESH_QUANTUM_MS + currentObservation && + now - currentObservation.observedAt < OBSERVATION_REFRESH_QUANTUM_MS ) { return s } return { - paneForegroundAgentByPaneKey: { - ...s.paneForegroundAgentByPaneKey, - [paneKey]: createPaneForegroundAgentEntry(current, { - observedAt: now, - ptyId: ptyId ?? current.ptyId - }) + paneForegroundAgentObservationByPaneKey: { + ...s.paneForegroundAgentObservationByPaneKey, + [paneKey]: { observedAt: now, ptyId: ptyId ?? currentObservation?.ptyId } } } } @@ -153,30 +143,41 @@ export const createPaneForegroundAgentSlice: StateCreator< return { paneForegroundAgentByPaneKey: { ...s.paneForegroundAgentByPaneKey, - [paneKey]: createPaneForegroundAgentEntry( - { agent, shellForeground: false }, - { observedAt: now, ptyId } - ) + [paneKey]: { agent, shellForeground: false } + }, + paneForegroundAgentObservationByPaneKey: { + ...s.paneForegroundAgentObservationByPaneKey, + [paneKey]: { observedAt: now, ptyId } } } }) }, clearPaneForegroundAgent: (paneKey) => { set((s) => { - if (!(paneKey in s.paneForegroundAgentByPaneKey)) { + if ( + !(paneKey in s.paneForegroundAgentByPaneKey) && + !(paneKey in s.paneForegroundAgentObservationByPaneKey) + ) { return s } const next = { ...s.paneForegroundAgentByPaneKey } delete next[paneKey] - return { paneForegroundAgentByPaneKey: next } + const nextObservations = { ...s.paneForegroundAgentObservationByPaneKey } + delete nextObservations[paneKey] + return { + paneForegroundAgentByPaneKey: next, + paneForegroundAgentObservationByPaneKey: nextObservations + } }) }, clearPaneForegroundAgentByTabPrefix: (tabIdPrefix) => { set( (s) => - buildPaneForegroundAgentTabPrefixClearPatch(s.paneForegroundAgentByPaneKey, [ - `${tabIdPrefix}:` - ]) ?? s + buildPaneForegroundAgentTabPrefixClearPatch( + s.paneForegroundAgentByPaneKey, + s.paneForegroundAgentObservationByPaneKey, + [`${tabIdPrefix}:`] + ) ?? s ) }, clearPaneForegroundAgentByWorktree: (worktreeId) => { @@ -186,7 +187,11 @@ export const createPaneForegroundAgentSlice: StateCreator< set((s) => { const prefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) return ( - buildPaneForegroundAgentTabPrefixClearPatch(s.paneForegroundAgentByPaneKey, prefixes) ?? s + buildPaneForegroundAgentTabPrefixClearPatch( + s.paneForegroundAgentByPaneKey, + s.paneForegroundAgentObservationByPaneKey, + prefixes + ) ?? s ) }) } @@ -195,39 +200,55 @@ export const createPaneForegroundAgentSlice: StateCreator< /** Return process identity only while the evidence and its PTY are current. */ export function resolveFreshPaneForegroundAgent( entry: PaneForegroundAgentEntry | undefined, + observation: PaneForegroundAgentObservation | undefined, args: { now: number; paneBoundPtyId?: string; liveTabPtyIds?: readonly string[] } ): TuiAgent | null { - if (!entry?.agent || entry.shellForeground || entry.observedAt === undefined) { + if (!entry?.agent || entry.shellForeground || !observation) { return null } - if (args.now - entry.observedAt > PANE_FOREGROUND_AGENT_EVIDENCE_TTL_MS) { + if (args.now - observation.observedAt > PANE_FOREGROUND_AGENT_EVIDENCE_TTL_MS) { return null } if (args.paneBoundPtyId !== undefined) { - return entry.ptyId === undefined || entry.ptyId === args.paneBoundPtyId ? entry.agent : null + return observation.ptyId === undefined || observation.ptyId === args.paneBoundPtyId + ? entry.agent + : null } - if (entry.ptyId !== undefined) { - return args.liveTabPtyIds?.includes(entry.ptyId) === true ? entry.agent : null + if (observation.ptyId !== undefined) { + return args.liveTabPtyIds?.includes(observation.ptyId) === true ? entry.agent : null } return (args.liveTabPtyIds?.length ?? 0) > 0 ? entry.agent : null } export function buildPaneForegroundAgentTabPrefixClearPatch( entries: Record, + observations: Record, tabPrefixes: readonly string[] -): Pick | null { +): Pick< + PaneForegroundAgentSlice, + 'paneForegroundAgentByPaneKey' | 'paneForegroundAgentObservationByPaneKey' +> | null { if (tabPrefixes.length === 0) { return null } - const staleKeys = Object.keys(entries).filter((paneKey) => - tabPrefixes.some((prefix) => paneKey.startsWith(prefix)) - ) + const staleKeys = [ + ...new Set( + [...Object.keys(entries), ...Object.keys(observations)].filter((paneKey) => + tabPrefixes.some((prefix) => paneKey.startsWith(prefix)) + ) + ) + ] if (staleKeys.length === 0) { return null } const next = { ...entries } + const nextObservations = { ...observations } for (const paneKey of staleKeys) { delete next[paneKey] + delete nextObservations[paneKey] + } + return { + paneForegroundAgentByPaneKey: next, + paneForegroundAgentObservationByPaneKey: nextObservations } - return { paneForegroundAgentByPaneKey: next } } diff --git a/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts b/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts index cfdefcf87e0..98c58361123 100644 --- a/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts +++ b/src/renderer/src/store/slices/retired-terminal-tab-state-sweep.ts @@ -19,7 +19,7 @@ export type RetiredTerminalTabSweepActions = Pick< /** The state the sweep reduces over: the two store maps plus everything the * agent-status drop reads. Narrow so a non-store caller can pass its own view. */ export type RetiredTerminalTabSweepState = AgentStatusTabPrefixDropState & - Pick + Pick /** * The suppressor-aware store maps plus three module registries a retired terminal tab strands. @@ -77,6 +77,7 @@ export function buildRetiredTerminalTabStateSweepPatch( ) const foreground = buildPaneForegroundAgentTabPrefixClearPatch( swept.paneForegroundAgentByPaneKey, + swept.paneForegroundAgentObservationByPaneKey, [`${tabId}:`] ) swept = { ...swept, ...patch, ...foreground } diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 67414db82f2..f569325cd9f 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -104,6 +104,9 @@ export function buildWorktreePurgeState( agentLaunchConfigByPaneKey: omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey), acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey), paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey), + paneForegroundAgentObservationByPaneKey: omitByPaneKeyTabPrefix( + s.paneForegroundAgentObservationByPaneKey + ), sleepingAgentSessionsByPaneKey: omitByPaneKeyTabPrefix(s.sleepingAgentSessionsByPaneKey), unreadTerminalTabs: omitByTabId(s.unreadTerminalTabs), unreadTerminalPanes: omitByPaneKeyTabPrefix(s.unreadTerminalPanes),