diff --git a/docs/web-ui/terminal-rendering.md b/docs/web-ui/terminal-rendering.md index 68b680ea..e9bf8b53 100644 --- a/docs/web-ui/terminal-rendering.md +++ b/docs/web-ui/terminal-rendering.md @@ -18,9 +18,7 @@ publishes rows bare, with no envelope around them read the time from. The frontend renders a `TermMsg` close to as-is -(`frontend/packages/shared/src/terminal/Row.tsx` — moved out of -`packages/agent` once swarm-ui became a second consumer of it): `level` -picks the +(`frontend/packages/agent/src/components/Row.tsx`): `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 @@ -61,7 +59,7 @@ read that when you need the specifics, this doc doesn't duplicate it. ## Markdown -`frontend/packages/shared/src/terminal/markdown.ts`'s `renderMarkdown()` runs +`frontend/packages/agent/src/lib/markdown.ts`'s `renderMarkdown()` runs `marked.parse()` through DOMPurify into a `
`. Applied to any `TermMsg` with `body_format: "markdown"`. diff --git a/frontend/npm-deps-hash b/frontend/npm-deps-hash index 2616ec20..6014c91f 100644 --- a/frontend/npm-deps-hash +++ b/frontend/npm-deps-hash @@ -1 +1 @@ -sha256-cX/fNFPKFmN88KQiyau2yYp3xydx5wrv5ZFdMud7UcQ= +sha256-9sh4G6A00NNi5YKaMQ1v0vZQj+gmGxYPhYZhlqweApM= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0f272639..8c25b577 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -990,6 +990,8 @@ "dependencies": { "@hive/shared": "*", "chart.js": "4.5.1", + "dompurify": "^3.2.4", + "marked": "18.0.6", "preact": "10.29.8" }, "devDependencies": { @@ -1010,8 +1012,6 @@ "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 3d305811..2a18ce33 100644 --- a/frontend/packages/agent/package.json +++ b/frontend/packages/agent/package.json @@ -11,6 +11,8 @@ "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 eca73dc7..16619ddd 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 "@hive/shared/term-row.js"; +import { Row } from "./Row.js"; const NEAR_BOTTOM_PX = 48; const LOAD_MORE_SCROLL_PX = 80; diff --git a/frontend/packages/shared/src/terminal/Row.tsx b/frontend/packages/agent/src/components/Row.tsx similarity index 87% rename from frontend/packages/shared/src/terminal/Row.tsx rename to frontend/packages/agent/src/components/Row.tsx index 8109213e..66ea53fb 100644 --- a/frontend/packages/shared/src/terminal/Row.tsx +++ b/frontend/packages/agent/src/components/Row.tsx @@ -1,20 +1,16 @@ // Renders one `TermRow` — flat `
` or expandable // `
`, driven straight off the wire shape -// (`./termMsg.js`'s `TermMsg`, mirroring hive-agent's `term_msg.rs`): +// (`lib/termMsg.ts`'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"; +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"; function MarkdownBody({ text }: { text: string }) { const ref = useRef(null); diff --git a/frontend/packages/agent/src/hooks/useLiveStream.ts b/frontend/packages/agent/src/hooks/useLiveStream.ts index 73fa471a..42eccb0b 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 "@hive/shared/term-msg.js"; +import type { TermEnvelope, TermRow } from "../lib/termMsg.js"; export interface UseLiveStreamOptions { historyUrl?: string; diff --git a/frontend/packages/shared/src/terminal/linkify.tsx b/frontend/packages/agent/src/lib/linkify.tsx similarity index 85% rename from frontend/packages/shared/src/terminal/linkify.tsx rename to frontend/packages/agent/src/lib/linkify.tsx index 1b844cd8..866eda30 100644 --- a/frontend/packages/shared/src/terminal/linkify.tsx +++ b/frontend/packages/agent/src/lib/linkify.tsx @@ -1,7 +1,7 @@ // 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 +// 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 // (matrix-relayed bodies, tool args) can't inject markup this way. import type { JSX } from "preact"; diff --git a/frontend/packages/shared/src/terminal/markdown.ts b/frontend/packages/agent/src/lib/markdown.ts similarity index 93% rename from frontend/packages/shared/src/terminal/markdown.ts rename to frontend/packages/agent/src/lib/markdown.ts index 99ed4550..1ee65c9a 100644 --- a/frontend/packages/shared/src/terminal/markdown.ts +++ b/frontend/packages/agent/src/lib/markdown.ts @@ -1,5 +1,5 @@ // Markdown → sanitized HTML, ported from app.js's `mdNode`. Message -// bodies rendered into a live stream (assistant text, send/recv +// bodies rendered into the 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/shared/src/terminal/termMsg.ts b/frontend/packages/agent/src/lib/termMsg.ts similarity index 71% rename from frontend/packages/shared/src/terminal/termMsg.ts rename to frontend/packages/agent/src/lib/termMsg.ts index 77fca2ad..f1a0aa11 100644 --- a/frontend/packages/shared/src/terminal/termMsg.ts +++ b/frontend/packages/agent/src/lib/termMsg.ts @@ -1,13 +1,8 @@ -// Wire types for a classified terminal row — mirrors hive-agent's +// Wire types for the agent's terminal stream — 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). +// 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." export type Level = "debug" | "info" | "warn" | "error"; export interface TermMsg { @@ -27,9 +22,7 @@ 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. - * - * Local to the per-hive stream — the swarm relay carries no envelope. */ + * a consumer reading a bare row (the swarm queue's) can also see it. */ export interface TermEnvelope { seq?: number; msgs: TermMsg[]; diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 2e98b15a..6f157263 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -6,17 +6,11 @@ "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/swarm-ui/src/pages/agents/AgentTermPreview.css b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css deleted file mode 100644 index 069ff99c..00000000 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css +++ /dev/null @@ -1,21 +0,0 @@ -/* — a *preview*, not the full terminal: a much - shorter cap than @hive/shared/terminal.css's own 32em default so it - reads as "peek at what's happening" inside the detail panel rather - than competing with the panel's own fields for vertical space. */ -@import "@hive/shared/terminal.css"; - -.ui-agent-term-preview { - margin-block-start: 1rem; -} - -.ui-agent-term-preview-status { - margin-block-end: 0.5rem; -} - -.ui-agent-term-preview-wrap.terminal-wrap { - max-height: 12em; -} - -.ui-agent-term-preview-wrap .live.terminal { - max-height: 12em; -} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx deleted file mode 100644 index 2624bf06..00000000 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx +++ /dev/null @@ -1,78 +0,0 @@ -// — the MVP mara's own ruling asked for: not a separate -// page, a small read-only preview embedded below `AgentsPage`'s detail -// panel fields. No header, no input — those (plus sending input back -// to the agent) are explicitly follow-up scope, not this issue's. -// Consumes swarm-controller's `GET /api/agents/{name}/term/stream` via -// `useSwarmTermStream`, rendering through the same `@hive/shared` -// `Row` component the per-hive local terminal uses — "share components -// between hive and swarm level term as much as possible" (mara). -import { useLayoutEffect, useRef, useState } from "preact/hooks"; -import { Badge, type BadgeTone } from "@hive/shared/badge.js"; -import { Row } from "@hive/shared/term-row.js"; -import { - useSwarmTermStream, - type ConnectionState, -} from "./useSwarmTermStream.js"; -import "./AgentTermPreview.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 AgentTermPreview({ agentName }: { agentName: string }) { - const { rows, connection } = useSwarmTermStream( - `/api/agents/${encodeURIComponent(agentName)}/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 `@hive/agent`'s `LiveStream` uses. - useLayoutEffect(() => { - const el = logRef.current; - if (el && stickToBottom) el.scrollTop = el.scrollHeight - el.clientHeight; - }, [rows, stickToBottom]); - - return ( -
- -
-
- {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 d2641135..4370d6bf 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx @@ -31,7 +31,6 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Badge } from "@hive/shared/badge.js"; import { LinkIcon } from "@hive/shared/icons.js"; import { AgentCard } from "./AgentCard.js"; -import { AgentTermPreview } from "./AgentTermPreview.js"; import { FRESHNESS, type AgentRow } from "./AgentTypes.js"; import { Button } from "../../ui/button/Button.js"; import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js"; @@ -582,10 +581,6 @@ export function AgentsPage() { } />
- {/* MVP scope per mara's own ruling: a small read-only - preview, no header/no input — the full terminal - (+ sending input back) is explicit follow-up scope. */} -
) : (

select an agent to see its details

diff --git a/frontend/packages/swarm-ui/src/pages/agents/useSwarmTermStream.ts b/frontend/packages/swarm-ui/src/pages/agents/useSwarmTermStream.ts deleted file mode 100644 index e5a56d7f..00000000 --- a/frontend/packages/swarm-ui/src/pages/agents/useSwarmTermStream.ts +++ /dev/null @@ -1,70 +0,0 @@ -// 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 }; -}