Drop classifyEvent.ts, render TermMsg directly

Per review: StreamRow was meant to match what the server sends in
TermMsg, not be a separate model needing a translation step.

- classifyEvent.ts and streamRow.ts deleted; termMsg.ts holds the wire
  types (TermMsg/TermEnvelope) plus TermRow, a TermMsg with just the
  key/fromHistory bookkeeping Preact needs for list rendering.
- Row.tsx renders a TermRow directly: level -> CSS class, empty
  summary + markdown body -> flat row, everything else with a body ->
  expandable details gated by the operator's preference. No separate
  classification step.
- useLiveStream.ts drops ClassifyCtx (a single incrementing key
  counter didn't need a whole context object) and maps envelopes to
  rows inline.
- docs/terminal-rendering.md trimmed substantially — was documenting
  more implementation detail than useful; points at stream_enrich.rs
  for the per-tool specifics instead of duplicating them in prose.
This commit is contained in:
iris 2026-08-30 21:23:28 +02:00
commit cebf3c6ced
6 changed files with 156 additions and 436 deletions

View file

@ -1,21 +1,21 @@
// 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.
// `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). Post mara's terminal-
// message redesign there's no per-row `kind` any more to sanity-check
// that against — `seq` alone is the whole dedup signal, see
// `TermEnvelope`'s doc in hive-agent's `web_ui/stream.rs`.
// initial history page, drop it from the buffer). There's no per-row
// `kind` any more to sanity-check that against — `seq` alone is the
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
// `web_ui/stream.rs`.
//
// The old `onLiveTurnBoundary` callback (a snappier one-off `/api/state`
// refresh right after a live turn_start/turn_end, instead of waiting for
@ -25,8 +25,7 @@
// longer has the structure to single one out. `useAgentState`'s 4s poll
// is the only refresh path now.
import { useEffect, useRef, useState } from 'preact/hooks';
import { classifyEvent, createClassifyCtx, type ClassifyCtx, type TermEnvelope } from '../lib/classifyEvent.js';
import type { StreamRow } from '../lib/streamRow.js';
import type { TermEnvelope, TermRow } from '../lib/termMsg.js';
export interface UseLiveStreamOptions {
historyUrl?: string;
@ -34,7 +33,7 @@ export interface UseLiveStreamOptions {
}
export interface UseLiveStreamResult {
rows: StreamRow[];
rows: TermRow[];
hasMore: boolean;
loadingMore: boolean;
loadMore: () => void;
@ -47,12 +46,12 @@ export interface UseLiveStreamResult {
clearLocal: () => void;
}
function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] {
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.coalesceKey && last && last.coalesceKey === row.coalesceKey) {
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;
@ -60,22 +59,28 @@ function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] {
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<StreamRow[]>([]);
const [rows, setRows] = useState<TermRow[]>([]);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const ctxRef = useRef<ClassifyCtx | null>(null);
if (!ctxRef.current) ctxRef.current = createClassifyCtx();
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[]>([]);
@ -84,8 +89,7 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
let cancelled = false;
function pushLive(env: TermEnvelope) {
const newRows = classifyEvent(env, false, ctxRef.current!);
if (newRows.length) setRows((prev) => appendMany(prev, newRows));
if (env.msgs.length) setRows((prev) => appendEnvelope(prev, env, false));
}
const es = new EventSource(streamUrl);
@ -95,8 +99,7 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
env = JSON.parse(e.data);
} catch {
setRows((prev) => appendRow(prev, {
key: 'parse-err-' + Date.now(), cssClass: 'level-warn', fromHistory: false,
text: '[parse err] ' + e.data,
key: 'parse-err-' + Date.now(), level: 'warn', summary: '[parse err] ' + e.data, fromHistory: false,
}));
return;
}
@ -108,8 +111,8 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
};
es.onerror = () => {
setRows((prev) => appendRow(prev, {
key: 'conn-note', cssClass: 'level-warn', fromHistory: false, coalesceKey: 'conn-status',
text: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]',
key: 'conn-note', level: 'warn', fromHistory: false, coalesce_key: 'conn-status',
summary: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]',
}));
};
@ -126,11 +129,11 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
}
if (cancelled) return;
let initial: StreamRow[] = [];
for (const env of events) initial = appendMany(initial, classifyEvent(env, true, ctxRef.current!));
let initial: TermRow[] = [];
for (const env of events) initial = appendEnvelope(initial, env, true);
initial = events.length
? appendRow(initial, { key: 'live-sep', cssClass: 'level-debug', fromHistory: true, text: '─── live (older above) ───' })
: [{ key: 'placeholder', cssClass: 'level-debug', fromHistory: true, text: '(connected — waiting for events)' }];
? 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;
@ -171,10 +174,10 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
setHasMore(!!body.has_more);
if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
if (events.length) {
let older: StreamRow[] = [];
for (const env of events) older = appendMany(older, classifyEvent(env, true, ctxRef.current!));
let older: TermRow[] = [];
for (const env of events) older = appendEnvelope(older, env, true);
older = appendRow(older, {
key: 'older-sep-' + minIdRef.current, cssClass: 'level-debug', fromHistory: true, text: '─── older above ───',
key: 'older-sep-' + minIdRef.current, level: 'debug', fromHistory: true, summary: '─── older above ───',
});
setRows((prev) => [...older, ...prev]);
}
@ -185,10 +188,8 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
}
}
const localKeyRef = useRef(0);
function pushLocalNote(text: string) {
localKeyRef.current += 1;
setRows((prev) => appendRow(prev, { key: `local-${localKeyRef.current}`, cssClass: 'level-info', fromHistory: false, text }));
setRows((prev) => appendRow(prev, { key: 'local-' + nextKey(), level: 'info', fromHistory: false, summary: text }));
}
function clearLocal() {
setRows([]);