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) => (