refactor(web): polish stage renderer designs after visual review

Tighten the meta bar (drop redundant labels, fold timestamp into the
duration tooltip), elevate the wait card to a centered hero clock,
strengthen the conditional view with the actual chosen edge and
condition expression sourced from run-level edge.selected events,
upgrade the fan-in selected card with a trophy badge and gradient, and
redesign the parallel stat strip with toned numbers and a duration
column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-09 19:16:27 -04:00
parent 80d3965670
commit 8b4442096d
No known key found for this signature in database
7 changed files with 269 additions and 87 deletions

View file

@ -1,39 +1,127 @@
import { useMemo } from "react";
import { Link } from "react-router";
import {
ArrowLongRightIcon,
ArrowsRightLeftIcon,
InformationCircleIcon,
} from "@heroicons/react/20/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import type { Stage } from "../stage-sidebar";
import { StageMetaBar } from "./meta-bar";
import { findEdgeForNode } from "./helpers";
// TODO: render the evaluated condition expression and the chosen edge once
// `ConditionalHandler` emits a `conditional.evaluated` event with that data.
// Right now the handler is a passthrough — engine-level edge selection is the
// only signal — so we only have notes/duration to display.
const REASON_LABEL: Record<string, string> = {
condition: "Matched condition",
unconditional: "Default edge",
jump: "Jumped",
preferred_label: "Preferred label",
};
function reasonLabel(reason: string): string {
return REASON_LABEL[reason] ?? reason;
}
export function ConditionalDecision({
stage,
runEvents,
allStages,
runId,
}: {
stage: Stage;
runEvents: EventEnvelope[];
allStages: Stage[];
runId: string;
}) {
const edge = useMemo(
() => findEdgeForNode(runEvents, stage.nodeId),
[runEvents, stage.nodeId],
);
const targetStage = useMemo(() => {
if (!edge) return null;
let pick: Stage | null = null;
for (const s of allStages) {
if (s.nodeId !== edge.toNode) continue;
if (!pick || s.visit > pick.visit) pick = s;
}
return pick;
}, [allStages, edge]);
export function ConditionalDecision({ stage }: { stage: Stage }) {
return (
<div className="space-y-6 pl-3 pr-4 pt-2 sm:pr-6 lg:pr-8">
<StageMetaBar stage={stage} />
<section className="rounded-lg bg-panel p-5 outline-1 -outline-offset-1 outline-line">
<section className="rounded-lg bg-panel p-6 outline-1 -outline-offset-1 outline-line">
<div className="flex items-center gap-2 text-xs text-fg-muted">
<ArrowsRightLeftIcon className="size-4" aria-hidden="true" />
<span className="font-medium uppercase tracking-wider">Decision</span>
</div>
<p className="mt-2 text-sm text-fg-2">
The conditional <span className="font-mono text-fg">{stage.nodeId}</span>{" "}
evaluated and the workflow continued along its chosen edge.
</p>
<p className="mt-3 inline-flex items-start gap-1.5 text-xs text-fg-muted">
<InformationCircleIcon
className="mt-px size-3.5 shrink-0"
<div className="mt-4 grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<div className="rounded-md bg-overlay-strong px-3 py-2 text-center">
<div className="text-[10px] font-medium uppercase tracking-wider text-fg-muted">
From
</div>
<div className="mt-0.5 truncate font-mono text-sm text-fg-2">
{stage.nodeId}
</div>
</div>
<ArrowLongRightIcon
className="size-6 text-fg-muted"
aria-hidden="true"
/>
The condition expression and chosen edge aren't surfaced yet — open
the Debug tab to inspect raw events, or follow the next stage in the
sidebar to see which branch was taken.
</p>
{edge ? (
targetStage ? (
<Link
to={`/runs/${runId}/stages/${targetStage.id}`}
className="group rounded-md bg-teal-500/10 px-3 py-2 text-center transition-colors hover:bg-teal-500/20 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-teal-500"
>
<div className="text-[10px] font-medium uppercase tracking-wider text-teal-500">
To
</div>
<div className="mt-0.5 truncate font-mono text-sm text-fg group-hover:text-fg">
{edge.toNode}
</div>
</Link>
) : (
<div className="rounded-md bg-teal-500/10 px-3 py-2 text-center">
<div className="text-[10px] font-medium uppercase tracking-wider text-teal-500">
To
</div>
<div className="mt-0.5 truncate font-mono text-sm text-fg">
{edge.toNode}
</div>
</div>
)
) : (
<div className="rounded-md bg-overlay-strong px-3 py-2 text-center">
<div className="text-[10px] font-medium uppercase tracking-wider text-fg-muted">
To
</div>
<div className="mt-0.5 text-sm text-fg-muted">—</div>
</div>
)}
</div>
{edge && (
<dl className="mt-5 grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-xs">
<dt className="text-fg-muted">Reason</dt>
<dd className="text-fg-2">{reasonLabel(edge.reason)}</dd>
{edge.condition && (
<>
<dt className="text-fg-muted">Condition</dt>
<dd className="rounded bg-overlay-strong px-2 py-1 font-mono text-fg-3">
{edge.condition}
</dd>
</>
)}
{edge.isJump && (
<>
<dt className="text-fg-muted">Edge type</dt>
<dd className="text-fg-2">Jump</dd>
</>
)}
</dl>
)}
</section>
</div>
);

View file

@ -1,6 +1,7 @@
import { useMemo } from "react";
import {
CpuChipIcon,
SparklesIcon,
TrophyIcon,
} from "@heroicons/react/20/solid";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
@ -111,26 +112,36 @@ export function FanInResults({
}
/>
<section className="rounded-lg bg-panel p-5 outline-1 -outline-offset-1 outline-line">
<div className="flex items-center gap-2 text-xs">
<TrophyIcon className="size-4 text-amber" aria-hidden="true" />
<span className="font-medium uppercase tracking-wider text-fg-muted">
Selected
</span>
<section className="overflow-hidden rounded-lg bg-gradient-to-br from-amber/10 via-panel to-panel outline-1 -outline-offset-1 outline-line">
<div className="flex flex-col gap-5 p-6 sm:flex-row sm:items-center sm:gap-8">
<div className="flex size-14 shrink-0 items-center justify-center rounded-full bg-amber/15 ring-1 ring-amber/30">
<TrophyIcon className="size-7 text-amber" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber">
Selected branch
</div>
{outcome.selectedId ? (
<p className="mt-1 truncate font-mono text-2xl text-fg">
{outcome.selectedId}
</p>
) : (
<p className="mt-1 text-sm text-fg-muted">
Awaiting fan-in completion
</p>
)}
<p className="mt-2 inline-flex items-center gap-1.5 text-xs text-fg-muted">
{outcome.hasReducerTranscript ? (
<>
<SparklesIcon className="size-3.5" aria-hidden="true" />
Selected by LLM reducer
</>
) : (
<>Selected by heuristic (status &middot; score &middot; id)</>
)}
</p>
</div>
</div>
{outcome.selectedId ? (
<p className="mt-2 font-mono text-base text-fg">{outcome.selectedId}</p>
) : (
<p className="mt-2 text-sm text-fg-muted">
No winner reported yet. The fan-in will publish the selected branch
once it completes.
</p>
)}
<p className="mt-2 text-xs text-fg-muted">
{outcome.hasReducerTranscript
? "Selected by an LLM reducer (see transcript below)."
: "Selected heuristically by status, score, and id."}
</p>
</section>
{reducer && promptParts && (

View file

@ -139,6 +139,7 @@ export interface ParallelOverview {
joinPolicy: string | null;
successCount: number | null;
failureCount: number | null;
durationMs: number | null;
results: ParallelBranchResult[];
isComplete: boolean;
}
@ -153,6 +154,7 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview
let joinPolicy: string | null = null;
let successCount: number | null = null;
let failureCount: number | null = null;
let durationMs: number | null = null;
let results: ParallelBranchResult[] = [];
let isComplete = false;
@ -165,6 +167,7 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview
isComplete = true;
successCount = getNumber(props, "success_count") ?? successCount;
failureCount = getNumber(props, "failure_count") ?? failureCount;
durationMs = getNumber(props, "duration_ms") ?? durationMs;
const rawResults = getArray(props, "results") ?? [];
results = rawResults
.map((entry) => {
@ -181,7 +184,15 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview
}
}
return { branchCount, joinPolicy, successCount, failureCount, results, isComplete };
return {
branchCount,
joinPolicy,
successCount,
failureCount,
durationMs,
results,
isComplete,
};
}
export interface FanInOutcome {
@ -237,5 +248,43 @@ export function extractStageNotes(events: EventEnvelope[]): string | null {
return null;
}
export interface EdgeSelection {
fromNode: string;
toNode: string;
reason: string;
condition: string | null;
isJump: boolean;
}
/**
* Find the `edge.selected` event whose `from_node` matches this conditional
* stage's node. Edge events are run-scoped (no stage_id) so callers must pass
* the full run events list, not the per-stage events.
*
* When the stage runs multiple times, the most recent matching event wins —
* for now we just take the last one. Sufficient until we surface visit data.
*/
export function findEdgeForNode(
runEvents: EventEnvelope[],
nodeId: string,
): EdgeSelection | null {
let latest: EdgeSelection | null = null;
for (const event of runEvents) {
if (event.event !== "edge.selected") continue;
const props = event.properties ?? {};
const fromNode = getString(props, "from_node");
if (fromNode !== nodeId) continue;
const toNode = getString(props, "to_node") ?? "";
latest = {
fromNode: nodeId,
toNode,
reason: getString(props, "reason") ?? "",
condition: getString(props, "condition") ?? null,
isJump: props.is_jump === true,
};
}
return latest;
}
// Re-export helper used by renderers that need to read nested properties.
export { getObject, getString, getNumber, getArray };

View file

@ -23,8 +23,10 @@ function liveDuration(startedAt: string | null, fallback: string): string {
/**
* Compact horizontal status strip used at the top of every specialized stage
* renderer. Shows the status pill, live-or-final duration, and an optional
* trailing slot for renderer-specific metadata (e.g. branch counts).
* renderer. Shows the status pill and live-or-final duration; the timestamp
* is exposed via tooltip on the duration to avoid stuffing the bar with
* extra labels. The trailing slot is right-aligned for renderer-specific
* metadata (chips, counts, model badges, etc.).
*/
export function StageMetaBar({
stage,
@ -38,23 +40,27 @@ export function StageMetaBar({
useTickingNow(isActive);
const duration = isActive ? liveDuration(stage.startedAt, stage.duration) : stage.duration;
const durationNode = (
<span className="inline-flex items-center gap-1 font-mono tabular-nums text-fg-muted">
<ClockIcon className="size-3.5" aria-hidden="true" />
{duration}
</span>
);
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider ${stageStatusTone(stage.status)}`}
className={`inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider ${stageStatusTone(stage.status)}`}
>
{stageStatusLabel(stage.status)}
</span>
<span className="inline-flex items-center gap-1 font-mono tabular-nums text-fg-muted">
<ClockIcon className="size-3" aria-hidden="true" />
{duration}
</span>
{stage.startedAt && (
<Tooltip label={formatAbsoluteTs(stage.startedAt)}>
<span className="text-fg-muted">started</span>
{stage.startedAt ? (
<Tooltip label={`Started ${formatAbsoluteTs(stage.startedAt)}`}>
{durationNode}
</Tooltip>
) : (
durationNode
)}
<span className="font-mono text-fg-muted">{stage.handler}</span>
{trailing && <span className="ml-auto inline-flex items-center gap-3">{trailing}</span>}
</div>
);

View file

@ -30,17 +30,35 @@ function shortSha(sha: string | null): string | null {
return sha.length > 8 ? sha.slice(0, 8) : sha;
}
function StatItem({ label, value }: { label: string; value: string | number }) {
function StatItem({
label,
value,
tone = "default",
}: {
label: string;
value: string | number;
tone?: "default" | "success" | "danger";
}) {
const toneClass =
tone === "success" ? "text-mint" : tone === "danger" ? "text-coral" : "text-fg";
return (
<div className="flex flex-col">
<span className="text-[10px] font-medium uppercase tracking-wider text-fg-muted">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-medium uppercase tracking-[0.16em] text-fg-muted">
{label}
</span>
<span className="font-mono text-sm tabular-nums text-fg-2">{value}</span>
<span className={`font-mono text-xl tabular-nums ${toneClass}`}>{value}</span>
</div>
);
}
function formatMs(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const mins = Math.floor(ms / 60_000);
const secs = Math.round((ms % 60_000) / 1000);
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
}
function ChildRow({
result,
stageHref,
@ -133,20 +151,28 @@ export function ParallelChildren({
stage={stage}
trailing={
overview.joinPolicy ? (
<span className="text-xs text-fg-muted">
join: <span className="font-mono text-fg-3">{overview.joinPolicy}</span>
<span className="inline-flex items-center rounded-full bg-overlay-strong px-2 py-0.5 font-mono text-[10px] uppercase tracking-wider text-fg-2">
{overview.joinPolicy.replace(/_/g, " ")}
</span>
) : null
}
/>
<section className="grid grid-cols-2 gap-x-6 gap-y-3 rounded-lg bg-panel p-4 outline-1 -outline-offset-1 outline-line sm:grid-cols-4">
<section className="grid grid-cols-2 gap-x-6 gap-y-4 rounded-lg bg-panel p-5 outline-1 -outline-offset-1 outline-line sm:grid-cols-4">
<StatItem label="Branches" value={overview.branchCount ?? "—"} />
<StatItem label="Succeeded" value={overview.successCount ?? (overview.isComplete ? 0 : "—")} />
<StatItem label="Failed" value={overview.failureCount ?? (overview.isComplete ? 0 : "—")} />
<StatItem
label="State"
value={overview.isComplete ? "Complete" : "In flight"}
label="Succeeded"
value={overview.successCount ?? (overview.isComplete ? 0 : "—")}
tone="success"
/>
<StatItem
label="Failed"
value={overview.failureCount ?? (overview.isComplete ? 0 : "—")}
tone={overview.failureCount && overview.failureCount > 0 ? "danger" : "default"}
/>
<StatItem
label="Duration"
value={overview.durationMs != null ? formatMs(overview.durationMs) : overview.isComplete ? "—" : "running"}
/>
</section>

View file

@ -1,11 +1,11 @@
import { ClockIcon, PauseCircleIcon } from "@heroicons/react/20/solid";
import { PauseCircleIcon } from "@heroicons/react/20/solid";
import type { Stage } from "../stage-sidebar";
import { ACTIVE_STAGE_STATES } from "../../lib/stage-sidebar";
import { useTickingNow } from "../../lib/time";
import { StageMetaBar } from "./meta-bar";
function formatElapsed(startedAt: string | null): string {
function formatHms(startedAt: string | null): string {
if (!startedAt) return "—";
const startMs = Date.parse(startedAt);
if (Number.isNaN(startMs)) return "—";
@ -20,36 +20,27 @@ export function WaitStatus({ stage }: { stage: Stage }) {
const isActive = ACTIVE_STAGE_STATES.has(stage.status);
// Tick every second while waiting so the clock keeps moving.
useTickingNow(isActive);
const elapsed = formatElapsed(stage.startedAt);
const elapsed = isActive ? formatHms(stage.startedAt) : stage.duration;
const label = isActive ? "Waiting" : "Wait complete";
return (
<div className="space-y-6 pl-3 pr-4 pt-2 sm:pr-6 lg:pr-8">
<StageMetaBar stage={stage} />
<section className="rounded-lg bg-panel p-6 outline-1 -outline-offset-1 outline-line">
<div className="flex items-center gap-3">
{isActive ? (
<PauseCircleIcon className="size-6 text-amber" aria-hidden="true" />
) : (
<ClockIcon className="size-6 text-fg-muted" aria-hidden="true" />
)}
<div>
<p className="text-sm font-medium text-fg">
{isActive ? "Waiting" : "Wait complete"}
</p>
<p className="mt-0.5 text-xs text-fg-muted">
{isActive
? "The workflow will resume automatically when the configured duration elapses."
: `Held for ${stage.duration} before continuing.`}
</p>
<section className="flex items-center justify-center rounded-lg bg-panel py-12 outline-1 -outline-offset-1 outline-line">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-[0.18em] text-fg-muted">
{isActive && (
<PauseCircleIcon className="size-3.5 text-amber" aria-hidden="true" />
)}
{label}
</div>
<div className="ml-auto text-right">
<div className="text-[10px] font-medium uppercase tracking-wider text-fg-muted">
{isActive ? "Elapsed" : "Total"}
</div>
<div className="font-mono text-2xl tabular-nums text-fg">
{isActive ? elapsed : stage.duration}
</div>
<div
className={`font-mono text-5xl tabular-nums ${
isActive ? "text-fg" : "text-fg-2"
}`}
>
{elapsed}
</div>
</div>
</section>

View file

@ -40,6 +40,7 @@ import { WaitStatus } from "../components/stage-renderers/wait-status";
import { formatAbsoluteTs, formatBytes } from "../lib/format";
import {
useRun,
useRunEventsList,
useRunStageEvents,
useRunStageLog,
useRunStages,
@ -1137,6 +1138,11 @@ export default function RunStages() {
const renderer: StageRenderer = selectedStage
? selectStageRenderer(selectedStage.handler)
: "summary";
// Some renderers need run-scoped events (e.g. conditional renders the
// engine-level edge.selected event, which has no stage_id). Only fetch when
// the active renderer actually needs it to keep this off the hot path.
const needsRunEvents = renderer === "conditional";
const runEventsQuery = useRunEventsList(needsRunEvents ? id : undefined);
const commandTurn = useMemo<CommandTurn | null>(() => {
for (let i = turns.length - 1; i >= 0; i -= 1) {
const t = turns[i];
@ -1344,7 +1350,12 @@ export default function RunStages() {
) : renderer === "human" ? (
<HumanQA stage={selectedStage} events={debugEvents} />
) : renderer === "conditional" ? (
<ConditionalDecision stage={selectedStage} />
<ConditionalDecision
stage={selectedStage}
runEvents={runEventsQuery.data ?? []}
allStages={stages}
runId={id}
/>
) : renderer === "parallel" ? (
<ParallelChildren
stage={selectedStage}