From 61c527555efeeb0ac94c31389d9c40d1ba145d40 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 13:47:07 +0300 Subject: [PATCH 1/7] style(studio): kit - card header centred on its action, button icons honour an explicit size, value-missing utility .card-header aligns items center with a 20px title line box; .btn svg sizes only lucide-default (24) or unsized icons (16px, 14px in .btn-sm) so an explicit size wins; .value-missing is the faint em dash for absent values. Co-Authored-By: Claude Opus 5.5 --- apps/agent-forge/src/ui/base.css | 20 ++++++++++++++++++-- apps/agent-forge/src/ui/card.css | 5 +++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/agent-forge/src/ui/base.css b/apps/agent-forge/src/ui/base.css index ab89f38e3..dd121136f 100644 --- a/apps/agent-forge/src/ui/base.css +++ b/apps/agent-forge/src/ui/base.css @@ -26,6 +26,12 @@ border: 0; } +/* a value the row does not have, in a key/value grid or a table cell: a faint em dash. */ +.value-missing { + color: var(--text-faint); + font-weight: 400; +} + /* ---------- Type utilities ---------- */ .overline { font-size: var(--fs-overline); @@ -99,12 +105,22 @@ kbd { outline: 2px solid var(--accent); outline-offset: 2px; } +/* Icons: 16px in a button, 14px in a small one - but only lucide's default (size 24) or an unsized svg; + an explicit `size` (width attribute) wins. */ .btn svg { - width: 16px; - height: 16px; stroke-width: 1.75; flex: none; } +.btn svg:not([width]), +.btn svg[width='24'] { + width: 16px; + height: 16px; +} +.btn-sm svg:not([width]), +.btn-sm svg[width='24'] { + width: 14px; + height: 14px; +} .btn-sm { min-height: 28px; padding: 0 10px; diff --git a/apps/agent-forge/src/ui/card.css b/apps/agent-forge/src/ui/card.css index a47af10fb..889ec90ef 100644 --- a/apps/agent-forge/src/ui/card.css +++ b/apps/agent-forge/src/ui/card.css @@ -13,7 +13,8 @@ .card-header { display: flex; flex-wrap: wrap; - align-items: flex-start; + /* centred: a one-line title sits on the middle of a 28/32px action button (its 20px line box, below). */ + align-items: center; justify-content: space-between; gap: var(--space-2) var(--space-4); padding: var(--space-4) var(--space-5) 0; @@ -25,7 +26,7 @@ margin: 0; font-size: var(--fs-body); font-weight: 600; - line-height: 1.4; + line-height: 20px; color: var(--text); } .card-description { From 8985663717ecfd84ccf4052645a01df8d4ecf42d Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 13:47:08 +0300 Subject: [PATCH 2/7] style(studio): Agents canvas - legible auto-fit, 160x100 minimap, visible dark dot grid Load/resize/auto-layout fits stop at zoom 0.75 (max 1) and pin an oversized graph to its top-left so the trigger stays in view; the toolbar Fit view still fits the whole graph. The dot grid lifts toward --text-faint in dark only. Co-Authored-By: Claude Opus 5.5 --- .../agent-forge/src/components/CanvasArea.tsx | 59 +++++++++++++++---- .../agent-forge/src/screens/agents/agents.css | 3 + 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/apps/agent-forge/src/components/CanvasArea.tsx b/apps/agent-forge/src/components/CanvasArea.tsx index 37500e7d2..b4fd33590 100644 --- a/apps/agent-forge/src/components/CanvasArea.tsx +++ b/apps/agent-forge/src/components/CanvasArea.tsx @@ -8,7 +8,10 @@ import { MiniMap, ReactFlow, SmoothStepEdge, + getNodesBounds, + getViewportForBounds, useReactFlow, + useStoreApi, type EdgeProps, type Connection, type Edge, @@ -114,9 +117,20 @@ 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; +const AUTO_FIT_OPTIONS = { ...FIT_VIEW_OPTIONS, minZoom: AUTO_FIT_MIN_ZOOM }; +/** 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 @@ -321,25 +335,47 @@ 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 | 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]); } /** @@ -349,7 +385,7 @@ function useFitViewOnResize() { * measured, so the fit uses real card sizes. */ function useFitViewOnLoad(agentId: string, graph: AgentGraphSpec, measured: MeasuredSizes) { - const { fitView } = useReactFlow(); + const fitLegibly = useLegibleFit(); const pending = useRef(true); const loadKey = `${agentId}|${graph.nodes.length > 0 ? 'filled' : 'empty'}`; useEffect(() => { @@ -359,18 +395,19 @@ function useFitViewOnLoad(agentId: string, graph: AgentGraphSpec, measured: Meas useEffect(() => { if (!pending.current || !allMeasured) return; pending.current = false; - const frame = window.requestAnimationFrame(() => void fitView(FIT_VIEW_OPTIONS)); + const frame = window.requestAnimationFrame(() => fitLegibly()); return () => window.cancelAnimationFrame(frame); - }, [loadKey, allMeasured, fitView]); + }, [loadKey, allMeasured, fitLegibly]); } /** 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() { @@ -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" @@ -546,11 +583,11 @@ function CanvasInner() { isValidConnection={isValidConnection} deleteKeyCode={['Backspace', 'Delete']} fitView - fitViewOptions={FIT_VIEW_OPTIONS} + fitViewOptions={AUTO_FIT_OPTIONS} minZoom={MIN_ZOOM} proOptions={{ hideAttribution: true }} > - + {/* Nothing to overview or zoom on an empty canvas; the template card has the room. */} {graph.nodes.length > 0 && } diff --git a/apps/agent-forge/src/screens/agents/agents.css b/apps/agent-forge/src/screens/agents/agents.css index 0614535bc..e823ec7ab 100644 --- a/apps/agent-forge/src/screens/agents/agents.css +++ b/apps/agent-forge/src/screens/agents/agents.css @@ -384,6 +384,9 @@ button.breakpoint-toggle { position: relative; overflow: hidden; background: var(--bg); + /* The dot grid (CanvasArea.tsx's ): --border-strong stays subtle in light; on the + near-black dark canvas it all but vanishes, so dark lifts it halfway to --text-faint. */ + --canvas-dot: light-dark(var(--border-strong), color-mix(in srgb, var(--border-strong) 55%, var(--text-faint))); } .canvas-wrap .react-flow { --xy-background-color: var(--bg); From 07cded09a74485ae0923a45e5fc137abd3422153 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 13:47:09 +0300 Subject: [PATCH 3/7] feat(studio): trace detail - no dash chips, faint em dash in grids, one Playground link per place, tool steps in the info tone Header chips with no value are left out (trace tokens/cost/model, thread span); inspector usage cells and null scores show a faint em dash; the trace screen drops the inspector Open in Playground (header button and tree icon link stay); tool kind is info in tree and waterfall, the agent root takes the accent. Co-Authored-By: Claude Opus 5.5 --- .../src/screens/threads/ThreadScreen.tsx | 9 ++- .../src/screens/traces/TraceHeader.tsx | 29 +++++---- .../src/screens/traces/TraceScreen.tsx | 2 + .../traces/__tests__/TraceHeader.test.tsx | 60 +++++++++++++++++++ .../traces/__tests__/TraceScreen.test.tsx | 6 +- apps/agent-forge/src/ui/run/RunInspector.tsx | 22 ++++--- apps/agent-forge/src/ui/run/TraceViewer.tsx | 14 ++++- .../src/ui/run/__tests__/run.test.tsx | 20 +++++++ apps/agent-forge/src/ui/run/run.css | 14 +++-- 9 files changed, 145 insertions(+), 31 deletions(-) create mode 100644 apps/agent-forge/src/screens/traces/__tests__/TraceHeader.test.tsx diff --git a/apps/agent-forge/src/screens/threads/ThreadScreen.tsx b/apps/agent-forge/src/screens/threads/ThreadScreen.tsx index 53b9315cc..1dc36c472 100644 --- a/apps/agent-forge/src/screens/threads/ThreadScreen.tsx +++ b/apps/agent-forge/src/screens/threads/ThreadScreen.tsx @@ -133,9 +133,12 @@ function ThreadHeader({ session, now }: { session: ObserveSessionRow; now: numbe {fmtCost(session.cost_usd)} - - {fmtDuration(session.ended_at === null ? null : session.ended_at - session.started_at)} - + {/* An active thread has no span yet: no chip rather than a dash. */} + {session.ended_at !== null && ( + + {fmtDuration(session.ended_at - session.started_at)} + + )} {fmtDateTime(session.started_at)} diff --git a/apps/agent-forge/src/screens/traces/TraceHeader.tsx b/apps/agent-forge/src/screens/traces/TraceHeader.tsx index 0227672b2..2b7c435d2 100644 --- a/apps/agent-forge/src/screens/traces/TraceHeader.tsx +++ b/apps/agent-forge/src/screens/traces/TraceHeader.tsx @@ -56,7 +56,8 @@ interface TraceHeaderProps { * current URL, `?obs=` included), "Add to dataset", "Add to queue" and, when * the trace has a generation, "Open in Playground" - then metric chips: * duration (counting to `now` while running), tokens, cost, model, start, - * agent (to the agent-filtered trace list) and session (to its thread). + * agent (to the agent-filtered trace list) and session (to its thread). A + * chip whose value the trace does not have is left out, not shown as a dash. */ export function TraceHeader({ root, observations, agentName, now, selectedObsId }: TraceHeaderProps) { const { tokens, cost } = traceTotals(observations); @@ -86,16 +87,22 @@ export function TraceHeader({ root, observations, agentName, now, selectedObsId {fmtDuration(elapsedMs(root, now, latestEnd(observations)))} - - {fmtInt(tokens)} - tokens - - - {fmtCost(cost)} - - - {model ? {model} : '-'} - + {tokens !== null && ( + + {fmtInt(tokens)} + tokens + + )} + {cost !== null && ( + + {fmtCost(cost)} + + )} + {model && ( + + {model} + + )} {fmtDateTime(root.start)} diff --git a/apps/agent-forge/src/screens/traces/TraceScreen.tsx b/apps/agent-forge/src/screens/traces/TraceScreen.tsx index 8c6e5db96..811e3afd9 100644 --- a/apps/agent-forge/src/screens/traces/TraceScreen.tsx +++ b/apps/agent-forge/src/screens/traces/TraceScreen.tsx @@ -129,6 +129,8 @@ export function TraceScreen({ traceId, pollMs = DEFAULT_POLL_MS }: { traceId: st onModeChange={setMode} now={now} renderFeedback={renderFeedback} + /* The header card's "Open in Playground" and the tree row's icon link already cover the selected step. */ + inspectorPlaygroundLink={false} /> ); diff --git a/apps/agent-forge/src/screens/traces/__tests__/TraceHeader.test.tsx b/apps/agent-forge/src/screens/traces/__tests__/TraceHeader.test.tsx new file mode 100644 index 000000000..ec2b8f0da --- /dev/null +++ b/apps/agent-forge/src/screens/traces/__tests__/TraceHeader.test.tsx @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { render } from '../../../ui/__tests__/render'; +import { ToastProvider } from '../../../ui/Toast'; +import type { ObserveObservationRow } from '../../../observe/types'; +import { TraceHeader } from '../TraceHeader'; + +const T0 = 1_700_000_000_000; + +function obs(over: Partial & { obs_id: string }): ObserveObservationRow { + return { + trace_id: 't1', + session_id: null, + agent_id: null, + parent_obs_id: null, + kind: 'agent', + name: over.obs_id, + start: T0, + end: T0 + 100, + status: 'ok', + input: null, + output: null, + model: null, + usage_input_tokens: null, + usage_output_tokens: null, + usage_cached_tokens: null, + usage_reasoning_tokens: null, + cost_usd: null, + cost_partial: null, + usage_estimated: null, + truncated: false, + attrs: null, + ...over, + }; +} + +const chips = (c: HTMLElement) => [...c.querySelectorAll('.header-card-fact')].map((el) => el.getAttribute('title')); + +const mount = (rows: ObserveObservationRow[]) => + render( + + + , + ); + +describe('TraceHeader', () => { + it('leaves out the tokens, cost and model chips when the trace has none (no "-" chips)', () => { + const c = mount([obs({ obs_id: 'root' }), obs({ obs_id: 'tool', parent_obs_id: 'root', kind: 'tool' })]); + expect(chips(c)).toEqual(['Duration', 'Start']); + expect(c.querySelector('.header-card-facts')!.textContent).not.toMatch(/(^|\s)-(\s|$)/); + }); + + it('shows them once a step reports usage, cost and a model', () => { + const c = mount([ + obs({ obs_id: 'root' }), + obs({ obs_id: 'gen', parent_obs_id: 'root', kind: 'generation', model: 'gpt-4o', usage_input_tokens: 10, usage_output_tokens: 2, cost_usd: 0.001 }), + ]); + expect(chips(c)).toEqual(['Duration', 'Tokens', 'Cost', 'Model', 'Start']); + }); +}); diff --git a/apps/agent-forge/src/screens/traces/__tests__/TraceScreen.test.tsx b/apps/agent-forge/src/screens/traces/__tests__/TraceScreen.test.tsx index aae12a5d6..466a3147f 100644 --- a/apps/agent-forge/src/screens/traces/__tests__/TraceScreen.test.tsx +++ b/apps/agent-forge/src/screens/traces/__tests__/TraceScreen.test.tsx @@ -187,9 +187,11 @@ describe('TraceScreen', () => { expect(window.location.hash).toBe('#/traces/t1?obs=gen1'); expect(window.history.length).toBe(before); await waitFor(() => inspectedId(c) === 'gen1'); - // The observe trace screen keeps the generation's Playground links (row and inspector). + // The observe trace screen keeps the row's icon link and the header button for the selected generation; + // the inspector drops its duplicate. expect(c.querySelector('.run-tree [data-obs-id="gen1"] .run-playground-link')?.getAttribute('href')).toBe('#/playground?trace=t1&fromObs=gen1'); - expect(c.querySelector('.run-inspector .run-playground-link')).not.toBeNull(); + expect(c.querySelector('.trace-header a[href="#/playground?trace=t1&fromObs=gen1"]')).not.toBeNull(); + expect(c.querySelector('.run-inspector .run-playground-link')).toBeNull(); }); it('shows an EmptyState with "Back to traces" for a missing trace', async () => { diff --git a/apps/agent-forge/src/ui/run/RunInspector.tsx b/apps/agent-forge/src/ui/run/RunInspector.tsx index 803aeb674..431a906d3 100644 --- a/apps/agent-forge/src/ui/run/RunInspector.tsx +++ b/apps/agent-forge/src/ui/run/RunInspector.tsx @@ -39,6 +39,14 @@ function errorText(attrs: Record | null): string { return 'This step failed.'; } +/** A value the step does not have, in a key/value grid: a faint em dash. */ +const MISSING = —; + +/** `format(value)`, or {@link MISSING} for null/undefined. */ +function orMissing(value: T | null | undefined, format: (v: T) => ReactNode): ReactNode { + return value === null || value === undefined ? MISSING : format(value); +} + function scoresFor(row: ObserveObservationRow, scores: ObserveScoreRow[]): ObserveScoreRow[] { const isRoot = row.parent_obs_id === null; return scores.filter( @@ -62,7 +70,7 @@ function Feedback({ scores }: { scores: ObserveScoreRow[] }) { {scores.map((s) => (
  • {s.name} - {s.value === null ? '-' : String(s.value)} + {orMissing(s.value, String)} {s.source} {s.comment && {s.comment}}
  • @@ -74,18 +82,18 @@ function Feedback({ scores }: { scores: ObserveScoreRow[] }) { function Usage({ row }: { row: ObserveObservationRow }) { const markers = [row.cost_partial && 'partial', row.usage_estimated && 'estimated'].filter(Boolean); const facts: [string, ReactNode][] = [ - ['Input tokens', fmtInt(row.usage_input_tokens)], - ['Output tokens', fmtInt(row.usage_output_tokens)], - ['Cached tokens', fmtInt(row.usage_cached_tokens)], - ['Reasoning tokens', fmtInt(row.usage_reasoning_tokens)], + ['Input tokens', orMissing(row.usage_input_tokens, fmtInt)], + ['Output tokens', orMissing(row.usage_output_tokens, fmtInt)], + ['Cached tokens', orMissing(row.usage_cached_tokens, fmtInt)], + ['Reasoning tokens', orMissing(row.usage_reasoning_tokens, fmtInt)], [ 'Cost', <> - {fmtCost(row.cost_usd)} + {orMissing(row.cost_usd, fmtCost)} {markers.length > 0 && ({markers.join(', ')})} , ], - ['Model', row.model ? {row.model} : '-'], + ['Model', row.model ? {row.model} : MISSING], ]; return (
    diff --git a/apps/agent-forge/src/ui/run/TraceViewer.tsx b/apps/agent-forge/src/ui/run/TraceViewer.tsx index fdc3532a3..f51571d91 100644 --- a/apps/agent-forge/src/ui/run/TraceViewer.tsx +++ b/apps/agent-forge/src/ui/run/TraceViewer.tsx @@ -17,7 +17,9 @@ export type TraceViewMode = 'tree' | 'waterfall'; * mode; pass `onModeChange` to get a Tree/Waterfall toggle. `now` is the clock * open observations run to (defaults to `Date.now()`). `renderFeedback` is * handed to the inspector (see {@link RunInspectorProps}). `playgroundLinks: - * false` hides the "Open in Playground" links (default true). + * false` hides the "Open in Playground" links (default true); + * `inspectorPlaygroundLink: false` hides only the inspector's one, for a + * screen whose own header already carries it (the trace screen). */ export interface TraceViewerProps { observations: ObserveObservationRow[]; @@ -29,6 +31,7 @@ export interface TraceViewerProps { now?: number; renderFeedback?: RunInspectorProps['renderFeedback']; playgroundLinks?: boolean; + inspectorPlaygroundLink?: boolean; } const MODES: { mode: TraceViewMode; label: string; icon: LucideIcon }[] = [ @@ -85,6 +88,7 @@ export function TraceViewer({ now, renderFeedback, playgroundLinks = true, + inspectorPlaygroundLink = true, }: TraceViewerProps) { const inspected = useMemo(() => pickInspected(observations, selectedObsId), [observations, selectedObsId]); if (inspected === undefined) { @@ -98,7 +102,13 @@ export function TraceViewer({ {mode === 'waterfall' ? : }
    - +
    ); diff --git a/apps/agent-forge/src/ui/run/__tests__/run.test.tsx b/apps/agent-forge/src/ui/run/__tests__/run.test.tsx index 18263a25a..f5cc0f6f1 100644 --- a/apps/agent-forge/src/ui/run/__tests__/run.test.tsx +++ b/apps/agent-forge/src/ui/run/__tests__/run.test.tsx @@ -283,9 +283,29 @@ describe('RunInspector', () => { }); }); +describe('RunInspector missing values', () => { + it('shows a faint em dash for usage, cost, model and a score the step does not have', () => { + const row = obs({ obs_id: 'bare', kind: 'tool' }); + const c = render(); + const dashes = [...c.querySelectorAll('.value-missing')].map((el) => el.textContent); + // four token counts, cost, model, and the null score value + expect(dashes).toEqual(['—', '—', '—', '—', '—', '—', '—']); + expect(c.querySelector('[data-section="usage"]')!.textContent).not.toContain('-'); + }); +}); + describe('TraceViewer', () => { const inspected = (c: HTMLElement) => (c.querySelector('.run-inspector') as HTMLElement).dataset.obsId; + it('drops only the inspector Playground link with inspectorPlaygroundLink={false}', () => { + const rows = [obs({ obs_id: 'g', kind: 'generation' })]; + const c = render( + undefined} mode="tree" inspectorPlaygroundLink={false} />, + ); + expect(c.querySelectorAll('.run-tree .run-playground-link')).toHaveLength(1); + expect(c.querySelector('.run-inspector .run-playground-link')).toBeNull(); + }); + it('hides every Playground link with playgroundLinks={false}', () => { const rows = [obs({ obs_id: 'g', kind: 'generation' })]; const shown = render( undefined} mode="tree" />); diff --git a/apps/agent-forge/src/ui/run/run.css b/apps/agent-forge/src/ui/run/run.css index df83e9678..becd31cea 100644 --- a/apps/agent-forge/src/ui/run/run.css +++ b/apps/agent-forge/src/ui/run/run.css @@ -170,17 +170,19 @@ background: var(--surface-2); color: var(--text-faint); } +/* Kind colours match the canvas (canvas/nodeKinds.ts): tool = info, generation = violet; amber is for warning status only. + The agent root takes the accent, so it stays apart from the (blue) tool steps. */ .run-kind-agent { - background: color-mix(in srgb, var(--chart-2) 16%, var(--surface)); - color: var(--chart-2); + background: var(--accent-soft); + color: var(--accent); } .run-kind-generation { background: var(--violet-soft); color: var(--violet); } .run-kind-tool { - background: var(--warning-soft); - color: var(--warning); + background: var(--info-soft); + color: var(--info); } .run-kind-span { background: var(--surface-3); @@ -386,13 +388,13 @@ background: var(--accent); } .run-wf-bar-agent { - background: var(--chart-2); + background: var(--accent); } .run-wf-bar-generation { background: var(--violet); } .run-wf-bar-tool { - background: var(--warning); + background: var(--info); } .run-wf-bar-span { background: color-mix(in srgb, var(--text-faint) 55%, transparent); From 619cb293199f100f61152fb2ae31aa260b4b4467 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 13:47:09 +0300 Subject: [PATCH 4/7] style(studio): sweep - faint em dash for missing table values, skeleton-only eval detail loading, hub TTL separator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fleet p95/dropped, scores mean, eval checks mean, drawer History cells, dataset expected and evaluator comment use .value-missing; the eval detail loading line is screen-reader only beside its skeletons; hub TTL reads "6h · expires in"; dead .ds-faint removed. Co-Authored-By: Claude Opus 5.5 --- .../src/components/debug/HistoryPanel.tsx | 14 ++++++++------ .../debug/__tests__/HistoryPanel.test.tsx | 4 ++-- .../src/screens/datasets/DatasetDetail.tsx | 4 ++-- apps/agent-forge/src/screens/evals/evals.css | 4 ---- .../src/screens/evaluators/EvaluatorDetail.tsx | 2 +- apps/agent-forge/src/screens/hub/HubScreen.tsx | 2 +- .../src/screens/hub/__tests__/hub.test.tsx | 2 +- apps/agent-forge/src/screens/hub/hubModel.ts | 2 +- .../src/screens/monitoring/FleetTable.tsx | 7 +++++-- apps/agent-forge/src/screens/scores/EvalDetail.tsx | 7 ++++--- .../src/screens/scores/ScoresScreen.tsx | 2 +- 11 files changed, 26 insertions(+), 24 deletions(-) diff --git a/apps/agent-forge/src/components/debug/HistoryPanel.tsx b/apps/agent-forge/src/components/debug/HistoryPanel.tsx index 989513b43..c1930200f 100644 --- a/apps/agent-forge/src/components/debug/HistoryPanel.tsx +++ b/apps/agent-forge/src/components/debug/HistoryPanel.tsx @@ -9,9 +9,11 @@ import type { ForkRunRequest, RunComparisonPayload, RunHistoryStep } from '../.. type ForkPatch = NonNullable; - const NUMERIC_COLUMNS = new Set(['Step', 'Tokens', 'Cost']); +/** A value the step does not have: a faint em dash. */ +const MISSING = —; + /** 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 ( @@ -32,10 +34,10 @@ export function HistoryList({ steps, onReplay }: { steps: RunHistoryStep[]; onRe - {s.finishReason ?? '-'} - {s.toolCalls.map((call) => call.name).join(', ') || '-'} - {s.tokens ?? '-'} - {s.costUsd === undefined ? '-' : `$${s.costUsd.toFixed(4)}`} + {s.finishReason ?? MISSING} + {s.toolCalls.map((call) => call.name).join(', ') || MISSING} + {s.tokens ?? MISSING} + {s.costUsd === undefined ? MISSING : `$${s.costUsd.toFixed(4)}`}
    )} diff --git a/apps/agent-forge/src/screens/hub/HubScreen.tsx b/apps/agent-forge/src/screens/hub/HubScreen.tsx index 324a4447b..8f66b64d7 100644 --- a/apps/agent-forge/src/screens/hub/HubScreen.tsx +++ b/apps/agent-forge/src/screens/hub/HubScreen.tsx @@ -232,7 +232,7 @@ function HubContent({ namespace }: { namespace: string | undefined }) { } const names = new Map(sorted.map((a) => [a.agent_id, agentLabel(a)])); - const writerName = (id: string | null) => (id === null ? '-' : (names.get(id) ?? id)); + const writerName = (id: string | null) => (id === null ? '—' : (names.get(id) ?? id)); // Changing the viewer to the default drops `agent`; any filter edit replaces the history entry. const update = (patch: HubUrlState) => { const next = { ...state, ...patch }; diff --git a/apps/agent-forge/src/screens/hub/__tests__/hub.test.tsx b/apps/agent-forge/src/screens/hub/__tests__/hub.test.tsx index edd4ab636..49b6b68d2 100644 --- a/apps/agent-forge/src/screens/hub/__tests__/hub.test.tsx +++ b/apps/agent-forge/src/screens/hub/__tests__/hub.test.tsx @@ -225,7 +225,7 @@ describe('HubScreen', () => { expect(readme.textContent).toContain('{"title":"Hi"'); expect(readme.querySelector('.status-pill')?.textContent).toBe('fleet'); expect(readme.textContent).toContain('zeta'); - expect(readme.textContent).toMatch(/1h - expires in 5[89]m/); + expect(readme.textContent).toMatch(/1h · expires in 5[89]m/); const draft = rowFor(c, 'draft'); expect(draft.querySelector('.status-pill')?.textContent).toBe('private'); expect(draft.textContent).toContain('alpha'); diff --git a/apps/agent-forge/src/screens/hub/hubModel.ts b/apps/agent-forge/src/screens/hub/hubModel.ts index b6b8c2812..8d8fbe9e9 100644 --- a/apps/agent-forge/src/screens/hub/hubModel.ts +++ b/apps/agent-forge/src/screens/hub/hubModel.ts @@ -127,5 +127,5 @@ export function fmtSpan(ms: number): string { /** The TTL cell: `never`, or the lifetime and the time left. */ export function ttlText(entry: ObserveHubEntry, now: number): string { if (entry.ttl === null || entry.expires_at === null) return 'never'; - return `${fmtSpan(entry.ttl)} - expires in ${fmtSpan(entry.expires_at - now)}`; + return `${fmtSpan(entry.ttl)} · expires in ${fmtSpan(entry.expires_at - now)}`; } diff --git a/apps/agent-forge/src/screens/monitoring/FleetTable.tsx b/apps/agent-forge/src/screens/monitoring/FleetTable.tsx index 10181358d..11bc5e3ec 100644 --- a/apps/agent-forge/src/screens/monitoring/FleetTable.tsx +++ b/apps/agent-forge/src/screens/monitoring/FleetTable.tsx @@ -70,10 +70,13 @@ function TracesLink({ agent, compact }: { agent: ObserveAgentRow; compact: boole ); } +/** A value the row does not have: a faint em dash. */ +const MISSING = —; + function DroppedCell({ dropped }: { dropped: number | null | undefined }) { return ( - {dropped === null || dropped === undefined ? '—' : fmtInt(dropped)} + {dropped === null || dropped === undefined ? MISSING : fmtInt(dropped)} ); } @@ -125,7 +128,7 @@ function fleetColumns(compact: boolean): Column[] { hideBelow: 300, render: ({ agent }) => , }, - { key: 'p95', header: 'p95', label: 'p95 (24h)', width: 72, align: 'right', hideBelow: 640, render: ({ p95 }) => fmtDuration(p95) }, + { key: 'p95', header: 'p95', label: 'p95 (24h)', width: 72, align: 'right', hideBelow: 640, render: ({ p95 }) => (Number.isFinite(p95) ? fmtDuration(p95) : MISSING) }, { key: 'dropped', header: 'Dropped', diff --git a/apps/agent-forge/src/screens/scores/EvalDetail.tsx b/apps/agent-forge/src/screens/scores/EvalDetail.tsx index 45b976c4f..867cd3a02 100644 --- a/apps/agent-forge/src/screens/scores/EvalDetail.tsx +++ b/apps/agent-forge/src/screens/scores/EvalDetail.tsx @@ -14,7 +14,7 @@ import '../../ui/card.css'; /** Rows fetched for one eval; a full page means older rows were left out. */ export const EVAL_ROW_LIMIT = 1000; -const fmtMean = (v: number | null) => (v === null ? '-' : v.toFixed(2)); +const fmtMean = (v: number | null) => (v === null ? — : v.toFixed(2)); /** * A pass rate as a two-tone bar (passed in success, failed in the danger tint) @@ -80,9 +80,10 @@ function Failures({ evalName, rows }: { evalName: string; rows: ObserveScoreRow[ function DetailLoading({ evalName }: { evalName: string }) { return (
    -

    + {/* Announced, not shown: the skeletons are the visible loading state. */} + Loading {evalName}... -

    +
    diff --git a/apps/agent-forge/src/screens/scores/ScoresScreen.tsx b/apps/agent-forge/src/screens/scores/ScoresScreen.tsx index 580041ac3..4ea3c8c87 100644 --- a/apps/agent-forge/src/screens/scores/ScoresScreen.tsx +++ b/apps/agent-forge/src/screens/scores/ScoresScreen.tsx @@ -113,7 +113,7 @@ function evalColumns(open: string, toggle: (name: string) => void): Column (r.avg_value === null ? '-' : r.avg_value.toFixed(2)), + render: (r) => (r.avg_value === null ? {NONE} : r.avg_value.toFixed(2)), }, { key: 'last', header: 'Last seen', width: 150, hideBelow: 560, render: (r) => fmtDateTime(r.last_ts) }, ]; From b4ace17046dc74ebd9cc0f43c6cb03e7314cdd3b Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 13:47:09 +0300 Subject: [PATCH 5/7] feat(studio): designed shortcut sheet, error boundary card and daemon-down state The ? sheet gets an icon header with a hint, an icon close button (aria-label Close) and description/kbd rows; the ErrorBoundary is a centred card with an AlertTriangle tile, the message in mono and Reload/Try again; ObserveDaemonDown is the kit EmptyState in a card and ObserveError gets an alert icon; observe.css on tokens only. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ErrorBoundary.tsx | 32 +++++--- apps/agent-forge/src/components/layout.css | 77 +++++++++++++++---- apps/agent-forge/src/observe/chrome.tsx | 20 +++-- apps/agent-forge/src/observe/observe.css | 52 +++++++------ apps/agent-forge/src/ui/CommandPalette.tsx | 20 +++-- apps/agent-forge/src/ui/commandPalette.css | 45 +++++++++-- 6 files changed, 179 insertions(+), 67 deletions(-) diff --git a/apps/agent-forge/src/components/ErrorBoundary.tsx b/apps/agent-forge/src/components/ErrorBoundary.tsx index 9902ce23f..f2856eb24 100644 --- a/apps/agent-forge/src/components/ErrorBoundary.tsx +++ b/apps/agent-forge/src/components/ErrorBoundary.tsx @@ -1,5 +1,6 @@ import { Component } from 'react'; import type { ErrorInfo, ReactNode } from 'react'; +import { AlertTriangle, RotateCw } from 'lucide-react'; interface ErrorBoundaryProps { children: ReactNode; @@ -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 { state: ErrorBoundaryState = { error: undefined }; @@ -31,17 +33,23 @@ export class ErrorBoundary extends Component -

    Something went wrong

    -

    Agent Forge hit an error it could not recover from:

    -
    {error.message}
    -
    - - +
    +
    + +

    Something went wrong

    +

    Agent Forge hit an error it could not recover from. Try again, or reload the Studio.

    +
    {error.message}
    +
    + + +
    ); diff --git a/apps/agent-forge/src/components/layout.css b/apps/agent-forge/src/components/layout.css index ba8e99e2f..c9a6bc84e 100644 --- a/apps/agent-forge/src/components/layout.css +++ b/apps/agent-forge/src/components/layout.css @@ -327,24 +327,75 @@ display: none; } -/* top-level error boundary fallback. */ +/* top-level error boundary fallback (ErrorBoundary.tsx): an EmptyState-like card centred on the canvas. */ +.error-boundary-screen { + display: grid; + place-items: center; + min-height: 100vh; + min-height: 100dvh; + padding: var(--space-6) var(--space-4); + background: var(--bg); +} .error-boundary { - max-width: 640px; - margin: 64px auto; - padding: 24px; + display: flex; + flex-direction: column; + align-items: center; + width: min(560px, 100%); + padding: var(--space-8) var(--space-6) var(--space-6); background: var(--surface); color: var(--text); border: 1px solid var(--border); - border-radius: 10px; + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + font-family: var(--font-ui); + text-align: center; } -.error-boundary h1 { font-size: 18px; margin: 0 0 8px; } -.error-boundary pre { - white-space: pre-wrap; - word-break: break-word; +.error-boundary-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + margin-bottom: var(--space-4); + border-radius: var(--radius-lg); background: var(--danger-soft); color: var(--danger); - padding: 10px; - border-radius: 7px; - font-size: 12px; } -.error-boundary-actions { display: flex; gap: 8px; margin-top: 12px; } +.error-boundary h1 { + margin: 0; + font-size: 16px; + font-weight: 600; + letter-spacing: -0.01em; +} +.error-boundary-hint { + max-width: 48ch; + margin: var(--space-1) 0 0; + color: var(--text-muted); + font-size: 13px; + line-height: 1.5; +} +.error-boundary-message { + align-self: stretch; + max-height: 200px; + margin: var(--space-5) 0 0; + padding: var(--space-3); + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-2); + color: var(--text); + font-family: var(--font-mono); + font-size: var(--fs-meta); + font-weight: 400; + line-height: 1.6; + text-align: left; + white-space: pre-wrap; + overflow-wrap: anywhere; +} +.error-boundary-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--space-2); + margin-top: var(--space-5); +} diff --git a/apps/agent-forge/src/observe/chrome.tsx b/apps/agent-forge/src/observe/chrome.tsx index 7c650fed8..a3105bd2d 100644 --- a/apps/agent-forge/src/observe/chrome.tsx +++ b/apps/agent-forge/src/observe/chrome.tsx @@ -1,6 +1,8 @@ /** Shared bits of the observe panels' chrome: the daemon-down empty state, inline API errors, status pills, filter controls. */ import { useEffect, useState } from 'react'; +import { AlertCircle, Unplug } from 'lucide-react'; +import { EmptyState } from '../ui/EmptyState'; import { observeClient } from './observeClient'; import type { ObserveAgentRow } from './types'; @@ -48,11 +50,16 @@ export function ObserveDaemonDown() { const value = target === undefined || target.replace(/\/+$/, '') === DEFAULT_OBSERVE_URL ? '1' : target; return (
    -

    No data yet - the observe daemon is not reachable.

    -

    - Run lousho observe start, then set LOUSHO_OBSERVE={value} on your agents; their traces, - scores and heartbeats show up here automatically. -

    + + Run lousho observe start, then set LOUSHO_OBSERVE={value} on your agents; their traces, + scores and heartbeats show up here automatically. + + } + />
    ); } @@ -62,7 +69,8 @@ export function ObserveError({ error }: { error: Error | undefined }) { if (!error) return null; return (
    - {error.message} +
    ); } diff --git a/apps/agent-forge/src/observe/observe.css b/apps/agent-forge/src/observe/observe.css index 96d74255c..38dbe1ad7 100644 --- a/apps/agent-forge/src/observe/observe.css +++ b/apps/agent-forge/src/observe/observe.css @@ -1,39 +1,47 @@ /* * Shared observe chrome (observe/chrome.tsx): the daemon-down empty state and - * inline API errors, used by the Traces, Trace, Monitoring and Scores screens. + * inline API errors, used by every observe screen. Tokens only. */ +/* the daemon-down state: the kit's EmptyState in a card, centred under the screen header. */ .observe-empty { max-width: 560px; - margin: 48px auto; - padding: 20px; - text-align: center; - color: var(--text-muted); - font-size: 13px; - border: 1px dashed var(--border); - border-radius: 10px; + margin: var(--space-10) auto; background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); } -.observe-empty-title { - font-weight: 700; - color: var(--text); - margin-top: 0; +.observe-empty .empty-state { + padding: var(--space-8) var(--space-6); } .observe-empty code { - font-family: var(--font-mono); - background: var(--surface-2); - border: 1px solid var(--border); padding: 1px 5px; - border-radius: 5px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); color: var(--text); + font-family: var(--font-mono); + font-size: var(--fs-meta); + overflow-wrap: anywhere; } + +/* an inline API error (400/404/500): danger on its tint, with an alert icon. */ .observe-error { - margin: 8px 0; - padding: 8px 12px; - border: 1px solid var(--danger); - border-radius: 8px; + display: flex; + align-items: flex-start; + gap: var(--space-2); + margin: var(--space-2) 0; + padding: var(--space-2) var(--space-3); + border: 1px solid color-mix(in srgb, var(--danger) 35%, transparent); + border-radius: var(--radius); background: var(--danger-soft); color: var(--danger); - font-size: 12px; + font-size: var(--fs-meta); + line-height: 1.5; + overflow-wrap: anywhere; +} +.observe-error svg { + flex: none; + margin-top: 2px; } - diff --git a/apps/agent-forge/src/ui/CommandPalette.tsx b/apps/agent-forge/src/ui/CommandPalette.tsx index 5292faa47..9d77d5cec 100644 --- a/apps/agent-forge/src/ui/CommandPalette.tsx +++ b/apps/agent-forge/src/ui/CommandPalette.tsx @@ -5,9 +5,11 @@ import { ArrowUp, Bot, CornerDownLeft, + Keyboard, Search, SunMoon, TextSearch, + X, type LucideIcon, } from 'lucide-react'; import { navigate } from '../router/router'; @@ -286,18 +288,24 @@ function ShortcutSheet({ onClose }: { onClose: () => void }) { return (
    -

    Keyboard shortcuts

    -
    {SHORTCUTS.map((s) => (
    -
    +
    {s.description}
    +
    {s.keys} - -
    {s.description}
    +
    ))}
    diff --git a/apps/agent-forge/src/ui/commandPalette.css b/apps/agent-forge/src/ui/commandPalette.css index 2a5e4384f..afa6bdf37 100644 --- a/apps/agent-forge/src/ui/commandPalette.css +++ b/apps/agent-forge/src/ui/commandPalette.css @@ -177,40 +177,69 @@ } /* ---------- Shortcut sheet ---------- */ +/* The same dialog card (radius-lg, shadow-lg): icon tile + title + hint, an icon close button, then rows of + description left and kbd right on hairlines (like Settings' shortcuts card). */ .cmdk-sheet { width: min(480px, 100%); - padding: 4px 16px 12px; } .cmdk-sheet-head { display: flex; + align-items: flex-start; + gap: var(--space-3); + flex: none; + padding: var(--space-4) var(--space-4) var(--space-3) var(--space-5); + border-bottom: 1px solid var(--border); +} +.cmdk-sheet-icon { + display: inline-flex; align-items: center; - justify-content: space-between; - gap: 12px; - height: 48px; + justify-content: center; + flex: none; + width: 32px; + height: 32px; + border-radius: var(--radius); + background: var(--surface-2); + color: var(--text-muted); +} +.cmdk-sheet-heading { + flex: 1; + min-width: 0; } .cmdk-sheet-title { margin: 0; font-size: 14px; font-weight: 600; + line-height: 20px; +} +.cmdk-sheet-hint { + margin: 0; + color: var(--text-muted); + font-size: var(--fs-meta); + line-height: 1.5; } .cmdk-shortcuts { margin: 0; + padding: var(--space-1) var(--space-5) var(--space-3); overflow-y: auto; } .cmdk-shortcut { display: flex; align-items: center; - gap: 12px; - min-height: 36px; + justify-content: space-between; + gap: var(--space-3); + min-height: 40px; font-size: 13px; +} +.cmdk-shortcut + .cmdk-shortcut { border-top: 1px solid var(--border); } .cmdk-shortcut dt { - min-width: 110px; + min-width: 0; + color: var(--text); } .cmdk-shortcut dd { + flex: none; margin: 0; - color: var(--text-muted); } @media (prefers-reduced-motion: no-preference) { From f9533cabd9d6d304b30739c73c6c757205011a86 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 13:47:09 +0300 Subject: [PATCH 6/7] feat(studio): docShots - tool step selected, ? sheet and error boundary review shots x-trace-tool-selected(-waterfall), x-shortcut-sheet(-375), x-error-boundary(-375). Co-Authored-By: Claude Opus 5.5 --- apps/agent-forge/e2e/docShots.spec.ts | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index fc2f88bfb..f53b6b810 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -1379,3 +1379,35 @@ 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'); +}); From 1d06ad7243bdc2648802e4afe454e4bf8c660ff6 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 29 Oct 2026 14:12:50 +0300 Subject: [PATCH 7/7] style(studio): P10b fix round 1 - outlined agent kind, motion-free running bars, one first-fit owner, aligned daemon-down card Agent kind: hollow waterfall bar (accent-soft fill, 1px accent border) and an accent hairline on its tile, so it differs from the violet generation by shape. Running bars keep their kind colour with a square, fading right end (shimmer only when motion is allowed). The canvas drops the ReactFlow fitView prop; useFitViewOnLoad owns the first fit and hides the viewport until it lands. The daemon-down card sits 60px under the header on every screen. Remaining "-" values (drawer run cost, eval pass rate and failure value, hub writer) are the faint em dash. docShots: x-trace-running-waterfall(-reduced). Co-Authored-By: Claude Opus 5.5 --- apps/agent-forge/e2e/docShots.spec.ts | 21 ++++++++++++ .../agent-forge/src/components/CanvasArea.tsx | 34 +++++++++---------- .../src/components/debug/TraceHistory.tsx | 6 ++-- .../debug/__tests__/TraceHistory.test.tsx | 2 +- apps/agent-forge/src/observe/observe.css | 5 +++ .../agent-forge/src/screens/agents/agents.css | 4 +++ apps/agent-forge/src/screens/hub/HubEntry.tsx | 3 +- .../src/screens/hub/HubEntryCards.tsx | 2 +- apps/agent-forge/src/screens/hub/hub.css | 6 ++++ .../src/screens/scores/EvalDetail.tsx | 23 +++++++++---- .../scores/__tests__/PassRateBar.test.tsx | 18 ++++++++++ apps/agent-forge/src/ui/run/run.css | 31 +++++++++++------ 12 files changed, 115 insertions(+), 40 deletions(-) create mode 100644 apps/agent-forge/src/screens/scores/__tests__/PassRateBar.test.tsx diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index f53b6b810..2fa11e972 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -1411,3 +1411,24 @@ test('x-shell: the error boundary after a render crash, at 1280 and 375 wide', a 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'); +}); diff --git a/apps/agent-forge/src/components/CanvasArea.tsx b/apps/agent-forge/src/components/CanvasArea.tsx index b4fd33590..e6ceb8d24 100644 --- a/apps/agent-forge/src/components/CanvasArea.tsx +++ b/apps/agent-forge/src/components/CanvasArea.tsx @@ -127,7 +127,6 @@ const FIT_VIEW_OPTIONS = { padding: 0.15, maxZoom: 1 }; * centred. The user can still zoom out to MIN_ZOOM, or press Fit view. */ const AUTO_FIT_MIN_ZOOM = 0.75; -const AUTO_FIT_OPTIONS = { ...FIT_VIEW_OPTIONS, minZoom: AUTO_FIT_MIN_ZOOM }; /** The inset (screen px) of a pinned graph's left/top edge. */ const AUTO_FIT_INSET = 40; @@ -379,25 +378,26 @@ function useFitViewOnResize() { } /** - * 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) { +function useFitViewOnLoad(agentId: string, graph: AgentGraphSpec, measured: MeasuredSizes): boolean { const fitLegibly = useLegibleFit(); - const pending = useRef(true); const loadKey = `${agentId}|${graph.nodes.length > 0 ? 'filled' : 'empty'}`; - useEffect(() => { - pending.current = true; - }, [loadKey]); + const [fittedKey, setFittedKey] = useState(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(() => fitLegibly()); + if (fittedKey === loadKey || !allMeasured) return; + const frame = window.requestAnimationFrame(() => { + fitLegibly(); + setFittedKey(loadKey); + }); return () => window.cancelAnimationFrame(frame); - }, [loadKey, allMeasured, fitLegibly]); + }, [loadKey, fittedKey, allMeasured, fitLegibly]); + return graph.nodes.length > 0 && fittedKey !== loadKey; } /** Toolbar actions (auto layout, fit view, duplicate) plus the Ctrl/Cmd+D shortcut. */ @@ -555,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 ( -
    +
    diff --git a/apps/agent-forge/src/components/debug/TraceHistory.tsx b/apps/agent-forge/src/components/debug/TraceHistory.tsx index f959e1643..f4f90c39c 100644 --- a/apps/agent-forge/src/components/debug/TraceHistory.tsx +++ b/apps/agent-forge/src/components/debug/TraceHistory.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from 'react'; import type { TraceSummaryPayload } from '../../../shared/wireTypes'; import { statusTone } from '../../ui/StatusPill'; import { TonePill } from './TonePill'; @@ -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 ? — : `$${costUsd.toFixed(costUsd < 0.01 ? 6 : 4)}`; } /** One line per persisted run: when, how long, model calls, tokens in/out, cost and status. */ diff --git a/apps/agent-forge/src/components/debug/__tests__/TraceHistory.test.tsx b/apps/agent-forge/src/components/debug/__tests__/TraceHistory.test.tsx index 6ff88e150..767d7ddd1 100644 --- a/apps/agent-forge/src/components/debug/__tests__/TraceHistory.test.tsx +++ b/apps/agent-forge/src/components/debug/__tests__/TraceHistory.test.tsx @@ -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'); }); diff --git a/apps/agent-forge/src/observe/observe.css b/apps/agent-forge/src/observe/observe.css index 38dbe1ad7..3f98b946b 100644 --- a/apps/agent-forge/src/observe/observe.css +++ b/apps/agent-forge/src/observe/observe.css @@ -12,6 +12,11 @@ border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); } +/* Straight under the screen header (a list screen returning the card in place of its content), add the + screens' own top padding, so the card sits 60px under the header on every screen. */ +.screen-main > .observe-empty { + margin-top: calc(var(--space-5) + var(--space-10)); +} .observe-empty .empty-state { padding: var(--space-8) var(--space-6); } diff --git a/apps/agent-forge/src/screens/agents/agents.css b/apps/agent-forge/src/screens/agents/agents.css index e823ec7ab..3990fd033 100644 --- a/apps/agent-forge/src/screens/agents/agents.css +++ b/apps/agent-forge/src/screens/agents/agents.css @@ -411,6 +411,10 @@ button.breakpoint-toggle { --xy-selection-border: 1px dashed var(--accent); --xy-attribution-background-color: transparent; } +/* Until the first fit of a loaded graph lands (CanvasArea.tsx's useFitViewOnLoad), the nodes stay laid out but unseen. */ +.canvas-wrap[data-fitting] .react-flow__viewport { + visibility: hidden; +} .canvas-wrap .react-flow__background { background: var(--bg); } diff --git a/apps/agent-forge/src/screens/hub/HubEntry.tsx b/apps/agent-forge/src/screens/hub/HubEntry.tsx index ec6de7cdc..67e25a4e1 100644 --- a/apps/agent-forge/src/screens/hub/HubEntry.tsx +++ b/apps/agent-forge/src/screens/hub/HubEntry.tsx @@ -120,7 +120,8 @@ function EntryFacts({ entry, writer, now }: { entry: ObserveHubEntry; writer: st
    Writer
    -
    {writer}
    + {/* No writer recorded: HubScreen's writerName gives a dash, drawn faint. */} +
    {writer}
    Updated
    diff --git a/apps/agent-forge/src/screens/hub/HubEntryCards.tsx b/apps/agent-forge/src/screens/hub/HubEntryCards.tsx index bd472ee61..b386db70c 100644 --- a/apps/agent-forge/src/screens/hub/HubEntryCards.tsx +++ b/apps/agent-forge/src/screens/hub/HubEntryCards.tsx @@ -51,7 +51,7 @@ function EntryCard({ {preview} - {writer} + {writer} Updated {fmtStarted(entry.created_at, now)} diff --git a/apps/agent-forge/src/screens/hub/hub.css b/apps/agent-forge/src/screens/hub/hub.css index 9dcc3f193..e9e921d12 100644 --- a/apps/agent-forge/src/screens/hub/hub.css +++ b/apps/agent-forge/src/screens/hub/hub.css @@ -207,6 +207,12 @@ color: var(--text); font-weight: 500; } +/* no writer recorded: the faint em dash (base.css .value-missing), over the writer's own colour and weight. */ +.hub-card-writer.value-missing, +.hub-facts dd.value-missing { + color: var(--text-faint); + font-weight: 400; +} /* the open entry */ .hub-entry { diff --git a/apps/agent-forge/src/screens/scores/EvalDetail.tsx b/apps/agent-forge/src/screens/scores/EvalDetail.tsx index 867cd3a02..ddb889327 100644 --- a/apps/agent-forge/src/screens/scores/EvalDetail.tsx +++ b/apps/agent-forge/src/screens/scores/EvalDetail.tsx @@ -14,11 +14,14 @@ import '../../ui/card.css'; /** Rows fetched for one eval; a full page means older rows were left out. */ export const EVAL_ROW_LIMIT = 1000; -const fmtMean = (v: number | null) => (v === null ? — : v.toFixed(2)); +/** A value the row does not have: a faint em dash. */ +const MISSING = —; + +const fmtMean = (v: number | null) => (v === null ? MISSING : v.toFixed(2)); /** * A pass rate as a two-tone bar (passed in success, failed in the danger tint) - * plus its tabular figure; no rate shows '-'. + * plus its tabular figure; no rate shows an empty bar and a faint em dash. */ export function PassRateBar({ rate }: { rate: number | null }) { const width = rate === null ? 0 : Math.max(0, Math.min(1, rate)) * 100; @@ -27,7 +30,7 @@ export function PassRateBar({ rate }: { rate: number | null }) { ); } @@ -61,10 +64,16 @@ function Failures({ evalName, rows }: { evalName: string; rows: ObserveScoreRow[ {rows.map((row) => (
  • - - + {row.value === null ? ( + + — + + ) : ( + + + )} {row.name === evalName ? 'eval' : row.name.slice(evalName.length + 1)} {fmtDateTime(row.created_at)} diff --git a/apps/agent-forge/src/screens/scores/__tests__/PassRateBar.test.tsx b/apps/agent-forge/src/screens/scores/__tests__/PassRateBar.test.tsx new file mode 100644 index 000000000..6d161b28e --- /dev/null +++ b/apps/agent-forge/src/screens/scores/__tests__/PassRateBar.test.tsx @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { render } from '../../../ui/__tests__/render'; +import { PassRateBar } from '../EvalDetail'; + +describe('PassRateBar', () => { + it('shows the rate as a percentage', () => { + const c = render(); + expect(c.querySelector('.score-rate-value')!.textContent).toBe('50.0%'); + expect(c.querySelector('.value-missing')).toBeNull(); + }); + + it('shows an empty bar and a faint em dash (not "-") with no rate', () => { + const c = render(); + expect(c.querySelector('.score-rate-bar')!.getAttribute('data-empty')).toBe('true'); + expect(c.querySelector('.score-rate-value .value-missing')!.textContent).toBe('—'); + }); +}); diff --git a/apps/agent-forge/src/ui/run/run.css b/apps/agent-forge/src/ui/run/run.css index becd31cea..d76c27696 100644 --- a/apps/agent-forge/src/ui/run/run.css +++ b/apps/agent-forge/src/ui/run/run.css @@ -171,10 +171,12 @@ color: var(--text-faint); } /* Kind colours match the canvas (canvas/nodeKinds.ts): tool = info, generation = violet; amber is for warning status only. - The agent root takes the accent, so it stays apart from the (blue) tool steps. */ + The agent kind is outlined (an accent hairline round its soft tile, a hollow waterfall bar), so it differs from the + (lavender) generation by shape, not only by hue. */ .run-kind-agent { background: var(--accent-soft); color: var(--accent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 55%, transparent); } .run-kind-generation { background: var(--violet-soft); @@ -195,6 +197,7 @@ .run-row-error .run-kind { background: var(--danger-soft); color: var(--danger); + box-shadow: none; } /* ---------- tree ---------- */ @@ -379,6 +382,7 @@ background-size: var(--wf-step, 20%) 100%; } .run-wf-bar { + box-sizing: border-box; position: absolute; top: 50%; height: 10px; @@ -388,7 +392,8 @@ background: var(--accent); } .run-wf-bar-agent { - background: var(--accent); + background: var(--accent-soft); + border: 1px solid var(--accent); } .run-wf-bar-generation { background: var(--violet); @@ -402,11 +407,20 @@ .run-wf-bar-event { background: var(--chart-4); } +/* Running: the bar keeps its kind colour and is open-ended - square right end fading out over its last 16px - so it + reads as unfinished without motion; the shimmer runs on top only when motion is allowed. */ .run-wf-bar.running { - background-color: var(--info); - background-image: linear-gradient(90deg, transparent, color-mix(in srgb, var(--surface) 45%, transparent), transparent); - background-size: 200% 100%; - animation: run-wf-running 1.4s linear infinite; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + -webkit-mask-image: linear-gradient(90deg, currentColor calc(100% - 16px), transparent); + mask-image: linear-gradient(90deg, currentColor calc(100% - 16px), transparent); +} +@media (prefers-reduced-motion: no-preference) { + .run-wf-bar.running { + background-image: linear-gradient(90deg, transparent, color-mix(in srgb, var(--surface) 45%, transparent), transparent); + background-size: 200% 100%; + animation: run-wf-running 1.4s linear infinite; + } } @keyframes run-wf-running { from { @@ -417,6 +431,7 @@ } } .run-wf-bar.error { + border-color: var(--danger); background-color: var(--danger); background-image: repeating-linear-gradient( -45deg, @@ -606,8 +621,4 @@ .run-tree-row .run-playground-link { transition: none; } - .run-wf-bar.running { - animation: none; - background-image: none; - } }