From 8bf53f7ed9f68bf226dfc8b0b26b5c8015e9da27 Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 15 Oct 2026 19:18:01 +0200 Subject: [PATCH] swarm-ui: start page setup assistant offers to create ruth --- docs/swarm/ui.md | 24 +++--- .../swarm-ui/src/pages/CreateAgentForm.tsx | 5 +- .../swarm-ui/src/pages/SetupAssistant.css | 51 ++++++++++++ .../swarm-ui/src/pages/SetupAssistant.tsx | 77 +++++++++++++++++++ .../packages/swarm-ui/src/pages/StartPage.tsx | 7 +- .../swarm-ui/src/shell/ruth-missing.ts | 22 ++++++ .../swarm-ui/test/ruth-missing.test.mjs | 37 +++++++++ 7 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/SetupAssistant.css create mode 100644 frontend/packages/swarm-ui/src/pages/SetupAssistant.tsx create mode 100644 frontend/packages/swarm-ui/src/shell/ruth-missing.ts create mode 100644 frontend/packages/swarm-ui/test/ruth-missing.test.mjs diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 0d88aeff..86f12487 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -7,18 +7,18 @@ domain, covers host-level detail for one hive. ## What it shows -| route | what | -| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/` | the start page: the swarm's name over a digital-rain backdrop; the swarm name in the header links here | -| `/hives` | the hive directory, each hive with its last reported status | -| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents (with their permissions), link forge, matrix and GitHub accounts | -| `/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 (firing, pending, no data, error), read through the controller with a Viewer service-account token | -| `/jobs` | the controller's job graph — where agent creation and credential mints show progress | -| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | -| `/issues` | a cross-repo issue report | -| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | +| route | what | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here | +| `/hives` | the hive directory, each hive with its last reported status | +| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents (with their permissions), link forge, matrix and GitHub accounts | +| `/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 (firing, pending, no data, error), read through the controller with a Viewer service-account token | +| `/jobs` | the controller's job graph — where agent creation and credential mints show progress | +| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | +| `/issues` | a cross-repo issue report | +| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). An agent created here or with `swarmctl agent create` starts `paused`; set it diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx index c001988e..95eb5453 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx @@ -97,13 +97,16 @@ const NAME_PATTERN = "[a-z0-9\\-]{1,63}"; export function CreateAgentForm({ onClose, + initialName = "", }: { + /** The name field's starting value, for a caller that suggests one. */ + initialName?: string; /** Rendered as the form panel's own close button — this form always * mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer * floats its own. */ onClose?: () => void; }) { - const [name, setName] = useState(""); + const [name, setName] = useState(initialName); const [hive, setHive] = useState(""); // `null` = still loading, `[]` = loaded but empty (a real, if unusual, // swarm state) — distinct from "not fetched yet" so the placeholder diff --git a/frontend/packages/swarm-ui/src/pages/SetupAssistant.css b/frontend/packages/swarm-ui/src/pages/SetupAssistant.css new file mode 100644 index 00000000..52445b4a --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/SetupAssistant.css @@ -0,0 +1,51 @@ +/* Bottom-right of the start page, over the rain: a speech bubble above the + * paperclip. The whole thing is one button. */ +.setup-assistant { + position: absolute; + right: 2em; + bottom: 2em; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.4em; + padding: 0; + border: 0; + background: none; + color: var(--purple); + font: inherit; + cursor: pointer; +} +.setup-assistant-bubble { + position: relative; + max-width: 14em; + padding: 0.6em 0.9em; + border: 1px solid var(--purple); + border-radius: 0.8em; + background: var(--bg-elev); + color: var(--fg); + text-align: left; +} +/* The bubble's tail, pointing down at the clip. */ +.setup-assistant-bubble::after { + content: ""; + position: absolute; + right: 1.2em; + bottom: -0.45em; + width: 0.8em; + height: 0.8em; + border-right: 1px solid var(--purple); + border-bottom: 1px solid var(--purple); + background: var(--bg-elev); + transform: rotate(45deg); +} +.setup-assistant-clip { + width: 3.5em; + margin-right: 0.8em; + filter: drop-shadow( + 0 0 0.4em color-mix(in srgb, var(--purple) 55%, transparent) + ); +} +.setup-assistant:hover .setup-assistant-bubble, +.setup-assistant:focus-visible .setup-assistant-bubble { + border-width: 2px; +} diff --git a/frontend/packages/swarm-ui/src/pages/SetupAssistant.tsx b/frontend/packages/swarm-ui/src/pages/SetupAssistant.tsx new file mode 100644 index 00000000..7d6cfbcd --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/SetupAssistant.tsx @@ -0,0 +1,77 @@ +// - the start page's first-time helper: a paperclip with a +// speech bubble offering to set up an agent. It renders only while the swarm +// has no `ruth` (see `ruthIsMissing`), and a click opens the create-agent +// form with her name filled in; the form already selects the default +// permissions and the only hive. No dismiss: once ruth exists it goes away. +import { useEffect, useState } from "preact/hooks"; +import { Dialog } from "../ui/dialog/Dialog.js"; +import { ruthIsMissing } from "../shell/ruth-missing.js"; +import { CreateAgentForm } from "./CreateAgentForm.js"; +import "./SetupAssistant.css"; + +export function SetupAssistant() { + const [missing, setMissing] = useState(false); + const [open, setOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + void ruthIsMissing((url) => fetch(url)).then((m) => { + if (!cancelled) setMissing(m); + }); + return () => { + cancelled = true; + }; + }, []); + + if (!missing) return null; + return ( + <> + + setOpen(false)} + label="create agent" + plain + > + setOpen(false)} /> + + + ); +} diff --git a/frontend/packages/swarm-ui/src/pages/StartPage.tsx b/frontend/packages/swarm-ui/src/pages/StartPage.tsx index 1f0162bc..dc23a8ab 100644 --- a/frontend/packages/swarm-ui/src/pages/StartPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/StartPage.tsx @@ -1,8 +1,10 @@ // — `/`, the swarm's landing page: the swarm's name over the -// digital-rain backdrop, and nothing else. The header's swarm name links -// here. The hive roster that used to be `/` is now `/hives`. +// digital-rain backdrop, plus the setup assistant while the swarm has no +// `ruth`. The header's swarm name links here. The hive roster that used to be +// `/` is now `/hives`. import { MatrixRain } from "../shell/MatrixRain.js"; import { useSwarmName } from "../shell/useSwarmName.js"; +import { SetupAssistant } from "./SetupAssistant.js"; import "./StartPage.css"; // Same fallback as the header and the page title, so an unnamed swarm shows @@ -15,6 +17,7 @@ export function StartPage() {

{name}

+
); } diff --git a/frontend/packages/swarm-ui/src/shell/ruth-missing.ts b/frontend/packages/swarm-ui/src/shell/ruth-missing.ts new file mode 100644 index 00000000..d1655e74 --- /dev/null +++ b/frontend/packages/swarm-ui/src/shell/ruth-missing.ts @@ -0,0 +1,22 @@ +// Whether the swarm has no agent named `ruth`, from `GET /api/agents/status`. +// The start page's setup assistant shows only then. It is a nudge, so +// anything short of a clear "ruth is not in the roster" answers `false`: a +// failed request or an unreadable list must not tell the operator to set +// up an agent that may already exist. + +type Fetch = (url: string) => Promise<{ + ok: boolean; + json: () => Promise; +}>; + +export async function ruthIsMissing(fetchImpl: Fetch): Promise { + try { + const r = await fetchImpl("/api/agents/status"); + if (!r.ok) return false; + const rows = await r.json(); + if (!Array.isArray(rows)) return false; + return !rows.some((row) => (row as { name?: unknown }).name === "ruth"); + } catch { + return false; + } +} diff --git a/frontend/packages/swarm-ui/test/ruth-missing.test.mjs b/frontend/packages/swarm-ui/test/ruth-missing.test.mjs new file mode 100644 index 00000000..17f28f87 --- /dev/null +++ b/frontend/packages/swarm-ui/test/ruth-missing.test.mjs @@ -0,0 +1,37 @@ +// The setup assistant's "is ruth missing" check. Run by `npm test`. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { ruthIsMissing } from "../src/shell/ruth-missing.ts"; + +const answering = + (body, ok = true) => + async () => ({ + ok, + json: async () => body, + }); + +test("a roster without ruth means ruth is missing", async () => { + assert.equal( + await ruthIsMissing(answering([{ name: "atlas" }, { name: "argus" }])), + true, + ); + assert.equal(await ruthIsMissing(answering([])), true); +}); + +test("a roster with ruth means she is not missing", async () => { + assert.equal( + await ruthIsMissing(answering([{ name: "atlas" }, { name: "ruth" }])), + false, + ); +}); + +test("anything but a clear answer is not missing", async () => { + assert.equal(await ruthIsMissing(answering([], false)), false); + assert.equal(await ruthIsMissing(answering({ not: "a list" })), false); + assert.equal( + await ruthIsMissing(async () => { + throw new Error("offline"); + }), + false, + ); +});