diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css index 60f73352..666f90fc 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css @@ -29,4 +29,33 @@ .ui-agent-term-preview-wrap .live.terminal { max-height: 12em; box-sizing: border-box; + /* Extra top padding (base `.live.terminal` rule: `0.8em 1em 0.4em`) so + the floating badge cluster below has room to sit above the first + row's text instead of overlapping it — found by actually rendering + this: with the base padding, "thinking"/"model sonnet" sat directly + on top of the terminal's first line whenever content started right + at the top (a running preview scrolled to the newest rows almost + always does). */ + padding-block-start: 2.4em; +} + +/* Floating turn_state/agent_state/model/ctx/cost badge cluster — + positioned by `.terminal-wrap`'s own `position: relative`, same + anchor `@hive/shared/terminal.css`'s `.tail-pill` already uses, rather + than a second positioned ancestor of our own. Top-right corner: the + scroll-to-bottom `.tail-pill` (that same stylesheet) anchors + bottom-right, so the two overlays never compete for the same corner. + `flex-wrap` rather than a fixed width — a badge cluster this narrow + can still overflow the box at small viewport widths, and wrapping + beats clipping or shrinking the terminal underneath. */ +.ui-agent-term-preview-header-badges { + position: absolute; + top: 0.4em; + right: 0.4em; + z-index: 1; + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.25em; + max-width: calc(100% - 0.8em); } diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx index 4afc1aa4..b6e113a1 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx @@ -1,11 +1,23 @@ // — 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 header, no input — those (plus sending input back -// to the agent) are 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). +// 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`. import { useLayoutEffect, useRef, useState } from "preact/hooks"; import { Badge, type BadgeTone } from "@hive/shared/badge.js"; import { Row } from "@hive/shared/term-row.js"; @@ -13,6 +25,10 @@ import { useSwarmTermStream, type ConnectionState, } from "./useSwarmTermStream.js"; +import { + useSwarmAgentStateStream, + type TokenUsage, +} from "./useSwarmAgentStateStream.js"; import "./AgentTermPreview.css"; const NEAR_BOTTOM_PX = 48; @@ -31,13 +47,53 @@ const CONNECTION_TONE: Record = { 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 }: { agentName: string }) { const { rows, connection } = useSwarmTermStream( `/api/agents/${encodeURIComponent(agentName)}/term/stream`, ); + const { header } = useSwarmAgentStateStream( + `/api/agents/${encodeURIComponent(agentName)}/state/stream`, + ); 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; @@ -61,6 +117,47 @@ export function AgentTermPreview({ agentName }: { agentName: string }) { value={CONNECTION_LABEL[connection]} />
+ {header && ( +
+ + {/* 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 ? (
diff --git a/frontend/packages/swarm-ui/src/pages/agents/useSwarmAgentStateStream.ts b/frontend/packages/swarm-ui/src/pages/agents/useSwarmAgentStateStream.ts new file mode 100644 index 00000000..3050e7cb --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/useSwarmAgentStateStream.ts @@ -0,0 +1,79 @@ +// Live SSE consumer for the swarm-relayed agent turn-state header +// (`GET /api/agents/{name}/state/stream`, swarm-controller's +// `agent_state_stream.rs`). Same transport shape as `useSwarmTermStream` +// — one core-NATS subject relayed as bare SSE `data`, no envelope, no +// replay — but the payload here is a full header that *replaces* the +// last one rather than an appendable row, so there is nothing to +// coalesce: each event is just the latest value. +// +// **No seed on attach.** `agent_state_stream.rs`'s own module doc names +// this limitation: the subject is published on transition only, so an +// agent that has sat idle since before this hook mounted announces +// nothing until its next change. `header` stays `null` until the first +// event lands — callers should render no badges in that gap rather than +// a stale or guessed one (there is no swarm-level endpoint today that +// can seed turn_state/model/ctx/cost synchronously — `GET +// /api/agents/status`'s snapshot carries only `status_text`/`running`). +import { useEffect, useState } from "preact/hooks"; + +export type ConnectionState = "connecting" | "open" | "reconnecting" | "closed"; + +export interface TokenUsage { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; +} + +// Mirrors `hive_agent::swarm_agent_state::AgentStateMsg` field-for-field +// — see that struct's own doc comment for why `turn_state_since` (an +// ISO 8601 string here, unlike the per-agent page's unix-seconds +// `/api/state`) and `agent_state` (the swarm `AgentState` enum, not a +// bare `paused: bool`) diverge from the per-agent page's own shape. +export interface AgentStateHeader { + turn_state: "idle" | "thinking" | "compacting"; + turn_state_since: string; + agent_state: "up" | "offline" | "paused" | "destroyed"; + model: string; + resolved_model: string | null; + context_window_tokens: number; + ctx_usage: TokenUsage | null; + cost_usage: TokenUsage | null; +} + +export interface UseSwarmAgentStateStreamResult { + header: AgentStateHeader | null; + connection: ConnectionState; +} + +export function useSwarmAgentStateStream( + streamUrl: string, +): UseSwarmAgentStateStreamResult { + const [header, setHeader] = useState(null); + const [connection, setConnection] = useState("connecting"); + + useEffect(() => { + setHeader(null); + setConnection("connecting"); + const es = new EventSource(streamUrl); + es.onopen = () => setConnection("open"); + es.onmessage = (e) => { + try { + setHeader(JSON.parse(e.data) as AgentStateHeader); + } catch { + // Malformed payload: drop it silently. Unlike the terminal + // preview there is no row list to append a "[parse err]" line + // to here, and one bad header isn't worth degrading the whole + // badge cluster over — the next valid header replaces it. + } + }; + es.onerror = () => { + setConnection( + es.readyState === 0 /* CONNECTING */ ? "reconnecting" : "closed", + ); + }; + return () => es.close(); + }, [streamUrl]); + + return { header, connection }; +}