Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: a broadcast page that sends one message to several agents

Pick agents from a grid of their icons (running or all), type the text, send.
The page posts the names and the text to the controller and shows what
happened to each agent, with a retry that selects just the ones that failed.
The selection rules and the controller's answer are read by plain functions
with tests.
This commit is contained in:
iris 2026-10-18 00:03:36 +02:00 • committed by mara
commit 89b20a5a50

View file

@ -0,0 +1,310 @@
// <BroadcastPage> — one message to several agents at once. Pick agents from a
// grid of their icons (every agent, or just the ones running), type the
// message, send. The controller does the fan-out: `POST /api/agents/broadcast`
// answers one outcome per agent, so one that is down does not stop the rest and
// the page can say which ones did not get it and offer a retry to just those.
//
// The roster is `GET /api/agents/status` on a refresh interval like the agents
// page. The ticks and the message are this page's own state, so a refresh only
// replaces the agent list: what is typed and picked stays. A ticked agent that
// the filter hides is kept but not sent to (`selectedVisible`).
//
// The message goes exactly as typed, as the signed-in operator (the
// controller takes the sender from the gateway, as for the single-agent box).
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { AgentIcon } from "../../agent/AgentIcon.js";
import { Button } from "../../ui/button/Button.js";
import { Panel } from "../../ui/panel/Panel.js";
import {
RefreshIntervalPicker,
useRefreshInterval,
type RefreshIntervalMs,
} from "../../ui/refresh-interval/RefreshInterval.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import {
failedAgents,
isRunning,
parseResults,
selectedVisible,
MAX_BROADCAST,
sendLabel,
tickAll,
toggled,
visibleAgents,
type AgentFilter,
type BroadcastAgent,
type BroadcastResult,
} from "./broadcast-types.js";
import "./BroadcastPage.css";
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
const FILTER_KEY = "swarm-ui:broadcast:filter";
type SendState =
| { status: "idle" }
| { status: "sending" }
| { status: "done"; results: BroadcastResult[] }
| { status: "error"; problem: ProblemDetails };
function storedFilter(): AgentFilter {
try {
return localStorage.getItem(FILTER_KEY) === "all" ? "all" : "running";
} catch {
return "running";
}
}
export function BroadcastPage() {
const [agents, setAgents] = useState<BroadcastAgent[] | null>(null);
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const [filter, setFilterState] = useState<AgentFilter>(storedFilter);
const [ticked, setTicked] = useState<string[]>([]);
const [body, setBody] = useState("");
const [send, setSend] = useState<SendState>({ status: "idle" });
function setFilter(next: AgentFilter) {
setFilterState(next);
try {
localStorage.setItem(FILTER_KEY, next);
} catch {
/* the choice lasts for the visit only */
}
}
async function refresh() {
const res = await fetch("/api/agents/status");
if (!res.ok) {
setLoadError(await readApiError(res));
return;
}
setAgents((await res.json()) as BroadcastAgent[]);
setLoadError(null);
}
useRefreshInterval(intervalMs, () => {
refresh().catch((e: unknown) => setLoadError({ detail: String(e) }));
});
const all = agents ?? [];
const shown = visibleAgents(all, filter);
const targets = selectedVisible(ticked, shown);
const runningCount = all.filter(isRunning).length;
const sending = send.status === "sending";
const overCap = targets.length > MAX_BROADCAST;
const canSend =
targets.length > 0 && !overCap && body.trim() !== "" && !sending;
async function submit(e: Event) {
e.preventDefault();
if (!canSend) return;
setSend({ status: "sending" });
try {
const res = await fetch("/api/agents/broadcast", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agents: targets, body }),
});
if (!res.ok) {
setSend({ status: "error", problem: await readApiError(res) });
return;
}
const results = parseResults(await res.json());
if (results === null) {
setSend({
status: "error",
problem: { detail: "the controller's answer was not understood" },
});
return;
}
setSend({ status: "done", results });
// Everyone got it: the text has served its purpose. A partial send keeps
// it so the retry to the rest sends the same words.
if (failedAgents(results).length === 0) setBody("");
} catch (err) {
setSend({ status: "error", problem: { detail: String(err) } });
}
}
function retryFailed(results: BroadcastResult[]) {
setTicked(failedAgents(results));
setSend({ status: "idle" });
}
return (
<Panel
title="broadcast"
icon="📣"
actions={
<RefreshIntervalPicker
id="broadcast-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
}
>
{loadError && (
<ApiErrorPanel context="failed to load agents" problem={loadError} />
)}
<form class="ui-broadcast" onSubmit={submit}>
<div class="ui-broadcast-bar">
<div class="ui-broadcast-filter" role="group" aria-label="show">
<button
type="button"
aria-pressed={filter === "running"}
class={filter === "running" ? "active" : undefined}
onClick={() => setFilter("running")}
>
running {agents ? runningCount : ""}
</button>
<button
type="button"
aria-pressed={filter === "all"}
class={filter === "all" ? "active" : undefined}
onClick={() => setFilter("all")}
>
all {agents ? all.length : ""}
</button>
</div>
<button
type="button"
class="ui-broadcast-link"
disabled={shown.length === 0}
onClick={() => setTicked((prev) => tickAll(prev, shown))}
>
select all
</button>
<button
type="button"
class="ui-broadcast-link"
disabled={targets.length === 0}
onClick={() => setTicked([])}
>
clear
</button>
<span class="ui-broadcast-count" role="status">
{targets.length} selected
</span>
</div>
{agents !== null && shown.length === 0 ? (
<p class="ui-broadcast-empty">
{filter === "running" && all.length > 0
? "no agent is running right now. Show all to pick a stopped one."
: "no agents."}
</p>
) : (
<ul class="ui-broadcast-grid" aria-label="agents">
{shown.map((agent) => {
const on = targets.includes(agent.name);
return (
<li key={agent.name}>
<label
class={
"ui-broadcast-agent" +
(on ? " ui-broadcast-agent-on" : "")
}
>
<input
type="checkbox"
class="ui-broadcast-check"
checked={on}
onChange={() =>
setTicked((prev) => toggled(prev, agent.name))
}
/>
<AgentIcon name={agent.name} />
<span class="ui-broadcast-name">{agent.name}</span>
<span
class={
"ui-broadcast-state" +
(isRunning(agent) ? " ui-broadcast-state-up" : "")
}
>
{isRunning(agent) ? "running" : "not running"}
</span>
</label>
</li>
);
})}
</ul>
)}
<TextAreaField
id="broadcast-body"
label="message"
value={body}
rows={5}
placeholder="sent exactly as typed, from you, to each selected agent"
onInput={setBody}
/>
<div class="ui-broadcast-send">
<Button variant="primary" type="submit" disabled={!canSend}>
{sending ? "sending…" : sendLabel(targets.length)}
</Button>
{overCap && (
<span class="ui-broadcast-cap" role="status">
at most {MAX_BROADCAST} agents at once, untick{" "}
{targets.length - MAX_BROADCAST}
</span>
)}
</div>
</form>
<div class="ui-broadcast-result" aria-live="polite">
{send.status === "error" && (
<ApiErrorPanel context="failed to send" problem={send.problem} />
)}
{send.status === "done" && (
<SendResults results={send.results} onRetry={retryFailed} />
)}
</div>
</Panel>
);
}
function SendResults({
results,
onRetry,
}: {
results: BroadcastResult[];
onRetry: (results: BroadcastResult[]) => void;
}) {
const failed = failedAgents(results);
const sent = results.length - failed.length;
return (
<section class="ui-broadcast-results" aria-label="send result">
<p class="ui-broadcast-summary">
sent to {sent} of {results.length}
{failed.length > 0 && (
<button
type="button"
class="ui-broadcast-link"
onClick={() => onRetry(results)}
>
select the {failed.length} that failed
</button>
)}
</p>
<ul class="ui-broadcast-outcomes">
{results.map((r) => (
<li
key={r.agent}
class={r.sent ? "ui-broadcast-ok" : "ui-broadcast-fail"}
>
<AgentIcon name={r.agent} small />
<span class="ui-broadcast-outcome-name">{r.agent}</span>
<span>
{r.sent
? "✓ sent"
: `✗ not sent${r.reason ? `: ${r.reason}` : ""}`}
</span>
</li>
))}
</ul>
</section>
);
}