From 8521c6becb9a925d1b4d29e6c1376ae89f2cbf0e Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 28 Aug 2026 02:30:28 +0200 Subject: [PATCH] =?UTF-8?q?agent:=20LiveStream/Row/DetailsRow=20=E2=80=94?= =?UTF-8?q?=20Preact=20live=20event=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the row taxonomy in docs/terminal-rendering.md (app.js's renderStream/renderRichToolUse/renderToolResult/renderTaskEvent) to real Preact components + hooks: - lib/streamRow.ts: plain-data StreamRow model (one row = one line/ panel), lib/classifyEvent.ts: raw stream-json event -> StreamRow[], almost entirely dispatching on the backend's precomputed _icon/ _summary/_category/_body/_body_type fields, same as the old client. - lib/markdown.ts, lib/linkify.tsx: sanitized-markdown + auto-link helpers, same behavior as app.js's mdNode/terminal.js's linkify. - hooks/useLiveStream.ts: backfill + SSE + seq-dedupe + coalescing, reduced to a plain StreamRow[] — deliberately has no opinion on scroll position, only on what rows exist. - components/Row.tsx: renders one StreamRow (flat or details). - components/LiveStream.tsx: owns the scrollable DOM node + a from- scratch sticky-bottom implementation — not the old MutationObserver + rAF snap-animation + smoothScrollingUntil gate. stickToBottom is plain state driven by the scroll handler; snapping is an instant scrollTop write in a useLayoutEffect that runs after Preact has already committed the new rows, so it always sees the real scrollHeight. A load-older prepend is told apart from a normal append via an explicit ref set right before calling loadMore(), rather than inferred from DOM mutation shape after the fact. Wired into Root.tsx below Header/StatusChips; turn_start/turn_end also nudge useAgentState's refresh() for a snappier badge update than the plain poll interval. Reuses @hive/shared/terminal.css's existing row-kind classes as-is — the taxonomy's visual language isn't what changed, the component model underneath it is. --- frontend/packages/agent/src/Root.tsx | 93 ++++--- .../agent/src/components/LiveStream.tsx | 113 ++++++++ .../packages/agent/src/components/Row.tsx | 71 +++++ .../packages/agent/src/hooks/useLiveStream.ts | 183 +++++++++++++ .../packages/agent/src/lib/classifyEvent.ts | 250 ++++++++++++++++++ frontend/packages/agent/src/lib/format.ts | 7 + frontend/packages/agent/src/lib/linkify.tsx | 39 +++ frontend/packages/agent/src/lib/markdown.ts | 25 ++ frontend/packages/agent/src/lib/streamRow.ts | 49 ++++ 9 files changed, 789 insertions(+), 41 deletions(-) create mode 100644 frontend/packages/agent/src/components/LiveStream.tsx create mode 100644 frontend/packages/agent/src/components/Row.tsx create mode 100644 frontend/packages/agent/src/hooks/useLiveStream.ts create mode 100644 frontend/packages/agent/src/lib/classifyEvent.ts create mode 100644 frontend/packages/agent/src/lib/linkify.tsx create mode 100644 frontend/packages/agent/src/lib/markdown.ts create mode 100644 frontend/packages/agent/src/lib/streamRow.ts diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index 40e3532b..9834b85d 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -5,6 +5,7 @@ import type { BadgeTone } from '@hive/shared/badge.js'; import { Header } from './components/Header.js'; import { StatusChips } from './components/StatusChips.js'; +import { LiveStream } from './components/LiveStream.js'; import { useAgentState } from './hooks/useAgentState.js'; import { fmtAge, fmtTokens } from './lib/format.js'; import { resolveDashboardBase } from './lib/dashboardBase.js'; @@ -43,22 +44,27 @@ export function Root() { if (!state) { return ( -
- {}} - effort="" - availableEfforts={[]} - onSelectEffort={() => {}} - paused={false} - onTogglePause={() => {}} - /> -
+ <> +
+ {}} + effort="" + availableEfforts={[]} + onSelectEffort={() => {}} + paused={false} + onTogglePause={() => {}} + /> +
+
+ +
+ ); } @@ -74,30 +80,35 @@ export function Root() { const cost = tokenTotal(state.cost_usage); return ( -
- { - postModel(name).then(() => refresh()); - }} - effort={state.effort} - availableEfforts={state.available_efforts} - onSelectEffort={(level) => { - postEffort(level).then(() => refresh()); - }} - ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined} - costLabel={cost !== null ? fmtTokens(cost) : undefined} - paused={state.paused} - onTogglePause={() => { - submitPauseResume(resolveDashboardBase(state.dashboard_port), state.label, state.paused ? 'resume' : 'pause'); - }} - /> -
+ <> +
+ { + postModel(name).then(() => refresh()); + }} + effort={state.effort} + availableEfforts={state.available_efforts} + onSelectEffort={(level) => { + postEffort(level).then(() => refresh()); + }} + ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined} + costLabel={cost !== null ? fmtTokens(cost) : undefined} + paused={state.paused} + onTogglePause={() => { + submitPauseResume(resolveDashboardBase(state.dashboard_port), state.label, state.paused ? 'resume' : 'pause'); + }} + /> +
+
+ +
+ ); } diff --git a/frontend/packages/agent/src/components/LiveStream.tsx b/frontend/packages/agent/src/components/LiveStream.tsx new file mode 100644 index 00000000..64e9a0d1 --- /dev/null +++ b/frontend/packages/agent/src/components/LiveStream.tsx @@ -0,0 +1,113 @@ +// Live event pane — Preact owner of the DOM node `useLiveStream`'s rows +// get rendered into. Deliberately NOT the old MutationObserver + rAF +// snap-animation + `smoothScrollingUntil` gate machinery +// (@hive/shared/terminal.js) — mara explicitly flagged that the old +// page's autoscroll breaks when new events land while the operator has +// scrolled up, and not to copy it 1:1. This is the "do it properly the +// preact way" version: +// +// - `stickToBottom` is plain state, updated synchronously from the +// scroll handler (near-bottom → true, scrolled away → false). No +// separate "am I mid-animation" gate to fight with it — there's no +// animation to fight, snapping is an instant `scrollTop` write in a +// `useLayoutEffect` that runs once after Preact has already +// committed the new rows to the DOM, so it always sees the real +// `scrollHeight`, never a stale pre-mutation one. +// - A "load older" prepend is told apart from a normal append via +// `prependingRef` (set immediately before calling `loadMore()`) so +// the effect can choose the right response: viewport-preserving +// scrollTop compensation for a prepend, stick-to-bottom-or-count- +// unseen for an append. The old code inferred this from DOM +// mutation shape after the fact; here the caller already knows +// which one it did, so there's nothing to infer. +import { useLayoutEffect, useRef, useState } from 'preact/hooks'; +import { useLiveStream } from '../hooks/useLiveStream.js'; +import { Row } from './Row.js'; + +const NEAR_BOTTOM_PX = 48; +const LOAD_MORE_SCROLL_PX = 80; + +export interface LiveStreamProps { + /** Forwarded to useLiveStream — fires on live turn_start/turn_end so + * the caller can refresh `/api/state` sooner than its poll interval. */ + onLiveTurnBoundary?: () => void; +} + +export function LiveStream({ onLiveTurnBoundary }: LiveStreamProps) { + const { rows, hasMore, loadingMore, loadMore } = useLiveStream({ onLiveTurnBoundary }); + const logRef = useRef(null); + const [stickToBottom, setStickToBottom] = useState(true); + const [unseen, setUnseen] = useState(0); + const prevRowCount = useRef(0); + const prependingRef = useRef(false); + const preScrollHeightRef = useRef(0); + + function isNearBottom(el: HTMLDivElement): boolean { + return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX; + } + + function handleScroll() { + const el = logRef.current; + if (!el) return; + const nearBottom = isNearBottom(el); + setStickToBottom(nearBottom); + if (nearBottom) setUnseen(0); + if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore) startLoadMore(); + } + + function startLoadMore() { + const el = logRef.current; + if (!el || loadingMore || !hasMore) return; + prependingRef.current = true; + preScrollHeightRef.current = el.scrollHeight; + loadMore(); + } + + function jumpToBottom() { + const el = logRef.current; + if (el) el.scrollTop = el.scrollHeight - el.clientHeight; + setStickToBottom(true); + setUnseen(0); + } + + // Runs after every commit that changed `rows` — i.e. after the browser + // has already laid out the new/coalesced content, so `scrollHeight` + // below is always current. + useLayoutEffect(() => { + const el = logRef.current; + if (!el) return; + const added = rows.length - prevRowCount.current; + prevRowCount.current = rows.length; + + if (prependingRef.current) { + prependingRef.current = false; + el.scrollTop += el.scrollHeight - preScrollHeightRef.current; + return; + } + if (stickToBottom) { + el.scrollTop = el.scrollHeight - el.clientHeight; + } else if (added > 0) { + setUnseen((n) => n + added); + } + }, [rows, stickToBottom]); + + return ( +
+
+ {hasMore && ( + + )} + {rows.map((row) => ( + + ))} +
+ {unseen > 0 && ( + + )} +
+ ); +} diff --git a/frontend/packages/agent/src/components/Row.tsx b/frontend/packages/agent/src/components/Row.tsx new file mode 100644 index 00000000..2f2c9900 --- /dev/null +++ b/frontend/packages/agent/src/components/Row.tsx @@ -0,0 +1,71 @@ +// Renders one `StreamRow` — flat `
` or expandable +// `
`, matching @hive/shared/terminal.css's +// existing row-kind classes exactly (see docs/terminal-rendering.md). +// Reuses that stylesheet as-is (imported once by LiveStream.tsx) — the +// taxonomy's visual language isn't what mara asked to change, the +// component *model* underneath it is. +import { useEffect, useRef } from 'preact/hooks'; +import type { StreamRow } from '../lib/streamRow.js'; +import { linkifyToNodes } from '../lib/linkify.js'; +import { renderMarkdown } from '../lib/markdown.js'; + +function MarkdownBody({ text }: { text: string }) { + const ref = useRef(null); + const html = renderMarkdown(text); + useEffect(() => { + // marked autolinks URLs but leaves them same-tab — open externally + // so a click never navigates the terminal away. + ref.current?.querySelectorAll('a[href]').forEach((a) => { + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + }); + }, [html]); + // eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown + return
; +} + +function DiffBody({ text }: { text: string }) { + const lines = String(text).split('\n'); + return ( +
+      {lines.map((line, i) => {
+        const cls = line.startsWith('+ ') ? 'diff-add' : line.startsWith('- ') ? 'diff-del' : 'diff-ctx';
+        return (
+          
+            {line}
+            {'\n'}
+          
+        );
+      })}
+    
+ ); +} + +export function Row({ row }: { row: StreamRow }) { + if (row.details) { + return ( +
+ + {row.icon != null && row.icon !== '' && {row.icon}} + {row.text} + + {row.diffBody != null && } + {row.markdownBody != null && } + {row.plainBody != null &&
{linkifyToNodes(row.plainBody)}
} +
+ ); + } + return ( +
+ {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([]); + const [hasMore, setHasMore] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + + const ctxRef = useRef(null); + if (!ctxRef.current) ctxRef.current = createClassifyCtx(); + const minIdRef = useRef(null); + const liveRef = useRef(false); + const bufferedRef = useRef([]); + const onBoundaryRef = useRef(opts.onLiveTurnBoundary); + onBoundaryRef.current = opts.onLiveTurnBoundary; + + useEffect(() => { + let cancelled = false; + + function pushLive(ev: AnyEvent) { + const newRows = classifyEvent(ev, 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; + try { + ev = JSON.parse(e.data); + } catch { + setRows((prev) => appendRow(prev, { + key: 'parse-err-' + Date.now(), cssClass: 'note', fromHistory: false, + text: '[parse err] ' + e.data, + })); + return; + } + if (!liveRef.current) { + bufferedRef.current.push(ev); + return; + } + pushLive(ev); + }; + es.onerror = () => { + setRows((prev) => appendRow(prev, { + key: 'conn-note', cssClass: 'note', fromHistory: false, coalesceKey: 'conn-status', + text: 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: AnyEvent[] = 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!)); + 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)' }]; + 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); + } + } catch (err) { + console.warn('history backfill failed', err); + if (cancelled) return; + const drained = bufferedRef.current; + bufferedRef.current = []; + liveRef.current = true; + for (const ev of drained) pushLive(ev); + } + } + 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: AnyEvent[] = 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!)); + older = appendRow(older, { + key: 'older-sep-' + minIdRef.current, cssClass: 'note', fromHistory: true, text: '─── older above ───', + }); + setRows((prev) => [...older, ...prev]); + } + } catch (err) { + console.warn('loadMore failed', err); + } finally { + setLoadingMore(false); + } + } + + return { rows, hasMore, loadingMore, loadMore }; +} diff --git a/frontend/packages/agent/src/lib/classifyEvent.ts b/frontend/packages/agent/src/lib/classifyEvent.ts new file mode 100644 index 00000000..07aa5784 --- /dev/null +++ b/frontend/packages/agent/src/lib/classifyEvent.ts @@ -0,0 +1,250 @@ +// Turns one raw harness/stream-json event into zero or more `StreamRow`s. +// Preact-data port of app.js's `renderStream` / `renderRichToolUse` / +// `renderToolResult` / `renderTaskEvent` — see docs/terminal-rendering.md +// for the row taxonomy this mirrors. Almost all per-tool classification +// is already done server-side (`hive-agent/src/stream_enrich.rs::enrich` +// stamps `_icon`/`_summary`/`_category`/`_body`/`_body_type`), so this +// mostly just dispatches on those fields rather than re-deriving them. +// +// One deliberate simplification vs. app.js: the client-side "compacting… +// s" live override on `system/status` ticks (computed from the +// harness's local `stateSince`) isn't ported — this just shows the +// backend's `_summary` as-is. `turn_state`/`state_since` (and therefore +// the "compacting" badge itself) still update correctly via +// useAgentState's poll; only that one status row's live elapsed-seconds +// text loses its client-side tick. Revisit if that's missed in practice. +import type { StreamRow, StreamRowMeta } from './streamRow.js'; +import { fmtAge, fmtClock } from './format.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- stream-json +// content is dynamically-shaped JSON, same as app.js's untyped handling. +type AnyEvent = any; + +export interface ClassifyCtx { + toolNameById: Map; + pendingTurnStartTs: { current: number | null }; + keySeq: { current: number }; +} + +export function createClassifyCtx(): ClassifyCtx { + return { toolNameById: new Map(), pendingTurnStartTs: { current: null }, keySeq: { current: 0 } }; +} + +function nextKey(ctx: ClassifyCtx): string { + ctx.keySeq.current += 1; + return 'r' + ctx.keySeq.current; +} + +function trim(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + '…' : s; +} + +export function classifyEvent(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow[] { + switch (ev.kind) { + case 'turn_start': + return [classifyTurnStart(ev, fromHistory, ctx)]; + case 'turn_end': + return [classifyTurnEnd(ev, fromHistory, ctx)]; + case 'note': + return [classifyNote(ev, fromHistory, ctx)]; + case 'stream': { + const v = { ...ev }; + delete v.kind; + return classifyStream(v, fromHistory, ctx); + } + default: + // status_changed / model_changed / effort_changed / + // token_usage_changed / turn_state_changed drive badges elsewhere + // (useAgentState's poll — see Root.tsx) rather than rows here. + return []; + } +} + +function classifyTurnStart(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow { + const meta: StreamRowMeta[] = []; + if (typeof ev.ts === 'number') { + ctx.pendingTurnStartTs.current = ev.ts; + meta.push({ cls: 'turn-time', text: '· ' + fmtClock(ev.ts) }); + } + if (ev.unread > 0) { + meta.push({ cls: 'unread-badge', text: '· ' + ev.unread + ' unread' }); + } + return { + key: nextKey(ctx), + cssClass: 'turn-start', + fromHistory, + text: '◆ TURN ← ' + ev.from, + meta, + childText: { cls: 'turn-body', text: String(ev.body ?? '') }, + }; +} + +function classifyTurnEnd(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow { + const meta: StreamRowMeta[] = []; + if (typeof ev.ts === 'number') { + let label = '· ' + fmtClock(ev.ts); + if (ctx.pendingTurnStartTs.current != null && ev.ts >= ctx.pendingTurnStartTs.current) { + label += ' · ' + fmtAge((ev.ts - ctx.pendingTurnStartTs.current) * 1000); + } + meta.push({ cls: 'turn-time', text: label }); + } + ctx.pendingTurnStartTs.current = null; + return { + key: nextKey(ctx), + cssClass: ev.ok ? 'turn-end-ok' : 'turn-end-fail', + fromHistory, + text: (ev.ok ? '✅' : '❌') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''), + meta, + }; +} + +function classifyNote(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow { + const t = String(ev.text ?? ''); + if (t.startsWith('stderr:')) return { key: nextKey(ctx), cssClass: 'note stderr', fromHistory, text: '! ' + t }; + if (t.startsWith('operator:')) return { key: nextKey(ctx), cssClass: 'note op', fromHistory, text: '· ' + t }; + return { key: nextKey(ctx), cssClass: 'note', fromHistory, text: '· ' + t }; +} + +function classifyStream(v: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow[] { + if (v._category === 'drop') return []; + + if (v.type === 'system') { + const cat = v._category; + const summary = v._summary; + if (cat === 'thinking_tok') { + return [{ + key: nextKey(ctx), cssClass: 'note', icon: '🧠', fromHistory, + text: summary || 'thinking…', coalesceKey: 'thinking-tok', + }]; + } + if (v.subtype === 'plugin_install') { + return [{ + key: nextKey(ctx), cssClass: 'note', fromHistory, + text: summary || '⚙ plugin install', coalesceKey: 'plugin-install', + }]; + } + if (v.subtype === 'status') { + return [{ + key: nextKey(ctx), cssClass: 'note', fromHistory, + text: summary || '⚙ status', coalesceKey: 'status-tick', + }]; + } + if (cat === 'details') { + return [{ + key: nextKey(ctx), cssClass: 'note', fromHistory, details: true, + text: summary || '⚙ ' + (v.subtype || ''), plainBody: v._body || '', + }]; + } + return [{ key: nextKey(ctx), cssClass: 'note', fromHistory, text: summary || '⚙ ' + (v.subtype || 'system') }]; + } + + if (v.subtype === 'task_started' || v.subtype === 'task_notification') { + const row = classifyTaskEvent(v, fromHistory, ctx); + if (row) return [row]; + } + + if (v.type === 'assistant' && v.message && v.message.content) { + const rows: StreamRow[] = []; + for (const c of v.message.content) { + if (c.type === 'text' && c.text && String(c.text).trim()) { + rows.push({ key: nextKey(ctx), cssClass: 'text', fromHistory, markdownBody: c.text }); + } else if (c.type === 'thinking') { + const txt = String(c.thinking || c.text || '').trim(); + rows.push({ key: nextKey(ctx), cssClass: 'thinking', icon: '💭', fromHistory, text: txt || 'thinking …' }); + } else if (c.type === 'tool_use') { + if (c.id && c.name) ctx.toolNameById.set(c.id, c.name); + rows.push(classifyToolUse(c, fromHistory, ctx)); + } + } + return rows; + } + + if (v.type === 'user' && v.message && v.message.content) { + const rows: StreamRow[] = []; + for (const c of v.message.content) { + if (c.type === 'tool_result') rows.push(classifyToolResult(c, fromHistory, ctx)); + } + return rows; + } + + return [{ key: nextKey(ctx), cssClass: 'sys', fromHistory, text: '! ' + trim(JSON.stringify(v), 200) }]; +} + +// `_category === 'rich'` tools get an expandable row: diff body (Edit), +// default-open markdown body (send/ask/answer/recv-shaped), or a plain +// collapsed body — all pre-computed server-side, no per-tool JS needed. +function classifyToolUse(c: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow { + const icon = c._icon || '🔧'; + const name = c.name || ''; + if (c._category === 'rich' && c._body != null) { + const summary = c._summary || name || '?'; + if (c._body_type === 'diff') { + return { key: nextKey(ctx), cssClass: 'tool-use', fromHistory, details: true, icon, text: summary, diffBody: c._body }; + } + if (c._body_type === 'markdown') { + return { + key: nextKey(ctx), cssClass: 'tool-use', fromHistory, details: true, defaultOpen: true, + icon, text: summary, markdownBody: c._body, + }; + } + return { key: nextKey(ctx), cssClass: 'tool-use', fromHistory, details: true, icon, text: summary, plainBody: c._body }; + } + return { key: nextKey(ctx), cssClass: 'tool-use', fromHistory, icon, text: c._summary || name || '?' }; +} + +function classifyToolResult(c: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow { + const rawTxt = Array.isArray(c.content) ? c.content.map((p: AnyEvent) => p.text || '').join('') : c.content || ''; + const isError = !!c.is_error; + // Strip the wrapper — implementation + // detail claude emits on failed tool calls, adds nothing for the operator. + const txt = isError + ? String(rawTxt).replace(/^([\s\S]*)<\/tool_use_error>$/, '$1').trim() + : String(rawTxt); + const sourceName = c.tool_use_id ? ctx.toolNameById.get(c.tool_use_id) : null; + const isMessageBearing = sourceName === 'mcp__hyperhive__recv'; + const trimmed = txt.replace(/\s+/g, ' ').trim(); + const summaryBody = (() => { + if (!trimmed) return '(empty)'; + if (trimmed.length <= 120) return trimmed; + const lines = txt.split('\n').filter((l: string) => l.length).length; + const headline = trimmed.slice(0, 90) + '…'; + return `${lines}L · ${headline}`; + })(); + if (isError) { + if (!txt.trim() || txt.length <= 120) { + return { key: nextKey(ctx), cssClass: 'tool-result error', fromHistory, text: '✗ ' + summaryBody }; + } + return { key: nextKey(ctx), cssClass: 'tool-result-block error', fromHistory, details: true, text: summaryBody, plainBody: txt }; + } + if (isMessageBearing && txt.trim()) { + return { + key: nextKey(ctx), cssClass: 'tool-result-block', fromHistory, details: true, defaultOpen: true, + text: 'recv ← ' + summaryBody, markdownBody: txt, + }; + } + if (!txt.trim() || txt.length <= 120) { + return { key: nextKey(ctx), cssClass: 'tool-result', fromHistory, text: '← ' + summaryBody }; + } + return { key: nextKey(ctx), cssClass: 'tool-result-block', fromHistory, details: true, text: summaryBody, plainBody: txt }; +} + +// Subagent (claude `Task`-tool) activity — dead path for agents today +// (`Task` is omitted from the allow-list) but kept for parity, same as +// app.js's `renderTaskEvent`. Glyph stays embedded in the row text +// (not routed through the icon column) — matches the original exactly. +function classifyTaskEvent(v: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow | null { + const id = String(v.task_id || '').slice(0, 8); + const kind = v.task_type ? ` [${v.task_type}]` : ''; + const desc = v.description || v.summary || '(no description)'; + if (v.subtype === 'task_started') { + return { key: nextKey(ctx), cssClass: 'tool-use', fromHistory, text: `⌁ task ${id} started · ${desc}${kind}` }; + } + if (v.subtype === 'task_notification') { + const status = v.status || 'unknown'; + const glyph = status === 'completed' ? '✓' : status === 'failed' ? '✗' : '◌'; + const cssClass = status === 'completed' ? 'turn-end-ok' : status === 'failed' ? 'turn-end-fail' : 'tool-result'; + const out = v.output_file ? ` · → ${v.output_file}` : ''; + return { key: nextKey(ctx), cssClass, fromHistory, text: `⌁ task ${id} ${glyph} ${status} · ${desc}${out}` }; + } + return null; +} diff --git a/frontend/packages/agent/src/lib/format.ts b/frontend/packages/agent/src/lib/format.ts index 11201359..37797e8c 100644 --- a/frontend/packages/agent/src/lib/format.ts +++ b/frontend/packages/agent/src/lib/format.ts @@ -15,3 +15,10 @@ export function fmtAge(ms: number): string { const h = Math.floor(m / 60); return h + 'h ' + (m % 60) + 'm'; } + +/** Wall-clock HH:MM:SS (UTC) from a unix-seconds value — labels + * turn-start / turn-end rows in the live stream (ported from app.js's + * `fmtClock`). */ +export function fmtClock(sec: number): string { + return new Date(sec * 1000).toISOString().slice(11, 19); +} diff --git a/frontend/packages/agent/src/lib/linkify.tsx b/frontend/packages/agent/src/lib/linkify.tsx new file mode 100644 index 00000000..ce021d5a --- /dev/null +++ b/frontend/packages/agent/src/lib/linkify.tsx @@ -0,0 +1,39 @@ +// Auto-linkify bare http(s) URLs in plain (non-markdown) row text — +// Preact-node port of @hive/shared/terminal.js's `linkify` (text-node +// output there, JSX fragment here). Same regex + trailing-punctuation +// strip; deliberately text-only, never innerHTML, so untrusted row text +// (matrix-relayed bodies, tool args) can't inject markup this way. +import type { JSX } from 'preact'; + +const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g; + +export function linkifyToNodes(text: string | null | undefined): (string | JSX.Element)[] { + const str = text == null ? '' : String(text); + if (str.indexOf('://') === -1) return [str]; + const out: (string | JSX.Element)[] = []; + let last = 0; + let m: RegExpExecArray | null; + let key = 0; + LINKIFY_URL_RE.lastIndex = 0; + while ((m = LINKIFY_URL_RE.exec(str)) !== null) { + let url = m[0]; + const trail = url.match(/[.,;:!?)\]}'"]+$/); + const tail = trail ? trail[0] : ''; + if (tail) url = url.slice(0, -tail.length); + if (m.index > last) out.push(str.slice(last, m.index)); + if (!url.slice(url.indexOf('://') + 3)) { + // Nothing past the scheme — not a real URL, emit verbatim. + out.push(m[0]); + } else { + out.push( + + {url} + , + ); + if (tail) out.push(tail); + } + last = m.index + m[0].length; + } + if (last < str.length) out.push(str.slice(last)); + return out; +} diff --git a/frontend/packages/agent/src/lib/markdown.ts b/frontend/packages/agent/src/lib/markdown.ts new file mode 100644 index 00000000..9b363b74 --- /dev/null +++ b/frontend/packages/agent/src/lib/markdown.ts @@ -0,0 +1,25 @@ +// Markdown → sanitized HTML, ported from app.js's `mdNode`. Message +// bodies rendered into the live stream (assistant text, send/ask/answer/ +// recv payloads) are untrusted (peer-agent / matrix-relayed content, +// agent-authored files) — `marked` itself no longer sanitizes (v5+ +// dropped the built-in sanitizer), so every parse is run through +// DOMPurify before it's ever handed to `dangerouslySetInnerHTML`. +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +marked.setOptions({ breaks: true, gfm: true }); + +const ESCAPE_RE = /[&<>"]/g; +const ESCAPE_MAP: Record = { '&': '&', '<': '<', '>': '>', '"': '"' }; + +/** Render `text` as sanitized markdown HTML. Falls back to escaped plain + * text if `marked` throws (mirrors app.js's try/catch fallback). */ +export function renderMarkdown(text: string | null | undefined): string { + const src = String(text ?? ''); + try { + return DOMPurify.sanitize(marked.parse(src) as string); + } catch (err) { + console.warn('marked failed', err); + return src.replace(ESCAPE_RE, (c) => ESCAPE_MAP[c] ?? c); + } +} diff --git a/frontend/packages/agent/src/lib/streamRow.ts b/frontend/packages/agent/src/lib/streamRow.ts new file mode 100644 index 00000000..e985f922 --- /dev/null +++ b/frontend/packages/agent/src/lib/streamRow.ts @@ -0,0 +1,49 @@ +// Row model for the live event stream. One `StreamRow` = one rendered +// line/panel in the terminal pane; `classifyEvent` (classifyEvent.ts) +// turns a raw harness/stream-json event into zero or more of these, and +// `` (components/Row.tsx) renders one. Kept as plain data (not JSX) +// so the append/coalesce bookkeeping in useLiveStream.ts stays pure — +// see docs/terminal-rendering.md for the exact taxonomy this mirrors. + +export interface StreamRowMeta { + cls: string; + text: string; +} + +export interface StreamRow { + /** Stable across re-renders; reused in place when a row is coalesced + * (e.g. the thinking-token counter) so Preact updates rather than + * remounts it. */ + key: string; + /** Space-joined class names appended after "row" / "row details", e.g. + * "turn-start", "tool-result error" — taken verbatim from + * @hive/shared/terminal.css's row-kind taxonomy. */ + cssClass: string; + icon?: string; + fromHistory: boolean; + /** When set, an event that maps to the same coalesceKey and lands + * while this row is still the last one in the list replaces it in + * place instead of appending a new row (thinking-token ticks, status + * ticks, plugin_install started→completed). */ + coalesceKey?: string; + /** `false`/absent → flat `
`; `true` → `
` with `.summary-text` + optional body. */ + details?: boolean; + defaultOpen?: boolean; + /** Flat-row prefix text, or the details `` text. Linkified, + * never markdown. */ + text?: string; + /** Small trailing spans after the prefix text (turn-time, unread + * count) — flat rows only. */ + meta?: StreamRowMeta[]; + /** Plain-text child block under a flat row (just the turn-start wake + * body today) — its own class, no markdown parsing. */ + childText?: StreamRowMeta; + /** Sanitized-markdown body: appended under a flat row (assistant + * text) or inside an open details row (send/ask/answer/recv bodies). */ + markdownBody?: string; + /** Plain `
` body inside a details row (generic long tool output). */
+  plainBody?: string;
+  /** `+`/`-`/context diff body inside a details row (Edit tool). */
+  diffBody?: string;
+}