The hives nav tab and route go, with no redirect; the roster is a card above the agents panel that starts folded and is read on the first open. The settings toggle is renamed from hide debug output to hide developer details, as it now also drops the components tab (the page still answers at /components); the stored preference keeps its key.
127 lines
4.4 KiB
TypeScript
127 lines
4.4 KiB
TypeScript
// 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, useState } from "preact/hooks";
|
|
import type { TermRow } from "./termMsg.js";
|
|
import { linkifyToNodes } from "./linkify.js";
|
|
import { renderMarkdown } from "./markdown.js";
|
|
import {
|
|
getExpandDetailsPref,
|
|
getHideDebugPref,
|
|
subscribeHideDebugPref,
|
|
} 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>
|
|
);
|
|
}
|
|
|
|
// A `debug` row, which follows the "hide developer details" preference as it
|
|
// changes. Only debug rows subscribe, so a long terminal does not hold a
|
|
// listener per row.
|
|
function DebugRow({ row }: { row: TermRow }) {
|
|
const [hidden, setHidden] = useState(() => getHideDebugPref());
|
|
useEffect(
|
|
() => subscribeHideDebugPref(() => setHidden(getHideDebugPref())),
|
|
[],
|
|
);
|
|
// "hide developer details" 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.
|
|
return hidden ? null : <RowBody row={row} />;
|
|
}
|
|
|
|
export function Row({ row }: { row: TermRow }) {
|
|
return row.level === "debug" ? <DebugRow row={row} /> : <RowBody row={row} />;
|
|
}
|
|
|
|
function RowBody({ row }: { row: TermRow }) {
|
|
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>
|
|
);
|
|
}
|