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).
140 lines
5 KiB
TypeScript
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>
|
|
);
|
|
},
|
|
);
|