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

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