mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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:
parent
80d3965670
commit
8b4442096d
7 changed files with 269 additions and 87 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 · score · 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 && (
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue