Watch
0
0
Fork
You've already forked hyperhive
0

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).
This commit is contained in:
iris 2026-09-13 18:28:50 +02:00 • committed by mara
commit 593923375c
15 changed files with 232 additions and 22 deletions

View file

@ -0,0 +1,70 @@
// Live SSE consumer for the swarm-relayed agent terminal
// (`GET /api/agents/{name}/term/stream`, swarm-controller's
// `term_stream.rs`). Deliberately NOT `@hive/agent`'s `useLiveStream` —
// that hook's wire shape is `TermEnvelope` (`{seq, msgs: []}`) plus a
// history/backfill dance; the swarm relay forwards one bare `TermMsg`
// per SSE event with no envelope and no history endpoint (a core NATS
// subject, not `JetStream` — see `term_stream.rs`'s own module doc), so
// there's nothing to buffer/dedupe/backfill against. Same row-coalescing
// rule as `useLiveStream`'s `appendRow` (ported, not imported — one
// small helper isn't worth a cross-package hop for).
import { useEffect, useRef, useState } from "preact/hooks";
import type { TermMsg, TermRow } from "@hive/shared/term-msg.js";
export type ConnectionState =
| "connecting"
| "open"
| "reconnecting"
| "closed";
function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
const last = rows[rows.length - 1];
if (row.coalesce_key && last && last.coalesce_key === row.coalesce_key) {
return [...rows.slice(0, -1), { ...row, key: last.key }];
}
return [...rows, row];
}
export interface UseSwarmTermStreamResult {
rows: TermRow[];
connection: ConnectionState;
}
export function useSwarmTermStream(streamUrl: string): UseSwarmTermStreamResult {
const [rows, setRows] = useState<TermRow[]>([]);
const [connection, setConnection] = useState<ConnectionState>("connecting");
const keySeqRef = useRef(0);
useEffect(() => {
setRows([]);
setConnection("connecting");
keySeqRef.current = 0;
const es = new EventSource(streamUrl);
es.onopen = () => setConnection("open");
es.onmessage = (e) => {
keySeqRef.current += 1;
const key = "r" + keySeqRef.current;
let msg: TermMsg;
try {
msg = JSON.parse(e.data);
} catch {
setRows((prev) =>
appendRow(prev, {
key,
level: "warn",
fromHistory: false,
summary: "[parse err] " + e.data,
}),
);
return;
}
setRows((prev) => appendRow(prev, { ...msg, key, fromHistory: false }));
};
es.onerror = () => {
setConnection(es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed");
};
return () => es.close();
}, [streamUrl]);
return { rows, connection };
}