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:
parent
f0a80c9812
commit
339da0aebe
7 changed files with 662 additions and 246 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
133
frontend/packages/swarm-ui/src/pages/CreateAgentSteps.ts
Normal file
133
frontend/packages/swarm-ui/src/pages/CreateAgentSteps.ts
Normal 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"}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
101
frontend/packages/swarm-ui/test/create-agent-steps.test.mjs
Normal file
101
frontend/packages/swarm-ui/test/create-agent-steps.test.mjs
Normal 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");
|
||||
});
|
||||
Loading…
Reference in a new issue