Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-10-12 00:02:15 +02:00
commit 74e588415b
27 changed files with 28 additions and 25 deletions

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,85 @@
// <AgentDetailDialog> — the agent detail panel from `/agents`, in a dialog, for
// a page that is already showing the agent's terminal and should not be left
// to read or change its details. It carries the same body (`AgentDetail`) and
// the same actions (`useAgentActions`) as the panel, minus the terminal
// preview the page itself already is.
//
// The row comes from `GET /api/agents/status` when the dialog 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.
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 { Dialog } from "../../ui/dialog/Dialog.js";
import { Panel } from "../../ui/panel/Panel.js";
import { useAgentActions } from "../roster/AgentActions.js";
import { AgentDetail } from "./AgentDetail.js";
import type { AgentRow } from "../AgentTypes.js";
export function AgentDetailDialog({
agent,
open,
onClose,
}: {
agent: string;
open: boolean;
onClose: () => void;
}) {
// `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 dialog 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 (
<>
<Dialog open={open} onClose={onClose} label={`${agent} details`} plain>
{open ? (
<Panel title={agent} icon="🔎" onClose={onClose}>
{error ? (
<ApiErrorPanel
context="failed to load the agent's details"
problem={error}
/>
) : row ? (
<AgentDetail row={row} actions={actions} />
) : row === null ? (
<p>{agent} is not in the swarm's agent roster</p>
) : (
<p>loading…</p>
)}
</Panel>
) : null}
</Dialog>
{actions.dialogs}
</>
);
}

View file

@ -0,0 +1,40 @@
/* <AgentInference> — one setting per line: badge, then where it comes from. */
.ui-agent-inference {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.3em;
}
.ui-agent-inference-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3em;
}
.ui-agent-inference-list li {
display: flex;
align-items: center;
gap: 0.5em;
flex-wrap: wrap;
}
.ui-agent-inference-source,
.ui-agent-inference-muted {
color: var(--muted);
}
.ui-agent-inference-form {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75em;
margin-top: 1em;
}
.ui-agent-inference-problem {
margin: 0;
color: var(--red);
}
.ui-agent-inference-actions {
display: flex;
align-items: center;
gap: 0.75em;
}

View file

@ -0,0 +1,301 @@
// <AgentInference> — one agent's inference settings on the detail panel: the
// model and effort it runs on now, with where each came from, and an editor
// for the value the swarm sets.
//
// Reads `GET /api/agents/{name}/inference`, which answers
// `{ configured, effective }`: `configured` is the swarm's stored value (null
// when none), `effective` what the agent reported running on (null until its
// first report reaches the controller). The editor PUTs the whole value and
// the clear action DELETEs it; a cleared field puts the agent back on its own
// value, which is why an empty field is left out of the body rather than sent.
//
// Its state belongs to one agent: callers key it by agent name, so switching
// agents remounts it instead of showing the previous agent's settings.
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 { Dialog } from "../../ui/dialog/Dialog.js";
import { Panel } from "../../ui/panel/Panel.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { Button } from "../../ui/button/Button.js";
import "./AgentInference.css";
type Source = "swarm" | "agent_nix" | "local_file" | "default";
// Mirrors the swarm's `InferenceSettings`; every field is optional.
interface Settings {
model?: string;
effort?: string;
subagent_models?: string[];
}
// Mirrors `EffectiveInference`.
interface Effective {
model: string;
effort: string;
model_source: Source;
effort_source: Source;
}
interface View {
configured: Settings | null;
effective: Effective | null;
}
// The values the swarm accepts for `effort`.
const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
const EFFORT_OPTIONS = [
{ value: "", label: "the agent's own" },
...EFFORTS.map((e) => ({ value: e, label: e })),
];
const SOURCE_LABEL: Record<Source, string> = {
swarm: "swarm",
agent_nix: "agent.nix",
local_file: "its own pick",
default: "default",
};
// The same checks the swarm makes on a model name, so an obviously bad one
// is refused before the round trip; the swarm stays the real gate.
function modelNameProblem(name: string): string | null {
if (/\s/.test(name)) return "a model name has no whitespace";
if (name.startsWith("-")) return "a model name does not start with -";
return null;
}
// Subagent models are typed as one list, split on commas and whitespace.
function parseModels(text: string): string[] {
return text.split(/[\s,]+/).filter((m) => m !== "");
}
function inferenceUrl(agent: string): string {
return `/api/agents/${encodeURIComponent(agent)}/inference`;
}
export function AgentInference({ agent }: { agent: string }) {
const [view, setView] = useState<View | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
// Bumped after a save or a clear, so the settings are fetched again.
const [reload, setReload] = useState(0);
const [editing, setEditing] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(inferenceUrl(agent));
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const body = (await r.json()) as Partial<View>;
if (cancelled) return;
setView({
configured: body.configured ?? null,
effective: body.effective ?? null,
});
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [agent, reload]);
if (error) {
return (
<Badge
tone="negative"
value="inference unavailable"
title={error.detail ?? "reading the inference settings failed"}
/>
);
}
if (view === null) return <span class="ui-agent-inference-muted">…</span>;
const { configured, effective } = view;
const swarmModels = configured?.subagent_models ?? [];
return (
<div class="ui-agent-inference">
<ul class="ui-agent-inference-list">
<li>
{effective ? (
<>
<Badge label="model" value={effective.model} />
<span class="ui-agent-inference-source">
{SOURCE_LABEL[effective.model_source]}
</span>
</>
) : (
<Badge
label="model"
value={configured?.model ?? "its own"}
title="the agent has not reported what it runs on yet"
/>
)}
</li>
<li>
{effective ? (
<>
<Badge label="effort" value={effective.effort} />
<span class="ui-agent-inference-source">
{SOURCE_LABEL[effective.effort_source]}
</span>
</>
) : (
<Badge
label="effort"
value={configured?.effort ?? "its own"}
title="the agent has not reported what it runs on yet"
/>
)}
</li>
<li>
<Badge
label="subagent models"
value={swarmModels.length > 0 ? swarmModels.join(", ") : "its own"}
/>
</li>
</ul>
<Badge
variant="quiet"
value="edit"
title={`set the swarm's model, effort and subagent models for ${agent}`}
onClick={() => setEditing(true)}
/>
<Dialog
open={editing}
onClose={() => setEditing(false)}
label="edit inference settings"
plain
>
{/* Remounted per open, so a cancelled edit leaves nothing behind. */}
{editing ? (
<InferenceForm
agent={agent}
configured={configured}
onClose={() => setEditing(false)}
onSaved={() => {
setEditing(false);
setReload((n) => n + 1);
}}
/>
) : null}
</Dialog>
</div>
);
}
function InferenceForm({
agent,
configured,
onClose,
onSaved,
}: {
agent: string;
configured: Settings | null;
onClose: () => void;
onSaved: () => void;
}) {
const [model, setModel] = useState(configured?.model ?? "");
const [effort, setEffort] = useState(configured?.effort ?? "");
const [subagents, setSubagents] = useState(
(configured?.subagent_models ?? []).join(", "),
);
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<ProblemDetails | null>(null);
const subagentList = parseModels(subagents);
const modelProblem =
modelNameProblem(model.trim()) ??
subagentList.map(modelNameProblem).find((p) => p !== null) ??
null;
const empty =
model.trim() === "" && effort === "" && subagentList.length === 0;
async function send(init: RequestInit) {
setBusy(true);
try {
const r = await fetch(inferenceUrl(agent), init);
if (!r.ok) {
setProblem(await readApiError(r));
return;
}
onSaved();
} catch (e: unknown) {
setProblem({ detail: String(e) });
} finally {
setBusy(false);
}
}
function save(e: Event) {
e.preventDefault();
const body: Settings = {};
if (model.trim() !== "") body.model = model.trim();
if (effort !== "") body.effort = effort;
if (subagentList.length > 0) body.subagent_models = subagentList;
void send({
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
return (
<Panel title={`inference — ${agent}`} icon="🧠" onClose={onClose}>
<p>
Sets what the swarm makes {agent} run on. A field left empty stays the
agent's own value. The agent picks a change up within seconds; a turn
already running finishes on what it started with.
</p>
<form class="ui-agent-inference-form" onSubmit={save}>
<TextField
id="inference-model"
label="model"
value={model}
placeholder="e.g. opus"
onInput={setModel}
/>
<SelectField
id="inference-effort"
label="effort"
value={effort}
options={EFFORT_OPTIONS}
onChange={setEffort}
/>
<TextField
id="inference-subagent-models"
label="subagent models"
value={subagents}
placeholder="e.g. haiku, sonnet"
onInput={setSubagents}
/>
{modelProblem ? (
<p class="ui-agent-inference-problem">{modelProblem}</p>
) : null}
{problem ? (
<ApiErrorPanel context="saving failed" problem={problem} />
) : null}
<div class="ui-agent-inference-actions">
<Button
variant="primary"
type="submit"
disabled={busy || empty || modelProblem !== null}
>
{busy ? "saving…" : "save"}
</Button>
<Button
disabled={busy || configured === null}
onClick={() => void send({ method: "DELETE" })}
>
clear the swarm's setting
</Button>
</div>
</form>
</Panel>
);
}

View file

@ -0,0 +1,60 @@
// <LinkAccountMenu> — one "link account" badge that opens a `Dropdown` with
// the three kinds of account an agent can be linked to, in place of one
// badge per kind: three side-by-side badges were wider than the detail
// panel and spilled out of it. Presentational only, same split as
// `WantedMenu`: the caller owns what each selection opens.
import { useRef, useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import { LinkIcon } from "@hive/shared/icons.js";
export type LinkKind = "matrix" | "forge" | "github";
const OPTIONS: DropdownOption[] = [
{ value: "matrix", label: "matrix account" },
{ value: "forge", label: "forge account" },
{ value: "github", label: "github account" },
];
export function LinkAccountMenu({
agent,
hasHive,
onSelect,
}: {
agent: string;
/** Linking writes against the agent's hive, so without one there is
* nothing to link against and the menu stays shut. */
hasHive: boolean;
onSelect: (kind: LinkKind) => void;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
return (
<div class="ui-dropdown-anchor" ref={anchorRef}>
<Badge
variant="quiet"
icon={<LinkIcon />}
value="link account"
onClick={hasHive ? () => setOpen((o) => !o) : undefined}
expanded={open}
disabled={!hasHive}
title={
hasHive
? `link a matrix, forge or github account to ${agent}`
: "no hive on record for this agent — nothing to link against"
}
/>
<Dropdown
open={open}
options={OPTIONS}
label={`link an account to ${agent}`}
onSelect={(value) => {
setOpen(false);
onSelect(value as LinkKind);
}}
onClose={() => setOpen(false)}
anchorRef={anchorRef}
/>
</div>
);
}

View file

@ -0,0 +1,18 @@
/* <LinkedAccounts> — one account per line: kind/name badge, then host. */
.ui-linked-accounts {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.3em;
}
.ui-linked-accounts li {
display: flex;
align-items: center;
gap: 0.5em;
flex-wrap: wrap;
}
.ui-linked-accounts-host,
.ui-linked-accounts-muted {
color: var(--muted);
}

View file

@ -0,0 +1,197 @@
// <LinkedAccounts> — the accounts linked to one agent, one row each: kind,
// name, host, and a delete action on every row but the agent's own account.
// Reads `GET /api/hives/{hive}/agents/{agent}/linked-accounts`, which carries
// names and hosts only, never a credential.
//
// Fetched on mount, after a delete, and whenever `version` changes;
// `AgentsPage` bumps it when a link dialog closes, so an account linked there
// shows up without waiting for a reload.
//
// Its state belongs to one agent: callers key it by hive and agent, so
// switching agents remounts it instead of showing the previous agent's rows.
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 { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import "./LinkedAccounts.css";
type AccountKind = "matrix" | "forgejo" | "github";
// Mirrors `linked_accounts::LinkedAccount`.
interface LinkedAccount {
kind: AccountKind;
name: string;
host: string | null;
reserved: boolean;
}
// The DELETE route for one account, beside the PUT that links it.
function accountUrl(hive: string, agent: string, a: LinkedAccount): string {
const base = `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}`;
switch (a.kind) {
case "matrix":
return `${base}/matrix-accounts/${encodeURIComponent(a.name)}`;
case "forgejo":
return `${base}/forge-accounts/${encodeURIComponent(a.name)}`;
case "github":
return `${base}/github-account`;
}
}
export function LinkedAccounts({
hive,
agent,
version,
}: {
hive: string;
agent: string;
version: number;
}) {
const [accounts, setAccounts] = useState<LinkedAccount[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
// Bumped after a delete, so the list is fetched again.
const [reload, setReload] = useState(0);
// The row whose delete confirmation is open; null means closed.
const [deleteTarget, setDeleteTarget] = useState<LinkedAccount | null>(null);
// Matrix only: log the token out at its homeserver before deleting.
const [revoke, setRevoke] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<ProblemDetails | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/linked-accounts`,
);
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
}
const data = (await r.json()) as LinkedAccount[];
if (cancelled) return;
setAccounts(data);
setError(null);
})().catch((e: unknown) => {
if (!cancelled) setError({ detail: String(e) });
});
return () => {
cancelled = true;
};
}, [hive, agent, version, reload]);
function openDelete(a: LinkedAccount) {
setDeleteTarget(a);
setRevoke(false);
setDeleteError(null);
}
async function confirmDelete() {
if (!deleteTarget) return;
setDeleting(true);
try {
const url = accountUrl(hive, agent, deleteTarget);
const r = await fetch(
deleteTarget.kind === "matrix" && revoke ? `${url}?revoke=true` : url,
{ method: "DELETE" },
);
// A 404 means the account the dialog is about is already gone — the
// end state the delete wanted — so it closes the same as a success.
if (!r.ok && r.status !== 404) {
setDeleteError(await readApiError(r));
return;
}
setDeleteTarget(null);
setReload((n) => n + 1);
} catch (e: unknown) {
setDeleteError({ detail: String(e) });
} finally {
setDeleting(false);
}
}
const dialog = (
<ConfirmDialog
open={deleteTarget !== null}
label="delete linked account"
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
confirmLabel="delete"
confirmDisabled={deleting}
>
{deleteTarget ? (
<>
<p>
Delete the {deleteTarget.kind} account{" "}
<strong>{deleteTarget.name}</strong>
{deleteTarget.host ? (
<>
{" "}
on <strong>{deleteTarget.host}</strong>
</>
) : null}{" "}
from <strong>{agent}</strong>? Every stored version of its token is
removed from the swarm secret store. The agent is not told.
</p>
{deleteTarget.kind === "matrix" ? (
<label>
<input
type="checkbox"
checked={revoke}
onChange={(e) =>
setRevoke((e.target as HTMLInputElement).checked)
}
/>{" "}
also log the token out at the homeserver; if that fails, the
account is kept
</label>
) : null}
{deleteError ? (
<ApiErrorPanel context="delete failed" problem={deleteError} />
) : null}
</>
) : null}
</ConfirmDialog>
);
if (error) {
return (
<Badge
tone="negative"
value="accounts unavailable"
title={error.detail ?? "listing linked accounts failed"}
/>
);
}
if (accounts === null) return <span class="ui-linked-accounts-muted">…</span>;
if (accounts.length === 0) {
return <span class="ui-linked-accounts-muted">none linked</span>;
}
return (
<>
<ul class="ui-linked-accounts">
{accounts.map((a) => (
<li key={`${a.kind}/${a.name}`}>
<Badge label={a.kind} value={a.name} />
<span class="ui-linked-accounts-host">{a.host ?? "—"}</span>
{a.reserved ? (
<Badge
value="own account"
title="the agent's own account, which the swarm mints"
/>
) : (
<Badge
variant="quiet"
value="delete"
title={`delete the ${a.kind} account ${a.name}`}
onClick={() => openDelete(a)}
/>
)}
</li>
))}
</ul>
{dialog}
</>
);
}