Show stage details in hover popover on run stages sidebar

Hovering a stage row reveals handler, timing, model, and status-specific
detail — failure reason for failed/retrying, notes for skipped/partial,
tokens and files-touched for succeeded. Lazy-fetches per-stage events
on first hover via the existing useRunStageEvents hook; HoverCard gains
an openDelay so a cursor sweep doesn't trigger fetches for every row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-24 10:57:37 -04:00
parent abc5991177
commit e21bc6e59d
No known key found for this signature in database
5 changed files with 697 additions and 36 deletions

View file

@ -0,0 +1,305 @@
import { describe, expect, test } from "bun:test";
import type { ReactNode } from "react";
import TestRenderer, { act } from "react-test-renderer";
import { SWRConfig } from "swr";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import { deriveStageSummary, StagePopover } from "./stage-popover";
import type { Stage } from "../lib/stage-sidebar";
import { generatedAxios } from "../lib/api-client";
function makeEvent(overrides: Partial<EventEnvelope>): EventEnvelope {
return {
id: "evt-1",
ts: "2026-05-24T12:00:00Z",
run_id: "run-1",
event: "stage.started",
seq: 1,
...overrides,
} as EventEnvelope;
}
function makeStage(overrides: Partial<Stage> = {}): Stage {
return {
id: "implement@1",
name: "implement",
handler: "agent",
nodeId: "implement",
visit: 1,
status: "succeeded",
duration: "1m 30s",
startedAt: "2026-05-24T11:58:30Z",
providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" },
...overrides,
};
}
describe("deriveStageSummary", () => {
test("returns empty summary for no events", () => {
expect(deriveStageSummary([])).toEqual({});
});
test("captures attempt and max_attempts from latest stage.started", () => {
const summary = deriveStageSummary([
makeEvent({ event: "stage.started", seq: 1, properties: { attempt: 1, max_attempts: 3 } }),
makeEvent({ event: "stage.failed", seq: 2, properties: { failure: { message: "boom" } } }),
makeEvent({ event: "stage.started", seq: 3, properties: { attempt: 2, max_attempts: 3 } }),
]);
expect(summary.attempt).toBe(2);
expect(summary.maxAttempts).toBe(3);
});
test("captures failure message from stage.failed", () => {
const summary = deriveStageSummary([
makeEvent({
event: "stage.failed",
properties: { failure: { message: "verify failed: 3 tests failing", system_actor: "agent" } },
}),
]);
expect(summary.failureMessage).toBe("verify failed: 3 tests failing");
expect(summary.systemActor).toBe("agent");
});
test("captures billing tokens from stage.completed", () => {
const summary = deriveStageSummary([
makeEvent({
event: "stage.completed",
properties: { billing: { input_tokens: 12400, output_tokens: 3120 } },
}),
]);
expect(summary.inputTokens).toBe(12400);
expect(summary.outputTokens).toBe(3120);
});
test("captures notes from stage.completed", () => {
const summary = deriveStageSummary([
makeEvent({
event: "stage.completed",
properties: { notes: "skipped because input was empty" },
}),
]);
expect(summary.notes).toBe("skipped because input was empty");
});
test("captures files_touched count from stage.completed", () => {
const summary = deriveStageSummary([
makeEvent({
event: "stage.completed",
properties: { files_touched: ["a.rs", "b.rs", "c.rs"] },
}),
]);
expect(summary.filesTouchedCount).toBe(3);
});
test("captures termination exit_code from stage.completed", () => {
const summary = deriveStageSummary([
makeEvent({
event: "stage.completed",
properties: { termination: { exit_code: 137 } },
}),
]);
expect(summary.exitCode).toBe(137);
});
test("later events overwrite earlier ones (latest attempt wins)", () => {
const summary = deriveStageSummary([
makeEvent({ event: "stage.failed", seq: 1, properties: { failure: { message: "old" } } }),
makeEvent({ event: "stage.failed", seq: 2, properties: { failure: { message: "newer" } } }),
]);
expect(summary.failureMessage).toBe("newer");
});
test("ignores non-lifecycle events", () => {
const summary = deriveStageSummary([
makeEvent({ event: "agent.tool.completed", properties: { tool_name: "Bash" } }),
makeEvent({ event: "stage.started", properties: { attempt: 1, max_attempts: 1 } }),
]);
expect(summary.attempt).toBe(1);
});
test("tolerates missing or non-numeric properties", () => {
const summary = deriveStageSummary([
makeEvent({ event: "stage.started", properties: {} }),
makeEvent({ event: "stage.completed", properties: { billing: null } }),
]);
expect(summary).toEqual({});
});
});
function render(node: ReactNode): TestRenderer.ReactTestRenderer {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let tree!: TestRenderer.ReactTestRenderer;
act(() => {
tree = TestRenderer.create(
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
{node}
</SWRConfig>,
);
});
return tree;
}
function textOf(tree: TestRenderer.ReactTestRenderer): string {
const collect = (n: ReturnType<TestRenderer.ReactTestRenderer["toJSON"]>): string => {
if (!n) return "";
if (typeof n === "string") return n;
if (Array.isArray(n)) return n.map(collect).join("");
return (n.children ?? []).map(collect).join("");
};
return collect(tree.toJSON());
}
interface MockEventsResponse {
data: EventEnvelope[];
meta: { has_more: boolean };
}
function withMockedStageEvents<T>(
events: EventEnvelope[],
body: () => Promise<T>,
): Promise<T> {
const response: MockEventsResponse = { data: events, meta: { has_more: false } };
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => ({
data: response,
status: 200,
statusText: "OK",
headers: {},
config,
});
return body().finally(() => {
generatedAxios.defaults.adapter = originalAdapter;
});
}
describe("StagePopover rendering", () => {
test("succeeded stage shows model, tokens, and files touched", async () => {
await withMockedStageEvents(
[
makeEvent({
event: "stage.completed",
properties: {
billing: { input_tokens: 12400, output_tokens: 3120 },
files_touched: ["a.rs", "b.rs"],
},
}),
],
async () => {
const stage = makeStage({ status: "succeeded" });
const tree = render(<StagePopover runId="run-1" stage={stage} duration="1m 30s" />);
await act(async () => {
await Promise.resolve();
});
const text = textOf(tree);
expect(text).toContain("implement");
expect(text).toContain("Succeeded");
expect(text).toContain("agent");
expect(text).toContain("claude-opus-4-7");
expect(text).toContain("12.4k in");
expect(text).toContain("3.1k out");
expect(text).toContain("Files touched");
},
);
});
test("failed stage shows truncated reason", async () => {
const longMessage = "x".repeat(500);
await withMockedStageEvents(
[makeEvent({ event: "stage.failed", properties: { failure: { message: longMessage } } })],
async () => {
const stage = makeStage({ status: "failed", duration: "12s" });
const tree = render(<StagePopover runId="run-1" stage={stage} duration="12s" />);
await act(async () => {
await Promise.resolve();
});
const text = textOf(tree);
expect(text).toContain("Reason");
expect(text).toContain("…");
// Truncated to ≤240 chars (plus the ellipsis we appended).
const reasonMatch = text.match(/x+/);
expect(reasonMatch).not.toBeNull();
expect(reasonMatch![0].length).toBeLessThanOrEqual(240);
},
);
});
test("retrying stage shows attempt and previous failure", async () => {
await withMockedStageEvents(
[
makeEvent({ event: "stage.started", seq: 1, properties: { attempt: 1, max_attempts: 3 } }),
makeEvent({
event: "stage.failed",
seq: 2,
properties: { failure: { message: "transient infra error" }, will_retry: true },
}),
],
async () => {
const stage = makeStage({ status: "retrying" });
const tree = render(<StagePopover runId="run-1" stage={stage} duration="--" />);
await act(async () => {
await Promise.resolve();
});
const text = textOf(tree);
expect(text).toContain("Attempt");
expect(text).toContain("1 of 3");
expect(text).toContain("Previous failure");
expect(text).toContain("transient infra error");
},
);
});
test("skipped stage shows skip reason from notes", async () => {
await withMockedStageEvents(
[makeEvent({ event: "stage.completed", properties: { notes: "no work to do" } })],
async () => {
const stage = makeStage({ status: "skipped", duration: "--" });
const tree = render(<StagePopover runId="run-1" stage={stage} duration="--" />);
await act(async () => {
await Promise.resolve();
});
const text = textOf(tree);
expect(text).toContain("Reason");
expect(text).toContain("no work to do");
},
);
});
test("pending stage renders minimal shell without status tail", () => {
const stage = makeStage({ status: "pending", duration: "--", startedAt: null });
const tree = render(<StagePopover runId="run-1" stage={stage} duration="--" />);
const text = textOf(tree);
expect(text).toContain("Pending");
expect(text).toContain("agent");
expect(text).not.toContain("Tokens");
expect(text).not.toContain("Reason");
});
test("failed command stage shows exit code instead of model", async () => {
await withMockedStageEvents(
[
makeEvent({
event: "stage.failed",
properties: { failure: { message: "exit 2" } },
}),
makeEvent({
event: "stage.completed",
properties: { termination: { exit_code: 2 } },
}),
],
async () => {
const stage = makeStage({
status: "failed",
handler: "command",
providerUsed: null,
});
const tree = render(<StagePopover runId="run-1" stage={stage} duration="3s" />);
await act(async () => {
await Promise.resolve();
});
const text = textOf(tree);
expect(text).toContain("Exit code");
expect(text).toContain("2");
},
);
});
});

View file

@ -0,0 +1,305 @@
import { useMemo } from "react";
import type { EventEnvelope, StageState } from "@qltysh/fabro-api-client";
import { formatTokenCount } from "../lib/format";
import { useRunStageEvents } from "../lib/queries";
import {
formatStageLabel,
stageStatusLabel,
stageStatusTone,
type Stage,
} from "../lib/stage-sidebar";
import { timeAgo } from "../lib/time";
import {
getArray,
getNumber,
getObject,
getString,
} from "../lib/unknown";
import { PopoverHeader, PopoverRow, PopoverRows } from "./ui";
const REASON_MAX_CHARS = 240;
export interface StageSummary {
attempt?: number;
maxAttempts?: number;
failureMessage?: string;
notes?: string;
inputTokens?: number;
outputTokens?: number;
filesTouchedCount?: number;
systemActor?: string;
exitCode?: number;
}
/**
* Reduce a stage's event stream to the latest values relevant to the popover.
* Events arrive in `seq` order, so later events overwrite earlier ones —
* giving us the most recent attempt counts, failure message, and billing.
*/
export function deriveStageSummary(events: EventEnvelope[]): StageSummary {
const summary: StageSummary = {};
for (const e of events) {
const props = e.properties ?? {};
switch (e.event) {
case "stage.started": {
const attempt = getNumber(props, "attempt");
const max = getNumber(props, "max_attempts");
if (attempt !== undefined) summary.attempt = attempt;
if (max !== undefined) summary.maxAttempts = max;
break;
}
case "stage.completed": {
readFailure(summary, getObject(props, "failure"));
readBilling(summary, getObject(props, "billing"));
readTermination(summary, getObject(props, "termination"));
const notes = getString(props, "notes");
if (notes !== undefined) summary.notes = notes;
const files = getArray(props, "files_touched");
if (files !== undefined) summary.filesTouchedCount = files.length;
break;
}
case "stage.failed": {
readFailure(summary, getObject(props, "failure"));
readBilling(summary, getObject(props, "billing"));
break;
}
}
}
return summary;
}
function readFailure(summary: StageSummary, failure: unknown) {
if (!failure) return;
const message = getString(failure, "message");
if (message !== undefined) summary.failureMessage = message;
const actor = getString(failure, "system_actor");
if (actor !== undefined) summary.systemActor = actor;
}
function readBilling(summary: StageSummary, billing: unknown) {
if (!billing) return;
const input = getNumber(billing, "input_tokens");
const output = getNumber(billing, "output_tokens");
if (input !== undefined) summary.inputTokens = input;
if (output !== undefined) summary.outputTokens = output;
}
function readTermination(summary: StageSummary, termination: unknown) {
if (!termination) return;
const exitCode = getNumber(termination, "exit_code");
if (exitCode !== undefined) summary.exitCode = exitCode;
}
/** Trim, collapse blank-line runs, and cap to ~240 chars with an ellipsis. */
function truncateReason(text: string): { display: string; truncated: boolean } {
const collapsed = text
.replace(/\r\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (collapsed.length <= REASON_MAX_CHARS) {
return { display: collapsed, truncated: false };
}
return {
display: `${collapsed.slice(0, REASON_MAX_CHARS - 1).trimEnd()}…`,
truncated: true,
};
}
function TruncatedReason({ text }: { text: string }) {
const { display, truncated } = truncateReason(text);
return (
<span
className="line-clamp-3 break-words text-fg-2"
title={truncated ? text : undefined}
>
{display}
</span>
);
}
function StatusPill({ status }: { status: StageState }) {
return (
<span
className={`shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${stageStatusTone(status)}`}
>
{stageStatusLabel(status)}
</span>
);
}
function ModelRow({ providerUsed }: { providerUsed: Stage["providerUsed"] }) {
if (!providerUsed?.model) return null;
const effort = providerUsed.reasoning_effort;
return (
<PopoverRow label="Model">
<span className="break-all font-mono">
{effort ? `${providerUsed.model}[${effort}]` : providerUsed.model}
</span>
</PopoverRow>
);
}
function AttemptRow({ summary }: { summary: StageSummary }) {
if (summary.attempt === undefined) return null;
const max = summary.maxAttempts;
return (
<PopoverRow label="Attempt">
{max && max > 1 ? `${summary.attempt} of ${max}` : `${summary.attempt}`}
</PopoverRow>
);
}
function TokensRow({ summary }: { summary: StageSummary }) {
if (summary.inputTokens === undefined && summary.outputTokens === undefined) return null;
const inLabel = formatTokenCount(summary.inputTokens ?? 0, { compactDecimal: true });
const outLabel = formatTokenCount(summary.outputTokens ?? 0, { compactDecimal: true });
return (
<PopoverRow label="Tokens">
<span className="font-mono tabular-nums">
{inLabel} in / {outLabel} out
</span>
</PopoverRow>
);
}
function StatusTail({
stage,
summary,
loading,
}: {
stage: Stage;
summary: StageSummary;
loading: boolean;
}) {
switch (stage.status) {
case "pending":
case "cancelled":
return summary.systemActor ? (
<PopoverRow label="Cancelled by">{summary.systemActor}</PopoverRow>
) : null;
case "running":
return (
<>
<AttemptRow summary={summary} />
<ModelRow providerUsed={stage.providerUsed} />
</>
);
case "retrying":
return (
<>
<AttemptRow summary={summary} />
{summary.failureMessage && (
<PopoverRow label="Previous failure">
<TruncatedReason text={summary.failureMessage} />
</PopoverRow>
)}
</>
);
case "succeeded":
return (
<>
<ModelRow providerUsed={stage.providerUsed} />
<TokensRow summary={summary} />
{summary.filesTouchedCount !== undefined && summary.filesTouchedCount > 0 && (
<PopoverRow label="Files touched">{summary.filesTouchedCount}</PopoverRow>
)}
</>
);
case "partially_succeeded":
return (
<>
{summary.notes && (
<PopoverRow label="Notes">
<TruncatedReason text={summary.notes} />
</PopoverRow>
)}
<ModelRow providerUsed={stage.providerUsed} />
<TokensRow summary={summary} />
</>
);
case "failed": {
const isCommand = stage.handler === "command";
return (
<>
{summary.failureMessage ? (
<PopoverRow label="Reason">
<TruncatedReason text={summary.failureMessage} />
</PopoverRow>
) : loading ? (
<LoadingRow />
) : null}
<AttemptRow summary={summary} />
{isCommand && summary.exitCode !== undefined ? (
<PopoverRow label="Exit code">
<span className="font-mono tabular-nums">{summary.exitCode}</span>
</PopoverRow>
) : (
<ModelRow providerUsed={stage.providerUsed} />
)}
</>
);
}
case "skipped":
return summary.notes ? (
<PopoverRow label="Reason">
<TruncatedReason text={summary.notes} />
</PopoverRow>
) : loading ? (
<LoadingRow />
) : null;
default:
return null;
}
}
function LoadingRow() {
return (
<>
<dt className="text-fg-3" />
<dd className="text-fg-muted italic">Loading details…</dd>
</>
);
}
interface StagePopoverProps {
runId: string;
stage: Stage;
/** Live duration string from the sidebar (formatted, ticking for active stages). */
duration: string;
}
export function StagePopover({ runId, stage, duration }: StagePopoverProps) {
const { data: events } = useRunStageEvents(runId, stage.id);
const summary = useMemo(() => deriveStageSummary(events ?? []), [events]);
const loading = events === undefined && stage.status !== "pending";
return (
<div className="min-w-[14rem]">
<PopoverHeader>
<div className="flex items-center justify-between gap-3">
<span className="font-mono text-fg">{formatStageLabel(stage)}</span>
<StatusPill status={stage.status} />
</div>
</PopoverHeader>
<PopoverRows>
<PopoverRow label="Handler">
<span className="font-mono">{stage.handler}</span>
</PopoverRow>
{stage.startedAt && (
<PopoverRow label="Started">
<time dateTime={stage.startedAt} title={stage.startedAt}>
{timeAgo(stage.startedAt)}
</time>
</PopoverRow>
)}
{duration !== "--" && (
<PopoverRow label="Duration">
<span className="font-mono tabular-nums">{duration}</span>
</PopoverRow>
)}
<StatusTail stage={stage} summary={summary} loading={loading} />
</PopoverRows>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { type ComponentType, type ReactNode, useCallback, useState } from "react";
import { Link } from "react-router";
import type { StageHandler, StageModelUsage, StageState } from "@qltysh/fabro-api-client";
import type { StageState } from "@qltysh/fabro-api-client";
import {
ArrowPathIcon,
CheckCircleIcon,
@ -19,20 +19,12 @@ import {
PaperClipIcon,
} from "@heroicons/react/24/outline";
import { formatDurationSecs } from "../lib/format";
import { ACTIVE_STAGE_STATES, formatStageLabel } from "../lib/stage-sidebar";
import { ACTIVE_STAGE_STATES, formatStageLabel, type Stage } from "../lib/stage-sidebar";
import { elapsedSecsSince, useTickingNow } from "../lib/time";
import { HoverCard } from "./ui";
import { StagePopover } from "./stage-popover";
export interface Stage {
id: string;
name: string;
handler: StageHandler;
status: StageState;
duration: string;
nodeId: string;
visit: number;
startedAt: string | null;
providerUsed: StageModelUsage | null;
}
export type { Stage };
export const statusConfig: Record<StageState, { icon: ComponentType<{ className?: string }>; color: string }> = {
pending: { icon: PauseCircleIcon, color: "text-fg-muted" },
@ -78,26 +70,38 @@ interface SidebarRowProps {
collapsed: boolean;
/** Spin the icon to signal an in-flight stage. */
spin?: boolean;
/** Rich popover shown on hover/focus; supersedes the collapsed-mode title. */
popover?: ReactNode;
}
/** A single sidebar link. The icon stays visible when collapsed; the label
* becomes screen-reader-only and a `title` tooltip stands in for sighted users. */
function SidebarRow({ to, icon: Icon, iconClass, label, trailing, active, collapsed, spin }: SidebarRowProps) {
function SidebarRow({ to, icon: Icon, iconClass, label, trailing, active, collapsed, spin, popover }: SidebarRowProps) {
const link = (
<Link
to={to}
title={popover || !collapsed ? undefined : label}
className={`flex items-center rounded-md py-1.5 text-sm transition-colors ${
collapsed ? "mx-1 justify-center" : "gap-2 px-2"
} ${active ? "bg-overlay text-fg" : "text-fg-3 hover:bg-overlay hover:text-fg"}`}
>
<Icon className={`size-4 shrink-0 ${iconClass} ${spin ? "animate-spin" : ""}`} />
<span className={collapsed ? "sr-only" : "flex-1 truncate"}>{label}</span>
{trailing != null && !collapsed && (
<span className="shrink-0 font-mono text-xs tabular-nums text-fg-muted">{trailing}</span>
)}
</Link>
);
return (
<li>
<Link
to={to}
title={collapsed ? label : undefined}
className={`flex items-center rounded-md py-1.5 text-sm transition-colors ${
collapsed ? "mx-1 justify-center" : "gap-2 px-2"
} ${active ? "bg-overlay text-fg" : "text-fg-3 hover:bg-overlay hover:text-fg"}`}
>
<Icon className={`size-4 shrink-0 ${iconClass} ${spin ? "animate-spin" : ""}`} />
<span className={collapsed ? "sr-only" : "flex-1 truncate"}>{label}</span>
{trailing != null && !collapsed && (
<span className="shrink-0 font-mono text-xs tabular-nums text-fg-muted">{trailing}</span>
)}
</Link>
{popover ? (
<HoverCard openDelay={200} className="block" content={popover}>
{link}
</HoverCard>
) : (
link
)}
</li>
);
}
@ -211,6 +215,7 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
active={selectedStageId === stage.id}
collapsed={collapsed}
spin={ACTIVE_STAGE_STATES.has(stage.status)}
popover={<StagePopover runId={runId} stage={stage} duration={stageDuration(stage)} />}
/>
);
})}

View file

@ -2,7 +2,7 @@
// exposes the primary button, secondary button, input, error message, and
// copy button so the auth and in-app surfaces can match.
import { useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
import {
@ -155,16 +155,45 @@ export function ConfirmDialog({
// Shared hover/focus state for `Tooltip` and `HoverCard`. Returns the trigger
// props to spread and the trigger rect (only while open) for positioning.
function useHoverAnchor() {
// `openDelay` (ms) defers showing the card until the pointer has dwelled on
// the trigger long enough — protects against fetch-on-mount popovers being
// triggered by an incidental cursor sweep through a list.
function useHoverAnchor(openDelay = 0) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLSpanElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
const requestOpen = () => {
if (openDelay <= 0) {
setOpen(true);
return;
}
clearTimer();
timerRef.current = setTimeout(() => {
timerRef.current = null;
setOpen(true);
}, openDelay);
};
const requestClose = () => {
clearTimer();
setOpen(false);
};
useEffect(() => clearTimer, []);
const rect = open ? (triggerRef.current?.getBoundingClientRect() ?? null) : null;
const triggerProps = {
ref: triggerRef,
onMouseEnter: () => setOpen(true),
onMouseLeave: () => setOpen(false),
onFocus: () => setOpen(true),
onBlur: () => setOpen(false),
onMouseEnter: requestOpen,
onMouseLeave: requestClose,
onFocus: requestOpen,
onBlur: requestClose,
};
return { open, rect, triggerProps };
}
@ -227,12 +256,14 @@ export function HoverCard({
content,
children,
className = "inline-flex",
openDelay = 0,
}: {
content: ReactNode;
children: ReactNode;
className?: string;
openDelay?: number;
}) {
const { open, rect, triggerProps } = useHoverAnchor();
const { open, rect, triggerProps } = useHoverAnchor(openDelay);
const id = useId();
const portalTarget = typeof document === "undefined" ? null : document.body;

View file

@ -1,10 +1,25 @@
import { StageState } from "@qltysh/fabro-api-client";
import type { PaginatedRunStageList } from "@qltysh/fabro-api-client";
import type {
PaginatedRunStageList,
StageHandler,
StageModelUsage,
} from "@qltysh/fabro-api-client";
import type { Stage } from "../components/stage-sidebar";
import { isVisibleStage } from "../data/runs";
import { formatDurationMs } from "./format";
export interface Stage {
id: string;
name: string;
handler: StageHandler;
status: StageState;
duration: string;
nodeId: string;
visit: number;
startedAt: string | null;
providerUsed: StageModelUsage | null;
}
export const ACTIVE_STAGE_STATES: ReadonlySet<StageState> = new Set([
StageState.RUNNING,
StageState.RETRYING,