Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/web-shell/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -17935,6 +17957,7 @@ export function App({
todos: sessionWorkflowTodos,
tools: planAgentTools,
tasks: environmentAgentTasks,
projection: sessionWorkflowProjection,
artifacts,
selectedTodoId: selectedWorkflowTodoId,
onSelectedTodoIdChange: setSelectedWorkflowTodoId,
Expand Down Expand Up @@ -19395,6 +19418,7 @@ export function App({
todos={sessionWorkflowTodos}
tools={planAgentTools}
tasks={environmentAgentTasks}
projection={sessionWorkflowProjection}
selectedTodoId={selectedWorkflowTodoId}
onSelectedTodoIdChange={setSelectedWorkflowTodoId}
onBackToChat={closeCockpit}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('./PlanExecutionView')>();
return {
...actual,
layerPlanTodos: (...args: Parameters<typeof actual.layerPlanTodos>) => {
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(
<I18nProvider language="en">
<TranscriptRenderModeProvider>
<PlanExecutionView todos={todos} tools={[]} tasks={[]} />
</TranscriptRenderModeProvider>
</I18nProvider>,
);
});
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();
});
});
Loading
Loading