diff --git a/apps/fabro-web/app/components/stage-popover.test.tsx b/apps/fabro-web/app/components/stage-popover.test.tsx new file mode 100644 index 000000000..f289f1a1f --- /dev/null +++ b/apps/fabro-web/app/components/stage-popover.test.tsx @@ -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 { + 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 { + 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( + new Map(), dedupingInterval: 0 }}> + {node} + , + ); + }); + return tree; +} + +function textOf(tree: TestRenderer.ReactTestRenderer): string { + const collect = (n: ReturnType): 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( + events: EventEnvelope[], + body: () => Promise, +): Promise { + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await act(async () => { + await Promise.resolve(); + }); + const text = textOf(tree); + expect(text).toContain("Exit code"); + expect(text).toContain("2"); + }, + ); + }); +}); diff --git a/apps/fabro-web/app/components/stage-popover.tsx b/apps/fabro-web/app/components/stage-popover.tsx new file mode 100644 index 000000000..81d0f1266 --- /dev/null +++ b/apps/fabro-web/app/components/stage-popover.tsx @@ -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 ( + + {display} + + ); +} + +function StatusPill({ status }: { status: StageState }) { + return ( + + {stageStatusLabel(status)} + + ); +} + +function ModelRow({ providerUsed }: { providerUsed: Stage["providerUsed"] }) { + if (!providerUsed?.model) return null; + const effort = providerUsed.reasoning_effort; + return ( + + + {effort ? `${providerUsed.model}[${effort}]` : providerUsed.model} + + + ); +} + +function AttemptRow({ summary }: { summary: StageSummary }) { + if (summary.attempt === undefined) return null; + const max = summary.maxAttempts; + return ( + + {max && max > 1 ? `${summary.attempt} of ${max}` : `${summary.attempt}`} + + ); +} + +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 ( + + + {inLabel} in / {outLabel} out + + + ); +} + +function StatusTail({ + stage, + summary, + loading, +}: { + stage: Stage; + summary: StageSummary; + loading: boolean; +}) { + switch (stage.status) { + case "pending": + case "cancelled": + return summary.systemActor ? ( + {summary.systemActor} + ) : null; + case "running": + return ( + <> + + + + ); + case "retrying": + return ( + <> + + {summary.failureMessage && ( + + + + )} + + ); + case "succeeded": + return ( + <> + + + {summary.filesTouchedCount !== undefined && summary.filesTouchedCount > 0 && ( + {summary.filesTouchedCount} + )} + + ); + case "partially_succeeded": + return ( + <> + {summary.notes && ( + + + + )} + + + + ); + case "failed": { + const isCommand = stage.handler === "command"; + return ( + <> + {summary.failureMessage ? ( + + + + ) : loading ? ( + + ) : null} + + {isCommand && summary.exitCode !== undefined ? ( + + {summary.exitCode} + + ) : ( + + )} + + ); + } + case "skipped": + return summary.notes ? ( + + + + ) : loading ? ( + + ) : null; + default: + return null; + } +} + +function LoadingRow() { + return ( + <> +
+
Loading details…
+ + ); +} + +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 ( +
+ +
+ {formatStageLabel(stage)} + +
+
+ + + {stage.handler} + + {stage.startedAt && ( + + + + )} + {duration !== "--" && ( + + {duration} + + )} + + +
+ ); +} diff --git a/apps/fabro-web/app/components/stage-sidebar.tsx b/apps/fabro-web/app/components/stage-sidebar.tsx index 6e83ce076..b7ff32547 100644 --- a/apps/fabro-web/app/components/stage-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.tsx @@ -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; 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 = ( + + + {label} + {trailing != null && !collapsed && ( + {trailing} + )} + + ); + return (
  • - - - {label} - {trailing != null && !collapsed && ( - {trailing} - )} - + {popover ? ( + + {link} + + ) : ( + link + )}
  • ); } @@ -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={} /> ); })} diff --git a/apps/fabro-web/app/components/ui.tsx b/apps/fabro-web/app/components/ui.tsx index 5f460d4e8..17977bf38 100644 --- a/apps/fabro-web/app/components/ui.tsx +++ b/apps/fabro-web/app/components/ui.tsx @@ -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(null); + const timerRef = useRef | 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; diff --git a/apps/fabro-web/app/lib/stage-sidebar.ts b/apps/fabro-web/app/lib/stage-sidebar.ts index 415a27cd2..fc49f9cf1 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.ts @@ -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 = new Set([ StageState.RUNNING, StageState.RETRYING,