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:
parent
fd6953ed7c
commit
74e588415b
27 changed files with 28 additions and 25 deletions
19
frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css
Normal file
19
frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css
Normal 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;
|
||||
}
|
||||
135
frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx
Normal file
135
frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
301
frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx
Normal file
301
frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
197
frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.tsx
Normal file
197
frontend/packages/swarm-ui/src/agent/detail/LinkedAccounts.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue