Merge pull request #657 from fabro-sh/feat/runs-list-size-tooltip

Show run size chips consistently across list, board, and detail
This commit is contained in:
Bryan Helmkamp 2026-07-28 14:29:34 -04:00 • committed by GitHub
commit 6bd6712a5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 87 additions and 12 deletions

View file

@ -115,8 +115,10 @@ export function RunTableRow({
</td>
)}
{show("size") && (
<td className="whitespace-nowrap px-3 py-2.5 text-center">
{run.size != null && <SizeChip size={run.size} />}
<td className="relative z-10 px-3 py-2.5 text-center whitespace-nowrap">
{run.size != null && (
<SizeChip size={run.size} totalUsdMicros={run.totalUsdMicros} />
)}
</td>
)}
{show("changes") && (

View file

@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { setupReactTestEnv } from "../lib/test-utils";
import { SizeChip } from "./size-chip";
import { Tooltip } from "./ui";
let teardownReactTestEnv: (() => void) | undefined;
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
function render(element: React.ReactElement): TestRenderer.ReactTestRenderer {
let renderer: TestRenderer.ReactTestRenderer | undefined;
act(() => {
renderer = TestRenderer.create(element);
});
mountedRenderers.push(renderer!);
return renderer!;
}
function tooltipLabel(element: React.ReactElement): string {
return render(element).root.findByType(Tooltip).props.label as string;
}
describe("SizeChip", () => {
beforeEach(() => {
teardownReactTestEnv = setupReactTestEnv();
});
afterEach(() => {
act(() => {
for (const renderer of mountedRenderers.splice(0)) {
renderer.unmount();
}
});
teardownReactTestEnv?.();
teardownReactTestEnv = undefined;
});
test("renders the size letter", () => {
expect(JSON.stringify(render(<SizeChip size="M" />).toJSON())).toContain("M");
});
test("appends the cost to the tooltip", () => {
expect(tooltipLabel(<SizeChip size="M" totalUsdMicros={12_340_000} />))
.toBe("Size M · $12.34");
});
test("omits the cost when the run has no billing yet", () => {
expect(tooltipLabel(<SizeChip size="M" />)).toBe("Size M");
expect(tooltipLabel(<SizeChip size="M" totalUsdMicros={null} />)).toBe("Size M");
});
test("calls out the tiers that warrant attention", () => {
expect(tooltipLabel(<SizeChip size="L" totalUsdMicros={150_000_000} />))
.toBe("Size L (risky) · $150.00");
expect(tooltipLabel(<SizeChip size="XL" />)).toBe("Size XL (unhealthy)");
});
});

View file

@ -1,3 +1,4 @@
import { memo } from "react";
import type { RunSize } from "@qltysh/fabro-api-client";
import { formatUsdMicros } from "../lib/format";
@ -11,7 +12,7 @@ const SIZE_TONE: Record<RunSize, { className: string; note: string | null }> = {
XL: { className: "bg-coral/15 text-coral", note: "unhealthy" },
};
export function SizeChip({
export const SizeChip = memo(function SizeChip({
size,
totalUsdMicros,
}: {
@ -19,10 +20,10 @@ export function SizeChip({
totalUsdMicros?: number | null;
}) {
const tone = SIZE_TONE[size];
const billed = totalUsdMicros != null ? ` · ${formatUsdMicros(totalUsdMicros)} billed` : "";
const amount = totalUsdMicros != null ? ` · ${formatUsdMicros(totalUsdMicros)}` : "";
const tooltip = tone.note != null
? `Size ${size} (${tone.note})${billed}`
: `Size ${size}${billed}`;
? `Size ${size} (${tone.note})${amount}`
: `Size ${size}${amount}`;
return (
<Tooltip label={tooltip}>
<span className={`rounded px-1.5 py-0.5 font-mono text-xs font-bold tabular-nums ${tone.className}`}>
@ -30,4 +31,4 @@ export function SizeChip({
</span>
</Tooltip>
);
}
});

View file

@ -103,6 +103,17 @@ describe("mapRunListItem", () => {
expect(mapRunListItem(summary).title).toBe("Untitled run");
});
test("carries the billed total so the size chip can show it on hover", () => {
expect(mapRunListItem(makeRun()).totalUsdMicros).toBe(500000);
});
test("leaves the billed total undefined for runs without terminal billing", () => {
expect(mapRunListItem(makeRun({ billing: null })).totalUsdMicros).toBeUndefined();
expect(
mapRunListItem(makeRun({ billing: { total_usd_micros: null } })).totalUsdMicros,
).toBeUndefined();
});
});
describe("mapRunToRunItem", () => {

View file

@ -46,6 +46,7 @@ export interface RunItem {
createdBy: Principal;
lastEventAt?: string;
size?: RunSize;
totalUsdMicros?: number;
}
export const columnStatuses = [
@ -119,6 +120,7 @@ export function mapRunListItem(item: Run): RunItem {
additions: item.diff?.additions,
deletions: item.diff?.deletions,
size: item.size,
totalUsdMicros: item.billing?.total_usd_micros ?? undefined,
};
}

View file

@ -26,6 +26,7 @@ import { ciConfig, columnForRun, columnStatusDisplay, columnStatuses, deriveCiSt
import type { CiStatus, CheckRun, CheckStatus, RunItem } from "../data/runs";
import { EmptyState } from "../components/state";
import { PullRequestChip } from "../components/pull-request-chip";
import { SizeChip } from "../components/size-chip";
import {
summarizeBatchLifecycleAction,
} from "../components/runs-list/batch-lifecycle";
@ -345,7 +346,7 @@ function PrCard({
// All inline footer metadata on PrCard belongs in this one row. Adding a new
// piece as a sibling `<div>` below the card body recreates a recurring bug
// where stats stack onto separate lines instead of sitting next to elapsed/actions.
// where stats stack onto separate lines instead of sitting next to size/actions.
function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) {
const hasActions = actions != null && actions.length > 0;
const hasStats =
@ -354,7 +355,7 @@ function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) {
(pr.additions != null && pr.additions !== 0) ||
(pr.deletions != null && pr.deletions !== 0);
if (!hasStats && !hasActions && pr.elapsed == null) return null;
if (!hasStats && !hasActions && pr.size == null) return null;
return (
<div className="mt-3 flex items-center gap-3 font-mono text-xs">
@ -416,9 +417,9 @@ function PrCardFooter({ pr, actions }: { pr: RunItem; actions?: string[] }) {
))}
</div>
)}
{pr.elapsed != null && (
<span className={`text-fg-muted ${hasActions ? "" : "ml-auto"}`}>
{pr.elapsed}
{pr.size != null && (
<span className={hasActions ? "inline-flex" : "ml-auto inline-flex"}>
<SizeChip size={pr.size} totalUsdMicros={pr.totalUsdMicros} />
</span>
)}
</div>