Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-10-12 00:16:25 +02:00
commit f957e19d0d
7 changed files with 221 additions and 42 deletions

View file

@ -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.

View file

@ -1,29 +1,34 @@
// <AgentDetailDialog> — 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.
// <AgentDetailPopover> — 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<HTMLElement>;
}) {
// `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 (
<>
<Dialog open={open} onClose={onClose} label={`${agent} details`} plain>
{open ? (
<Panel title={agent} icon="🔎" onClose={onClose}>
{error ? (
<ApiErrorPanel
context="failed to load the agent's details"
problem={error}
/>
) : row ? (
<AgentDetail row={row} actions={actions} />
) : row === null ? (
<p>{agent} is not in the swarm's agent roster</p>
) : (
<p>loading…</p>
)}
</Panel>
) : null}
</Dialog>
<Popover
open={open}
onClose={onClose}
anchorRef={anchorRef}
label={`${agent} details`}
>
<Panel title={agent} icon="🔎" onClose={onClose}>
{error ? (
<ApiErrorPanel
context="failed to load the agent's details"
problem={error}
/>
) : row ? (
<AgentDetail row={row} actions={actions} />
) : row === null ? (
<p>{agent} is not in the swarm's agent roster</p>
) : (
<p>loading…</p>
)}
</Panel>
</Popover>
{actions.dialogs}
</>
);

View file

@ -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

View file

@ -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<HTMLSpanElement>(null);
return (
<>
<span ref={anchorRef}>
<Button onClick={() => setOpen((o) => !o)}>toggle popover</Button>
</span>
<Popover
open={open}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
label="sample popover"
>
<Panel title="a popover" icon="🔗" onClose={() => setOpen(false)}>
<p>
Floats under its trigger and leaves the page behind it clickable.
Outside click or Escape closes it.
</p>
</Panel>
</Popover>
</>
);
}
// `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() {
</Sample>
</Section>
<Section title="Popover">
<Sample label="anchored, no backdrop, content supplies its own card">
<PopoverSample />
</Sample>
</Section>
<Section title="ConfirmDialog">
<Sample label="modal, cancel/confirm button row">
<ConfirmDialogSample />

View file

@ -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<HTMLSpanElement>(null);
return (
<div class="ui-agent-terminal-page">
@ -61,18 +63,22 @@ export function AgentTerminalPage({
fullHeight
showHeaderBadges
leading={
<Badge
icon={<AgentIcon name={name} small />}
value={name}
title={`open ${name}'s details`}
onClick={() => setDetailOpen(true)}
/>
<span ref={badgeRef}>
<Badge
icon={<AgentIcon name={name} small />}
value={name}
title={`${detailOpen ? "close" : "open"} ${name}'s details`}
expanded={detailOpen}
onClick={() => setDetailOpen((o) => !o)}
/>
</span>
}
/>
<AgentDetailDialog
<AgentDetailPopover
agent={name}
open={detailOpen}
onClose={() => setDetailOpen(false)}
anchorRef={badgeRef}
/>
</div>
);

View file

@ -0,0 +1,15 @@
/* <Popover> — 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);
}

View file

@ -0,0 +1,115 @@
// <Popover> — 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 `<dialog>`: `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 `<dialog>` 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<HTMLElement>;
/** `aria-label` for the floating box. */
label: string;
children: ComponentChildren;
}) {
const ref = useRef<HTMLDivElement>(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(
<div
class="ui-popover"
role="dialog"
aria-label={label}
ref={ref}
style={
pos
? { top: `${pos.top}px`, left: `${pos.left}px` }
: { visibility: "hidden" }
}
>
{children}
</div>,
document.body,
);
}