// — 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 input — sending input back to the agent is 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). // // **Floating header badges** — turn state / model / ctx / cost parity // with the per-agent page's own `StatusChips` — mara: "dont make it a // header though - make it floating elements on top of the terminal", so // this is a small `Badge` cluster pinned to the terminal box's own // corner (`.terminal-wrap`'s `position: relative`, same anchor // `@hive/shared/terminal.css`'s `.tail-pill` already uses), not a // separate layout row. Read-only, no click targets: pause/resume already // has its own control surface (`WantedMenu` on `AgentsPage`), so nothing // here needs to be interactive. Sourced from `useSwarmAgentStateStream`, // not from `AgentRow` — the roster fetch `AgentsPage` already did has no // notion of turn_state/model/ctx/cost, only `wanted`/`snapshot.running`. // `showHeaderBadges` (default true) exists for a caller that already // shows this info elsewhere and wants this cluster suppressed — checked // against the original plan for this page, which proposed exactly that // for `AgentsPage`'s embedded usage; that page's detail panel turned out // NOT to duplicate any of this (`AgentRow`/`AgentTypes.ts` carry none of // turn_state/model/ctx/cost), so `AgentsPage` leaves this at its default // rather than hiding the only place swarm-ui shows it — see the commit // message for the full reasoning. The prop still exists because // suppressing it is a real, reusable need in principle, just not here. // **Expand-to-fullscreen trigger** — opens/navigates to // `/agents/:name/terminal` (`AgentTerminalPage`) via `useDynamicTabs`, // same header-tab mechanism `Shell`'s own nav row now shares. Lives here // (not on `AgentsPage`) so both the embedded preview and the future full // page mount the identical trigger without either needing to know where // the other one is. Suppressed via `fullHeight` — the full-page mount // already IS the destination, so it has nothing further to expand to. import { useLayoutEffect, useRef, useState } from "preact/hooks"; import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { ExpandIcon } from "@hive/shared/icons.js"; import { Row } from "@hive/shared/term-row.js"; import { useDynamicTabs } from "../../shell/useDynamicTabs.js"; import { useSwarmTermStream, type ConnectionState, } from "./useSwarmTermStream.js"; import { useSwarmAgentStateStream, type TokenUsage, } from "./useSwarmAgentStateStream.js"; import "./AgentTermPreview.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", }; // Ported, not imported, same call `useSwarmTermStream`'s own `appendRow` // already made for this file's sibling hook: `@hive/agent`'s equivalents // (`STATE_LABELS`/`tokenTotal`/`fmtTokens` in `Root.tsx`/`lib/format.ts`) // live in a different package this one doesn't otherwise depend on, and // a cross-package hop isn't worth it for three small, stable helpers. // Vocabulary (glyphless — the per-agent page's own glyphs are part of // its bigger consolidated status badge, not this smaller read-only one) // kept in exact parity with `Root.tsx`'s `STATE_LABELS` so "idle" here // means the same thing it means there. const TURN_STATE_TONE: Record = { idle: "neutral", thinking: "accent", compacting: "warning", }; function tokenTotal(u: TokenUsage | null): number | null { if (!u) return null; // Matches `Root.tsx`'s own `tokenTotal`: input + both cache buckets, // `output_tokens` deliberately excluded (shown in a title breakdown // there, not carried at all here — this badge has no breakdown to put // it in). return ( (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) ); } function fmtTokens(n: number): string { if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"; if (n >= 1_000) return Math.round(n / 1000) + "k"; return String(n); } export function AgentTermPreview({ agentName, showHeaderBadges = true, fullHeight = false, }: { agentName: string; showHeaderBadges?: boolean; fullHeight?: boolean; }) { const { rows, connection } = useSwarmTermStream( `/api/agents/${encodeURIComponent(agentName)}/term/stream`, ); const { header } = useSwarmAgentStateStream( `/api/agents/${encodeURIComponent(agentName)}/state/stream`, ); const { open: openTab } = useDynamicTabs(); const logRef = useRef(null); const [stickToBottom, setStickToBottom] = useState(true); const ctx = header ? tokenTotal(header.ctx_usage) : null; const cost = header ? tokenTotal(header.cost_usage) : null; 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 `@hive/agent`'s `LiveStream` uses. useLayoutEffect(() => { const el = logRef.current; if (el && stickToBottom) el.scrollTop = el.scrollHeight - el.clientHeight; }, [rows, stickToBottom]); return (
{!fullHeight && ( } value="expand" title={`open ${agentName}'s terminal in its own tab`} onClick={() => openTab({ href: `/agents/${encodeURIComponent(agentName)}/terminal`, label: agentName, }) } /> )}
{header && showHeaderBadges && (
{/* Only the non-boring case gets a badge — a healthy running agent (`agent_state: "up"`) doesn't need one for the obvious default. In practice this only ever fires for "paused": `hive-agent` can only honestly publish "up" or "paused" on this subject (see `AgentStateMsg::agent_state`'s own doc), never "offline"/"destroyed" — a stopped or destroyed agent publishes nothing at all. */} {header.agent_state !== "up" && ( )} {ctx !== null && ( )} {cost !== null && ( )}
)}
{rows.length === 0 ? (
{/* Explicit `.row-content` wrapper, not a bare text child — `.live .row`'s CSS grid (terminal.css) has a narrow first column reserved for a row icon; a row's lone child with no explicit `grid-column` auto-places into that column instead of the content one, wrapping the text one or two characters per line — mara reported exactly this on the live "connecting…" placeholder. `Row.tsx`'s own icon-less flat row already documents and avoids this same trap. */} {connection === "open" ? "(connected — waiting for events)" : CONNECTION_LABEL[connection]}
) : ( rows.map((row) => ) )}
); }