Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: start page setup assistant offers to create ruth

This commit is contained in:
damocles 2026-10-15 19:18:01 +02:00
commit 8bf53f7ed9
7 changed files with 208 additions and 15 deletions

View file

@ -8,8 +8,8 @@ domain, covers host-level detail for one hive.
## What it shows ## What it shows
| route | what | | route | what |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | the start page: the swarm's name over a digital-rain backdrop; the swarm name in the header links here | | `/` | 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 | | `/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` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents (with their permissions), link forge, matrix and GitHub accounts |
| `/agents/<name>/terminal` | one agent's live terminal | | `/agents/<name>/terminal` | one agent's live terminal |

View file

@ -97,13 +97,16 @@ const NAME_PATTERN = "[a-z0-9\\-]{1,63}";
export function CreateAgentForm({ export function CreateAgentForm({
onClose, 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 /** Rendered as the form panel's own close button — this form always
* mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer * mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer
* floats its own. */ * floats its own. */
onClose?: () => void; onClose?: () => void;
}) { }) {
const [name, setName] = useState(""); const [name, setName] = useState(initialName);
const [hive, setHive] = useState(""); const [hive, setHive] = useState("");
// `null` = still loading, `[]` = loaded but empty (a real, if unusual, // `null` = still loading, `[]` = loaded but empty (a real, if unusual,
// swarm state) — distinct from "not fetched yet" so the placeholder // swarm state) — distinct from "not fetched yet" so the placeholder

View file

@ -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;
}

View file

@ -0,0 +1,77 @@
// <SetupAssistant> - 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 (
<>
<button
type="button"
class="setup-assistant"
onClick={() => setOpen(true)}
>
<span class="setup-assistant-bubble">
want to help me set up an agent? click here
</span>
<svg
class="setup-assistant-clip"
viewBox="0 0 40 80"
aria-hidden="true"
>
<path
d="M27 22v34a9 9 0 0 1-18 0V16a6 6 0 0 1 12 0v36a3 3 0 0 1-6 0V24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
/>
<circle
cx="13"
cy="38"
r="3.2"
fill="var(--bg)"
stroke="currentColor"
/>
<circle
cx="23"
cy="38"
r="3.2"
fill="var(--bg)"
stroke="currentColor"
/>
<circle cx="13.6" cy="38.4" r="1.2" fill="currentColor" />
<circle cx="23.6" cy="38.4" r="1.2" fill="currentColor" />
</svg>
</button>
<Dialog
open={open}
onClose={() => setOpen(false)}
label="create agent"
plain
>
<CreateAgentForm initialName="ruth" onClose={() => setOpen(false)} />
</Dialog>
</>
);
}

View file

@ -1,8 +1,10 @@
// <StartPage> — `/`, the swarm's landing page: the swarm's name over the // <StartPage> — `/`, the swarm's landing page: the swarm's name over the
// digital-rain backdrop, and nothing else. The header's swarm name links // digital-rain backdrop, plus the setup assistant while the swarm has no
// here. The hive roster that used to be `/` is now `/hives`. // `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 { MatrixRain } from "../shell/MatrixRain.js";
import { useSwarmName } from "../shell/useSwarmName.js"; import { useSwarmName } from "../shell/useSwarmName.js";
import { SetupAssistant } from "./SetupAssistant.js";
import "./StartPage.css"; import "./StartPage.css";
// Same fallback as the header and the page title, so an unnamed swarm shows // Same fallback as the header and the page title, so an unnamed swarm shows
@ -15,6 +17,7 @@ export function StartPage() {
<div class="start-page"> <div class="start-page">
<MatrixRain /> <MatrixRain />
<h1 class="start-page-name">{name}</h1> <h1 class="start-page-name">{name}</h1>
<SetupAssistant />
</div> </div>
); );
} }

View file

@ -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<unknown>;
}>;
export async function ruthIsMissing(fetchImpl: Fetch): Promise<boolean> {
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;
}
}

View file

@ -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,
);
});