From 4dfcbc0e03da0550d4efb855ffc0c7f362f738ac Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 25 May 2026 12:48:18 -0400 Subject: [PATCH] feat(web): upgrade Children sub-tab to use the runs list view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../app/components/runs-list/preferences.ts | 133 +++++++++++ apps/fabro-web/app/lib/board-cache.ts | 2 +- apps/fabro-web/app/lib/mutations.ts | 2 +- apps/fabro-web/app/lib/queries.ts | 10 - apps/fabro-web/app/lib/query-keys.ts | 1 - apps/fabro-web/app/routes/run-children.tsx | 225 ++++++++++++++---- 6 files changed, 312 insertions(+), 61 deletions(-) diff --git a/apps/fabro-web/app/components/runs-list/preferences.ts b/apps/fabro-web/app/components/runs-list/preferences.ts index 3de2945ea..b544209a2 100644 --- a/apps/fabro-web/app/components/runs-list/preferences.ts +++ b/apps/fabro-web/app/components/runs-list/preferences.ts @@ -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 | 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 | 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) { diff --git a/apps/fabro-web/app/lib/board-cache.ts b/apps/fabro-web/app/lib/board-cache.ts index 1ef359dc2..b143a3266 100644 --- a/apps/fabro-web/app/lib/board-cache.ts +++ b/apps/fabro-web/app/lib/board-cache.ts @@ -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]; diff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts index 0fb4db240..9b15c06fc 100644 --- a/apps/fabro-web/app/lib/mutations.ts +++ b/apps/fabro-web/app/lib/mutations.ts @@ -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); } }); } diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index 2cf03d7d2..beab0f3ba 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -199,16 +199,6 @@ export function useRunFiles( ); } -export function useChildRuns(parentId: string | undefined) { - return useSWR( - parentId ? queryKeys.runs.children(parentId) : null, - () => - apiNullableData(() => - runsApi.listRuns(undefined, undefined, false, parentId!), - ), - ); -} - export function useRunCommits(id: string | undefined) { return useSWR( id ? queryKeys.runs.commits(id) : null, diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index b5f068190..31b2d69c1 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -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, diff --git a/apps/fabro-web/app/routes/run-children.tsx b/apps/fabro-web/app/routes/run-children.tsx index 5c4be7b4e..ca6b83fb5 100644 --- a/apps/fabro-web/app/routes/run-children.tsx +++ b/apps/fabro-web/app/routes/run-children.tsx @@ -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) => + 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(null); const [now, setNow] = useState(() => 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 ( -
-
-

Runs spawned from this run.

-
+
+
+
+ + 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" + /> +
+ + + + + +
{updatedAt != null ? ( 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" >
- {children.length === 0 ? ( - - Learn about parent links - - } - /> - ) : ( - <> -
- {children.map((child) => ( - - ))} -
- {hasMore ? ( -

- Showing the first {children.length} child runs — more exist. -

- ) : null} - - )} + + Learn about parent links + + } + /> + } + sort={sort} + direction={direction} + page={page} + pageSize={pageSize} + hiddenColumns={hiddenColumns} + onSortClick={handleSortClick} + onPageChange={setPage} + onPageSizeChange={setPageSize} + query={lowerQuery} + repoFilter="all" + workflowFilter="all" + createdCutoffMs={createdCutoffMs} + />
); }