diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index 5dff9ef7799..318d0d7a4ea 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -5,14 +5,16 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' +import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' import { makePaneKey } from '../../../../shared/stable-pane-id' import { buildWorktreeAgentRows } from './worktree-agent-rows' const ORPHAN_PANE_KEY = makePaneKey('tab-orphan', '11111111-1111-4111-8111-111111111111') -const PANE_KEY_1 = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222') +const LEAF_ID_1 = '22222222-2222-4222-8222-222222222222' +const LEAF_ID_1_SECOND = '77777777-7777-4777-8777-777777777777' +const PANE_KEY_1 = makePaneKey('tab-1', LEAF_ID_1) const PANE_KEY_2 = makePaneKey('tab-2', '33333333-3333-4333-8333-333333333333') const PANE_KEY_3 = makePaneKey('tab-3', '55555555-5555-4555-8555-555555555555') const PANE_KEY_4 = makePaneKey('tab-4', '66666666-6666-4666-8666-666666666666') @@ -59,6 +61,27 @@ function makeRetained(paneKey: string, worktreeId: string, startedAt: number): R } } +function makeSinglePaneLayout(leafId: string): TerminalLayoutSnapshot { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null + } +} + +function makeSplitPaneLayout(firstLeafId: string, secondLeafId: string): TerminalLayoutSnapshot { + return { + root: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: firstLeafId }, + second: { type: 'leaf', leafId: secondLeafId } + }, + activeLeafId: firstLeafId, + expandedLeafId: null + } +} + describe('buildWorktreeAgentRows', () => { it('includes retained rows even when their original tab is no longer current', () => { const rows = buildWorktreeAgentRows({ @@ -89,6 +112,44 @@ describe('buildWorktreeAgentRows', () => { expect(rows[0].startedAt).toBe(2000) }) + it('dedupes retained legacy numeric rows for a single current stable pane', () => { + const liveEntry = makeEntry(PANE_KEY_1, 2000, { + state: 'working', + agentType: 'copilot', + prompt: 'current turn' + }) + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1')], + entries: [liveEntry], + retained: [makeRetained('tab-1:1', 'wt-1', 1000), makeRetained('tab-1:2', 'wt-1', 1500)], + terminalLayoutsByTabId: { + 'tab-1': makeSinglePaneLayout(LEAF_ID_1) + }, + now: 3000 + }) + + expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY_1]) + }) + + it('keeps a retained legacy numeric row for a different split pane', () => { + const liveEntry = makeEntry(PANE_KEY_1, 2000, { + state: 'working', + agentType: 'copilot', + prompt: 'current turn' + }) + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1')], + entries: [liveEntry], + retained: [makeRetained('tab-1:1', 'wt-1', 1000), makeRetained('tab-1:2', 'wt-1', 1500)], + terminalLayoutsByTabId: { + 'tab-1': makeSplitPaneLayout(LEAF_ID_1, LEAF_ID_1_SECOND) + }, + now: 3000 + }) + + expect(rows.map((row) => row.paneKey)).toEqual(['tab-1:2', PANE_KEY_1]) + }) + it('decays a stale working entry to idle but leaves a stale done entry alone', () => { // Why: the freshness scheduler ticks agentStatusEpoch when an entry crosses // the stale boundary; the row state machine must collapse working/blocked/ diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index 15102a4ca5b..8f38aabc3ed 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -6,8 +6,17 @@ import { type AgentStatusEntry, type AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' -import { parsePaneKey } from '../../../../shared/stable-pane-id' -import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/types' +import { + makePaneKey, + parseLegacyNumericPaneKey, + parsePaneKey +} from '../../../../shared/stable-pane-id' +import type { + TerminalLayoutSnapshot, + TerminalPaneLayoutNode, + TerminalTab +} from '../../../../shared/types' +import { resolveRuntimePaneTitleLeafId } from './runtime-pane-title-leaf-id' import { buildTitleDerivedAgentRows } from './worktree-title-derived-agent-rows' function tabFromAttributedStatusEntry(entry: AgentStatusEntry): TerminalTab | null { @@ -70,6 +79,53 @@ function entryWithRuntimeOrchestration( return { ...entry, orchestration } } +function countTerminalLayoutLeaves(node: TerminalPaneLayoutNode | null | undefined): number { + if (!node) { + return 0 + } + if (node.type === 'leaf') { + return 1 + } + return countTerminalLayoutLeaves(node.first) + countTerminalLayoutLeaves(node.second) +} + +function seenStablePaneKeysForTab(seenPaneKeys: Set, tabId: string): string[] { + const keys: string[] = [] + for (const paneKey of seenPaneKeys) { + const parsed = parsePaneKey(paneKey) + if (parsed?.tabId === tabId) { + keys.push(paneKey) + } + } + return keys +} + +function isRetainedLegacyAliasOfSeenStablePane(args: { + paneKey: string + terminalLayoutsByTabId?: Record + seenPaneKeys: Set +}): boolean { + const legacy = parseLegacyNumericPaneKey(args.paneKey) + if (!legacy) { + return false + } + const stablePaneKeys = seenStablePaneKeysForTab(args.seenPaneKeys, legacy.tabId) + if (stablePaneKeys.length === 0) { + return false + } + + const layout = args.terminalLayoutsByTabId?.[legacy.tabId] + const leafId = resolveRuntimePaneTitleLeafId(layout, legacy.numericPaneId) + if (leafId) { + return args.seenPaneKeys.has(makePaneKey(legacy.tabId, leafId)) + } + + // Why: old PaneManager ids can advance across remounts/updates even for a + // single physical pane. Once the tab has exactly one current stable pane, + // retained numeric rows under that tab are stale aliases of it. + return countTerminalLayoutLeaves(layout?.root) === 1 && stablePaneKeys.length === 1 +} + export function buildWorktreeAgentRows(args: { tabs: TerminalTab[] entries: AgentStatusEntry[] @@ -152,6 +208,15 @@ export function buildWorktreeAgentRows(args: { if (seenPaneKeys.has(ra.entry.paneKey)) { continue } + if ( + isRetainedLegacyAliasOfSeenStablePane({ + paneKey: ra.entry.paneKey, + terminalLayoutsByTabId: args.terminalLayoutsByTabId, + seenPaneKeys + }) + ) { + continue + } const rowEntry = entryWithRuntimeOrchestration( ra.entry, args.runtimeAgentOrchestrationByPaneKey diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index 2d017c484f1..3a03be14f5d 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -1078,7 +1078,12 @@ describe('useIpcEvents updater integration', () => { leafId?: string splitFromLeafId?: string splitDirection?: 'horizontal' | 'vertical' - splitTelemetrySource?: 'contextual_tour' | 'keyboard' | 'context_menu' | 'command' | 'unknown' + splitTelemetrySource?: + | 'contextual_tour' + | 'keyboard' + | 'context_menu' + | 'command' + | 'unknown' }) => void) | null } = { current: null } @@ -1190,7 +1195,12 @@ describe('useIpcEvents updater integration', () => { leafId?: string splitFromLeafId?: string splitDirection?: 'horizontal' | 'vertical' - splitTelemetrySource?: 'contextual_tour' | 'keyboard' | 'context_menu' | 'command' | 'unknown' + splitTelemetrySource?: + | 'contextual_tour' + | 'keyboard' + | 'context_menu' + | 'command' + | 'unknown' }) => void ) => { createTerminalListenerRef.current = listener @@ -2501,6 +2511,15 @@ describe('useIpcEvents agent status snapshot integration', () => { toolInput?: string lastAssistantMessage?: string interrupted?: boolean + terminalHandle?: string + orchestration?: { + taskId?: string + dispatchId?: string + parentTerminalHandle?: string + parentPaneKey?: string + coordinatorHandle?: string + orchestrationRunId?: string + } connectionId?: string | null receivedAt: number stateStartedAt: number @@ -3993,6 +4012,140 @@ describe('useIpcEvents agent status snapshot integration', () => { expect(setAgentStatus).not.toHaveBeenCalled() }) + it('silently discards stale worktree-attributed snapshots for unknown panes', async () => { + const setAgentStatus = vi.fn() + const getSnapshot = vi.fn(() => + Promise.resolve([ + { + paneKey: ORPHAN_PANE_KEY, + state: 'done' as const, + prompt: 'old copilot turn', + agentType: 'copilot', + worktreeId: 'wt-1', + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + } + ]) + ) + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + repos: [{ id: 'repo-1', connectionId: null }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Copilot' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + }, + workspaceSessionReady: true + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot, + onSet: () => () => {} + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + await Promise.resolve() + + expect(setAgentStatus).not.toHaveBeenCalled() + }) + + it('applies worktree-attributed child snapshots when runtime identity is present', async () => { + const setAgentStatus = vi.fn() + const getSnapshot = vi.fn(() => + Promise.resolve([ + { + paneKey: ORPHAN_PANE_KEY, + state: 'working' as const, + prompt: 'child task', + agentType: 'codex', + worktreeId: 'wt-1', + terminalHandle: 'term-child', + orchestration: { + taskId: 'task-child', + dispatchId: 'dispatch-child', + parentTerminalHandle: 'term-parent' + }, + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + } + ]) + ) + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + repos: [{ id: 'repo-1', connectionId: null }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1' }] }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + }, + workspaceSessionReady: true + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + getSnapshot, + onSet: () => () => {} + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + await Promise.resolve() + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatus).toHaveBeenCalledWith( + ORPHAN_PANE_KEY, + expect.objectContaining({ + state: 'working', + prompt: 'child task', + agentType: 'codex', + orchestration: expect.objectContaining({ taskId: 'task-child' }) + }), + undefined, + { updatedAt: 1_700_000_000_000, stateStartedAt: 1_699_999_999_000 }, + expect.objectContaining({ worktreeId: 'wt-1', terminalHandle: 'term-child' }) + ) + }) + it('silently discards valid paneKeys whose leaf is not in the current layout', async () => { const setAgentStatus = vi.fn() const getSnapshot = vi.fn(() => diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 9c61c105394..e1402b7865b 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -1219,16 +1219,18 @@ export function useIpcEvents(): void { ) unsubs.push( - window.api.ui.onSplitTerminal(({ tabId, paneRuntimeId, direction, command, telemetrySource }) => { - const detail: SplitTerminalPaneDetail = { - tabId, - paneRuntimeId, - direction, - command, - telemetrySource + window.api.ui.onSplitTerminal( + ({ tabId, paneRuntimeId, direction, command, telemetrySource }) => { + const detail: SplitTerminalPaneDetail = { + tabId, + paneRuntimeId, + direction, + command, + telemetrySource + } + window.dispatchEvent(new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { detail })) } - window.dispatchEvent(new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { detail })) - }) + ) ) unsubs.push( @@ -2255,11 +2257,11 @@ export function useIpcEvents(): void { repoConnectionResolved, owningWorktreeId } = resolvePaneKey(store, data.paneKey) - if (!exists && data.worktreeId) { + if (!exists && data.worktreeId && hasRuntimeBackedWorktreeAttribution(data)) { // Why: orchestration worker hooks can carry main-side worktree // attribution before this renderer has a terminal tab for the pane. - // Accept those only when the worktree is known, then keep the normal - // repo connection check below for SSH/local ownership. + // Require runtime identity too; durable snapshots with only worktreeId + // can be stale cached rows from closed/remounted panes. const fallbackOwnership = resolveWorktreeConnection(store, data.worktreeId) if (fallbackOwnership.worktreeExists) { owningWorktreeId = data.worktreeId @@ -2610,6 +2612,13 @@ export function useIpcEvents(): void { }, []) } +function hasRuntimeBackedWorktreeAttribution(data: AgentStatusIpcPayload): boolean { + return ( + (typeof data.terminalHandle === 'string' && data.terminalHandle.length > 0) || + data.orchestration !== undefined + ) +} + function applyResolvedAgentTerminalTitleToTab( store: ReturnType, paneKey: string,