From 5e2373466755ff6e0fd78ed12955bd4ef8f5ba01 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 3 Nov 2026 23:47:44 +0100 Subject: [PATCH] swarm-ui: restructure the agent card into status, terminal, subagents and settings, with badge controls for internet, permissions and allowed domains --- docs/swarm/ui.md | 47 +-- .../swarm-ui/src/agent/detail/AgentDetail.tsx | 78 ++-- .../src/agent/detail/AgentPermissions.tsx | 353 +++++++++--------- .../src/agent/detail/AllowedDomains.tsx | 86 +++-- .../src/agent/detail/allowed-domains.ts | 26 ++ .../swarm-ui/src/agent/detail/sections.ts | 24 +- .../src/ui/badge-popover/BadgePopover.css | 26 ++ .../src/ui/badge-popover/BadgePopover.tsx | 57 +++ .../swarm-ui/test/allowed-domains.test.mjs | 23 ++ .../swarm-ui/test/detail-sections.test.mjs | 19 +- 10 files changed, 437 insertions(+), 302 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/ui/badge-popover/BadgePopover.css create mode 100644 frontend/packages/swarm-ui/src/ui/badge-popover/BadgePopover.tsx diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 9800402f..c390a092 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -26,7 +26,7 @@ smaller; short confirm dialogs stay short. | route | what | | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/` | the start page: the swarm's name over a digital-rain backdrop with the slug (`hyperhive / we are the wired`, its shaded gradients pulsing in a wave unless reduced motion is on) at the foot, plus a paperclip offering to set up an agent while no `ruth` exists, or her hive declares her destroyed (it opens the create form with `ruth` filled in); the swarm name in the header links here | -| `/agents` | every agent as a card that opens in place (status, config PR, wanted state, inference settings, resource caps, permissions, accounts, terminal), under a folded **hives** card with the hive directory and each hive's last reported status; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts | +| `/agents` | every agent as a card that opens in place (status, terminal, subagents, and settings with permissions, internet, allowed domains and accounts), under a folded **hives** card with the hive directory and each hive's last reported status; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts | | `/agents//terminal` | one agent's live terminal | | `/agents//subagents//terminal` | one of that agent's subagents' live terminal | | `/reports` | two folded cards, each read only once opened: the **alerts** the swarm's Grafana holds now, the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token, and a cross-repo **issue report** | @@ -70,18 +70,19 @@ filter card, which also holds the **+ agent** button and the refresh picker, filters by name, hive, status, message, wanted state and config PR. -An opened card has four sections, each folded except **overview**, and each -reads nothing until you open it: +An opened card has four sections, and each reads nothing until you open it. +**status** and **terminal** start open, the other two folded: -- **overview**: the agent's turn state, context and cost, its hive, links to its +- **status**: the agent's turn state, context and cost, its hive, links to its profile and its config repo on the swarm's forge (built from the forge entry of `GET /api/links`; the row stays hidden when the swarm lists none), and its open config PR. -- **settings**: inference, instructions, resource caps, permissions and allowed - domains, each with its own editor (described below). -- **accounts**: the agent's linked accounts, and the menu that links another. -- **terminal**: the live terminal with its message box, and the agent's - subagents under it (next section). +- **terminal**: the live terminal with its message box. +- **subagents**: the agent's subagents and their terminal preview (next section). +- **settings**: inference, instructions, resource caps, internet, allowed + domains and permissions, then the agent's linked accounts and the menu that + links another. Internet, allowed domains and permissions show a badge; select + it to open the control that changes it (described below). ### Agent and subagent terminals @@ -148,8 +149,8 @@ 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, state, wanted state, inference settings, resource caps, permissions, allowed domains and linked accounts as the -opened card on `/agents`, without the terminal preview. The popover doesn't dim the +the same status, state, wanted state and folded **status** and **settings** sections (inference, resource caps, internet, allowed domains, permissions and linked accounts) as the +opened card on `/agents`, without the terminal and subagents. The popover doesn't dim the page. Selecting outside it, or Escape, closes it. From the keyboard, opening a popover moves focus into it, and Escape puts focus back on the badge. The filter popovers on table headers work the same way. You can stop or pause the @@ -645,19 +646,22 @@ sets the hive's own limits. ### Permissions An opened agent card shows, under **settings**, what the swarm grants it under **permissions**: -the default set, none, or a count with one badge per permission. A swarm +a badge reading the default set, none, or a count. A swarm grant is the agent's whole set, so it follows the agent to whichever hive runs -it. **edit** opens a dialog with one checkbox per permission the controller +it. Selecting the badge opens a box under it with one checkbox per permission the controller knows (`GET /api/permissions`), ticked as the grant stands, or on the default set when the swarm sets none. -Saving does a `PUT /api/agents/{agent}/permissions` of the ticked boxes. A +Each tick saves at once, as a `PUT /api/agents/{agent}/permissions` of the ticked boxes. A `PUT` replaces the stored list whole, so an unticked box revokes that permission, and ticking nothing grants nothing. Ticking exactly the default set sends the `DELETE` instead, as the create form sends no list for it, so the agent follows the default if it changes. **use the default** sends the `DELETE` too. The -dialog shows a refusal's reason under the boxes and leaves them as you ticked -them. +box shows a refusal's reason under the boxes and puts them back as stored. + +The agent's **internet** access is its own row beside it: a badge reading +restricted, log or unrestricted. Selecting it lists the three modes; picking one does a +`PUT` of the mode with the stored permissions beside it. A saved change isn't instant. The hive reads the grant about every 30 seconds and the agent takes it at its next turn. A change to `manage_root_agent` @@ -670,14 +674,15 @@ same grant from a shell. An opened agent card lists, under **settings**, the destinations it may reach through its hive's egress proxy under **allowed domains**, beyond the base list every -agent has: the entries for every agent in the swarm (badge **swarm-wide**) and -the ones for this agent alone (badge with the agent's name). An entry matters +agent has: one badge per source with its count, **swarm-wide** (the entries for every +agent), **this agent** (the ones for it alone) and **automatic**. Selecting a badge +opens the names behind it. An entry matters to a restricted agent. One with the unrestricted internet flag reaches any -name the proxy's private-range rules leave. **remove** drops one entry; +name the proxy's private-range rules leave. **remove**, beside a name, drops one entry; **add** opens a dialog with a domain box and a tick for every agent in the swarm. The tick is off by default, so an entry is for this agent alone. -An entry with the badge **automatic** has no **remove**: the controller keeps +An entry under **automatic** has no **remove**: the controller keeps it from the host in the agent's stored API-key environment (`ANTHROPIC_BASE_URL`, or any `*_BASE_URL` variable), so a restricted agent on another provider reaches it. Saving or removing the environment changes it, and @@ -773,7 +778,7 @@ name that already holds an account; delete that account first to replace it. The fourth, the API-key environment, is the exception: a save overwrites what the store holds. -An opened agent card lists its linked accounts in its **accounts** section, one row +An opened agent card lists its linked accounts at the end of its **settings** section, one row per account: kind, name and host. Opening an agent makes one request, `GET /api/hives/{hive}/agents/{agent}/linked-accounts`, which returns every account of that agent as names and hosts and never a credential. When the card diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx index ad8ac91b..60e0a6b9 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx @@ -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 }) {
-
permissions
+
internet
- +
allowed domains
+
permissions
+
+ +
); } @@ -251,35 +255,27 @@ function SectionBody({ actions: AgentActions; }) { switch (section) { - case "overview": + case "status": return (
); case "settings": - return ( -
- -
- ); - case "accounts": return ( <>
+
); case "terminal": - return ( - <> - {/* The agent's live terminal and its input box. */} - - - - ); + // The agent's live terminal and its input box. + return ; + case "subagents": + return ; } } @@ -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 ( -
- {sectionsFor(withTerminal).map((section) => ( - - - - ))} -
- ); - } return ( -
-
- - - - -
- - {withTerminal ? ( - - ) : null} +
+ {inCard ? null : ( +
+ +
+ )} + {sectionsFor(withTerminal).map((section) => ( + + + + ))}
); } diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx index 7a08a110..62603d2c 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx @@ -1,24 +1,27 @@ -// — one agent's permissions in an opened agent card: what the -// swarm grants it, and an editor for that. +// and — 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(null); const [error, setError] = useState(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(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 { + 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 ? ( <> ) : null; - if (grant === null) { - return ( - errorPanel ?? ( - - loading the permissions… - - ) - ); - } - - return ( -
- {errorPanel} - - {grant.permissions && grant.permissions.length > 0 ? ( -
    - {grant.permissions.map((p) => ( -
  • - -
  • - ))} -
- ) : null} - - {INTERNET_MODES.filter( - ([mode]) => - mode !== - internetMode(grant.unrestricted_internet, grant.internet_log), - ).map(([mode, label, title]) => ( - void setInternet(grant, mode)} - /> - ))} - setEditing(true)} - /> - setEditing(false)} - label="edit permissions" - plain - > - {/* Remounted per open, so a cancelled edit leaves nothing behind. */} - {editing ? ( - setEditing(false)} - onSaved={() => { - setEditing(false); - setReload((n) => n + 1); - }} - /> - ) : null} - -
- ); + const loading = + grant === null + ? (readFailure ?? ( + + loading the permissions… + + )) + : 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(null); - const [selected, setSelected] = useState([]); - const [busy, setBusy] = useState(false); - const [problem, setProblem] = useState(null); + const [catalogueError, setCatalogueError] = useState( + null, + ); + // The boxes as ticked; they follow the stored grant once it is read again. + const [selected, setSelected] = useState(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 ( - -

- 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 manage_root_agent restarts it after - its current turn. -

-
-
- {catalogue ? ( +
+ {readFailure} + +

+ 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 manage_root_agent restarts it after its + current turn. +

+ {catalogue ? ( +
setSelected((s) => togglePermission(s, v))} + filter={{ values: ticked, negate: false }} + onToggle={(v) => void toggle(v)} /> - ) : ( - - {problem ? "could not load the permissions" : "loading…"} - - )} -
- {problem ? ( +
+ ) : ( + + {catalogueError ? "could not load the permissions" : "loading…"} + + )} + {saveError ? ( ) : null} -
- - -
- - + + +
+ ); +} + +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 ( +
+ {readFailure} + +

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

+
    + {INTERNET_MODES.map(([m, label, meaning]) => ( +
  • + + void send({ + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + internetBody(grant.permissions, m), + ), + }) + } + />{" "} + {meaning} +
  • + ))} +
+ {saveError ? ( + + ) : null} +
+
); } diff --git a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx index bcba39a8..25874cd2 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx @@ -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 = { + "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(null); const [error, setError] = useState(null); @@ -116,40 +136,38 @@ export function AllowedDomains({ agent }: { agent: string }) { return (
{errorPanel} - - {shown.length > 0 ? ( -
    - {shown.map((e) => ( -
  • - {" "} - {e.derived === true ? null : ( - void remove(e)} - /> - )} -
  • - ))} -
+ {shown.length === 0 ? ( + ) : null} + {entryGroups(shown).map((g) => ( + +

{groupHelp(g.source, agent)}

+
    + {g.entries.map((e) => ( +
  • + {" "} + {e.derived === true ? null : ( + void remove(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; diff --git a/frontend/packages/swarm-ui/src/agent/detail/sections.ts b/frontend/packages/swarm-ui/src/agent/detail/sections.ts index 45477966..39e17476 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/sections.ts +++ b/frontend/packages/swarm-ui/src/agent/detail/sections.ts @@ -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 = { - 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"; } diff --git a/frontend/packages/swarm-ui/src/ui/badge-popover/BadgePopover.css b/frontend/packages/swarm-ui/src/ui/badge-popover/BadgePopover.css new file mode 100644 index 00000000..af18ac5b --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/badge-popover/BadgePopover.css @@ -0,0 +1,26 @@ +/* — 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; +} diff --git a/frontend/packages/swarm-ui/src/ui/badge-popover/BadgePopover.tsx b/frontend/packages/swarm-ui/src/ui/badge-popover/BadgePopover.tsx new file mode 100644 index 00000000..98774b2b --- /dev/null +++ b/frontend/packages/swarm-ui/src/ui/badge-popover/BadgePopover.tsx @@ -0,0 +1,57 @@ +// — 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(null); + return ( + <> + + setOpen((o) => !o)} + /> + + setOpen(false)} + anchorRef={anchorRef} + label={popoverLabel} + > +
{children}
+
+ + ); +} diff --git a/frontend/packages/swarm-ui/test/allowed-domains.test.mjs b/frontend/packages/swarm-ui/test/allowed-domains.test.mjs index 31aa8315..ee15228a 100644 --- a/frontend/packages/swarm-ui/test/allowed-domains.test.mjs +++ b/frontend/packages/swarm-ui/test/allowed-domains.test.mjs @@ -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([]), []); +}); diff --git a/frontend/packages/swarm-ui/test/detail-sections.test.mjs b/frontend/packages/swarm-ui/test/detail-sections.test.mjs index 41ce8c31..3ef7fcba 100644 --- a/frontend/packages/swarm-ui/test/detail-sections.test.mjs +++ b/frontend/packages/swarm-ui/test/detail-sections.test.mjs @@ -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", () => {