Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx
iris 31b7452e56 swarm-ui: let a closed stream reconnect and a failed caps read retry
When the server answers an event stream with an error status the browser
gives up and the badge stayed on disconnected with nothing to press. The
terminal, its state header and the messages page now offer a reconnect,
and the one rule for the state after an error moved into a tested helper.
The resource caps panel gets a retry beside its error, and its loading
state announces itself.
2026-10-22 07:13:32 +02:00

301 lines
11 KiB
TypeScript

// <AgentTermPreview> — the MVP mara's own ruling asked for: not a separate
// page, a small preview embedded below `AgentsPage`'s detail panel fields, with
// an `AgentInputForm` under an agent's terminal (a subagent's has none: it
// takes no input of its own). Consumes swarm-controller's
// `GET /api/agents/{name}/term/stream`, or for a subagent
// `GET /api/agents/{name}/subagents/{subagent}/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 type { ComponentChildren } from "preact";
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 { AgentInputForm } from "./AgentInputForm.js";
import "./AgentTermPreview.css";
const NEAR_BOTTOM_PX = 48;
const CONNECTION_LABEL: Record<ConnectionState, string> = {
connecting: "connecting…",
open: "live",
reconnecting: "reconnecting…",
closed: "disconnected",
};
const CONNECTION_TONE: Record<ConnectionState, BadgeTone> = {
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<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);
}
// The paths one terminal is reached at. A subagent's terminal sits under its
// agent's, and has no turn-state header: the swarm relays none for it.
interface TermPaths {
label: string;
page: string;
stream: string;
state: string | null;
}
export function termPaths(agentName: string, subagent?: string): TermPaths {
const agent = `/agents/${encodeURIComponent(agentName)}`;
if (subagent === undefined) {
return {
label: agentName,
page: `${agent}/terminal`,
stream: `/api${agent}/term/stream`,
state: `/api${agent}/state/stream`,
};
}
const sub = `${agent}/subagents/${encodeURIComponent(subagent)}`;
return {
label: `${agentName}/${subagent}`,
page: `${sub}/terminal`,
stream: `/api${sub}/term/stream`,
state: null,
};
}
// Turn state, agent state, model, ctx and cost, as the badges beside the
// connection one. Siblings only, no wrapper: they sit in the caller's row.
function StateBadges({
header,
ctx,
cost,
}: {
header: NonNullable<ReturnType<typeof useSwarmAgentStateStream>["header"]>;
ctx: number | null;
cost: number | null;
}) {
return (
<>
<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"
/>
)}
</>
);
}
export function AgentTermPreview({
agentName,
subagent,
showHeaderBadges = true,
fullHeight = false,
leading,
}: {
agentName: string;
// Set: this is one of `agentName`'s subagents, output only like the
// agent's own.
subagent?: string;
showHeaderBadges?: boolean;
fullHeight?: boolean;
// A badge ahead of the connection badge, for the full-tab mount's
// link back to the agent's details.
leading?: ComponentChildren;
}) {
const paths = termPaths(agentName, subagent);
const { rows, connection, reconnect } = useSwarmTermStream(paths.stream);
const { header, reconnect: reconnectState } = useSwarmAgentStateStream(
paths.state,
);
const { open: openTab } = useDynamicTabs();
const logRef = useRef<HTMLDivElement>(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 (
<div
class={
"ui-agent-term-preview" +
(fullHeight ? " ui-agent-term-preview-full" : "")
}
>
<div class="terminal-wrap ui-agent-term-preview-wrap">
{/* One floating row over the terminal's top edge: the state
badges (connection first) on the left, the expand trigger on
the right. The full-tab mount is already the destination, so
it has no expand trigger. */}
<div class="ui-agent-term-preview-controls">
<div class="ui-agent-term-preview-header-badges">
{leading}
<Badge
tone={CONNECTION_TONE[connection]}
value={CONNECTION_LABEL[connection]}
/>
{connection === "closed" && (
<Badge
variant="quiet"
value="reconnect"
title={`open ${paths.label}'s terminal stream again`}
onClick={() => {
reconnect();
reconnectState();
}}
/>
)}
{header && showHeaderBadges && (
<StateBadges header={header} ctx={ctx} cost={cost} />
)}
</div>
{!fullHeight && (
<Badge
icon={<ExpandIcon />}
value="expand"
title={`open ${paths.label}'s terminal in its own tab`}
onClick={() => openTab({ href: paths.page, label: paths.label })}
/>
)}
</div>
<div class="live terminal" ref={logRef} onScroll={handleScroll}>
{rows.length === 0 ? (
<div class="row note">
{/* 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. */}
<span class="row-content">
{connection === "open"
? "(connected — waiting for events)"
: CONNECTION_LABEL[connection]}
</span>
</div>
) : (
rows.map((row) => <Row key={row.key} row={row} />)
)}
</div>
</div>
{subagent === undefined && <AgentInputForm agentName={agentName} />}
</div>
);
}