diff --git a/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx b/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx index 3f608e07..906a1296 100644 --- a/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/messages/MessagesPage.tsx @@ -8,6 +8,7 @@ // 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 { 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"; @@ -15,6 +16,7 @@ import { Panel } from "../../ui/panel/Panel.js"; import { SelectField } from "../../ui/select-field/SelectField.js"; import { TextField } from "../../ui/text-field/TextField.js"; import { + type ArrivedMessage, DEFAULT_WINDOW_MINUTES, filterMessages, messageRow, @@ -39,6 +41,13 @@ const CONNECTION_TONE: Record = { closed: "negative", }; +// One message's row. Memoised on the message, which keeps its identity while it +// is on the page, so a new arrival or a keystroke in the filter re-renders only +// the rows that changed, not every body's markdown. +const MessageRow = memo(function MessageRow({ m }: { m: ArrivedMessage }) { + return ; +}); + export function MessagesPage() { const [sinceMinutes, setSinceMinutes] = useState(DEFAULT_WINDOW_MINUTES); const [query, setQuery] = useState(""); @@ -57,11 +66,13 @@ export function MessagesPage() { } // After new rows are committed, follow the tail unless the reader has - // scrolled up. + // scrolled up. The newest id changes with every arrival, which the count + // does not once the list is full and each arrival pushes one off the front. + const newestId = shown.length > 0 ? shown[shown.length - 1].id : -1; useLayoutEffect(() => { const el = logRef.current; if (el && stickToBottom) el.scrollTop = el.scrollHeight - el.clientHeight; - }, [shown.length, stickToBottom]); + }, [newestId, shown.length, stickToBottom]); return ( ) : ( - shown.map((m, i) => ( - - )) + shown.map((m) => ) )} diff --git a/frontend/packages/swarm-ui/src/pages/messages/messages.ts b/frontend/packages/swarm-ui/src/pages/messages/messages.ts index 15addb54..ebbc8659 100644 --- a/frontend/packages/swarm-ui/src/pages/messages/messages.ts +++ b/frontend/packages/swarm-ui/src/pages/messages/messages.ts @@ -13,6 +13,23 @@ export interface SwarmMessage { body_format?: "markdown" | "diff"; } +/** + * A message with the id the page gave it when it arrived. The id only grows, + * so it names a row for as long as the message is on the page, however many + * older ones drop off the front. + */ +export interface ArrivedMessage extends SwarmMessage { + id: number; +} + +/** `batch` numbered from `firstId` on, in order. */ +export function numbered( + batch: SwarmMessage[], + firstId: number, +): ArrivedMessage[] { + return batch.map((m, i) => ({ ...m, id: firstId + i })); +} + /** The history windows the page offers, in minutes; 0 is live only. */ export const WINDOWS: { minutes: number; label: string }[] = [ { minutes: 0, label: "live only" }, @@ -33,10 +50,10 @@ export function messagesUrl(sinceMinutes: number): string { * 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[], +export function filterMessages( + messages: T[], query: string, -): SwarmMessage[] { +): T[] { const q = query.trim().toLowerCase(); if (q === "") return messages; return messages.filter( @@ -45,7 +62,10 @@ export function filterMessages( } /** At most `max` messages, the newest ones, when there are more. */ -export function newest(messages: SwarmMessage[], max: number): SwarmMessage[] { +export function newest( + messages: T[], + max: number, +): T[] { return messages.length > max ? messages.slice(messages.length - max) : messages; diff --git a/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts b/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts index 85284cbb..663935ed 100644 --- a/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts +++ b/frontend/packages/swarm-ui/src/pages/messages/useMessageStream.ts @@ -3,9 +3,13 @@ // 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"; +// +// Each message gets an id as it arrives, and the events that arrive within one +// frame join the list in a single update, so a long replay is one render per +// frame instead of one per message. +import { useEffect, useRef, useState } from "preact/hooks"; +import type { ArrivedMessage, SwarmMessage } from "./messages.js"; +import { messagesUrl, newest, numbered } from "./messages.js"; export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed"; @@ -13,17 +17,37 @@ export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed"; export const MAX_MESSAGES = 2000; export function useMessageStream(sinceMinutes: number): { - messages: SwarmMessage[]; + messages: ArrivedMessage[]; connection: ConnectionState; } { - const [messages, setMessages] = useState([]); + const [messages, setMessages] = useState([]); const [connection, setConnection] = useState("connecting"); + // The next id to hand out; it never goes back, so an id is not reused after + // a reconnect either. + const nextId = useRef(0); useEffect(() => { setMessages([]); setConnection("connecting"); const es = new EventSource(messagesUrl(sinceMinutes)); + let pending: SwarmMessage[] = []; + let frame: number | null = null; + + function drop() { + pending = []; + if (frame !== null) cancelAnimationFrame(frame); + frame = null; + } + function flush() { + frame = null; + const batch = numbered(pending, nextId.current); + pending = []; + nextId.current += batch.length; + setMessages((prev) => newest([...prev, ...batch], MAX_MESSAGES)); + } + es.onopen = () => { + drop(); setMessages([]); setConnection("open"); }; @@ -34,14 +58,18 @@ export function useMessageStream(sinceMinutes: number): { } catch { return; } - setMessages((prev) => newest([...prev, m], MAX_MESSAGES)); + pending.push(m); + if (frame === null) frame = requestAnimationFrame(flush); }; es.onerror = () => { setConnection( es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed", ); }; - return () => es.close(); + return () => { + drop(); + es.close(); + }; }, [sinceMinutes]); return { messages, connection }; diff --git a/frontend/packages/swarm-ui/test/messages.test.mjs b/frontend/packages/swarm-ui/test/messages.test.mjs index 23d1c34f..56771ec3 100644 --- a/frontend/packages/swarm-ui/test/messages.test.mjs +++ b/frontend/packages/swarm-ui/test/messages.test.mjs @@ -10,6 +10,7 @@ import { messageRow, messagesUrl, newest, + numbered, snippet, } from "../src/pages/messages/messages.ts"; @@ -56,6 +57,46 @@ test("the cap keeps the newest messages and leaves a short list alone", () => { assert.equal(newest(all, 10), all); }); +test("arrivals are numbered in order from the id given", () => { + const batch = numbered([msg("a", "b", "x"), msg("c", "d", "y")], 7); + assert.deepEqual( + batch.map((m) => [m.id, m.body]), + [ + [7, "x"], + [8, "y"], + ], + ); + assert.deepEqual(numbered([], 3), []); +}); + +test("in a full list the newest id still changes with every arrival", () => { + // The page follows the tail on the newest id, because the count stops + // moving once the cap drops one message per arrival. + let list = numbered( + [1, 2, 3].map((n) => msg("a", "b", String(n))), + 0, + ); + const seen = new Set(); + for (let id = 3; id < 8; id++) { + list = newest([...list, ...numbered([msg("a", "b", "n")], id)], 3); + assert.equal(list.length, 3); + seen.add(list[list.length - 1].id); + } + assert.equal(seen.size, 5); +}); + +test("a number-tagged message keeps its id through the filter and the cap", () => { + const all = numbered([msg("atlas", "iris"), msg("a", "b")], 40); + assert.deepEqual( + filterMessages(all, "atlas").map((m) => m.id), + [40], + ); + assert.deepEqual( + newest(all, 1).map((m) => m.id), + [41], + ); +}); + 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…");