From 581ab41d285f3c92a373f8fbbd70aa595638acff Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Sun, 17 May 2026 13:52:48 -0700 Subject: [PATCH] feat(web): add Overview summary panel and promote PR to header pill (#299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Promotes the most useful per-run state into the Overview tab so users no longer have to tab-hop to read the basics of a run. - **Adds a horizontal summary panel** above the workflow graph (right column only — does not span the stages sidebar) with five columns: **Created by · Changes · Sandbox · Cost · Artifacts**. Quiet-uppercase labels (`text-[10px] uppercase tracking-[0.08em] text-fg-muted`) over regular-weight values. Skeleton loaders while queries are in flight; em dash in muted color for missing/zero data. - **Promotes the PR chip** out of the meta strip into a `SECONDARY_BUTTON_CLASS`-style pill next to the Actions menu, visible on every tab. Pill renders only when a PR exists. - Lifts `formatBytesAsMemory`, `formatCpuCores`, `formatUsdMicros` to `lib/format.ts` so the panel can reuse them. - New `RunSummaryPanel` is split into a smart wrapper (owns the SWR hooks) + a presentational `RunSummaryPanelView` (prop-driven) for clean test seams. - All 7 `Principal` kinds (user / agent / system / slack / webhook / worker / anonymous) map to glyph + label; user kind uses login-initial avatar. ## Screenshots Captured against a real local Fabro server (`fabro server start`) on demo runs — these only exercise the Created-by column (the other cells display em dashes because the demo runs have no PR / diff / billing / artifacts data). The em-dash states **are** the intended empty-state design. ### Overview tab — full page ![Overview tab](https://files.catbox.moe/2idmv5.png) ### Header + tabs + summary panel close-up ![Header and panel](https://files.catbox.moe/bugbmy.png) ### Summary panel detail ![Summary panel](https://files.catbox.moe/4sbcl0.png) > The PR pill (mint icon + `#number` next to Actions) is unverified visually because no demo run on this server has an associated PR — but the rendering path is the same `SECONDARY_BUTTON_CLASS` markup as the Actions button and is conditioned on `run.pullRequestUrl && run.number != null`. See the [HTML prototype](https://github.com/fabro-sh/fabro/blob/feat/run-overview-summary-panel/.context/run-overview-options.html) for the locked design. ## Test plan - [x] `cd apps/fabro-web && bun run typecheck` clean (only pre-existing assistant-ui errors) - [x] `bun test` — +12 new passes, no new failures (387 pass / 5 fail / 2 errors vs baseline 375 / 6 / 3) - [x] Manual: load `/runs/` against a real server, confirm panel + em dashes render correctly - [ ] Manual on a run **with** a PR: verify the pill appears next to Actions and opens the PR in a new tab - [ ] Manual on a run **with** rich data (diff / billing / sandbox resources / artifacts): verify each column populates correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) --- .../app/components/run-summary-panel.test.tsx | 164 ++++++++++++++ .../app/components/run-summary-panel.tsx | 204 ++++++++++++++++++ apps/fabro-web/app/lib/format.ts | 34 +++ apps/fabro-web/app/routes/run-billing.tsx | 18 +- apps/fabro-web/app/routes/run-detail.test.ts | 7 +- apps/fabro-web/app/routes/run-detail.tsx | 21 +- apps/fabro-web/app/routes/run-overview.tsx | 4 +- .../fabro-web/app/routes/run-sandbox.test.tsx | 3 +- apps/fabro-web/app/routes/run-sandbox.tsx | 25 +-- 9 files changed, 441 insertions(+), 39 deletions(-) create mode 100644 apps/fabro-web/app/components/run-summary-panel.test.tsx create mode 100644 apps/fabro-web/app/components/run-summary-panel.tsx diff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx new file mode 100644 index 000000000..80c37f8ff --- /dev/null +++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import TestRenderer, { act } from "react-test-renderer"; + +import { + RunSummaryPanelView, + type RunSummaryPanelViewProps, +} from "./run-summary-panel"; + +function instanceText(instance: TestRenderer.ReactTestInstance): string { + const parts: string[] = []; + for (const child of instance.children) { + if (typeof child === "string") parts.push(child); + else parts.push(instanceText(child)); + } + return parts.join(""); +} + +function render(props: Partial = {}) { + const full: RunSummaryPanelViewProps = { + run: null, + runLoading: false, + sandboxResources: null, + sandboxLoading: false, + artifactsCount: null, + artifactsLoading: false, + ...props, + }; + let tree: TestRenderer.ReactTestRenderer | undefined; + act(() => { + tree = TestRenderer.create(); + }); + return tree!; +} + +function cellAfterLabel( + tree: TestRenderer.ReactTestRenderer, + label: string, +): TestRenderer.ReactTestInstance { + const labelNode = tree.root.find( + (node) => + node.type === "div" && + node.children.length === 1 && + typeof node.children[0] === "string" && + node.children[0] === label, + ); + const parent = labelNode.parent; + if (!parent) throw new Error(`Could not find parent of label "${label}"`); + return parent.children[1] as TestRenderer.ReactTestInstance; +} + +function makeRun(overrides: Record = {}) { + return { + id: "run_1", + created_by: null, + diff: null, + billing: null, + ...overrides, + } as any; +} + +const EM_DASH = "—"; + +describe("RunSummaryPanelView", () => { + test("renders all five column labels", () => { + const tree = render(); + const rendered = JSON.stringify(tree.toJSON()); + for (const label of ["Created by", "Changes", "Sandbox", "Cost", "Artifacts"]) { + expect(rendered).toContain(label); + } + }); + + test("shows em dash for missing run fields after load", () => { + const tree = render({ run: makeRun() }); + expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe(EM_DASH); + expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe(EM_DASH); + expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe(EM_DASH); + }); + + test("shows em dash when sandbox is absent", () => { + const tree = render({ run: makeRun(), sandboxResources: null }); + expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe(EM_DASH); + }); + + test("shows em dash when artifacts count is zero", () => { + const tree = render({ run: makeRun(), artifactsCount: 0 }); + expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe(EM_DASH); + }); + + test("renders diff additions/deletions/files with correct formatting", () => { + const tree = render({ + run: makeRun({ + diff: { additions: 124, deletions: 37, files_changed: 7 }, + }), + }); + expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe( + "+124 −37in 7 files", + ); + }); + + test("singular 'file' when files_changed is 1", () => { + const tree = render({ + run: makeRun({ + diff: { additions: 3, deletions: 0, files_changed: 1 }, + }), + }); + expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe( + "+3 −0in 1 file", + ); + }); + + test("renders cost from total_usd_micros", () => { + const tree = render({ + run: makeRun({ billing: { total_usd_micros: 840_000 } }), + }); + expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe("$0.84"); + }); + + test("renders sandbox CPU and memory", () => { + const tree = render({ + run: makeRun(), + sandboxResources: { cpu_cores: 4, memory_bytes: 8 * 1024 * 1024 * 1024 } as any, + }); + expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("4 CPU · 8 GiB"); + }); + + test("renders artifacts count when positive", () => { + const tree = render({ run: makeRun(), artifactsCount: 3 }); + expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe("3"); + }); + + test("renders user actor with login initial", () => { + const tree = render({ + run: makeRun({ + created_by: { + kind: "user", + identity: { issuer: "github", subject: "1" }, + login: "brynary", + auth_method: "oauth", + }, + }), + }); + expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe("Bbrynary"); + }); + + test("renders non-user actor with kind label", () => { + for (const kind of ["agent", "system", "slack", "webhook", "worker", "anonymous"]) { + const tree = render({ run: makeRun({ created_by: { kind } as any }) }); + expect(instanceText(cellAfterLabel(tree, "Created by"))).toContain(kind); + } + }); + + test("shows skeleton placeholders while queries are loading", () => { + const tree = render({ + runLoading: true, + sandboxLoading: true, + artifactsLoading: true, + }); + const rendered = JSON.stringify(tree.toJSON()); + expect(rendered).toContain("animate-pulse"); + expect(instanceText(cellAfterLabel(tree, "Created by"))).not.toContain(EM_DASH); + expect(instanceText(cellAfterLabel(tree, "Sandbox"))).not.toContain(EM_DASH); + expect(instanceText(cellAfterLabel(tree, "Artifacts"))).not.toContain(EM_DASH); + }); +}); diff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx new file mode 100644 index 000000000..d9d43ab95 --- /dev/null +++ b/apps/fabro-web/app/components/run-summary-panel.tsx @@ -0,0 +1,204 @@ +import type { ReactNode } from "react"; +import { + BoltIcon, + ChatBubbleLeftEllipsisIcon, + Cog6ToothIcon, + CpuChipIcon, + QuestionMarkCircleIcon, + ServerIcon, +} from "@heroicons/react/20/solid"; +import type { Principal, Run, SandboxResources } from "@qltysh/fabro-api-client"; + +import { + formatBytesAsMemory, + formatCpuCores, + formatUsdMicros, +} from "../lib/format"; +import { useRun, useRunArtifacts, useRunSandboxDetails } from "../lib/queries"; + +const LABEL_CLASS = + "text-[10px] font-medium uppercase tracking-[0.08em] text-fg-muted"; +const VALUE_WRAPPER_CLASS = "mt-1.5"; +const VALUE_CLASS = "text-sm text-fg"; +const VALUE_MONO_CLASS = "text-sm text-fg font-mono tabular-nums"; +const EM_DASH_CLASS = "text-sm text-fg-muted font-mono"; + +function EmDash() { + return —; +} + +function Skeleton({ widthClass }: { widthClass: string }) { + return ( + {demoMode && } + {run.pullRequestUrl && run.number != null && ( + + + #{run.number} + + )} + -
+
+ {graphSvg === undefined && graphQuery.isLoading ? (
) : graphSvg ? ( diff --git a/apps/fabro-web/app/routes/run-sandbox.test.tsx b/apps/fabro-web/app/routes/run-sandbox.test.tsx index 1ae03e0e6..c40763916 100644 --- a/apps/fabro-web/app/routes/run-sandbox.test.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.test.tsx @@ -80,8 +80,9 @@ mock.module("@pierre/diffs/react", () => ({ ), })); -const { default: RunSandbox, formatBytesAsMemory, normalizeSandboxMode } = +const { default: RunSandbox, normalizeSandboxMode } = await import("./run-sandbox"); +const { formatBytesAsMemory } = await import("../lib/format"); mock.restore(); const mountedRenderers: TestRenderer.ReactTestRenderer[] = []; diff --git a/apps/fabro-web/app/routes/run-sandbox.tsx b/apps/fabro-web/app/routes/run-sandbox.tsx index 8cc0dbc24..3a8402e12 100644 --- a/apps/fabro-web/app/routes/run-sandbox.tsx +++ b/apps/fabro-web/app/routes/run-sandbox.tsx @@ -4,7 +4,11 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import TerminalView, { TERMINAL_DOCK_CLEARANCE_CLASS } from "../components/terminal-view"; import { EmptyState, ErrorState } from "../components/state"; -import { formatAbsoluteTs } from "../lib/format"; +import { + formatAbsoluteTs, + formatBytesAsMemory, + formatCpuCores, +} from "../lib/format"; import { useRunSandboxDetails } from "../lib/queries"; import type { SandboxDetails, @@ -52,25 +56,6 @@ const STATE_DISPLAY: Record= BYTES_PER_GIB) { - const gib = bytes / BYTES_PER_GIB; - return `${Number.isInteger(gib) ? gib : gib.toFixed(1)} GiB`; - } - if (bytes >= BYTES_PER_MIB) { - const mib = bytes / BYTES_PER_MIB; - return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`; - } - return `${bytes} B`; -} - -function formatCpuCores(cores: number): string { - return Number.isInteger(cores) ? cores.toString() : cores.toFixed(2); -} - function nullable(value: string | null | undefined): string { return value && value.length > 0 ? value : EMPTY_VALUE; }