Compare commits

..
10 changed files with 28 additions and 176 deletions

View file

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

View file

@ -11,18 +11,9 @@
// client-side into one row per agent rather than N per-agent config-PR // 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 // calls — exactly why the bulk endpoint exists instead of looping the
// single-agent one. // 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 { useState } from 'preact/hooks';
import { ApiErrorPanel } from '@hive/shared/api-error-panel.js'; import { ApiErrorPanel } from '@hive/shared/api-error-panel.js';
import { readApiError, type ProblemDetails } from '@hive/shared/api-error.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 { Panel } from '../ui/panel/Panel.js';
import { import {
RefreshIntervalPicker, RefreshIntervalPicker,
@ -31,7 +22,6 @@ import {
} from '../ui/refresh-interval/RefreshInterval.js'; } from '../ui/refresh-interval/RefreshInterval.js';
import { StatusChip } from '../ui/status-chip/StatusChip.js'; import { StatusChip } from '../ui/status-chip/StatusChip.js';
import { Table, type TableColumn } from '../ui/table/Table.js'; import { Table, type TableColumn } from '../ui/table/Table.js';
import { CreateAgentForm } from './CreateAgentForm.js';
interface ConfigPrStatus { interface ConfigPrStatus {
pr_number: number; pr_number: number;
@ -77,7 +67,6 @@ export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null); const [rows, setRows] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null); const [error, setError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] = useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); const [intervalMs, setIntervalMs] = useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [createOpen, setCreateOpen] = useState(false);
useRefreshInterval(intervalMs, () => { useRefreshInterval(intervalMs, () => {
(async () => { (async () => {
@ -104,21 +93,11 @@ export function AgentsPage() {
<Panel <Panel
title="agents" title="agents"
icon="👥" icon="👥"
actions={ actions={<RefreshIntervalPicker id="agents-refresh" value={intervalMs} onChange={setIntervalMs} />}
<>
<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 ? <ApiErrorPanel context="failed to load the agent roster" problem={error} /> : null}
{!error && rows === null ? <p>loading</p> : null} {!error && rows === null ? <p>loading</p> : null}
{rows ? <Table columns={COLUMNS} rows={rows} rowKey={(a) => a.name} /> : null} {rows ? <Table columns={COLUMNS} rows={rows} rowKey={(a) => a.name} /> : null}
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} label="create agent">
<CreateAgentForm />
</Dialog>
</Panel> </Panel>
); );
} }

View file

@ -1,8 +1,6 @@
/* <CreateAgentForm> the create-agent form, rendered inside a `Dialog` /* <CreateAgentPage> the create-agent form. Input/button chrome now
from `AgentsPage` (see that component's file-top comment for why). comes from the shared `ui/` form kit (`TextField`/`SelectField`/
Input/button chrome comes from the shared `ui/` form kit `Button`); this file only owns the page's own layout + copy. Column,
(`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 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 row layout put fields of different natural widths on one baseline and
looked misaligned mara: "make it a col". Reuses the same base16 looked misaligned mara: "make it a col". Reuses the same base16

View file

@ -1,19 +1,20 @@
// <CreateAgentForm> — minimal agent creation form, POSTs to // <CreateAgentPage> — minimal agent creation form, POSTs to
// swarm-controller's `POST /api/agents`. That endpoint only queues a // swarm-controller's `POST /api/agents`. That endpoint only queues a
// `CreateIdentity` job and returns its node id — the identity isn't // `CreateIdentity` job and returns its node id — the identity isn't
// guaranteed to exist yet when this gets a response — so this shows // guaranteed to exist yet when this page gets a response — so this
// the queued confirmation and links to the Jobs page to watch it // page shows the queued confirmation and links to the Jobs page to
// settle, rather than duplicating JobqGraph's per-node polling here. // watch it settle, rather than duplicating JobqGraph's per-node
// Scope matches the originating issue exactly: name field only, no // polling here. Scope matches the originating issue exactly: name
// forge/deploy options (those aren't wired server-side yet either). // field only, no forge/deploy options (those aren't wired server-side
// yet either).
// //
// Rendered inside a `Dialog` from `AgentsPage`'s own "+ agent" button, // Lives at `/create-agent`, not `/agents` — a future agent *roster*
// not a standalone route — the roster it feeds is the natural home for // (listing existing agents) is the natural owner of the bare `/agents`
// the action that populates it, and a distinct top-level nav item next // path, and this creation form is a distinct action from that list,
// to it was one click of indirection for no benefit. Kept as its own // not a variant of it. Flat, not `/agents/new`: `index.html`'s asset
// component (not inlined into AgentsPage) since the two-panel layout // links are relative (`static/main.js`), which only resolve correctly
// below is unchanged from its former life as a page: same content, same // one path segment deep — a real bug, filed separately rather than
// review history, just a different mount point. // fixed here, but reason enough to avoid a nested route today.
// //
// `hive` field added because agent creation had no way to record which // `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 // hive an agent runs on. `POST /api/agents` now requires it, so the
@ -33,7 +34,7 @@ import { Panel } from '../ui/panel/Panel.js';
import { TextField } from '../ui/text-field/TextField.js'; import { TextField } from '../ui/text-field/TextField.js';
import { SelectField, type SelectOption } from '../ui/select-field/SelectField.js'; import { SelectField, type SelectOption } from '../ui/select-field/SelectField.js';
import { Button } from '../ui/button/Button.js'; import { Button } from '../ui/button/Button.js';
import './CreateAgentForm.css'; import './CreateAgentPage.css';
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage` // Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
// consumes off `/api/hives/status`, but this page hits the plain // consumes off `/api/hives/status`, but this page hits the plain
@ -69,7 +70,7 @@ type SubmitState =
// exception report on this page. // exception report on this page.
const NAME_PATTERN = '[a-z0-9\\-]{1,63}'; const NAME_PATTERN = '[a-z0-9\\-]{1,63}';
export function CreateAgentForm() { export function CreateAgentPage() {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [hive, setHive] = useState(''); const [hive, setHive] = useState('');
// `null` = still loading, `[]` = loaded but empty (a real, if unusual, // `null` = still loading, `[]` = loaded but empty (a real, if unusual,

View file

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

View file

@ -1,53 +0,0 @@
/* <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

@ -1,76 +0,0 @@
// <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 from (one class, so the two never drift). `width: 100%` fills
whichever container the caller gives it the kit itself has no whichever container the caller gives it the kit itself has no
opinion on a maximum width; a page that wants one narrower than its opinion on a maximum width; a page that wants one narrower than its
own layout caps it at the layout level (`CreateAgentForm.css`'s own layout caps it at the layout level (`CreateAgentPage.css`'s
`.create-agent-form-col` is the existing example), same reasoning `.create-agent-form-col` is the existing example), same reasoning
`Panel` has no width opinion of its own either. `min-height` is a `Panel` has no width opinion of its own either. `min-height` is a
touch-target floor (44px at the default 16px root font WCAG 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%` The field wrapper repeats `.ui-form-control`'s own `width: 100%`
rather than leaving the wrapper unconstrained: inside a shrink-to-fit rather than leaving the wrapper unconstrained: inside a shrink-to-fit
flex column (`CreateAgentForm`'s form is one), an unconstrained flex column (`CreateAgentPage`'s form is one), an unconstrained
wrapper sizes to its own content and a `width: 100%` *control* wrapper sizes to its own content and a `width: 100%` *control*
inside an auto-width wrapper resolves against that shrunk width, not inside an auto-width wrapper resolves against that shrunk width, not
the container the page actually gave it, so two fields with the container the page actually gave it, so two fields with
differently-long labels ("agent name" vs "hive") ended up with differently-long labels ("agent name" vs "hive") ended up with
differently-wide inputs the misalignment mara reported on the differently-wide inputs the misalignment mara reported on the
create-agent form. Matching the two declarations here means every create-agent page. Matching the two declarations here means every
field's control width is driven by the same container width field's control width is driven by the same container width
regardless of its label's length or its siblings'. */ regardless of its label's length or its siblings'. */
.ui-form-field { .ui-form-field {

View file

@ -15,7 +15,7 @@
// `icon` is a small header glyph, left of the title — the swarm-ui // `icon` is a small header glyph, left of the title — the swarm-ui
// design guide's own whimsy reference (before this, the guide only // design guide's own whimsy reference (before this, the guide only
// pointed at the dashboard's matrix-rain background). Grew out of // pointed at the dashboard's matrix-rain background). Grew out of
// CreateAgentForm's one-off 🪪 dropped straight into a panel's body // CreateAgentPage'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 // 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 // whimsy in the guide's sense (small, delightful, *consistent*), it
// should be a real theme every panel can opt into the same way. Plain // 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 // <TextField> — a labelled single-line text input, the shared control
// every form (`CreateAgentForm` today) reaches for instead // every page-level form (`CreateAgentPage` today) reaches for instead
// of hand-rolling its own label/input pair. No textarea/multi-line // 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 // mode — promote that the day a real caller needs one, same "don't
// build ahead of a caller" rule the rest of `ui/` follows. // build ahead of a caller" rule the rest of `ui/` follows.