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
53 changes: 53 additions & 0 deletions apps/agent-forge/e2e/docShots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1379,3 +1379,56 @@ test('evals x: queue review, queue is empty', async ({ page }) => {
await expect(main(page).getByText('Queue is empty')).toBeVisible();
await shoot(page, 'x-evals-queue-empty');
});

test('x-traces: a tool step selected, tree and waterfall', async ({ page }) => {
await page.route(`**/observe/api/v1/traces/${LONG_TRACE}`, (route) => json(route, longTraceDetail()));
await open(page, `/traces/${LONG_TRACE}?obs=${LONG_TRACE}-sub-0-gen-0-tool`);
const main = page.getByRole('main');
await expect(main.locator('.run-inspector')).toHaveAttribute('data-obs-id', `${LONG_TRACE}-sub-0-gen-0-tool`);
await shoot(page, 'x-trace-tool-selected');
await main.getByRole('button', { name: 'Waterfall' }).click();
await shoot(page, 'x-trace-tool-selected-waterfall');
});

test('x-shell: the ? shortcut sheet, at 1280 and 375 wide', async ({ page }) => {
await open(page, '/traces');
await expect(rows(page).first()).toBeVisible();
await page.keyboard.press('?');
await expect(page.getByRole('dialog', { name: 'Keyboard shortcuts' })).toBeVisible();
await shoot(page, 'x-shortcut-sheet');
await page.setViewportSize({ width: 375, height: 800 });
await shoot(page, 'x-shortcut-sheet-375');
});

test('x-shell: the error boundary after a render crash, at 1280 and 375 wide', async ({ page }) => {
// A session id the thread list renders as text, sent as an object: React cannot render it, and the app-level boundary catches it.
await page.route(/\/observe\/api\/v1\/sessions(\?|$)/, (route) =>
json(route, { sessions: [{ ...sessions[0], session_id: { broken: true } }], total: 1, limit: 50, offset: 0 }),
);
await page.goto(`${APP}#/threads`);
await expect(page.getByRole('alert').getByText('Something went wrong')).toBeVisible();
await shoot(page, 'x-error-boundary');
await page.setViewportSize({ width: 375, height: 800 });
await shoot(page, 'x-error-boundary-375');
});

test('x-traces: a running trace in the waterfall (motion and reduced motion)', async ({ page }) => {
// The root, the last plan step, its sub-agent, one tool call and the final generation still open (end: null).
const detail = longTraceDetail();
const openIds = new Set([`${LONG_TRACE}-span-2`, `${LONG_TRACE}-sub-2`, `${LONG_TRACE}-sub-2-gen-1-tool`, `${LONG_TRACE}-final`]);
// Shifted so the run started ~11s ago (the fixture's is 20 minutes back): the open bars run to "now" at a readable scale.
const shift = 20 * MINUTE - 11_000;
const observations = detail.observations.map((o) => {
const moved = { ...o, start: Number(o.start) + shift, end: o.end === null ? null : Number(o.end) + shift };
return o.parent_obs_id === null || openIds.has(String(o.obs_id)) ? { ...moved, end: null, status: 'ok', attrs: null } : moved;
});
await page.route(`**/observe/api/v1/traces/${LONG_TRACE}`, (route) => json(route, { ...detail, observations }));
await open(page, `/traces/${LONG_TRACE}`);
const main = page.getByRole('main');
await main.getByRole('button', { name: 'Waterfall' }).click();
await expect(main.locator('.run-wf-bar.running').first()).toBeVisible();
await main.locator('.run-wf-bar.running').last().scrollIntoViewIfNeeded();
await shoot(page, 'x-trace-running-waterfall');
await page.emulateMedia({ reducedMotion: 'reduce' });
await shoot(page, 'x-trace-running-waterfall-reduced');
});
85 changes: 60 additions & 25 deletions apps/agent-forge/src/components/CanvasArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
MiniMap,
ReactFlow,
SmoothStepEdge,
getNodesBounds,
getViewportForBounds,
useReactFlow,
useStoreApi,
type EdgeProps,
type Connection,
type Edge,
Expand Down Expand Up @@ -114,9 +117,19 @@ function GappedSmoothStepEdge(props: EdgeProps) {

const EDGE_TYPES = { gapped: GappedSmoothStepEdge };

/** Every fit (on load, on resize, the toolbar): the whole graph, never zoomed in past 1:1. */
/** The toolbar's and the Controls' Fit view: the whole graph, never zoomed in past 1:1. */
const FIT_VIEW_OPTIONS = { padding: 0.15, maxZoom: 1 };

/**
* The automatic fits (on load, on resize, after auto layout) stop zooming out
* here, so node text stays legible; a graph too big for the pane at this zoom
* opens pinned to its top-left corner (where the trigger sits) instead of
* centred. The user can still zoom out to MIN_ZOOM, or press Fit view.
*/
const AUTO_FIT_MIN_ZOOM = 0.75;
/** The inset (screen px) of a pinned graph's left/top edge. */
const AUTO_FIT_INSET = 40;

/**
* React Flow's default floor (0.5) is too high for a 4-column graph in the
* builder's ~540px pane at 1280px wide: the fit stopped at 0.5 and cut the
Expand Down Expand Up @@ -321,56 +334,80 @@ function useDropHandlers(graph: AgentGraphSpec, setGraph: SetGraph, flashError:
return { onDragOver, onDrop };
}

/**
* An automatic fit: the whole graph when it fits at AUTO_FIT_MIN_ZOOM or more
* (never past 1:1), else that zoom with the graph's top-left corner in view.
*/
function useLegibleFit() {
const { getNodes, setViewport } = useReactFlow();
const store = useStoreApi();
return useCallback(
(duration?: number) => {
const nodes = getNodes();
const { width, height } = store.getState();
if (nodes.length === 0 || !width || !height) return;
const bounds = getNodesBounds(nodes);
const fit = getViewportForBounds(bounds, width, height, AUTO_FIT_MIN_ZOOM, 1, FIT_VIEW_OPTIONS.padding);
const x = bounds.width * fit.zoom > width ? AUTO_FIT_INSET - bounds.x * fit.zoom : fit.x;
const y = bounds.height * fit.zoom > height ? AUTO_FIT_INSET - bounds.y * fit.zoom : fit.y;
void setViewport({ x, y, zoom: fit.zoom }, duration ? { duration } : undefined);
},
[getNodes, setViewport, store]
);
}

/**
* Refit the graph when the window is resized (e.g. rotating a
* phone, or crossing the narrow-layout breakpoint), so it never sits
* off-screen in a pane that changed size. Debounced to the last resize.
*/
function useFitViewOnResize() {
const { fitView } = useReactFlow();
const fitLegibly = useLegibleFit();
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined;
function onResize() {
if (timer) clearTimeout(timer);
timer = setTimeout(() => void fitView({ ...FIT_VIEW_OPTIONS, duration: 150 }), 150);
timer = setTimeout(() => fitLegibly(150), 150);
}
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
if (timer) clearTimeout(timer);
};
}, [fitView]);
}, [fitLegibly]);
}

/**
* Fit the whole graph when an agent is opened and when an empty canvas is
* filled (a template applied, or the first node dropped) - React Flow's own
* `fitView` prop only fits once, on mount. Waits until every node has been
* measured, so the fit uses real card sizes.
* The one owner of the first fit: when an agent is opened and when an empty
* canvas is filled (a template applied, or the first node dropped). Waits
* until every node has been measured, so the fit uses real card sizes, and
* returns `true` until that fit has landed - the canvas keeps its viewport
* hidden meanwhile, so the graph never shows unfitted and then jumps.
*/
function useFitViewOnLoad(agentId: string, graph: AgentGraphSpec, measured: MeasuredSizes) {
const { fitView } = useReactFlow();
const pending = useRef(true);
function useFitViewOnLoad(agentId: string, graph: AgentGraphSpec, measured: MeasuredSizes): boolean {
const fitLegibly = useLegibleFit();
const loadKey = `${agentId}|${graph.nodes.length > 0 ? 'filled' : 'empty'}`;
useEffect(() => {
pending.current = true;
}, [loadKey]);
const [fittedKey, setFittedKey] = useState<string | null>(null);
const allMeasured = graph.nodes.length > 0 && graph.nodes.every((n) => measured[n.id]);
useEffect(() => {
if (!pending.current || !allMeasured) return;
pending.current = false;
const frame = window.requestAnimationFrame(() => void fitView(FIT_VIEW_OPTIONS));
if (fittedKey === loadKey || !allMeasured) return;
const frame = window.requestAnimationFrame(() => {
fitLegibly();
setFittedKey(loadKey);
});
return () => window.cancelAnimationFrame(frame);
}, [loadKey, allMeasured, fitView]);
}, [loadKey, fittedKey, allMeasured, fitLegibly]);
return graph.nodes.length > 0 && fittedKey !== loadKey;
}

/** Toolbar actions (auto layout, fit view, duplicate) plus the Ctrl/Cmd+D shortcut. */
function useToolbarActions(setGraph: SetGraph, selectedNodeId: string | undefined) {
const { fitView } = useReactFlow();
const fitLegibly = useLegibleFit();

function handleAutoLayout() {
setGraph((g) => autoLayout(g));
window.requestAnimationFrame(() => fitView({ ...FIT_VIEW_OPTIONS, duration: 200 }));
window.requestAnimationFrame(() => fitLegibly(200));
}

function handleFitView() {
Expand Down Expand Up @@ -462,7 +499,7 @@ function CanvasNavigator() {
position="bottom-right"
pannable
zoomable
style={{ width: 160, height: 110 }}
style={{ width: 160, height: 100 }}
nodeClassName={miniMapNodeClassName}
nodeBorderRadius={6}
ariaLabel="Graph overview"
Expand Down Expand Up @@ -518,10 +555,10 @@ function CanvasInner() {
const { onDragOver, onDrop } = useDropHandlers(graph, setGraph, flashError);
const { handleAutoLayout, handleFitView, handleDuplicate } = useToolbarActions(setGraph, selectedNodeId);
useFitViewOnResize();
useFitViewOnLoad(agentId, graph, measured);
const fitting = useFitViewOnLoad(agentId, graph, measured);

return (
<main className="canvas-wrap" aria-label="Agent graph" onDrop={onDrop} onDragOver={onDragOver}>
<main className="canvas-wrap" aria-label="Agent graph" data-fitting={fitting || undefined} onDrop={onDrop} onDragOver={onDragOver}>
<CanvasToolbar
canDuplicate={!!selectedNodeId}
onAutoLayout={handleAutoLayout}
Expand All @@ -545,12 +582,10 @@ function CanvasInner() {
onConnect={onConnect}
isValidConnection={isValidConnection}
deleteKeyCode={['Backspace', 'Delete']}
fitView
fitViewOptions={FIT_VIEW_OPTIONS}
minZoom={MIN_ZOOM}
proOptions={{ hideAttribution: true }}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={2} color="var(--border-strong)" />
<Background variant={BackgroundVariant.Dots} gap={20} size={2} color="var(--canvas-dot)" />
{/* Nothing to overview or zoom on an empty canvas; the template card has the room. */}
{graph.nodes.length > 0 && <CanvasNavigator />}
</ReactFlow>
Expand Down
32 changes: 20 additions & 12 deletions apps/agent-forge/src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RotateCw } from 'lucide-react';

interface ErrorBoundaryProps {
children: ReactNode;
Expand All @@ -12,7 +13,8 @@ interface ErrorBoundaryState {
/**
* The top-level error boundary. A render error anywhere in the
* studio used to unmount the whole tree and leave a blank page; now it shows
* what broke and a way out (retry, or reload).
* what broke and a way out (reload, or retry): an error card centred on the
* canvas with the message in mono.
*/
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: undefined };
Expand All @@ -31,17 +33,23 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
const { error } = this.state;
if (!error) return this.props.children;
return (
<div className="error-boundary" role="alert">
<h1>Something went wrong</h1>
<p>Agent Forge hit an error it could not recover from:</p>
<pre>{error.message}</pre>
<div className="error-boundary-actions">
<button className="btn btn-primary" onClick={this.retry}>
Try again
</button>
<button className="btn" onClick={() => window.location.reload()}>
Reload
</button>
<div className="error-boundary-screen">
<div className="error-boundary" role="alert">
<span className="error-boundary-icon" aria-hidden="true">
<AlertTriangle size={20} strokeWidth={1.75} />
</span>
<h1>Something went wrong</h1>
<p className="error-boundary-hint">Agent Forge hit an error it could not recover from. Try again, or reload the Studio.</p>
<pre className="error-boundary-message">{error.message}</pre>
<div className="error-boundary-actions">
<button type="button" className="btn btn-primary" onClick={() => window.location.reload()}>
<RotateCw aria-hidden="true" />
Reload
</button>
<button type="button" className="btn" onClick={this.retry}>
Try again
</button>
</div>
</div>
</div>
);
Expand Down
14 changes: 8 additions & 6 deletions apps/agent-forge/src/components/debug/HistoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import type { ForkRunRequest, RunComparisonPayload, RunHistoryStep } from '../..

type ForkPatch = NonNullable<ForkRunRequest['patch']>;


const NUMERIC_COLUMNS = new Set(['Step', 'Tokens', 'Cost']);

/** A value the step does not have: a faint em dash. */
const MISSING = <span className="value-missing">—</span>;

/** One row per checkpointed step of the run, each with an "Edit and replay from here" action. */
export function HistoryList({ steps, onReplay }: { steps: RunHistoryStep[]; onReplay: (step: RunHistoryStep) => void }) {
return (
Expand All @@ -32,10 +34,10 @@ export function HistoryList({ steps, onReplay }: { steps: RunHistoryStep[]; onRe
<td>
<TonePill tone={stepTone(s.status)} label={s.status} />
</td>
<td className="mono">{s.finishReason ?? '-'}</td>
<td className="mono">{s.toolCalls.map((call) => call.name).join(', ') || '-'}</td>
<td className="num">{s.tokens ?? '-'}</td>
<td className="num">{s.costUsd === undefined ? '-' : `$${s.costUsd.toFixed(4)}`}</td>
<td className="mono">{s.finishReason ?? MISSING}</td>
<td className="mono">{s.toolCalls.map((call) => call.name).join(', ') || MISSING}</td>
<td className="num">{s.tokens ?? MISSING}</td>
<td className="num">{s.costUsd === undefined ? MISSING : `$${s.costUsd.toFixed(4)}`}</td>
<td className="history-action">
<button className="btn btn-ghost btn-sm" onClick={() => onReplay(s)}>
<RotateCcw size={14} strokeWidth={1.75} aria-hidden="true" />
Expand Down Expand Up @@ -86,7 +88,7 @@ function ReplayEditor({ step, onFork, onCancel }: { step: RunHistoryStep; onFork
}

function StepCell({ step }: { step: TrajectoryStep | undefined }) {
if (!step) return <div className="traj-cell traj-missing">-</div>;
if (!step) return <div className="traj-cell traj-missing">—</div>;
return (
<div className="traj-cell">
{step.text && <div>{step.text}</div>}
Expand Down
6 changes: 4 additions & 2 deletions apps/agent-forge/src/components/debug/TraceHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react';
import type { TraceSummaryPayload } from '../../../shared/wireTypes';
import { statusTone } from '../../ui/StatusPill';
import { TonePill } from './TonePill';
Expand All @@ -9,8 +10,9 @@ function formatDuration(ms: number): string {
return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(2)}s`;
}

function formatCost(costUsd: number | undefined): string {
return costUsd === undefined ? '-' : `$${costUsd.toFixed(costUsd < 0.01 ? 6 : 4)}`;
/** The cost, or a faint em dash when the run reported none. */
function formatCost(costUsd: number | undefined): ReactNode {
return costUsd === undefined ? <span className="value-missing">—</span> : `$${costUsd.toFixed(costUsd < 0.01 ? 6 : 4)}`;
}

/** One line per persisted run: when, how long, model calls, tokens in/out, cost and status. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe('HistoryList', () => {
);
expect(rows).toEqual([
['1', 'in-progress', 'tool_calls', 'get_weather', '42', '$0.0002'],
['2', 'finished', 'stop', '-', '30', '-'],
['2', 'finished', 'stop', '—', '30', '—'],
]);
});

Expand All @@ -83,7 +83,7 @@ describe('TrajectoryCompare', () => {
expect(rows[0].textContent).toContain('{"tempF":68}');
expect(rows[0].textContent).toContain('{"tempC":20}');
expect(rows[1].classList.contains('diverged')).toBe(false);
expect(rows[2].querySelector('.traj-missing')?.textContent).toBe('-'); // the original has no third turn
expect(rows[2].querySelector('.traj-missing')?.textContent).toBe('—'); // the original has no third turn

expect(container.querySelector('.traj-drift')?.textContent).toBe('steps: 2 -> 3');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('TraceHistory', () => {
expect(rows[0].textContent).toContain('$0.000043');
expect(rows[0].textContent).toContain('error');
expect(rows[1].textContent).toContain('420ms');
expect(rows[1].textContent).toContain('-');
expect(rows[1].querySelector('.value-missing')?.textContent).toBe('—');
expect(rows[1].getAttribute('aria-selected')).toBe('true');
});

Expand Down
Loading
Loading