diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 8cdacf6e..4d43f5d9 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -18,6 +18,7 @@ domain, covers host-level detail for one hive. | `/jobs` | the controller's job graph — where agent creation and credential mints show progress | | `/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 | | `/issues` | a cross-repo issue report | | `/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 | @@ -245,6 +246,16 @@ since that agent's `send` row already did. The query `since_minutes` (default read from each agent's newest 100 rows. A message an agent drained with an in-turn `recv` starts no turn and so is missing. +The **messages** page (`/messages`) reads that feed. A **history** select picks +the window, from live only up to 24 hours, and a filter keeps the messages whose +sender or recipient contains the text you type. Each message is one row of the +same list an agent's terminal uses, a summary line with the time, the two +parties and the start of the body, and the whole body under it. The list +follows the newest message unless you scroll up, and holds the last 2000. The +feed has no event id, so a reconnect sends the history again, and the page +empties its list each time the connection opens, then fills it from the replay. +Changing the window opens a new stream. + A command has its own subject, so the harness never mistakes typed text for one. `POST /api/agents//cancel` sends `"cancel"` as a NATS request on `$SWARM.control.`, and only swarm-controller may publish there. The diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 9002d98b..381f127d 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -7,6 +7,7 @@ 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 { ComponentsPage } from "./pages/ComponentsPage.js"; import { JobsPage } from "./pages/JobsPage.js"; import { HivesPage } from "./pages/HivesPage.js"; @@ -41,6 +42,7 @@ export function App() { + diff --git a/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.css b/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.css new file mode 100644 index 00000000..6fe1d758 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.css @@ -0,0 +1,28 @@ +/* : the filter bar over the terminal-style message list. */ +@import "@hive/shared/terminal.css"; + +.ui-messages-bar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.5em 1em; + margin-block-end: 0.75em; +} +.ui-messages-bar > .ui-form-field { + flex: 0 1 16em; +} +.ui-messages-count { + margin-inline-start: auto; + color: var(--muted); +} +.ui-messages-wrap.terminal-wrap { + max-height: 70vh; +} +.ui-messages-wrap .live.terminal { + box-sizing: border-box; + max-height: 70vh; +} +.ui-messages-note { + margin: 0.75em 0 0; + color: var(--muted); +} diff --git a/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx b/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx new file mode 100644 index 00000000..3f608e07 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx @@ -0,0 +1,130 @@ +// — `/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`). The read side of the hive +// dashboard's flow page, across all hives. +// +// 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 +// no turn; the page says so under the list. Each message is a row of the same +// terminal list an agent's own terminal uses, one summary line with the time, +// the two parties and the start of the body, and the whole body under it. +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 { Panel } from "../../ui/panel/Panel.js"; +import { SelectField } from "../../ui/select-field/SelectField.js"; +import { TextField } from "../../ui/text-field/TextField.js"; +import { + DEFAULT_WINDOW_MINUTES, + filterMessages, + messageRow, + WINDOWS, +} from "./messages.js"; +import { useMessageStream, type ConnectionState } from "./useMessageStream.js"; +import "./MessagesPage.css"; + +const NEAR_BOTTOM_PX = 48; + +const CONNECTION_LABEL: Record = { + connecting: "connecting…", + open: "live", + reconnecting: "reconnecting…", + closed: "disconnected", +}; + +const CONNECTION_TONE: Record = { + connecting: "neutral", + open: "positive", + reconnecting: "warning", + closed: "negative", +}; + +export function MessagesPage() { + const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES); + const [query, setQuery] = useState(""); + const { messages, connection } = useMessageStream(sinceMinutes); + const shown = filterMessages(messages, query); + + const logRef = useRef(null); + const [stickToBottom, setStickToBottom] = useState(true); + + function handleScroll() { + const el = logRef.current; + if (!el) return; + setStickToBottom( + el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX, + ); + } + + // After new rows are committed, follow the tail unless the reader has + // scrolled up. + useLayoutEffect(() => { + const el = logRef.current; + if (el && stickToBottom) el.scrollTop = el.scrollHeight - el.clientHeight; + }, [shown.length, stickToBottom]); + + return ( + + } + > +
+ setSinceMinutes(Number(v))} + options={WINDOWS.map((w) => ({ + value: String(w.minutes), + label: w.label, + }))} + /> + + + {query.trim() === "" + ? `${messages.length} messages` + : `${shown.length} of ${messages.length} messages`} + +
+
+
+ {shown.length === 0 ? ( +
+ + {connection === "open" + ? messages.length === 0 + ? "(connected — no messages yet)" + : "(no message matches the filter)" + : CONNECTION_LABEL[connection]} + +
+ ) : ( + shown.map((m, i) => ( + + )) + )} +
+
+

+ Shows what the agents sent each other. A message an agent drained with + an in-turn recv is missing. The history is at most 100 + recent messages per agent. +

+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/messages/messages.ts b/frontend/packages/swarm-ui/src/pages/messages/messages.ts new file mode 100644 index 00000000..15addb54 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/messages/messages.ts @@ -0,0 +1,88 @@ +// The messages page's logic, kept out of the component so `npm test` can run +// it: the stream URL, which messages a filter keeps, and how one message +// becomes a terminal row. +import type { TermRow } from "@hive/shared/term-msg.js"; + +/** One event of `GET /api/messages/stream`; mirrors `SwarmMessage`. */ +export interface SwarmMessage { + /** ISO 8601 / RFC 3339 UTC. */ + ts: string; + from: string; + to: string; + body: string; + body_format?: "markdown" | "diff"; +} + +/** The history windows the page offers, in minutes; 0 is live only. */ +export const WINDOWS: { minutes: number; label: string }[] = [ + { minutes: 0, label: "live only" }, + { minutes: 15, label: "last 15 min" }, + { minutes: 60, label: "last hour" }, + { minutes: 360, label: "last 6 hours" }, + { minutes: 1440, label: "last 24 hours" }, +]; + +export const DEFAULT_WINDOW_MINUTES = 60; + +/** The stream for `sinceMinutes` of history before the live tail. */ +export function messagesUrl(sinceMinutes: number): string { + return `/api/messages/stream?since_minutes=${sinceMinutes}`; +} + +/** + * The messages that name `query` in their sender or recipient, as a + * case-insensitive substring; all of them when `query` is blank. + */ +export function filterMessages( + messages: SwarmMessage[], + query: string, +): SwarmMessage[] { + const q = query.trim().toLowerCase(); + if (q === "") return messages; + return messages.filter( + (m) => m.from.toLowerCase().includes(q) || m.to.toLowerCase().includes(q), + ); +} + +/** At most `max` messages, the newest ones, when there are more. */ +export function newest(messages: SwarmMessage[], max: number): SwarmMessage[] { + return messages.length > max + ? messages.slice(messages.length - max) + : messages; +} + +/** `body`'s first non-blank line, cut to `max` characters with an ellipsis. */ +export function snippet(body: string, max: number): string { + const line = + body + .split("\n") + .map((l) => l.trim()) + .find((l) => l !== "") ?? ""; + return line.length > max ? line.slice(0, max - 1) + "…" : line; +} + +/** `ts` as `HH:MM:SS` in the viewer's time zone; `ts` itself when it is no date. */ +export function clock(ts: string): string { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ts; + const p = (n: number) => String(n).padStart(2, "0"); + return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} + +/** + * The terminal row for `m`: one summary line with the time, the two parties + * and the start of the body, and the whole body under it. + */ +export function messageRow(m: SwarmMessage, key: string): TermRow { + const start = snippet(m.body, 80); + return { + key, + ts: m.ts, + icon: "💬", + level: "info", + summary: `${clock(m.ts)} ${m.from} → ${m.to}${start ? ` — ${start}` : ""}`, + body: m.body, + body_format: m.body_format, + fromHistory: false, + }; +} diff --git a/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts b/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts new file mode 100644 index 00000000..85284cbb --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts @@ -0,0 +1,48 @@ +// The live feed of `GET /api/messages/stream`, one `SwarmMessage` per event. +// The stream has no event id, so a reconnect sends the history again from the +// start; the list therefore empties each time the connection opens, and the +// replay refills it. Not `useSwarmTermStream`: that one keeps its rows across +// a reconnect, which here would list every message twice. +import { useEffect, useState } from "preact/hooks"; +import type { SwarmMessage } from "./messages.js"; +import { messagesUrl, newest } from "./messages.js"; + +export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed"; + +/** The most messages the page holds; older ones drop off. */ +export const MAX_MESSAGES = 2000; + +export function useMessageStream(sinceMinutes: number): { + messages: SwarmMessage[]; + connection: ConnectionState; +} { + const [messages, setMessages] = useState([]); + const [connection, setConnection] = useState("connecting"); + + useEffect(() => { + setMessages([]); + setConnection("connecting"); + const es = new EventSource(messagesUrl(sinceMinutes)); + es.onopen = () => { + setMessages([]); + setConnection("open"); + }; + es.onmessage = (e) => { + let m: SwarmMessage; + try { + m = JSON.parse(e.data) as SwarmMessage; + } catch { + return; + } + setMessages((prev) => newest([...prev, m], MAX_MESSAGES)); + }; + es.onerror = () => { + setConnection( + es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed", + ); + }; + return () => es.close(); + }, [sinceMinutes]); + + return { messages, connection }; +} diff --git a/frontend/packages/swarm-ui/src/shell/Shell.tsx b/frontend/packages/swarm-ui/src/shell/Shell.tsx index d79b771d..6d23763b 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.tsx +++ b/frontend/packages/swarm-ui/src/shell/Shell.tsx @@ -55,6 +55,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ { 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: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/review", label: "review", accent: "var(--amber)" }, { href: "/builds", label: "builds", accent: "var(--green)" }, diff --git a/frontend/packages/swarm-ui/test/messages.test.mjs b/frontend/packages/swarm-ui/test/messages.test.mjs new file mode 100644 index 00000000..23d1c34f --- /dev/null +++ b/frontend/packages/swarm-ui/test/messages.test.mjs @@ -0,0 +1,87 @@ +// The messages page's logic: the stream URL, the filter, the cap, and how a +// message becomes a terminal row. Run by `npm test`, which Node runs with its +// built-in test runner and type stripping, so the `.ts` module is imported as +// it is. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + clock, + filterMessages, + messageRow, + messagesUrl, + newest, + snippet, +} from "../src/pages/messages/messages.ts"; + +const msg = (from, to, body = "hi", extra = {}) => ({ + ts: "2026-10-18T12:00:00Z", + from, + to, + body, + ...extra, +}); + +test("the stream URL carries the history window", () => { + assert.equal(messagesUrl(60), "/api/messages/stream?since_minutes=60"); + assert.equal(messagesUrl(0), "/api/messages/stream?since_minutes=0"); +}); + +test("a blank filter keeps every message", () => { + const all = [msg("a", "b"), msg("c", "d")]; + assert.deepEqual(filterMessages(all, ""), all); + assert.deepEqual(filterMessages(all, " "), all); +}); + +test("the filter matches sender or recipient, ignoring case", () => { + const all = [msg("atlas", "iris"), msg("iris", "coder"), msg("a", "b")]; + assert.deepEqual( + filterMessages(all, "IRIS").map((m) => `${m.from}>${m.to}`), + ["atlas>iris", "iris>coder"], + ); + assert.deepEqual(filterMessages(all, "cod").length, 1); + assert.deepEqual(filterMessages(all, "nobody"), []); +}); + +test("the filter does not search the body", () => { + assert.deepEqual(filterMessages([msg("a", "b", "talk to iris")], "iris"), []); +}); + +test("the cap keeps the newest messages and leaves a short list alone", () => { + const all = [1, 2, 3, 4, 5].map((n) => msg("a", "b", String(n))); + assert.deepEqual( + newest(all, 3).map((m) => m.body), + ["3", "4", "5"], + ); + assert.equal(newest(all, 5), all); + assert.equal(newest(all, 10), all); +}); + +test("a snippet is the first non-blank line, cut with an ellipsis", () => { + assert.equal(snippet("\n\n hello there \nsecond", 80), "hello there"); + assert.equal(snippet("abcdefghij", 5), "abcd…"); + assert.equal(snippet("abcde", 5), "abcde"); + assert.equal(snippet(" \n ", 5), ""); +}); + +test("the clock reads hours, minutes and seconds, and passes a non-date through", () => { + assert.match(clock("2026-10-18T12:00:05Z"), /^\d\d:\d\d:\d\d$/); + assert.equal(clock("not a date"), "not a date"); +}); + +test("a message becomes an info row with its parties in the summary", () => { + const row = messageRow(msg("atlas", "iris", "ready\nmore"), "k1"); + assert.equal(row.key, "k1"); + assert.equal(row.level, "info"); + assert.equal(row.body, "ready\nmore"); + assert.equal(row.fromHistory, false); + assert.match(row.summary, /atlas → iris — ready$/); +}); + +test("the body format rides on the row, and an empty body leaves no dash", () => { + assert.equal( + messageRow(msg("a", "b", "x", { body_format: "diff" }), "k").body_format, + "diff", + ); + assert.equal(messageRow(msg("a", "b", "x"), "k").body_format, undefined); + assert.doesNotMatch(messageRow(msg("a", "b", ""), "k").summary, / — /); +});