diff --git a/src/main/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 711cf3982ff..9cee0fee966 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -15,6 +15,7 @@ const handleHandlers = new Map u const removeHandler = vi.fn() const removeAllListeners = vi.fn() const PANE_KEY = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') +const CHILD_PANE_KEY = makePaneKey('tab-2', '22222222-2222-4222-8222-222222222222') vi.mock('electron', () => ({ ipcMain: { @@ -114,6 +115,68 @@ describe('agentStatus:getSnapshot IPC', () => { expect(handler).toBeDefined() expect(handler!({})).toEqual(snapshot) }) + + it('enriches the hook cache snapshot with runtime lineage metadata', async () => { + const snapshot = [ + { + paneKey: PANE_KEY, + state: 'done', + prompt: 'parent', + agentType: 'codex', + connectionId: null, + receivedAt: 1_700_000_000_000, + stateStartedAt: 1_699_999_999_000 + }, + { + paneKey: CHILD_PANE_KEY, + state: 'done', + prompt: 'child', + agentType: 'codex', + connectionId: null, + receivedAt: 1_700_000_001_000, + stateStartedAt: 1_700_000_000_500 + } + ] + getStatusSnapshot.mockReturnValue(snapshot) + const runtime = { + getAgentStatusTerminalHandleForPaneKey: vi.fn((paneKey: string) => + paneKey === PANE_KEY ? 'term-parent' : paneKey === CHILD_PANE_KEY ? 'term-child' : undefined + ), + getAgentStatusOrchestrationContextForPaneKey: vi.fn((paneKey: string) => + paneKey === CHILD_PANE_KEY + ? { + taskId: 'task-child', + dispatchId: 'dispatch-child', + parentTerminalHandle: 'term-parent', + parentPaneKey: PANE_KEY, + coordinatorHandle: 'term-parent' + } + : undefined + ) + } + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers(runtime) + + const handler = handleHandlers.get('agentStatus:getSnapshot') + expect(handler).toBeDefined() + expect(handler!({})).toEqual([ + { + ...snapshot[0], + terminalHandle: 'term-parent' + }, + { + ...snapshot[1], + terminalHandle: 'term-child', + orchestration: { + taskId: 'task-child', + dispatchId: 'dispatch-child', + parentTerminalHandle: 'term-parent', + parentPaneKey: PANE_KEY, + coordinatorHandle: 'term-parent' + } + } + ]) + }) }) describe('agentHooks:antigravityStatus IPC', () => { diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 7637fd0d25d..8c08054859f 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -5,6 +5,7 @@ import type { MigrationUnsupportedPtyEntry } from '../../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { agentHookServer, isValidPaneKey } from '../agent-hooks/server' import { ampHookService } from '../amp/hook-service' import { @@ -23,11 +24,32 @@ import { copilotHookService } from '../copilot/hook-service' import { hermesHookService } from '../hermes/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +type AgentStatusRuntimeEnrichment = Pick< + OrcaRuntimeService, + 'getAgentStatusTerminalHandleForPaneKey' | 'getAgentStatusOrchestrationContextForPaneKey' +> + +function enrichAgentStatusIpcPayload( + data: AgentStatusIpcPayload, + runtime: AgentStatusRuntimeEnrichment | undefined +): AgentStatusIpcPayload { + if (!runtime) { + return data + } + const terminalHandle = runtime.getAgentStatusTerminalHandleForPaneKey(data.paneKey) + const orchestration = runtime.getAgentStatusOrchestrationContextForPaneKey(data.paneKey) + return { + ...data, + ...(terminalHandle ? { terminalHandle } : {}), + ...(orchestration ? { orchestration } : {}) + } +} + // Why: install/remove are intentionally not exposed to the renderer. Orca // auto-installs managed hooks at app startup (see src/main/index.ts), so a // renderer-triggered remove would be silently reverted on the next launch // and mislead the user. -export function registerAgentHookHandlers(): void { +export function registerAgentHookHandlers(runtime?: AgentStatusRuntimeEnrichment): void { // Why: matches the defensive pattern in src/main/ipc/pty.ts so re-registration // never throws "Attempted to register a second handler..." if this function is // ever invoked more than once (e.g. the macOS app re-activation path that @@ -71,8 +93,11 @@ export function registerAgentHookHandlers(): void { }) ipcMain.handle('agentStatus:getSnapshot', (): AgentStatusIpcPayload[] => { // Why: the renderer pulls this after workspace hydration, so startup cannot - // lose replayed statuses while its local store is still empty. - return agentHookServer.getStatusSnapshot() + // lose replayed statuses while its local store is still empty. Match the + // live push enrichment in main/index.ts so parent/child rows survive replay. + return agentHookServer + .getStatusSnapshot() + .map((entry) => enrichAgentStatusIpcPayload(entry, runtime)) }) ipcMain.handle('agentStatus:inferInterrupt', (_event, request: unknown): boolean => { if (typeof request !== 'object' || request === null) { diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 0a0fb44cc04..ee7a90f07f1 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -357,7 +357,7 @@ describe('registerCoreHandlers', () => { expect(registerOpenCodeUsageHandlersMock).toHaveBeenCalledWith(openCodeUsage) expect(registerAppHandlersMock).toHaveBeenCalledWith(store, { onBeforeRelaunch }) expect(registerCodexAccountHandlersMock).toHaveBeenCalledWith(codexAccounts) - expect(registerAgentHookHandlersMock).toHaveBeenCalled() + expect(registerAgentHookHandlersMock).toHaveBeenCalledWith(runtime) expect(registerPetHandlersMock).toHaveBeenCalled() expect(registerClaudeAccountHandlersMock).toHaveBeenCalledWith(claudeAccounts) expect(registerRateLimitHandlersMock).toHaveBeenCalledWith(rateLimits) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 53e676f6106..3aeaa9b4332 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -103,7 +103,7 @@ export function registerCoreHandlers( registerCodexUsageHandlers(codexUsage) registerOpenCodeUsageHandlers(openCodeUsage) registerCodexAccountHandlers(codexAccounts) - registerAgentHookHandlers() + registerAgentHookHandlers(runtime) registerAgentTrustHandlers() registerClaudeAccountHandlers(claudeAccounts) registerRateLimitHandlers(rateLimits) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 26ba3049daf..a166328c679 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -7869,6 +7869,62 @@ describe('OrcaRuntimeService', () => { }) }) + it('does not attach an unrelated active coordinator run to a completed dispatch', () => { + const runtime = new OrcaRuntimeService(store) + const workerLeafId = '55555555-5555-4555-8555-555555555555' + const workerPaneKey = makePaneKey('tab-worker', workerLeafId) + const workerHandle = runtime.preAllocateHandleForPty('pty-worker') + runtime.setOrchestrationDb({ + getActiveDispatchForTerminal: vi.fn(() => undefined), + getLatestDispatchForTerminal: vi.fn((handle: string) => + handle === workerHandle + ? { + id: 'ctx-done', + task_id: 'task-done', + assignee_handle: workerHandle, + status: 'completed', + completed_at: new Date(Date.now()).toISOString() + } + : undefined + ), + getTask: vi.fn(() => ({ + id: 'task-done' + })), + getActiveCoordinatorRun: vi.fn(() => ({ + id: 'run-unrelated', + coordinator_handle: 'term_unrelated' + })) + } as never) + runtime.attachWindow(1) + + const result = runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-worker', + worktreeId: TEST_WORKTREE_ID, + title: 'Claude Code', + activeLeafId: workerLeafId, + layout: null + } + ], + leaves: [ + { + tabId: 'tab-worker', + worktreeId: TEST_WORKTREE_ID, + leafId: workerLeafId, + paneRuntimeId: 1, + ptyId: 'pty-worker', + paneTitle: null + } + ] + }) + + expect(result.agentOrchestrationByPaneKey?.[workerPaneKey]).toEqual({ + taskId: 'task-done', + dispatchId: 'ctx-done' + }) + }) + it('does not return stale completed orchestration context for renderer-synced terminal leaves', () => { const runtime = new OrcaRuntimeService(store) const workerLeafId = '77777777-7777-4777-8777-777777777777' diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 33b0ea54d23..30e9cfa99ad 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -11561,7 +11561,7 @@ export class OrcaRuntimeService { return undefined } const task = db?.getTask?.(dispatch.task_id) - const activeRun = db?.getActiveCoordinatorRun?.() + const activeRun = dispatch.status === 'completed' ? undefined : db?.getActiveCoordinatorRun?.() const parentTerminalHandle = task?.created_by_terminal_handle ?? (activeRun?.coordinator_handle && activeRun.coordinator_handle !== handle diff --git a/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts b/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts new file mode 100644 index 00000000000..028670a91a3 --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-row-lineage-model.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { TerminalTab } from '../../../../shared/types' +import type { DashboardAgentRow } from './useDashboardData' +import { buildAgentRowLineageTree } from './agent-row-lineage-model' + +function makeTab(id: string): TerminalTab { + return { + id, + worktreeId: 'wt-1', + ptyId: null, + title: 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } +} + +function makeRow( + paneKey: string, + options: { + terminalHandle?: string + parentPaneKey?: string + parentTerminalHandle?: string + coordinatorHandle?: string + } = {} +): DashboardAgentRow { + const orchestration = + options.parentPaneKey || options.parentTerminalHandle || options.coordinatorHandle + ? { + taskId: `${paneKey}-task`, + dispatchId: `${paneKey}-dispatch`, + ...(options.parentPaneKey ? { parentPaneKey: options.parentPaneKey } : {}), + ...(options.parentTerminalHandle + ? { parentTerminalHandle: options.parentTerminalHandle } + : {}), + ...(options.coordinatorHandle ? { coordinatorHandle: options.coordinatorHandle } : {}) + } + : undefined + const entry: AgentStatusEntry = { + paneKey, + state: 'done', + prompt: paneKey, + updatedAt: 1000, + stateStartedAt: 1000, + stateHistory: [], + agentType: 'codex', + ...(options.terminalHandle ? { terminalHandle: options.terminalHandle } : {}), + ...(orchestration ? { orchestration } : {}) + } + + return { + paneKey, + entry, + tab: makeTab(paneKey.split(':')[0] ?? paneKey), + agentType: 'codex', + state: 'done', + startedAt: 1000 + } +} + +describe('buildAgentRowLineageTree', () => { + it('groups children by explicit parent pane key', () => { + const parent = makeRow('parent:1') + const child = makeRow('child:1', { parentPaneKey: 'parent:1' }) + + const tree = buildAgentRowLineageTree([parent, child]) + + expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['parent:1']) + expect(tree.childrenByParentPaneKey.get('parent:1')?.map((row) => row.paneKey)).toEqual([ + 'child:1' + ]) + }) + + it('falls back to the parent terminal handle when the parent pane key is absent', () => { + const parent = makeRow('parent:1', { terminalHandle: 'term-parent' }) + const child = makeRow('child:1', { parentTerminalHandle: 'term-parent' }) + + const tree = buildAgentRowLineageTree([parent, child]) + + expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['parent:1']) + expect(tree.childrenByParentPaneKey.get('parent:1')?.map((row) => row.paneKey)).toEqual([ + 'child:1' + ]) + }) + + it('uses the coordinator handle as the last visible parent fallback', () => { + const parent = makeRow('parent:1', { terminalHandle: 'term-coordinator' }) + const child = makeRow('child:1', { coordinatorHandle: 'term-coordinator' }) + + const tree = buildAgentRowLineageTree([parent, child]) + + expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['parent:1']) + expect(tree.childrenByParentPaneKey.get('parent:1')?.map((row) => row.paneKey)).toEqual([ + 'child:1' + ]) + }) + + it('keeps cyclic lineage rows visible as flat roots', () => { + const root = makeRow('root:1') + const firstCycleRow = makeRow('cycle-a:1', { parentPaneKey: 'cycle-b:1' }) + const secondCycleRow = makeRow('cycle-b:1', { parentPaneKey: 'cycle-a:1' }) + + const tree = buildAgentRowLineageTree([root, firstCycleRow, secondCycleRow]) + + expect(tree.rootRows.map((row) => row.paneKey)).toEqual(['root:1', 'cycle-a:1', 'cycle-b:1']) + expect(tree.childrenByParentPaneKey.size).toBe(0) + expect(tree.childPaneKeys.size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/dashboard/agent-row-lineage-model.ts b/src/renderer/src/components/dashboard/agent-row-lineage-model.ts new file mode 100644 index 00000000000..cb1cf336346 --- /dev/null +++ b/src/renderer/src/components/dashboard/agent-row-lineage-model.ts @@ -0,0 +1,129 @@ +import type { DashboardAgentRow } from './useDashboardData' + +export type AgentRowLineageTree = { + rootRows: T[] + childrenByParentPaneKey: Map + childPaneKeys: Set +} + +function buildPaneKeyByTerminalHandle( + rows: readonly T[] +): Map { + const paneKeyByTerminalHandle = new Map() + for (const row of rows) { + if (row.entry.terminalHandle && !paneKeyByTerminalHandle.has(row.entry.terminalHandle)) { + paneKeyByTerminalHandle.set(row.entry.terminalHandle, row.paneKey) + } + } + return paneKeyByTerminalHandle +} + +export function resolveAgentRowParentPaneKey( + row: T, + rowsByPaneKey: ReadonlyMap, + paneKeyByTerminalHandle: ReadonlyMap +): string | undefined { + const explicitParentPaneKey = row.entry.orchestration?.parentPaneKey + if ( + explicitParentPaneKey && + explicitParentPaneKey !== row.paneKey && + rowsByPaneKey.has(explicitParentPaneKey) + ) { + return explicitParentPaneKey + } + + const parentTerminalHandles = [ + row.entry.orchestration?.parentTerminalHandle, + row.entry.orchestration?.coordinatorHandle + ] + for (const parentTerminalHandle of parentTerminalHandles) { + const parentPaneKey = parentTerminalHandle + ? paneKeyByTerminalHandle.get(parentTerminalHandle) + : undefined + if (parentPaneKey && parentPaneKey !== row.paneKey && rowsByPaneKey.has(parentPaneKey)) { + return parentPaneKey + } + } + + return undefined +} + +export function buildAgentRowLineageTree( + rows: readonly T[] +): AgentRowLineageTree { + const rowsByPaneKey = new Map() + for (const row of rows) { + if (!rowsByPaneKey.has(row.paneKey)) { + rowsByPaneKey.set(row.paneKey, row) + } + } + const paneKeyByTerminalHandle = buildPaneKeyByTerminalHandle(rows) + const childrenByParentPaneKey = new Map() + const childPaneKeys = new Set() + + for (const row of rows) { + const parentPaneKey = resolveAgentRowParentPaneKey(row, rowsByPaneKey, paneKeyByTerminalHandle) + if (!parentPaneKey) { + continue + } + childPaneKeys.add(row.paneKey) + const siblings = childrenByParentPaneKey.get(parentPaneKey) + if (siblings) { + siblings.push(row) + } else { + childrenByParentPaneKey.set(parentPaneKey, [row]) + } + } + + const rootRows = rows.filter((row) => !childPaneKeys.has(row.paneKey)) + if (rootRows.length === 0 && rows.length > 0) { + // Why: malformed orchestration metadata can form a closed cycle. Keep every + // row visible as a flat root instead of hiding all participants. + return { rootRows: [...rows], childrenByParentPaneKey: new Map(), childPaneKeys: new Set() } + } + + const reachablePaneKeys = new Set() + const markReachable = (row: T, ancestorPaneKeys: ReadonlySet = new Set()): void => { + if (reachablePaneKeys.has(row.paneKey) || ancestorPaneKeys.has(row.paneKey)) { + return + } + reachablePaneKeys.add(row.paneKey) + const descendantAncestorPaneKeys = new Set(ancestorPaneKeys) + descendantAncestorPaneKeys.add(row.paneKey) + for (const childRow of childrenByParentPaneKey.get(row.paneKey) ?? []) { + markReachable(childRow, descendantAncestorPaneKeys) + } + } + for (const rootRow of rootRows) { + markReachable(rootRow) + } + + const unreachableRows = rows.filter((row) => !reachablePaneKeys.has(row.paneKey)) + if (unreachableRows.length === 0) { + return { rootRows, childrenByParentPaneKey, childPaneKeys } + } + + const normalizedChildrenByParentPaneKey = new Map(childrenByParentPaneKey) + const normalizedChildPaneKeys = new Set(childPaneKeys) + for (const row of unreachableRows) { + if (!rootRows.some((rootRow) => rootRow.paneKey === row.paneKey)) { + rootRows.push(row) + } + normalizedChildPaneKeys.delete(row.paneKey) + normalizedChildrenByParentPaneKey.delete(row.paneKey) + for (const [parentPaneKey, siblings] of normalizedChildrenByParentPaneKey) { + const visibleSiblings = siblings.filter((sibling) => sibling.paneKey !== row.paneKey) + if (visibleSiblings.length === 0) { + normalizedChildrenByParentPaneKey.delete(parentPaneKey) + } else if (visibleSiblings.length !== siblings.length) { + normalizedChildrenByParentPaneKey.set(parentPaneKey, visibleSiblings) + } + } + } + + return { + rootRows, + childrenByParentPaneKey: normalizedChildrenByParentPaneKey, + childPaneKeys: normalizedChildPaneKeys + } +} diff --git a/src/renderer/src/components/dashboard/agent-row-lineage.ts b/src/renderer/src/components/dashboard/agent-row-lineage.ts index 8d8d70a2b4d..38efc312a67 100644 --- a/src/renderer/src/components/dashboard/agent-row-lineage.ts +++ b/src/renderer/src/components/dashboard/agent-row-lineage.ts @@ -1,4 +1,5 @@ import type { DashboardAgentRow } from './useDashboardData' +import { buildAgentRowLineageTree } from './agent-row-lineage-model' export type AgentRowLineagePresentation = { depth: 0 | 1 @@ -23,35 +24,7 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR return rows.map((row) => ({ ...row, lineage: ROOT_LINEAGE })) } - const rowsByPaneKey = new Map(rows.map((row) => [row.paneKey, row])) - const paneKeyByTerminalHandle = new Map() - for (const row of rows) { - if (row.entry.terminalHandle && !paneKeyByTerminalHandle.has(row.entry.terminalHandle)) { - paneKeyByTerminalHandle.set(row.entry.terminalHandle, row.paneKey) - } - } - const childrenByParentPaneKey = new Map() - const childPaneKeys = new Set() - - for (const row of rows) { - const explicitParentPaneKey = row.entry.orchestration?.parentPaneKey - const parentPaneKey = - explicitParentPaneKey && rowsByPaneKey.has(explicitParentPaneKey) - ? explicitParentPaneKey - : row.entry.orchestration?.parentTerminalHandle - ? paneKeyByTerminalHandle.get(row.entry.orchestration.parentTerminalHandle) - : undefined - if (!parentPaneKey || parentPaneKey === row.paneKey || !rowsByPaneKey.has(parentPaneKey)) { - continue - } - childPaneKeys.add(row.paneKey) - const siblings = childrenByParentPaneKey.get(parentPaneKey) - if (siblings) { - siblings.push(row) - } else { - childrenByParentPaneKey.set(parentPaneKey, [row]) - } - } + const { rootRows, childrenByParentPaneKey, childPaneKeys } = buildAgentRowLineageTree(rows) if (childPaneKeys.size === 0) { return rows.map((row) => ({ ...row, lineage: ROOT_LINEAGE })) @@ -86,10 +59,7 @@ export function applyAgentRowLineage(rows: DashboardAgentRow[]): DashboardAgentR }) } - for (const row of rows) { - if (childPaneKeys.has(row.paneKey)) { - continue - } + for (const row of rootRows) { emitSubtree(row, ROOT_LINEAGE) } diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index d845ec1ac46..bd9d8fe3556 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -15,7 +15,11 @@ type MockAgentOptions = { lastAssistantMessage?: string stateStartedAt?: number terminalHandle?: string - orchestration?: { parentPaneKey?: string; parentTerminalHandle?: string } + orchestration?: { + parentPaneKey?: string + parentTerminalHandle?: string + coordinatorHandle?: string + } lineage?: { depth: number isFirstSibling: boolean @@ -246,6 +250,30 @@ describe('WorktreeCardAgents', () => { expect(markup).toContain('aria-label="Hide 1 child agent"') }) + it('shows orchestration children under a visible coordinator when parent handle is absent', async () => { + mockAgentActivityDisplayMode = 'full' + mockAgents = [ + mockAgent({ + paneKey: 'tab-parent:1', + terminalHandle: 'term-coordinator' + }), + mockAgent({ + paneKey: 'tab-child:1', + state: 'done', + stateStartedAt: 1500, + orchestration: { coordinatorHandle: 'term-coordinator' } + }) + ] + const { default: WorktreeCardAgents } = await import('./WorktreeCardAgents') + + const markup = renderToStaticMarkup() + + expect(markup).toContain('role="tree"') + expect(markup).toContain('data-pane-key="tab-parent:1"') + expect(markup).toContain('data-pane-key="tab-child:1"') + expect(markup).toContain('aria-label="Hide 1 child agent"') + }) + it('keeps partially cyclic orchestration rows visible as flat roots', async () => { mockAgentActivityDisplayMode = 'full' mockAgents = [ diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index 6108858e25f..fcce9b8711b 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -19,6 +19,7 @@ import { CompactAgentRow, CompactAgentSummaryButton } from './worktree-card-compact-agents' +import { buildAgentRowLineageTree } from '@/components/dashboard/agent-row-lineage-model' import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constants' import { revealElementInScrollContainer } from './worktree-sidebar-reveal' @@ -74,82 +75,6 @@ type BodyProps = { className?: string } -type AgentLineageModel = { - rootAgents: DashboardAgentRowData[] - childrenByParentPaneKey: Map -} - -function buildAgentLineageModel(agents: DashboardAgentRowData[]): AgentLineageModel { - const agentPaneKeys = new Set(agents.map((agent) => agent.paneKey)) - const paneKeyByTerminalHandle = new Map() - for (const agent of agents) { - if (agent.entry.terminalHandle && !paneKeyByTerminalHandle.has(agent.entry.terminalHandle)) { - paneKeyByTerminalHandle.set(agent.entry.terminalHandle, agent.paneKey) - } - } - const childrenByParentPaneKey = new Map() - const childPaneKeys = new Set() - - for (const agent of agents) { - const explicitParentPaneKey = agent.entry.orchestration?.parentPaneKey - const parentPaneKey = - explicitParentPaneKey && agentPaneKeys.has(explicitParentPaneKey) - ? explicitParentPaneKey - : agent.entry.orchestration?.parentTerminalHandle - ? paneKeyByTerminalHandle.get(agent.entry.orchestration.parentTerminalHandle) - : undefined - if (!parentPaneKey || parentPaneKey === agent.paneKey || !agentPaneKeys.has(parentPaneKey)) { - continue - } - childPaneKeys.add(agent.paneKey) - const siblings = childrenByParentPaneKey.get(parentPaneKey) - if (siblings) { - siblings.push(agent) - } else { - childrenByParentPaneKey.set(parentPaneKey, [agent]) - } - } - - const rootAgents = agents.filter((agent) => !childPaneKeys.has(agent.paneKey)) - if (rootAgents.length === 0 && agents.length > 0) { - // Why: malformed orchestration metadata can theoretically form a cycle. - // Keep every row visible instead of recursing forever or hiding the list. - return { rootAgents: agents, childrenByParentPaneKey: new Map() } - } - - const reachablePaneKeys = new Set() - const markReachable = ( - agent: DashboardAgentRowData, - ancestorPaneKeys: ReadonlySet = new Set() - ): void => { - if (reachablePaneKeys.has(agent.paneKey) || ancestorPaneKeys.has(agent.paneKey)) { - return - } - reachablePaneKeys.add(agent.paneKey) - const descendantAncestorPaneKeys = new Set(ancestorPaneKeys) - descendantAncestorPaneKeys.add(agent.paneKey) - for (const childAgent of childrenByParentPaneKey.get(agent.paneKey) ?? []) { - markReachable(childAgent, descendantAncestorPaneKeys) - } - } - for (const rootAgent of rootAgents) { - markReachable(rootAgent) - } - - for (const agent of agents) { - if (reachablePaneKeys.has(agent.paneKey)) { - continue - } - // Why: a partial cycle alongside a valid root has no true root, so it - // would otherwise disappear. Render malformed participants as flat rows - // and drop their child edges, matching the dashboard lineage fallback. - rootAgents.push(agent) - childrenByParentPaneKey.delete(agent.paneKey) - } - - return { rootAgents, childrenByParentPaneKey } -} - const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ worktreeId, agents, @@ -287,8 +212,8 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ // never mount this component (see WorktreeCardAgents), so idle worktrees // don't pay any timer cost. const now = useNow(30_000) - const { rootAgents, childrenByParentPaneKey } = useMemo( - () => buildAgentLineageModel(agents), + const { rootRows: rootAgents, childrenByParentPaneKey } = useMemo( + () => buildAgentRowLineageTree(agents), [agents] ) const hasLineage = childrenByParentPaneKey.size > 0 diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts index bf6eec11f2c..5dff9ef7799 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.test.ts @@ -169,49 +169,49 @@ describe('buildWorktreeAgentRows', () => { expect(rows[1].entry.orchestration).toMatchObject({ parentPaneKey: PANE_KEY_1 }) }) - it('uses fresh runtime orchestration metadata over stale hook-reported dispatch identity', () => { - const staleParent = makeEntry(PANE_KEY_1, 1000, { - prompt: 'stale parent', + it('does not override current hook dispatch identity with mismatched runtime metadata', () => { + const currentParent = makeEntry(PANE_KEY_1, 1000, { + prompt: 'current parent', state: 'done' }) - const currentParent = makeEntry(PANE_KEY_3, 1100, { - prompt: 'current parent', + const staleParent = makeEntry(PANE_KEY_3, 1100, { + prompt: 'stale parent', state: 'working' }) const child = makeEntry(PANE_KEY_2, 1200, { prompt: 'child', state: 'working', orchestration: { - taskId: 'task-1', - dispatchId: 'ctx-1', + taskId: 'task-2', + dispatchId: 'ctx-2', parentPaneKey: PANE_KEY_1, - parentTerminalHandle: 'term-stale' + parentTerminalHandle: 'term-current' } }) const rows = applyAgentRowLineage( buildWorktreeAgentRows({ tabs: [makeTab('tab-1'), makeTab('tab-2'), makeTab('tab-3')], - entries: [staleParent, currentParent, child], + entries: [currentParent, staleParent, child], retained: [], runtimeAgentOrchestrationByPaneKey: { [PANE_KEY_2]: { - taskId: 'task-2', - dispatchId: 'ctx-2', + taskId: 'task-1', + dispatchId: 'ctx-1', parentPaneKey: PANE_KEY_3, - parentTerminalHandle: 'term-current' + parentTerminalHandle: 'term-stale' } }, now: 2000 }) ) - expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY_1, PANE_KEY_3, PANE_KEY_2]) - expect(rows[1].lineage).toMatchObject({ depth: 0, childCount: 1 }) - expect(rows[2].lineage).toMatchObject({ depth: 1, childCount: 0 }) - expect(rows[2].entry.orchestration).toEqual({ + expect(rows.map((row) => row.paneKey)).toEqual([PANE_KEY_1, PANE_KEY_2, PANE_KEY_3]) + expect(rows[0].lineage).toMatchObject({ depth: 0, childCount: 1 }) + expect(rows[1].lineage).toMatchObject({ depth: 1, childCount: 0 }) + expect(rows[1].entry.orchestration).toEqual({ taskId: 'task-2', dispatchId: 'ctx-2', - parentPaneKey: PANE_KEY_3, + parentPaneKey: PANE_KEY_1, parentTerminalHandle: 'term-current' }) }) diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index e664e9f5580..15102a4ca5b 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -51,6 +51,9 @@ function entryWithRuntimeOrchestration( runtimeOrchestration && entry.orchestration.taskId === runtimeOrchestration.taskId && entry.orchestration.dispatchId === runtimeOrchestration.dispatchId + if (entry.orchestration && runtimeOrchestration && !sameDispatch) { + return entry + } const orchestration = sameDispatch && entry.orchestration && runtimeOrchestration ? { ...entry.orchestration, ...runtimeOrchestration } diff --git a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts index 79d4786fec9..e5a678ad82b 100644 --- a/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts +++ b/src/renderer/src/components/sidebar/worktree-card-agent-summary.ts @@ -69,11 +69,6 @@ export function buildSummaryAgentGroups(agents: DashboardAgentRowData[]): Summar }) } -export function formatSummaryGroupLabel(group: SummaryAgentGroup): string { - const label = formatSummaryStateLabel(group.state) - return group.agents.length === 1 ? label : `${group.agents.length} ${label}` -} - export function summarizeAgents(agents: DashboardAgentRowData[], subjectLabel: string): string { const counts = new Map() for (const agent of agents) { diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 0f6b78de69e..426c994628f 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -4,7 +4,6 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalTab } from '../../../../shared/types' import type { AppState } from '../types' import type { RetainedAgentEntry } from './agent-status' import { createTestStore, makeTab, makeWorktree } from './store-test-helpers' @@ -363,7 +362,7 @@ describe('agent status runtime orchestration metadata', () => { const retained: RetainedAgentEntry = { entry, worktreeId: 'wt-1', - tab: { id: 'tab-child', title: 'codex' } as unknown as TerminalTab, + tab: makeTab({ id: 'tab-child', worktreeId: 'wt-1', title: 'codex' }), agentType: 'codex', startedAt: now } @@ -696,6 +695,47 @@ describe('agent status PR refresh handoff', () => { }) }) + it('uses hook worktree attribution for PR refresh when the agent tab is not mounted', async () => { + vi.useFakeTimers() + const enqueuePRRefresh = stubGitHubPRRefreshApi() + const store = createTestStore() + seedAgentPRRefreshFixture(store, ['pr']) + store.setState({ tabsByWorktree: { 'wt-1': [] } } as Partial) + const paneKey = 'tab-worker:11111111-1111-4111-8111-111111111111' + + store + .getState() + .setAgentStatus( + paneKey, + { state: 'working', prompt: 'create a PR', agentType: 'codex' }, + undefined, + undefined, + { tabId: 'tab-worker', worktreeId: 'wt-1', terminalHandle: 'term-worker' } + ) + store + .getState() + .setAgentStatus( + paneKey, + { state: 'done', prompt: 'create a PR', agentType: 'codex' }, + undefined, + undefined, + { tabId: 'tab-worker', worktreeId: 'wt-1', terminalHandle: 'term-worker' } + ) + + await flushMicrotasks() + + expect(enqueuePRRefresh).toHaveBeenCalledWith({ + candidate: expect.objectContaining({ + repoPath: '/repo', + branch: 'feature/pr-from-agent', + worktreeId: 'wt-1', + linkedPRNumber: null + }), + reason: 'active', + priority: 80 + }) + }) + it('does not spend a PR refresh when no PR surface is visible', async () => { vi.useFakeTimers() const enqueuePRRefresh = stubGitHubPRRefreshApi() @@ -881,14 +921,14 @@ describe('agent status retention + prefix sweep', () => { const retainedA: RetainedAgentEntry = { entry: entryA, worktreeId: 'wt-a', - tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab, + tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }), agentType: 'claude', startedAt: now } const retainedB: RetainedAgentEntry = { entry: entryB, worktreeId: 'wt-b', - tab: { id: 'tab-b', title: 'claude' } as unknown as TerminalTab, + tab: makeTab({ id: 'tab-b', worktreeId: 'wt-b', title: 'claude' }), agentType: 'claude', startedAt: now } @@ -931,14 +971,14 @@ describe('agent status retention + prefix sweep', () => { const retainedA: RetainedAgentEntry = { entry: entryA, worktreeId: 'wt-a', - tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab, + tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }), agentType: 'claude', startedAt: now } const retainedB: RetainedAgentEntry = { entry: entryB, worktreeId: 'wt-a', - tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab, + tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }), agentType: 'claude', startedAt: now } @@ -958,6 +998,35 @@ describe('agent status retention + prefix sweep', () => { expect(suppressed['tab-a:1']).toBeUndefined() }) + it('dropAgentStatusByWorktree removes live entries attributed before their tab exists', () => { + vi.useFakeTimers() + const store = createTestStore() + const paneKey = 'tab-worker:11111111-1111-4111-8111-111111111111' + + store + .getState() + .setAgentStatus( + paneKey, + { state: 'working', prompt: 'worker', agentType: 'codex' }, + undefined, + undefined, + { + tabId: 'tab-worker', + worktreeId: 'wt-a', + terminalHandle: 'term-worker' + } + ) + store.setState({ + acknowledgedAgentsByPaneKey: { [paneKey]: Date.now() } + } as Partial) + + store.getState().dropAgentStatusByWorktree('wt-a') + + expect(store.getState().agentStatusByPaneKey[paneKey]).toBeUndefined() + expect(store.getState().acknowledgedAgentsByPaneKey[paneKey]).toBeUndefined() + expect(store.getState().retentionSuppressedPaneKeys[paneKey]).toBe(true) + }) + it('pruneRetainedAgents keeps only entries whose worktreeId is in the valid set', () => { const store = createTestStore() const now = Date.now() @@ -980,14 +1049,14 @@ describe('agent status retention + prefix sweep', () => { const retainedA: RetainedAgentEntry = { entry: entryA, worktreeId: 'wt-a', - tab: { id: 'tab-a', title: 'claude' } as unknown as TerminalTab, + tab: makeTab({ id: 'tab-a', worktreeId: 'wt-a', title: 'claude' }), agentType: 'claude', startedAt: now } const retainedB: RetainedAgentEntry = { entry: entryB, worktreeId: 'wt-b', - tab: { id: 'tab-b', title: 'claude' } as unknown as TerminalTab, + tab: makeTab({ id: 'tab-b', worktreeId: 'wt-b', title: 'claude' }), agentType: 'claude', startedAt: now } diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 6ddf11a982c..ad4cecd91c9 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -96,15 +96,9 @@ export type AgentStatusSlice = { * Used on worktree sleep/remove — the whole worktree surface is folding, so * retained rows must drop even if their original tab is no longer present. * - * Note on orphan-live asymmetry: liveKeys are matched against tab prefixes - * derived from `tabsByWorktree[worktreeId]`. Live entries belonging to the - * same worktree but whose tab has already been pruned from `tabsByWorktree` - * (an orphan-live race) are not swept here. The retained side has a - * fallback (`retained.worktreeId === worktreeId`); the live side does not, - * because live entries do not carry a worktreeId. In practice the gap is - * bounded — `removeAgentStatus` is called on PTY exit — and dropping an - * orphan-live entry on shutdown is best-effort, so accepting the asymmetry - * is the simpler tradeoff. */ + * Live entries are swept by tab prefix and by main-stamped worktree + * attribution so worker rows that arrive before their tab exists do not + * survive sleep/remove. */ dropAgentStatusByWorktree: (worktreeId: string) => void /** Retain agent snapshots (called by the top-level retention sync effect). @@ -439,7 +433,7 @@ export const createAgentStatusSlice: StateCreatorfresh, or receives @@ -852,9 +846,13 @@ export const createAgentStatusSlice: StateCreator { const tabPrefixes = (s.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) - const liveKeys = Object.keys(s.agentStatusByPaneKey).filter((k) => - paneKeyMatchesAnyTabPrefix(k, tabPrefixes) - ) + const liveKeys = Object.entries(s.agentStatusByPaneKey) + .filter( + ([paneKey, entry]) => + entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) + ) + .map(([paneKey]) => paneKey) + const liveKeySet = new Set(liveKeys) const retainedKeys = Object.entries(s.retainedAgentsByPaneKey) .filter( ([paneKey, retained]) => @@ -869,10 +867,12 @@ export const createAgentStatusSlice: StateCreator paneKeyMatchesAnyTabPrefix(k, tabPrefixes) || retainedKeySet.has(k) + (k) => + paneKeyMatchesAnyTabPrefix(k, tabPrefixes) || liveKeySet.has(k) || retainedKeySet.has(k) ) if (ackKeys.length > 0) { nextAck = { ...nextAck }