From 13d7c73c12cfbce7e1981644857ef9b6bea1b6cb Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 24 Aug 2026 00:28:10 +0200 Subject: [PATCH] swarm-ui: move agent creation into the agents page as a dialog Adds a generic ui/dialog/Dialog primitive (native , 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. --- frontend/packages/swarm-ui/src/App.tsx | 2 - .../swarm-ui/src/pages/AgentsPage.tsx | 23 +++++- ...reateAgentPage.css => CreateAgentForm.css} | 8 +- ...reateAgentPage.tsx => CreateAgentForm.tsx} | 31 ++++---- .../packages/swarm-ui/src/shell/Shell.tsx | 1 - .../swarm-ui/src/ui/dialog/Dialog.css | 47 ++++++++++++ .../swarm-ui/src/ui/dialog/Dialog.tsx | 76 +++++++++++++++++++ 7 files changed, 165 insertions(+), 23 deletions(-) rename frontend/packages/swarm-ui/src/pages/{CreateAgentPage.css => CreateAgentForm.css} (86%) rename frontend/packages/swarm-ui/src/pages/{CreateAgentPage.tsx => CreateAgentForm.tsx} (88%) create mode 100644 frontend/packages/swarm-ui/src/ui/dialog/Dialog.css create mode 100644 frontend/packages/swarm-ui/src/ui/dialog/Dialog.tsx diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index b97634a3..65d47afe 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -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() { - diff --git a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx index 08fee821..fe312ec1 100644 --- a/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/AgentsPage.tsx @@ -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(null); const [error, setError] = useState(null); const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); + const [createOpen, setCreateOpen] = useState(false); useRefreshInterval(intervalMs, () => { (async () => { @@ -93,11 +104,21 @@ export function AgentsPage() { } + actions={ + <> + + + + } > {error ? : null} {!error && rows === null ?

loading…

: null} {rows ? a.name} /> : null} + setCreateOpen(false)} label="create agent"> + + ); } diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css similarity index 86% rename from frontend/packages/swarm-ui/src/pages/CreateAgentPage.css rename to frontend/packages/swarm-ui/src/pages/CreateAgentForm.css index 7c5d1810..1f59c51e 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.css +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.css @@ -1,6 +1,8 @@ -/* β€” the create-agent form. Input/button chrome now - comes from the shared `ui/` form kit (`TextField`/`SelectField`/ - `Button`); this file only owns the page's own layout + copy. Column, +/* β€” 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 diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx similarity index 88% rename from frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx rename to frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx index 82cc7cb8..b13aaa7c 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx @@ -1,20 +1,19 @@ -// β€” minimal agent creation form, POSTs to +// β€” 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). +// 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). // -// 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. +// 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 @@ -34,7 +33,7 @@ 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'; +import './CreateAgentForm.css'; // Mirrors swarm-controller's `HiveEntry` β€” same shape `HivesPage` // consumes off `/api/hives/status`, but this page hits the plain @@ -70,7 +69,7 @@ type SubmitState = // exception report on this page. const NAME_PATTERN = '[a-z0-9\\-]{1,63}'; -export function CreateAgentPage() { +export function CreateAgentForm() { const [name, setName] = useState(''); const [hive, setHive] = useState(''); // `null` = still loading, `[]` = loaded but empty (a real, if unusual, diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index 39b0cf79..539471d2 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -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)' }, ]; diff --git a/frontend/packages/swarm-ui/src/ui/dialog/Dialog.css b/frontend/packages/swarm-ui/src/ui/dialog/Dialog.css new file mode 100644 index 00000000..4feeaef4 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/dialog/Dialog.css @@ -0,0 +1,47 @@ +/* β€” native `` 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 { + 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; +} diff --git a/frontend/packages/swarm-ui/src/ui/dialog/Dialog.tsx b/frontend/packages/swarm-ui/src/ui/dialog/Dialog.tsx new file mode 100644 index 00000000..e485bad3 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/dialog/Dialog.tsx @@ -0,0 +1,76 @@ +// β€” a generic modal overlay, native `` 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(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 `` 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
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 ( + ` 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 ``. + onClick={(e) => { + if (e.target === ref.current) onClose(); + }} + > + +
{children}
+
+ ); +}