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..ee4d4b96 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/dialog/Dialog.css @@ -0,0 +1,53 @@ +/* β€” 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 { + /* 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; +} 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}
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/ui/form-field/FormField.css b/frontend/packages/swarm-ui/src/ui/form-field/FormField.css index ed4f1393..94906f12 100644 --- a/frontend/packages/swarm-ui/src/ui/form-field/FormField.css +++ b/frontend/packages/swarm-ui/src/ui/form-field/FormField.css @@ -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 { diff --git a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx index 01265adf..d74bcdb3 100644 --- a/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx +++ b/frontend/packages/swarm-ui/src/ui/panel/Panel.tsx @@ -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 diff --git a/frontend/packages/swarm-ui/src/ui/text-field/TextField.tsx b/frontend/packages/swarm-ui/src/ui/text-field/TextField.tsx index c759e6f6..e0cba5ef 100644 --- a/frontend/packages/swarm-ui/src/ui/text-field/TextField.tsx +++ b/frontend/packages/swarm-ui/src/ui/text-field/TextField.tsx @@ -1,5 +1,5 @@ // β€” 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.