diff --git a/apps/agent-forge/e2e/docShots.spec.ts b/apps/agent-forge/e2e/docShots.spec.ts index fc2f88bfb..2fa11e972 100644 --- a/apps/agent-forge/e2e/docShots.spec.ts +++ b/apps/agent-forge/e2e/docShots.spec.ts @@ -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'); +}); diff --git a/apps/agent-forge/src/components/CanvasArea.tsx b/apps/agent-forge/src/components/CanvasArea.tsx index 37500e7d2..e6ceb8d24 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,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 @@ -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 | 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(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() { @@ -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" @@ -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 ( -
+
- + {/* 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/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/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)}`}
{SHORTCUTS.map((s) => (
-
+
{s.description}
+
{s.keys} - -
{s.description}
+
))}
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 { 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) { 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..d76c27696 100644 --- a/apps/agent-forge/src/ui/run/run.css +++ b/apps/agent-forge/src/ui/run/run.css @@ -170,17 +170,21 @@ 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 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: color-mix(in srgb, var(--chart-2) 16%, var(--surface)); - color: var(--chart-2); + 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); 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); @@ -193,6 +197,7 @@ .run-row-error .run-kind { background: var(--danger-soft); color: var(--danger); + box-shadow: none; } /* ---------- tree ---------- */ @@ -377,6 +382,7 @@ background-size: var(--wf-step, 20%) 100%; } .run-wf-bar { + box-sizing: border-box; position: absolute; top: 50%; height: 10px; @@ -386,13 +392,14 @@ background: var(--accent); } .run-wf-bar-agent { - background: var(--chart-2); + background: var(--accent-soft); + border: 1px solid 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); @@ -400,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 { @@ -415,6 +431,7 @@ } } .run-wf-bar.error { + border-color: var(--danger); background-color: var(--danger); background-image: repeating-linear-gradient( -45deg, @@ -604,8 +621,4 @@ .run-tree-row .run-playground-link { transition: none; } - .run-wf-bar.running { - animation: none; - background-image: none; - } }