swarm-ui: add a messages page for the swarm-wide message feed
/messages reads GET /api/messages/stream. A history select picks the window (live only up to 24 hours) and a filter keeps the messages whose sender or recipient contains the text. Each message is a row of the terminal list an agent's terminal uses: one 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 and holds the last 2000. The feed has no event id, so a reconnect sends the history again. The page empties its list each time the connection opens and fills it from the replay, where the terminal stream hook, which keeps its rows across a reconnect, would list every message twice. The stream URL, filter, cap and row logic is in its own module with tests, and docs/swarm/ui.md describes the page.
This commit is contained in:
parent
c32906b893
commit
447498fef7
8 changed files with 395 additions and 0 deletions
|
|
@ -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 |
|
| `/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 |
|
| `/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 |
|
| `/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 |
|
||||||
|
|
@ -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
|
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.
|
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
|
A command has its own subject, so the harness never mistakes typed text for
|
||||||
one. `POST /api/agents/<name>/cancel` sends `"cancel"` as a NATS request on
|
one. `POST /api/agents/<name>/cancel` sends `"cancel"` as a NATS request on
|
||||||
`$SWARM.control.<name>`, and only swarm-controller may publish there. The
|
`$SWARM.control.<name>`, and only swarm-controller may publish there. The
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ 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 { BroadcastPage } from "./pages/broadcast/BroadcastPage.js";
|
||||||
|
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
||||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||||
import { JobsPage } from "./pages/JobsPage.js";
|
import { JobsPage } from "./pages/JobsPage.js";
|
||||||
import { HivesPage } from "./pages/HivesPage.js";
|
import { HivesPage } from "./pages/HivesPage.js";
|
||||||
|
|
@ -41,6 +42,7 @@ export function App() {
|
||||||
<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="/broadcast" component={BroadcastPage} />
|
||||||
|
<Route path="/messages" component={MessagesPage} />
|
||||||
<Route path="/issues" component={IssueReportPage} />
|
<Route path="/issues" component={IssueReportPage} />
|
||||||
<Route path="/review" component={ReviewPage} />
|
<Route path="/review" component={ReviewPage} />
|
||||||
<Route path="/components" component={ComponentsPage} />
|
<Route path="/components" component={ComponentsPage} />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
/* <MessagesPage>: 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);
|
||||||
|
}
|
||||||
130
frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx
Normal file
130
frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// <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`). 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<ConnectionState, string> = {
|
||||||
|
connecting: "connecting…",
|
||||||
|
open: "live",
|
||||||
|
reconnecting: "reconnecting…",
|
||||||
|
closed: "disconnected",
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTION_TONE: Record<ConnectionState, BadgeTone> = {
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<Panel
|
||||||
|
title="messages"
|
||||||
|
icon="💬"
|
||||||
|
actions={
|
||||||
|
<Badge
|
||||||
|
tone={CONNECTION_TONE[connection]}
|
||||||
|
value={CONNECTION_LABEL[connection]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="ui-messages-bar">
|
||||||
|
<SelectField
|
||||||
|
id="messages-window"
|
||||||
|
label="history"
|
||||||
|
value={String(sinceMinutes)}
|
||||||
|
onChange={(v) => setSinceMinutes(Number(v))}
|
||||||
|
options={WINDOWS.map((w) => ({
|
||||||
|
value: String(w.minutes),
|
||||||
|
label: w.label,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="messages-filter"
|
||||||
|
label="agent"
|
||||||
|
value={query}
|
||||||
|
placeholder="sender or recipient"
|
||||||
|
onInput={setQuery}
|
||||||
|
/>
|
||||||
|
<span class="ui-messages-count">
|
||||||
|
{query.trim() === ""
|
||||||
|
? `${messages.length} messages`
|
||||||
|
: `${shown.length} of ${messages.length} messages`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="terminal-wrap ui-messages-wrap">
|
||||||
|
<div class="live terminal" ref={logRef} onScroll={handleScroll}>
|
||||||
|
{shown.length === 0 ? (
|
||||||
|
<div class="row note">
|
||||||
|
<span class="row-content">
|
||||||
|
{connection === "open"
|
||||||
|
? messages.length === 0
|
||||||
|
? "(connected — no messages yet)"
|
||||||
|
: "(no message matches the filter)"
|
||||||
|
: CONNECTION_LABEL[connection]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
shown.map((m, i) => (
|
||||||
|
<Row
|
||||||
|
key={`${m.ts}|${m.from}|${m.to}|${i}`}
|
||||||
|
row={messageRow(m, "")}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="ui-messages-note">
|
||||||
|
Shows what the agents sent each other. A message an agent drained with
|
||||||
|
an in-turn <code>recv</code> is missing. The history is at most 100
|
||||||
|
recent messages per agent.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
frontend/packages/swarm-ui/src/pages/messages/messages.ts
Normal file
88
frontend/packages/swarm-ui/src/pages/messages/messages.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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<SwarmMessage[]>([]);
|
||||||
|
const [connection, setConnection] = useState<ConnectionState>("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 };
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,7 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
||||||
{ 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: "/broadcast", label: "broadcast", accent: "var(--blue)" },
|
||||||
|
{ href: "/messages", label: "messages", accent: "var(--purple)" },
|
||||||
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
|
{ href: "/issues", label: "issues", accent: "var(--yellow)" },
|
||||||
{ href: "/review", label: "review", accent: "var(--amber)" },
|
{ href: "/review", label: "review", accent: "var(--amber)" },
|
||||||
{ href: "/builds", label: "builds", accent: "var(--green)" },
|
{ href: "/builds", label: "builds", accent: "var(--green)" },
|
||||||
|
|
|
||||||
87
frontend/packages/swarm-ui/test/messages.test.mjs
Normal file
87
frontend/packages/swarm-ui/test/messages.test.mjs
Normal file
|
|
@ -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, / — /);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue