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
109
frontend/packages/shared/src/terminal/Row.tsx
Normal file
109
frontend/packages/shared/src/terminal/Row.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// Renders one `TermRow` — flat `<div class="row …">` or expandable
|
||||
// `<details class="row …">`, driven straight off the wire shape
|
||||
// (`./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 "./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<HTMLDivElement>(null);
|
||||
const html = renderMarkdown(text);
|
||||
useEffect(() => {
|
||||
// marked autolinks URLs but leaves them same-tab — open externally
|
||||
// so a click never navigates the terminal away.
|
||||
ref.current?.querySelectorAll("a[href]").forEach((a) => {
|
||||
a.setAttribute("target", "_blank");
|
||||
a.setAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
}, [html]);
|
||||
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
|
||||
return (
|
||||
<div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
);
|
||||
}
|
||||
|
||||
function DiffBody({ text }: { text: string }) {
|
||||
const lines = String(text).split("\n");
|
||||
return (
|
||||
<pre className="tool-body diff-body">
|
||||
{lines.map((line, i) => {
|
||||
const cls = line.startsWith("+ ")
|
||||
? "diff-add"
|
||||
: line.startsWith("- ")
|
||||
? "diff-del"
|
||||
: "diff-ctx";
|
||||
return (
|
||||
<span key={i} className={cls}>
|
||||
{line}
|
||||
{"\n"}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export function Row({ row }: { row: TermRow }) {
|
||||
// "hide debug output" is a skip, not a dimmer — a hidden debug row
|
||||
// still occupies no DOM node at all, same as it never streamed, rather
|
||||
// than a CSS `display: none` that would keep it in the layout/DOM for
|
||||
// no benefit.
|
||||
if (row.level === "debug" && getHideDebugPref()) return null;
|
||||
|
||||
const cssClass = "level-" + row.level;
|
||||
const icon = row.icon != null && row.icon !== "" && (
|
||||
<span className="row-glyph">{row.icon}</span>
|
||||
);
|
||||
|
||||
if (row.body == null) {
|
||||
return (
|
||||
<div className={`row ${cssClass}`}>
|
||||
{icon}
|
||||
{/* Explicit wrapper, not bare children — the row's grid places
|
||||
`.row-content` in its content column by class, not by
|
||||
auto-placement inference (an icon-less row's lone child would
|
||||
otherwise auto-place into the icon column instead). */}
|
||||
<span className="row-content">{linkifyToNodes(row.summary)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty summary + markdown body → the body itself is the whole row
|
||||
// (assistant text), no summary prefix line, never collapsible.
|
||||
if (row.body_format === "markdown" && row.summary === "") {
|
||||
return (
|
||||
<div className={`row ${cssClass}`}>
|
||||
{icon}
|
||||
<MarkdownBody text={row.body} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<details
|
||||
className={`row ${cssClass}`}
|
||||
open={getExpandDetailsPref() || undefined}
|
||||
>
|
||||
<summary>
|
||||
{icon}
|
||||
<span className="summary-text">{row.summary}</span>
|
||||
</summary>
|
||||
{row.body_format === "diff" && <DiffBody text={row.body} />}
|
||||
{row.body_format === "markdown" && <MarkdownBody text={row.body} />}
|
||||
{row.body_format == null && (
|
||||
<pre className="tool-body">{linkifyToNodes(row.body)}</pre>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue