swarm-ui: floating turn-state/model/ctx/cost badges on the agent term preview
Header-bar parity for the swarm-level agent terminal, per mara's ruling
"dont make it a header though - make it floating elements on top of the
terminal". Adds useSwarmAgentStateStream (SSE consumer for
swarm-controller's GET /api/agents/{name}/state/stream) and renders a
small read-only Badge cluster absolutely positioned in
AgentTermPreview's terminal box corner, reusing the same anchor pattern
@hive/shared/terminal.css's .tail-pill already uses.
Shows turn_state always, agent_state only when it isn't the boring "up"
case, model (with resolved-model tooltip), and ctx/cost usage. No badge
renders until the first header event lands - the swarm queue's
agent-state subject is transition-only with no seed/replay, and no
swarm-level endpoint today can seed turn_state/model/ctx/cost
synchronously.
Vocabulary (state tones, token-total/format helpers) ported from
@hive/agent's Root.tsx/lib/format.ts rather than imported, matching the
existing "small helper, not worth a cross-package hop" call
useSwarmTermStream's own appendRow already made.
Verified by rendering the real component tree against a stubbed
EventSource and screenshotting the result - the badge cluster initially
overlapped the terminal's first row of text, fixed by giving this
preview's own .live.terminal extra top padding.
This commit is contained in:
parent
273b2895a1
commit
d9c8b7ca8f
3 changed files with 211 additions and 6 deletions
|
|
@ -29,4 +29,33 @@
|
||||||
.ui-agent-term-preview-wrap .live.terminal {
|
.ui-agent-term-preview-wrap .live.terminal {
|
||||||
max-height: 12em;
|
max-height: 12em;
|
||||||
box-sizing: border-box;
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,23 @@
|
||||||
// <AgentTermPreview> — the MVP mara's own ruling asked for: not a separate
|
// <AgentTermPreview> — the MVP mara's own ruling asked for: not a separate
|
||||||
// page, a small read-only preview embedded below `AgentsPage`'s detail
|
// page, a small read-only preview embedded below `AgentsPage`'s detail
|
||||||
// panel fields. No header, no input — those (plus sending input back
|
// panel fields. No input — sending input back to the agent is explicitly
|
||||||
// to the agent) are explicitly follow-up scope, not this issue's.
|
// follow-up scope, not this issue's. Consumes swarm-controller's
|
||||||
// Consumes swarm-controller's `GET /api/agents/{name}/term/stream` via
|
// `GET /api/agents/{name}/term/stream` via `useSwarmTermStream`,
|
||||||
// `useSwarmTermStream`, rendering through the same `@hive/shared`
|
// rendering through the same `@hive/shared` `Row` component the per-hive
|
||||||
// `Row` component the per-hive local terminal uses — "share components
|
// local terminal uses — "share components between hive and swarm level
|
||||||
// between hive and swarm level term as much as possible" (mara).
|
// 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 { useLayoutEffect, useRef, useState } from "preact/hooks";
|
||||||
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
|
||||||
import { Row } from "@hive/shared/term-row.js";
|
import { Row } from "@hive/shared/term-row.js";
|
||||||
|
|
@ -13,6 +25,10 @@ import {
|
||||||
useSwarmTermStream,
|
useSwarmTermStream,
|
||||||
type ConnectionState,
|
type ConnectionState,
|
||||||
} from "./useSwarmTermStream.js";
|
} from "./useSwarmTermStream.js";
|
||||||
|
import {
|
||||||
|
useSwarmAgentStateStream,
|
||||||
|
type TokenUsage,
|
||||||
|
} from "./useSwarmAgentStateStream.js";
|
||||||
import "./AgentTermPreview.css";
|
import "./AgentTermPreview.css";
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
const NEAR_BOTTOM_PX = 48;
|
||||||
|
|
@ -31,13 +47,53 @@ const CONNECTION_TONE: Record<ConnectionState, BadgeTone> = {
|
||||||
closed: "negative",
|
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<string, BadgeTone> = {
|
||||||
|
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 }) {
|
export function AgentTermPreview({ agentName }: { agentName: string }) {
|
||||||
const { rows, connection } = useSwarmTermStream(
|
const { rows, connection } = useSwarmTermStream(
|
||||||
`/api/agents/${encodeURIComponent(agentName)}/term/stream`,
|
`/api/agents/${encodeURIComponent(agentName)}/term/stream`,
|
||||||
);
|
);
|
||||||
|
const { header } = useSwarmAgentStateStream(
|
||||||
|
`/api/agents/${encodeURIComponent(agentName)}/state/stream`,
|
||||||
|
);
|
||||||
const logRef = useRef<HTMLDivElement>(null);
|
const logRef = useRef<HTMLDivElement>(null);
|
||||||
const [stickToBottom, setStickToBottom] = useState(true);
|
const [stickToBottom, setStickToBottom] = useState(true);
|
||||||
|
|
||||||
|
const ctx = header ? tokenTotal(header.ctx_usage) : null;
|
||||||
|
const cost = header ? tokenTotal(header.cost_usage) : null;
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const el = logRef.current;
|
const el = logRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
@ -61,6 +117,47 @@ export function AgentTermPreview({ agentName }: { agentName: string }) {
|
||||||
value={CONNECTION_LABEL[connection]}
|
value={CONNECTION_LABEL[connection]}
|
||||||
/>
|
/>
|
||||||
<div class="terminal-wrap ui-agent-term-preview-wrap">
|
<div class="terminal-wrap ui-agent-term-preview-wrap">
|
||||||
|
{header && (
|
||||||
|
<div class="ui-agent-term-preview-header-badges">
|
||||||
|
<Badge
|
||||||
|
tone={TURN_STATE_TONE[header.turn_state] ?? "neutral"}
|
||||||
|
value={header.turn_state}
|
||||||
|
/>
|
||||||
|
{/* 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" && (
|
||||||
|
<Badge tone="warning" value={header.agent_state} />
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
label="model"
|
||||||
|
value={header.model}
|
||||||
|
title={
|
||||||
|
header.resolved_model
|
||||||
|
? `resolved to ${header.resolved_model}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{ctx !== null && (
|
||||||
|
<Badge
|
||||||
|
label="ctx"
|
||||||
|
value={fmtTokens(ctx)}
|
||||||
|
title="tokens used in the current context window"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cost !== null && (
|
||||||
|
<Badge
|
||||||
|
label="cost"
|
||||||
|
value={fmtTokens(cost)}
|
||||||
|
title="cumulative tokens billed across the last turn"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div class="live terminal" ref={logRef} onScroll={handleScroll}>
|
<div class="live terminal" ref={logRef} onScroll={handleScroll}>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div class="row note">
|
<div class="row note">
|
||||||
|
|
|
||||||
|
|
@ -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<AgentStateHeader | null>(null);
|
||||||
|
const [connection, setConnection] = useState<ConnectionState>("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 };
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue