Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: broadcast is a composer on the messages page, not a tab of its own

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.
This commit is contained in:
iris 2026-10-25 16:07:01 +01:00 • committed by mara
commit 6cf89755e2
6 changed files with 63 additions and 40 deletions

View file

@ -7,7 +7,6 @@ import { AgentsPage } from "./pages/agents/AgentsPage.js";
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
import { AlertsPage } from "./pages/AlertsPage.js";
import { BuildsPage } from "./pages/builds/BuildsPage.js";
import { BroadcastPage } from "./pages/broadcast/BroadcastPage.js";
import { MessagesPage } from "./pages/messages/MessagesPage.js";
import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js";
import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
@ -46,7 +45,6 @@ export function App() {
<Route path="/jobs" component={JobsPage} />
<Route path="/schedules" component={SchedulesPage} />
<Route path="/builds" component={BuildsPage} />
<Route path="/broadcast" component={BroadcastPage} />
<Route path="/messages" component={MessagesPage} />
<Route path="/notifications" component={NotificationsPage} />
<Route path={NEW_FLOW_PATH}>

View file

@ -0,0 +1,166 @@
/* <BroadcastComposer>: the agent picker over the message box, then the per-agent
result. One column; the grid wraps to however many icons fit. */
.ui-broadcast {
display: flex;
flex-direction: column;
gap: 1em;
}
.ui-broadcast-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em 1em;
}
/* Same segmented pair as the agents page's view switcher. */
.ui-broadcast-filter {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 0.4em;
overflow: hidden;
}
.ui-broadcast-filter button {
border: none;
background: none;
color: var(--muted);
padding: 0.45em 0.75em;
font: inherit;
font-size: 0.9em;
cursor: pointer;
}
.ui-broadcast-filter button + button {
border-left: 1px solid var(--border);
}
.ui-broadcast-filter button.active {
background: var(--bg-elev);
color: var(--fg);
}
.ui-broadcast-filter button:not(.active):hover {
color: var(--fg);
}
.ui-broadcast-link {
border: none;
background: none;
padding: 0.2em 0;
color: var(--blue);
font: inherit;
font-size: 0.9em;
cursor: pointer;
text-decoration: underline;
}
.ui-broadcast-link:disabled {
color: var(--muted);
text-decoration: none;
cursor: default;
}
.ui-broadcast-count {
margin-left: auto;
color: var(--muted);
font-size: 0.9em;
}
.ui-broadcast-empty {
color: var(--muted);
text-align: center;
padding: 1.25em 0.75em;
margin: 0;
}
/* The picker: each agent a labelled checkbox card, icon over name. */
.ui-broadcast-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8em, 1fr));
gap: 0.6em;
list-style: none;
margin: 0;
padding: 0;
}
.ui-broadcast-agent {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35em;
padding: 0.7em 0.5em 0.6em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg);
cursor: pointer;
}
.ui-broadcast-agent:hover {
border-color: var(--muted);
}
.ui-broadcast-agent-on {
border-color: var(--purple);
background: var(--bg-elev);
}
.ui-broadcast-agent:focus-within {
outline: 2px solid var(--purple);
outline-offset: 2px;
}
.ui-broadcast-check {
position: absolute;
top: 0.5em;
left: 0.5em;
margin: 0;
}
.ui-broadcast-name {
max-width: 100%;
overflow-wrap: anywhere;
text-align: center;
}
.ui-broadcast-state {
color: var(--muted);
font-size: 0.8em;
}
.ui-broadcast-state-up {
color: var(--green);
}
.ui-broadcast-send {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75em;
}
.ui-broadcast-cap {
color: var(--red);
font-size: 0.9em;
}
/* What came back, one line per agent. */
.ui-broadcast-result {
margin-top: 1em;
}
.ui-broadcast-summary {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5em 1em;
margin: 0 0 0.5em;
}
.ui-broadcast-outcomes {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35em;
}
.ui-broadcast-outcomes li {
display: flex;
align-items: center;
gap: 0.6em;
}
.ui-broadcast-outcome-name {
font-weight: 600;
}
.ui-broadcast-ok {
color: var(--green);
}
.ui-broadcast-fail {
color: var(--red);
}
.ui-broadcast-ok .ui-broadcast-outcome-name,
.ui-broadcast-fail .ui-broadcast-outcome-name {
color: var(--fg);
}

View file

@ -0,0 +1,296 @@
// <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>
);
}

View file

@ -1,6 +1,16 @@
/* <MessagesPage>: the filter bar over the terminal-style message list. */
@import "@hive/shared/terminal.css";
/* The broadcast composer above the list, set apart from it by a rule. The
composer's own rules are BroadcastComposer.css's. */
.ui-messages-broadcast {
margin-block-end: 1em;
padding-block-end: 1em;
border-bottom: 1px solid var(--border);
}
.ui-messages-broadcast[hidden] {
display: none;
}
.ui-messages-bar {
display: flex;
flex-wrap: wrap;

View file

@ -1,6 +1,7 @@
// <MessagesPage> — `/messages`: every message between agents, and from the
// operator, reminders and compaction to an agent, as the swarm's controller
// streams them (`GET /api/messages/stream`), across all hives.
// streams them (`GET /api/messages/stream`), across all hives. The **broadcast**
// button opens a composer above the list to send one message to several agents.
//
// The stream sends the chosen window of history first, then the live tail. A
// message an agent drained with an in-turn `recv` is missing, because it starts
@ -11,6 +12,8 @@ import { memo } from "preact/compat";
import { useLayoutEffect, useRef, useState } from "preact/hooks";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Row } from "@hive/shared/term-row.js";
import { BroadcastComposer } from "../broadcast/BroadcastComposer.js";
import { Button } from "../../ui/button/Button.js";
import { Panel } from "../../ui/panel/Panel.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextField } from "../../ui/text-field/TextField.js";
@ -50,6 +53,7 @@ const MessageRow = memo(function MessageRow({ m }: { m: ArrivedMessage }) {
export function MessagesPage() {
const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES);
const [query, setQuery] = useState("");
const [composing, setComposing] = useState(false);
const { messages, connection, reconnect } = useMessageStream(sinceMinutes);
const shown = filterMessages(messages, query);
@ -79,6 +83,12 @@ export function MessagesPage() {
icon="💬"
actions={
<>
<Button
variant={composing ? "primary" : "default"}
onClick={() => setComposing((on) => !on)}
>
{composing ? "close broadcast" : "📣 broadcast"}
</Button>
<Badge
tone={CONNECTION_TONE[connection]}
value={CONNECTION_LABEL[connection]}
@ -94,6 +104,15 @@ export function MessagesPage() {
</>
}
>
{/* Kept mounted while closed, so what was typed and picked stays and the
agent list is already loaded when it opens. */}
<section
class="ui-messages-broadcast"
aria-label="broadcast a message"
hidden={!composing}
>
<BroadcastComposer />
</section>
<div class="ui-messages-bar">
<SelectField
id="messages-window"

View file

@ -54,7 +54,6 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: "/alerts", label: "alerts", accent: "var(--red)" },
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
{ href: "/broadcast", label: "broadcast", accent: "var(--blue)" },
{ href: "/messages", label: "messages", accent: "var(--purple)" },
{ href: "/notifications", label: "notifications", accent: "var(--yellow)" },
{ href: "/issues", label: "issues", accent: "var(--yellow)" },