// 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([]); 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(null); const liveRef = useRef(false); const bufferedRef = useRef([]); 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 }; }