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
7 changed files with 662 additions and 246 deletions

View file

@ -527,18 +527,41 @@ restarts the agent after its current turn. The panel reads
the reason and a **retry** button. `swarmctl agent permissions set` sets the
same grant from a shell.
### Creating an agent
The **+ agent** button opens a dialog of five steps, which you go through in
order with **next** and can step back through with **back** or the step tabs:
1. **identity**: the name, the hive, and optionally an
[archetype](archetypes.md) that fills in the settings of the steps after it.
**next** waits for a valid name (1-63 lowercase letters, digits and hyphens)
and a hive.
2. **instructions**: the text the agent's `AGENTS.md` starts with, in a box
of its own.
3. **permissions**: one checkbox per permission, starting from the default set
or the archetype's.
4. **resources and inference**: the optional resource caps and inference
settings.
5. **summary**: every setting the request will carry, and what stays at its
default, then **what this creates**. **create** is on this step only.
The header, the step tabs and the buttons stay in place and only the step's own
fields scroll, so a long step never pushes **next** out of view. After
**create**, the dialog says it queued the job and links to **jobs**, and offers
**create another** and **close**. If the controller refuses the `AGENTS.md` text,
the dialog goes back to step 2 and shows the reason there.
### Settings at creation
The **+ agent** dialog has two collapsed sections, **resource caps** and
**inference**, with the same fields as the detail panel's editors. Leave the
fields empty and the request is the plain name and hive; collapsing a section doesn't
withdraw what you typed in it. Fill them and `POST /api/agents` carries them
Step 4 of the **+ agent** dialog has **resource caps** and **inference**, with
the same fields as the detail panel's editors. Leave the
fields empty and the request is the plain name and hive. Fill them and `POST /api/agents` carries them
as optional `limits` and `inference` objects, the same shapes the two `PUT`
routes take. The controller checks them with the `PUT` rules before it queues
anything, so a bad value gets a refusal with its reason and creates no agent.
The dialog loads the hive list and the permission list when it opens. If either
load fails, the dialog shows the reason in an error panel above the fields, and
load fails, the dialog shows the reason in an error panel above the step's fields, and
the hive picker or the permissions area reads `could not load hives` or
`could not load permissions` in place of `loading…`.
@ -550,14 +573,14 @@ stored settings when you leave the fields empty.
### Initial AGENTS.md at creation
A third collapsed section, **instructions**, holds a plain text box for the
Step 2, **instructions**, holds a plain text box for the
text the new agent's `AGENTS.md` starts with. Left empty, or holding only
whitespace, it sends nothing and the agent starts without the file. Otherwise
the text goes as typed in the optional `agents_md` field of `POST /api/agents`.
The agent writes it on its first start, and only when it has no `AGENTS.md`
yet, so creating an agent again under a name that has one never replaces it.
The controller refuses a text over 64 KiB. Its reason shows under the
section, open or not, and the swarm creates no agent. After a successful
box, and the swarm creates no agent. After a successful
create the box empties. The
[instructions editor](#editing-an-agents-instructions) changes the file later.
The [swarm README](README.md#an-agents-agentsmd) has the wire details.

View file

@ -1,75 +1,127 @@
/* <CreateAgentForm> — the create-agent form, rendered inside a `Dialog`
from `AgentsPage` (see that component's file-top comment for why).
Input/button chrome comes from the shared `ui/` form kit
(`TextField`/`SelectField`/`Button`); this file only owns this
component's own layout + copy. Column,
not row inside the form itself: with a second field (hive) added, a
row layout put fields of different natural widths on one baseline and
looked misaligned — mara: "make it a col". Reuses the same base16
slots (../theme.css) every other component draws from.
/* <CreateAgentForm> — the create-agent dialog, a stepper rendered inside a
`Dialog` from `AgentsPage`. Input/button chrome comes from the shared `ui/`
form kit; this file owns the layout: a header, the step tabs and a footer
that stay put while only the step's own fields scroll.
This layout dropped an explicit page `max-width` after a few rounds:
first added so bare `Panel` (no width opinion of its own) wouldn't
fill `.shell-body`'s full 60em column behind a narrow form (the
original "weird empty space" complaint), then widened for two panels
side by side — but once both were equal-width flex children that cap
itself became "doesn't fill the space" (mara: "the cards are neither
centered nor fill the space tho"). Removing it lets the row fill
`.shell-body`'s own width like most pages — nothing sits off-center
once nothing is narrower than the row.
`.create-agent-form-col`/`.create-agent-info-col` are equal-sized
(`flex: 1 1 0` — zero basis so the 1.5em `gap` splits the row evenly,
not each column keeping its natural content width) per mara's "both
cards should be equal sized". `min-width` is the wrap threshold below
which the row goes single-column instead (info card moving under the
form) — no separate media query needed. Inputs fill their whole
column now too ("inputs should fill full card width") — the kit's
`.ui-form-control`/`.ui-form-field` dropped their old 16em cap for
exactly this (../ui/form-field/FormField.css). */
.create-agent-layout {
`.create-agent-panel` is a column no taller than the dialog (85vh, the
dialog's own cap), and its body hands all that height to the step. */
.create-agent-panel {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: min(90vw, 44em);
max-height: 85vh;
}
.create-agent-panel > .ui-panel-body {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.create-agent-wizard {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
gap: 0.75em;
}
.create-agent-steps {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 1.5em;
gap: 0.35em;
margin: 0;
padding: 0;
list-style: none;
}
.create-agent-form-col,
.create-agent-info-col {
flex: 1 1 0;
min-width: 16em;
.create-agent-step-tab {
padding: 0.3em 0.7em;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg);
color: var(--muted);
font: inherit;
font-size: 0.85em;
cursor: pointer;
}
.create-agent-step-tab:disabled {
cursor: default;
opacity: 0.6;
}
.create-agent-step-current {
border-color: var(--purple);
color: var(--fg);
}
/* The one part that scrolls. A floor keeps the dialog from changing height
as the steps change. */
.create-agent-step {
flex: 1 1 auto;
min-height: 18em;
overflow-y: auto;
}
/* On a short window the floor gives way, so the footer stays on screen. */
@media (max-height: 45em) {
.create-agent-step {
min-height: 8em;
}
}
.create-agent-fields {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.75em;
}
.create-agent-intro {
margin: 0 0 1.5em;
margin: 0;
color: var(--muted);
}
.create-agent-form {
.create-agent-heading {
margin: 0.5em 0 0;
font-size: 1em;
}
.create-agent-footer {
display: flex;
flex: none;
align-items: center;
gap: 0.5em;
padding-top: 0.75em;
border-top: 1px solid var(--border);
}
/* Back on the left, then why next waits, then next (or create) on the right. */
.create-agent-footer > :last-child {
margin-left: auto;
}
.create-agent-hint {
flex: 1 1 auto;
min-width: 0;
margin-left: 0.25em;
color: var(--muted);
font-size: 0.9em;
}
.create-agent-summary {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
gap: 0.35em;
margin: 0;
}
/* Optional settings, collapsed so the plain name-and-hive form stays short. */
.create-agent-section {
align-self: stretch;
.create-agent-summary-row {
display: flex;
flex-wrap: wrap;
gap: 0.25em 1em;
}
.create-agent-section > summary {
cursor: pointer;
.create-agent-summary-row dt {
flex: 0 0 9em;
color: var(--muted);
}
.create-agent-section-body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
margin-top: 0.75em;
}
.create-agent-result {
margin-top: 1em;
.create-agent-summary-row dd {
flex: 1 1 12em;
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.create-agent-result-ok {
color: var(--green);
}
/* The controller's refusal of the AGENTS.md text, under its section. */
/* The controller's refusal of the AGENTS.md text, under its box. */
.create-agent-field-error {
margin: 0;
color: var(--red);

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

View file

@ -0,0 +1,133 @@
// The create-agent dialog's steps: which there are, what lets the operator
// leave one, and what the last one says will be sent. Kept out of the
// component so `npm test` can run it.
export const STEPS = [
"identity",
"instructions",
"permissions",
"resources",
"summary",
] as const;
export type Step = (typeof STEPS)[number];
export const STEP_TITLES: Record<Step, string> = {
identity: "identity",
instructions: "instructions",
permissions: "permissions",
resources: "resources and inference",
summary: "summary",
};
// Client-side mirror of `hive_types::Ident` (1-63 chars of `[a-z0-9-]`), the
// hint the name box shows; the server's own check stays the authority.
//
// The hyphen is escaped: browsers read a `pattern` attribute in Unicode-set
// (`v`) mode, which refuses a bare trailing hyphen in a class.
export const NAME_PATTERN = "[a-z0-9\\-]{1,63}";
export function nameProblem(name: string): string | null {
return new RegExp(`^${NAME_PATTERN}$`).test(name)
? null
: "the name is 1-63 characters: lowercase letters, digits and hyphens";
}
/** The step after `step`, or `step` itself on the last. */
export function nextStep(step: Step): Step {
return STEPS[Math.min(STEPS.indexOf(step) + 1, STEPS.length - 1)];
}
/** The step before `step`, or `step` itself on the first. */
export function previousStep(step: Step): Step {
return STEPS[Math.max(STEPS.indexOf(step) - 1, 0)];
}
/** What the steps need before the one `step` can be left. */
export interface StepInputs {
name: string;
hive: string;
// The permission list has loaded, so a pick is made against the real set.
permissionsReady: boolean;
// `draftProblem` of the inference boxes.
inferenceProblem: string | null;
}
/**
* Why the operator cannot go on from `step`, or null when they can. Every
* step must be gone through, so this gates **next**; the last step has
* nothing after it.
*/
export function stepProblem(step: Step, input: StepInputs): string | null {
switch (step) {
case "identity":
return (
nameProblem(input.name) ?? (input.hive === "" ? "pick a hive" : null)
);
case "permissions":
return input.permissionsReady ? null : "the permission list is loading";
case "resources":
return input.inferenceProblem;
default:
return null;
}
}
/** One line of the summary: a setting and what will be sent for it. */
export interface SummaryRow {
label: string;
value: string;
}
export interface SummaryInput {
name: string;
hive: string;
// The picked archetype's name, or undefined for a custom agent.
archetype: string | undefined;
// `requestPermissions`: undefined when the default set is kept.
permissions: string[] | undefined;
// The filled fields only, as `capsBody` and `draftBody` make them.
caps: object;
inference: object;
agentsMd: string;
}
function settings(values: object): string {
const parts = Object.entries(values).map(
([key, value]) =>
`${key.replace(/_/g, " ")} ${Array.isArray(value) ? value.join(", ") : String(value)}`,
);
return parts.length === 0 ? "none set" : parts.join("; ");
}
/**
* What the summary lists: every setting the request carries, and for each
* one left alone, what then applies.
*/
export function summaryRows(s: SummaryInput): SummaryRow[] {
const words =
s.agentsMd.trim() === "" ? 0 : s.agentsMd.trim().split(/\s+/).length;
return [
{ label: "name", value: s.name },
{ label: "hive", value: s.hive },
{ label: "archetype", value: s.archetype ?? "none: a custom agent" },
{
label: "permissions",
value:
s.permissions === undefined
? "the default set"
: s.permissions.length === 0
? "none"
: s.permissions.join(", "),
},
{ label: "resource caps", value: settings(s.caps) },
{ label: "inference", value: settings(s.inference) },
{
label: "instructions",
value:
words === 0
? "none"
: `AGENTS.md, ${words} ${words === 1 ? "word" : "words"}`,
},
];
}

View file

@ -1,10 +1,7 @@
/* <LinkMatrixAccountForm> — rendered inside a `Dialog` from `AgentsPage`,
one panel wide (no second info column — this form has no "what this
creates" explanation worth a whole sibling card the way
`CreateAgentForm` does, just the one-line note already in the JSX).
Column layout + `.link-matrix-account-result-ok` mirror
`CreateAgentForm.css`'s `.create-agent-form`/`.create-agent-result-ok`
exactly — same shape, kept as its own file rather than sharing that
one panel wide. Column layout + `.link-matrix-account-result-ok`
mirror `CreateAgentForm.css`'s `.create-agent-fields`/`.create-agent-result-ok`
— same shape, kept as its own file rather than sharing that
one since "form" here means a different component's layout, not a
generic kit primitive (see that file's own top comment on why
`ui/form-field` stays the only truly shared piece). */

View file

@ -4,7 +4,7 @@
whichever container the caller gives it — the kit itself has no
opinion on a maximum width; a page that wants one narrower than its
own layout caps it at the layout level (`CreateAgentForm.css`'s
`.create-agent-form-col` is the existing example), same reasoning
`.create-agent-panel` is the existing example), same reasoning
`Panel` has no width opinion of its own either. `min-height` is a
touch-target floor (44px at the default 16px root font — WCAG
2.5.5's minimum), not a visual choice — it's the same on every

View file

@ -0,0 +1,101 @@
// The create-agent dialog's steps: order, what gates next, and the summary.
// Run by `npm test` with Node's type stripping.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
nameProblem,
nextStep,
previousStep,
STEPS,
stepProblem,
summaryRows,
} from "../src/pages/CreateAgentSteps.ts";
const ready = {
name: "ruth",
hive: "h1",
permissionsReady: true,
inferenceProblem: null,
};
test("the steps run in order and stop at both ends", () => {
assert.deepEqual(STEPS, [
"identity",
"instructions",
"permissions",
"resources",
"summary",
]);
assert.equal(nextStep("identity"), "instructions");
assert.equal(nextStep("resources"), "summary");
assert.equal(nextStep("summary"), "summary");
assert.equal(previousStep("summary"), "resources");
assert.equal(previousStep("identity"), "identity");
});
test("a name is 1-63 lowercase letters, digits and hyphens", () => {
assert.equal(nameProblem("ruth-2"), null);
assert.equal(nameProblem("a".repeat(63)), null);
assert.notEqual(nameProblem(""), null);
assert.notEqual(nameProblem("a".repeat(64)), null);
assert.notEqual(nameProblem("Ruth"), null);
assert.notEqual(nameProblem("ru th"), null);
});
test("identity needs a valid name and a hive before next", () => {
assert.equal(stepProblem("identity", ready), null);
assert.notEqual(stepProblem("identity", { ...ready, name: "" }), null);
assert.notEqual(stepProblem("identity", { ...ready, name: "No Way" }), null);
assert.equal(stepProblem("identity", { ...ready, hive: "" }), "pick a hive");
});
test("permissions waits for the list, resources for a valid inference", () => {
assert.notEqual(
stepProblem("permissions", { ...ready, permissionsReady: false }),
null,
);
assert.equal(stepProblem("permissions", ready), null);
assert.equal(
stepProblem("resources", { ...ready, inferenceProblem: "bad model" }),
"bad model",
);
assert.equal(stepProblem("instructions", { ...ready, name: "" }), null);
});
test("the summary lists what is sent and what stays at its default", () => {
const rows = (over) =>
Object.fromEntries(
summaryRows({
name: "ruth",
hive: "h1",
archetype: undefined,
permissions: undefined,
caps: {},
inference: {},
agentsMd: "",
...over,
}).map((r) => [r.label, r.value]),
);
const plain = rows({});
assert.equal(plain.name, "ruth");
assert.equal(plain.archetype, "none: a custom agent");
assert.equal(plain.permissions, "the default set");
assert.equal(plain["resource caps"], "none set");
assert.equal(plain.inference, "none set");
assert.equal(plain.instructions, "none");
const set = rows({
archetype: "reviewer",
permissions: ["meta", "inbox"],
caps: { cpu_quota: "200%", memory_max: "2G" },
inference: { model: "opus", subagent_models: ["haiku", "sonnet"] },
agentsMd: " Be brief.\n",
});
assert.equal(set.archetype, "reviewer");
assert.equal(set.permissions, "meta, inbox");
assert.equal(set["resource caps"], "cpu quota 200%; memory max 2G");
assert.equal(set.inference, "model opus; subagent models haiku, sonnet");
assert.equal(set.instructions, "AGENTS.md, 2 words");
assert.equal(rows({ permissions: [] }).permissions, "none");
assert.equal(rows({ agentsMd: "x" }).instructions, "AGENTS.md, 1 word");
});