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:
parent
74e588415b
commit
f957e19d0d
7 changed files with 221 additions and 42 deletions
|
|
@ -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.
|
with no icon keeps the hyperhive mark.
|
||||||
|
|
||||||
The tab also has an agent badge, with the agent's icon and name, ahead of the
|
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
|
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
|
agent, or link an account, without leaving the terminal. A subagent's tab opens
|
||||||
its agent's details.
|
its agent's details.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,34 @@
|
||||||
// <AgentDetailDialog> — the agent detail panel from `/agents`, in a dialog, for
|
// <AgentDetailPopover> — the agent detail panel from `/agents`, in a popover
|
||||||
// a page that is already showing the agent's terminal and should not be left
|
// under its anchor, for a page that is already showing the agent's terminal
|
||||||
// to read or change its details. It carries the same body (`AgentDetail`) and
|
// and should not be left, or dimmed, to read or change its details. It carries
|
||||||
// the same actions (`useAgentActions`) as the panel, minus the terminal
|
// the same body (`AgentDetail`) and the same actions (`useAgentActions`) as the
|
||||||
// preview the page itself already is.
|
// 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
|
// 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.
|
// 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 { useEffect, useState } from "preact/hooks";
|
||||||
|
import type { RefObject } from "preact";
|
||||||
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 { Dialog } from "../../ui/dialog/Dialog.js";
|
|
||||||
import { Panel } from "../../ui/panel/Panel.js";
|
import { Panel } from "../../ui/panel/Panel.js";
|
||||||
|
import { Popover } from "../../ui/popover/Popover.js";
|
||||||
import { useAgentActions } from "../roster/AgentActions.js";
|
import { useAgentActions } from "../roster/AgentActions.js";
|
||||||
import { AgentDetail } from "./AgentDetail.js";
|
import { AgentDetail } from "./AgentDetail.js";
|
||||||
import type { AgentRow } from "../AgentTypes.js";
|
import type { AgentRow } from "../AgentTypes.js";
|
||||||
|
|
||||||
export function AgentDetailDialog({
|
export function AgentDetailPopover({
|
||||||
agent,
|
agent,
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
|
anchorRef,
|
||||||
}: {
|
}: {
|
||||||
agent: string;
|
agent: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
anchorRef: RefObject<HTMLElement>;
|
||||||
}) {
|
}) {
|
||||||
// `undefined` until the roster has answered; `null` when it has and the
|
// `undefined` until the roster has answered; `null` when it has and the
|
||||||
// agent is not in it.
|
// 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.
|
// what the page last saw.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
|
@ -61,24 +66,27 @@ export function AgentDetailDialog({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onClose={onClose} label={`${agent} details`} plain>
|
<Popover
|
||||||
{open ? (
|
open={open}
|
||||||
<Panel title={agent} icon="🔎" onClose={onClose}>
|
onClose={onClose}
|
||||||
{error ? (
|
anchorRef={anchorRef}
|
||||||
<ApiErrorPanel
|
label={`${agent} details`}
|
||||||
context="failed to load the agent's details"
|
>
|
||||||
problem={error}
|
<Panel title={agent} icon="🔎" onClose={onClose}>
|
||||||
/>
|
{error ? (
|
||||||
) : row ? (
|
<ApiErrorPanel
|
||||||
<AgentDetail row={row} actions={actions} />
|
context="failed to load the agent's details"
|
||||||
) : row === null ? (
|
problem={error}
|
||||||
<p>{agent} is not in the swarm's agent roster</p>
|
/>
|
||||||
) : (
|
) : row ? (
|
||||||
<p>loading…</p>
|
<AgentDetail row={row} actions={actions} />
|
||||||
)}
|
) : row === null ? (
|
||||||
</Panel>
|
<p>{agent} is not in the swarm's agent roster</p>
|
||||||
) : null}
|
) : (
|
||||||
</Dialog>
|
<p>loading…</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</Popover>
|
||||||
{actions.dialogs}
|
{actions.dialogs}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// its wanted state (up / offline / paused / destroyed) and link an account to
|
// 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
|
// it, with the confirm and form dialogs those need. Shared by the agent
|
||||||
// roster (`AgentsPage`) and the detail dialog on a terminal page
|
// 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.
|
// caller renders the returned `dialogs` once.
|
||||||
//
|
//
|
||||||
// "offline" and "paused" are the *same* confirm shape — same button
|
// "offline" and "paused" are the *same* confirm shape — same button
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
import { Button, type ButtonVariant } from "../ui/button/Button.js";
|
import { Button, type ButtonVariant } from "../ui/button/Button.js";
|
||||||
import { Dialog } from "../ui/dialog/Dialog.js";
|
import { Dialog } from "../ui/dialog/Dialog.js";
|
||||||
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.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 { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||||
import { GearIcon } from "@hive/shared/icons.js";
|
import { GearIcon } from "@hive/shared/icons.js";
|
||||||
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.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
|
// `plain`: a `Panel`'s own header bar/border is the dialog's
|
||||||
// only card, not a second one nested inside `Dialog`'s own — pair
|
// only card, not a second one nested inside `Dialog`'s own — pair
|
||||||
// `plain` with `Panel`'s `onClose` so the close button still exists,
|
// `plain` with `Panel`'s `onClose` so the close button still exists,
|
||||||
|
|
@ -511,6 +539,12 @@ export function ComponentsPage() {
|
||||||
</Sample>
|
</Sample>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Popover">
|
||||||
|
<Sample label="anchored, no backdrop, content supplies its own card">
|
||||||
|
<PopoverSample />
|
||||||
|
</Sample>
|
||||||
|
</Section>
|
||||||
|
|
||||||
<Section title="ConfirmDialog">
|
<Section title="ConfirmDialog">
|
||||||
<Sample label="modal, cancel/confirm button row">
|
<Sample label="modal, cancel/confirm button row">
|
||||||
<ConfirmDialogSample />
|
<ConfirmDialogSample />
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@
|
||||||
// plain page reload should all re-open the tab instead of landing on a
|
// 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
|
// page with no way back into the tab row. `useDynamicTabs().open()` is
|
||||||
// idempotent (dedupes by href), so this is safe to call every mount.
|
// 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 type { RouteComponentProps } from "wouter-preact";
|
||||||
import { Badge } from "@hive/shared/badge.js";
|
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 { AgentIcon } from "../../agent/AgentIcon.js";
|
||||||
import {
|
import {
|
||||||
AgentTermPreview,
|
AgentTermPreview,
|
||||||
|
|
@ -48,10 +48,12 @@ export function AgentTerminalPage({
|
||||||
// instead of once per terminal `page`.
|
// instead of once per terminal `page`.
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
// The agent's details open in a dialog over the terminal, so reading or
|
// The agent's details open in a popover under its badge, so reading or
|
||||||
// changing them never means leaving it. A subagent's terminal opens its
|
// changing them never means leaving the terminal, or dimming it. A
|
||||||
// agent's details: a subagent has none of its own.
|
// subagent's terminal opens its agent's details: a subagent has none of its
|
||||||
|
// own.
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const badgeRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="ui-agent-terminal-page">
|
<div class="ui-agent-terminal-page">
|
||||||
|
|
@ -61,18 +63,22 @@ export function AgentTerminalPage({
|
||||||
fullHeight
|
fullHeight
|
||||||
showHeaderBadges
|
showHeaderBadges
|
||||||
leading={
|
leading={
|
||||||
<Badge
|
<span ref={badgeRef}>
|
||||||
icon={<AgentIcon name={name} small />}
|
<Badge
|
||||||
value={name}
|
icon={<AgentIcon name={name} small />}
|
||||||
title={`open ${name}'s details`}
|
value={name}
|
||||||
onClick={() => setDetailOpen(true)}
|
title={`${detailOpen ? "close" : "open"} ${name}'s details`}
|
||||||
/>
|
expanded={detailOpen}
|
||||||
|
onClick={() => setDetailOpen((o) => !o)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<AgentDetailDialog
|
<AgentDetailPopover
|
||||||
agent={name}
|
agent={name}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => setDetailOpen(false)}
|
||||||
|
anchorRef={badgeRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
15
frontend/packages/swarm-ui/src/ui/popover/Popover.css
Normal file
15
frontend/packages/swarm-ui/src/ui/popover/Popover.css
Normal 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);
|
||||||
|
}
|
||||||
115
frontend/packages/swarm-ui/src/ui/popover/Popover.tsx
Normal file
115
frontend/packages/swarm-ui/src/ui/popover/Popover.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue