Simplify terminal message shape to a uniform TermMsg
Move terminal-row classification server-side into a new
hive-agent/src/term_msg.rs, replacing the old JSON-mutation
enrich()/stamped-field approach in stream_enrich.rs with one
uniform wire shape: {icon?, level: debug|info|warn|error, summary,
body?, body_format?: markdown|diff, coalesce_key?}. No more per-row
`kind` tag or raw claude-JSON passthrough — every row is the same
shape, with structural identity carried by icon + summary text
instead of a CSS class per row kind.
hive-agent/src/web_ui/stream.rs's history + SSE endpoints now both
call term_msg::classify() and serve TermEnvelope{ts, seq?, msgs}
frames; events that classify to zero rows (agent-state changes,
drop-noise) never reach the wire.
Frontend: classifyEvent.ts collapses from a large per-tool dispatch
tree to a thin TermMsg -> StreamRow adapter. streamRow.ts/Row.tsx
drop the now-dead meta/childText fields. terminal.css switches from
a dozen-odd per-row-kind classes to four level-based color rules.
Expand/collapse of a bodied row is now a uniform client-side
decision (the operator's preference), no server-side per-tool
override.
docs/terminal-rendering.md rewritten to match.
This commit is contained in:
parent
daa6eb96f8
commit
5eefaa951d
13 changed files with 886 additions and 628 deletions
|
|
@ -9,25 +9,28 @@
|
|||
// 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).
|
||||
// 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`.
|
||||
//
|
||||
// The old `onLiveTurnBoundary` callback (a snappier one-off `/api/state`
|
||||
// refresh right after a live turn_start/turn_end, instead of waiting for
|
||||
// the plain poll interval) is gone with the `kind` tag it relied on to
|
||||
// spot a turn boundary — per mara's own framing that trigger is an
|
||||
// agent-state concern, not a terminal-stream one, and this stream no
|
||||
// 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 } from '../lib/classifyEvent.js';
|
||||
import { classifyEvent, createClassifyCtx, type ClassifyCtx, type TermEnvelope } 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 {
|
||||
|
|
@ -75,40 +78,37 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
if (!ctxRef.current) ctxRef.current = createClassifyCtx();
|
||||
const minIdRef = useRef<number | null>(null);
|
||||
const liveRef = useRef(false);
|
||||
const bufferedRef = useRef<AnyEvent[]>([]);
|
||||
const onBoundaryRef = useRef(opts.onLiveTurnBoundary);
|
||||
onBoundaryRef.current = opts.onLiveTurnBoundary;
|
||||
const bufferedRef = useRef<TermEnvelope[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
function pushLive(ev: AnyEvent) {
|
||||
const newRows = classifyEvent(ev, false, ctxRef.current!);
|
||||
function pushLive(env: TermEnvelope) {
|
||||
const newRows = classifyEvent(env, false, ctxRef.current!);
|
||||
if (newRows.length) setRows((prev) => appendMany(prev, newRows));
|
||||
if (ev.kind === 'turn_start' || ev.kind === 'turn_end') onBoundaryRef.current?.();
|
||||
}
|
||||
|
||||
const es = new EventSource(streamUrl);
|
||||
es.onmessage = (e) => {
|
||||
let ev: AnyEvent;
|
||||
let env: TermEnvelope;
|
||||
try {
|
||||
ev = JSON.parse(e.data);
|
||||
env = JSON.parse(e.data);
|
||||
} catch {
|
||||
setRows((prev) => appendRow(prev, {
|
||||
key: 'parse-err-' + Date.now(), cssClass: 'note', fromHistory: false,
|
||||
key: 'parse-err-' + Date.now(), cssClass: 'level-warn', fromHistory: false,
|
||||
text: '[parse err] ' + e.data,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!liveRef.current) {
|
||||
bufferedRef.current.push(ev);
|
||||
bufferedRef.current.push(env);
|
||||
return;
|
||||
}
|
||||
pushLive(ev);
|
||||
pushLive(env);
|
||||
};
|
||||
es.onerror = () => {
|
||||
setRows((prev) => appendRow(prev, {
|
||||
key: 'conn-note', cssClass: 'note', fromHistory: false, coalesceKey: 'conn-status',
|
||||
key: 'conn-note', cssClass: 'level-warn', fromHistory: false, coalesceKey: 'conn-status',
|
||||
text: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]',
|
||||
}));
|
||||
};
|
||||
|
|
@ -118,30 +118,29 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
const resp = await fetch(historyUrl);
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
const body = await resp.json();
|
||||
const events: AnyEvent[] = Array.isArray(body) ? body : body.events || [];
|
||||
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;
|
||||
}
|
||||
const historyKinds = new Set(events.map((e) => e.kind));
|
||||
if (cancelled) return;
|
||||
|
||||
let initial: StreamRow[] = [];
|
||||
for (const ev of events) initial = appendMany(initial, classifyEvent(ev, true, ctxRef.current!));
|
||||
for (const env of events) initial = appendMany(initial, classifyEvent(env, true, ctxRef.current!));
|
||||
initial = events.length
|
||||
? appendRow(initial, { key: 'live-sep', cssClass: 'note', fromHistory: true, text: '─── live (older above) ───' })
|
||||
: [{ key: 'placeholder', cssClass: 'note', fromHistory: true, text: '(connected — waiting for events)' }];
|
||||
? 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)' }];
|
||||
setRows(initial);
|
||||
|
||||
const drained = bufferedRef.current;
|
||||
bufferedRef.current = [];
|
||||
liveRef.current = true;
|
||||
for (const ev of drained) {
|
||||
if (boundarySeq != null && typeof ev.seq === 'number' && ev.seq <= boundarySeq && historyKinds.has(ev.kind)) {
|
||||
continue;
|
||||
}
|
||||
pushLive(ev);
|
||||
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);
|
||||
|
|
@ -149,7 +148,7 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
const drained = bufferedRef.current;
|
||||
bufferedRef.current = [];
|
||||
liveRef.current = true;
|
||||
for (const ev of drained) pushLive(ev);
|
||||
for (const env of drained) pushLive(env);
|
||||
}
|
||||
}
|
||||
backfill();
|
||||
|
|
@ -168,14 +167,14 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
|
|||
const resp = await fetch(historyUrl + sep + 'before=' + minIdRef.current);
|
||||
if (!resp.ok) return;
|
||||
const body = await resp.json();
|
||||
const events: AnyEvent[] = Array.isArray(body) ? body : body.events || [];
|
||||
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: StreamRow[] = [];
|
||||
for (const ev of events) older = appendMany(older, classifyEvent(ev, true, ctxRef.current!));
|
||||
for (const env of events) older = appendMany(older, classifyEvent(env, true, ctxRef.current!));
|
||||
older = appendRow(older, {
|
||||
key: 'older-sep-' + minIdRef.current, cssClass: 'note', fromHistory: true, text: '─── older above ───',
|
||||
key: 'older-sep-' + minIdRef.current, cssClass: 'level-debug', fromHistory: true, text: '─── older above ───',
|
||||
});
|
||||
setRows((prev) => [...older, ...prev]);
|
||||
}
|
||||
|
|
@ -189,7 +188,7 @@ 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: 'note', fromHistory: false, text }));
|
||||
setRows((prev) => appendRow(prev, { key: `local-${localKeyRef.current}`, cssClass: 'level-info', fromHistory: false, text }));
|
||||
}
|
||||
function clearLocal() {
|
||||
setRows([]);
|
||||
|
|
|
|||
Loading…
Reference in a new issue