Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
iris
5c15580060 swarm-ui: popover keeps its position object when a scroll moved nothing 2026-10-12 00:21:38 +02:00
iris
afa9d861ba docs: swarm UI popover wording without the input-specific verb 2026-10-12 00:19:33 +02:00
iris
f957e19d0d 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.
2026-10-12 00:19:33 +02:00
iris
74e588415b swarm-ui: split pages/agents into two pages and the agent components
pages/agents held the roster page, the terminal page and every component under them. It keeps the two pages; the components move to src/agent, by concern: roster (card, wanted menu, actions), detail (panel body, dialog, inference, linked accounts, link menu) and terminal (preview, input, subagents, stream hooks, favicon), with the shared row type and icon at the top. Moves and import rewrites only.
2026-10-12 00:19:33 +02:00
iris
fd6953ed7c swarm-ui: show a failed wanted-state change in the agent details
The failure was recorded but only the roster card drew it, so the details dialog on a terminal page said nothing when a declaration failed. AgentDetail now shows it under the wanted menu, which the roster's detail panel gets too.
2026-10-12 00:19:33 +02:00
iris
103c842890 swarm-ui: open an agent's details in a dialog from its terminal tab
The terminal page gets an agent badge (icon and name) that opens the agent's detail panel in a dialog over the terminal, without the terminal preview. The panel body and the actions on an agent (declare a wanted state, link an account, with their confirm and form dialogs) move out of the agent roster page into AgentDetail and useAgentActions, so the roster and the dialog share them. AgentIcon moves to its own file for the same reason.
2026-10-12 00:19:33 +02:00
32 changed files with 842 additions and 472 deletions

View file

@ -38,6 +38,14 @@ A terminal's own tab takes the agent's icon as its browser-tab icon, so tabs of
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 popover under the badge:
the same status, wanted state, inference settings and linked accounts as the
panel on `/agents`, without the terminal preview. The popover doesn't dim the
page. Selecting 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.
### Scheduled prompts
`/schedules` has one card per scheduled prompt. The **name** is the card's

View file

@ -0,0 +1,27 @@
/* The icon square, sized and dimmed like the hive dashboard's
`.container-icon` (dashboard.css). */
.ui-agent-icon {
position: relative;
overflow: hidden;
flex: none;
width: 5em;
aspect-ratio: 1;
border-radius: 6px;
background-color: color-mix(in srgb, var(--crust) 60%, transparent);
}
.ui-agent-icon > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.ui-agent-icon-none {
filter: grayscale(1);
opacity: 0.4;
}
/* Beside a line of text, inside a badge. */
.ui-agent-icon-small {
width: 1.3em;
border-radius: 3px;
}

View file

@ -0,0 +1,38 @@
// <AgentIcon> — the agent's icon from swarm-controller's
// `GET /api/agents/{name}/icon`, the same square and fallback as the hive
// dashboard's container row: an agent with no icon (404) or an unreachable
// controller shows the dimmed hyperhive mark instead of a broken image.
// Always an `<img>`: the body is an agent-authored SVG, and only an image load
// keeps its script from running.
import { useState } from "preact/hooks";
import "./AgentIcon.css";
export function AgentIcon({
name,
small = false,
}: {
name: string;
/** Badge-sized, for beside a line of text, rather than the card's square. */
small?: boolean;
}) {
const [fallback, setFallback] = useState(false);
return (
<div
class={
"ui-agent-icon" +
(small ? " ui-agent-icon-small" : "") +
(fallback ? " ui-agent-icon-none" : "")
}
>
<img
alt=""
src={
fallback
? "/favicon.svg"
: `/api/agents/${encodeURIComponent(name)}/icon`
}
onError={() => setFallback(true)}
/>
</div>
);
}

View file

@ -0,0 +1,19 @@
/* <AgentDetail> body — the panel or dialog around it supplies the title (the
agent's name), so it is not repeated in here. */
.ui-agent-detail-fields {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.4em 1em;
margin: 0 0 1em;
}
.ui-agent-detail-fields dt {
color: var(--muted);
}
.ui-agent-detail-fields dd {
margin: 0;
}
.ui-agent-detail-actions {
display: flex;
align-items: center;
gap: 0.75em;
}

View file

@ -0,0 +1,135 @@
// <AgentDetail> — one agent's details: its status, wanted state, hive, config
// PR, inference settings and linked accounts, plus the actions on them. The
// body of the detail panel on `/agents` (where `withTerminal` adds the live
// terminal and subagents below it) and of the detail dialog on a terminal
// page (which is already beside the terminal, so omits it).
//
// Takes the agent's *live* row and the shared `AgentActions`, so a state
// change or a link made here shows wherever else the same row is drawn.
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge } from "@hive/shared/badge.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import type { AgentActions } from "../roster/AgentActions.js";
import { AgentInference } from "./AgentInference.js";
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
import { LinkAccountMenu } from "./LinkAccountMenu.js";
import { LinkedAccounts } from "./LinkedAccounts.js";
import { SubagentTerms } from "../terminal/SubagentTerms.js";
import { WantedMenu } from "../roster/WantedMenu.js";
import "./AgentDetail.css";
export function AgentDetail({
row,
actions,
withTerminal = false,
}: {
row: AgentRow;
actions: AgentActions;
withTerminal?: boolean;
}) {
const { tone, label } = FRESHNESS[row.freshness];
const declareError = actions.actionErrors.get(row.name);
return (
<div class="ui-agent-detail">
<dl class="ui-agent-detail-fields">
<dt>status</dt>
<dd>
<Badge
tone={tone}
value={
<>
{label}
{row.last_seen_unix !== null ? (
<>
{" "}
(<RelativeTime epochMs={row.last_seen_unix * 1000} />)
</>
) : null}
</>
}
/>
</dd>
<dt>message</dt>
<dd>{row.snapshot?.status_text ?? "—"}</dd>
<dt>wanted</dt>
<dd>
{/* Full menu (destroy included) — mara: "destroy is already
available via wanted state", no separate button needed. */}
<WantedMenu
row={row}
pending={actions.pendingAgents.has(row.name)}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
/>
{/* A failed declaration shows here too: on a terminal page's
dialog there is no roster card to carry the "failed" badge. */}
{declareError ? (
<ApiErrorPanel
context="the declaration failed"
problem={declareError}
/>
) : null}
</dd>
<dt>hive</dt>
<dd>{row.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{row.config_pr ? (
<Badge
tone="warning"
value={
row.config_pr.html_url ? (
<a
href={row.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
#{row.config_pr.pr_number}
</a>
) : (
`#${row.config_pr.pr_number}`
)
}
/>
) : (
"—"
)}
</dd>
<dt>inference</dt>
<dd>
<AgentInference key={row.name} agent={row.name} />
</dd>
<dt>accounts</dt>
<dd>
{row.hive ? (
<LinkedAccounts
key={`${row.hive}/${row.name}`}
hive={row.hive}
agent={row.name}
version={actions.linkVersion}
/>
) : (
"—"
)}
</dd>
</dl>
<div class="ui-agent-detail-actions">
<LinkAccountMenu
agent={row.name}
hasHive={row.hive !== null}
onSelect={(kind) => actions.openLink(row, kind)}
/>
</div>
{withTerminal ? (
<>
{/* The agent's live terminal and its input box. */}
<AgentTermPreview agentName={row.name} />
<SubagentTerms key={row.name} agentName={row.name} />
</>
) : null}
</div>
);
}

View file

@ -0,0 +1,93 @@
// <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 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 { 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 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.
const [row, setRow] = useState<AgentRow | null | undefined>(undefined);
const [error, setError] = useState<ProblemDetails | null>(null);
const actions = useAgentActions((declaredRow, declared) => {
const state = declared.find((d) => d.agent === declaredRow.name)?.state;
if (state !== undefined) {
setRow((prev) => (prev ? { ...prev, wanted: state } : prev));
}
});
// Fetched on each open, so what the popover shows is current rather than
// what the page last saw.
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
const res = await fetch("/api/agents/status");
if (!res.ok) {
if (!cancelled) setError(await readApiError(res));
return;
}
const rows = (await res.json()) as AgentRow[];
if (cancelled) return;
setRow(rows.find((r) => r.name === agent) ?? null);
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [open, agent]);
return (
<>
<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

@ -0,0 +1,266 @@
// useAgentActions — what can be done to an agent from the swarm UI: declare
// 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
// (`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
// treatment, same "disruptive to a running turn loop, unlike up" reason for
// needing a confirm at all — so one union state + one `ConfirmDialog` covers
// both. Destroy is kept apart: it is irreversible, has a longer warning and
// is danger-styled, not one more case of that shape.
import { useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { LinkMatrixAccountForm } from "../../pages/LinkMatrixAccountForm.js";
import { LinkForgeAccountForm } from "../../pages/LinkForgeAccountForm.js";
import { LinkGithubAccountForm } from "../../pages/LinkGithubAccountForm.js";
import type { AgentRow } from "../AgentTypes.js";
import type { LinkKind } from "../detail/LinkAccountMenu.js";
// The one thing that differs between the "offline" and "paused" confirm
// dialogs `confirmTarget` drives — everything else (button row, open/close
// wiring) is the shared `ConfirmDialog`.
const CONFIRM_COPY: Record<
"offline" | "paused",
{
label: string;
confirmLabel: string;
message: (agentName: string) => ComponentChildren;
}
> = {
offline: {
label: "stop agent",
confirmLabel: "stop",
message: (agentName) => (
<p>
Declare <strong>{agentName}</strong> offline? The hive brings its
container down on its next reconcile sweep.
</p>
),
},
paused: {
label: "pause agent",
confirmLabel: "pause",
message: (agentName) => (
<p>
Declare <strong>{agentName}</strong> paused? Its container stays up —
the web UI and MCP daemons keep running — but its turn loop parks on the
hive's next reconcile sweep. Select "up" again to resume it.
</p>
),
},
};
/** A hive's whole declaration set, as the state PUT answers it. */
export type Declared = { agent: string; state: string }[];
export interface AgentActions {
/** Per-agent, not one flag: one row's declare-in-flight must not disable
* every other row's button. */
pendingAgents: ReadonlySet<string>;
actionErrors: ReadonlyMap<string, ProblemDetails>;
/** Bumped when a link dialog closes; `LinkedAccounts` refetches on a
* change, so a newly linked account shows without a reload. */
linkVersion: number;
selectUp: (row: AgentRow) => void;
selectOffline: (row: AgentRow) => void;
selectPaused: (row: AgentRow) => void;
requestDestroy: (row: AgentRow) => void;
openLink: (row: AgentRow, kind: LinkKind) => void;
/** The confirm and link dialogs; render once, anywhere. */
dialogs: ComponentChildren;
}
export function useAgentActions(
/** Called with the hive's declaration set after a PUT succeeds, so the
* caller can patch the `wanted` field into rows already on screen. */
onDeclared: (row: AgentRow, declared: Declared) => void,
): AgentActions {
const [pendingAgents, setPendingAgents] = useState<ReadonlySet<string>>(
new Set(),
);
const [actionErrors, setActionErrors] = useState<
ReadonlyMap<string, ProblemDetails>
>(new Map());
// null means closed, not a boolean, so a dialog can name the agent without
// a second piece of state to keep in sync with it.
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
const [confirmTarget, setConfirmTarget] = useState<{
row: AgentRow;
state: "offline" | "paused";
} | null>(null);
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
const [linkVersion, setLinkVersion] = useState(0);
const linkClosed = () => setLinkVersion((v) => v + 1);
const closeMatrix = () => {
setMatrixTarget(null);
linkClosed();
};
const closeForge = () => {
setForgeTarget(null);
linkClosed();
};
const closeGithub = () => {
setGithubTarget(null);
linkClosed();
};
// The one PUT every wanted-state selection ends at, whether or not it went
// through a `ConfirmDialog` first: PUT a new `wanted` declaration, track
// per-agent pending/error state, hand the response back to the caller.
async function declareState(row: AgentRow, target: string) {
if (!row.hive) return;
setPendingAgents((prev) => new Set(prev).add(row.name));
setActionErrors((prev) => {
const next = new Map(prev);
next.delete(row.name);
return next;
});
try {
const res = await fetch(
`/api/hives/${encodeURIComponent(row.hive)}/agents/${encodeURIComponent(row.name)}/state`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ state: target }),
},
);
if (!res.ok) {
const problem = await readApiError(res);
setActionErrors((prev) => new Map(prev).set(row.name, problem));
return;
}
onDeclared(row, (await res.json()) as Declared);
} catch (e: unknown) {
setActionErrors((prev) =>
new Map(prev).set(row.name, { detail: String(e) }),
);
} finally {
setPendingAgents((prev) => {
const next = new Set(prev);
next.delete(row.name);
return next;
});
}
}
async function confirmDeclare() {
if (!confirmTarget) return;
const { row, state } = confirmTarget;
setConfirmTarget(null);
await declareState(row, state);
}
// Fires from the destroy confirm dialog, never directly off a click. Unlike
// the other directions there is no "current state" to read: destroy is a
// one-way declaration, not a flip.
async function destroyAgent(row: AgentRow) {
setDestroyTarget(null);
await declareState(row, "destroyed");
}
const dialogs = (
<>
<Dialog
open={matrixTarget !== null}
onClose={closeMatrix}
label="link a matrix account"
plain
>
{/* `matrixTarget.hive` is non-null here — the link triggers are
disabled without one, so this can only open with a real hive to
PUT against. */}
{matrixTarget?.hive ? (
<LinkMatrixAccountForm
hive={matrixTarget.hive}
agent={matrixTarget.name}
onClose={closeMatrix}
/>
) : null}
</Dialog>
<Dialog
open={forgeTarget !== null}
onClose={closeForge}
label="link a forge account"
plain
>
{forgeTarget?.hive ? (
<LinkForgeAccountForm
hive={forgeTarget.hive}
agent={forgeTarget.name}
onClose={closeForge}
/>
) : null}
</Dialog>
<Dialog
open={githubTarget !== null}
onClose={closeGithub}
label="link a github account"
plain
>
{githubTarget?.hive ? (
<LinkGithubAccountForm
hive={githubTarget.hive}
agent={githubTarget.name}
onClose={closeGithub}
/>
) : null}
</Dialog>
<ConfirmDialog
open={confirmTarget !== null}
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
onCancel={() => setConfirmTarget(null)}
onConfirm={() => void confirmDeclare()}
confirmLabel={
confirmTarget
? CONFIRM_COPY[confirmTarget.state].confirmLabel
: undefined
}
>
{confirmTarget
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
: null}
</ConfirmDialog>
<ConfirmDialog
open={destroyTarget !== null}
label="destroy agent"
onCancel={() => setDestroyTarget(null)}
onConfirm={() => destroyTarget && void destroyAgent(destroyTarget)}
confirmLabel="destroy"
>
{destroyTarget ? (
<p>
Destroy <strong>{destroyTarget.name}</strong>? The hive tears its
container down on its next reconcile sweep. This is not reversible
from here — bringing it back means redeploying via "+ agent", which
reuses the agent's existing identity, config repo, and forge
collaborator access rather than starting over.
</p>
) : null}
</ConfirmDialog>
</>
);
return {
pendingAgents,
actionErrors,
linkVersion,
selectUp: (row) => void declareState(row, "up"),
selectOffline: (row) => setConfirmTarget({ row, state: "offline" }),
selectPaused: (row) => setConfirmTarget({ row, state: "paused" }),
requestDestroy: setDestroyTarget,
openLink: (row, kind) => {
if (kind === "matrix") setMatrixTarget(row);
else if (kind === "forge") setForgeTarget(row);
else setGithubTarget(row);
},
dialogs,
};
}

View file

@ -22,8 +22,7 @@
white-space: normal;
word-break: break-word;
}
/* Icon beside the name/message, sized and dimmed like the hive
dashboard's `.container-icon` (dashboard.css). */
/* Icon (`AgentIcon`) beside the name/message. */
.ui-agent-card-row {
display: flex;
align-items: flex-start;
@ -36,23 +35,3 @@
flex: 1;
min-width: 0;
}
.ui-agent-card-icon {
position: relative;
overflow: hidden;
flex: none;
width: 5em;
aspect-ratio: 1;
border-radius: 6px;
background-color: color-mix(in srgb, var(--crust) 60%, transparent);
}
.ui-agent-card-icon > img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.ui-agent-card-icon-none {
filter: grayscale(1);
opacity: 0.4;
}

View file

@ -12,12 +12,12 @@
// `WantedMenu` below keeps its own clicks (mouse or keyboard-synthesized,
// `WantedMenu` hosts real `<button>`s) from also firing `Card`'s
// `onClick` and opening the detail panel.
import { useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js";
import type { ProblemDetails } from "@hive/shared/api-error.js";
import { Card } from "../../ui/card/Card.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { AgentIcon } from "../AgentIcon.js";
import { FRESHNESS, type AgentRow } from "../AgentTypes.js";
import { WantedMenu } from "./WantedMenu.js";
import "./AgentCard.css";
@ -100,31 +100,3 @@ export function AgentCard({
</Card>
);
}
// The agent's icon from swarm-controller's `GET /api/agents/{name}/icon`,
// the same square and fallback as the hive dashboard's container row: an
// agent with no icon (404) or an unreachable controller shows the dimmed
// hyperhive mark instead of a broken image. Always an `<img>`: the body is
// an agent-authored SVG, and only an image load keeps its script from running.
function AgentIcon({ name }: { name: string }) {
const [fallback, setFallback] = useState(false);
return (
<div
class={
fallback
? "ui-agent-card-icon ui-agent-card-icon-none"
: "ui-agent-card-icon"
}
>
<img
alt=""
src={
fallback
? "/favicon.svg"
: `/api/agents/${encodeURIComponent(name)}/icon`
}
onError={() => setFallback(true)}
/>
</div>
);
}

View file

@ -20,7 +20,7 @@
import { useRef, useState } from "preact/hooks";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import type { AgentRow } from "./AgentTypes.js";
import type { AgentRow } from "../AgentTypes.js";
export function WantedMenu({
row,

View file

@ -37,6 +37,7 @@
// the other one is. Suppressed via `fullHeight` — the full-page mount
// already IS the destination, so it has nothing further to expand to.
import { useLayoutEffect, useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { ExpandIcon } from "@hive/shared/icons.js";
import { Row } from "@hive/shared/term-row.js";
@ -189,6 +190,7 @@ export function AgentTermPreview({
subagent,
showHeaderBadges = true,
fullHeight = false,
leading,
}: {
agentName: string;
// Set: this is one of `agentName`'s subagents, output only like the
@ -196,6 +198,9 @@ export function AgentTermPreview({
subagent?: string;
showHeaderBadges?: boolean;
fullHeight?: boolean;
// A badge ahead of the connection badge, for the full-tab mount's
// link back to the agent's details.
leading?: ComponentChildren;
}) {
const paths = termPaths(agentName, subagent);
const { rows, connection } = useSwarmTermStream(paths.stream);
@ -236,6 +241,7 @@ export function AgentTermPreview({
it has no expand trigger. */}
<div class="ui-agent-term-preview-controls">
<div class="ui-agent-term-preview-header-badges">
{leading}
<Badge
tone={CONNECTION_TONE[connection]}
value={CONNECTION_LABEL[connection]}

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,16 @@
// 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 } from "preact/hooks";
import { useEffect, useRef, useState } from "preact/hooks";
import type { RouteComponentProps } from "wouter-preact";
import { AgentTermPreview, termPaths } from "./AgentTermPreview.js";
import { useAgentFavicon } from "../../shell/useAgentFavicon.js";
import { Badge } from "@hive/shared/badge.js";
import { AgentDetailPopover } from "../../agent/detail/AgentDetailPopover.js";
import { AgentIcon } from "../../agent/AgentIcon.js";
import {
AgentTermPreview,
termPaths,
} from "../../agent/terminal/AgentTermPreview.js";
import { useAgentFavicon } from "../../agent/terminal/useAgentFavicon.js";
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
import "./AgentTerminalPage.css";
@ -42,6 +48,13 @@ export function AgentTerminalPage({
// instead of once per terminal `page`.
}, [page]);
// 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">
<AgentTermPreview
@ -49,6 +62,23 @@ export function AgentTerminalPage({
subagent={subagent}
fullHeight
showHeaderBadges
leading={
<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>
}
/>
<AgentDetailPopover
agent={name}
open={detailOpen}
onClose={() => setDetailOpen(false)}
anchorRef={badgeRef}
/>
</div>
);

View file

@ -36,23 +36,3 @@
.ui-agents-view-toggle button:not(.active):hover {
color: var(--fg);
}
/* Detail panel body — its own `Panel`, title supplied by the panel's
`title` prop (the agent's name), not repeated in here. */
.ui-agent-detail-fields {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.4em 1em;
margin: 0 0 1em;
}
.ui-agent-detail-fields dt {
color: var(--muted);
}
.ui-agent-detail-fields dd {
margin: 0;
}
.ui-agent-detail-actions {
display: flex;
align-items: center;
gap: 0.75em;
}

View file

@ -18,27 +18,24 @@
// natural home for the action that populates it. The form itself
// (`CreateAgentForm`) mounts inside a `Dialog` here, not its own route.
//
// This file owns the page's own state/actions/`columns` only —
// `AgentRow` and friends live in `AgentTypes.ts`, the wanted-state
// control in `WantedMenu.tsx`, the card content in `AgentCard.tsx`
// (mara: "agentspage is now giant and deserves a split"). Filtering +
// cards-vs-table rendering is `FilterableView` (`ui/filterable-view/`,
// see its own comment) — `viewMode` here just says which one to show.
// This file owns the page's own state/`columns` only — `AgentRow` and
// friends live in `AgentTypes.ts`, the wanted-state control in
// `WantedMenu.tsx`, the actions on an agent (declare a state, link an
// account) in `AgentActions.tsx`, the card content in `AgentCard.tsx` and
// the detail panel's body in `AgentDetail.tsx` (mara: "agentspage is now
// giant and deserves a split"). Filtering + cards-vs-table rendering is
// `FilterableView` (`ui/filterable-view/`, see its own comment) — `viewMode`
// here just says which one to show.
import { useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { LinkIcon } from "@hive/shared/icons.js";
import { AgentCard } from "./AgentCard.js";
import { AgentTermPreview } from "./AgentTermPreview.js";
import { SubagentTerms } from "./SubagentTerms.js";
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
import { AgentInference } from "./AgentInference.js";
import { LinkedAccounts } from "./LinkedAccounts.js";
import { LinkAccountMenu } from "./LinkAccountMenu.js";
import { useAgentActions } from "../../agent/roster/AgentActions.js";
import { AgentCard } from "../../agent/roster/AgentCard.js";
import { AgentDetail } from "../../agent/detail/AgentDetail.js";
import { FRESHNESS, type AgentRow } from "../../agent/AgentTypes.js";
import { Button } from "../../ui/button/Button.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
import { Panel } from "../../ui/panel/Panel.js";
@ -51,46 +48,9 @@ import {
import { SplitView } from "../../ui/split-view/SplitView.js";
import { type TableColumn } from "../../ui/table/Table.js";
import { CreateAgentForm } from "../CreateAgentForm.js";
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js";
import { LinkGithubAccountForm } from "../LinkGithubAccountForm.js";
import { WantedMenu } from "./WantedMenu.js";
import { WantedMenu } from "../../agent/roster/WantedMenu.js";
import "./AgentsPage.css";
// The one thing that differs between the "offline" and "paused" confirm
// dialogs `confirmTarget` drives — everything else (button row, open/close
// wiring) is the shared `ConfirmDialog`.
const CONFIRM_COPY: Record<
"offline" | "paused",
{
label: string;
confirmLabel: string;
message: (agentName: string) => ComponentChildren;
}
> = {
offline: {
label: "stop agent",
confirmLabel: "stop",
message: (agentName) => (
<p>
Declare <strong>{agentName}</strong> offline? The hive brings its
container down on its next reconcile sweep.
</p>
),
},
paused: {
label: "pause agent",
confirmLabel: "pause",
message: (agentName) => (
<p>
Declare <strong>{agentName}</strong> paused? Its container stays up —
the web UI and MCP daemons keep running — but its turn loop parks on the
hive's next reconcile sweep. Select "up" again to resume it.
</p>
),
},
};
// Same 30s default + same reasoning as HivesPage: no inputs on this page
// for a refresh to clobber, so the out-of-the-box behaviour should just
// solve staleness rather than require an opt-in every visit.
@ -103,8 +63,6 @@ const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
export function AgentsPage() {
const [rows, setRows] = useState<AgentRow[] | null>(null);
// The row showing the "link a github account" dialog, same shape.
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
@ -128,56 +86,24 @@ export function AgentsPage() {
}
}
const [createOpen, setCreateOpen] = useState(false);
// Per-agent, not one page-wide flag: one row's declare-in-flight
// shouldn't disable every other row's button.
const [pendingAgents, setPendingAgents] = useState<ReadonlySet<string>>(
new Set(),
);
const [actionErrors, setActionErrors] = useState<
ReadonlyMap<string, ProblemDetails>
>(new Map());
// The row pending destroy confirmation, `null` when the dialog is
// closed — not a boolean, so the dialog can name the agent without a
// second piece of state to keep in sync with it. Kept apart from
// `confirmTarget` below rather than folded into it as a third state:
// destroy is categorically different (irreversible, longer warning,
// danger-styled), not one more case of the same "confirm before parking
// a running agent" shape "offline"/"paused" share.
const [destroyTarget, setDestroyTarget] = useState<AgentRow | null>(null);
// "offline" and "paused" are the *same* confirm shape — same button
// treatment, same "disruptive to a running turn loop, unlike up" reason
// for needing a confirm at all — so one union state + one `ConfirmDialog`
// covers both instead of two near-identical copies (argus, PR review: "a
// shared confirm-dialog-body-generator would drop real line count").
// `CONFIRM_COPY` below supplies the one thing that differs.
const [confirmTarget, setConfirmTarget] = useState<{
row: AgentRow;
state: "offline" | "paused";
} | null>(null);
// The row currently showing the "link a matrix account" dialog — same
// null-means-closed shape as `destroyTarget`/`stopTarget`, own piece of
// state rather than folded into either since this dialog isn't a
// confirmation of a `declareState` call, it's an unrelated action with
// its own form (`LinkMatrixAccountForm`).
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
// The row showing the "link a forge account" dialog, same shape.
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
// Bumped whenever a link dialog closes; the panel's `LinkedAccounts` refetches
// on a change, so a newly linked account shows without a reload.
const [linkVersion, setLinkVersion] = useState(0);
const linkClosed = () => setLinkVersion((v) => v + 1);
const closeMatrix = () => {
setMatrixTarget(null);
linkClosed();
};
const closeForge = () => {
setForgeTarget(null);
linkClosed();
};
const closeGithub = () => {
setGithubTarget(null);
linkClosed();
};
// The PUT response is the hive's whole declaration set (every agent's
// `wanted` state) — same shape `GET /api/hives/{hive}/wanted` returns — so
// patch just that field into the rows already on screen rather than firing
// a second, full `/api/agents/status` round-trip merely to learn one
// hive's `wanted` column changed. Everything else on the grid (freshness,
// snapshot, config PR) still catches up on the next interval-driven
// `refresh()`.
const actions = useAgentActions((row, declared) => {
setRows((prev) => {
if (!prev) return prev;
const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
return prev.map((r) =>
r.hive === row.hive && byAgent.has(r.name)
? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
: r,
);
});
});
// Which agent the detail panel shows, *by name* — not the `AgentRow`
// object itself. Storing the row would snapshot it at selection time;
// `rows` replaces its whole array on every `refresh()` and every
@ -207,99 +133,6 @@ export function AgentsPage() {
refresh().catch((e: unknown) => setError({ detail: String(e) }));
});
// The one PUT every `WantedMenu` selection ends at, whether or not it
// went through a `ConfirmDialog` first (`confirmStop`/`destroyAgent`
// for "offline"/"destroy"; called directly for "up", which needs no
// confirmation): "PUT a new `wanted` declaration, track per-agent
// pending/error state, patch the response back into `rows`" is the
// same shape regardless of target state or how the caller got here.
async function declareState(row: AgentRow, target: string) {
if (!row.hive) return;
setPendingAgents((prev) => new Set(prev).add(row.name));
setActionErrors((prev) => {
const next = new Map(prev);
next.delete(row.name);
return next;
});
try {
const res = await fetch(
`/api/hives/${encodeURIComponent(row.hive)}/agents/${encodeURIComponent(row.name)}/state`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ state: target }),
},
);
if (!res.ok) {
const problem = await readApiError(res);
setActionErrors((prev) => new Map(prev).set(row.name, problem));
return;
}
// The PUT response is the hive's whole declaration set (every
// agent's `wanted` state) — same shape `GET /api/hives/{hive}/wanted`
// returns — so patch just that field into the rows already on
// screen rather than firing a second, full `/api/agents/status`
// round-trip merely to learn one hive's `wanted` column changed.
// Everything else on the grid (freshness, snapshot, config PR)
// still catches up on the next interval-driven `refresh()`.
const declared = (await res.json()) as { agent: string; state: string }[];
setRows((prev) => {
if (!prev) return prev;
const byAgent = new Map(declared.map((d) => [d.agent, d.state]));
return prev.map((r) =>
r.hive === row.hive && byAgent.has(r.name)
? { ...r, wanted: byAgent.get(r.name) ?? r.wanted }
: r,
);
});
} catch (e: unknown) {
setActionErrors((prev) =>
new Map(prev).set(row.name, { detail: String(e) }),
);
} finally {
setPendingAgents((prev) => {
const next = new Set(prev);
next.delete(row.name);
return next;
});
}
}
// Fires from the `confirmTarget` confirm `Dialog` (covers both "offline"
// and "paused" — see that state's own comment). Used to be a native
// `window.confirm` for the "offline" case (stop is reversible, a later
// start un-does it, so a lighter-weight prompt than destroy's felt
// proportionate) — mara, reviewing the destroy confirm: use the shared
// component everywhere we already confirm before acting, not just for
// destroy. Once `ConfirmDialog` existed as a one-line-per-caller
// component, the "native is lighter" argument no longer bought
// consistency anything.
async function confirmDeclare() {
if (!confirmTarget) return;
const { row, state } = confirmTarget;
setConfirmTarget(null);
await declareState(row, state);
}
// Fires from the `destroyTarget` confirm `Dialog`, never directly off
// a row click — see `destroyTarget`/`WantedMenu`'s "destroy" option.
// Unlike the "up"/"offline" directions, there's no "current state" to
// read: destroy is a one-way declaration, not a flip.
async function destroyAgent(row: AgentRow) {
setDestroyTarget(null);
await declareState(row, "destroyed");
}
// The three non-destroy `WantedMenu` selections, factored out once
// rather than repeated per call site — the card, the table's "wanted"
// column, and the detail panel's own `WantedMenu` (below) all wire the
// same three.
const selectUp = (row: AgentRow) => void declareState(row, "up");
const selectOffline = (row: AgentRow) =>
setConfirmTarget({ row, state: "offline" });
const selectPaused = (row: AgentRow) =>
setConfirmTarget({ row, state: "paused" });
// `FilterableView`'s `columns` — the full field set (including
// per-column sort/filter, which drives both the table's own popovers
// and the card view's filter bar now — see that component's own
@ -367,17 +200,17 @@ export function AgentsPage() {
filterValues: (a) => [a.wanted ?? "no declaration"],
filterMode: "multiselect",
render: (a) => {
const err = actionErrors.get(a.name);
const err = actions.actionErrors.get(a.name);
return (
<>
<WantedMenu
row={a}
pending={pendingAgents.has(a.name)}
pending={actions.pendingAgents.has(a.name)}
portal
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onDestroy={setDestroyTarget}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
/>
{err ? (
<Badge
@ -398,7 +231,7 @@ export function AgentsPage() {
variant="quiet"
icon={<LinkIcon />}
value="link account"
onClick={a.hive ? () => setMatrixTarget(a) : undefined}
onClick={a.hive ? () => actions.openLink(a, "matrix") : undefined}
disabled={!a.hive}
title={
a.hive
@ -493,12 +326,12 @@ export function AgentsPage() {
renderCard={(a) => (
<AgentCard
row={a}
pending={pendingAgents.has(a.name)}
error={actionErrors.get(a.name)}
pending={actions.pendingAgents.has(a.name)}
error={actions.actionErrors.get(a.name)}
selected={detailTargetName === a.name}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onOpenDetail={(row) => setDetailTargetName(row.name)}
/>
)}
@ -520,114 +353,7 @@ export function AgentsPage() {
icon="🔎"
>
{detailTarget ? (
<div class="ui-agent-detail">
<dl class="ui-agent-detail-fields">
<dt>status</dt>
<dd>
{(() => {
const { tone, label } = FRESHNESS[detailTarget.freshness];
return (
<Badge
tone={tone}
value={
<>
{label}
{detailTarget.last_seen_unix !== null ? (
<>
{" "}
(
<RelativeTime
epochMs={detailTarget.last_seen_unix * 1000}
/>
)
</>
) : null}
</>
}
/>
);
})()}
</dd>
<dt>message</dt>
<dd>{detailTarget.snapshot?.status_text ?? "—"}</dd>
<dt>wanted</dt>
<dd>
{/* Full menu (destroy included) — mara: "destroy is
already available via wanted state", no separate
button needed. */}
<WantedMenu
row={detailTarget}
pending={pendingAgents.has(detailTarget.name)}
onSelectUp={selectUp}
onSelectOffline={selectOffline}
onSelectPaused={selectPaused}
onDestroy={setDestroyTarget}
/>
</dd>
<dt>hive</dt>
<dd>{detailTarget.hive ?? "—"}</dd>
<dt>config PR</dt>
<dd>
{detailTarget.config_pr ? (
<Badge
tone="warning"
value={
detailTarget.config_pr.html_url ? (
<a
href={detailTarget.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
#{detailTarget.config_pr.pr_number}
</a>
) : (
`#${detailTarget.config_pr.pr_number}`
)
}
/>
) : (
"—"
)}
</dd>
<dt>inference</dt>
<dd>
<AgentInference
key={detailTarget.name}
agent={detailTarget.name}
/>
</dd>
<dt>accounts</dt>
<dd>
{detailTarget.hive ? (
<LinkedAccounts
key={`${detailTarget.hive}/${detailTarget.name}`}
hive={detailTarget.hive}
agent={detailTarget.name}
version={linkVersion}
/>
) : (
"—"
)}
</dd>
</dl>
<div class="ui-agent-detail-actions">
<LinkAccountMenu
agent={detailTarget.name}
hasHive={detailTarget.hive !== null}
onSelect={(kind) => {
if (kind === "matrix") setMatrixTarget(detailTarget);
else if (kind === "forge") setForgeTarget(detailTarget);
else setGithubTarget(detailTarget);
}}
/>
</div>
{/* The agent's live terminal and its input box. */}
<AgentTermPreview agentName={detailTarget.name} />
<SubagentTerms
key={detailTarget.name}
agentName={detailTarget.name}
/>
</div>
<AgentDetail row={detailTarget} actions={actions} withTerminal />
) : (
<p class="ui-agents-empty">select an agent to see its details</p>
)}
@ -642,83 +368,7 @@ export function AgentsPage() {
>
<CreateAgentForm onClose={() => setCreateOpen(false)} />
</Dialog>
<Dialog
open={matrixTarget !== null}
onClose={closeMatrix}
label="link a matrix account"
plain
>
{/* `matrixTarget.hive` is non-null here — the trigger badge above
is disabled without one, so this can only open with a real
hive to PUT against. */}
{matrixTarget?.hive ? (
<LinkMatrixAccountForm
hive={matrixTarget.hive}
agent={matrixTarget.name}
onClose={closeMatrix}
/>
) : null}
</Dialog>
<Dialog
open={forgeTarget !== null}
onClose={closeForge}
label="link a forge account"
plain
>
{forgeTarget?.hive ? (
<LinkForgeAccountForm
hive={forgeTarget.hive}
agent={forgeTarget.name}
onClose={closeForge}
/>
) : null}
</Dialog>
<Dialog
open={githubTarget !== null}
onClose={closeGithub}
label="link a github account"
plain
>
{githubTarget?.hive ? (
<LinkGithubAccountForm
hive={githubTarget.hive}
agent={githubTarget.name}
onClose={closeGithub}
/>
) : null}
</Dialog>
<ConfirmDialog
open={confirmTarget !== null}
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
onCancel={() => setConfirmTarget(null)}
onConfirm={() => void confirmDeclare()}
confirmLabel={
confirmTarget
? CONFIRM_COPY[confirmTarget.state].confirmLabel
: undefined
}
>
{confirmTarget
? CONFIRM_COPY[confirmTarget.state].message(confirmTarget.row.name)
: null}
</ConfirmDialog>
<ConfirmDialog
open={destroyTarget !== null}
label="destroy agent"
onCancel={() => setDestroyTarget(null)}
onConfirm={() => destroyTarget && void destroyAgent(destroyTarget)}
confirmLabel="destroy"
>
{destroyTarget ? (
<p>
Destroy <strong>{destroyTarget.name}</strong>? The hive tears its
container down on its next reconcile sweep. This is not reversible
from here — bringing it back means redeploying via "+ agent", which
reuses the agent's existing identity, config repo, and forge
collaborator access rather than starting over.
</p>
) : null}
</ConfirmDialog>
{actions.dialogs}
</>
);
}

View file

@ -22,7 +22,7 @@ import {
type RefreshIntervalMs,
} from "../../ui/refresh-interval/RefreshInterval.js";
import type { TableColumn } from "../../ui/table/Table.js";
import type { AgentRow } from "../agents/AgentTypes.js";
import type { AgentRow } from "../../agent/AgentTypes.js";
import { ScheduleCard } from "./ScheduleCard.js";
import { ScheduleForm } from "./ScheduleForm.js";
import { scheduleState, type Schedule } from "./ScheduleTypes.js";

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,118 @@
// <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;
const top = r.bottom + GAP_PX;
const left = Math.max(EDGE_PX, Math.min(r.left, maxLeft));
// Scrolling anywhere (the streaming terminal included) calls this; keep
// the old object when nothing moved so the subtree does not re-render.
setPos((prev) =>
prev && prev.top === top && prev.left === left ? prev : { top, left },
);
}
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,
);
}