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.
236 lines
9.3 KiB
TypeScript
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>
|
|
);
|
|
}
|