feat(web): add sortable Size column to runs list

Surfaces the run t-shirt size (XS/S/M/L/XL) in both the main runs
list and the Children sub-tab, visible by default. L renders in
amber and XL in coral to flag risky and unhealthy runs at a glance.

Extracts a shared SizeChip component used by the run header and the
table cell, derives Ord on RunSize so the new sort key (server-side
ListRuns sort) orders by bucket, and reorders TOGGLEABLE_COLUMNS so
the column picker mirrors the visible table order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-25 14:07:17 -04:00
parent d69198bc30
commit acf8caa351
No known key found for this signature in database
11 changed files with 63 additions and 19 deletions

View file

@ -5,6 +5,7 @@ import type { RunWithStatus } from "../../data/runs";
import { formatRelativeTime } from "../../lib/format";
import { InlineMarkdown } from "../inline-markdown";
import { PullRequestChip } from "../pull-request-chip";
import { SizeChip } from "../size-chip";
import { RowActionsMenu } from "./row-actions-menu";
import { SelectionCheckbox } from "./selection-checkbox";
import type { ToggleableColumn } from "./toggleable-column";
@ -101,6 +102,11 @@ export function RunTableRow({
{run.elapsed}
</td>
)}
{show("size") && (
<td className="whitespace-nowrap px-3 py-2.5 text-right">
{run.size != null && <SizeChip size={run.size} />}
</td>
)}
{show("changes") && (
<td className="whitespace-nowrap px-3 py-2.5 text-right font-mono text-xs tabular-nums">
{run.additions != null && <span className="text-mint">+{run.additions.toLocaleString()}</span>}

View file

@ -150,6 +150,9 @@ export function RunsListView({
{show("elapsed") && (
<SortHeader label="Elapsed" sortKey="elapsed" activeSort={sort} direction={direction} align="right" onClick={onSortClick} />
)}
{show("size") && (
<SortHeader label="Size" sortKey="size" activeSort={sort} direction={direction} align="right" onClick={onSortClick} />
)}
{show("changes") && (
<SortHeader label="Changes" sortKey="changes" activeSort={sort} direction={direction} align="right" onClick={onSortClick} />
)}

View file

@ -1,9 +1,10 @@
export const TOGGLEABLE_COLUMNS = [
"elapsed",
"repo",
"workflow",
"created",
"updated",
"elapsed",
"size",
"changes",
"pr",
] as const;
@ -11,11 +12,12 @@ export const TOGGLEABLE_COLUMNS = [
export type ToggleableColumn = (typeof TOGGLEABLE_COLUMNS)[number];
export const toggleableColumnLabels: Record<ToggleableColumn, string> = {
elapsed: "Elapsed",
repo: "Repo",
workflow: "Workflow",
created: "Created",
updated: "Updated",
elapsed: "Elapsed",
size: "Size",
changes: "Changes",
pr: "PR",
};

View file

@ -0,0 +1,33 @@
import type { RunSize } from "@qltysh/fabro-api-client";
import { formatUsdMicros } from "../lib/format";
import { Tooltip } from "./ui";
const SIZE_TONE: Record<RunSize, { className: string; note: string | null }> = {
XS: { className: "bg-overlay text-fg-muted", note: null },
S: { className: "bg-overlay text-fg-muted", note: null },
M: { className: "bg-overlay text-fg-muted", note: null },
L: { className: "bg-amber/15 text-amber", note: "risky" },
XL: { className: "bg-coral/15 text-coral", note: "unhealthy" },
};
export function SizeChip({
size,
totalUsdMicros,
}: {
size: RunSize;
totalUsdMicros?: number | null;
}) {
const tone = SIZE_TONE[size];
const billed = totalUsdMicros != null ? ` · ${formatUsdMicros(totalUsdMicros)} billed` : "";
const tooltip = tone.note != null
? `Size ${size} (${tone.note})${billed}`
: `Size ${size}${billed}`;
return (
<Tooltip label={tooltip}>
<span className={`rounded px-1.5 py-0.5 font-mono text-xs font-bold tabular-nums ${tone.className}`}>
{size}
</span>
</Tooltip>
);
}

View file

@ -2,6 +2,7 @@ import { formatDurationMs } from "../lib/format";
import {
BoardColumn,
type Run,
type RunSize,
type RunStatus as ApiRunStatus,
} from "@qltysh/fabro-api-client";
@ -39,6 +40,7 @@ export interface RunItem {
sourceDirectory?: string;
createdAt?: string;
lastEventAt?: string;
size?: RunSize;
}
export const columnStatuses = [
@ -109,6 +111,7 @@ export function mapRunListItem(item: Run): RunItem {
lastEventAt: item.timestamps.last_event_at ?? undefined,
additions: item.diff?.additions,
deletions: item.diff?.deletions,
size: item.size,
};
}

View file

@ -36,6 +36,7 @@ import {
import { EditableRunTitle } from "../components/editable-run-title";
import { GitPullRequestIcon } from "../components/icons";
import { InterviewDock } from "../components/interview-dock";
import { SizeChip } from "../components/size-chip";
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
import { ErrorState } from "../components/state";
import { useToast } from "../components/toast";
@ -82,7 +83,6 @@ import {
formatAbsoluteTs,
formatDurationMs,
formatRelativeTime,
formatUsdMicros,
} from "../lib/format";
import { queryKeys } from "../lib/query-keys";
import { useRunEvents } from "../lib/run-events";
@ -561,16 +561,8 @@ export default function RunDetail({ params }: { params: { id: string } }) {
{run.workflow}
</span>
);
const totalUsdMicros = summary.billing?.total_usd_micros;
const sizeTooltip = totalUsdMicros != null
? `Size ${summary.size} · ${formatUsdMicros(totalUsdMicros)} billed`
: `Size ${summary.size}`;
const sizeChip = (
<Tooltip label={sizeTooltip}>
<span className="rounded bg-overlay px-1.5 py-0.5 font-mono text-xs font-bold text-fg-muted tabular-nums">
{summary.size}
</span>
</Tooltip>
<SizeChip size={summary.size} totalUsdMicros={summary.billing?.total_usd_micros} />
);
const visibility = lifecycleActionVisibility(run.lifecycleStatus);

View file

@ -249,7 +249,7 @@ describe("runs route workspace preferences", () => {
}),
);
expect(loadStoredRunsWorkspaceSearchParams(storage).toString()).toBe("hide=elapsed%2Crepo");
expect(loadStoredRunsWorkspaceSearchParams(storage).toString()).toBe("hide=repo%2Celapsed");
});
test("valid stored preferences produce canonical URL params", () => {

View file

@ -4763,7 +4763,7 @@ components:
description: Field to sort by. Defaults to `created_at`.
schema:
type: string
enum: [created_at, updated_at, status, elapsed, repo, title, workflow, changes]
enum: [created_at, updated_at, status, elapsed, repo, title, workflow, changes, size]
default: created_at
example: created_at

View file

@ -97,6 +97,7 @@ enum RunsSortKey {
Title,
Workflow,
Changes,
Size,
}
#[derive(Debug, Clone, Copy, Default, serde::Deserialize)]
@ -194,6 +195,7 @@ fn sort_runs(runs: &mut [fabro_types::Run], key: RunsSortKey, direction: RunsSor
RunsSortKey::Title => run_title_key(a).cmp(&run_title_key(b)),
RunsSortKey::Workflow => run_workflow_key(a).cmp(&run_workflow_key(b)),
RunsSortKey::Changes => run_changes_total(a).cmp(&run_changes_total(b)),
RunsSortKey::Size => a.size.cmp(&b.size),
};
let primary = if asc { primary } else { primary.reverse() };
// Stable tiebreak: newer ULIDs (and thus newer runs) first.

View file

@ -211,6 +211,8 @@ pub struct RunBillingSummary {
Default,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
Serialize,
Deserialize,

View file

@ -1120,7 +1120,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1868,7 +1868,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -2264,7 +2264,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.retrieveRunGraphSource(id, options).then((request) => request(axios, basePath));
},
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -2652,7 +2652,7 @@ export class RunsApi extends BaseAPI {
}
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -2773,7 +2773,8 @@ export const ListRunsSortEnum = {
REPO: 'repo',
TITLE: 'title',
WORKFLOW: 'workflow',
CHANGES: 'changes'
CHANGES: 'changes',
SIZE: 'size'
} as const;
export type ListRunsSortEnum = typeof ListRunsSortEnum[keyof typeof ListRunsSortEnum];
export const ListRunsDirectionEnum = {