hyperhive/frontend/packages/agent/src/hooks/useLiveStream.ts
atlas 39b95c2ede treefmt: apply prettier
Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
2026-09-02 15:25:07 +02:00

242 lines
7.6 KiB
TypeScript

// Backfill + live SSE for the agent's event stream, reduced to a plain
// `TermRow[]` — the Preact-data sibling of @hive/shared/terminal.js's
// `create()`. Scroll behaviour is deliberately NOT this hook's job (see
// components/LiveStream.tsx): mara flagged the old page's scroll-while-
// streaming bug explicitly as something not to copy 1:1, and keeping
// "what rows exist" separate from "where the viewport is" is what makes
// that fixable — this hook only ever appends/prepends to `rows`, the
// DOM-owning component decides whether that should move the scroll
// position.
//
// Same subscribe → buffer → fetch-history → seq-dedupe → flush dance as
// terminal.js's `start()`: a live envelope landing between EventSource-
// open and the history response resolving is buffered, not dropped or
// double-counted (`envelope.seq <= history.seq` → already covered by the
// initial history page, drop it from the buffer). `seq` alone is the
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
// `web_ui/stream.rs`.
import { useEffect, useRef, useState } from "preact/hooks";
import type { TermEnvelope, TermRow } from "../lib/termMsg.js";
export interface UseLiveStreamOptions {
historyUrl?: string;
streamUrl?: string;
}
export interface UseLiveStreamResult {
rows: TermRow[];
hasMore: boolean;
loadingMore: boolean;
loadMore: () => void;
/** Append a local-only note row (TermInput's `/help` output, error
* feedback on a failed slash-command POST) — never sent anywhere,
* purely a client-side echo. */
pushLocalNote: (text: string) => void;
/** Wipe the local view only (server-side history is untouched) —
* TermInput's `/clear`. */
clearLocal: () => void;
}
function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
const last = rows[rows.length - 1];
// Coalesce in place only while the coalescible row is still the last
// one in the list — any other row landing in between starts a fresh
// one, same rule as terminal.js's makeCoalescer.
if (row.coalesce_key && last && last.coalesce_key === row.coalesce_key) {
const next = rows.slice(0, -1);
next.push({ ...row, key: last.key });
return next;
}
return [...rows, row];
}
export function useLiveStream(
opts: UseLiveStreamOptions = {},
): UseLiveStreamResult {
const historyUrl = opts.historyUrl ?? "events/history";
const streamUrl = opts.streamUrl ?? "events/stream";
const [rows, setRows] = useState<TermRow[]>([]);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const keySeqRef = useRef(0);
function nextKey(): string {
keySeqRef.current += 1;
return "r" + keySeqRef.current;
}
function toRows(env: TermEnvelope, fromHistory: boolean): TermRow[] {
return env.msgs.map((m) => ({ ...m, key: nextKey(), fromHistory }));
}
function appendEnvelope(
rows: TermRow[],
env: TermEnvelope,
fromHistory: boolean,
): TermRow[] {
let next = rows;
for (const row of toRows(env, fromHistory)) next = appendRow(next, row);
return next;
}
const minIdRef = useRef<number | null>(null);
const liveRef = useRef(false);
const bufferedRef = useRef<TermEnvelope[]>([]);
useEffect(() => {
let cancelled = false;
function pushLive(env: TermEnvelope) {
if (env.msgs.length) setRows((prev) => appendEnvelope(prev, env, false));
}
const es = new EventSource(streamUrl);
es.onmessage = (e) => {
let env: TermEnvelope;
try {
env = JSON.parse(e.data);
} catch {
setRows((prev) =>
appendRow(prev, {
key: "parse-err-" + Date.now(),
level: "warn",
summary: "[parse err] " + e.data,
fromHistory: false,
}),
);
return;
}
if (!liveRef.current) {
bufferedRef.current.push(env);
return;
}
pushLive(env);
};
es.onerror = () => {
setRows((prev) =>
appendRow(prev, {
key: "conn-note",
level: "warn",
fromHistory: false,
coalesce_key: "conn-status",
summary:
es.readyState === 0 /* CONNECTING */
? "[reconnecting…]"
: "[disconnected]",
}),
);
};
async function backfill() {
try {
const resp = await fetch(historyUrl);
if (!resp.ok) throw new Error("http " + resp.status);
const body = await resp.json();
const events: TermEnvelope[] = Array.isArray(body)
? body
: body.events || [];
const boundarySeq: number | null = Array.isArray(body)
? null
: (body.seq ?? null);
if (!Array.isArray(body)) {
setHasMore(!!body.has_more);
if (typeof body.min_id === "number") minIdRef.current = body.min_id;
}
if (cancelled) return;
let initial: TermRow[] = [];
for (const env of events) initial = appendEnvelope(initial, env, true);
initial = events.length
? appendRow(initial, {
key: "live-sep",
level: "debug",
fromHistory: true,
summary: "─── live (older above) ───",
})
: [
{
key: "placeholder",
level: "debug",
fromHistory: true,
summary: "(connected — waiting for events)",
},
];
setRows(initial);
const drained = bufferedRef.current;
bufferedRef.current = [];
liveRef.current = true;
for (const env of drained) {
// Already covered by the initial history page — drop it from
// the buffer rather than rendering it twice.
if (
boundarySeq != null &&
typeof env.seq === "number" &&
env.seq <= boundarySeq
)
continue;
pushLive(env);
}
} catch (err) {
console.warn("history backfill failed", err);
if (cancelled) return;
const drained = bufferedRef.current;
bufferedRef.current = [];
liveRef.current = true;
for (const env of drained) pushLive(env);
}
}
backfill();
return () => {
cancelled = true;
es.close();
};
}, [historyUrl, streamUrl]);
async function loadMore() {
if (!hasMore || loadingMore || minIdRef.current == null) return;
setLoadingMore(true);
try {
const sep = historyUrl.includes("?") ? "&" : "?";
const resp = await fetch(historyUrl + sep + "before=" + minIdRef.current);
if (!resp.ok) return;
const body = await resp.json();
const events: TermEnvelope[] = Array.isArray(body)
? body
: body.events || [];
setHasMore(!!body.has_more);
if (typeof body.min_id === "number") minIdRef.current = body.min_id;
if (events.length) {
let older: TermRow[] = [];
for (const env of events) older = appendEnvelope(older, env, true);
older = appendRow(older, {
key: "older-sep-" + minIdRef.current,
level: "debug",
fromHistory: true,
summary: "─── older above ───",
});
setRows((prev) => [...older, ...prev]);
}
} catch (err) {
console.warn("loadMore failed", err);
} finally {
setLoadingMore(false);
}
}
function pushLocalNote(text: string) {
setRows((prev) =>
appendRow(prev, {
key: "local-" + nextKey(),
level: "info",
fromHistory: false,
summary: text,
}),
);
}
function clearLocal() {
setRows([]);
}
return { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal };
}