+ {row.icon != null && row.icon !== '' &&
{row.icon}}
+ {row.text != null && linkifyToNodes(row.text)}
+ {row.meta?.map((m) => (
+
+ {m.text}
+
+ ))}
+ {row.childText != null &&
{row.childText.text}
}
+ {row.markdownBody != null &&
}
+
+ );
+}
diff --git a/frontend/packages/agent/src/hooks/useLiveStream.ts b/frontend/packages/agent/src/hooks/useLiveStream.ts
new file mode 100644
index 00000000..9df8b25a
--- /dev/null
+++ b/frontend/packages/agent/src/hooks/useLiveStream.ts
@@ -0,0 +1,183 @@
+// Backfill + live SSE for the agent's event stream, reduced to a plain
+// `StreamRow[]` — 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 event landing between EventSource-open
+// and the history response resolving is buffered, not dropped or
+// double-counted (seq <= history.seq AND the event's kind appeared in
+// the history replay → already covered, drop it from the buffer).
+import { useEffect, useRef, useState } from 'preact/hooks';
+import { classifyEvent, createClassifyCtx, type ClassifyCtx } from '../lib/classifyEvent.js';
+import type { StreamRow } from '../lib/streamRow.js';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any -- raw SSE payload, dynamically shaped
+type AnyEvent = any;
+
+export interface UseLiveStreamOptions {
+ historyUrl?: string;
+ streamUrl?: string;
+ /** Fired on every LIVE (not history-replay) turn_start/turn_end, so the
+ * caller can trigger a snappier `/api/state` refresh than the plain
+ * poll interval — mirrors app.js's turn_end → refreshState()/
+ * refreshTodos(). */
+ onLiveTurnBoundary?: () => void;
+}
+
+export interface UseLiveStreamResult {
+ rows: StreamRow[];
+ hasMore: boolean;
+ loadingMore: boolean;
+ loadMore: () => void;
+}
+
+function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] {
+ 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.coalesceKey && last && last.coalesceKey === row.coalesceKey) {
+ const next = rows.slice(0, -1);
+ next.push({ ...row, key: last.key });
+ return next;
+ }
+ return [...rows, row];
+}
+
+function appendMany(rows: StreamRow[], newRows: StreamRow[]): StreamRow[] {
+ let next = rows;
+ for (const r of newRows) next = appendRow(next, r);
+ return next;
+}
+
+export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamResult {
+ const historyUrl = opts.historyUrl ?? 'events/history';
+ const streamUrl = opts.streamUrl ?? 'events/stream';
+
+ const [rows, setRows] = useState