Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx
iris c460e91bd1 swarm-ui: full-tab agent terminal (#4506)
Adds a full, non-capped agent terminal reachable from a new expand
trigger on the embedded AgentTermPreview (the detail-panel preview on
AgentsPage stays as-is, just gains the trigger). Opens
/agents/:name/terminal in a new dynamic tab in Shell's header, next to
the static nav row — tabs persist across a reload via useDynamicTabs,
a small localStorage-backed hook built on @hive/shared's existing
settings-storage primitive.

AgentTermPreview gains two new props to support both mounts from one
component: fullHeight (drops the 12em preview cap, fills its page)
and showHeaderBadges (default true — lets a future caller that
already shows turn_state/model/ctx/cost elsewhere suppress this
cluster; AgentsPage doesn't use it, see below).

Deviation from the originally posted plan (issue comment 80596): that
plan proposed AgentsPage's embedded preview pass showHeaderBadges as
false, reasoning the detail panel already duplicates that info.
Checked the actual code before implementing — it doesn't; AgentRow/
AgentTypes.ts carry none of turn_state/model/ctx/cost, and
AgentTermPreview's own floating badges are the only place swarm-ui
shows them. Left the badges visible there instead of shipping a
regression the plan's own stated justification didn't hold up to.

Also fixed a same-tab pub/sub race found by actually rendering a cold
load of /agents/:name/terminal (headless chromium, not just reasoning
about the code): useLocalSetting subscribes inside a useEffect, and
mount effects fire children-before-parents, so a descendant's
mount-time write (AgentTerminalPage registering its own tab) can beat
an ancestor's (Shell's) subscription into existence, leaving Shell's
tab row silently empty on a direct/reload load. Fixed by having
useDynamicTabs re-sync from storage on every location change, not
just on notify() — the fix lives in the new hook itself, not in the
shared settings-storage primitive theme/motion overrides also use.
2026-09-28 23:17:44 +02:00

236 lines
9.3 KiB
TypeScript

// <AgentTermPreview> — 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<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);
}
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<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="ui-agent-term-preview-toolbar">
<Badge
class="ui-agent-term-preview-status"
tone={CONNECTION_TONE[connection]}
value={CONNECTION_LABEL[connection]}
/>
{!fullHeight && (
<Badge
class="ui-agent-term-preview-expand"
variant="quiet"
icon={<ExpandIcon />}
value="expand"
title={`open ${agentName}'s terminal in its own tab`}
onClick={() =>
openTab({
href: `/agents/${encodeURIComponent(agentName)}/terminal`,
label: agentName,
})
}
/>
)}
</div>
<div class="terminal-wrap ui-agent-term-preview-wrap">
{header && showHeaderBadges && (
<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}>
{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>
</div>
);
}