Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/agent/terminal/AgentTermPreview.tsx
iris f7c8d4c14c swarm-ui: the model and effort badges on an agent's terminal open its inference settings
Clicking either opens the settings in a popover under the badge, the same panel the agent's card has. A subagent's terminal keeps plain badges.
2026-11-12 10:37:30 +01:00

201 lines
8.2 KiB
TypeScript

// <AgentTermPreview> — the MVP mara's own ruling asked for: not a separate
// page, a small preview in an opened agent card on `AgentsPage`, 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`.
// Only the full-tab mount (`fullHeight`) shows the state badges: the small
// preview in an opened card stays free of them (mara: "the preview is small
// and should not be encroached further with badges on top, but for the full
// screen term i want it like that"), and the card's overview lists the
// same state in a row of its own (`AgentStateRow`).
// **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 } from "./useSwarmAgentStateStream.js";
import { termPaths } from "./term-paths.js";
import { StateBadges, tokenTotal } from "../state/StateBadges.js";
import { AgentInputForm } from "./AgentInputForm.js";
import { CancelTurnBadge } from "./CancelTurnBadge.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",
};
export function AgentTermPreview({
agentName,
subagent,
fullHeight = false,
leading,
trailing,
}: {
agentName: string;
// Set: this is one of `agentName`'s subagents, output only like the
// agent's own.
subagent?: string;
// The full-tab mount: fills the tab, has no expand trigger, and shows the
// agent's state badges beside the connection one.
fullHeight?: boolean;
// A badge ahead of the connection badge, for the full-tab mount's
// link back to the agent's details.
leading?: ComponentChildren;
// Icon-only badges in the top right corner, for the full-tab mount's own
// controls (a group pane's maximize and close).
trailing?: ComponentChildren;
}) {
const paths = termPaths(agentName, subagent);
const { rows, connection, reconnect } = useSwarmTermStream(paths.stream);
// The small preview has no state badges, so it does not follow the state
// stream at all.
const { header, reconnect: reconnectState } = useSwarmAgentStateStream(
fullHeight ? paths.state : null,
);
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 && (
<StateBadges
header={header}
ctx={ctx}
cost={cost}
agent={subagent === undefined ? agentName : undefined}
/>
)}
{header && subagent === undefined && (
<CancelTurnBadge
agentName={agentName}
turnState={header.turn_state}
/>
)}
</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 })}
/>
)}
{trailing !== undefined && (
<div class="ui-agent-term-preview-trailing">{trailing}</div>
)}
</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>
);
}