swarm-ui: split pages/agents into two pages and the agent components
pages/agents held the roster page, the terminal page and every component under them. It keeps the two pages; the components move to src/agent, by concern: roster (card, wanted menu, actions), detail (panel body, dialog, inference, linked accounts, link menu) and terminal (preview, input, subagents, stream hooks, favicon), with the shared row type and icon at the top. Moves and import rewrites only.
This commit is contained in:
parent
fd6953ed7c
commit
74e588415b
27 changed files with 28 additions and 25 deletions
|
|
@ -0,0 +1,288 @@
|
|||
// <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 } = useSwarmTermStream(paths.stream);
|
||||
const { header } = 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]}
|
||||
/>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue