From f957e19d0d5967d9de80b55afc23e30be63a70de Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 12 Oct 2026 00:16:25 +0200 Subject: [PATCH] swarm-ui: show an agent's details in a popover, not a modal dialog The details open under the agent badge without dimming or blocking the page; a click outside or Escape closes it. New ui/popover primitive (anchored, portal, no backdrop) with a sample on the components page. A confirm or link dialog opened from inside it stays modal, and the popover ignores clicks and Escape that belong to that dialog. AgentDetailDialog becomes AgentDetailPopover. --- docs/swarm/ui.md | 5 +- ...etailDialog.tsx => AgentDetailPopover.tsx} | 62 ++++++---- .../src/agent/roster/AgentActions.tsx | 2 +- .../swarm-ui/src/pages/ComponentsPage.tsx | 34 ++++++ .../src/pages/agents/AgentTerminalPage.tsx | 30 +++-- .../swarm-ui/src/ui/popover/Popover.css | 15 +++ .../swarm-ui/src/ui/popover/Popover.tsx | 115 ++++++++++++++++++ 7 files changed, 221 insertions(+), 42 deletions(-) rename frontend/packages/swarm-ui/src/agent/detail/{AgentDetailDialog.tsx => AgentDetailPopover.tsx} (55%) create mode 100644 frontend/packages/swarm-ui/src/ui/popover/Popover.css create mode 100644 frontend/packages/swarm-ui/src/ui/popover/Popover.tsx diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 4b920f34..8fc31a83 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -39,9 +39,10 @@ different agents tell apart. A subagent's tab shows its agent's icon. An agent with no icon keeps the hyperhive mark. The tab also has an agent badge, with the agent's icon and name, ahead of the -connection badge. It opens the agent's details in a dialog over the terminal: +connection badge. It opens the agent's details in a popover under the badge: the same status, wanted state, inference settings and linked accounts as the -panel on `/agents`, without the terminal preview. You can stop or pause the +panel on `/agents`, without the terminal preview. The popover doesn't dim the +page, and a click outside it or Escape closes it. You can stop or pause the agent, or link an account, without leaving the terminal. A subagent's tab opens its agent's details. diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetailDialog.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetailPopover.tsx similarity index 55% rename from frontend/packages/swarm-ui/src/agent/detail/AgentDetailDialog.tsx rename to frontend/packages/swarm-ui/src/agent/detail/AgentDetailPopover.tsx index 4fa49980..5c282225 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetailDialog.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetailPopover.tsx @@ -1,29 +1,34 @@ -// — the agent detail panel from `/agents`, in a dialog, for -// a page that is already showing the agent's terminal and should not be left -// to read or change its details. It carries the same body (`AgentDetail`) and -// the same actions (`useAgentActions`) as the panel, minus the terminal -// preview the page itself already is. +// — the agent detail panel from `/agents`, in a popover +// under its anchor, for a page that is already showing the agent's terminal +// and should not be left, or dimmed, to read or change its details. It carries +// the same body (`AgentDetail`) and the same actions (`useAgentActions`) as the +// panel, minus the terminal preview the page itself already is. // -// The row comes from `GET /api/agents/status` when the dialog opens, found by +// The row comes from `GET /api/agents/status` when the popover opens, found by // name: the panel on `/agents` has the whole roster in hand, this page does // not. A wanted-state change patches the row in place from the PUT's answer. +// The confirm and link dialogs those actions open stay modal: they hold a +// decision, and the popover ignores clicks inside them. import { useEffect, useState } from "preact/hooks"; +import type { RefObject } from "preact"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; -import { Dialog } from "../../ui/dialog/Dialog.js"; import { Panel } from "../../ui/panel/Panel.js"; +import { Popover } from "../../ui/popover/Popover.js"; import { useAgentActions } from "../roster/AgentActions.js"; import { AgentDetail } from "./AgentDetail.js"; import type { AgentRow } from "../AgentTypes.js"; -export function AgentDetailDialog({ +export function AgentDetailPopover({ agent, open, onClose, + anchorRef, }: { agent: string; open: boolean; onClose: () => void; + anchorRef: RefObject; }) { // `undefined` until the roster has answered; `null` when it has and the // agent is not in it. @@ -36,7 +41,7 @@ export function AgentDetailDialog({ } }); - // Fetched on each open, so what the dialog shows is current rather than + // Fetched on each open, so what the popover shows is current rather than // what the page last saw. useEffect(() => { if (!open) return; @@ -61,24 +66,27 @@ export function AgentDetailDialog({ return ( <> - - {open ? ( - - {error ? ( - - ) : row ? ( - - ) : row === null ? ( -

{agent} is not in the swarm's agent roster

- ) : ( -

loading…

- )} -
- ) : null} -
+ + + {error ? ( + + ) : row ? ( + + ) : row === null ? ( +

{agent} is not in the swarm's agent roster

+ ) : ( +

loading…

+ )} +
+
{actions.dialogs} ); diff --git a/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx b/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx index 3b041c7b..229b644d 100644 --- a/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx +++ b/frontend/packages/swarm-ui/src/agent/roster/AgentActions.tsx @@ -2,7 +2,7 @@ // its wanted state (up / offline / paused / destroyed) and link an account to // it, with the confirm and form dialogs those need. Shared by the agent // roster (`AgentsPage`) and the detail dialog on a terminal page -// (`AgentDetailDialog`), so a state change reads the same from either; each +// (`AgentDetailPopover`), so a state change reads the same from either; each // caller renders the returned `dialogs` once. // // "offline" and "paused" are the *same* confirm shape — same button diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx index 2d691653..984d193e 100644 --- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx @@ -30,6 +30,7 @@ import { import { Button, type ButtonVariant } from "../ui/button/Button.js"; import { Dialog } from "../ui/dialog/Dialog.js"; import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js"; +import { Popover } from "../ui/popover/Popover.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { GearIcon } from "@hive/shared/icons.js"; import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js"; @@ -231,6 +232,33 @@ function DialogSample() { ); } +// `Popover` anchors to the trigger it is given and toggles from it, so the +// sample needs the trigger's ref and its own `open` state. +function PopoverSample() { + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + return ( + <> + + + + setOpen(false)} + anchorRef={anchorRef} + label="sample popover" + > + setOpen(false)}> +

+ Floats under its trigger and leaves the page behind it clickable. + Outside click or Escape closes it. +

+
+
+ + ); +} + // `plain`: a `Panel`'s own header bar/border is the dialog's // only card, not a second one nested inside `Dialog`'s own — pair // `plain` with `Panel`'s `onClose` so the close button still exists, @@ -511,6 +539,12 @@ export function ComponentsPage() { +
+ + + +
+
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx index 501647e1..248a907e 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx @@ -9,10 +9,10 @@ // plain page reload should all re-open the tab instead of landing on a // page with no way back into the tab row. `useDynamicTabs().open()` is // idempotent (dedupes by href), so this is safe to call every mount. -import { useEffect, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import type { RouteComponentProps } from "wouter-preact"; import { Badge } from "@hive/shared/badge.js"; -import { AgentDetailDialog } from "../../agent/detail/AgentDetailDialog.js"; +import { AgentDetailPopover } from "../../agent/detail/AgentDetailPopover.js"; import { AgentIcon } from "../../agent/AgentIcon.js"; import { AgentTermPreview, @@ -48,10 +48,12 @@ export function AgentTerminalPage({ // instead of once per terminal `page`. }, [page]); - // The agent's details open in a dialog over the terminal, so reading or - // changing them never means leaving it. A subagent's terminal opens its - // agent's details: a subagent has none of its own. + // The agent's details open in a popover under its badge, so reading or + // changing them never means leaving the terminal, or dimming it. A + // subagent's terminal opens its agent's details: a subagent has none of its + // own. const [detailOpen, setDetailOpen] = useState(false); + const badgeRef = useRef(null); return (
@@ -61,18 +63,22 @@ export function AgentTerminalPage({ fullHeight showHeaderBadges leading={ - } - value={name} - title={`open ${name}'s details`} - onClick={() => setDetailOpen(true)} - /> + + } + value={name} + title={`${detailOpen ? "close" : "open"} ${name}'s details`} + expanded={detailOpen} + onClick={() => setDetailOpen((o) => !o)} + /> + } /> - setDetailOpen(false)} + anchorRef={badgeRef} />
); diff --git a/frontend/packages/swarm-ui/src/ui/popover/Popover.css b/frontend/packages/swarm-ui/src/ui/popover/Popover.css new file mode 100644 index 00000000..e0397413 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/popover/Popover.css @@ -0,0 +1,15 @@ +/* — floating box under its anchor. Placement (`top`/`left`) is set + inline from the anchor's rect; this is the rest. The content brings its own + card (a `Panel`), so the box itself only floats, casts a shadow and caps its + size to the viewport. */ +.ui-popover { + position: fixed; + z-index: 30; + box-sizing: border-box; + width: 28em; + max-width: calc(100vw - 1em); + max-height: calc(100vh - 4em); + overflow: auto; + border-radius: 0.5em; + box-shadow: 0 0.4em 1.2em rgba(0, 0, 0, 0.45); +} diff --git a/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx b/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx new file mode 100644 index 00000000..3bc5d1e9 --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/popover/Popover.tsx @@ -0,0 +1,115 @@ +// — arbitrary content in a floating box anchored under the element +// that opened it, with no backdrop: the page behind stays visible and +// clickable, unlike `Dialog`'s modal overlay. For content that belongs to the +// thing you clicked (an agent's details from its badge), not a task that +// should hold the whole page. +// +// Not a native ``: `showModal()` always dims and blocks the page, and a +// non-modal `show()` gets no outside-click or Escape handling for free, so +// this hand-rolls that contract the way `Dropdown` does. Rendered in a portal +// at `position: fixed`, placed from the anchor's `getBoundingClientRect()`, so +// a clipping ancestor (the terminal's scrolling box) can't cut it off. +// +// A modal `` opened from inside it (a confirm, a form) is not +// "outside": a click in an open dialog, or Escape while one is open, goes to +// that dialog and leaves the popover alone. +import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +import { createPortal } from "preact/compat"; +import type { ComponentChildren, RefObject } from "preact"; +import "./Popover.css"; + +// Space kept between the popover and the viewport's edges. +const EDGE_PX = 8; +const GAP_PX = 4; + +export function Popover({ + open, + onClose, + anchorRef, + label, + children, +}: { + open: boolean; + onClose: () => void; + /** The element the popover hangs under. A click on it is not an outside + * click, so its own toggle handler stays in charge of closing. */ + anchorRef: RefObject; + /** `aria-label` for the floating box. */ + label: string; + children: ComponentChildren; +}) { + const ref = useRef(null); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + + // Placed from the anchor on open, and again on scroll and resize, so it + // follows the anchor instead of floating where it first appeared. + useLayoutEffect(() => { + if (!open) { + setPos(null); + return; + } + function place() { + const anchor = anchorRef.current; + if (!anchor) return; + const r = anchor.getBoundingClientRect(); + const width = ref.current?.offsetWidth ?? 0; + const maxLeft = window.innerWidth - width - EDGE_PX; + setPos({ + top: r.bottom + GAP_PX, + left: Math.max(EDGE_PX, Math.min(r.left, maxLeft)), + }); + } + place(); + window.addEventListener("scroll", place, true); + window.addEventListener("resize", place); + return () => { + window.removeEventListener("scroll", place, true); + window.removeEventListener("resize", place); + }; + }, [open, anchorRef]); + + useEffect(() => { + if (!open) return; + function inside(target: EventTarget | null, el: Element | null) { + return target instanceof Node && el !== null && el.contains(target); + } + function onPointerDown(e: PointerEvent) { + if ( + inside(e.target, ref.current) || + inside(e.target, anchorRef.current) || + inside(e.target, document.querySelector("dialog[open]")) + ) { + return; + } + onClose(); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key !== "Escape" || document.querySelector("dialog[open]")) return; + onClose(); + } + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open, onClose, anchorRef]); + + if (!open) return null; + return createPortal( + , + document.body, + ); +}