feat(web): hide Updated/Changes by default, reorder Elapsed after Updated

Updated and Changes are now hidden by default in both the main runs list
and the Children sub-tab — they're still toggleable via the column
picker. The column order shifts so Elapsed lives between Updated and
Changes (i.e. after Created/Updated), keeping the time-related columns
grouped on the right.

Defaults are applied in two places: fresh sessions (no stored prefs)
and existing v1 stored prefs that have no `hide` field. Users who
explicitly cleared all hides keep that choice; stored `hide: ""`
serializes round-trip as `?hide=` (empty value) so the URL distinguishes
"show every column" from "use defaults".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-05-25 13:31:47 -04:00
parent 1724cbadcd
commit febd5e2d2e
No known key found for this signature in database
5 changed files with 60 additions and 22 deletions

View file

@ -1,6 +1,35 @@
import type { ListRunsDirectionEnum, ListRunsSortEnum } from "@qltysh/fabro-api-client";
import { parseHiddenColumns, serializeHiddenColumns } from "./toggleable-column";
import type { ToggleableColumn } from "./toggleable-column";
// Columns hidden by default in both the main runs list and the Children
// sub-tab. Users can still reveal them via the column picker.
export const DEFAULT_HIDDEN_RUN_LIST_COLUMNS: readonly ToggleableColumn[] = [
"updated",
"changes",
];
function defaultHideString(): string {
return serializeHiddenColumns(new Set(DEFAULT_HIDDEN_RUN_LIST_COLUMNS)) ?? "";
}
// Read the `hide` URL param honouring the convention that an absent param
// means "use defaults" while an explicit empty value means "show every
// column". Both forms persist round-trips through the column picker.
export function hiddenColumnsFromSearchParams(
searchParams: URLSearchParams,
): Set<ToggleableColumn> {
const raw = searchParams.get("hide");
if (raw == null) return new Set(DEFAULT_HIDDEN_RUN_LIST_COLUMNS);
return parseHiddenColumns(raw);
}
function readHideField(searchParams: URLSearchParams): string {
const raw = searchParams.get("hide");
if (raw == null) return defaultHideString();
return serializeHiddenColumns(parseHiddenColumns(raw)) ?? "";
}
export type ViewMode = "columns" | "list";
@ -111,7 +140,7 @@ export function defaultRunsWorkspacePreferences(): RunsWorkspacePreferences {
sort: "created_at",
direction: "desc",
size: DEFAULT_LIST_PAGE_SIZE,
hide: "",
hide: defaultHideString(),
page: 1,
};
}
@ -144,7 +173,6 @@ function normalizeStoredRunsWorkspacePreferences(value: unknown): RunsWorkspaceP
return defaultRunsWorkspacePreferences();
}
const hiddenColumns = parseHiddenColumns(stringValue(record.hide));
const size = record.size;
return {
@ -158,11 +186,20 @@ function normalizeStoredRunsWorkspacePreferences(value: unknown): RunsWorkspaceP
sort: parseSort(stringValue(record.sort)),
direction: parseDirection(stringValue(record.direction)),
size: parsePageSize(typeof size === "number" || typeof size === "string" ? String(size) : null),
hide: serializeHiddenColumns(hiddenColumns) ?? "",
hide: normalizeStoredHide(stringValue(record.hide)),
page: 1,
};
}
// Stored records from before the default-hidden-columns change have no `hide`
// field. Treat the absence as "use the new defaults" so existing users pick up
// the new default automatically. An explicit empty string means the user
// chose to show every column, so preserve it.
function normalizeStoredHide(stored: string | null): string {
if (stored == null) return defaultHideString();
return serializeHiddenColumns(parseHiddenColumns(stored)) ?? "";
}
export function runsWorkspacePreferencesFromSearchParams(
searchParams: URLSearchParams,
): RunsWorkspacePreferences {
@ -177,7 +214,7 @@ export function runsWorkspacePreferencesFromSearchParams(
sort: parseSort(searchParams.get("sort")),
direction: parseDirection(searchParams.get("direction")),
size: parsePageSize(searchParams.get("size")),
hide: serializeHiddenColumns(parseHiddenColumns(searchParams.get("hide"))) ?? "",
hide: readHideField(searchParams),
page: parsePage(searchParams.get("page")),
};
}
@ -195,7 +232,7 @@ export function runsWorkspacePreferencesToSearchParams(
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.hide !== defaultHideString()) params.set("hide", preferences.hide);
if (preferences.page > 1) params.set("page", String(preferences.page));
return params;
}
@ -281,7 +318,7 @@ export function defaultChildRunsListPreferences(): ChildRunsListPreferences {
sort: "created_at",
direction: "desc",
size: DEFAULT_LIST_PAGE_SIZE,
hide: "",
hide: defaultHideString(),
page: 1,
};
}
@ -292,7 +329,6 @@ function normalizeStoredChildRunsListPreferences(value: unknown): ChildRunsListP
return defaultChildRunsListPreferences();
}
const hiddenColumns = parseHiddenColumns(stringValue(record.hide));
const size = record.size;
return {
@ -303,7 +339,7 @@ function normalizeStoredChildRunsListPreferences(value: unknown): ChildRunsListP
sort: parseSort(stringValue(record.sort)),
direction: parseDirection(stringValue(record.direction)),
size: parsePageSize(typeof size === "number" || typeof size === "string" ? String(size) : null),
hide: serializeHiddenColumns(hiddenColumns) ?? "",
hide: normalizeStoredHide(stringValue(record.hide)),
page: 1,
};
}
@ -319,7 +355,7 @@ export function childRunsListPreferencesFromSearchParams(
sort: parseSort(searchParams.get("sort")),
direction: parseDirection(searchParams.get("direction")),
size: parsePageSize(searchParams.get("size")),
hide: serializeHiddenColumns(parseHiddenColumns(searchParams.get("hide"))) ?? "",
hide: readHideField(searchParams),
page: parsePage(searchParams.get("page")),
};
}
@ -334,7 +370,7 @@ export function childRunsListPreferencesToSearchParams(
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.hide !== defaultHideString()) params.set("hide", preferences.hide);
if (preferences.page > 1) params.set("page", String(preferences.page));
return params;
}

View file

@ -112,11 +112,6 @@ export function RunTableRow({
<span className={`font-mono text-xs ${statusDisplay.text}`}>{run.statusLabel}</span>
</span>
</td>
{show("elapsed") && (
<td className="whitespace-nowrap px-3 py-2.5 font-mono text-xs text-fg-muted">
{run.elapsed}
</td>
)}
{show("repo") && (
<td className="whitespace-nowrap px-3 py-2.5 font-mono text-xs font-medium text-teal-500">
{run.repo}
@ -164,6 +159,11 @@ export function RunTableRow({
{run.lastEventAt != null ? formatRelativeTime(run.lastEventAt) : ""}
</td>
)}
{show("elapsed") && (
<td className="whitespace-nowrap px-3 py-2.5 text-right font-mono text-xs text-fg-muted">
{run.elapsed}
</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

@ -134,9 +134,6 @@ export function RunsListView({
/>
</th>
<SortHeader label="Status" sortKey="status" activeSort={sort} direction={direction} onClick={onSortClick} />
{show("elapsed") && (
<SortHeader label="Elapsed" sortKey="elapsed" activeSort={sort} direction={direction} onClick={onSortClick} />
)}
{show("repo") && (
<SortHeader label="Repo" sortKey="repo" activeSort={sort} direction={direction} onClick={onSortClick} />
)}
@ -150,6 +147,9 @@ export function RunsListView({
{show("updated") && (
<SortHeader label="Updated" sortKey="updated_at" activeSort={sort} direction={direction} align="right" onClick={onSortClick} />
)}
{show("elapsed") && (
<SortHeader label="Elapsed" sortKey="elapsed" 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

@ -11,6 +11,7 @@ import {
childRunsListPreferencesToSearchParams,
createdCutoffMsFor,
createdFilterOptions,
hiddenColumnsFromSearchParams,
parseCreatedFilter,
parseDirection,
parsePage,
@ -21,7 +22,7 @@ import {
} 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 { serializeHiddenColumns } from "../components/runs-list/toggleable-column";
import type { ToggleableColumn } from "../components/runs-list/toggleable-column";
import { SECONDARY_BUTTON_CLASS } from "../components/ui";
import { ApiError } from "../lib/api-client";
@ -48,7 +49,7 @@ export default function RunChildren() {
const page = parsePage(searchParams.get("page"));
const pageSize = parsePageSize(searchParams.get("size"));
const hiddenColumns = useMemo(
() => parseHiddenColumns(searchParams.get("hide")),
() => hiddenColumnsFromSearchParams(searchParams),
[searchParams],
);

View file

@ -34,6 +34,7 @@ import { FilterButton } from "../components/runs-list/filter-button";
import {
createdCutoffMsFor,
createdFilterOptions,
hiddenColumnsFromSearchParams,
parseCreatedFilter,
parseDirection,
parsePage,
@ -51,7 +52,7 @@ import type {
ViewMode,
} from "../components/runs-list/preferences";
import { RunsListView } from "../components/runs-list/runs-list-view";
import { parseHiddenColumns, serializeHiddenColumns } from "../components/runs-list/toggleable-column";
import { serializeHiddenColumns } from "../components/runs-list/toggleable-column";
import type { ToggleableColumn } from "../components/runs-list/toggleable-column";
import { mutateRunListCaches } from "../lib/board-cache";
import { shouldRefreshBoardForEvent, useBoardEvents } from "../lib/board-events";
@ -746,7 +747,7 @@ export default function Runs() {
const page = parsePage(searchParams.get("page"));
const pageSize = parsePageSize(searchParams.get("size"));
const hiddenColumns = useMemo(
() => parseHiddenColumns(searchParams.get("hide")),
() => hiddenColumnsFromSearchParams(searchParams),
[searchParams],
);