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
|
|
@ -1,41 +0,0 @@
|
|||
// 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
|
||||
// (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;
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
// Markdown → sanitized HTML, ported from app.js's `mdNode`. Message
|
||||
// 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
|
||||
// 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> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
};
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
// 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 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 {
|
||||
/** 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. */
|
||||
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;
|
||||
}
|
||||
Loading…
Reference in a new issue