feat(web): upgrade Children sub-tab to use the runs list view

The /runs/:id/children tab now gets server-side pagination, sortable
columns, column picker, search/time/archived filters, and bulk
archive/unarchive/delete — same affordances as the main runs list view.
Preferences persist to localStorage under a dedicated key so they don't
collide with the /runs page.

Repo and Workflow filter buttons are intentionally omitted (children
typically share these with the parent), but those columns remain visible
for the cases where workflows fan out across repos.

- New childRunsListPreferences in components/runs-list/preferences.ts
- run-children.tsx fetches via useRunsPage({parentId, ...}) with all
  list controls wired up
- Empty state retains the existing "Learn about parent links" CTA
- useChildRuns + queryKeys.runs.children removed (replaced by the
  generalized useRunsPage)
- useRetryRun broadcasts via mutateRunListCaches now that the dedicated
  children cache key is gone
- Revert board-cache children matcher added in the previous commit
  (no longer needed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-25 12:48:18 -04:00
parent d59f97d6a0
commit 4dfcbc0e03
No known key found for this signature in database
6 changed files with 312 additions and 61 deletions

View file

@ -247,6 +247,139 @@ export function persistRunsWorkspacePreferences(
}
}
const CHILD_RUNS_LIST_PREFERENCES_VERSION = 1;
export const CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY = "fabro:run-children-preferences:v1";
const CHILD_RUNS_LIST_PARAM_KEYS = [
"search",
"created",
"archived",
"sort",
"direction",
"size",
"hide",
] as const;
export interface ChildRunsListPreferences {
version: typeof CHILD_RUNS_LIST_PREFERENCES_VERSION;
search: string;
created: CreatedFilter;
archived: boolean;
sort: ListRunsSortEnum;
direction: ListRunsDirectionEnum;
size: number;
hide: string;
// URL-only: never persisted to localStorage.
page: number;
}
export function defaultChildRunsListPreferences(): ChildRunsListPreferences {
return {
version: CHILD_RUNS_LIST_PREFERENCES_VERSION,
search: "",
created: "all",
archived: false,
sort: "created_at",
direction: "desc",
size: DEFAULT_LIST_PAGE_SIZE,
hide: "",
page: 1,
};
}
function normalizeStoredChildRunsListPreferences(value: unknown): ChildRunsListPreferences {
const record = storageRecord(value);
if (record == null || record.version !== CHILD_RUNS_LIST_PREFERENCES_VERSION) {
return defaultChildRunsListPreferences();
}
const hiddenColumns = parseHiddenColumns(stringValue(record.hide));
const size = record.size;
return {
version: CHILD_RUNS_LIST_PREFERENCES_VERSION,
search: stringValue(record.search) ?? "",
created: parseCreatedFilter(stringValue(record.created)),
archived: record.archived === true || record.archived === "1",
sort: parseSort(stringValue(record.sort)),
direction: parseDirection(stringValue(record.direction)),
size: parsePageSize(typeof size === "number" || typeof size === "string" ? String(size) : null),
hide: serializeHiddenColumns(hiddenColumns) ?? "",
page: 1,
};
}
export function childRunsListPreferencesFromSearchParams(
searchParams: URLSearchParams,
): ChildRunsListPreferences {
return {
version: CHILD_RUNS_LIST_PREFERENCES_VERSION,
search: searchParams.get("search") ?? "",
created: parseCreatedFilter(searchParams.get("created")),
archived: searchParams.get("archived") === "1",
sort: parseSort(searchParams.get("sort")),
direction: parseDirection(searchParams.get("direction")),
size: parsePageSize(searchParams.get("size")),
hide: serializeHiddenColumns(parseHiddenColumns(searchParams.get("hide"))) ?? "",
page: parsePage(searchParams.get("page")),
};
}
export function childRunsListPreferencesToSearchParams(
preferences: ChildRunsListPreferences,
): URLSearchParams {
const params = new URLSearchParams();
if (preferences.search !== "") params.set("search", preferences.search);
if (preferences.created !== "all") params.set("created", preferences.created);
if (preferences.archived) params.set("archived", "1");
if (preferences.sort !== "created_at") params.set("sort", preferences.sort);
if (preferences.direction === "asc") params.set("direction", "asc");
if (preferences.size !== DEFAULT_LIST_PAGE_SIZE) params.set("size", String(preferences.size));
if (preferences.hide !== "") params.set("hide", preferences.hide);
if (preferences.page > 1) params.set("page", String(preferences.page));
return params;
}
function hasChildRunsListParams(searchParams: URLSearchParams): boolean {
return CHILD_RUNS_LIST_PARAM_KEYS.some((key) => searchParams.has(key));
}
export function loadStoredChildRunsListSearchParams(
storage: Pick<Storage, "getItem"> | null = runsPreferencesStorage(),
): URLSearchParams {
if (storage == null) return new URLSearchParams();
try {
const raw = storage.getItem(CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY);
if (raw == null) return new URLSearchParams();
return childRunsListPreferencesToSearchParams(
normalizeStoredChildRunsListPreferences(JSON.parse(raw)),
);
} catch {
return new URLSearchParams();
}
}
export function resolveChildRunsListSearchParams(
urlSearchParams: URLSearchParams,
): URLSearchParams {
if (hasChildRunsListParams(urlSearchParams)) return urlSearchParams;
const stored = loadStoredChildRunsListSearchParams();
return stored.toString() === "" ? urlSearchParams : stored;
}
export function persistChildRunsListPreferences(
preferences: ChildRunsListPreferences,
storage: Pick<Storage, "setItem"> | null = runsPreferencesStorage(),
) {
if (storage == null) return;
// `page` is URL-only ephemeral view state; strip it before persisting.
const { page: _page, ...storable } = preferences;
try {
storage.setItem(CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY, JSON.stringify(storable));
} catch {
// localStorage persistence is best effort only.
}
}
export function createdCutoffMsFor(filter: CreatedFilter): number | null {
const now = Date.now();
switch (filter) {

View file

@ -5,7 +5,7 @@ type Mutator = (key: KeyOrMatcher) => unknown;
const isRunListKey: KeyMatcher = (key) =>
Array.isArray(key) &&
key[0] === "runs" &&
(key[1] === "all" || key[1] === "page" || key[1] === "children");
(key[1] === "all" || key[1] === "page");
export function runListCacheMatchers(): KeyOrMatcher[] {
return [isRunListKey];

View file

@ -86,7 +86,7 @@ export function useRetryRun(id: string | undefined) {
return useLifecycleMutation(id, "retry", retryRun, (run, mutate) => {
void mutate(queryKeys.runs.detail(run.id), run, { revalidate: false });
if (run.parent_id) {
void mutate(queryKeys.runs.children(run.parent_id));
mutateRunListCaches(mutate);
}
});
}

View file

@ -199,16 +199,6 @@ export function useRunFiles(
);
}
export function useChildRuns(parentId: string | undefined) {
return useSWR<PaginatedRunList | null>(
parentId ? queryKeys.runs.children(parentId) : null,
() =>
apiNullableData(() =>
runsApi.listRuns(undefined, undefined, false, parentId!),
),
);
}
export function useRunCommits(id: string | undefined) {
return useSWR<PaginatedRunCommitList | null>(
id ? queryKeys.runs.commits(id) : null,

View file

@ -52,7 +52,6 @@ export const queryKeys = {
queryKeys.runs.files(id, runFileScopeSelection(scope)),
),
commits: (id: string) => ["runs", "commits", id] as const,
children: (parentId: string) => ["runs", "children", parentId] as const,
stages: (id: string) => ["runs", "stages", id] as const,
graph: (id: string, direction?: RunGraphDirection) =>
["runs", "graph", id, direction ?? null] as const,

View file

@ -1,21 +1,119 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams } from "react-router";
import { ArrowPathIcon } from "@heroicons/react/20/solid";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams, useSearchParams } from "react-router";
import { ArchiveBoxIcon, ArrowPathIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import type { ListRunsSortEnum } from "@qltysh/fabro-api-client";
import { EmptyState, ErrorState, LoadingState } from "../components/state";
import { ColumnPickerButton } from "../components/runs-list/column-picker-button";
import { FilterButton } from "../components/runs-list/filter-button";
import {
childRunsListPreferencesFromSearchParams,
childRunsListPreferencesToSearchParams,
createdCutoffMsFor,
createdFilterOptions,
parseCreatedFilter,
parseDirection,
parsePage,
parsePageSize,
parseSort,
persistChildRunsListPreferences,
resolveChildRunsListSearchParams,
} from "../components/runs-list/preferences";
import type { ChildRunsListPreferences, CreatedFilter } from "../components/runs-list/preferences";
import { RunsListView } from "../components/runs-list/runs-list-view";
import { parseHiddenColumns, serializeHiddenColumns } from "../components/runs-list/toggleable-column";
import type { ToggleableColumn } from "../components/runs-list/toggleable-column";
import { SECONDARY_BUTTON_CLASS } from "../components/ui";
import { toRunWithStatus } from "../data/runs";
import { ApiError } from "../lib/api-client";
import { formatRelativeTime } from "../lib/format";
import { useChildRuns, useRun } from "../lib/queries";
import { RUNS_LIST_GRID_TEMPLATE, RunRow } from "./runs";
import { useRun, useRunsPage } from "../lib/queries";
export const handle = { wide: true };
export default function RunChildren() {
const { id } = useParams();
const runQuery = useRun(id);
const childRunsQuery = useChildRuns(id);
const [urlSearchParams, setSearchParams] = useSearchParams();
const searchParams = useMemo(
() => resolveChildRunsListSearchParams(urlSearchParams),
[urlSearchParams],
);
const query = searchParams.get("search") ?? "";
const createdFilter = parseCreatedFilter(searchParams.get("created"));
const includeArchived = searchParams.get("archived") === "1";
const sort = parseSort(searchParams.get("sort"));
const direction = parseDirection(searchParams.get("direction"));
const page = parsePage(searchParams.get("page"));
const pageSize = parsePageSize(searchParams.get("size"));
const hiddenColumns = useMemo(
() => parseHiddenColumns(searchParams.get("hide")),
[searchParams],
);
const updatePreferences = useCallback(
(updater: (prev: ChildRunsListPreferences) => ChildRunsListPreferences) => {
setSearchParams(
(prevParams) => {
const next = updater(childRunsListPreferencesFromSearchParams(prevParams));
persistChildRunsListPreferences(next);
return childRunsListPreferencesToSearchParams(next);
},
{ replace: true },
);
},
[setSearchParams],
);
const setQuery = (value: string) =>
updatePreferences((prev) => ({ ...prev, search: value }));
const setCreatedFilter = (value: CreatedFilter) =>
updatePreferences((prev) => ({ ...prev, created: value }));
const setIncludeArchived = (value: boolean) =>
updatePreferences((prev) => ({ ...prev, archived: value }));
const setPage = useCallback(
(next: number) => updatePreferences((prev) => ({ ...prev, page: next })),
[updatePreferences],
);
const setPageSize = useCallback(
(next: number) => updatePreferences((prev) => ({ ...prev, size: next, page: 1 })),
[updatePreferences],
);
const setHiddenColumns = useCallback(
(next: Set<ToggleableColumn>) =>
updatePreferences((prev) => ({ ...prev, hide: serializeHiddenColumns(next) ?? "" })),
[updatePreferences],
);
const handleSortClick = useCallback(
(key: ListRunsSortEnum) =>
updatePreferences((prev) =>
prev.sort === key
? { ...prev, direction: prev.direction === "asc" ? "desc" : "asc", page: 1 }
: { ...prev, sort: key, direction: "desc", page: 1 },
),
[updatePreferences],
);
const hydratedFromStorage = useRef(false);
useEffect(() => {
if (hydratedFromStorage.current) return;
hydratedFromStorage.current = true;
if (searchParams === urlSearchParams) return;
setSearchParams(searchParams, { replace: true });
}, [searchParams, urlSearchParams, setSearchParams]);
const childRunsQuery = useRunsPage(
{
parentId: id,
includeArchived,
sort,
direction,
limit: pageSize,
offset: (page - 1) * pageSize,
},
id != null,
);
const lastFetchedAtRef = useRef<number | null>(null);
const [now, setNow] = useState<number>(() => Date.now());
@ -53,16 +151,46 @@ export default function RunChildren() {
);
}
const data = childRunsQuery.data;
const children = data?.data ?? [];
const hasMore = data?.meta.has_more ?? false;
const updatedAt = lastFetchedAtRef.current;
const lowerQuery = query.toLowerCase();
const createdCutoffMs = createdCutoffMsFor(createdFilter);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-fg-3">Runs spawned from this run.</p>
<div className="flex items-center gap-3">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-64">
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-fg-muted" />
<input
type="text"
name="search"
aria-label="Search child runs"
placeholder="Search child runs…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-md border border-line bg-panel/80 py-2 pl-9 pr-3 text-sm text-fg-2 placeholder-fg-muted outline-none transition-colors focus:border-focus focus:ring-0"
/>
</div>
<FilterButton
label="Time"
value={createdFilter}
allValue="all"
options={createdFilterOptions}
onChange={setCreatedFilter}
/>
<button
type="button"
onClick={() => setIncludeArchived(!includeArchived)}
aria-pressed={includeArchived}
title={includeArchived ? "Hide archived runs" : "Show archived runs"}
className={`inline-flex items-center gap-1.5 rounded-md border border-line bg-panel/80 px-3 py-2 text-xs font-medium transition-colors ${includeArchived ? "text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
>
<ArchiveBoxIcon className="size-4" aria-hidden="true" />
<span>Show archived</span>
</button>
<div className="ml-auto flex items-center gap-3">
{updatedAt != null ? (
<span className="font-mono text-xs text-fg-muted">
Updated{" "}
@ -79,48 +207,49 @@ export default function RunChildren() {
: "Refresh child runs"
}
title="Refresh"
className="inline-flex size-7 items-center justify-center rounded-md border border-line bg-panel text-fg-3 transition-colors hover:bg-overlay hover:text-fg disabled:cursor-default disabled:opacity-60 disabled:hover:bg-panel disabled:hover:text-fg-3"
className="inline-flex size-9 items-center justify-center rounded-md border border-line bg-panel/80 text-fg-3 transition-colors hover:bg-panel hover:text-fg disabled:cursor-default disabled:opacity-60 disabled:hover:bg-panel/80 disabled:hover:text-fg-3"
>
<ArrowPathIcon
className={`size-3.5 ${childRunsQuery.isValidating ? "animate-spin [animation-duration:450ms]" : ""}`}
className={`size-4 ${childRunsQuery.isValidating ? "animate-spin [animation-duration:450ms]" : ""}`}
aria-hidden="true"
/>
</button>
<ColumnPickerButton hidden={hiddenColumns} onChange={setHiddenColumns} />
</div>
</div>
{children.length === 0 ? (
<EmptyState
title="No child runs"
description="When you launch another run with this run as its parent, it will appear here."
action={
<a
href="https://docs.fabro.sh/reference/cli#fabro-parent-link"
target="_blank"
rel="noopener noreferrer"
className={SECONDARY_BUTTON_CLASS}
>
Learn about parent links
</a>
}
/>
) : (
<>
<div
className="grid gap-2"
style={{ gridTemplateColumns: RUNS_LIST_GRID_TEMPLATE }}
>
{children.map((child) => (
<RunRow key={child.id} run={toRunWithStatus(child)} />
))}
</div>
{hasMore ? (
<p className="text-xs text-fg-muted">
Showing the first {children.length} child runs more exist.
</p>
) : null}
</>
)}
<RunsListView
data={childRunsQuery.data ?? undefined}
isLoading={childRunsQuery.data == null && childRunsQuery.isLoading}
emptyState={
<EmptyState
title="No child runs"
description="When you launch another run with this run as its parent, it will appear here."
action={
<a
href="https://docs.fabro.sh/reference/cli#fabro-parent-link"
target="_blank"
rel="noopener noreferrer"
className={SECONDARY_BUTTON_CLASS}
>
Learn about parent links
</a>
}
/>
}
sort={sort}
direction={direction}
page={page}
pageSize={pageSize}
hiddenColumns={hiddenColumns}
onSortClick={handleSortClick}
onPageChange={setPage}
onPageSizeChange={setPageSize}
query={lowerQuery}
repoFilter="all"
workflowFilter="all"
createdCutoffMs={createdCutoffMs}
/>
</div>
);
}