mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
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:
parent
abc5991177
commit
e21bc6e59d
5 changed files with 697 additions and 36 deletions
305
apps/fabro-web/app/components/stage-popover.test.tsx
Normal file
305
apps/fabro-web/app/components/stage-popover.test.tsx
Normal 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");
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
305
apps/fabro-web/app/components/stage-popover.tsx
Normal file
305
apps/fabro-web/app/components/stage-popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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)} />}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue