// 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, useImperativeHandle } from 'preact/hooks'; import { forwardRef } from 'preact/compat'; 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; } /** Imperative escape hatch for TermInput's local-only slash commands * (`/help`, `/clear`) — same shape as app.js's old `termAPI` object, * kept as a ref handle rather than lifting the whole row array up to * Root so LiveStream/useLiveStream still own their state privately. */ export interface LiveStreamHandle { pushNote: (text: string) => void; clear: () => void; } export const LiveStream = forwardRef(function LiveStream( { onLiveTurnBoundary }, ref, ) { const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream({ onLiveTurnBoundary }); useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]); 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 && ( )}
); });