diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css index 789a6403..394ad05c 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css @@ -1,16 +1,19 @@ /* — the create-agent form. Input/button chrome now - comes from the shared `ui/` form kit (`TextField`/`Button`); this - file only owns the page's own layout + copy. Reuses the same base16 - slots (../theme.css) every other component draws from. */ + comes from the shared `ui/` form kit (`TextField`/`SelectField`/ + `Button`); this file only owns the page's own layout + copy. Column, + not row: 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. */ .create-agent-intro { margin: 0 0 1.5em; color: var(--muted); } .create-agent-form { display: flex; - align-items: flex-end; + flex-direction: column; + align-items: flex-start; gap: 0.75em; - flex-wrap: wrap; } .create-agent-result { margin-top: 1em; diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx index 35fc4722..5d6d7634 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx @@ -16,19 +16,35 @@ // 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'; +// `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 +// roster fetched off `GET /api/hives` backs a required select here +// rather than a free-text field — a spawn target has to be one of the +// swarm's actual hives, same reasoning `Ident.parse` gets client-side +// pattern validation for `name`. +// +// Its name field, hive select, and submit button all come from the +// shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than +// page-scoped input/button chrome — see that kit's own comments for why. +import { useEffect, 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 { SelectField, type SelectOption } from '../ui/select-field/SelectField.js'; import { Button } from '../ui/button/Button.js'; import './CreateAgentPage.css'; +// Mirrors swarm-controller's `HiveEntry` — same shape `OverviewPage` +// consumes off `/api/hives/status`, but this page hits the plain +// `/api/hives` roster (no status/freshness needed, just "what hives +// exist to spawn into"). +interface HiveEntry { + name: string; + domain: string; +} + interface CreateAgentResponse { node_id: number; } @@ -56,8 +72,37 @@ const NAME_PATTERN = '[a-z0-9\\-]{1,63}'; export function CreateAgentPage() { const [name, setName] = useState(''); + const [hive, setHive] = useState(''); + // `null` = still loading, `[]` = loaded but empty (a real, if unusual, + // swarm state) — distinct from "not fetched yet" so the placeholder + // option's label can tell the two apart. + const [hives, setHives] = useState(null); + const [hivesError, setHivesError] = useState(null); const [result, setResult] = useState({ status: 'idle' }); + // Loaded once, not re-fetched on submit: the roster changes rarely + // enough (a nix-level swarm config change) that staleness within one + // page visit isn't worth a second round-trip per keystroke/submit. + useEffect(() => { + (async () => { + const r = await fetch('/api/hives'); + if (!r.ok) { + setHivesError(await readApiError(r)); + return; + } + const data = (await r.json()) as HiveEntry[]; + setHives(data); + // Single-hive swarms are the common case — default it rather than + // making the operator pick the only option. Multi-hive swarms get + // no default (the placeholder stays selected), so `required` on + // the select below forces an explicit choice per mara's scope. + if (data.length === 1) setHive(data[0].name); + })().catch((e: unknown) => setHivesError({ detail: String(e) })); + }, []); + + const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({ value: h.name, label: h.name })); + const hivesReady = hives !== null && hives.length > 0; + async function submit(e: Event) { e.preventDefault(); setResult({ status: 'submitting' }); @@ -65,7 +110,7 @@ export function CreateAgentPage() { const r = await fetch('/api/agents', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, hive }), }); if (!r.ok) { setResult({ status: 'error', problem: await readApiError(r) }); @@ -85,6 +130,7 @@ export function CreateAgentPage() { Create a new agent's swarm-level identity. This only queues the job — check{' '} jobs to watch it settle.

+ {hivesError && }
- diff --git a/frontend/packages/swarm-ui/src/ui/select-field/SelectField.tsx b/frontend/packages/swarm-ui/src/ui/select-field/SelectField.tsx index 982b0fbd..d9f66305 100644 --- a/frontend/packages/swarm-ui/src/ui/select-field/SelectField.tsx +++ b/frontend/packages/swarm-ui/src/ui/select-field/SelectField.tsx @@ -18,6 +18,8 @@ export function SelectField({ onChange, options, required, + disabled, + placeholder, }: { id: string; label: string; @@ -25,6 +27,13 @@ export function SelectField({ onChange: (value: string) => void; options: SelectOption[]; required?: boolean; + disabled?: boolean; + // Rendered as a disabled, always-first `value=""` option — for a + // required select with no default (e.g. a multi-hive roster: no + // single right answer to pre-select), so the control shows something + // other than silently landing on whatever option happens to be first. + // Omit when the field always has a real default (`value` never `''`). + placeholder?: string; }) { return ( @@ -33,8 +42,14 @@ export function SelectField({ class="ui-form-control" value={value} required={required} + disabled={disabled} onChange={(e) => onChange((e.target as HTMLSelectElement).value)} > + {placeholder !== undefined && ( + + )} {options.map((o) => (