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.
This commit is contained in:
parent
433429ebfd
commit
c460e91bd1
9 changed files with 445 additions and 11 deletions
|
|
@ -8,14 +8,60 @@
|
|||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
.ui-agent-term-preview-status {
|
||||
/* Holds the connection-status badge + (unless `fullHeight`) the
|
||||
expand-to-fullscreen trigger — `margin-left: auto` targets the expand
|
||||
trigger by its OWN class, not `:last-child` (tried that first; it
|
||||
silently broke when `fullHeight` hides the expand trigger, since the
|
||||
status badge then becomes the `:last-child` too and gets pushed to
|
||||
the right edge instead of staying flush left — caught by actually
|
||||
rendering the `fullHeight` case, not just reasoning about the CSS).
|
||||
Same split-row intent `Shell.css`'s `.shell-header-actions` already
|
||||
uses for its own pair of triggers, just addressed directly instead of
|
||||
positionally. */
|
||||
.ui-agent-term-preview-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-block-end: 0.5rem;
|
||||
}
|
||||
.ui-agent-term-preview-expand {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ui-agent-term-preview-wrap.terminal-wrap {
|
||||
max-height: 12em;
|
||||
}
|
||||
|
||||
/* `AgentTerminalPage`'s full-tab mount (`fullHeight` prop → the
|
||||
`-full` modifier on the OUTER `.ui-agent-term-preview` root, not the
|
||||
wrap) — self-contained flex chain, not dependent on the page around
|
||||
it doing anything beyond giving this root a real height to fill (see
|
||||
AgentTerminalPage.css). Column flex so the toolbar keeps its natural
|
||||
height and the terminal-wrap below it takes the rest; `flex: 1` +
|
||||
`min-height: 0` on the wrap (not `height: 100%`) is deliberate — a
|
||||
flex item's `height` percentage doesn't account for a sibling
|
||||
(the toolbar) already consuming space the way `flex: 1` does, and
|
||||
`min-height: 0` is what actually lets the item shrink below its
|
||||
content's natural height so `.live.terminal`'s own `overflow-y: auto`
|
||||
(terminal.css) scrolls instead of the whole column growing past its
|
||||
container. `!important`-free, but the `max-height: none` overrides
|
||||
below rely on source order (equal specificity to the un-modified
|
||||
rules above — same class count on both sides), not on being "more
|
||||
specific" — keep this block after the un-modified one if this file
|
||||
is ever reordered. */
|
||||
.ui-agent-term-preview-full {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.ui-agent-term-preview-full .ui-agent-term-preview-wrap.terminal-wrap {
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.ui-agent-term-preview-full .ui-agent-term-preview-wrap .live.terminal {
|
||||
max-height: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* `box-sizing: border-box`, not the browser's default `content-box` —
|
||||
`.live.terminal`'s own `padding: 0.8em 1em 0.4em` (terminal.css) is
|
||||
otherwise added ON TOP of this `max-height`, so the box renders
|
||||
|
|
|
|||
|
|
@ -18,9 +18,28 @@
|
|||
// 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,
|
||||
|
|
@ -81,13 +100,22 @@ function fmtTokens(n: number): string {
|
|||
return String(n);
|
||||
}
|
||||
|
||||
export function AgentTermPreview({ agentName }: { agentName: string }) {
|
||||
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);
|
||||
|
||||
|
|
@ -110,14 +138,36 @@ export function AgentTermPreview({ agentName }: { agentName: string }) {
|
|||
}, [rows, stickToBottom]);
|
||||
|
||||
return (
|
||||
<div class="ui-agent-term-preview">
|
||||
<Badge
|
||||
class="ui-agent-term-preview-status"
|
||||
tone={CONNECTION_TONE[connection]}
|
||||
value={CONNECTION_LABEL[connection]}
|
||||
/>
|
||||
<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 && (
|
||||
{header && showHeaderBadges && (
|
||||
<div class="ui-agent-term-preview-header-badges">
|
||||
<Badge
|
||||
tone={TURN_STATE_TONE[header.turn_state] ?? "neutral"}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
/* <AgentTerminalPage> — gives `AgentTermPreview`'s `fullHeight` flex
|
||||
chain (`.ui-agent-term-preview-full`, AgentTermPreview.css) something
|
||||
real to fill. `Shell.css`'s `.shell-body` has no fixed height of its
|
||||
own (it's a plain flow column sized by content), so without an
|
||||
explicit height here the chain's `flex: 1` would resolve against
|
||||
nothing and the terminal would collapse to its content's own height
|
||||
instead of filling the viewport — the opposite of what "full-height"
|
||||
mount means for this page specifically (the 12em-capped preview
|
||||
inside `AgentsPage` deliberately has no such wrapper; this is the one
|
||||
place that needs it).
|
||||
|
||||
`100dvh`, not `100vh` — `100vh` on a mobile browser includes the
|
||||
address-bar chrome even while it's visibly on-screen, so the terminal
|
||||
would render taller than the actually-visible viewport there;
|
||||
`100dvh` tracks the real visible area instead. The subtraction below
|
||||
is a fixed estimate (Shell.css's own `.shell-header` padding +
|
||||
`.shell-body`'s), not a measured value — an exact fit isn't worth a
|
||||
ResizeObserver for a single full-bleed page; a few pixels short of
|
||||
the viewport edge reads as intentional breathing room, not a bug. */
|
||||
.ui-agent-terminal-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100dvh - 4.5em - 3em);
|
||||
}
|
||||
|
||||
.ui-agent-terminal-page .ui-agent-term-preview-full {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
// <AgentTerminalPage> — the swarm terminal tab's full page, mounted at
|
||||
// `/agents/:name/terminal`. The MVP `AgentTermPreview` (below `AgentsPage`'s
|
||||
// detail panel) is explicitly capped and read-only; this is that same
|
||||
// component's `fullHeight` mount — no 12em cap, floating header badges
|
||||
// shown (there's no detail-panel `<dl>` here to duplicate them). Sending
|
||||
// input back to the agent is still explicit follow-up scope, same as the
|
||||
// preview's own file comment says — this page doesn't change that.
|
||||
//
|
||||
// Registers itself in the dynamic-tabs row on mount (not just when opened
|
||||
// via the preview's expand trigger) — a bookmark, a shared link, or a
|
||||
// plain page reload should all re-open the tab instead of landing on a
|
||||
// page with no way back into the tab row. `useDynamicTabs().open()` is
|
||||
// idempotent (dedupes by href), so this is safe to call every mount.
|
||||
import { useEffect } from "preact/hooks";
|
||||
import type { RouteComponentProps } from "wouter-preact";
|
||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
|
||||
import "./AgentTerminalPage.css";
|
||||
|
||||
export function AgentTerminalPage({
|
||||
params,
|
||||
}: RouteComponentProps<{ name: string }>) {
|
||||
const { open } = useDynamicTabs();
|
||||
const name = decodeURIComponent(params.name);
|
||||
|
||||
// Runs once per mounted agent name — re-registering on every render
|
||||
// would just be redundant work against an already-deduped list, but
|
||||
// there's no reason to pay it more than once per identity.
|
||||
useEffect(() => {
|
||||
open({ href: `/agents/${encodeURIComponent(name)}/terminal`, label: name });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- `open` is a
|
||||
// fresh function identity every render (`useDynamicTabs` doesn't
|
||||
// memoize it); including it would re-fire this effect every render
|
||||
// instead of once per agent `name`.
|
||||
}, [name]);
|
||||
|
||||
return (
|
||||
<div class="ui-agent-terminal-page">
|
||||
<AgentTermPreview agentName={name} fullHeight showHeaderBadges />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue