hyperhive/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx
iris ba873926fa swarm-ui: shared form-field kit (TextField, SelectField, Button)
Closes #3448.

New ui/ primitives: FormField (shared label+control wrapper),
TextField, SelectField, Button — each with a min-height touch target
(2.75em ~= 44px, WCAG 2.5.5) per mara's #3447 ask, and a max-width
instead of a fixed width so the control caps on desktop without
overflowing a narrow/touch viewport.

CreateAgentPage's name field + submit button now come from the kit
instead of page-scoped CSS; ComponentsPage gets a section for each new
primitive with an editable sample.
2026-08-18 20:29:11 +02:00

112 lines
4.6 KiB
TypeScript

// <CreateAgentPage> — minimal agent creation form, POSTs to
// swarm-controller's `POST /api/agents`. That endpoint only queues a
// `CreateIdentity` job and returns its node id — the identity isn't
// guaranteed to exist yet when this page gets a response — so this
// page 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).
//
// Lives at `/create-agent`, not `/agents` — a future agent *roster*
// (listing existing agents) is the natural owner of the bare `/agents`
// path, and this creation form is a distinct action from that list,
// not a variant of it. Flat, not `/agents/new`: `index.html`'s asset
// links are relative (`static/main.js`), which only resolve correctly
// one path segment deep — a real bug, filed separately rather than
// fixed here, but reason enough to avoid a nested route today.
//
// First real form in this package — its name field + submit button now
// come from the shared `ui/` form kit (`TextField`/`Button`) rather than
// page-scoped input/button chrome, so a hive-picker soon landing on this
// same page has something to reuse instead of copying this page's CSS.
import { useState } from 'preact/hooks';
import { Link } from 'wouter-preact';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Panel } from '../ui/panel/Panel.js';
import { TextField } from '../ui/text-field/TextField.js';
import { Button } from '../ui/button/Button.js';
import './CreateAgentPage.css';
interface CreateAgentResponse {
node_id: number;
}
type SubmitState =
| { status: 'idle' }
| { status: 'submitting' }
| { 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 CreateAgentPage() {
const [name, setName] = useState('');
const [result, setResult] = useState<SubmitState>({ status: 'idle' });
async function submit(e: Event) {
e.preventDefault();
setResult({ status: 'submitting' });
try {
const r = await fetch('/api/agents', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name }),
});
if (!r.ok) {
setResult({ status: 'error', problem: await readApiError(r) });
return;
}
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: 'done', nodeId: data.node_id });
setName('');
} catch (err) {
setResult({ status: 'error', problem: { detail: String(err) } });
}
}
return (
<Panel title="create agent">
<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.
</p>
<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}
/>
<Button variant="primary" type="submit" disabled={result.status === 'submitting'}>
{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>
)}
{result.status === 'error' && (
<ApiErrorPanel context="failed to queue" problem={result.problem} />
)}
</Panel>
);
}