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

@ -18,7 +18,9 @@ publishes rows bare, with no envelope around them
read the time from.
The frontend renders a `TermMsg` close to as-is
(`frontend/packages/agent/src/components/Row.tsx`): `level` picks the
(`frontend/packages/shared/src/terminal/Row.tsx` — moved out of
`packages/agent` once swarm-ui became a second consumer of it): `level`
picks the
CSS colour (`terminal.css`'s `.live .level-*`), an empty `summary` +
markdown `body` renders as a flat row with just the body (assistant
text), and any other bodied row is an expandable `<details>`, opened by
@ -59,7 +61,7 @@ read that when you need the specifics, this doc doesn't duplicate it.
## Markdown
`frontend/packages/agent/src/lib/markdown.ts`'s `renderMarkdown()` runs
`frontend/packages/shared/src/terminal/markdown.ts`'s `renderMarkdown()` runs
`marked.parse()` through DOMPurify into a `<div class="md">`. Applied to
any `TermMsg` with `body_format: "markdown"`.

View file

@ -990,8 +990,6 @@
"dependencies": {
"@hive/shared": "*",
"chart.js": "4.5.1",
"dompurify": "^3.2.4",
"marked": "18.0.6",
"preact": "10.29.8"
},
"devDependencies": {
@ -1012,6 +1010,8 @@
"name": "@hive/shared",
"version": "0.0.0",
"dependencies": {
"dompurify": "^3.2.4",
"marked": "18.0.6",
"preact": "10.29.8"
}
},

View file

@ -11,8 +11,6 @@
"dependencies": {
"@hive/shared": "*",
"chart.js": "4.5.1",
"dompurify": "^3.2.4",
"marked": "18.0.6",
"preact": "10.29.8"
},
"devDependencies": {

View file

@ -28,7 +28,7 @@ import {
} from "preact/hooks";
import { forwardRef } from "preact/compat";
import { useLiveStream } from "../hooks/useLiveStream.js";
import { Row } from "./Row.js";
import { Row } from "@hive/shared/term-row.js";
const NEAR_BOTTOM_PX = 48;
const LOAD_MORE_SCROLL_PX = 80;

View file

@ -16,7 +16,7 @@
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
// `web_ui/stream.rs`.
import { useEffect, useRef, useState } from "preact/hooks";
import type { TermEnvelope, TermRow } from "../lib/termMsg.js";
import type { TermEnvelope, TermRow } from "@hive/shared/term-msg.js";
export interface UseLiveStreamOptions {
historyUrl?: string;

View file

@ -6,11 +6,17 @@
"type": "module",
"main": "./src/index.js",
"dependencies": {
"dompurify": "^3.2.4",
"marked": "18.0.6",
"preact": "10.29.8"
},
"exports": {
".": "./src/index.js",
"./terminal.js": "./src/terminal/terminal.js",
"./term-msg.js": "./src/terminal/termMsg.ts",
"./term-row.js": "./src/terminal/Row.tsx",
"./term-linkify.js": "./src/terminal/linkify.tsx",
"./term-markdown.js": "./src/terminal/markdown.ts",
"./tabs.js": "./src/tabs/tabs.js",
"./tabs.css": "./src/tabs/tabs.css",
"./hive-tab-strip.js": "./src/tabs/hive-tab-strip.js",

View file

@ -0,0 +1,109 @@
// Renders one `TermRow` — flat `<div class="row …">` or expandable
// `<details class="row …">`, driven straight off the wire shape
// (`./termMsg.js`'s `TermMsg`, mirroring hive-agent's `term_msg.rs`):
// `level` picks the colour class, an empty `summary` + markdown `body`
// is a flat row with just the body (assistant text), anything else with
// a body is an expandable details row gated by the operator's
// expand-tool-output preference. No separate classification step —
// mara: "StreamRow should now match what the server sends in TermMsg."
//
// Shared between `@hive/agent`'s local stream and `@hive/swarm-ui`'s
// relay consumer (see `./termMsg.js`'s doc) — both hand this the same
// `TermRow` shape regardless of which wire framing they read it off of.
import { useEffect, useRef } from "preact/hooks";
import type { TermRow } from "./termMsg.js";
import { linkifyToNodes } from "./linkify.js";
import { renderMarkdown } from "./markdown.js";
import { getExpandDetailsPref, getHideDebugPref } from "../prefs.js";
function MarkdownBody({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null);
const html = renderMarkdown(text);
useEffect(() => {
// marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates the terminal away.
ref.current?.querySelectorAll("a[href]").forEach((a) => {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
});
}, [html]);
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
return (
<div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />
);
}
function DiffBody({ text }: { text: string }) {
const lines = String(text).split("\n");
return (
<pre className="tool-body diff-body">
{lines.map((line, i) => {
const cls = line.startsWith("+ ")
? "diff-add"
: line.startsWith("- ")
? "diff-del"
: "diff-ctx";
return (
<span key={i} className={cls}>
{line}
{"\n"}
</span>
);
})}
</pre>
);
}
export function Row({ row }: { row: TermRow }) {
// "hide debug output" is a skip, not a dimmer — a hidden debug row
// still occupies no DOM node at all, same as it never streamed, rather
// than a CSS `display: none` that would keep it in the layout/DOM for
// no benefit.
if (row.level === "debug" && getHideDebugPref()) return null;
const cssClass = "level-" + row.level;
const icon = row.icon != null && row.icon !== "" && (
<span className="row-glyph">{row.icon}</span>
);
if (row.body == null) {
return (
<div className={`row ${cssClass}`}>
{icon}
{/* Explicit wrapper, not bare children — the row's grid places
`.row-content` in its content column by class, not by
auto-placement inference (an icon-less row's lone child would
otherwise auto-place into the icon column instead). */}
<span className="row-content">{linkifyToNodes(row.summary)}</span>
</div>
);
}
// Empty summary + markdown body → the body itself is the whole row
// (assistant text), no summary prefix line, never collapsible.
if (row.body_format === "markdown" && row.summary === "") {
return (
<div className={`row ${cssClass}`}>
{icon}
<MarkdownBody text={row.body} />
</div>
);
}
return (
<details
className={`row ${cssClass}`}
open={getExpandDetailsPref() || undefined}
>
<summary>
{icon}
<span className="summary-text">{row.summary}</span>
</summary>
{row.body_format === "diff" && <DiffBody text={row.body} />}
{row.body_format === "markdown" && <MarkdownBody text={row.body} />}
{row.body_format == null && (
<pre className="tool-body">{linkifyToNodes(row.body)}</pre>
)}
</details>
);
}

View file

@ -0,0 +1,41 @@
// Auto-linkify bare http(s) URLs in plain (non-markdown) row text —
// Preact-node port of ./terminal.js's `linkify` (text-node output there,
// JSX fragment here). Same regex + trailing-punctuation strip;
// deliberately text-only, never innerHTML, so untrusted row text
// (matrix-relayed bodies, tool args) can't inject markup this way.
import type { JSX } from "preact";
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
export function linkifyToNodes(
text: string | null | undefined,
): (string | JSX.Element)[] {
const str = text == null ? "" : String(text);
if (str.indexOf("://") === -1) return [str];
const out: (string | JSX.Element)[] = [];
let last = 0;
let m: RegExpExecArray | null;
let key = 0;
LINKIFY_URL_RE.lastIndex = 0;
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
let url = m[0];
const trail = url.match(/[.,;:!?)\]}'"]+$/);
const tail = trail ? trail[0] : "";
if (tail) url = url.slice(0, -tail.length);
if (m.index > last) out.push(str.slice(last, m.index));
if (!url.slice(url.indexOf("://") + 3)) {
// Nothing past the scheme — not a real URL, emit verbatim.
out.push(m[0]);
} else {
out.push(
<a key={key++} href={url} target="_blank" rel="noopener noreferrer">
{url}
</a>,
);
if (tail) out.push(tail);
}
last = m.index + m[0].length;
}
if (last < str.length) out.push(str.slice(last));
return out;
}

View file

@ -0,0 +1,30 @@
// Markdown → sanitized HTML, ported from app.js's `mdNode`. Message
// bodies rendered into a live stream (assistant text, send/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> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
};
/** 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);
}
}

View file

@ -0,0 +1,49 @@
// Wire types for a classified terminal row — mirrors hive-agent's
// `term_msg.rs`/`web_ui/stream.rs` field-for-field. The frontend renders
// a `TermMsg` close to as-is (see Row.tsx); there's no separate
// client-side row model or classification step any more — mara:
// "StreamRow should now match what the server sends in TermMsg."
//
// Shared between `@hive/agent` (the per-hive local stream, wrapped in
// `TermEnvelope`s) and `@hive/swarm-ui` (the swarm relay's `GET
// /api/agents/{name}/term/stream`, which forwards one bare `TermMsg`
// per SSE event — no envelope, no `seq`, no history endpoint).
export type Level = "debug" | "info" | "warn" | "error";
export interface TermMsg {
/** When the event this row came from happened — ISO 8601 UTC, e.g.
* "2026-09-13T12:35:03Z". The source event's own time, so a row replayed
* from history reads as when it happened rather than when it was fetched. */
ts: string;
icon?: string;
level: Level;
summary: string;
body?: string;
body_format?: "markdown" | "diff";
coalesce_key?: string;
}
/** One SSE frame / history array entry — `hive-agent`'s `TermEnvelope`.
* `seq` is the live per-event dedup counter (`BusEvent::seq`); absent on
* history-replayed envelopes, see useLiveStream.ts's backfill dance. It is
* the only field left: the event's time rides on each row's own `ts`, where
* a consumer reading a bare row (the swarm queue's) can also see it.
*
* Local to the per-hive stream — the swarm relay carries no envelope. */
export interface TermEnvelope {
seq?: number;
msgs: TermMsg[];
}
/** A `TermMsg` plus the bookkeeping Preact needs to render a list —
* stable identity for coalescing/keys, and whether it came from history
* replay vs. the live tail. Not a separate model: everything content-wise
* is still exactly the wire shape. */
export interface TermRow extends Omit<TermMsg, "ts"> {
/** Absent on the rows the client invents for itself — the history/live
* separators, the connection note, a locally echoed prompt — which answer
* to no server event and so have no event time to carry. */
ts?: string;
key: string;
fromHistory: boolean;
}

View file

@ -3,6 +3,7 @@
import { Route, Switch } from "wouter-preact";
import { Shell } from "./shell/Shell.js";
import { AgentsPage } from "./pages/agents/AgentsPage.js";
import { AgentTermPage } from "./pages/agents/AgentTermPage.js";
import { ComponentsPage } from "./pages/ComponentsPage.js";
import { JobsPage } from "./pages/JobsPage.js";
import { HivesPage } from "./pages/HivesPage.js";
@ -23,6 +24,7 @@ export function App() {
<Switch>
<Route path="/" component={HivesPage} />
<Route path="/agents" component={AgentsPage} />
<Route path="/agents/:name/term" component={AgentTermPage} />
<Route path="/jobs" component={JobsPage} />
<Route path="/issues" component={IssueReportPage} />
<Route path="/components" component={ComponentsPage} />

View file

@ -0,0 +1,20 @@
/* <AgentTermPage> — `.terminal-wrap`/`.live.terminal` come from
@hive/shared/terminal.css unstyled (its own default 32em max-height
+ scroll is exactly right here, no page override needed). Just
spacing for the status badge + back link this page adds around it. */
@import "@hive/shared/terminal.css";
.ui-agent-term-status {
display: inline-flex;
margin-block-end: 0.5rem;
}
.ui-agent-term-back {
font-size: 0.85rem;
color: var(--fg);
text-decoration: none;
}
.ui-agent-term-back:hover {
text-decoration: underline;
}

View file

@ -0,0 +1,88 @@
// <AgentTermPage> — read-only live terminal for one agent, consuming
// swarm-controller's `GET /api/agents/{name}/term/stream`. First
// per-agent detail route in swarm-ui (linked from `AgentsPage`'s detail
// panel). Live-only, no backfill/history — see `useSwarmTermStream`'s
// doc for why a fresh visit starts empty rather than replaying anything.
//
// Auto-scroll is the simple "stick to bottom unless the operator
// scrolled up" rule `@hive/agent`'s `LiveStream` uses, minus its
// load-more/history plumbing (nothing to load older here).
import { useLayoutEffect, useRef, useState } from "preact/hooks";
import { Link, useParams } from "wouter-preact";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Row } from "@hive/shared/term-row.js";
import { Panel } from "../../ui/panel/Panel.js";
import { useSwarmTermStream, type ConnectionState } from "./useSwarmTermStream.js";
import "./AgentTermPage.css";
const NEAR_BOTTOM_PX = 48;
const CONNECTION_LABEL: Record<ConnectionState, string> = {
connecting: "connecting…",
open: "live",
reconnecting: "reconnecting…",
closed: "disconnected",
};
const CONNECTION_TONE: Record<ConnectionState, BadgeTone> = {
connecting: "neutral",
open: "positive",
reconnecting: "warning",
closed: "negative",
};
export function AgentTermPage() {
const { name } = useParams<{ name: string }>();
const { rows, connection } = useSwarmTermStream(
`/api/agents/${encodeURIComponent(name ?? "")}/term/stream`,
);
const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true);
function handleScroll() {
const el = logRef.current;
if (!el) return;
setStickToBottom(
el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX,
);
}
// Runs after Preact has committed new rows, so `scrollHeight` is
// already current — same ordering `LiveStream`'s own layout effect
// relies on.
useLayoutEffect(() => {
const el = logRef.current;
if (el && stickToBottom) el.scrollTop = el.scrollHeight - el.clientHeight;
}, [rows, stickToBottom]);
return (
<Panel
title={`terminal — ${name}`}
icon="🖥️"
actions={
<Link to="/agents" class="ui-agent-term-back">
← agents
</Link>
}
>
<Badge
class="ui-agent-term-status"
tone={CONNECTION_TONE[connection]}
value={CONNECTION_LABEL[connection]}
/>
<div class="terminal-wrap">
<div class="live terminal" ref={logRef} onScroll={handleScroll}>
{rows.length === 0 ? (
<div class="row note">
{connection === "open"
? "(connected — waiting for events)"
: CONNECTION_LABEL[connection]}
</div>
) : (
rows.map((row) => <Row key={row.key} row={row} />)
)}
</div>
</div>
</Panel>
);
}

View file

@ -26,6 +26,7 @@
// see its own comment) — `viewMode` here just says which one to show.
import { useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { useLocation } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
@ -95,6 +96,7 @@ type ViewMode = "cards" | "table";
const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
export function AgentsPage() {
const [, navigate] = useLocation();
const [rows, setRows] = useState<AgentRow[] | null>(null);
const [error, setError] = useState<ProblemDetails | null>(null);
const [intervalMs, setIntervalMs] =
@ -564,6 +566,17 @@ export function AgentsPage() {
</dd>
</dl>
<div class="ui-agent-detail-actions">
<Badge
variant="quiet"
icon="🖥️"
value="terminal"
onClick={() =>
navigate(
`/agents/${encodeURIComponent(detailTarget.name)}/term`,
)
}
title={`view ${detailTarget.name}'s live terminal`}
/>
<Badge
variant="quiet"
icon={<LinkIcon />}

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 };
}