Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: restructure the agent card into status, terminal, subagents and settings, with badge controls for internet, permissions and allowed domains

This commit is contained in:
iris 2026-11-03 23:47:44 +01:00 • committed by mara
commit 5e23734667
9 changed files with 437 additions and 302 deletions

View file

@ -17,7 +17,7 @@ import type { AgentActions } from "../roster/AgentActions.js";
import { AgentInference } from "./AgentInference.js";
import { AgentInstructions } from "./AgentInstructions.js";
import { AgentLimits } from "./AgentLimits.js";
import { AgentPermissions } from "./AgentPermissions.js";
import { AgentInternet, AgentPermissions } from "./AgentPermissions.js";
import { AllowedDomains } from "./AllowedDomains.js";
import { AgentStateRow } from "./AgentStateRow.js";
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
@ -184,14 +184,18 @@ function SettingsRows({ agent }: { agent: string }) {
<dd>
<AgentLimits key={agent} agent={agent} />
</dd>
<dt>permissions</dt>
<dt>internet</dt>
<dd>
<AgentPermissions key={agent} agent={agent} />
<AgentInternet key={agent} agent={agent} />
</dd>
<dt>allowed domains</dt>
<dd>
<AllowedDomains key={agent} agent={agent} />
</dd>
<dt>permissions</dt>
<dd>
<AgentPermissions key={agent} agent={agent} />
</dd>
</>
);
}
@ -251,35 +255,27 @@ function SectionBody({
actions: AgentActions;
}) {
switch (section) {
case "overview":
case "status":
return (
<dl class="ui-agent-detail-fields">
<OverviewRows row={row} actions={actions} />
</dl>
);
case "settings":
return (
<dl class="ui-agent-detail-fields">
<SettingsRows agent={row.name} />
</dl>
);
case "accounts":
return (
<>
<dl class="ui-agent-detail-fields">
<SettingsRows agent={row.name} />
<AccountsRows row={row} actions={actions} />
</dl>
<LinkAccountAction row={row} actions={actions} />
</>
);
case "terminal":
return (
<>
{/* The agent's live terminal and its input box. */}
<AgentTermPreview agentName={row.name} />
<SubagentTerms key={row.name} agentName={row.name} />
</>
);
// The agent's live terminal and its input box.
return <AgentTermPreview agentName={row.name} />;
case "subagents":
return <SubagentTerms key={row.name} agentName={row.name} />;
}
}
@ -291,38 +287,30 @@ export function AgentDetail({
}: {
row: AgentRow;
actions: AgentActions;
/** Add the live terminal and subagents: for the flat layout, below the rows. */
/** Add the live terminal and subagents sections. */
withTerminal?: boolean;
/** The folded sections of an opened card, without the rows its header shows. */
/**
* An opened card, whose header already shows the state rows. Without it
* (the terminal page's popover) those rows go above the same sections.
*/
inCard?: boolean;
}) {
if (inCard) {
return (
<div class="ui-agent-detail ui-agent-detail-sections">
{sectionsFor(withTerminal).map((section) => (
<LazyDisclosure
key={section}
title={SECTION_TITLE[section]}
startOpen={startsOpen(section)}
>
<SectionBody section={section} row={row} actions={actions} />
</LazyDisclosure>
))}
</div>
);
}
return (
<div class="ui-agent-detail">
<dl class="ui-agent-detail-fields">
<HeaderRows row={row} actions={actions} />
<OverviewRows row={row} actions={actions} />
<SettingsRows agent={row.name} />
<AccountsRows row={row} actions={actions} />
</dl>
<LinkAccountAction row={row} actions={actions} />
{withTerminal ? (
<SectionBody section="terminal" row={row} actions={actions} />
) : null}
<div class="ui-agent-detail ui-agent-detail-sections">
{inCard ? null : (
<dl class="ui-agent-detail-fields">
<HeaderRows row={row} actions={actions} />
</dl>
)}
{sectionsFor(withTerminal).map((section) => (
<LazyDisclosure
key={section}
title={SECTION_TITLE[section]}
startOpen={startsOpen(section)}
>
<SectionBody section={section} row={row} actions={actions} />
</LazyDisclosure>
))}
</div>
);
}

View file

@ -1,24 +1,27 @@
// <AgentPermissions> — one agent's permissions in an opened agent card: what the
// swarm grants it, and an editor for that.
// <AgentPermissions> and <AgentInternet> — one agent's grant in an opened agent
// card: the permissions it holds, and how far its traffic may leave the host.
// Each is a badge that opens a control under it, which saves as soon as it
// changes.
//
// Reads `GET /api/agents/{name}/permissions`, which answers the stored list as
// `{ permissions? }` (`{}` when the swarm sets none, so the agent runs on the
// default set). The editor offers the catalogue from `GET /api/permissions` as
// checkboxes and PUTs the ticked ones, a PUT replacing the stored value whole.
// A selection equal to the default set DELETEs instead, as the create form
// sends nothing for it: the agent then follows the default if that changes.
// The route's refusal shows as it arrives, next to the boxes.
// Both read `GET /api/agents/{name}/permissions`, which answers the stored
// grant as `{ permissions?, unrestricted_internet?, internet_log? }` (`{}` when
// the swarm sets none, so the agent runs on the default set). The permissions
// control offers the catalogue from `GET /api/permissions` as checkboxes and
// PUTs the ticked ones, a PUT replacing the stored value whole. A selection
// equal to the default set DELETEs instead, as the create form sends nothing
// for it: the agent then follows the default if that changes. The internet
// control PUTs the mode with the stored list beside it. The route's refusal
// shows as it arrives, in the control.
//
// Its state belongs to one agent: callers key it by agent name, so switching
// agents remounts it instead of showing the previous agent's grant.
// Their state belongs to one agent: callers key them by agent name, so
// switching agents remounts them instead of showing the previous agent's grant.
import { useEffect, useState } from "preact/hooks";
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 { BadgePopover } from "../../ui/badge-popover/BadgePopover.js";
import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { MultiselectFilterOptions } from "../../ui/multiselect-filter/MultiselectFilter.js";
import { Panel } from "../../ui/panel/Panel.js";
import {
requestPermissions,
togglePermission,
@ -46,28 +49,26 @@ interface Catalogue {
default: string[];
}
// The buttons for the modes an agent is not in: mode, label, tooltip.
// The modes of the internet control: mode, label, what it means.
const INTERNET_MODES: [InternetMode, string, string][] = [
[
"unrestricted",
"allow unrestricted",
"let its traffic leave the host unfiltered",
],
["restricted", "restricted", "allow only the listed names"],
["log", "log", "allow what restricted would deny, and log it as would_deny"],
["restricted", "restrict", "allow only the listed names"],
["unrestricted", "unrestricted", "let its traffic leave the host unfiltered"],
];
function permissionsUrl(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/permissions`;
}
export function AgentPermissions({ agent }: { agent: string }) {
// The stored grant of one agent, and what changes it.
function useGrant(agent: string) {
const [grant, setGrant] = useState<Grant | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
// Bumped after a save or a clear, so the grant is fetched again.
const [reload, setReload] = useState(0);
const [editing, setEditing] = useState(false);
const [flagBusy, setFlagBusy] = useState(false);
const [busy, setBusy] = useState(false);
// The refusal of the last save, kept apart from a failed read.
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
useEffect(() => {
let cancelled = false;
@ -89,165 +90,94 @@ export function AgentPermissions({ agent }: { agent: string }) {
};
}, [agent, reload]);
// Sets the internet mode, sending the stored list with it.
async function setInternet(g: Grant, mode: InternetMode) {
if (flagBusy) return;
setFlagBusy(true);
// Sends one change and reads the grant again; true when the route took it.
async function send(init: RequestInit): Promise<boolean> {
setBusy(true);
setSaveError(null);
try {
const r = await fetch(permissionsUrl(agent), {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(internetBody(g.permissions, mode)),
});
const r = await fetch(permissionsUrl(agent), init);
if (!r.ok) {
setError(await readApiError(r));
return;
setSaveError(await readApiError(r));
return false;
}
setReload((n) => n + 1);
return true;
} catch (e: unknown) {
setError({ detail: String(e) });
setSaveError({ detail: String(e) });
return false;
} finally {
setFlagBusy(false);
setBusy(false);
}
}
// A grant already loaded stays under a failed refetch, so a save that
// worked is not hidden by a read that did not.
const errorPanel = error ? (
const readFailure = error ? (
<>
<ApiErrorPanel context="reading the permissions failed" problem={error} />
<Button onClick={() => setReload((n) => n + 1)}>retry</Button>
</>
) : null;
if (grant === null) {
return (
errorPanel ?? (
<span class="ui-agent-limits-muted" role="status">
loading the permissions…
</span>
)
);
}
return (
<div class="ui-agent-limits">
{errorPanel}
<Badge label="permissions" value={grantSummary(grant.permissions)} />
{grant.permissions && grant.permissions.length > 0 ? (
<ul class="ui-agent-limits-list">
{grant.permissions.map((p) => (
<li key={p}>
<Badge value={p} />
</li>
))}
</ul>
) : null}
<Badge
label="internet"
value={internetSummary(grant.unrestricted_internet, grant.internet_log)}
/>
{INTERNET_MODES.filter(
([mode]) =>
mode !==
internetMode(grant.unrestricted_internet, grant.internet_log),
).map(([mode, label, title]) => (
<Badge
key={mode}
variant="quiet"
value={label}
title={`${title} for ${agent}`}
onClick={() => void setInternet(grant, mode)}
/>
))}
<Badge
variant="quiet"
value="edit"
title={`set the permissions the swarm grants ${agent}`}
onClick={() => setEditing(true)}
/>
<Dialog
open={editing}
onClose={() => setEditing(false)}
label="edit permissions"
plain
>
{/* Remounted per open, so a cancelled edit leaves nothing behind. */}
{editing ? (
<PermissionsForm
agent={agent}
grant={grant}
onClose={() => setEditing(false)}
onSaved={() => {
setEditing(false);
setReload((n) => n + 1);
}}
/>
) : null}
</Dialog>
</div>
);
const loading =
grant === null
? (readFailure ?? (
<span class="ui-agent-limits-muted" role="status">
loading the permissions…
</span>
))
: null;
return { grant, busy, saveError, readFailure, loading, send };
}
function PermissionsForm({
agent,
grant,
onClose,
onSaved,
}: {
agent: string;
grant: Grant;
onClose: () => void;
onSaved: () => void;
}) {
export function AgentPermissions({ agent }: { agent: string }) {
const { grant, busy, saveError, readFailure, loading, send } =
useGrant(agent);
const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
const [selected, setSelected] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<ProblemDetails | null>(null);
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
null,
);
// The boxes as ticked; they follow the stored grant once it is read again.
const [selected, setSelected] = useState<string[] | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch("/api/permissions");
if (!r.ok) {
if (!cancelled) setProblem(await readApiError(r));
if (!cancelled) setCatalogueError(await readApiError(r));
return;
}
const data = (await r.json()) as Catalogue;
if (cancelled) return;
setCatalogue(data);
setSelected(editorSelection(grant.permissions, data.all, data.default));
if (!cancelled) setCatalogue(data);
})().catch((e: unknown) => {
if (!cancelled) setProblem({ detail: String(e) });
if (!cancelled) setCatalogueError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [grant]);
}, []);
async function send(init: RequestInit) {
setBusy(true);
setProblem(null);
try {
const r = await fetch(permissionsUrl(agent), init);
if (!r.ok) {
// The boxes stay as ticked, so the operator can fix it and save again.
setProblem(await readApiError(r));
return;
}
onSaved();
} catch (e: unknown) {
setProblem({ detail: String(e) });
} finally {
setBusy(false);
}
}
// The stored grant changing (a save went through) resets the boxes to it.
const stored = grant?.permissions;
useEffect(() => {
setSelected(null);
}, [stored]);
function save(e: Event) {
e.preventDefault();
if (grant === null) return loading;
const ticked =
selected ??
(catalogue
? editorSelection(stored, catalogue.all, catalogue.default)
: []);
async function toggle(value: string) {
if (!catalogue) return;
const next = togglePermission(ticked, value);
setSelected(next);
// The default set sends no list, so the agent follows the default.
const list = requestPermissions(selected, catalogue.default);
void send(
const list = requestPermissions(next, catalogue.default);
const ok = await send(
list === undefined
? { method: "DELETE" }
: {
@ -256,53 +186,110 @@ function PermissionsForm({
body: JSON.stringify({ permissions: list }),
},
);
// A refused change puts the boxes back as stored.
if (!ok) setSelected(null);
}
return (
<Panel title={`permissions — ${agent}`} icon="🔑" onClose={onClose}>
<p>
What the swarm lets {agent} do. A tick grants that permission; leaving
the default set ticked keeps {agent} on the default if it changes. The
hive reads a change within about 30 seconds, and the agent takes it at
its next turn. Changing <code>manage_root_agent</code> restarts it after
its current turn.
</p>
<form class="ui-agent-limits-form" onSubmit={save}>
<div role="group" aria-label="permissions">
{catalogue ? (
<div class="ui-agent-limits">
{readFailure}
<BadgePopover
label="permissions"
value={grantSummary(grant.permissions)}
title={`see and change what the swarm lets ${agent} do`}
popoverLabel={`${agent} permissions`}
>
<p class="ui-badge-popover-help">
What the swarm lets {agent} do. A tick grants it; ticking the default
set keeps {agent} on the default if that changes. The hive reads a
change within about 30 seconds and the agent takes it at its next
turn. Changing <code>manage_root_agent</code> restarts it after its
current turn.
</p>
{catalogue ? (
<div role="group" aria-label="permissions">
<MultiselectFilterOptions
options={catalogue.all}
filter={{ values: selected, negate: false }}
onToggle={(v) => setSelected((s) => togglePermission(s, v))}
filter={{ values: ticked, negate: false }}
onToggle={(v) => void toggle(v)}
/>
) : (
<span class="ui-agent-limits-muted" role="status">
{problem ? "could not load the permissions" : "loading…"}
</span>
)}
</div>
{problem ? (
</div>
) : (
<span class="ui-agent-limits-muted" role="status">
{catalogueError ? "could not load the permissions" : "loading…"}
</span>
)}
{saveError ? (
<ApiErrorPanel
context="the permissions were not saved"
problem={problem}
problem={saveError}
/>
) : null}
<div class="ui-agent-limits-actions">
<Button
variant="primary"
type="submit"
disabled={busy || catalogue === null}
>
{busy ? "saving…" : "save"}
</Button>
<Button
disabled={busy || grant.permissions === undefined}
onClick={() => void send({ method: "DELETE" })}
>
use the default
</Button>
</div>
</form>
</Panel>
<Button
disabled={busy || grant.permissions === undefined}
onClick={() => void send({ method: "DELETE" })}
>
use the default
</Button>
</BadgePopover>
</div>
);
}
export function AgentInternet({ agent }: { agent: string }) {
const { grant, busy, saveError, readFailure, loading, send } =
useGrant(agent);
if (grant === null) return loading;
const mode = internetMode(grant.unrestricted_internet, grant.internet_log);
return (
<div class="ui-agent-limits">
{readFailure}
<BadgePopover
label="internet"
value={internetSummary(grant.unrestricted_internet, grant.internet_log)}
tone={mode === "restricted" ? "neutral" : "warning"}
title={`how far ${agent}'s traffic may leave the host`}
popoverLabel={`${agent} internet access`}
>
<p class="ui-badge-popover-help">
How far {agent}'s traffic may leave the host. Restricted allows only
the names listed under allowed domains; the hive reads a change within
about 30 seconds.
</p>
<ul class="ui-badge-popover-list">
{INTERNET_MODES.map(([m, label, meaning]) => (
<li key={m}>
<Badge
variant={m === mode ? "default" : "quiet"}
tone={m === mode ? "accent" : "neutral"}
value={label}
title={`${meaning} for ${agent}`}
disabled={busy}
onClick={
m === mode
? undefined
: () =>
void send({
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(
internetBody(grant.permissions, m),
),
})
}
/>{" "}
<span class="ui-agent-limits-muted">{meaning}</span>
</li>
))}
</ul>
{saveError ? (
<ApiErrorPanel
context="the internet mode was not saved"
problem={saveError}
/>
) : null}
</BadgePopover>
</div>
);
}

View file

@ -24,6 +24,7 @@ import { useEffect, useState } from "preact/hooks";
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 { BadgePopover } from "../../ui/badge-popover/BadgePopover.js";
import { Button } from "../../ui/button/Button.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
@ -31,10 +32,11 @@ import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import {
ALLOWED_DOMAINS_URL,
allowedSummary,
entriesFor,
entryBody,
entryGroups,
type AllowedDomain,
type EntryGroup,
} from "./allowed-domains.js";
import "./AgentLimits.css";
@ -42,6 +44,24 @@ interface Listing {
entries: AllowedDomain[];
}
// What each source's badge says, and what its list holds.
const GROUP_LABEL: Record<EntryGroup["source"], string> = {
"swarm-wide": "swarm-wide",
own: "this agent",
automatic: "automatic",
};
function groupHelp(source: EntryGroup["source"], agent: string): string {
switch (source) {
case "swarm-wide":
return "Names every agent in the swarm may reach through its hive's proxy.";
case "own":
return `Names only ${agent} may reach through its hive's proxy. Remove one to stop allowing it.`;
case "automatic":
return `Kept from ${agent}'s backend environment or a linked account; change or unlink that to change them.`;
}
}
export function AllowedDomains({ agent }: { agent: string }) {
const [entries, setEntries] = useState<AllowedDomain[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
@ -116,40 +136,38 @@ export function AllowedDomains({ agent }: { agent: string }) {
return (
<div class="ui-agent-limits">
{errorPanel}
<Badge label="allowed domains" value={allowedSummary(shown)} />
{shown.length > 0 ? (
<ul class="ui-agent-limits-list">
{shown.map((e) => (
<li key={`${e.agent ?? "*"}/${e.domain}/${e.derived === true}`}>
<Badge
label={
e.derived === true
? "automatic"
: e.agent === undefined
? "swarm-wide"
: agent
}
value={e.domain}
title={
e.derived === true
? "kept from this agent's backend environment or a linked account; change or unlink that to change it"
: undefined
}
/>{" "}
{e.derived === true ? null : (
<Badge
variant="quiet"
value="remove"
title={`stop allowing ${e.domain} for ${
e.agent === undefined ? "every agent" : agent
}`}
onClick={() => void remove(e)}
/>
)}
</li>
))}
</ul>
{shown.length === 0 ? (
<Badge label="allowed domains" value="none added" />
) : null}
{entryGroups(shown).map((g) => (
<BadgePopover
key={g.source}
label={GROUP_LABEL[g.source]}
value={g.entries.length}
title={groupHelp(g.source, agent)}
popoverLabel={`${agent} allowed domains, ${GROUP_LABEL[g.source]}`}
>
<p class="ui-badge-popover-help">{groupHelp(g.source, agent)}</p>
<ul class="ui-badge-popover-list">
{g.entries.map((e) => (
<li key={`${e.agent ?? "*"}/${e.domain}`}>
<Badge value={e.domain} />{" "}
{e.derived === true ? null : (
<Badge
variant="quiet"
value="remove"
title={`stop allowing ${e.domain} for ${
e.agent === undefined ? "every agent" : agent
}`}
disabled={busy}
onClick={() => void remove(e)}
/>
)}
</li>
))}
</ul>
</BadgePopover>
))}
<Badge
variant="quiet"
value="add"

View file

@ -44,6 +44,32 @@ export function allowedSummary(shown: AllowedDomain[]): string {
.join(", ");
}
/** The entries of one source, for the badge that counts them. */
export interface EntryGroup {
source: "swarm-wide" | "own" | "automatic";
entries: AllowedDomain[];
}
/**
* The shown entries by where they come from, in the order the badges go: the
* ones for every agent, the ones for this agent alone, then the automatic
* ones. A source with no entry has no group.
*/
export function entryGroups(shown: AllowedDomain[]): EntryGroup[] {
const groups: EntryGroup[] = [
{
source: "swarm-wide",
entries: shown.filter((e) => e.agent === undefined && !e.derived),
},
{
source: "own",
entries: shown.filter((e) => e.agent !== undefined && !e.derived),
},
{ source: "automatic", entries: shown.filter((e) => e.derived === true) },
];
return groups.filter((g) => g.entries.length > 0);
}
/**
* The body that adds or removes `domain`, as typed: the route lower-cases it
* and refuses what is not a name. `forSwarm` makes it hold for every agent;

View file

@ -1,28 +1,30 @@
// The sections an agent card opens into, without a DOM: which there are, in
// what order, and which start open. The card's header already shows the
// agent's status, message and wanted state, so the sections hold the rest.
// agent's state, message and wanted state, so the sections hold the rest.
/** One folded block of an opened agent card. */
export type DetailSection = "overview" | "settings" | "accounts" | "terminal";
export type DetailSection = "status" | "terminal" | "subagents" | "settings";
/** What each section's header says. */
export const SECTION_TITLE: Record<DetailSection, string> = {
overview: "overview",
settings: "settings",
accounts: "accounts",
status: "status",
terminal: "terminal",
subagents: "subagents",
settings: "settings",
};
/**
* The sections a card shows, top to bottom. The terminal is left out where
* the page is already beside one (the terminal page's details dialog).
* The sections a card shows, top to bottom. The terminal and subagents are
* left out where the page is already beside a terminal (the terminal page's
* details popover).
*/
export function sectionsFor(withTerminal: boolean): DetailSection[] {
const sections: DetailSection[] = ["overview", "settings", "accounts"];
return withTerminal ? [...sections, "terminal"] : sections;
return withTerminal
? ["status", "terminal", "subagents", "settings"]
: ["status", "settings"];
}
/** Only the overview is open when a card opens; the rest build on demand. */
/** The status and the terminal are open when a card opens; the rest build on demand. */
export function startsOpen(section: DetailSection): boolean {
return section === "overview";
return section === "status" || section === "terminal";
}

View file

@ -0,0 +1,26 @@
/* <BadgePopover> — the box a badge opens under itself. */
.ui-badge-popover {
display: inline-block;
}
.ui-badge-popover-body {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.4em;
padding: 0.5em 0.75em;
max-width: min(32em, 90vw);
max-height: 60vh;
overflow-y: auto;
}
.ui-badge-popover-help {
margin: 0;
color: var(--muted);
font-size: 0.9em;
}
.ui-badge-popover-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3em;
}

View file

@ -0,0 +1,57 @@
// <BadgePopover> — a badge that shows a value and, when clicked, opens a
// popover under it holding the control that changes the value: a list of
// checkboxes, a choice of modes, the entries behind a count. The control sits
// next to the thing it changes instead of in a dialog away from it.
//
// A click on the badge toggles the popover; a click outside it or Escape
// closes it (`Popover` does that).
import { useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Popover } from "../popover/Popover.js";
import "./BadgePopover.css";
export function BadgePopover({
label,
value,
tone,
title,
popoverLabel,
children,
}: {
/** Dim prefix of the badge, e.g. "permissions". */
label?: ComponentChildren;
/** What the badge says, e.g. "7 granted". */
value: ComponentChildren;
tone?: BadgeTone;
/** Tooltip on the badge: what clicking it does. */
title?: string;
/** `aria-label` of the floating box. */
popoverLabel: string;
children: ComponentChildren;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLSpanElement>(null);
return (
<>
<span class="ui-badge-popover" ref={anchorRef}>
<Badge
label={label}
value={value}
tone={tone}
title={title}
expanded={open}
onClick={() => setOpen((o) => !o)}
/>
</span>
<Popover
open={open}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
label={popoverLabel}
>
<div class="ui-badge-popover-body">{children}</div>
</Popover>
</>
);
}

View file

@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
allowedSummary,
entryGroups,
entriesFor,
entryBody,
} from "../src/agent/detail/allowed-domains.ts";
@ -62,3 +63,25 @@ test("a derived entry is counted as automatic, not as the operator's own", () =>
assert.equal(allowedSummary([derived]), "1 automatic");
assert.deepEqual(entriesFor([derived], "atlas"), [derived]);
});
test("entries are grouped by source, empty sources left out", () => {
const derived = { domain: "auto.io", agent: "atlas", derived: true };
const groups = entryGroups([
{ domain: "a.io" },
{ domain: "b.io", agent: "atlas" },
derived,
]);
assert.deepEqual(
groups.map((g) => [g.source, g.entries.length]),
[
["swarm-wide", 1],
["own", 1],
["automatic", 1],
],
);
assert.deepEqual(
entryGroups([{ domain: "a.io" }]).map((g) => g.source),
["swarm-wide"],
);
assert.deepEqual(entryGroups([]), []);
});

View file

@ -8,21 +8,24 @@ import {
startsOpen,
} from "../src/agent/detail/sections.ts";
test("a card holds overview, settings and accounts, then the terminal", () => {
test("a card holds status, terminal, subagents, then settings", () => {
assert.deepEqual(sectionsFor(true), [
"overview",
"settings",
"accounts",
"status",
"terminal",
"subagents",
"settings",
]);
});
test("a page beside a terminal leaves the terminal out", () => {
assert.deepEqual(sectionsFor(false), ["overview", "settings", "accounts"]);
test("a page beside a terminal leaves the terminal and subagents out", () => {
assert.deepEqual(sectionsFor(false), ["status", "settings"]);
});
test("only the overview starts open", () => {
assert.deepEqual(sectionsFor(true).filter(startsOpen), ["overview"]);
test("the status and the terminal start open", () => {
assert.deepEqual(sectionsFor(true).filter(startsOpen), [
"status",
"terminal",
]);
});
test("every section has a header", () => {