// — 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, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js"; import { failedAgents, filterShowing, 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 [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); const { data: agents, error: loadError } = usePolled(intervalMs, (signal) => fetchJson("/api/agents/status", signal), ); const [filter, setFilterState] = useState(storedFilter); const [ticked, setTicked] = useState([]); const [body, setBody] = useState(""); const [send, setSend] = useState({ status: "idle" }); function setFilter(next: AgentFilter) { setFilterState(next); try { localStorage.setItem(FILTER_KEY, next); } catch { /* the choice lasts for the visit only */ } } 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[]) { const failed = failedAgents(results); // Only this page's view changes; the saved choice stays as it was. setFilterState(filterShowing(filter, all, failed)); setTicked(failed); setSend({ status: "idle" }); } return ( } > {loadError && ( )}
{targets.length} selected
{agents !== null && shown.length === 0 ? (

{filter === "running" && all.length > 0 ? "no agent is running right now. Show all to pick a stopped one." : "no agents."}

) : (
    {shown.map((agent) => { const on = targets.includes(agent.name); return (
  • ); })}
)}
{overCap && ( at most {MAX_BROADCAST} agents at once, untick{" "} {targets.length - MAX_BROADCAST} )}
{send.status === "error" && ( )} {send.status === "done" && ( )}
); } function SendResults({ results, onRetry, }: { results: BroadcastResult[]; onRetry: (results: BroadcastResult[]) => void; }) { const failed = failedAgents(results); const sent = results.length - failed.length; return (

sent to {sent} of {results.length} {failed.length > 0 && ( )}

    {results.map((r) => (
  • {r.agent} {r.sent ? "✓ sent" : `✗ not sent${r.reason ? `: ${r.reason}` : ""}`}
  • ))}
); }