diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index f935a1262a9..80c3f2c0cbe 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -337,6 +337,7 @@ import { type SerializedTasksMessage, } from './components/messages/TasksStatusMessage'; import { SessionWorkflowCockpit } from './components/workflow/SessionWorkflowCockpit'; +import { buildSessionWorkflowProjection } from './components/workflow/session-workflow-model'; import { serializeContextUsageMessage } from './components/messages/ContextUsageMessage'; import { serializeStatsMessage, @@ -11377,6 +11378,27 @@ export function App({ : [], [floatingTodosState, messages, tasksDialogMessage], ); + // One projection per render for every session-workflow surface. The + // cockpit, the artifact-panel inspector and the graph embedded in the + // cockpit each used to derive their own copy of the same projection; they + // now share this one, which also carries the single task-execution index + // they all read from. + const sessionWorkflowProjection = useMemo( + () => + sessionWorkflowEnabled + ? buildSessionWorkflowProjection( + sessionWorkflowTodos, + planAgentTools, + environmentAgentTasks, + ) + : undefined, + [ + environmentAgentTasks, + planAgentTools, + sessionWorkflowEnabled, + sessionWorkflowTodos, + ], + ); const reloadTargetedWorkspaceSettings = useCallback(async () => { const status = await reloadWorkspaceSettings(); if (mainVoiceTarget?.route === 'workspace-qualified') { @@ -17935,6 +17957,7 @@ export function App({ todos: sessionWorkflowTodos, tools: planAgentTools, tasks: environmentAgentTasks, + projection: sessionWorkflowProjection, artifacts, selectedTodoId: selectedWorkflowTodoId, onSelectedTodoIdChange: setSelectedWorkflowTodoId, @@ -19395,6 +19418,7 @@ export function App({ todos={sessionWorkflowTodos} tools={planAgentTools} tasks={environmentAgentTasks} + projection={sessionWorkflowProjection} selectedTodoId={selectedWorkflowTodoId} onSelectedTodoIdChange={setSelectedWorkflowTodoId} onBackToChat={closeCockpit} diff --git a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx index 6b38c8c771b..c0bea254807 100644 --- a/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx +++ b/packages/web-shell/client/components/artifacts/ArtifactPanel.tsx @@ -104,6 +104,7 @@ import { AgentWorkflow } from './AgentWorkflow'; import type { EnvironmentAgentTask } from '../panels/EnvironmentPanel'; import { SideTaskPanel } from './SideTaskPanel'; import { SessionWorkflowInspector } from '../workflow/SessionWorkflowInspector'; +import type { SessionWorkflowProjection } from '../workflow/session-workflow-model'; import { TerminalPanel } from '../terminal/TerminalPanel'; import { WebPreviewPanel } from '../preview/WebPreviewPanel'; import { SavedWebPreview } from '../preview/SavedWebPreview'; @@ -417,6 +418,8 @@ interface ArtifactPanelProps { todos: readonly TodoItem[]; tools: readonly ACPToolCall[]; tasks: readonly DaemonSessionTaskStatus[]; + /** Shared per-render projection; also feeds the cockpit and its graph. */ + projection?: SessionWorkflowProjection; artifacts: readonly DaemonSessionArtifact[]; selectedTodoId?: string; onSelectedTodoIdChange: (todoId: string | undefined) => void; diff --git a/packages/web-shell/client/components/messages/PlanExecutionView.derivation.test.tsx b/packages/web-shell/client/components/messages/PlanExecutionView.derivation.test.tsx new file mode 100644 index 00000000000..10358e24fbd --- /dev/null +++ b/packages/web-shell/client/components/messages/PlanExecutionView.derivation.test.tsx @@ -0,0 +1,179 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { TodoItem } from '../../adapters/types'; +import { I18nProvider } from '../../i18n'; +import { TranscriptRenderModeProvider } from '../../transcriptRenderMode'; + +// Acceptance #10865, graph-side guarantees, pinned by counting rather than +// by inspection: +// - hovering a node re-renders without re-running the topological layering +// or the topology serialization; +// - `measure` runs at most once per animation frame even when a resize +// storm lands several schedule calls inside one frame. +// In its own file so the module mock cannot reach the behavioural suite +// next to it. +const counts = vi.hoisted(() => ({ layers: 0 })); + +vi.mock('./PlanExecutionView', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + layerPlanTodos: (...args: Parameters) => { + counts.layers += 1; + return actual.layerPlanTodos(...args); + }, + }; +}); + +const { PlanExecutionView } = await import('./PlanExecutionView'); + +const todos: TodoItem[] = [ + { id: 'research', content: 'Research', status: 'completed' }, + { + id: 'build', + content: 'Build', + status: 'in_progress', + blockedBy: ['research'], + }, + { + id: 'verify', + content: 'Verify', + status: 'pending', + blockedBy: ['build'], + }, +]; + +// Each test's tree is unmounted after the test: the graph binds a window +// resize listener, and a leaked listener would double-count the next test's +// resize storm. +const roots: Root[] = []; + +function mount(): HTMLElement { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + act(() => { + root.render( + + + + + , + ); + }); + return container; +} + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()); + } +}); + +describe('PlanExecutionView derivation discipline', () => { + it('does not re-run the layering or the topology serialization on hover', () => { + const container = mount(); + const stringifySpy = vi.spyOn(JSON, 'stringify'); + const layersAfterMount = counts.layers; + const stringifyAfterMount = stringifySpy.mock.calls.length; + + const buildNode = container + .querySelector('[data-plan-node-id="build"]') + ?.closest('article'); + expect(buildNode).toBeTruthy(); + + const edges = container.querySelector('[data-plan-edge]')?.closest('svg'); + expect(edges?.getAttribute('data-focused')).toBeNull(); + + // jsdom has no PointerEvent; React synthesizes onPointerEnter from a + // bubbling pointerover, and onPointerLeave from pointerout. + act(() => { + buildNode?.dispatchEvent( + new MouseEvent('pointerover', { bubbles: true }), + ); + }); + + // The hover did re-render (focus state flipped)... + const focused = container.querySelector('[data-plan-edge]')?.closest('svg'); + expect(focused?.getAttribute('data-focused')).toBe('true'); + + // ...but the derivation did not re-run: no extra topological layering, + // no extra topology serialization. + expect(counts.layers).toBe(layersAfterMount); + expect(stringifySpy.mock.calls.length).toBe(stringifyAfterMount); + + act(() => { + buildNode?.dispatchEvent(new MouseEvent('pointerout', { bubbles: true })); + }); + const unfocused = container + .querySelector('[data-plan-edge]') + ?.closest('svg'); + expect(unfocused?.getAttribute('data-focused')).toBeNull(); + expect(counts.layers).toBe(layersAfterMount); + expect(stringifySpy.mock.calls.length).toBe(stringifyAfterMount); + + stringifySpy.mockRestore(); + }); + + it('coalesces a same-frame resize storm into one measure per animation frame', () => { + const frames: FrameRequestCallback[] = []; + const animationSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + frames.push(callback); + return frames.length; + }); + const rectSpy = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockReturnValue({ + x: 0, + y: 0, + top: 0, + left: 0, + width: 100, + height: 80, + right: 100, + bottom: 80, + toJSON: () => ({}), + } as DOMRect); + + const container = mount(); + const nodes = container.querySelectorAll('[data-plan-node-id]').length; + expect(nodes).toBe(todos.length); + + const framesAfterMount = frames.length; + + // One viewport change lands as several schedule calls in the same + // frame (the window resize plus, in a real browser, the resize + // observer's per-node batch). All of them must share a single + // animation frame, and the frame must run `measure` exactly once. + act(() => { + window.dispatchEvent(new Event('resize')); + window.dispatchEvent(new Event('resize')); + window.dispatchEvent(new Event('resize')); + }); + expect(frames.length - framesAfterMount).toBe(1); + + rectSpy.mockClear(); + act(() => { + frames.at(-1)!(0); + }); + // One measure pass reads the graph container's rect plus one rect per + // node — not one batch per schedule call. + expect(rectSpy.mock.calls.length).toBe(nodes + 1); + + // The next storm schedules one new frame again. + act(() => { + window.dispatchEvent(new Event('resize')); + window.dispatchEvent(new Event('resize')); + }); + expect(frames.length - framesAfterMount).toBe(2); + + animationSpy.mockRestore(); + rectSpy.mockRestore(); + }); +}); diff --git a/packages/web-shell/client/components/messages/PlanExecutionView.tsx b/packages/web-shell/client/components/messages/PlanExecutionView.tsx index 7a2764ca6c5..f3b9de8d275 100644 --- a/packages/web-shell/client/components/messages/PlanExecutionView.tsx +++ b/packages/web-shell/client/components/messages/PlanExecutionView.tsx @@ -8,10 +8,7 @@ import { useState, type CSSProperties, } from 'react'; -import type { - DaemonSessionAgentTaskStatus, - DaemonSessionTaskStatus, -} from '@qwen-code/sdk/daemon'; +import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; import type { ACPToolCall, TodoItem } from '../../adapters/types'; import { isSubAgentToolCall } from '../../adapters/toolClassification'; import { useI18n } from '../../i18n'; @@ -20,19 +17,38 @@ import { formatRuntime } from '../../utils/formatRuntime'; import { getAgentDescription, getSubagentDetailsUnavailableReason, - getAgentDisplayStatus, - isAgentCancelled, sanitizeControlChars, } from './toolFormatting'; +import { + executionStatus, + nestedAgentToolsForTool, + nestedTasksFromIndex, + taskForTool, + toolForNestedTask, + type PlanNodeStatus, +} from './taskExecutionIndex'; +import type { SessionWorkflowProjection } from '../workflow/session-workflow-model'; +import { buildSessionWorkflowProjection } from '../workflow/session-workflow-model'; import styles from './PlanExecutionView.module.css'; -export type PlanNodeStatus = - | 'running' - | 'paused' - | 'completed' - | 'blocked' - | 'in_progress' - | 'ready'; +// The task-execution lookups this view used to own now live in +// `taskExecutionIndex` so the workflow projection can share them without a +// circular import. Re-exported here to keep this module's public surface +// stable for its existing importers (tests included). +export { + createTaskExecutionIndex, + getActiveAgents, + getActiveAgentsFromIndex, + getAttentionAgentStatuses, + getAttentionAgentTool, + getPlanNodeState, + getPlanNodeStateFromIndex, + nestedAgentToolsForTool, + nestedTasksFromIndex, + nestedTasksForTool, + todoIdOf, +} from './taskExecutionIndex'; +export type { PlanNodeStatus, TaskExecutionIndex } from './taskExecutionIndex'; interface PlanEdgePath { from: string; @@ -135,328 +151,6 @@ export function layerPlanTodos(todos: readonly TodoItem[]): TodoItem[][] { return layers; } -export interface TaskExecutionIndex { - rootByToolCallId: ReadonlyMap; - childrenByParentId: ReadonlyMap; - nestedByRootId: Map< - string, - Array<{ task: DaemonSessionAgentTaskStatus; depth: number }> - >; -} - -export function createTaskExecutionIndex( - tasks: readonly DaemonSessionTaskStatus[], -): TaskExecutionIndex { - const rootByToolCallId = new Map(); - const childrenByParentId = new Map(); - for (const task of tasks) { - if (task.kind !== 'agent') continue; - if (task.parentAgentId == null) { - if (!task.toolUseId || rootByToolCallId.has(task.toolUseId)) continue; - rootByToolCallId.set(task.toolUseId, task); - continue; - } - const siblings = childrenByParentId.get(task.parentAgentId) ?? []; - siblings.push(task); - childrenByParentId.set(task.parentAgentId, siblings); - } - return { - rootByToolCallId, - childrenByParentId, - nestedByRootId: new Map(), - }; -} - -function taskForTool( - tool: ACPToolCall, - taskIndex: TaskExecutionIndex, -): DaemonSessionAgentTaskStatus | undefined { - return taskIndex.rootByToolCallId.get(tool.callId); -} - -function executionStatus( - tool: ACPToolCall, - taskIndex: TaskExecutionIndex, -): string { - const liveStatus = taskForTool(tool, taskIndex)?.status; - if (liveStatus) return liveStatus; - const persistedStatus = - tool.rawOutput && typeof tool.rawOutput === 'object' - ? (tool.rawOutput as Record)['status'] - : undefined; - if (persistedStatus === 'paused') return persistedStatus; - return isAgentCancelled(tool) ? 'cancelled' : getAgentDisplayStatus(tool); -} - -/** - * Whether an executionStatus counts toward the overview strip's "Active - * agents". Deliberately the same statuses that make - * `getPlanNodeStateFromIndex` render a node running/paused: the live task - * statuses ('running' / 'paused') plus the transcript 'in_progress' that - * `executionStatus` reports for an in-flight tool call with no live daemon - * task — so the strip and the node badges never contradict each other. - */ -function isAgentExecutionActive(status: string): boolean { - return ( - status === 'running' || status === 'in_progress' || status === 'paused' - ); -} - -export function nestedTasksFromIndex( - tool: ACPToolCall, - taskIndex: TaskExecutionIndex, -): Array<{ task: DaemonSessionAgentTaskStatus; depth: number }> { - const root = taskForTool(tool, taskIndex); - if (!root) return []; - const cached = taskIndex.nestedByRootId.get(root.id); - if (cached) return cached; - - const nested: Array<{ - task: DaemonSessionAgentTaskStatus; - depth: number; - }> = []; - const visited = new Set([root.id]); - const stack = (taskIndex.childrenByParentId.get(root.id) ?? []) - .slice() - .reverse() - .map((task) => ({ task, depth: 1 })); - while (stack.length > 0) { - const entry = stack.pop()!; - if (visited.has(entry.task.id)) continue; - visited.add(entry.task.id); - nested.push(entry); - const descendants = taskIndex.childrenByParentId.get(entry.task.id) ?? []; - for (let index = descendants.length - 1; index >= 0; index--) { - stack.push({ task: descendants[index], depth: entry.depth + 1 }); - } - } - taskIndex.nestedByRootId.set(root.id, nested); - return nested; -} - -export function nestedTasksForTool( - tool: ACPToolCall, - tasks: readonly DaemonSessionTaskStatus[], -): Array<{ task: DaemonSessionAgentTaskStatus; depth: number }> { - return nestedTasksFromIndex(tool, createTaskExecutionIndex(tasks)); -} - -/** - * Deliberately uncached. Keying on the tool object would be wrong the moment - * a reused object gains a sub-tool — `appendSubTool` mutates `subTools` in - * place — and the only thing standing between that and a stale render is - * `useMessages`' prefix-reuse rule in another module. The callers' own - * derivations are memoized, so the repetition this would remove is bounded to - * a single derivation; a silent wrong subtree is not worth that. - */ -export function nestedAgentToolsForTool( - tool: ACPToolCall, -): Array<{ tool: ACPToolCall; depth: number }> { - const result: Array<{ tool: ACPToolCall; depth: number }> = []; - const visit = (parent: ACPToolCall, depth: number) => { - for (const child of parent.subTools ?? []) { - if (!isSubAgentToolCall(child)) continue; - result.push({ tool: child, depth }); - visit(child, depth + 1); - } - }; - visit(tool, 1); - return result; -} - -/** - * The execution status observed for every agent under one tool: the tool's - * own execution, every nested live task, and every nested transcript agent. - * An agent observed through BOTH a live task and a persisted transcript tool - * (a nested task whose toolUseId matches the nested tool's callId) counts - * once, keeping the actionable observation: getAttentionAgentTool opens the - * failed/cancelled surface when either reports one, so the tally must agree - * with the affordance. getPlanNodeStateFromIndex decides attention on - * exactly these statuses and the cockpit's attention stats tally them, so - * the triage strip and the queue can never contradict each other. - */ -function attentionAgentStatuses( - tool: ACPToolCall, - taskIndex: TaskExecutionIndex, -): string[] { - const byAgent = new Map(); - const record = (agentKey: string, status: string) => { - const existing = byAgent.get(agentKey); - if ( - existing === undefined || - (existing !== 'failed' && - existing !== 'cancelled' && - (status === 'failed' || status === 'cancelled')) - ) { - byAgent.set(agentKey, status); - } - }; - const root = taskForTool(tool, taskIndex); - record( - root ? `task:${root.id}` : `tool:${tool.callId}`, - executionStatus(tool, taskIndex), - ); - const liveTaskIdByToolCallId = new Map(); - for (const { task } of nestedTasksFromIndex(tool, taskIndex)) { - record(`task:${task.id}`, task.status); - if (task.toolUseId) liveTaskIdByToolCallId.set(task.toolUseId, task.id); - } - for (const { tool: nestedTool } of nestedAgentToolsForTool(tool)) { - const liveTaskId = liveTaskIdByToolCallId.get(nestedTool.callId); - record( - liveTaskId ? `task:${liveTaskId}` : `tool:${nestedTool.callId}`, - executionStatus(nestedTool, taskIndex), - ); - } - return [...byAgent.values()]; -} - -/** - * Same agent-status walk as {@link attentionAgentStatuses}, for callers that - * hold the raw task list instead of a prebuilt index (the cockpit's stats - * strip, which must tally exactly what the attention queue shows). - */ -export function getAttentionAgentStatuses( - tool: ACPToolCall, - tasks: readonly DaemonSessionTaskStatus[], -): string[] { - return attentionAgentStatuses(tool, createTaskExecutionIndex(tasks)); -} - -function transcriptAgentTask( - tool: ACPToolCall, - status: string, - depth?: number, -): DaemonSessionAgentTaskStatus { - return { - kind: 'agent', - id: `tool:${tool.callId}`, - label: tool.title || String(tool.args?.description ?? 'Agent'), - description: - typeof tool.args?.description === 'string' ? tool.args.description : '', - status: status === 'paused' ? 'paused' : 'running', - startTime: 0, - runtimeMs: 0, - isBackgrounded: false, - toolUseId: tool.callId, - ...(depth === undefined ? {} : { depth }), - }; -} - -function activeAgentEntry( - tool: ACPToolCall, - taskIndex: TaskExecutionIndex, - depth?: number, -): DaemonSessionAgentTaskStatus | undefined { - const status = executionStatus(tool, taskIndex); - if (!isAgentExecutionActive(status)) return undefined; - const liveTask = taskForTool(tool, taskIndex); - if (liveTask) return liveTask; - return transcriptAgentTask(tool, status, depth); -} - -/** - * One entry per agent the overview strip reports as active, and the single - * source the workflow inspector summary counts: the live daemon task when - * one exists, otherwise a transcript-derived stand-in for an in-flight tool - * call with no live task (the replay shape). The walk mirrors the node - * badges (executionStatus), so the strip, the badges, and the inspector can - * never contradict each other. An agent observed through BOTH a live task - * and a persisted transcript tool counts once (dedup by toolUseId). - */ -export function getActiveAgents( - tools: readonly ACPToolCall[], - tasks: readonly DaemonSessionTaskStatus[], -): DaemonSessionAgentTaskStatus[] { - return getActiveAgentsFromIndex(tools, createTaskExecutionIndex(tasks)); -} - -/** - * {@link getActiveAgents} for callers that already hold an index. Building the - * index is O(tasks); doing it per todo and per tool — as the workflow - * projection used to — makes the walk O((todos + tools) x tasks) for a result - * that never varies with the todo or the tool. - */ -export function getActiveAgentsFromIndex( - tools: readonly ACPToolCall[], - taskIndex: TaskExecutionIndex, -): DaemonSessionAgentTaskStatus[] { - const active: DaemonSessionAgentTaskStatus[] = []; - for (const tool of tools) { - const root = activeAgentEntry(tool, taskIndex); - if (root) active.push(root); - const nestedLiveTasks = nestedTasksFromIndex(tool, taskIndex); - for (const { task } of nestedLiveTasks) { - if (task.status === 'running' || task.status === 'paused') { - active.push(task); - } - } - const liveNestedToolUseIds = new Set( - nestedLiveTasks - .map(({ task }) => task.toolUseId) - .filter((toolUseId): toolUseId is string => toolUseId !== undefined), - ); - for (const { tool: nestedTool, depth } of nestedAgentToolsForTool(tool)) { - if (liveNestedToolUseIds.has(nestedTool.callId)) continue; - const nested = activeAgentEntry(nestedTool, taskIndex, depth); - if (nested) active.push(nested); - } - } - return active; -} - -export function getPlanNodeStateFromIndex( - todo: TodoItem, - todosById: ReadonlyMap, - tools: readonly ACPToolCall[], - taskIndex: TaskExecutionIndex, -): { status: PlanNodeStatus; attention: boolean } { - const executionStatuses = tools.map((tool) => - executionStatus(tool, taskIndex), - ); - const attention = tools.some((tool) => - attentionAgentStatuses(tool, taskIndex).some( - (status) => status === 'failed' || status === 'cancelled', - ), - ); - if ( - executionStatuses.includes('running') || - executionStatuses.includes('in_progress') - ) - return { status: 'running', attention }; - if (executionStatuses.includes('paused')) - return { status: 'paused', attention }; - if (todo.status === 'completed') - return { status: 'completed', attention: false }; - const blocked = (todo.blockedBy ?? []).some( - (id) => todosById.has(id) && todosById.get(id)?.status !== 'completed', - ); - if (blocked) return { status: 'blocked', attention }; - if (todo.status === 'in_progress') - return { status: 'in_progress', attention }; - return { status: 'ready', attention }; -} - -export function getPlanNodeState( - todo: TodoItem, - todosById: ReadonlyMap, - tools: readonly ACPToolCall[], - tasks: readonly DaemonSessionTaskStatus[], -): { status: PlanNodeStatus; attention: boolean } { - return getPlanNodeStateFromIndex( - todo, - todosById, - tools, - createTaskExecutionIndex(tasks), - ); -} - -/** The plan step a tool call was issued for, when it declares one. */ -export function todoIdOf(tool: ACPToolCall): string | undefined { - const value = tool.args?.todo_id; - return typeof value === 'string' && value.length > 0 ? value : undefined; -} - function statusKey(status: PlanNodeStatus) { return `planExecution.status.${status}` as const; } @@ -479,59 +173,11 @@ function executionStatusKey(status: string) { } } -function toolForNestedTask( - task: DaemonSessionAgentTaskStatus, -): ACPToolCall | undefined { - if (!task.toolUseId) return undefined; - const status: ACPToolCall['status'] = - task.status === 'failed' - ? 'failed' - : task.status === 'running' || task.status === 'paused' - ? 'in_progress' - : 'completed'; - return { - callId: task.toolUseId, - toolName: 'Agent', - title: task.label, - args: { description: task.description }, - status, - rawOutput: { type: 'task_execution', status: task.status }, - }; -} - -export function getAttentionAgentTool( - tool: ACPToolCall, - tasks: readonly DaemonSessionTaskStatus[], -): ACPToolCall | undefined { - const taskIndex = createTaskExecutionIndex(tasks); - const nestedTools = nestedAgentToolsForTool(tool); - const nestedToolByCallId = new Map( - nestedTools.map(({ tool: nestedTool }) => [nestedTool.callId, nestedTool]), - ); - const failedTask = [...nestedTasksFromIndex(tool, taskIndex)] - .reverse() - .find( - ({ task }) => task.status === 'failed' || task.status === 'cancelled', - )?.task; - if (failedTask?.toolUseId) { - return ( - nestedToolByCallId.get(failedTask.toolUseId) ?? - toolForNestedTask(failedTask) - ); - } - const failedTool = [...nestedTools].reverse().find(({ tool: nestedTool }) => { - const status = executionStatus(nestedTool, taskIndex); - return status === 'failed' || status === 'cancelled'; - })?.tool; - if (failedTool) return failedTool; - const status = executionStatus(tool, taskIndex); - return status === 'failed' || status === 'cancelled' ? tool : undefined; -} - export function PlanExecutionView({ todos, tools, tasks, + projection: sharedProjection, onOpenSubagent, hideTitle = false, selection, @@ -540,6 +186,14 @@ export function PlanExecutionView({ todos: readonly TodoItem[]; tools: readonly ACPToolCall[]; tasks: readonly DaemonSessionTaskStatus[]; + /** + * A host that already derives the workflow projection (the cockpit, which + * mounts this graph beside the inspector) passes its own, so the whole + * surface shares one projection — and one task-execution index — per + * render instead of each component privately rebuilding both. Standalone + * mounts derive it from `todos` / `tools` / `tasks` here. + */ + projection?: SessionWorkflowProjection; onOpenSubagent?: (tool: ACPToolCall) => void; /** * Drop the "Plan execution" caption when the host already titles the region. @@ -556,24 +210,29 @@ export function PlanExecutionView({ }) { const { t } = useI18n(); const documentMode = useTranscriptRenderMode() === 'document'; - const taskIndex = useMemo(() => createTaskExecutionIndex(tasks), [tasks]); + const projection = useMemo( + () => + sharedProjection ?? buildSessionWorkflowProjection(todos, tools, tasks), + [sharedProjection, tasks, todos, tools], + ); + const taskIndex = projection.taskIndex; - // One derivation for the whole graph, so a hover — which only flips - // `data-focused` / `data-active` — no longer re-runs the topological sort, - // the topology serialization, and the per-todo x per-tool attention walk. - // `todos` arrives with a stable identity from `useStableArray`, and `tools` - // is rebuilt whenever the transcript changes, so this memo tracks content - // rather than defeating itself on fresh array identities. + // Grouping, node states, counts and dependents all come from the shared + // projection — one derivation per render across every workflow surface. + // Only the graph-specific layering and topology serialization are derived + // here, so a hover — which only flips `data-focused` / `data-active` — + // re-renders without re-running the topological sort or the serialization. + // `todos` arrives with a stable identity from `useStableArray`, and the + // projection rebuilds whenever the transcript does, so the memo tracks + // content rather than defeating itself on fresh array identities. + const { todosById, toolsByTodo, states: statesByTodo } = projection; + const unassigned = projection.unassignedTools; + const completedCount = projection.completedCount; + const progressPercent = projection.progressPercent; + const activeAgentCount = projection.activeAgents.length; + const attentionCount = projection.attentionTodos.length; const { - todosById, stepNumberByTodo, - toolsByTodo, - unassigned, - statesByTodo, - completedCount, - progressPercent, - activeAgentCount, - attentionCount, topology, dependencyIdsByTodo, topologyKey, @@ -584,64 +243,17 @@ export function PlanExecutionView({ layerByTodo, dependentsByTodo, } = useMemo(() => { - const knownIds = new Set(todos.map((todo) => todo.id)); - const todosById = new Map(todos.map((todo) => [todo.id, todo])); // The step number addresses a step in the inspector list and in the // dependency chips, so the graph shows the same number or the three // surfaces name the same step differently. const stepNumberByTodo = new Map( todos.map((todo, index) => [todo.id, index + 1]), ); - const toolsByTodo = new Map(); - const unassigned: ACPToolCall[] = []; - for (const tool of tools) { - const todoId = todoIdOf(tool); - if (!todoId || !knownIds.has(todoId)) { - unassigned.push(tool); - continue; - } - const grouped = toolsByTodo.get(todoId) ?? []; - grouped.push(tool); - toolsByTodo.set(todoId, grouped); - } - const statesByTodo = new Map( - todos.map((todo) => [ - todo.id, - getPlanNodeStateFromIndex( - todo, - todosById, - toolsByTodo.get(todo.id) ?? [], - taskIndex, - ), - ]), - ); - const completedCount = todos.filter( - (todo) => todo.status === 'completed', - ).length; - // floor, not round: (N-1)/N rounds up to 100% on long plans, reporting - // completion (including to aria-valuenow) while a step is still - // outstanding. - const progressPercent = - todos.length === 0 - ? 0 - : Math.floor((completedCount / todos.length) * 100); - // Derive from the same source as the node badges (executionStatus): the - // live daemon index when a task exists, otherwise the tool call's - // persisted/transcript status. Counting only live tasks contradicted the - // badges on a replayed transcript of an interrupted session — the node - // rendered Running off an in_progress tool call while this strip reported - // "Active agents: 0" because no live daemon task existed. The workflow - // inspector summary counts the very same helper output, so the two - // surfaces can never contradict each other. - const activeAgentCount = getActiveAgentsFromIndex(tools, taskIndex).length; - const attentionCount = [...statesByTodo.values()].filter( - (state) => state.attention, - ).length; const topology = todos.map((todo): [string, string[]] => [ todo.id, [...new Set(todo.blockedBy ?? [])].filter( (dependencyId) => - dependencyId !== todo.id && knownIds.has(dependencyId), + dependencyId !== todo.id && projection.todosById.has(dependencyId), ), ]); const dependencyIdsByTodo = new Map(topology); @@ -659,23 +271,18 @@ export function PlanExecutionView({ layers.forEach((layer, index) => { for (const todo of layer) layerByTodo.set(todo.id, index); }); - for (const [todoId, dependencies] of topology) { - for (const dependencyId of dependencies) { - const dependents = dependentsByTodo.get(dependencyId) ?? []; - dependents.push(todoId); - dependentsByTodo.set(dependencyId, dependents); - } + // Downstream step ids straight from the projection's own derivation — + // the graph used to rebuild this from the topology it had just + // serialized, a third copy of the same `blockedBy` walk (after the + // projection's own and the one it replaced in the inspector). + for (const [todoId, dependents] of projection.dependentsByTodo) { + dependentsByTodo.set( + todoId, + dependents.map((dependent) => dependent.id), + ); } return { - todosById, stepNumberByTodo, - toolsByTodo, - unassigned, - statesByTodo, - completedCount, - progressPercent, - activeAgentCount, - attentionCount, topology, dependencyIdsByTodo, topologyKey, @@ -686,7 +293,7 @@ export function PlanExecutionView({ layerByTodo, dependentsByTodo, }; - }, [taskIndex, todos, tools]); + }, [projection, todos]); const graphId = useId().replaceAll(':', ''); const markerId = `plan-arrow-${graphId}`; const dimMarkerId = `plan-arrow-dim-${graphId}`; diff --git a/packages/web-shell/client/components/messages/taskExecutionIndex.ts b/packages/web-shell/client/components/messages/taskExecutionIndex.ts new file mode 100644 index 00000000000..a5e4ce2c38c --- /dev/null +++ b/packages/web-shell/client/components/messages/taskExecutionIndex.ts @@ -0,0 +1,398 @@ +import type { + DaemonSessionAgentTaskStatus, + DaemonSessionTaskStatus, +} from '@qwen-code/sdk/daemon'; +import type { ACPToolCall, TodoItem } from '../../adapters/types'; +import { isSubAgentToolCall } from '../../adapters/toolClassification'; +import { getAgentDisplayStatus, isAgentCancelled } from './toolFormatting'; + +/** + * Shared task-execution lookups for the plan surfaces. Extracted from + * `PlanExecutionView` so the workflow projection (`session-workflow-model`) + * and the graph (`PlanExecutionView`) both depend on this module instead of + * on each other, and so one build of the index can be threaded from the + * projection into every consumer in a single render. + */ +export type PlanNodeStatus = + | 'running' + | 'paused' + | 'completed' + | 'blocked' + | 'in_progress' + | 'ready'; + +export interface TaskExecutionIndex { + rootByToolCallId: ReadonlyMap; + childrenByParentId: ReadonlyMap; + nestedByRootId: Map< + string, + Array<{ task: DaemonSessionAgentTaskStatus; depth: number }> + >; +} + +export function createTaskExecutionIndex( + tasks: readonly DaemonSessionTaskStatus[], +): TaskExecutionIndex { + const rootByToolCallId = new Map(); + const childrenByParentId = new Map(); + for (const task of tasks) { + if (task.kind !== 'agent') continue; + if (task.parentAgentId == null) { + if (!task.toolUseId || rootByToolCallId.has(task.toolUseId)) continue; + rootByToolCallId.set(task.toolUseId, task); + continue; + } + const siblings = childrenByParentId.get(task.parentAgentId) ?? []; + siblings.push(task); + childrenByParentId.set(task.parentAgentId, siblings); + } + return { + rootByToolCallId, + childrenByParentId, + nestedByRootId: new Map(), + }; +} + +export function taskForTool( + tool: ACPToolCall, + taskIndex: TaskExecutionIndex, +): DaemonSessionAgentTaskStatus | undefined { + return taskIndex.rootByToolCallId.get(tool.callId); +} + +export function executionStatus( + tool: ACPToolCall, + taskIndex: TaskExecutionIndex, +): string { + const liveStatus = taskForTool(tool, taskIndex)?.status; + if (liveStatus) return liveStatus; + const persistedStatus = + tool.rawOutput && typeof tool.rawOutput === 'object' + ? (tool.rawOutput as Record)['status'] + : undefined; + if (persistedStatus === 'paused') return persistedStatus; + return isAgentCancelled(tool) ? 'cancelled' : getAgentDisplayStatus(tool); +} + +/** + * Whether an executionStatus counts toward the overview strip's "Active + * agents". Deliberately the same statuses that make + * `getPlanNodeStateFromIndex` render a node running/paused: the live task + * statuses ('running' / 'paused') plus the transcript 'in_progress' that + * `executionStatus` reports for an in-flight tool call with no live daemon + * task — so the strip and the node badges never contradict each other. + */ +function isAgentExecutionActive(status: string): boolean { + return ( + status === 'running' || status === 'in_progress' || status === 'paused' + ); +} + +export function nestedTasksFromIndex( + tool: ACPToolCall, + taskIndex: TaskExecutionIndex, +): Array<{ task: DaemonSessionAgentTaskStatus; depth: number }> { + const root = taskForTool(tool, taskIndex); + if (!root) return []; + const cached = taskIndex.nestedByRootId.get(root.id); + if (cached) return cached; + + const nested: Array<{ + task: DaemonSessionAgentTaskStatus; + depth: number; + }> = []; + const visited = new Set([root.id]); + const stack = (taskIndex.childrenByParentId.get(root.id) ?? []) + .slice() + .reverse() + .map((task) => ({ task, depth: 1 })); + while (stack.length > 0) { + const entry = stack.pop()!; + if (visited.has(entry.task.id)) continue; + visited.add(entry.task.id); + nested.push(entry); + const descendants = taskIndex.childrenByParentId.get(entry.task.id) ?? []; + for (let index = descendants.length - 1; index >= 0; index--) { + stack.push({ task: descendants[index], depth: entry.depth + 1 }); + } + } + taskIndex.nestedByRootId.set(root.id, nested); + return nested; +} + +export function nestedTasksForTool( + tool: ACPToolCall, + tasks: readonly DaemonSessionTaskStatus[], +): Array<{ task: DaemonSessionAgentTaskStatus; depth: number }> { + return nestedTasksFromIndex(tool, createTaskExecutionIndex(tasks)); +} + +/** + * Deliberately uncached. Keying on the tool object would be wrong the moment + * a reused object gains a sub-tool — `appendSubTool` mutates `subTools` in + * place — and the only thing standing between that and a stale render is + * `useMessages`' prefix-reuse rule in another module. The callers' own + * derivations are memoized, so the repetition this would remove is bounded to + * a single derivation; a silent wrong subtree is not worth that. + */ +export function nestedAgentToolsForTool( + tool: ACPToolCall, +): Array<{ tool: ACPToolCall; depth: number }> { + const result: Array<{ tool: ACPToolCall; depth: number }> = []; + const visit = (parent: ACPToolCall, depth: number) => { + for (const child of parent.subTools ?? []) { + if (!isSubAgentToolCall(child)) continue; + result.push({ tool: child, depth }); + visit(child, depth + 1); + } + }; + visit(tool, 1); + return result; +} + +/** + * The execution status observed for every agent under one tool: the tool's + * own execution, every nested live task, and every nested transcript agent. + * An agent observed through BOTH a live task and a persisted transcript tool + * (a nested task whose toolUseId matches the nested tool's callId) counts + * once, keeping the actionable observation: getAttentionAgentTool opens the + * failed/cancelled surface when either reports one, so the tally must agree + * with the affordance. getPlanNodeStateFromIndex decides attention on + * exactly these statuses and the cockpit's attention stats tally them, so + * the triage strip and the queue can never contradict each other. + */ +function attentionAgentStatuses( + tool: ACPToolCall, + taskIndex: TaskExecutionIndex, +): string[] { + const byAgent = new Map(); + const record = (agentKey: string, status: string) => { + const existing = byAgent.get(agentKey); + if ( + existing === undefined || + (existing !== 'failed' && + existing !== 'cancelled' && + (status === 'failed' || status === 'cancelled')) + ) { + byAgent.set(agentKey, status); + } + }; + const root = taskForTool(tool, taskIndex); + record( + root ? `task:${root.id}` : `tool:${tool.callId}`, + executionStatus(tool, taskIndex), + ); + const liveTaskIdByToolCallId = new Map(); + for (const { task } of nestedTasksFromIndex(tool, taskIndex)) { + record(`task:${task.id}`, task.status); + if (task.toolUseId) liveTaskIdByToolCallId.set(task.toolUseId, task.id); + } + for (const { tool: nestedTool } of nestedAgentToolsForTool(tool)) { + const liveTaskId = liveTaskIdByToolCallId.get(nestedTool.callId); + record( + liveTaskId ? `task:${liveTaskId}` : `tool:${nestedTool.callId}`, + executionStatus(nestedTool, taskIndex), + ); + } + return [...byAgent.values()]; +} + +/** + * Same agent-status walk as {@link attentionAgentStatuses}, for callers that + * hold the raw task list instead of a prebuilt index (the cockpit's stats + * strip, which must tally exactly what the attention queue shows). + */ +export function getAttentionAgentStatuses( + tool: ACPToolCall, + tasks: readonly DaemonSessionTaskStatus[], +): string[] { + return attentionAgentStatuses(tool, createTaskExecutionIndex(tasks)); +} + +function transcriptAgentTask( + tool: ACPToolCall, + status: string, + depth?: number, +): DaemonSessionAgentTaskStatus { + return { + kind: 'agent', + id: `tool:${tool.callId}`, + label: tool.title || String(tool.args?.description ?? 'Agent'), + description: + typeof tool.args?.description === 'string' ? tool.args.description : '', + status: status === 'paused' ? 'paused' : 'running', + startTime: 0, + runtimeMs: 0, + isBackgrounded: false, + toolUseId: tool.callId, + ...(depth === undefined ? {} : { depth }), + }; +} + +function activeAgentEntry( + tool: ACPToolCall, + taskIndex: TaskExecutionIndex, + depth?: number, +): DaemonSessionAgentTaskStatus | undefined { + const status = executionStatus(tool, taskIndex); + if (!isAgentExecutionActive(status)) return undefined; + const liveTask = taskForTool(tool, taskIndex); + if (liveTask) return liveTask; + return transcriptAgentTask(tool, status, depth); +} + +/** + * One entry per agent the overview strip reports as active, and the single + * source the workflow inspector summary counts: the live daemon task when + * one exists, otherwise a transcript-derived stand-in for an in-flight tool + * call with no live task (the replay shape). The walk mirrors the node + * badges (executionStatus), so the strip, the badges, and the inspector can + * never contradict each other. An agent observed through BOTH a live task + * and a persisted transcript tool counts once (dedup by toolUseId). + */ +export function getActiveAgents( + tools: readonly ACPToolCall[], + tasks: readonly DaemonSessionTaskStatus[], +): DaemonSessionAgentTaskStatus[] { + return getActiveAgentsFromIndex(tools, createTaskExecutionIndex(tasks)); +} + +/** + * {@link getActiveAgents} for callers that already hold an index. Building the + * index is O(tasks); doing it per todo and per tool — as the workflow + * projection used to — makes the walk O((todos + tools) x tasks) for a result + * that never varies with the todo or the tool. + */ +export function getActiveAgentsFromIndex( + tools: readonly ACPToolCall[], + taskIndex: TaskExecutionIndex, +): DaemonSessionAgentTaskStatus[] { + const active: DaemonSessionAgentTaskStatus[] = []; + for (const tool of tools) { + const root = activeAgentEntry(tool, taskIndex); + if (root) active.push(root); + const nestedLiveTasks = nestedTasksFromIndex(tool, taskIndex); + for (const { task } of nestedLiveTasks) { + if (task.status === 'running' || task.status === 'paused') { + active.push(task); + } + } + const liveNestedToolUseIds = new Set( + nestedLiveTasks + .map(({ task }) => task.toolUseId) + .filter((toolUseId): toolUseId is string => toolUseId !== undefined), + ); + for (const { tool: nestedTool, depth } of nestedAgentToolsForTool(tool)) { + if (liveNestedToolUseIds.has(nestedTool.callId)) continue; + const nested = activeAgentEntry(nestedTool, taskIndex, depth); + if (nested) active.push(nested); + } + } + return active; +} + +export function getPlanNodeStateFromIndex( + todo: TodoItem, + todosById: ReadonlyMap, + tools: readonly ACPToolCall[], + taskIndex: TaskExecutionIndex, +): { status: PlanNodeStatus; attention: boolean } { + const executionStatuses = tools.map((tool) => + executionStatus(tool, taskIndex), + ); + const attention = tools.some((tool) => + attentionAgentStatuses(tool, taskIndex).some( + (status) => status === 'failed' || status === 'cancelled', + ), + ); + if ( + executionStatuses.includes('running') || + executionStatuses.includes('in_progress') + ) + return { status: 'running', attention }; + if (executionStatuses.includes('paused')) + return { status: 'paused', attention }; + if (todo.status === 'completed') + return { status: 'completed', attention: false }; + const blocked = (todo.blockedBy ?? []).some( + (id) => todosById.has(id) && todosById.get(id)?.status !== 'completed', + ); + if (blocked) return { status: 'blocked', attention }; + if (todo.status === 'in_progress') + return { status: 'in_progress', attention }; + return { status: 'ready', attention }; +} + +export function getPlanNodeState( + todo: TodoItem, + todosById: ReadonlyMap, + tools: readonly ACPToolCall[], + tasks: readonly DaemonSessionTaskStatus[], +): { status: PlanNodeStatus; attention: boolean } { + return getPlanNodeStateFromIndex( + todo, + todosById, + tools, + createTaskExecutionIndex(tasks), + ); +} + +/** The plan step a tool call was issued for, when it declares one. */ +export function todoIdOf(tool: ACPToolCall): string | undefined { + const value = tool.args?.todo_id; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** + * A clickable stand-in for a live agent task that has no transcript tool + * call, so the graph's nested execution rows can still open its panel. + */ +export function toolForNestedTask( + task: DaemonSessionAgentTaskStatus, +): ACPToolCall | undefined { + if (!task.toolUseId) return undefined; + const status: ACPToolCall['status'] = + task.status === 'failed' + ? 'failed' + : task.status === 'running' || task.status === 'paused' + ? 'in_progress' + : 'completed'; + return { + callId: task.toolUseId, + toolName: 'Agent', + title: task.label, + args: { description: task.description }, + status, + rawOutput: { type: 'task_execution', status: task.status }, + }; +} + +export function getAttentionAgentTool( + tool: ACPToolCall, + tasks: readonly DaemonSessionTaskStatus[], +): ACPToolCall | undefined { + const taskIndex = createTaskExecutionIndex(tasks); + const nestedTools = nestedAgentToolsForTool(tool); + const nestedToolByCallId = new Map( + nestedTools.map(({ tool: nestedTool }) => [nestedTool.callId, nestedTool]), + ); + const failedTask = [...nestedTasksFromIndex(tool, taskIndex)] + .reverse() + .find( + ({ task }) => task.status === 'failed' || task.status === 'cancelled', + )?.task; + if (failedTask?.toolUseId) { + return ( + nestedToolByCallId.get(failedTask.toolUseId) ?? + toolForNestedTask(failedTask) + ); + } + const failedTool = [...nestedTools].reverse().find(({ tool: nestedTool }) => { + const status = executionStatus(nestedTool, taskIndex); + return status === 'failed' || status === 'cancelled'; + })?.tool; + if (failedTool) return failedTool; + const status = executionStatus(tool, taskIndex); + if (status === 'failed' || status === 'cancelled') return tool; + return undefined; +} diff --git a/packages/web-shell/client/components/workflow/SessionWorkflowCockpit.tsx b/packages/web-shell/client/components/workflow/SessionWorkflowCockpit.tsx index df7dd7ae931..a511bfdfb2c 100644 --- a/packages/web-shell/client/components/workflow/SessionWorkflowCockpit.tsx +++ b/packages/web-shell/client/components/workflow/SessionWorkflowCockpit.tsx @@ -7,6 +7,7 @@ import { PlanExecutionView } from '../messages/PlanExecutionView'; import { buildSessionWorkflowProjection, getDefaultWorkflowTodoId, + type SessionWorkflowProjection, } from './session-workflow-model'; import styles from './SessionWorkflowCockpit.module.css'; @@ -18,6 +19,12 @@ interface SessionWorkflowCockpitProps { todos: readonly TodoItem[]; tools: readonly ACPToolCall[]; tasks: readonly DaemonSessionTaskStatus[]; + /** + * The projection shared by every workflow surface for this render. The app + * derives it once (it also feeds the inspector beside this cockpit); when + * absent — a standalone mount, a test — it is derived from the raw props. + */ + projection?: SessionWorkflowProjection; selectedTodoId?: string; onSelectedTodoIdChange: (todoId: string | undefined) => void; onBackToChat: () => void; @@ -32,6 +39,7 @@ export function SessionWorkflowCockpit({ todos, tools, tasks, + projection: sharedProjection, selectedTodoId, onSelectedTodoIdChange, onBackToChat, @@ -39,9 +47,13 @@ export function SessionWorkflowCockpit({ }: SessionWorkflowCockpitProps) { const { t } = useI18n(); const backButtonRef = useRef(null); + // Fallback only: with a shared projection this memo returns the passed-in + // object without rebuilding, and the embedded graph reuses it too, so one + // render of the whole workflow surface derives the projection once. const projection = useMemo( - () => buildSessionWorkflowProjection(todos, tools, tasks), - [tasks, todos, tools], + () => + sharedProjection ?? buildSessionWorkflowProjection(todos, tools, tasks), + [sharedProjection, tasks, todos, tools], ); const defaultTodoId = getDefaultWorkflowTodoId(todos, projection); @@ -121,6 +133,7 @@ export function SessionWorkflowCockpit({
void; @@ -45,6 +52,7 @@ export function SessionWorkflowInspector({ todos, tools, tasks, + projection: sharedProjection, artifacts, selectedTodoId, onSelectedTodoIdChange, @@ -54,9 +62,12 @@ export function SessionWorkflowInspector({ canvasMode = false, }: SessionWorkflowInspectorProps) { const { language, t } = useI18n(); + // Fallback only: with the app's shared projection this returns the + // passed-in object without rebuilding. const projection = useMemo( - () => buildSessionWorkflowProjection(todos, tools, tasks), - [tasks, todos, tools], + () => + sharedProjection ?? buildSessionWorkflowProjection(todos, tools, tasks), + [sharedProjection, tasks, todos, tools], ); const defaultTodoId = getDefaultWorkflowTodoId(todos, projection); // Dependencies are stated as Todo ids, which are addresses, not labels. The diff --git a/packages/web-shell/client/components/workflow/session-workflow-model.index.test.ts b/packages/web-shell/client/components/workflow/session-workflow-model.index.test.ts index e0a4fab463c..736c9ebea51 100644 --- a/packages/web-shell/client/components/workflow/session-workflow-model.index.test.ts +++ b/packages/web-shell/client/components/workflow/session-workflow-model.index.test.ts @@ -17,9 +17,9 @@ import type { ACPToolCall, TodoItem } from '../../adapters/types'; // suite next to it. const indexBuilds = vi.hoisted(() => ({ count: 0 })); -vi.mock('../messages/PlanExecutionView', async (importOriginal) => { +vi.mock('../messages/taskExecutionIndex', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal(); return { ...actual, createTaskExecutionIndex: ( diff --git a/packages/web-shell/client/components/workflow/session-workflow-model.ts b/packages/web-shell/client/components/workflow/session-workflow-model.ts index 6cf1b19bdfd..074959f5621 100644 --- a/packages/web-shell/client/components/workflow/session-workflow-model.ts +++ b/packages/web-shell/client/components/workflow/session-workflow-model.ts @@ -12,12 +12,20 @@ import { todoIdOf, type PlanNodeStatus, type TaskExecutionIndex, -} from '../messages/PlanExecutionView'; +} from '../messages/taskExecutionIndex'; export interface SessionWorkflowProjection { + /** + * The task execution index this projection was built from, so a surface + * rendering per-tool live status (the graph's executions) reuses the one + * build instead of raising its own. + */ + taskIndex: TaskExecutionIndex; todosById: ReadonlyMap; toolsByTaskId: ReadonlyMap; toolsByTodo: ReadonlyMap; + /** Tool calls whose `todo_id` is missing or points outside the plan. */ + unassignedTools: readonly ACPToolCall[]; agentToolsByTodo: ReadonlyMap; tasksByTool: ReadonlyMap; states: ReadonlyMap; @@ -122,9 +130,15 @@ export function buildSessionWorkflowProjection( const todosById = new Map(todos.map((todo) => [todo.id, todo])); const toolsByTodo = new Map(); const agentToolsByTodo = new Map(); + const unassignedTools: ACPToolCall[] = []; for (const tool of tools) { const todoId = todoIdOf(tool); - if (!todoId || !todosById.has(todoId)) continue; + if (!todoId || !todosById.has(todoId)) { + // The graph renders these in its unassigned bucket; derived here so + // the shared projection and the graph agree on one grouping walk. + unassignedTools.push(tool); + continue; + } const group = toolsByTodo.get(todoId) ?? []; group.push(tool); toolsByTodo.set(todoId, group); @@ -195,9 +209,11 @@ export function buildSessionWorkflowProjection( : 'waiting'; return { + taskIndex, todosById, toolsByTaskId, toolsByTodo, + unassignedTools, agentToolsByTodo, tasksByTool, states, diff --git a/packages/web-shell/client/components/workflow/session-workflow-surfaces.test.tsx b/packages/web-shell/client/components/workflow/session-workflow-surfaces.test.tsx new file mode 100644 index 00000000000..a9470a2f1df --- /dev/null +++ b/packages/web-shell/client/components/workflow/session-workflow-surfaces.test.tsx @@ -0,0 +1,210 @@ +// @vitest-environment jsdom + +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it, vi } from 'vitest'; +import type { DaemonSessionTaskStatus } from '@qwen-code/sdk/daemon'; +import type { ACPToolCall, TodoItem } from '../../adapters/types'; +import { I18nProvider } from '../../i18n'; +import { SessionWorkflowCockpit } from './SessionWorkflowCockpit'; +import { SessionWorkflowInspector } from './SessionWorkflowInspector'; + +// Acceptance #10865: the cockpit, the inspector and the graph embedded in +// the cockpit share one projection per render instead of deriving three +// copies. These counters watch both the projection build and the +// task-execution index it carries; a regression re-introduces extra builds +// in the component bodies (App builds exactly one and passes it down). +// In its own file so the module mocks cannot reach the behavioural suites. +const counts = vi.hoisted(() => ({ projections: 0, indexBuilds: 0 })); + +vi.mock('./session-workflow-model', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + buildSessionWorkflowProjection: ( + ...args: Parameters + ) => { + counts.projections += 1; + return actual.buildSessionWorkflowProjection(...args); + }, + }; +}); + +vi.mock('../messages/taskExecutionIndex', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createTaskExecutionIndex: ( + ...args: Parameters + ) => { + counts.indexBuilds += 1; + return actual.createTaskExecutionIndex(...args); + }, + }; +}); + +const { buildSessionWorkflowProjection } = await import( + './session-workflow-model' +); + +const todos: TodoItem[] = [ + { id: 'prepare', content: 'Prepare inputs', status: 'completed' }, + { + id: 'build', + content: 'Build the thing', + status: 'in_progress', + blockedBy: ['prepare'], + }, + { + id: 'verify', + content: 'Verify the thing', + status: 'pending', + blockedBy: ['build'], + }, +]; + +const tools: ACPToolCall[] = [ + { + callId: 'build-step', + toolName: 'workflow_step', + status: 'in_progress', + args: { todo_id: 'build' }, + subTools: [ + { + callId: 'build-agent', + toolName: 'Agent', + title: 'Build Agent', + status: 'in_progress', + }, + ], + }, +]; + +const tasks: DaemonSessionTaskStatus[] = [ + { + kind: 'agent', + id: 'build-task', + label: 'Build Agent', + description: 'Building', + status: 'running', + startTime: 1, + runtimeMs: 1_000, + isBackgrounded: false, + toolUseId: 'build-agent', + }, +]; + +function mount(node: React.ReactNode): HTMLElement { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render({node}); + }); + return container; +} + +describe('session workflow surfaces share one projection', () => { + it('builds no extra projection or index when the app passes one down', () => { + // The app-level derivation (App.tsx memoizes one projection per render + // and hands the same object to every surface). This call is the single + // build for the whole render; the counters below must stay at zero. + const shared = buildSessionWorkflowProjection(todos, tools, tasks); + expect(counts.projections).toBe(1); + expect(counts.indexBuilds).toBe(1); + counts.projections = 0; + counts.indexBuilds = 0; + + const onSelectedTodoIdChange = vi.fn(); + const container = mount( + <> + + + , + ); + + // The cockpit (with its embedded graph) and the inspector rendered from + // the shared projection without re-deriving it or its task index. + expect(counts.projections).toBe(0); + expect(counts.indexBuilds).toBe(0); + expect( + container.querySelector('[data-testid="session-workflow-cockpit"]'), + ).toBeTruthy(); + // The embedded graph derived its nodes from the same projection. + expect( + container.querySelector('[data-plan-node-id="verify"]'), + ).toBeTruthy(); + expect(container.textContent).toContain('Verify the thing'); + // The inspector reads the same state the cockpit header does. + expect(container.textContent).toContain('Build the thing'); + }); + + it('derives the projection once for a standalone cockpit tree', () => { + counts.projections = 0; + counts.indexBuilds = 0; + + const container = mount( + , + ); + + // One projection per render: the cockpit derives it and the embedded + // graph reuses it — the graph used to rebuild its own grouping, node + // states and counts from the raw props. One task index per projection. + expect(counts.projections).toBe(1); + expect(counts.indexBuilds).toBe(1); + expect( + container.querySelector('[data-plan-node-id="verify"]'), + ).toBeTruthy(); + }); + + it('derives the projection once for a standalone inspector', () => { + counts.projections = 0; + counts.indexBuilds = 0; + + const container = mount( + , + ); + + expect(counts.projections).toBe(1); + expect(counts.indexBuilds).toBe(1); + expect(container.textContent).toContain('Verify the thing'); + }); +});