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.
201 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|