Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/agent/src/components/LiveStream.tsx
iris 593923375c swarm-ui: read-only agent terminal page consuming the swarm term stream
Moves the TermMsg rendering pipeline (Row.tsx, termMsg.ts, linkify.tsx,
markdown.ts) from @hive/agent into @hive/shared, so swarm-ui becomes a
second consumer of it instead of forking a copy -- CSS was already
shared (@hive/shared/terminal.css). marked+dompurify move from
@hive/agent's deps to @hive/shared's; swarm-ui picks them up
transitively, no new direct dep there.

New swarm-ui route /agents/:name/term (AgentTermPage), linked from
AgentsPage's detail panel via a "terminal" badge next to "link matrix
account". Consumes GET /api/agents/{name}/term/stream: unlike
@hive/agent's own useLiveStream (TermEnvelope-wrapped, history/backfill
dance), the swarm relay forwards one bare TermMsg per SSE event with no
envelope and no history endpoint -- useSwarmTermStream is a much
smaller hook for that shape (EventSource -> parse -> coalesce, nothing
to buffer/dedupe/backfill against).

Verified against a live SSE mock (screenshots in /agents/iris/state/screenshots/
3801-agents-detail-panel-terminal-badge.png and
3801-agent-term-page-live-rows.png -- real rows rendering through the
shared Row component, not just a build/typecheck pass).
2026-09-13 20:23:51 +02:00

140 lines
5 KiB
TypeScript

// 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 "@hive/shared/term-row.js";
const NEAR_BOTTOM_PX = 48;
const LOAD_MORE_SCROLL_PX = 80;
/** 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<LiveStreamHandle, object>(
function LiveStream(_props, ref) {
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } =
useLiveStream();
useImperativeHandle(
ref,
() => ({ pushNote: pushLocalNote, clear: clearLocal }),
[pushLocalNote, clearLocal],
);
const logRef = useRef<HTMLDivElement>(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 (
<div className="terminal-wrap">
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
{hasMore && (
<button
type="button"
className="load-more-pill"
disabled={loadingMore}
onClick={startLoadMore}
>
{loadingMore ? "↑ loading…" : "↑ load older"}
</button>
)}
{rows.map((row) => (
<Row key={row.key} row={row} />
))}
</div>
{unseen > 0 && (
<button
type="button"
className="tail-pill visible"
onClick={jumpToBottom}
>
↓ {unseen} new
</button>
)}
</div>
);
},
);