feat(web): add Overview summary panel and promote PR to header pill (#299)

## 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/<id>` 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) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-17 13:52:48 -07:00 • committed by GitHub
parent 73ebb7d28b
commit 581ab41d28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 441 additions and 39 deletions

View file

@ -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<RunSummaryPanelViewProps> = {}) {
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(<RunSummaryPanelView {...full} />);
});
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<string, any> = {}) {
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);
});
});

View file

@ -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 <span className={EM_DASH_CLASS}>—</span>;
}
function Skeleton({ widthClass }: { widthClass: string }) {
return (
<div
aria-hidden="true"
className={`h-4 ${widthClass} animate-pulse rounded bg-overlay`}
/>
);
}
function Cell({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<div className={LABEL_CLASS}>{label}</div>
<div className={VALUE_WRAPPER_CLASS}>{children}</div>
</div>
);
}
interface CreatedByDisplay {
glyph: ReactNode;
label: string;
}
function principalGlyph(icon: ReactNode) {
return (
<span className="grid size-5 place-items-center rounded-full bg-teal-500/20 text-teal-500">
{icon}
</span>
);
}
function createdByDisplay(actor: Principal): CreatedByDisplay {
switch (actor.kind) {
case "user": {
const initial = actor.login.charAt(0).toUpperCase() || "?";
return {
glyph: (
<span className="grid size-5 place-items-center rounded-full bg-teal-500/20 font-mono text-[10px] font-medium text-teal-500">
{initial}
</span>
),
label: actor.login,
};
}
case "agent":
return { glyph: principalGlyph(<CpuChipIcon className="size-3" />), label: "agent" };
case "system":
return { glyph: principalGlyph(<Cog6ToothIcon className="size-3" />), label: "system" };
case "slack":
return {
glyph: principalGlyph(<ChatBubbleLeftEllipsisIcon className="size-3" />),
label: "slack",
};
case "webhook":
return { glyph: principalGlyph(<BoltIcon className="size-3" />), label: "webhook" };
case "worker":
return { glyph: principalGlyph(<ServerIcon className="size-3" />), label: "worker" };
case "anonymous":
return {
glyph: principalGlyph(<QuestionMarkCircleIcon className="size-3" />),
label: "anonymous",
};
}
}
export interface RunSummaryPanelViewProps {
run: Run | null;
runLoading: boolean;
sandboxResources: SandboxResources | null;
sandboxLoading: boolean;
artifactsCount: number | null;
artifactsLoading: boolean;
}
export function RunSummaryPanelView({
run,
runLoading,
sandboxResources,
sandboxLoading,
artifactsCount,
artifactsLoading,
}: RunSummaryPanelViewProps) {
const created = run?.created_by ? createdByDisplay(run.created_by) : null;
const diff = run?.diff ?? null;
const cost = formatUsdMicros(run?.billing?.total_usd_micros);
return (
<div className="rounded-md border border-line bg-panel/60 px-6 py-4">
<div className="flex flex-wrap items-baseline gap-x-14 gap-y-3">
<Cell label="Created by">
{runLoading ? (
<Skeleton widthClass="w-20" />
) : created ? (
<div className="flex items-center gap-2">
{created.glyph}
<span className={VALUE_CLASS}>{created.label}</span>
</div>
) : (
<EmDash />
)}
</Cell>
<Cell label="Changes">
{runLoading ? (
<Skeleton widthClass="w-32" />
) : diff ? (
<div className="flex items-baseline gap-2 text-sm">
<span className="font-mono tabular-nums">
<span className="text-mint">+{diff.additions}</span>{" "}
<span className="text-coral">−{diff.deletions}</span>
</span>
<span className="text-fg-3">
in {diff.files_changed} {diff.files_changed === 1 ? "file" : "files"}
</span>
</div>
) : (
<EmDash />
)}
</Cell>
<Cell label="Sandbox">
{sandboxLoading ? (
<Skeleton widthClass="w-24" />
) : sandboxResources &&
sandboxResources.cpu_cores != null &&
sandboxResources.memory_bytes != null ? (
<span className={VALUE_CLASS}>
{formatCpuCores(sandboxResources.cpu_cores)} CPU ·{" "}
{formatBytesAsMemory(sandboxResources.memory_bytes)}
</span>
) : (
<EmDash />
)}
</Cell>
<Cell label="Cost">
{runLoading ? (
<Skeleton widthClass="w-12" />
) : cost != null ? (
<span className={VALUE_MONO_CLASS}>{cost}</span>
) : (
<EmDash />
)}
</Cell>
<Cell label="Artifacts">
{artifactsLoading ? (
<Skeleton widthClass="w-8" />
) : artifactsCount != null && artifactsCount > 0 ? (
<span className={VALUE_MONO_CLASS}>{artifactsCount}</span>
) : (
<EmDash />
)}
</Cell>
</div>
</div>
);
}
export function RunSummaryPanel({ runId }: { runId: string }) {
const runQuery = useRun(runId);
const sandboxQuery = useRunSandboxDetails(runId);
const artifactsQuery = useRunArtifacts(runId);
return (
<RunSummaryPanelView
run={runQuery.data ?? null}
runLoading={runQuery.isLoading && !runQuery.data}
sandboxResources={sandboxQuery.data?.resources ?? null}
sandboxLoading={sandboxQuery.isLoading && !sandboxQuery.data}
artifactsCount={artifactsQuery.data?.data.length ?? null}
artifactsLoading={artifactsQuery.isLoading && !artifactsQuery.data}
/>
);
}

View file

@ -100,3 +100,37 @@ export function formatTokenCount(
if (value < 1_000_000) return `${Math.round(value / 1000)}k`;
return `${Math.round(value / 1_000_000)}M`;
}
const BYTES_PER_GIB = 1024 * 1024 * 1024;
const BYTES_PER_MIB = 1024 * 1024;
/**
* Format a byte count as a memory/disk size (e.g. "8 GiB", "512 MiB", "1024 B").
*/
export function formatBytesAsMemory(bytes: number): string {
if (bytes >= 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`;
}
/**
* Format a CPU-core count for display (whole cores as integer; fractional as 2-decimal).
*/
export function formatCpuCores(cores: number): string {
return Number.isInteger(cores) ? cores.toString() : cores.toFixed(2);
}
/**
* Format a USD-micros amount as a dollar string ("$1.23"). Returns null when the
* input is null/undefined so callers can render their own empty placeholder.
*/
export function formatUsdMicros(usdMicros: number | null | undefined): string | null {
if (usdMicros == null) return null;
return `$${(usdMicros / 1_000_000).toFixed(2)}`;
}

View file

@ -1,7 +1,11 @@
import { useMemo } from "react";
import { EmptyState } from "../components/state";
import { formatDurationSecs, formatTokenCount } from "../lib/format";
import {
formatDurationSecs,
formatTokenCount,
formatUsdMicros,
} from "../lib/format";
import { useRunBilling } from "../lib/queries";
import { IN_FLIGHT_STAGE_STATES } from "../lib/stage-sidebar";
import { useTickingNow } from "../lib/time";
@ -18,8 +22,8 @@ function formatTokens(n: number | null | undefined) {
return formatTokenCount(n, { compactDecimal: true });
}
function formatUsdMicros(usdMicros?: number | null) {
return usdMicros == null ? EMPTY_VALUE : `$${(usdMicros / 1_000_000).toFixed(2)}`;
function formatUsdMicrosOrDash(usdMicros?: number | null): string {
return formatUsdMicros(usdMicros) ?? EMPTY_VALUE;
}
function formatModelRef(model?: BillingModelRef | null): string | null {
@ -176,7 +180,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{formatDurationSecs(row.runtimeSecs)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatUsdMicros(row.totalUsdMicros)}
{formatUsdMicrosOrDash(row.totalUsdMicros)}
</td>
</tr>
))}
@ -193,7 +197,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{formatDurationSecs(totalRuntimeSecs)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatUsdMicros(totalUsdMicros)}
{formatUsdMicrosOrDash(totalUsdMicros)}
</td>
</tr>
</tfoot>
@ -225,7 +229,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{formatTokens(row.outputTokens)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatUsdMicros(row.totalUsdMicros)}
{formatUsdMicrosOrDash(row.totalUsdMicros)}
</td>
</tr>
))}
@ -241,7 +245,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{formatTokens(totalOutput)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatUsdMicros(totalUsdMicros)}
{formatUsdMicrosOrDash(totalUsdMicros)}
</td>
</tr>
</tfoot>

View file

@ -445,7 +445,7 @@ describe("RunDetail full-height child routes", () => {
expect(tabCountBadges(renderer)).toHaveLength(0);
});
test("shows a linked pull request chip in the run header", async () => {
test("shows a linked pull request pill in the run header", async () => {
const renderer = await renderRunDetail({
initialEntry: "/runs/run_1",
pullRequest: {
@ -464,7 +464,10 @@ describe("RunDetail full-height child routes", () => {
expect(links).toHaveLength(1);
expect(links[0].props.target).toBe("_blank");
expect(links[0].children.filter((child) => typeof child !== "object").join("")).toBe("#123");
const numberSpan = links[0].findByType("span");
expect(
numberSpan.children.filter((child) => typeof child !== "object").join(""),
).toBe("#123");
});
test("keeps blocked full-height children clear of the interview dock without an h-72 sibling", async () => {

View file

@ -17,8 +17,8 @@ import { Link, Outlet, useLocation, useMatches, useNavigate } from "react-router
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { EditableRunTitle } from "../components/editable-run-title";
import { GitPullRequestIcon } from "../components/icons";
import { InterviewDock } from "../components/interview-dock";
import { PullRequestChip } from "../components/pull-request-chip";
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
import { ErrorState } from "../components/state";
import { useToast } from "../components/toast";
@ -341,18 +341,23 @@ export default function RunDetail({ params }: { params: { id: string } }) {
</span>
</Tooltip>
)}
{run.pullRequestUrl && run.number != null && (
<PullRequestChip
number={run.number}
url={run.pullRequestUrl}
iconClassName="size-3.5"
/>
)}
</div>
</div>
{demoMode && <ConnectMenu />}
{run.pullRequestUrl && run.number != null && (
<a
href={run.pullRequestUrl}
target="_blank"
rel="noopener noreferrer"
className={SECONDARY_BUTTON_CLASS}
>
<GitPullRequestIcon className="size-4 text-mint" />
<span className="font-mono">#{run.number}</span>
</a>
)}
<ActionsMenu
canSendInterrupt={statusKind === "running"}
interruptPending={interruptMutation.isMutating}

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router";
import { graphTheme } from "../lib/graph-theme";
import { useRun, useRunGraph, useRunStages } from "../lib/queries";
import { RunSummaryPanel } from "../components/run-summary-panel";
import { StageSidebar } from "../components/stage-sidebar";
import {
GRAPH_DEFAULT_ZOOM_INDEX,
@ -192,7 +193,8 @@ export default function RunOverview() {
<div className="flex gap-6">
<StageSidebar stages={stages} runId={id!} />
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 space-y-4">
<RunSummaryPanel runId={id!} />
{graphSvg === undefined && graphQuery.isLoading ? (
<div className="py-12" />
) : graphSvg ? (

View file

@ -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[] = [];

View file

@ -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<SandboxState, { label: string; dot: string; text: st
error: { label: "Error", dot: "bg-coral", text: "text-coral" },
};
const BYTES_PER_GIB = 1024 * 1024 * 1024;
const BYTES_PER_MIB = 1024 * 1024;
export function formatBytesAsMemory(bytes: number): string {
if (bytes >= 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;
}