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.
25 lines
1.1 KiB
TypeScript
25 lines
1.1 KiB
TypeScript
// 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<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
|
|
/** 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);
|
|
}
|
|
}
|