Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
iris
f1ab7cd43d fixup: Dialog overflows a phone-width viewport (mara)
No sitewide box-sizing: border-box reset exists, so .ui-dialog's
padding + border were adding on top of width: 90vw instead of being
carved out of it -- 401px rendered against a 390px viewport, real
horizontal overflow. Caught via a real 390x844 screenshot per mara's
"phone size?" ask on the PR, not assumed.
2026-08-24 00:44:16 +02:00
iris
4fd6480c9c fixup: update stale CreateAgentPage cross-references (argus review)
Three doc comments elsewhere in the tree still named the old
CreateAgentPage identifier/page framing after the rename in this
branch -- TextField.tsx, Panel.tsx, FormField.css (x3). None of these
files are touched by the rest of the diff, which is exactly how the
staleness happened.
2026-08-24 00:34:34 +02:00
iris
13d7c73c12 swarm-ui: move agent creation into the agents page as a dialog
Adds a generic ui/dialog/Dialog primitive (native <dialog>, no
third-party modal lib and no shadow-DOM custom element per the esbuild
gap on those) and wires a "+ agent" button into AgentsPage that opens
the existing create-agent form inside it, content unchanged from its
former life as a standalone /create-agent route/nav item.

Removes the "new agent" top-level nav entry and the /create-agent
route entirely -- creation now only reachable from the roster that
gets populated by it. CreateAgentPage.tsx/css renamed to
CreateAgentForm.tsx/css to match its new role as a mounted component
rather than a page.

Screenshot-verified the dialog open/closed states against a mock
server.
2026-08-24 00:28:34 +02:00
10 changed files with 176 additions and 28 deletions

View file

@ -4,7 +4,6 @@ import { Route, Switch } from 'wouter-preact';
import { Shell } from './shell/Shell.js';
import { AgentsPage } from './pages/AgentsPage.js';
import { ComponentsPage } from './pages/ComponentsPage.js';
import { CreateAgentPage } from './pages/CreateAgentPage.js';
import { JobsPage } from './pages/JobsPage.js';
import { HivesPage } from './pages/HivesPage.js';
import { Panel } from './ui/panel/Panel.js';
@ -23,7 +22,6 @@ export function App() {
<Switch>
<Route path="/" component={HivesPage} />
<Route path="/agents" component={AgentsPage} />
<Route path="/create-agent" component={CreateAgentPage} />
<Route path="/jobs" component={JobsPage} />
<Route path="/components" component={ComponentsPage} />
<Route component={NotFound} />

View file

@ -11,9 +11,18 @@
// client-side into one row per agent rather than N per-agent config-PR
// calls — exactly why the bulk endpoint exists instead of looping the
// single-agent one.
//
// Owns the "+ agent" trigger too: creation used to be its own
// `/create-agent` route + nav item, but the roster this populates is
// the natural home for the action that populates it — a separate top-
// level nav entry was one click of indirection for no benefit. The form
// itself (`CreateAgentForm`) is unchanged from its page days, just
// mounted inside a `Dialog` instead of a route.
import { useState } from 'preact/hooks';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.js';
import { Button } from '../ui/button/Button.js';
import { Dialog } from '../ui/dialog/Dialog.js';
import { Panel } from '../ui/panel/Panel.js';
import {
RefreshIntervalPicker,
@ -22,6 +31,7 @@ import {
} from '../ui/refresh-interval/RefreshInterval.js';
import { StatusChip } from '../ui/status-chip/StatusChip.js';
import { Table, type TableColumn } from '../ui/table/Table.js';
import { CreateAgentForm } from './CreateAgentForm.js';
interface ConfigPrStatus {
pr_number: number;
@ -67,6 +77,7 @@ export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] = useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [createOpen, setCreateOpen] = useState(false);
useRefreshInterval(intervalMs, () => {
(async () => {
@ -93,11 +104,21 @@ export function AgentsPage() {
<Panel
title="agents"
icon="👥"
actions={<RefreshIntervalPicker id="agents-refresh" value={intervalMs} onChange={setIntervalMs} />}
actions={
<>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
+ agent
</Button>
<RefreshIntervalPicker id="agents-refresh" value={intervalMs} onChange={setIntervalMs} />
</>
}
>
{error ? <ApiErrorPanel context="failed to load the agent roster" problem={error} /> : null}
{!error && rows === null ? <p>loading…</p> : null}
{rows ? <Table columns={COLUMNS} rows={rows} rowKey={(a) => a.name} /> : null}
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} label="create agent">
<CreateAgentForm />
</Dialog>
</Panel>
);
}

View file

@ -0,0 +1,56 @@
/* <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.
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 {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 1.5em;
}
.create-agent-form-col,
.create-agent-info-col {
flex: 1 1 0;
min-width: 16em;
}
.create-agent-intro {
margin: 0 0 1.5em;
color: var(--muted);
}
.create-agent-form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
}
.create-agent-result {
margin-top: 1em;
}
.create-agent-result-ok {
color: var(--green);
}

View file

@ -0,0 +1,197 @@
// <CreateAgentForm> — 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 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.
//
// `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 './CreateAgentForm.css';
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
// 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;
}
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 CreateAgentForm() {
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<HiveEntry[] | null>(null);
const [hivesError, setHivesError] = useState<ProblemDetails | null>(null);
const [result, setResult] = useState<SubmitState>({ 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' });
try {
const r = await fetch('/api/agents', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, hive }),
});
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 (
<div class="create-agent-layout">
<div class="create-agent-form-col">
<Panel title="create agent" icon="🤖">
<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>
{hivesError && (
<ApiErrorPanel context="failed to load the hive list" problem={hivesError} />
)}
<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 ? 'loading…' : hives.length === 0 ? 'no hives configured' : 'select a hive'
}
/>
<Button
variant="primary"
type="submit"
disabled={result.status === 'submitting' || !hivesReady}
>
{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>
</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. The 🪪 glyph that
used to sit here as body copy now lives on `Panel`'s own `icon`
prop instead — it's what prompted that prop to exist at all
(see Panel.tsx's doc comment) */}
<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. Watch it all settle on{' '}
<Link href="/jobs">jobs</Link>.
</p>
</Panel>
</div>
</div>
);
}

View file

@ -39,7 +39,6 @@ import './Shell.css';
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: '/', label: 'hives', accent: 'var(--purple)' },
{ href: '/agents', label: 'agents', accent: 'var(--green)' },
{ href: '/create-agent', label: 'new agent', accent: 'var(--cyan)' },
{ href: '/jobs', label: 'jobs', accent: 'var(--pink)' },
{ href: '/components', label: 'components', accent: 'var(--blue)' },
];

View file

@ -0,0 +1,53 @@
/* <Dialog> — native `<dialog>` styling. Sized to comfortably hold the
create-agent form's own two-panel layout (which wraps to single-
column below its existing 16em-per-column threshold — see
CreateAgentForm.css — so this doesn't need to special-case that). */
.ui-dialog {
/* No sitewide `box-sizing: border-box` reset exists — without this,
the padding + border below add ON TOP of `width: 90vw` rather than
being carved out of it, overflowing a real phone-width viewport
(measured: 401px rendered against a 390px viewport). Caught via a
real screenshot at 390px, not assumed. */
box-sizing: border-box;
position: relative;
width: 90vw;
max-width: 44em;
max-height: 85vh;
overflow: auto;
padding: 1.5em;
border: 1px solid var(--border);
border-radius: 0.6em;
background: var(--bg-elev);
color: var(--fg);
}
.ui-dialog::backdrop {
background: rgba(0, 0, 0, 0.5);
}
.ui-dialog-close {
position: absolute;
top: 0.75em;
right: 0.75em;
display: flex;
align-items: center;
justify-content: center;
width: 2.2em;
height: 2.2em;
border: 1px solid transparent;
border-radius: 0.4em;
background: none;
color: var(--fg);
font-size: 1em;
line-height: 1;
cursor: pointer;
}
.ui-dialog-close:hover {
border-color: var(--border);
background: var(--bg);
}
.ui-dialog-body {
/* Clears the close button's own box (0.75em inset + 2.2em size) with
room to spare — verified against a real screenshot; a smaller value
let wide content (e.g. the create-agent form's two-panel layout)
render directly under the button instead of beside it. */
padding-right: 3.25em;
}

View file

@ -0,0 +1,76 @@
// <Dialog> — a generic modal overlay, native `<dialog>` element rather
// than a hand-rolled focus-trap + backdrop: `showModal()` gives focus
// trapping, Escape-to-close, and a real `::backdrop` for free, and
// `@hive/shared`'s own shadow-DOM `hive-dialog` custom element isn't an
// option here — swarm-ui's esbuild config can't consume those custom
// elements at all (a separate, already-tracked gap).
// No opinion on content beyond a close button; the caller's own markup
// supplies whatever heading/layout it needs (its first real caller,
// the create-agent form, already has an established two-panel layout
// from its former life as a standalone route — reusing that unchanged
// is simpler and lower-risk than re-deriving a modal-specific layout
// for content mara has already reviewed).
import { useEffect, useRef } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import './Dialog.css';
export function Dialog({
open,
onClose,
label,
children,
}: {
open: boolean;
onClose: () => void;
// `aria-label` only, no visible title row — see file-top comment on
// why content owns its own heading.
label: string;
children: ComponentChildren;
}) {
const ref = useRef<HTMLDialogElement>(null);
// Drives the native open/closed state from the `open` prop rather
// than mounting/unmounting the element — `showModal()`/`close()` are
// imperative, there's no declarative `<dialog open>` equivalent that
// also gets you the backdrop + focus trap.
useEffect(() => {
const el = ref.current;
if (!el) return;
if (open && !el.open) el.showModal();
else if (!open && el.open) el.close();
}, [open]);
// The dialog's own `close` event fires on Escape (and would fire on
// a native <form method="dialog"> submit, unused here) — syncing it
// back to the caller's state keeps `open` truthful even when nothing
// in this component's own JS drove the close.
useEffect(() => {
const el = ref.current;
if (!el) return;
function handleClose() {
onClose();
}
el.addEventListener('close', handleClose);
return () => el.removeEventListener('close', handleClose);
}, [onClose]);
return (
<dialog
ref={ref}
class="ui-dialog"
aria-label={label}
// A click lands on the `<dialog>` element itself (not any child)
// exactly when it's outside the rendered content box — inside
// the box, the click target is always some descendant. Standard
// "click the backdrop to close" trick for native `<dialog>`.
onClick={(e) => {
if (e.target === ref.current) onClose();
}}
>
<button type="button" class="ui-dialog-close" aria-label="close" onClick={onClose}>
✕
</button>
<div class="ui-dialog-body">{children}</div>
</dialog>
);
}

View file

@ -3,7 +3,7 @@
from (one class, so the two never drift). `width: 100%` fills
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 (`CreateAgentPage.css`'s
own layout caps it at the layout level (`CreateAgentForm.css`'s
`.create-agent-form-col` 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
@ -15,13 +15,13 @@
The field wrapper repeats `.ui-form-control`'s own `width: 100%`
rather than leaving the wrapper unconstrained: inside a shrink-to-fit
flex column (`CreateAgentPage`'s form is one), an unconstrained
flex column (`CreateAgentForm`'s form is one), an unconstrained
wrapper sizes to its own content — and a `width: 100%` *control*
inside an auto-width wrapper resolves against that shrunk width, not
the container the page actually gave it, so two fields with
differently-long labels ("agent name" vs "hive") ended up with
differently-wide inputs — the misalignment mara reported on the
create-agent page. Matching the two declarations here means every
create-agent form. Matching the two declarations here means every
field's control width is driven by the same container width
regardless of its label's length or its siblings'. */
.ui-form-field {

View file

@ -15,7 +15,7 @@
// `icon` is a small header glyph, left of the title — the swarm-ui
// design guide's own whimsy reference (before this, the guide only
// pointed at the dashboard's matrix-rain background). Grew out of
// CreateAgentPage's one-off 🪪 dropped straight into a panel's body
// CreateAgentForm's one-off 🪪 dropped straight into a panel's body
// copy: mara's call on review was that a single ad-hoc emoji isn't
// whimsy in the guide's sense (small, delightful, *consistent*), it
// should be a real theme every panel can opt into the same way. Plain

View file

@ -1,5 +1,5 @@
// <TextField> — a labelled single-line text input, the shared control
// every page-level form (`CreateAgentPage` today) reaches for instead
// every form (`CreateAgentForm` today) reaches for instead
// of hand-rolling its own label/input pair. No textarea/multi-line
// mode — promote that the day a real caller needs one, same "don't
// build ahead of a caller" rule the rest of `ui/` follows.