diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md
index 990bd90b..1306888f 100644
--- a/docs/swarm/ui.md
+++ b/docs/swarm/ui.md
@@ -221,7 +221,8 @@ commit, whether the run was a try-out or a requested review, how long ago it
started, how long it took, how many classifications ran, and the outcome as a
badge. Select a card to open it: it shows the same result table as the
[try-out](#review-try-out), the overall verdict, the reason when the outcome
-has one, and a link to the posted review when there is one.
+has one, and a link to the posted review when there is one. While the first
+answer loads, the section shows three grey placeholder cards.
An outcome can be more than approve or request changes:
@@ -589,7 +590,8 @@ config PR). Each card shows the repo and PR, or for a deploy's build the agent
and `internal/meta`, 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.
+closure; for an error, why no build came back. While the first answer loads, the
+card shows three grey placeholder cards.
The **Details** link on a `swarm-builder` commit status opens the jobs tab as
`/jobs?run=`, with the builds card open and that build's card in view. Links made before the
diff --git a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css
index d8b07af4..40060f64 100644
--- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css
+++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.css
@@ -8,6 +8,5 @@
margin-top: 1em;
}
.claude-login-ok {
- margin-top: 1em;
- color: var(--green);
+ margin-block: 1em;
}
diff --git a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx
index 135bad83..3ce0167a 100644
--- a/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx
+++ b/frontend/packages/swarm-ui/src/agent/claude-login/ClaudeLoginDialog.tsx
@@ -11,6 +11,8 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
+import { ExternalLink } from "../../ui/external-link/ExternalLink.js";
+import { Hint } from "../../ui/hint/Hint.js";
import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import {
@@ -98,9 +100,7 @@ export function ClaudeLoginDialog({
<>
Open{" "}
-
- the claude login page
-
+ the claude login page
, sign in with this agent's account, and paste the code it shows.
);
diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.css b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.css
index 01ae1d16..5e6ed150 100644
--- a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.css
+++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.css
@@ -38,9 +38,3 @@
border: 1px solid var(--red);
border-radius: 4px;
}
-.ui-agent-instructions-actions {
- display: flex;
- align-items: center;
- flex-wrap: wrap;
- gap: 0.75em;
-}
diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx
index 4ca6cd80..e8577d14 100644
--- a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx
+++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx
@@ -16,6 +16,7 @@ import { useEffect, useRef, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
+import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Panel } from "../../ui/panel/Panel.js";
@@ -235,9 +236,9 @@ function InstructionsForm({
context="loading AGENTS.md failed"
problem={load.problem}
/>
-
+
)}
{result.status === "error" && (
gh and git push to
github.com then authenticate with it. Use a dedicated bot account and a
minimally scoped token, created at{" "}
-
+
github.com/settings/tokens
-
+
; GitHub notifications also need the notifications scope.
+ )}
+ >
+ ) : (
+ nothing more was recorded for this build.
+ )}
+
);
}
@@ -133,14 +133,14 @@ function BuildsBody() {
return (
<>
-
+
-
+
What the swarm builder built, newest first: agent config PR heads, and
the agents it builds from internal/meta before a deploy. How each ended,
@@ -153,15 +153,22 @@ function BuildsBody() {
/>
) : null}
{missing ? (
-
+
the build in this link is no longer listed: the builder keeps only its
newest builds.
-
@@ -159,11 +160,11 @@ export function MessagesPage() {
)}
-
+
Shows what the agents sent each other. A message an agent drained with
an in-turn recv is missing. The history is at most 100
recent messages per agent.
-
+
);
diff --git a/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx b/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx
index 8bb2dd11..8be87289 100644
--- a/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx
+++ b/frontend/packages/swarm-ui/src/pages/notifications/Deliveries.tsx
@@ -1,6 +1,7 @@
// — the notification router's recent deliveries, newest first.
// The router keeps the newest 500 rows; this shows what
// `GET /api/notify/deliveries` returns.
+import { EmptyState } from "../../ui/empty-state/EmptyState.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { Table, type TableColumn } from "../../ui/table/Table.js";
import { issueRef, outcomeText, type Delivery } from "./RouterLive.js";
@@ -48,7 +49,7 @@ const COLUMNS: TableColumn[] = [
];
export function Deliveries({ rows }: { rows: Delivery[] }) {
- if (rows.length === 0) return
no deliveries yet
;
+ if (rows.length === 0) return no deliveries yet;
const placed = rows.map((d, place) => ({ ...d, place }));
return (
<>
diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx
index 50ebdc7e..5807565b 100644
--- a/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx
+++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx
@@ -21,6 +21,7 @@ import {
type Edit,
type Limits,
} from "./FlowDraft.js";
+import { Hint } from "../../ui/hint/Hint.js";
import { BoxFace, hitsText } from "./FlowBox.js";
import { layoutFlow, neighbour, nodeAt } from "./FlowLayout.js";
import { describeNode, ordinal, type Flow } from "./FlowTypes.js";
@@ -282,7 +283,9 @@ export function FlowCanvas({
return (
- add
+
+ add
+
{palette.map((p) => (
{shown ? (
diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx
index 46b18448..cb993318 100644
--- a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx
+++ b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx
@@ -2,9 +2,11 @@
// it: move it under another node, change when it runs among its siblings,
// remove it. Every change goes out as an `Edit` of the flow; the editor shows
// the reason when one is refused. New nodes come from the canvas palette.
+import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { Card } from "../../ui/card/Card.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
+import { Hint } from "../../ui/hint/Hint.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import { TextField } from "../../ui/text-field/TextField.js";
@@ -204,10 +206,10 @@ export function NodeInspector({
) : null}
{order.count > 1 ? (
-
-
+
+
runs {ordinal(order.index + 1)} of {order.count} below its parent
-
+
onEdit(moveAmongSiblings(flow, node.id, -1))}
@@ -220,10 +222,10 @@ export function NodeInspector({
>
run later
-
+
{error ? (
— the row of buttons at the end of a form, a dialog or a
+// section: aligned on the centre line, wrapping when it is narrow, with one
+// gap. `end` pushes the buttons to the right edge.
+import type { ComponentChildren } from "preact";
+import "./ActionRow.css";
+
+export function ActionRow({
+ align = "start",
+ class: extraClass,
+ children,
+}: {
+ align?: "start" | "end";
+ class?: string;
+ children: ComponentChildren;
+}) {
+ const classes = [
+ "ui-action-row",
+ align === "end" && "ui-action-row-end",
+ extraClass,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ return
{children}
;
+}
diff --git a/frontend/packages/swarm-ui/src/ui/button/Button.css b/frontend/packages/swarm-ui/src/ui/button/Button.css
index 1c5e42a6..6558607f 100644
--- a/frontend/packages/swarm-ui/src/ui/button/Button.css
+++ b/frontend/packages/swarm-ui/src/ui/button/Button.css
@@ -20,6 +20,15 @@
color: var(--fg);
border: 1px solid var(--border);
}
+/* `plain`: text only, no box, for a control that reads as a link in a row of
+ text (it keeps the touch-target height). */
+.ui-button-plain {
+ background: none;
+ color: inherit;
+ border: 0;
+ padding: 0;
+ text-align: left;
+}
.ui-button:disabled {
opacity: 0.6;
cursor: default;
diff --git a/frontend/packages/swarm-ui/src/ui/button/Button.tsx b/frontend/packages/swarm-ui/src/ui/button/Button.tsx
index cdf77e4b..7311907f 100644
--- a/frontend/packages/swarm-ui/src/ui/button/Button.tsx
+++ b/frontend/packages/swarm-ui/src/ui/button/Button.tsx
@@ -7,7 +7,7 @@
import type { ComponentChildren } from "preact";
import "./Button.css";
-export type ButtonVariant = "primary" | "default";
+export type ButtonVariant = "primary" | "default" | "plain";
export function Button({
variant = "default",
diff --git a/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.css b/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.css
new file mode 100644
index 00000000..2c0d4ad7
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.css
@@ -0,0 +1,8 @@
+.ui-empty-state {
+ margin: 0;
+ color: var(--muted);
+}
+.ui-empty-state-centered {
+ padding: 1.25em 0.75em;
+ text-align: center;
+}
diff --git a/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.tsx b/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.tsx
new file mode 100644
index 00000000..e178b895
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/empty-state/EmptyState.tsx
@@ -0,0 +1,19 @@
+// — what a list or a section says when it holds nothing: "No runs
+// yet.", "none linked". Muted, with room around it. `Table`'s `emptyMessage`
+// and `FilterableView` use the same look through this component.
+import type { ComponentChildren } from "preact";
+import "./EmptyState.css";
+
+export function EmptyState({
+ centered = false,
+ children,
+}: {
+ centered?: boolean;
+ children: ComponentChildren;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.css b/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.css
new file mode 100644
index 00000000..8208f2a8
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.css
@@ -0,0 +1,43 @@
+/* The head is the control: it reads as a row of text, not a boxed button. */
+.ui-expandable-head {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5em 0.75em;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.ui-expandable-head:focus-visible {
+ outline: 2px solid var(--purple);
+ outline-offset: 2px;
+}
+/* The placeholder head is not a control. */
+.ui-expandable-head-static {
+ cursor: default;
+}
+.ui-expandable-title {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 0.5em;
+}
+.ui-expandable-meta {
+ margin: 0;
+ color: var(--muted);
+}
+.ui-expandable-meta-row {
+ flex-basis: 100%;
+}
+.ui-expandable-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5em;
+ margin-top: 0.6em;
+}
+.ui-expandable-body p {
+ margin: 0;
+}
diff --git a/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.tsx b/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.tsx
new file mode 100644
index 00000000..9f5c5482
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/expandable-card/ExpandableCard.tsx
@@ -0,0 +1,112 @@
+// — a card in a list whose head is a button that opens its
+// body in place: a build, a classifier run. The head holds a title, a badge
+// and a line of meta (when it started, how long it took); the body is shown
+// only while the card is open. It is controlled (`open`, `onToggle`), so a
+// page can keep its open cards across a refresh and open one for the user.
+//
+// Not `ui/disclosure`: that is a `` for one section of a page, and a
+// list of these needs the page to own which are open. The head is a real
+// `` with `aria-expanded` and `aria-controls`.
+//
+// `ExpandableCardSkeleton` is its twin for a list that is loading: the same
+// card and the same head layout with a placeholder in each slot.
+import type { ComponentChildren } from "preact";
+import { Skeleton } from "../skeleton/Skeleton.js";
+import { Card } from "../card/Card.js";
+import "./ExpandableCard.css";
+
+function Head({
+ title,
+ badge,
+ meta,
+ metaOnOwnRow,
+}: {
+ title: ComponentChildren;
+ badge: ComponentChildren;
+ meta: ComponentChildren;
+ metaOnOwnRow: boolean;
+}) {
+ return (
+ <>
+ {title}
+ {badge}
+
+ {meta}
+
+ >
+ );
+}
+
+export function ExpandableCard({
+ id,
+ open,
+ onToggle,
+ title,
+ badge,
+ meta,
+ metaOnOwnRow = false,
+ class: extraClass,
+ children,
+}: {
+ /** The id of the card's element, for a link that scrolls to it. */
+ id: string;
+ open: boolean;
+ onToggle: () => void;
+ title: ComponentChildren;
+ badge: ComponentChildren;
+ meta: ComponentChildren;
+ /** Put the meta line on a row of its own under the title and badge. */
+ metaOnOwnRow?: boolean;
+ class?: string;
+ children: ComponentChildren;
+}) {
+ const bodyId = `${id}-body`;
+ return (
+
+
}
+ badge={}
+ meta={}
+ metaOnOwnRow={metaOnOwnRow}
+ />
+
+
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/ui/external-link/ExternalLink.tsx b/frontend/packages/swarm-ui/src/ui/external-link/ExternalLink.tsx
new file mode 100644
index 00000000..988205e6
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/external-link/ExternalLink.tsx
@@ -0,0 +1,28 @@
+// — a link that opens another site in a new tab. It always
+// sets `rel="noopener noreferrer"`, so the opened page cannot reach this one
+// and is not told where the visitor came from.
+import type { ComponentChildren } from "preact";
+
+export function ExternalLink({
+ href,
+ title,
+ class: extraClass,
+ children,
+}: {
+ href: string;
+ title?: string;
+ class?: string;
+ children: ComponentChildren;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/ui/hint/Hint.css b/frontend/packages/swarm-ui/src/ui/hint/Hint.css
new file mode 100644
index 00000000..19da0b86
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/hint/Hint.css
@@ -0,0 +1,17 @@
+/* : small supporting text. A `p` loses its default margin, so the page
+ that places it owns the spacing around it. */
+.ui-hint {
+ margin: 0;
+}
+.ui-hint-muted {
+ color: var(--muted);
+}
+.ui-hint-warn {
+ color: var(--yellow);
+}
+.ui-hint-error {
+ color: var(--red);
+}
+.ui-hint-ok {
+ color: var(--green);
+}
diff --git a/frontend/packages/swarm-ui/src/ui/hint/Hint.tsx b/frontend/packages/swarm-ui/src/ui/hint/Hint.tsx
new file mode 100644
index 00000000..48025cd3
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/hint/Hint.tsx
@@ -0,0 +1,39 @@
+// — a line of small supporting text: helper text under a field, a
+// count, a status line, an inline problem or a success note. One component
+// for what each form and card used to restyle under its own class name, so
+// "muted", "warning", "error" and "ok" mean the same colour everywhere.
+//
+// `error` and `warn` are read out when they appear (`role="alert"` for an
+// error); the others are plain text unless the caller passes a `role`, such as
+// `status` for a line that changes after a save. `class` is for a page's
+// layout around the hint (a margin, a width), not for its colour.
+import type { ComponentChildren } from "preact";
+import "./Hint.css";
+
+export type HintTone = "muted" | "warn" | "error" | "ok";
+
+export function Hint({
+ tone = "muted",
+ as: Tag = "p",
+ role,
+ class: extraClass,
+ children,
+}: {
+ tone?: HintTone;
+ as?: "p" | "span" | "div";
+ role?: "status" | "alert";
+ class?: string;
+ children: ComponentChildren;
+}) {
+ const classes = ["ui-hint", `ui-hint-${tone}`, extraClass]
+ .filter(Boolean)
+ .join(" ");
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.css b/frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.css
similarity index 52%
rename from frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.css
rename to frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.css
index bf3bfebb..71218ac8 100644
--- a/frontend/packages/swarm-ui/src/pages/schedules/SchedulesPage.css
+++ b/frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.css
@@ -1,6 +1,4 @@
-/* The bar at the top of the schedules card: the add button, then the refresh
- picker, to the right. */
-.ui-schedules-bar {
+.ui-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -8,3 +6,6 @@
gap: 0.5em 0.75em;
margin-block-end: 0.5em;
}
+.ui-toolbar-start {
+ justify-content: flex-start;
+}
diff --git a/frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.tsx b/frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.tsx
new file mode 100644
index 00000000..4685b5d5
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/toolbar/Toolbar.tsx
@@ -0,0 +1,24 @@
+// — the bar above a list or a card: filters, a count, the refresh
+// picker. Items sit on one line and wrap when narrow, aligned to the end by
+// default (where the refresh picker sits on every page that has one).
+import type { ComponentChildren } from "preact";
+import "./Toolbar.css";
+
+export function Toolbar({
+ justify = "end",
+ class: extraClass,
+ children,
+}: {
+ justify?: "start" | "end";
+ class?: string;
+ children: ComponentChildren;
+}) {
+ const classes = [
+ "ui-toolbar",
+ justify === "start" && "ui-toolbar-start",
+ extraClass,
+ ]
+ .filter(Boolean)
+ .join(" ");
+ return