diff --git a/docs/web-ui/terminal-rendering.md b/docs/web-ui/terminal-rendering.md index e9bf8b53..68b680ea 100644 --- a/docs/web-ui/terminal-rendering.md +++ b/docs/web-ui/terminal-rendering.md @@ -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 `
`, 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 `
`. Applied to any `TermMsg` with `body_format: "markdown"`. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c25b577..0f272639 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" } }, diff --git a/frontend/packages/agent/package.json b/frontend/packages/agent/package.json index 2a18ce33..3d305811 100644 --- a/frontend/packages/agent/package.json +++ b/frontend/packages/agent/package.json @@ -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": { diff --git a/frontend/packages/agent/src/components/LiveStream.tsx b/frontend/packages/agent/src/components/LiveStream.tsx index 16619ddd..eca73dc7 100644 --- a/frontend/packages/agent/src/components/LiveStream.tsx +++ b/frontend/packages/agent/src/components/LiveStream.tsx @@ -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; diff --git a/frontend/packages/agent/src/hooks/useLiveStream.ts b/frontend/packages/agent/src/hooks/useLiveStream.ts index 42eccb0b..73fa471a 100644 --- a/frontend/packages/agent/src/hooks/useLiveStream.ts +++ b/frontend/packages/agent/src/hooks/useLiveStream.ts @@ -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; diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 6f157263..2e98b15a 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -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", diff --git a/frontend/packages/agent/src/components/Row.tsx b/frontend/packages/shared/src/terminal/Row.tsx similarity index 87% rename from frontend/packages/agent/src/components/Row.tsx rename to frontend/packages/shared/src/terminal/Row.tsx index 66ea53fb..8109213e 100644 --- a/frontend/packages/agent/src/components/Row.tsx +++ b/frontend/packages/shared/src/terminal/Row.tsx @@ -1,16 +1,20 @@ // Renders one `TermRow` — flat `
` or expandable // `
`, driven straight off the wire shape -// (`lib/termMsg.ts`'s `TermMsg`, mirroring hive-agent's `term_msg.rs`): +// (`./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 "../lib/termMsg.js"; -import { linkifyToNodes } from "../lib/linkify.js"; -import { renderMarkdown } from "../lib/markdown.js"; -import { getExpandDetailsPref, getHideDebugPref } from "@hive/shared/prefs.js"; +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(null); diff --git a/frontend/packages/agent/src/lib/linkify.tsx b/frontend/packages/shared/src/terminal/linkify.tsx similarity index 85% rename from frontend/packages/agent/src/lib/linkify.tsx rename to frontend/packages/shared/src/terminal/linkify.tsx index 866eda30..1b844cd8 100644 --- a/frontend/packages/agent/src/lib/linkify.tsx +++ b/frontend/packages/shared/src/terminal/linkify.tsx @@ -1,7 +1,7 @@ // Auto-linkify bare http(s) URLs in plain (non-markdown) row text — -// Preact-node port of @hive/shared/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 +// 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"; diff --git a/frontend/packages/agent/src/lib/markdown.ts b/frontend/packages/shared/src/terminal/markdown.ts similarity index 93% rename from frontend/packages/agent/src/lib/markdown.ts rename to frontend/packages/shared/src/terminal/markdown.ts index 1ee65c9a..99ed4550 100644 --- a/frontend/packages/agent/src/lib/markdown.ts +++ b/frontend/packages/shared/src/terminal/markdown.ts @@ -1,5 +1,5 @@ // Markdown → sanitized HTML, ported from app.js's `mdNode`. Message -// bodies rendered into the live stream (assistant text, send/recv +// 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 diff --git a/frontend/packages/agent/src/lib/termMsg.ts b/frontend/packages/shared/src/terminal/termMsg.ts similarity index 71% rename from frontend/packages/agent/src/lib/termMsg.ts rename to frontend/packages/shared/src/terminal/termMsg.ts index f1a0aa11..77fca2ad 100644 --- a/frontend/packages/agent/src/lib/termMsg.ts +++ b/frontend/packages/shared/src/terminal/termMsg.ts @@ -1,8 +1,13 @@ -// Wire types for the agent's terminal stream — mirrors hive-agent's +// 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 components/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." +// 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 { @@ -22,7 +27,9 @@ export interface TermMsg { * `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. */ + * 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[]; diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index 99380be6..c8722811 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -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() { + diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.css b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.css new file mode 100644 index 00000000..83005f88 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.css @@ -0,0 +1,20 @@ +/* — `.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; +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.tsx new file mode 100644 index 00000000..9d58c30b --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.tsx @@ -0,0 +1,88 @@ +// — 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 = { + connecting: "connecting…", + open: "live", + reconnecting: "reconnecting…", + closed: "disconnected", +}; + +const CONNECTION_TONE: Record = { + 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(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 ( + + ← agents + + } + > + +
+
+ {rows.length === 0 ? ( +
+ {connection === "open" + ? "(connected — waiting for events)" + : CONNECTION_LABEL[connection]} +
+ ) : ( + rows.map((row) => ) + )} +
+
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx index 4370d6bf..9017c417 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx @@ -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(null); const [error, setError] = useState(null); const [intervalMs, setIntervalMs] = @@ -564,6 +566,17 @@ export function AgentsPage() {
+ + navigate( + `/agents/${encodeURIComponent(detailTarget.name)}/term`, + ) + } + title={`view ${detailTarget.name}'s live terminal`} + /> } diff --git a/frontend/packages/swarm-ui/src/pages/agents/useSwarmTermStream.ts b/frontend/packages/swarm-ui/src/pages/agents/useSwarmTermStream.ts new file mode 100644 index 00000000..3af22ad7 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/useSwarmTermStream.ts @@ -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([]); + const [connection, setConnection] = useState("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 }; +}