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
7 changed files with 63 additions and 40 deletions

View file

@ -11,22 +11,21 @@ The signed-in user's avatar sits at the right end of the header on every page.
It opens a menu with the user's name and links to authelia's settings and It opens a menu with the user's name and links to authelia's settings and
sign-out pages; the hive dashboard and the per-agent page have the same menu. sign-out pages; the hive dashboard and the per-agent page have the same menu.
| route | what | | route | what |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here | | `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here |
| `/hives` | the hive directory, each hive with its last reported status | | `/hives` | the hive directory, each hive with its last reported status |
| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps, permissions; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts | | `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps, permissions; create agents (with their permissions, optionally from an [archetype](archetypes.md)), link forge, matrix and GitHub accounts |
| `/agents/<name>/terminal` | one agent's live terminal | | `/agents/<name>/terminal` | one agent's live terminal |
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal | | `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
| `/alerts` | the alerts the swarm's Grafana holds now: the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token | | `/alerts` | the alerts the swarm's Grafana holds now: the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token |
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress | | `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
| `/broadcast` | send one message to several agents at once, picked from a grid of their icons | | `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient; its **broadcast** button opens a composer that sends one message to several agents at once |
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient | | `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows |
| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows | | `/issues` | a cross-repo issue report |
| `/issues` | a cross-repo issue report | | `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | | `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed |
| `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed |
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
Pages that refresh on a timer share one polling rule: the page sends no new Pages that refresh on a timer share one polling rule: the page sends no new
@ -478,18 +477,21 @@ its newest 100 builds, so a link to an older one shows a note instead.
### Broadcasting a message ### Broadcasting a message
The **broadcast** page sends one message to several agents at once. Pick the The **broadcast** button on the messages page (`/messages`) opens a composer above the
message list that sends one message to several agents at once, and the messages
show up in the list below it as they go. The button reads **close broadcast**
while the composer is open; closing it keeps what you typed and ticked. Pick the
agents from a grid of their icons. **running** shows the agents that report agents from a grid of their icons. **running** shows the agents that report
themselves running and **all** shows every agent; the browser keeps the choice. themselves running and **all** shows every agent; the browser keeps the choice.
**select all** ticks every agent the filter shows, and **clear** unticks them. **select all** ticks every agent the filter shows, and **clear** unticks them.
The page skips an agent you ticked and then hid with the filter. Type the The composer skips an agent you ticked and then hid with the filter. Type the
message and press **send to N agents**. The text goes exactly as typed, as an message and press **send to N agents**. The text goes exactly as typed, as an
inbox message from your authelia username, the same as the box under an agent's inbox message from your authelia username, the same as the box under an agent's
terminal. One broadcast may name at most 64 agents, and the button stays off terminal. One broadcast may name at most 64 agents, and the button stays off
until you untick the extra ones. A broadcast to many agents can take a few until you untick the extra ones. A broadcast to many agents can take a few
seconds to answer, and the button reads **sending…** meanwhile. seconds to answer, and the button reads **sending…** meanwhile.
The page then lists what happened to each agent. An agent that wasn't The composer then lists what happened to each agent. An agent that wasn't
listening shows as not sent, with the reason, and the others still got the listening shows as not sent, with the reason, and the others still got the
message. **select the N that failed** ticks just those, switching to **all** if the message. **select the N that failed** ticks just those, switching to **all** if the
filter hid any, so you can send the same message again. When every agent got the message the box clears; otherwise the filter hid any, so you can send the same message again. When every agent got the message the box clears; otherwise the

View file

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

View file

@ -1,4 +1,4 @@
/* <BroadcastPage>: the agent picker over the message box, then the per-agent /* <BroadcastComposer>: the agent picker over the message box, then the per-agent
result. One column; the grid wraps to however many icons fit. */ result. One column; the grid wraps to however many icons fit. */
.ui-broadcast { .ui-broadcast {
display: flex; display: flex;

View file

@ -1,4 +1,5 @@
// <BroadcastPage> — one message to several agents at once. Pick agents from a // <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 // 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` // 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 // answers one outcome per agent, so one that is down does not stop the rest and
@ -16,7 +17,6 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { AgentIcon } from "../../agent/AgentIcon.js"; import { AgentIcon } from "../../agent/AgentIcon.js";
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import { Panel } from "../../ui/panel/Panel.js";
import { import {
RefreshIntervalPicker, RefreshIntervalPicker,
type RefreshIntervalMs, type RefreshIntervalMs,
@ -38,7 +38,7 @@ import {
type BroadcastAgent, type BroadcastAgent,
type BroadcastResult, type BroadcastResult,
} from "./broadcast-types.js"; } from "./broadcast-types.js";
import "./BroadcastPage.css"; import "./BroadcastComposer.css";
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
const FILTER_KEY = "swarm-ui:broadcast:filter"; const FILTER_KEY = "swarm-ui:broadcast:filter";
@ -57,7 +57,7 @@ function storedFilter(): AgentFilter {
} }
} }
export function BroadcastPage() { export function BroadcastComposer() {
const [intervalMs, setIntervalMs] = const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS); useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const { data: agents, error: loadError } = usePolled(intervalMs, (signal) => const { data: agents, error: loadError } = usePolled(intervalMs, (signal) =>
@ -126,17 +126,7 @@ export function BroadcastPage() {
} }
return ( return (
<Panel <>
title="broadcast"
icon="📣"
actions={
<RefreshIntervalPicker
id="broadcast-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
}
>
{loadError && ( {loadError && (
<ApiErrorPanel context="failed to load agents" problem={loadError} /> <ApiErrorPanel context="failed to load agents" problem={loadError} />
)} )}
@ -179,6 +169,11 @@ export function BroadcastPage() {
<span class="ui-broadcast-count" role="status"> <span class="ui-broadcast-count" role="status">
{targets.length} selected {targets.length} selected
</span> </span>
<RefreshIntervalPicker
id="broadcast-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
</div> </div>
{agents !== null && shown.length === 0 ? ( {agents !== null && shown.length === 0 ? (
@ -253,7 +248,7 @@ export function BroadcastPage() {
<SendResults results={send.results} onRetry={retryFailed} /> <SendResults results={send.results} onRetry={retryFailed} />
)} )}
</div> </div>
</Panel> </>
); );
} }

View file

@ -1,6 +1,16 @@
/* <MessagesPage>: the filter bar over the terminal-style message list. */ /* <MessagesPage>: the filter bar over the terminal-style message list. */
@import "@hive/shared/terminal.css"; @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 { .ui-messages-bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View file

@ -1,6 +1,7 @@
// <MessagesPage> — `/messages`: every message between agents, and from the // <MessagesPage> — `/messages`: every message between agents, and from the
// operator, reminders and compaction to an agent, as the swarm's controller // 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 // 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 // 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 { useLayoutEffect, useRef, useState } from "preact/hooks";
import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Row } from "@hive/shared/term-row.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 { Panel } from "../../ui/panel/Panel.js";
import { SelectField } from "../../ui/select-field/SelectField.js"; import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextField } from "../../ui/text-field/TextField.js"; import { TextField } from "../../ui/text-field/TextField.js";
@ -50,6 +53,7 @@ const MessageRow = memo(function MessageRow({ m }: { m: ArrivedMessage }) {
export function MessagesPage() { export function MessagesPage() {
const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES); const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [composing, setComposing] = useState(false);
const { messages, connection, reconnect } = useMessageStream(sinceMinutes); const { messages, connection, reconnect } = useMessageStream(sinceMinutes);
const shown = filterMessages(messages, query); const shown = filterMessages(messages, query);
@ -79,6 +83,12 @@ export function MessagesPage() {
icon="💬" icon="💬"
actions={ actions={
<> <>
<Button
variant={composing ? "primary" : "default"}
onClick={() => setComposing((on) => !on)}
>
{composing ? "close broadcast" : "📣 broadcast"}
</Button>
<Badge <Badge
tone={CONNECTION_TONE[connection]} tone={CONNECTION_TONE[connection]}
value={CONNECTION_LABEL[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"> <div class="ui-messages-bar">
<SelectField <SelectField
id="messages-window" 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: "/alerts", label: "alerts", accent: "var(--red)" },
{ href: "/jobs", label: "jobs", accent: "var(--pink)" }, { href: "/jobs", label: "jobs", accent: "var(--pink)" },
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" }, { href: "/schedules", label: "schedules", accent: "var(--cyan)" },
{ href: "/broadcast", label: "broadcast", accent: "var(--blue)" },
{ href: "/messages", label: "messages", accent: "var(--purple)" }, { href: "/messages", label: "messages", accent: "var(--purple)" },
{ href: "/notifications", label: "notifications", accent: "var(--yellow)" }, { href: "/notifications", label: "notifications", accent: "var(--yellow)" },
{ href: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" },