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.
This commit is contained in:
iris 2026-08-24 00:28:10 +02:00
commit 13d7c73c12
7 changed files with 165 additions and 23 deletions

View file

@ -0,0 +1,47 @@
/* <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 {
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>
);
}