Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: make agent creation a five-step dialog with a fixed header and footer

The create dialog was one tall form that scrolled as a whole, so its header
and button moved out of view. It is now steps you go through in order:
identity, instructions (AGENTS.md in a box of its own), permissions,
resources and inference, then a summary of what will be sent and what the
agent creation queues, where create is. The header, step tabs and footer stay
put and only the step's own fields scroll. A refused AGENTS.md text sends the
dialog back to its step. The request is unchanged.
This commit is contained in:
iris 2026-10-25 01:40:20 +02:00
commit 339da0aebe

View file

@ -4,16 +4,13 @@
// guaranteed to exist yet when this gets a response — so this shows
// the queued confirmation and links to the Jobs page to watch it
// settle, rather than duplicating JobqGraph's per-node polling here.
// Scope matches the originating issue exactly: name field only, no
// forge/deploy options (those aren't wired server-side yet either).
//
// Rendered inside a `Dialog` from `AgentsPage`'s own "+ agent" button,
// not a standalone route — the roster it feeds is the natural home for
// the action that populates it, and a distinct top-level nav item next
// to it was one click of indirection for no benefit. Kept as its own
// component (not inlined into AgentsPage) since the two-panel layout
// below is unchanged from its former life as a page: same content, same
// review history, just a different mount point.
// Rendered inside a `Dialog` from `AgentsPage`'s own "+ agent" button, not a
// standalone route: the roster it feeds is the natural home for the action
// that populates it. It is a stepper (see `CreateAgentSteps`): identity,
// instructions, permissions, resources and inference, then a summary that
// says what will be sent and holds **create**. Every step is gone through, in
// order; the header, step tabs and footer stay put while the step scrolls.
//
// `hive` field added because agent creation had no way to record which
// hive an agent runs on. `POST /api/agents` now requires it, so the
@ -62,6 +59,16 @@ import {
type ArchetypeList,
} from "./CreateAgentArchetype.js";
import { agentsMdBody, agentsMdRefusal } from "./CreateAgentAgentsMd.js";
import {
NAME_PATTERN,
nextStep,
previousStep,
STEP_TITLES,
STEPS,
stepProblem,
summaryRows,
type Step,
} from "./CreateAgentSteps.js";
import "./CreateAgentForm.css";
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
@ -89,21 +96,6 @@ type SubmitState =
| { status: "done"; nodeId: number }
| { status: "error"; problem: ProblemDetails };
// Client-side mirror of `hive_types::Ident` (1-63 chars of `[a-z0-9-]`) —
// a `pattern` hint only, not a substitute for the server's own
// validation; a name this rejects would fail server-side anyway, so
// catching it before a round-trip is a pure UX win, not a new gate.
//
// Hyphen escaped (`\-`) rather than left trailing in the class: modern
// browsers validate the `pattern` attribute's regex in Unicode-set (`v`)
// mode, which is far stricter about hyphen placement than classic mode —
// `[a-z0-9-]` throws "Invalid character class" under `v` mode even though
// it's valid classic-mode regex (a trailing `-` is unambiguous there).
// Reproduced directly: `new RegExp('[a-z0-9-]', 'v')` throws,
// `new RegExp('[a-z0-9\\-]', 'v')` doesn't — confirmed via mara's console
// exception report on this page.
const NAME_PATTERN = "[a-z0-9\\-]{1,63}";
export function CreateAgentForm({
onClose,
initialName = "",
@ -118,6 +110,7 @@ export function CreateAgentForm({
* floats its own. */
onClose?: () => void;
}) {
const [step, setStep] = useState<Step>("identity");
const [name, setName] = useState(initialName);
const [hive, setHive] = useState("");
// `null` = still loading, `[]` = loaded but empty (a real, if unusual,
@ -220,8 +213,7 @@ export function CreateAgentForm({
const refusal =
result.status === "error" ? agentsMdRefusal(result.problem) : null;
async function submit(e: Event) {
e.preventDefault();
async function submit() {
setResult({ status: "submitting" });
try {
// A section left empty is left out, so the request is the plain
@ -244,7 +236,10 @@ export function CreateAgentForm({
}),
});
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
const problem = await readApiError(r);
setResult({ status: "error", problem });
// The text's own refusal shows beside its box, so go back to it.
if (agentsMdRefusal(problem) !== null) setStep("instructions");
return;
}
const data = (await r.json()) as CreateAgentResponse;
@ -261,16 +256,52 @@ export function CreateAgentForm({
}
}
const problem = stepProblem(step, {
name,
hive,
permissionsReady: catalogue !== null,
inferenceProblem,
});
const done = result.status === "done";
const at = STEPS.indexOf(step);
// Enter goes on to the next step, and creates only from the summary.
function onSubmit(e: Event) {
e.preventDefault();
if (done) return;
if (step === "summary") void submit();
else if (problem === null) setStep(nextStep(step));
}
function createAnother() {
setResult({ status: "idle" });
setStep("identity");
}
return (
<div class="create-agent-layout">
<div class="create-agent-form-col">
<Panel title="create agent" icon="🤖" onClose={onClose}>
<p class="create-agent-intro">
Create a new agent's swarm-level identity. This only queues the job
— check <Link href="/jobs">jobs</Link> to watch it settle. Resource
caps, inference and instructions are optional; leave them empty to
set nothing. A name set up before keeps the settings stored for it.
</p>
<Panel
title="create agent"
icon="🤖"
class="create-agent-panel"
onClose={onClose}
>
<form class="create-agent-wizard" onSubmit={onSubmit}>
<ol class="create-agent-steps" aria-label="steps">
{STEPS.map((s, i) => (
<li key={s}>
<button
type="button"
class={`create-agent-step-tab${s === step ? " create-agent-step-current" : ""}`}
aria-current={s === step ? "step" : undefined}
disabled={i > at || done}
onClick={() => setStep(s)}
>
{i + 1}. {STEP_TITLES[s]}
</button>
</li>
))}
</ol>
<div class="create-agent-step">
{hivesError && (
<ApiErrorPanel
context="failed to load the hive list"
@ -289,153 +320,232 @@ export function CreateAgentForm({
problem={archetypesError}
/>
)}
<form class="create-agent-form" onSubmit={submit}>
<TextField
id="agent-name"
label="agent name"
value={name}
pattern={NAME_PATTERN}
title="1-63 chars: lowercase letters, digits, hyphens"
required
onInput={setName}
/>
<SelectField
id="agent-hive"
label="hive"
value={hive}
onChange={setHive}
options={hiveOptions}
required
disabled={!hivesReady}
placeholder={
!hives
? hivesError
? "could not load hives"
: "loading…"
: hives.length === 0
? "no hives configured"
: "select a hive"
}
/>
{archetypes && archetypes.archetypes.length > 0 && (
<>
<SelectField
id="agent-archetype"
label="archetype"
value={archetype}
onChange={pickArchetype}
disabled={!catalogue}
options={[
{ value: "", label: "none" },
...archetypes.archetypes.map((a) => ({
value: archetypeKey(a),
label: a.name,
})),
]}
/>
{picked && (
<p class="create-agent-archetype-description">
{picked.description} An agent created from an archetype has
no config repo: its hive builds it from the archetype's
module.
</p>
)}
</>
)}
<FormField label="permissions" labelId="agent-permissions-label">
<div role="group" aria-labelledby="agent-permissions-label">
{catalogue ? (
<MultiselectFilterOptions
options={catalogue.all}
filter={{ values: selected, negate: false }}
onToggle={(v) => setSelected((s) => togglePermission(s, v))}
{done && (
<div class="create-agent-result create-agent-result-ok">
<p>
Queued as job node #{result.nodeId} —{" "}
<Link href="/jobs">watch it in jobs</Link>.
</p>
</div>
)}
{!done && step === "identity" && (
<div class="create-agent-fields">
<p class="create-agent-intro">
A new agent's swarm-level identity: its name, the hive it runs
on, and optionally an archetype that fills in the settings of
the later steps. A name set up before keeps the settings stored
for it.
</p>
<TextField
id="agent-name"
label="agent name"
value={name}
pattern={NAME_PATTERN}
title="1-63 chars: lowercase letters, digits, hyphens"
required
onInput={setName}
/>
<SelectField
id="agent-hive"
label="hive"
value={hive}
onChange={setHive}
options={hiveOptions}
required
disabled={!hivesReady}
placeholder={
!hives
? hivesError
? "could not load hives"
: "loading…"
: hives.length === 0
? "no hives configured"
: "select a hive"
}
/>
{archetypes && archetypes.archetypes.length > 0 && (
<>
<SelectField
id="agent-archetype"
label="archetype"
value={archetype}
onChange={pickArchetype}
disabled={!catalogue}
options={[
{ value: "", label: "none" },
...archetypes.archetypes.map((a) => ({
value: archetypeKey(a),
label: a.name,
})),
]}
/>
) : (
<span>
{catalogueError ? "could not load permissions" : "loading…"}
</span>
)}
</div>
</FormField>
<details class="create-agent-section">
<summary>resource caps</summary>
<div class="create-agent-section-body">
<LimitsFields
idPrefix="create-limits"
cpu={cpu}
memory={memory}
onCpu={setCpu}
onMemory={setMemory}
/>
</div>
</details>
<details class="create-agent-section">
<summary>inference</summary>
<div class="create-agent-section-body">
<InferenceFields
idPrefix="create-inference"
draft={inference}
onChange={setInference}
/>
</div>
</details>
<details class="create-agent-section">
<summary>instructions</summary>
<div class="create-agent-section-body">
<TextAreaField
id="agent-agents-md"
label="AGENTS.md"
value={agentsMd}
rows={8}
placeholder="the text the agent's AGENTS.md starts with; leave empty for none"
onInput={setAgentsMd}
/>
</div>
</details>
{refusal !== null && (
<p class="create-agent-field-error">{refusal}</p>
)}
<Button
variant="primary"
type="submit"
disabled={
result.status === "submitting" ||
!hivesReady ||
!catalogue ||
inferenceProblem !== null
}
>
{result.status === "submitting" ? "creating…" : "create"}
</Button>
</form>
{result.status === "done" && (
<p class="create-agent-result create-agent-result-ok">
queued as job node #{result.nodeId} —{" "}
<Link href="/jobs">watch it in jobs</Link>
</p>
{picked && (
<p class="create-agent-archetype-description">
{picked.description} An agent created from an archetype
has no config repo: its hive builds it from the
archetype's module.
</p>
)}
</>
)}
</div>
)}
{result.status === "error" && refusal === null && (
<ApiErrorPanel context="failed to queue" problem={result.problem} />
{!done && step === "instructions" && (
<div class="create-agent-fields">
<p class="create-agent-intro">
The text the agent's AGENTS.md starts with. Optional: leave it
empty and the agent starts without the file.
</p>
<TextAreaField
id="agent-agents-md"
label="AGENTS.md"
value={agentsMd}
rows={14}
placeholder="what the agent should know and do"
onInput={setAgentsMd}
/>
{refusal !== null && (
<p class="create-agent-field-error">{refusal}</p>
)}
</div>
)}
</Panel>
</div>
<div class="create-agent-info-col">
{/* Describes the intended end state (a running agent), not today's
literal behaviour (deploying the container onto the hive isn't
wired up server-side yet — see this file's top comment) —
mara's explicit call on this copy: read as finished, not as a
running commentary on partial implementation. */}
<Panel title="what this creates" icon="🪪">
<p>
Submitting this queues everything a new agent needs: a swarm-level
identity, a config repo on the forge with the operator added as a
collaborator, and a container running the agent on the hive you pick
above. An agent created from an archetype gets no config repo: its
container runs the archetype's module. Watch it all settle on{" "}
<Link href="/jobs">jobs</Link>.
</p>
</Panel>
</div>
</div>
{!done && step === "permissions" && (
<div class="create-agent-fields">
<p class="create-agent-intro">
What the swarm lets the agent do. Leaving the default set ticked
keeps the agent on the default if that changes.
</p>
<FormField label="permissions" labelId="agent-permissions-label">
<div role="group" aria-labelledby="agent-permissions-label">
{catalogue ? (
<MultiselectFilterOptions
options={catalogue.all}
filter={{ values: selected, negate: false }}
onToggle={(v) =>
setSelected((s) => togglePermission(s, v))
}
/>
) : (
<span>
{catalogueError
? "could not load permissions"
: "loading…"}
</span>
)}
</div>
</FormField>
</div>
)}
{!done && step === "resources" && (
<div class="create-agent-fields">
<p class="create-agent-intro">
Optional: leave a box empty to set nothing, and the hive's or
the agent's own value applies.
</p>
<h3 class="create-agent-heading">resource caps</h3>
<LimitsFields
idPrefix="create-limits"
cpu={cpu}
memory={memory}
onCpu={setCpu}
onMemory={setMemory}
/>
<h3 class="create-agent-heading">inference</h3>
<InferenceFields
idPrefix="create-inference"
draft={inference}
onChange={setInference}
/>
</div>
)}
{!done && step === "summary" && (
<div class="create-agent-fields">
<dl class="create-agent-summary">
{summaryRows({
name,
hive,
archetype: picked?.name,
permissions: catalogue
? requestPermissions(selected, catalogue.default)
: undefined,
caps: capsBody(cpu, memory),
inference: draftBody(inference),
agentsMd,
}).map((row) => (
<div class="create-agent-summary-row" key={row.label}>
<dt>{row.label}</dt>
<dd>{row.value}</dd>
</div>
))}
</dl>
{/* Describes the intended end state (a running agent), not
today's literal behaviour: mara's call on this copy was to
read as finished, not as a running commentary on partial
implementation. */}
<h3 class="create-agent-heading">what this creates</h3>
<p>
Creating queues everything a new agent needs: a swarm-level
identity, a config repo on the forge with the operator added as
a collaborator, and a container running the agent on the hive
above. An agent created from an archetype gets no config repo:
its container runs the archetype's module. Watch it all settle
on <Link href="/jobs">jobs</Link>.
</p>
{result.status === "error" && refusal === null && (
<ApiErrorPanel
context="failed to queue"
problem={result.problem}
/>
)}
</div>
)}
</div>
<div class="create-agent-footer">
{done ? (
<>
<Button onClick={createAnother}>create another</Button>
{onClose && (
<Button variant="primary" onClick={onClose}>
close
</Button>
)}
</>
) : (
<>
<Button
disabled={at === 0 || result.status === "submitting"}
onClick={() => setStep(previousStep(step))}
>
back
</Button>
{problem !== null && (step !== "identity" || name !== "") && (
<span class="create-agent-hint" role="status">
{problem}
</span>
)}
{step === "summary" ? (
<Button
variant="primary"
type="submit"
disabled={
result.status === "submitting" || !hivesReady || !catalogue
}
>
{result.status === "submitting" ? "creating…" : "create"}
</Button>
) : (
<Button
variant="primary"
type="submit"
disabled={problem !== null}
>
next
</Button>
)}
</>
)}
</div>
</form>
</Panel>
);
}