Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1ab7cd43d | ||
|
|
4fd6480c9c | ||
|
|
13d7c73c12 |
10 changed files with 176 additions and 28 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
/* <CreateAgentPage> — 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,
|
||||
/* <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
|
||||
|
|
@ -1,20 +1,19 @@
|
|||
// <CreateAgentPage> — minimal agent creation form, POSTs to
|
||||
// <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 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,
|
||||
|
|
@ -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)' },
|
||||
];
|
||||
|
|
|
|||
53
frontend/packages/swarm-ui/src/ui/dialog/Dialog.css
Normal file
53
frontend/packages/swarm-ui/src/ui/dialog/Dialog.css
Normal 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;
|
||||
}
|
||||
76
frontend/packages/swarm-ui/src/ui/dialog/Dialog.tsx
Normal file
76
frontend/packages/swarm-ui/src/ui/dialog/Dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue