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:
parent
5dff508e79
commit
593923375c
15 changed files with 232 additions and 22 deletions
|
|
@ -18,7 +18,9 @@ publishes rows bare, with no envelope around them
|
||||||
read the time from.
|
read the time from.
|
||||||
|
|
||||||
The frontend renders a `TermMsg` close to as-is
|
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` +
|
CSS colour (`terminal.css`'s `.live .level-*`), an empty `summary` +
|
||||||
markdown `body` renders as a flat row with just the body (assistant
|
markdown `body` renders as a flat row with just the body (assistant
|
||||||
text), and any other bodied row is an expandable `<details>`, opened by
|
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
|
## 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
|
`marked.parse()` through DOMPurify into a `<div class="md">`. Applied to
|
||||||
any `TermMsg` with `body_format: "markdown"`.
|
any `TermMsg` with `body_format: "markdown"`.
|
||||||
|
|
||||||
|
|
|
||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
|
|
@ -990,8 +990,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hive/shared": "*",
|
"@hive/shared": "*",
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"dompurify": "^3.2.4",
|
|
||||||
"marked": "18.0.6",
|
|
||||||
"preact": "10.29.8"
|
"preact": "10.29.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1012,6 +1010,8 @@
|
||||||
"name": "@hive/shared",
|
"name": "@hive/shared",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"dompurify": "^3.2.4",
|
||||||
|
"marked": "18.0.6",
|
||||||
"preact": "10.29.8"
|
"preact": "10.29.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hive/shared": "*",
|
"@hive/shared": "*",
|
||||||
"chart.js": "4.5.1",
|
"chart.js": "4.5.1",
|
||||||
"dompurify": "^3.2.4",
|
|
||||||
"marked": "18.0.6",
|
|
||||||
"preact": "10.29.8"
|
"preact": "10.29.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
} from "preact/hooks";
|
} from "preact/hooks";
|
||||||
import { forwardRef } from "preact/compat";
|
import { forwardRef } from "preact/compat";
|
||||||
import { useLiveStream } from "../hooks/useLiveStream.js";
|
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 NEAR_BOTTOM_PX = 48;
|
||||||
const LOAD_MORE_SCROLL_PX = 80;
|
const LOAD_MORE_SCROLL_PX = 80;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
|
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
|
||||||
// `web_ui/stream.rs`.
|
// `web_ui/stream.rs`.
|
||||||
import { useEffect, useRef, useState } from "preact/hooks";
|
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 {
|
export interface UseLiveStreamOptions {
|
||||||
historyUrl?: string;
|
historyUrl?: string;
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,17 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.js",
|
"main": "./src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"dompurify": "^3.2.4",
|
||||||
|
"marked": "18.0.6",
|
||||||
"preact": "10.29.8"
|
"preact": "10.29.8"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.js",
|
".": "./src/index.js",
|
||||||
"./terminal.js": "./src/terminal/terminal.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.js": "./src/tabs/tabs.js",
|
||||||
"./tabs.css": "./src/tabs/tabs.css",
|
"./tabs.css": "./src/tabs/tabs.css",
|
||||||
"./hive-tab-strip.js": "./src/tabs/hive-tab-strip.js",
|
"./hive-tab-strip.js": "./src/tabs/hive-tab-strip.js",
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,20 @@
|
||||||
// Renders one `TermRow` — flat `<div class="row …">` or expandable
|
// Renders one `TermRow` — flat `<div class="row …">` or expandable
|
||||||
// `<details class="row …">`, driven straight off the wire shape
|
// `<details class="row …">`, 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`
|
// `level` picks the colour class, an empty `summary` + markdown `body`
|
||||||
// is a flat row with just the body (assistant text), anything else with
|
// 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
|
// a body is an expandable details row gated by the operator's
|
||||||
// expand-tool-output preference. No separate classification step —
|
// expand-tool-output preference. No separate classification step —
|
||||||
// mara: "StreamRow should now match what the server sends in TermMsg."
|
// 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 { useEffect, useRef } from "preact/hooks";
|
||||||
import type { TermRow } from "../lib/termMsg.js";
|
import type { TermRow } from "./termMsg.js";
|
||||||
import { linkifyToNodes } from "../lib/linkify.js";
|
import { linkifyToNodes } from "./linkify.js";
|
||||||
import { renderMarkdown } from "../lib/markdown.js";
|
import { renderMarkdown } from "./markdown.js";
|
||||||
import { getExpandDetailsPref, getHideDebugPref } from "@hive/shared/prefs.js";
|
import { getExpandDetailsPref, getHideDebugPref } from "../prefs.js";
|
||||||
|
|
||||||
function MarkdownBody({ text }: { text: string }) {
|
function MarkdownBody({ text }: { text: string }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// Auto-linkify bare http(s) URLs in plain (non-markdown) row text —
|
// Auto-linkify bare http(s) URLs in plain (non-markdown) row text —
|
||||||
// Preact-node port of @hive/shared/terminal.js's `linkify` (text-node
|
// Preact-node port of ./terminal.js's `linkify` (text-node output there,
|
||||||
// output there, JSX fragment here). Same regex + trailing-punctuation
|
// JSX fragment here). Same regex + trailing-punctuation strip;
|
||||||
// strip; deliberately text-only, never innerHTML, so untrusted row text
|
// deliberately text-only, never innerHTML, so untrusted row text
|
||||||
// (matrix-relayed bodies, tool args) can't inject markup this way.
|
// (matrix-relayed bodies, tool args) can't inject markup this way.
|
||||||
import type { JSX } from "preact";
|
import type { JSX } from "preact";
|
||||||
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Markdown → sanitized HTML, ported from app.js's `mdNode`. Message
|
// 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,
|
// payloads) are untrusted (peer-agent / matrix-relayed content,
|
||||||
// agent-authored files) — `marked` itself no longer sanitizes (v5+
|
// agent-authored files) — `marked` itself no longer sanitizes (v5+
|
||||||
// dropped the built-in sanitizer), so every parse is run through
|
// dropped the built-in sanitizer), so every parse is run through
|
||||||
|
|
@ -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
|
// `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
|
// a `TermMsg` close to as-is (see Row.tsx); there's no separate
|
||||||
// separate client-side row model or classification step any more —
|
// client-side row model or classification step any more — mara:
|
||||||
// mara: "StreamRow should now match what the server sends in TermMsg."
|
// "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 type Level = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
export interface TermMsg {
|
export interface TermMsg {
|
||||||
|
|
@ -22,7 +27,9 @@ export interface TermMsg {
|
||||||
* `seq` is the live per-event dedup counter (`BusEvent::seq`); absent on
|
* `seq` is the live per-event dedup counter (`BusEvent::seq`); absent on
|
||||||
* history-replayed envelopes, see useLiveStream.ts's backfill dance. It is
|
* 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
|
* 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 {
|
export interface TermEnvelope {
|
||||||
seq?: number;
|
seq?: number;
|
||||||
msgs: TermMsg[];
|
msgs: TermMsg[];
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { Route, Switch } from "wouter-preact";
|
import { Route, Switch } from "wouter-preact";
|
||||||
import { Shell } from "./shell/Shell.js";
|
import { Shell } from "./shell/Shell.js";
|
||||||
import { AgentsPage } from "./pages/agents/AgentsPage.js";
|
import { AgentsPage } from "./pages/agents/AgentsPage.js";
|
||||||
|
import { AgentTermPage } from "./pages/agents/AgentTermPage.js";
|
||||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||||
import { JobsPage } from "./pages/JobsPage.js";
|
import { JobsPage } from "./pages/JobsPage.js";
|
||||||
import { HivesPage } from "./pages/HivesPage.js";
|
import { HivesPage } from "./pages/HivesPage.js";
|
||||||
|
|
@ -23,6 +24,7 @@ export function App() {
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={HivesPage} />
|
<Route path="/" component={HivesPage} />
|
||||||
<Route path="/agents" component={AgentsPage} />
|
<Route path="/agents" component={AgentsPage} />
|
||||||
|
<Route path="/agents/:name/term" component={AgentTermPage} />
|
||||||
<Route path="/jobs" component={JobsPage} />
|
<Route path="/jobs" component={JobsPage} />
|
||||||
<Route path="/issues" component={IssueReportPage} />
|
<Route path="/issues" component={IssueReportPage} />
|
||||||
<Route path="/components" component={ComponentsPage} />
|
<Route path="/components" component={ComponentsPage} />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
// see its own comment) — `viewMode` here just says which one to show.
|
// see its own comment) — `viewMode` here just says which one to show.
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import type { ComponentChildren } from "preact";
|
import type { ComponentChildren } from "preact";
|
||||||
|
import { useLocation } from "wouter-preact";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
import { Badge } from "@hive/shared/badge.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";
|
const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
|
||||||
|
|
||||||
export function AgentsPage() {
|
export function AgentsPage() {
|
||||||
|
const [, navigate] = useLocation();
|
||||||
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
||||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
const [intervalMs, setIntervalMs] =
|
const [intervalMs, setIntervalMs] =
|
||||||
|
|
@ -564,6 +566,17 @@ export function AgentsPage() {
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="ui-agent-detail-actions">
|
<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
|
<Badge
|
||||||
variant="quiet"
|
variant="quiet"
|
||||||
icon={<LinkIcon />}
|
icon={<LinkIcon />}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue