Don't state what a function/module doesn't do and where that happens instead — just describe what it does. Cut the "not something this function decides" / "not affected by this" / "not a placeholder for a later commit" asides from the doc comments touched in the last two commits.
190 lines
7.1 KiB
TypeScript
190 lines
7.1 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 };
|
|
}
|