From 8f5b3a2fa81c10f74b1a7708503cbc8843feb9ab Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 26 Oct 2026 20:09:17 +0100 Subject: [PATCH] swarm-ui: the jobs and builds cards are folded by default Both cards on the jobs tab start shut and read nothing until opened; a link with ?run= opens the builds card. The fold-and-build-on-first-open part of the hives card is now one LazyDisclosure, which the hives card uses too. Docs follow. --- docs/swarm/ui.md | 7 +++- .../packages/swarm-ui/src/pages/JobsPage.css | 5 +++ .../packages/swarm-ui/src/pages/JobsPage.tsx | 27 +++++++------ .../swarm-ui/src/pages/agents/HivesCard.tsx | 16 ++------ .../swarm-ui/src/pages/builds/BuildsPage.css | 5 +++ .../swarm-ui/src/pages/builds/BuildsPage.tsx | 30 +++++++++----- .../src/ui/disclosure/LazyDisclosure.tsx | 40 +++++++++++++++++++ .../agents => ui/disclosure}/lazy-open.ts | 0 .../packages/swarm-ui/test/lazy-open.test.mjs | 2 +- 9 files changed, 96 insertions(+), 36 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/ui/disclosure/LazyDisclosure.tsx rename frontend/packages/swarm-ui/src/{pages/agents => ui/disclosure}/lazy-open.ts (100%) diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 02c5468b..0523b6c8 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -472,7 +472,10 @@ previous repo's issues under the error. ### Builds -The builds sit in a card under the job graph on the **jobs** tab. The old `/builds` address redirects there and keeps its query. +The builds sit in a card under the job graph on the **jobs** tab. Both cards are +folded when the tab opens, and each reads nothing until you open it; a link to +a build opens the builds card. The old `/builds` address redirects there and +keeps its query. The **builds** card lists the agent config PR heads the swarm builder built, newest first, from `GET /api/builder/runs`. Each card shows the repo and PR, @@ -482,7 +485,7 @@ read it: for a failed build, what `nix` printed; for a built one, the system closure; for an error, why no build came back. The **Details** link on a `swarm-builder` commit status opens the jobs tab as -`/jobs?run=`, with that build's card open and in view. Links made before the +`/jobs?run=`, with the builds card open and that build's card in view. Links made before the builds moved, to `/builds?run=`, redirect to the same place. The builder keeps its newest 100 builds, so a link to an older one shows a note instead. diff --git a/frontend/packages/swarm-ui/src/pages/JobsPage.css b/frontend/packages/swarm-ui/src/pages/JobsPage.css index 09d650de..855771f7 100644 --- a/frontend/packages/swarm-ui/src/pages/JobsPage.css +++ b/frontend/packages/swarm-ui/src/pages/JobsPage.css @@ -14,3 +14,8 @@ flex-direction: column; gap: 1em; } +.ui-jobs-refresh { + display: flex; + justify-content: flex-end; + margin-block-end: 0.5em; +} diff --git a/frontend/packages/swarm-ui/src/pages/JobsPage.tsx b/frontend/packages/swarm-ui/src/pages/JobsPage.tsx index e5f55232..b0c08dd9 100644 --- a/frontend/packages/swarm-ui/src/pages/JobsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/JobsPage.tsx @@ -21,7 +21,7 @@ import { useState } from "preact/hooks"; import { JobqGraph } from "@hive/shared/jobq-graph.js"; import { JobqRollup } from "@hive/shared/jobq-rollup.js"; -import { Panel } from "../ui/panel/Panel.js"; +import { LazyDisclosure } from "../ui/disclosure/LazyDisclosure.js"; import { BuildsCard } from "./builds/BuildsPage.js"; import { RefreshIntervalPicker, @@ -35,7 +35,7 @@ import "./JobsPage.css"; // just solve staleness rather than require an opt-in every visit. const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; -function JobsCard() { +function JobsBody() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); const [refreshToken, setRefreshToken] = useState(0); @@ -51,29 +51,32 @@ function JobsCard() { useRefreshInterval(intervalMs, () => setRefreshToken((t) => t + 1)); return ( - +
- } - > +
-
+ ); } -// The jobs tab holds two cards: the controller's job graph, and the swarm -// builder's recent builds under it. +// The jobs tab holds two folded cards: the controller's job graph, and the swarm +// builder's recent builds under it. Neither reads anything until it is opened. export function JobsPage() { return (
- + + +
); diff --git a/frontend/packages/swarm-ui/src/pages/agents/HivesCard.tsx b/frontend/packages/swarm-ui/src/pages/agents/HivesCard.tsx index 5f4306c5..2b5a2263 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/HivesCard.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/HivesCard.tsx @@ -16,7 +16,7 @@ import { useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js"; -import { Disclosure } from "../../ui/disclosure/Disclosure.js"; +import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { RefreshIntervalPicker, @@ -25,7 +25,6 @@ import { import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { Table, type TableColumn } from "../../ui/table/Table.js"; import { domainCell } from "../hive-domain.js"; -import { builtAfterToggle } from "./lazy-open.js"; type Freshness = "fresh" | "stale" | "never_reported" | "unknown"; @@ -151,20 +150,13 @@ function HiveRoster() { } export function HivesCard() { - const [open, setOpen] = useState(false); - const [built, setBuilt] = useState(false); return ( - { - setOpen(next); - setBuilt((was) => builtAfterToggle(was, next)); - }} > - {built ? : null} - + + ); } diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css index a76fab4f..bb8f7616 100644 --- a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css +++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css @@ -54,3 +54,8 @@ .ui-builds-empty { color: var(--muted); } +.ui-builds-refresh { + display: flex; + justify-content: flex-end; + margin-block-end: 0.5em; +} diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx index abac7d28..50a138c0 100644 --- a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx @@ -10,7 +10,7 @@ import { useSearch } from "wouter-preact"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { Badge } from "@hive/shared/badge.js"; import { Card } from "../../ui/card/Card.js"; -import { Panel } from "../../ui/panel/Panel.js"; +import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js"; import { RefreshIntervalPicker, type RefreshIntervalMs, @@ -93,7 +93,7 @@ function BuildCard({ ); } -export function BuildsCard() { +function BuildsBody() { const wanted = wantedRun(useSearch()); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); @@ -129,17 +129,14 @@ export function BuildsCard() { wanted !== null && runs !== null && !runs.some((r) => r.id === wanted); return ( - +
- } - > +

The agent config PR heads the swarm builder built, newest first: how each ended, and what nix printed when one failed. Select a build to read @@ -170,6 +167,21 @@ export function BuildsCard() { /> ))} - + + ); +} + +// Shut by default, and open from the start when the URL names a build: a +// status link goes straight to that build's card. +export function BuildsCard() { + return ( + + + ); } diff --git a/frontend/packages/swarm-ui/src/ui/disclosure/LazyDisclosure.tsx b/frontend/packages/swarm-ui/src/ui/disclosure/LazyDisclosure.tsx new file mode 100644 index 00000000..c2e0d310 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/disclosure/LazyDisclosure.tsx @@ -0,0 +1,40 @@ +// — a Disclosure that is shut by default and builds its body +// on the first open. For a card that polls or fetches: one nobody opens costs +// nothing, and one that was opened keeps its body, and what it read, after a +// fold. `startOpen` opens it from the first render, for a link that points +// into it. +import { useState } from "preact/hooks"; +import type { ComponentChildren } from "preact"; +import { Disclosure } from "./Disclosure.js"; +import { builtAfterToggle } from "./lazy-open.js"; + +export function LazyDisclosure({ + title, + icon, + hint, + startOpen = false, + children, +}: { + title: string; + icon?: string; + hint?: ComponentChildren; + startOpen?: boolean; + children: ComponentChildren; +}) { + const [open, setOpen] = useState(startOpen); + const [built, setBuilt] = useState(startOpen); + return ( + { + setOpen(next); + setBuilt((was) => builtAfterToggle(was, next)); + }} + > + {built ? children : null} + + ); +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/lazy-open.ts b/frontend/packages/swarm-ui/src/ui/disclosure/lazy-open.ts similarity index 100% rename from frontend/packages/swarm-ui/src/pages/agents/lazy-open.ts rename to frontend/packages/swarm-ui/src/ui/disclosure/lazy-open.ts diff --git a/frontend/packages/swarm-ui/test/lazy-open.test.mjs b/frontend/packages/swarm-ui/test/lazy-open.test.mjs index 8fa8ac31..4f919199 100644 --- a/frontend/packages/swarm-ui/test/lazy-open.test.mjs +++ b/frontend/packages/swarm-ui/test/lazy-open.test.mjs @@ -1,7 +1,7 @@ // A folded card fetches nothing until first opened. Run by `npm test`. import assert from "node:assert/strict"; import { test } from "node:test"; -import { builtAfterToggle } from "../src/pages/agents/lazy-open.ts"; +import { builtAfterToggle } from "../src/ui/disclosure/lazy-open.ts"; test("a card nobody opened is not built", () => { assert.equal(builtAfterToggle(false, false), false);