From 593923375cdfe200c815313d020eb74d8d11a238 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 13 Sep 2026 18:28:50 +0200 Subject: [PATCH 1/4] 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). --- docs/web-ui/terminal-rendering.md | 6 +- frontend/package-lock.json | 4 +- frontend/packages/agent/package.json | 2 - .../agent/src/components/LiveStream.tsx | 2 +- .../packages/agent/src/hooks/useLiveStream.ts | 2 +- frontend/packages/shared/package.json | 6 ++ .../src/terminal}/Row.tsx | 14 +-- .../lib => shared/src/terminal}/linkify.tsx | 6 +- .../lib => shared/src/terminal}/markdown.ts | 2 +- .../lib => shared/src/terminal}/termMsg.ts | 17 ++-- frontend/packages/swarm-ui/src/App.tsx | 2 + .../src/pages/agents/AgentTermPage.css | 20 +++++ .../src/pages/agents/AgentTermPage.tsx | 88 +++++++++++++++++++ .../swarm-ui/src/pages/agents/AgentsPage.tsx | 13 +++ .../src/pages/agents/useSwarmTermStream.ts | 70 +++++++++++++++ 15 files changed, 232 insertions(+), 22 deletions(-) rename frontend/packages/{agent/src/components => shared/src/terminal}/Row.tsx (87%) rename frontend/packages/{agent/src/lib => shared/src/terminal}/linkify.tsx (85%) rename frontend/packages/{agent/src/lib => shared/src/terminal}/markdown.ts (93%) rename frontend/packages/{agent/src/lib => shared/src/terminal}/termMsg.ts (71%) create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.css create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.tsx create mode 100644 frontend/packages/swarm-ui/src/pages/agents/useSwarmTermStream.ts 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 }; +} From 727bfb775b4df3d9f8da5f69e42a00c6391d5ade Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 13 Sep 2026 18:38:04 +0200 Subject: [PATCH 2/4] swarm-ui: fold the agent terminal into AgentsPage's detail panel, not a route mara's steer on the issue, right as the previous commit's plan was posted: the swarm-level term isn't a separate page, it's part of the same pwa -- selecting an agent should show a small preview (no header, no input) below the existing detail fields. That's the whole MVP for this issue; the full terminal (+ sending input back to the agent) is explicit follow-up scope. AgentTermPage -> AgentTermPreview: drops the Panel wrapper, the "terminal" nav badge + wouter route, and the back-link -- just the inline preview, height-capped at 12em (@hive/shared/terminal.css's own default is a much taller 72vh/60em, sized for a dedicated page rather than a peek). --- frontend/packages/swarm-ui/src/App.tsx | 2 - .../src/pages/agents/AgentTermPage.css | 20 --------- .../src/pages/agents/AgentTermPreview.css | 21 +++++++++ ...AgentTermPage.tsx => AgentTermPreview.tsx} | 45 +++++++------------ .../swarm-ui/src/pages/agents/AgentsPage.tsx | 18 +++----- 5 files changed, 42 insertions(+), 64 deletions(-) delete mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.css create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css rename frontend/packages/swarm-ui/src/pages/agents/{AgentTermPage.tsx => AgentTermPreview.tsx} (59%) diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index c8722811..99380be6 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -3,7 +3,6 @@ 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"; @@ -24,7 +23,6 @@ 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 deleted file mode 100644 index 83005f88..00000000 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.css +++ /dev/null @@ -1,20 +0,0 @@ -/* — `.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/AgentTermPreview.css b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css new file mode 100644 index 00000000..069ff99c --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css @@ -0,0 +1,21 @@ +/* — 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/AgentTermPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx similarity index 59% rename from frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.tsx rename to frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx index 9d58c30b..1275a9fd 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx @@ -1,19 +1,16 @@ -// — 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). +// — 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 { 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"; +import "./AgentTermPreview.css"; const NEAR_BOTTOM_PX = 48; @@ -31,10 +28,9 @@ const CONNECTION_TONE: Record = { closed: "negative", }; -export function AgentTermPage() { - const { name } = useParams<{ name: string }>(); +export function AgentTermPreview({ agentName }: { agentName: string }) { const { rows, connection } = useSwarmTermStream( - `/api/agents/${encodeURIComponent(name ?? "")}/term/stream`, + `/api/agents/${encodeURIComponent(agentName)}/term/stream`, ); const logRef = useRef(null); const [stickToBottom, setStickToBottom] = useState(true); @@ -48,29 +44,20 @@ export function AgentTermPage() { } // Runs after Preact has committed new rows, so `scrollHeight` is - // already current — same ordering `LiveStream`'s own layout effect - // relies on. + // 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 ( - - ← agents - - } - > +
-
+
{rows.length === 0 ? (
@@ -83,6 +70,6 @@ export function AgentTermPage() { )}
- +
); } diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx index 9017c417..d2641135 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx @@ -26,12 +26,12 @@ // 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"; 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"; @@ -96,7 +96,6 @@ 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] = @@ -566,17 +565,6 @@ export function AgentsPage() {
- - navigate( - `/agents/${encodeURIComponent(detailTarget.name)}/term`, - ) - } - title={`view ${detailTarget.name}'s live terminal`} - /> } @@ -594,6 +582,10 @@ 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

From b3c242ea5cd019c3aaf8af28dd0ed0ccd45ae40a Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 13 Sep 2026 19:21:51 +0200 Subject: [PATCH 3/4] frontend: regenerate npm-deps-hash for the dompurify/marked workspace move --- frontend/npm-deps-hash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/npm-deps-hash b/frontend/npm-deps-hash index 6014c91f..2616ec20 100644 --- a/frontend/npm-deps-hash +++ b/frontend/npm-deps-hash @@ -1 +1 @@ -sha256-9sh4G6A00NNi5YKaMQ1v0vZQj+gmGxYPhYZhlqweApM= +sha256-cX/fNFPKFmN88KQiyau2yYp3xydx5wrv5ZFdMud7UcQ= From e18619329d439b4cdc0cf3dba1aa7b6a93a8e775 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 13 Sep 2026 20:13:37 +0200 Subject: [PATCH 4/4] swarm-ui: fix treefmt formatting drift on the agent term files --- .../swarm-ui/src/pages/agents/AgentTermPreview.tsx | 5 ++++- .../src/pages/agents/useSwarmTermStream.ts | 14 +++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx index 1275a9fd..2624bf06 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx @@ -9,7 +9,10 @@ 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 { + useSwarmTermStream, + type ConnectionState, +} from "./useSwarmTermStream.js"; import "./AgentTermPreview.css"; const NEAR_BOTTOM_PX = 48; diff --git a/frontend/packages/swarm-ui/src/pages/agents/useSwarmTermStream.ts b/frontend/packages/swarm-ui/src/pages/agents/useSwarmTermStream.ts index 3af22ad7..e5a56d7f 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/useSwarmTermStream.ts +++ b/frontend/packages/swarm-ui/src/pages/agents/useSwarmTermStream.ts @@ -11,11 +11,7 @@ import { useEffect, useRef, useState } from "preact/hooks"; import type { TermMsg, TermRow } from "@hive/shared/term-msg.js"; -export type ConnectionState = - | "connecting" - | "open" - | "reconnecting" - | "closed"; +export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed"; function appendRow(rows: TermRow[], row: TermRow): TermRow[] { const last = rows[rows.length - 1]; @@ -30,7 +26,9 @@ export interface UseSwarmTermStreamResult { connection: ConnectionState; } -export function useSwarmTermStream(streamUrl: string): UseSwarmTermStreamResult { +export function useSwarmTermStream( + streamUrl: string, +): UseSwarmTermStreamResult { const [rows, setRows] = useState([]); const [connection, setConnection] = useState("connecting"); const keySeqRef = useRef(0); @@ -61,7 +59,9 @@ export function useSwarmTermStream(streamUrl: string): UseSwarmTermStreamResult setRows((prev) => appendRow(prev, { ...msg, key, fromHistory: false })); }; es.onerror = () => { - setConnection(es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed"); + setConnection( + es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed", + ); }; return () => es.close(); }, [streamUrl]);