swarm-ui: start page setup assistant offers to create ruth
This commit is contained in:
parent
89efda5ea2
commit
8bf53f7ed9
7 changed files with 208 additions and 15 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
51
frontend/packages/swarm-ui/src/pages/SetupAssistant.css
Normal file
51
frontend/packages/swarm-ui/src/pages/SetupAssistant.css
Normal 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;
|
||||||
|
}
|
||||||
77
frontend/packages/swarm-ui/src/pages/SetupAssistant.tsx
Normal file
77
frontend/packages/swarm-ui/src/pages/SetupAssistant.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
frontend/packages/swarm-ui/src/shell/ruth-missing.ts
Normal file
22
frontend/packages/swarm-ui/src/shell/ruth-missing.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
frontend/packages/swarm-ui/test/ruth-missing.test.mjs
Normal file
37
frontend/packages/swarm-ui/test/ruth-missing.test.mjs
Normal 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,
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue