The messages page gets a broadcast button that opens the agent picker and message box above the stream, so a sent message shows up in the list below it. The broadcast route and nav tab go, with no redirect: the page is now the composer, BroadcastComposer, in the messages page.
296 lines
9.8 KiB
TypeScript
296 lines
9.8 KiB
TypeScript
// <BroadcastComposer> — one message to several agents at once, in a panel on
|
|
// the messages page above the stream that shows the messages arriving. 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 {
|
|
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 "./BroadcastComposer.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 BroadcastComposer() {
|
|
const [intervalMs, setIntervalMs] =
|
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
|
const { data: agents, error: loadError } = usePolled(intervalMs, (signal) =>
|
|
fetchJson<BroadcastAgent[]>("/api/agents/status", signal),
|
|
);
|
|
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 */
|
|
}
|
|
}
|
|
|
|
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 && (
|
|
<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>
|
|
<RefreshIntervalPicker
|
|
id="broadcast-refresh"
|
|
value={intervalMs}
|
|
onChange={setIntervalMs}
|
|
/>
|
|
</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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|