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
1 changed files with 662 additions and 246 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue