From a2f000bab4af32ea578784d8800265fa50bd37ec Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 26 Oct 2026 20:02:57 +0100 Subject: [PATCH 1/3] swarm-ui: builds are a card under the job graph on the jobs tab The builds tab is gone from the nav; its card sits below the jobs card on /jobs. The swarm-builder commit status now links to /jobs?run=, and the old /builds address redirects there with its query so links already on PRs still open the build. Docs and the link test follow. --- docs/swarm/builder.md | 8 ++++---- docs/swarm/ui.md | 12 +++++++----- frontend/packages/swarm-ui/src/App.tsx | 13 ++++++++++--- frontend/packages/swarm-ui/src/pages/JobsPage.css | 7 +++++++ frontend/packages/swarm-ui/src/pages/JobsPage.tsx | 14 +++++++++++++- .../swarm-ui/src/pages/builds/BuildsPage.tsx | 10 ++++++---- .../swarm-ui/src/pages/builds/builds-types.ts | 5 +++++ frontend/packages/swarm-ui/src/shell/Shell.tsx | 1 - .../packages/swarm-ui/test/builds-types.test.mjs | 11 ++++++++++- swarm-controller/src/builder.rs | 4 ++-- 10 files changed, 64 insertions(+), 21 deletions(-) diff --git a/docs/swarm/builder.md b/docs/swarm/builder.md index 7677469e2..94705cadf 100644 --- a/docs/swarm/builder.md +++ b/docs/swarm/builder.md @@ -86,10 +86,10 @@ newest 100. Both are behind the swarm UI vhost's `admins` gate, and answer 503 when the controller has no swarm queue or can't open the bucket, 500 when it can't read it. -The final status links to `https:///builds?run=`, the swarm -UI's builds page, so selecting **Details** on a red `swarm-builder` check opens -that build's card, with `nix`'s tail, in the browser. The raw record stays at -`GET /api/builder/runs/`. A status has no link when the record wasn't +The final status links to `https:///jobs?run=`, the swarm +UI's jobs tab, where the builds are a card under the job graph. Selecting +**Details** on a red `swarm-builder` check opens that build's card, with `nix`'s tail, in the browser. The raw record +stays at `GET /api/builder/runs/`. A status has no link when the record wasn't stored (the controller logs the failed write) or when the controller doesn't serve the swarm vhost (`SWARM_CONTROLLER_PUBLIC_URL` unset). The `pending` status has no link. diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 4368290a7..02c5468b1 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -27,13 +27,12 @@ shut. A dropdown inside a dialog opens above the dialog and the dialog doesn't c | `/agents//terminal` | one agent's live terminal | | `/agents//subagents//terminal` | one of that agent's subagents' live terminal | | `/alerts` | the alerts the swarm's Grafana holds now: the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token | -| `/jobs` | the controller's job graph — where agent creation and credential mints show progress | +| `/jobs` | the controller's job graph, where agent creation and credential mints show progress, and under it the swarm builder's recent builds of agent config PRs, with what nix printed when one failed | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient; a folded **broadcast** card above the feed sends one message to more than one agent at once | | `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows | | `/issues` | a cross-repo issue report | | `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | -| `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed | Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). Pages that refresh on a timer share one polling rule: the page sends no new @@ -473,15 +472,18 @@ previous repo's issues under the error. ### Builds -The **builds** page lists the agent config PR heads the swarm builder built, +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** 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, the first seven characters of the commit, how the build ended (**built**, **failed** or **error**), when it started and how long it took. Select a card to 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 this page as -`/builds?run=`, with that build's card open and in view. The builder keeps +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 +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. ### Broadcasting a message diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index e43b12a30..8c6626b03 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -1,12 +1,12 @@ // Root shell component — routing only. Each route's content is its own // page component under `./pages/`; this file just maps paths to them. -import { Route, Router, Switch } from "wouter-preact"; +import { Redirect, Route, Router, Switch, useSearch } from "wouter-preact"; +import { jobsHref } from "./pages/builds/builds-types.js"; import { Shell } from "./shell/Shell.js"; import { useGuardedLocation } from "./shell/useGuardedLocation.js"; import { AgentsPage } from "./pages/agents/AgentsPage.js"; import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js"; import { AlertsPage } from "./pages/AlertsPage.js"; -import { BuildsPage } from "./pages/builds/BuildsPage.js"; import { MessagesPage } from "./pages/messages/MessagesPage.js"; import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js"; import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js"; @@ -19,6 +19,13 @@ import { SchedulesPage } from "./pages/schedules/SchedulesPage.js"; import { StartPage } from "./pages/StartPage.js"; import { Panel } from "./ui/panel/Panel.js"; +// The builds page moved onto the jobs tab. Links made before that, such as the +// Details link on a swarm-builder commit status, land here and go on to the +// same build's card, query kept. +function BuildsMoved() { + return ; +} + function NotFound() { return ( @@ -42,7 +49,7 @@ export function App() { /> - + diff --git a/frontend/packages/swarm-ui/src/pages/JobsPage.css b/frontend/packages/swarm-ui/src/pages/JobsPage.css index 579a88246..09d650de3 100644 --- a/frontend/packages/swarm-ui/src/pages/JobsPage.css +++ b/frontend/packages/swarm-ui/src/pages/JobsPage.css @@ -7,3 +7,10 @@ per bundle call). */ @import "@hive/shared/jobq-graph.css"; @import "@hive/shared/jobq-rollup.css"; + +/* The jobs tab: the job graph's card, then the builds card, one gap apart. */ +.ui-jobs-page { + display: flex; + flex-direction: column; + gap: 1em; +} diff --git a/frontend/packages/swarm-ui/src/pages/JobsPage.tsx b/frontend/packages/swarm-ui/src/pages/JobsPage.tsx index 99028179b..e5f552329 100644 --- a/frontend/packages/swarm-ui/src/pages/JobsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/JobsPage.tsx @@ -22,6 +22,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 { BuildsCard } from "./builds/BuildsPage.js"; import { RefreshIntervalPicker, useRefreshInterval, @@ -34,7 +35,7 @@ import "./JobsPage.css"; // just solve staleness rather than require an opt-in every visit. const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; -export function JobsPage() { +function JobsCard() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); const [refreshToken, setRefreshToken] = useState(0); @@ -66,3 +67,14 @@ export function JobsPage() { ); } + +// The jobs tab holds two cards: the controller's job graph, and the swarm +// builder's recent builds under it. +export function JobsPage() { + return ( +
+ + +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx index 49263b6af..abac7d286 100644 --- a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx @@ -1,8 +1,10 @@ -// — the swarm builder's recent builds, newest first: which agent +// — the swarm builder's recent builds, newest first: which agent // config PR heads it built, how each ended, and what `nix` printed when one // failed. One card per build; selecting a card opens it. Reads -// `GET /api/builder/runs`. A `swarm-builder` commit status links here as -// `/builds?run=`, which opens that build's card and scrolls to it. +// `GET /api/builder/runs`. A `swarm-builder` commit status links to the jobs +// page as `/jobs?run=`, which opens that build's card and scrolls to it. +// The card sits under the job graph on that page; the old `/builds` address +// redirects there with its query. import { useEffect, useRef, useState } from "preact/hooks"; import { useSearch } from "wouter-preact"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; @@ -91,7 +93,7 @@ function BuildCard({ ); } -export function BuildsPage() { +export function BuildsCard() { const wanted = wantedRun(useSearch()); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); diff --git a/frontend/packages/swarm-ui/src/pages/builds/builds-types.ts b/frontend/packages/swarm-ui/src/pages/builds/builds-types.ts index 322ba6a50..6e0f8a799 100644 --- a/frontend/packages/swarm-ui/src/pages/builds/builds-types.ts +++ b/frontend/packages/swarm-ui/src/pages/builds/builds-types.ts @@ -37,6 +37,11 @@ export const OUTCOME_TONE: Record = { error: "warning", }; +/** Where an old `/builds` link goes: the jobs page, keeping its query. */ +export function jobsHref(search: string): string { + return search === "" ? "/jobs" : `/jobs?${search}`; +} + /** The run a `?run=` query names, or `null` when it names none. */ export function wantedRun(search: string): string | null { const id = new URLSearchParams(search).get("run"); diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 459641849..fc742835f 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -58,7 +58,6 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ { href: "/notifications", label: "notifications", accent: "var(--yellow)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/review", label: "review", accent: "var(--amber)" }, - { href: "/builds", label: "builds", accent: "var(--green)" }, { href: "/components", label: "components", accent: "var(--blue)" }, ]; diff --git a/frontend/packages/swarm-ui/test/builds-types.test.mjs b/frontend/packages/swarm-ui/test/builds-types.test.mjs index 2cba6d471..d4210fa28 100644 --- a/frontend/packages/swarm-ui/test/builds-types.test.mjs +++ b/frontend/packages/swarm-ui/test/builds-types.test.mjs @@ -3,7 +3,11 @@ // stripping, so the `.ts` module is imported as it is. import assert from "node:assert/strict"; import { test } from "node:test"; -import { bodyText, wantedRun } from "../src/pages/builds/builds-types.ts"; +import { + bodyText, + jobsHref, + wantedRun, +} from "../src/pages/builds/builds-types.ts"; const base = { id: "atlas-abc-1", @@ -49,3 +53,8 @@ test("a run with no text for its outcome shows no body", () => { assert.equal(bodyText({ ...base, outcome: "failed" }), null); assert.equal(bodyText({ ...base, outcome: "built", error_tail: "x" }), null); }); + +test("an old builds link goes to the jobs page and keeps its query", () => { + assert.equal(jobsHref("run=atlas-abc-1"), "/jobs?run=atlas-abc-1"); + assert.equal(jobsHref(""), "/jobs"); +}); diff --git a/swarm-controller/src/builder.rs b/swarm-controller/src/builder.rs index 468ba2ec3..45480538f 100644 --- a/swarm-controller/src/builder.rs +++ b/swarm-controller/src/builder.rs @@ -210,7 +210,7 @@ async fn record( if !builds.record(&build).await { return None; } - link_base.map(|base| format!("{}/builds?run={}", base.trim_end_matches('/'), build.id)) + link_base.map(|base| format!("{}/jobs?run={}", base.trim_end_matches('/'), build.id)) } /// A failed node carrying `error`, and the status write's own failure when @@ -349,7 +349,7 @@ mod tests { assert_eq!((&build.out_path, &build.detail), (&None, &None)); assert!(build.id.starts_with("atlas-abc-"), "{}", build.id); assert!(build.finished_unix >= build.started_unix); - let link = format!("https://swarm.example/builds?run={}", build.id); + let link = format!("https://swarm.example/jobs?run={}", build.id); assert_eq!( written, [ From 8f5b3a2fa81c10f74b1a7708503cbc8843feb9ab Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 26 Oct 2026 20:09:17 +0100 Subject: [PATCH 2/3] 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 02c5468b1..0523b6c85 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 09d650de3..855771f71 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 e5f552329..b0c08dd9e 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 5f4306c53..2b5a2263d 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 a76fab4f9..bb8f76164 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 abac7d286..50a138c0e 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 000000000..c2e0d310f --- /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 8fa8ac319..4f9191999 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); From d269c06d58960e2c8562584dd1f47423c0b5aee3 Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 26 Oct 2026 20:09:29 +0100 Subject: [PATCH 3/3] docs: say the jobs and builds cards start shut, in the active voice --- docs/swarm/ui.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 0523b6c85..5b8b52229 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -472,8 +472,8 @@ previous repo's issues under the error. ### Builds -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 +The builds sit in a card under the job graph on the **jobs** tab. Both cards start +shut, 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.