Watch
0
0
Fork
You've already forked hyperhive
0

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:
iris 2026-09-28 21:43:29 +02:00 • committed by mara
commit c460e91bd1
9 changed files with 445 additions and 11 deletions

View file

@ -57,6 +57,32 @@ export function LinkIcon() {
);
}
// Four corner-arrows, the standard "expand to fullscreen" glyph — added
// for the terminal-preview-to-full-tab trigger (`AgentTermPreview`'s own
// expand affordance). Same size/stroke recipe as `LinkIcon`/`GearIcon`
// above, not a smaller variant like `FilterIcon` — this one sits as a
// standalone `Badge` trigger, not inside a table header cell.
export function ExpandIcon() {
return (
<svg
width="1.3em"
height="1.3em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
);
}
// A funnel, the standard "filter" glyph — smaller than the other two
// (1em not 1.3em) since its first caller sits inside a table header
// cell, not a nav trigger button; scale via the caller's own

View file

@ -3,6 +3,7 @@
import { Route, Switch } from "wouter-preact";
import { Shell } from "./shell/Shell.js";
import { AgentsPage } from "./pages/agents/AgentsPage.js";
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
import { ComponentsPage } from "./pages/ComponentsPage.js";
import { JobsPage } from "./pages/JobsPage.js";
import { HivesPage } from "./pages/HivesPage.js";
@ -23,6 +24,7 @@ export function App() {
<Switch>
<Route path="/" component={HivesPage} />
<Route path="/agents" component={AgentsPage} />
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
<Route path="/jobs" component={JobsPage} />
<Route path="/issues" component={IssueReportPage} />
<Route path="/components" component={ComponentsPage} />

View file

@ -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

View file

@ -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">
<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"}

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -165,6 +165,75 @@
.shell-nav-indicator.shell-nav-indicator-instant {
transition: none;
}
/* Dynamic tab row — sits below `.shell-header`, its own sticky
strip rather than folded into the header itself: the header's own
sticky offset (`top: 0`) is already claimed, and a header that grows
taller every time a tab opens would shift `.shell-nav`'s measured
positions (`measureIndex`, Shell.tsx) out from under the sliding
indicator mid-animation. `top` below is `.shell-header`'s own
footprint — `0.75em` padding top+bottom either side of the
`2.75em`-min-height nav row (`.shell-nav-link`, this file), so ~4.25em
rounded up to 4.5em for the 1px border + a little slack — not
measured, same fixed-estimate tradeoff `AgentTerminalPage.css`'s own
comment makes for the same reason (not worth a ResizeObserver for one
sticky offset), and the same 4.5em value that file's own height calc
already uses for this header, so the two stay consistent with each
other rather than drifting to two different guesses at one thing.
Undershooting this would mean the sticky header (z-index 10, above
this row's 9) visibly covers the top of the tab row once scrolled —
worth erring high, not low, if the real value ever drifts. */
.shell-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
padding: 0.4em 1.25em;
border-bottom: 1px solid var(--border);
background: var(--bg-elev);
position: sticky;
top: 4.5em;
z-index: 9; /* one below .shell-header's 10, so the header still wins if both ever overlap during a fast scroll */
}
.shell-tab {
display: flex;
align-items: center;
gap: 0.25em;
padding: 0.2em 0.4em 0.2em 0.7em;
border-radius: 0.3em;
background: var(--bg);
border: 1px solid var(--border);
}
.shell-tab-active {
border-color: var(--pink);
}
.shell-tab-link {
color: var(--muted);
text-decoration: none;
font-size: 0.9em;
}
.shell-tab-active .shell-tab-link {
color: var(--fg);
}
.shell-tab-link:hover {
color: var(--fg);
}
/* Same reset every icon-only chrome button in this codebase uses
(`Badge`'s own `<button>` case) — a bare `<button>` here isn't routed
through `Badge` since it's not a status/value pill, just a close
glyph, so the reset is inlined rather than reaching for a component
that doesn't fit this shape. */
.shell-tab-close {
all: unset;
cursor: pointer;
line-height: 1;
padding: 0.1em 0.3em;
border-radius: 0.2em;
color: var(--muted);
}
.shell-tab-close:hover {
color: var(--fg);
background: color-mix(in srgb, var(--fg) 10%, transparent);
}
.shell-body {
max-width: 60em;
margin: 0 auto;

View file

@ -34,6 +34,7 @@ import { UserMenu } from "./UserMenu.js";
import { SettingsMenu } from "@hive/shared/settings-menu.js";
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
import { useDynamicTabs } from "./useDynamicTabs.js";
import "./Shell.css";
// Storage keys this app owns for `@hive/shared`'s settings mechanism —
@ -61,6 +62,20 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
// its own table view (plus the list+detail split) hits the same cap.
const WIDE_BODY_ROUTES = new Set(["/issues", "/agents"]);
// `/agents/:name/terminal` needs the same widening but can't join
// the `Set` above — it's a parameterized path, one literal string can't
// match every agent name. A 60em-capped terminal pane reads as cramped
// (this is a full tab, not a sidebar preview), same reasoning as the
// table routes above just for a different reason (a wide pane, not a
// wide table).
const WIDE_BODY_ROUTE_PATTERN = /^\/agents\/[^/]+\/terminal$/;
function isWideBodyRoute(location: string): boolean {
return (
WIDE_BODY_ROUTES.has(location) || WIDE_BODY_ROUTE_PATTERN.test(location)
);
}
// Static fallback — matches `index.html`'s `<title>` default, so a page
// never flashes something else before the fetch below resolves, and an
// operator who never set `services.hyperhive.swarm.name` sees the exact
@ -135,6 +150,50 @@ function NavLink({
);
}
// One open agent-terminal tab (`useDynamicTabs`) — deliberately
// its own small component, not folded into `NavLink` above: dynamic tabs
// don't ride the sliding `.shell-nav-indicator` (there's no fixed set to
// hop between — see `WIDE_BODY_ROUTE_PATTERN`'s own comment on why they
// can't share `NAV_ITEMS`'s Set-based route list either), so "active"
// here is a plain class toggle, not a measured position. The close ×
// is a real sibling button, not nested inside the `<Link>` — a button
// inside an `<a>` is invalid HTML and (worse) means a click anywhere on
// the tab, including the ×, triggers the link navigation first.
function DynamicTabLink({
href,
label,
active,
onClose,
}: {
href: string;
label: string;
active: boolean;
onClose: () => void;
}) {
return (
<span class={"shell-tab" + (active ? " shell-tab-active" : "")}>
<Link href={href} className="shell-tab-link">
{label}
</Link>
<button
type="button"
class="shell-tab-close"
title={`close ${label}`}
onClick={(e) => {
// Not a navigation — stop it reaching `Link`'s own click
// handling even though this button sits outside the anchor,
// since the two are visually one row and a stray bubble
// shouldn't be able to trigger a route change on close.
e.stopPropagation();
onClose();
}}
>
×
</button>
</span>
);
}
export function Shell({ children }: { children: ComponentChildren }) {
const [swarmName, setSwarmName] = useState<string | null>(null);
const [location] = useLocation();
@ -155,6 +214,11 @@ export function Shell({ children }: { children: ComponentChildren }) {
// Every subsequent change (a real navigation) still animates normally.
const [indicatorSettledOnce, setIndicatorSettledOnce] = useState(false);
// Open agent-terminal tabs — the row below `.shell-nav` that
// grows/shrinks as tabs open/close, entirely separate from the fixed
// `NAV_ITEMS` list above.
const { tabs, close: closeTab } = useDynamicTabs();
// Applied once here, not inside `SettingsMenu` — every route mounts
// through this one `<Shell>`, so the override takes effect regardless
// of which page is showing or whether the menu's ever been opened,
@ -334,6 +398,26 @@ export function Shell({ children }: { children: ComponentChildren }) {
<UserMenu />
</div>
</header>
{/* Second row, only when there's something to show — an always-
present empty bar would just be dead chrome for the far more
common case of no open terminal tabs. Own `<nav>`, not folded
into `.shell-nav` above: these come and go independently of
the static route list and shouldn't shift `NAV_ITEMS`'
measured positions (`measureIndex` above) by changing the
header's height mid-sweep. */}
{tabs.length > 0 && (
<nav class="shell-tabs" aria-label="open agent terminals">
{tabs.map((tab) => (
<DynamicTabLink
key={tab.href}
href={tab.href}
label={tab.label}
active={tab.href === location}
onClose={() => closeTab(tab.href)}
/>
))}
</nav>
)}
{/* Keyed by route so it remounts (and replays its entrance
animation) on every navigation — see the file-top comment.
`shell-body-wide` is additive (see `WIDE_BODY_ROUTES` above),
@ -341,8 +425,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
apply, only the max-width cap changes. */}
<div
class={
"shell-body" +
(WIDE_BODY_ROUTES.has(location) ? " shell-body-wide" : "")
"shell-body" + (isWideBodyRoute(location) ? " shell-body-wide" : "")
}
key={location}
>

View file

@ -0,0 +1,87 @@
// useDynamicTabs — the small piece of shared state behind the swarm-ui
// header's second tab row: agent terminal tabs a user has opened, on top
// of `Shell`'s always-present static `NAV_ITEMS`. Not a generic "any page
// can open a tab" mechanism — the swarm-level terminal-tab work's own
// scope note keeps this to just agent terminals for now.
//
// Built directly on `@hive/shared/settings-storage.js`'s `useLocalSetting`
// — same browser-local persistence + same-tab pub/sub `theme-apply.js`/
// `motion-apply.js` already use, so an opened terminal tab survives a
// reload instead of vanishing (mara's own bar for "browser-local setting"
// framing in the issue's plan comment). `Shell` is the one mount point
// that renders the row; `AgentTermPreview`'s expand trigger and
// `AgentTerminalPage`'s own mount effect are the two callers of `open()`.
// See the `useEffect` below for a same-tab pub/sub race this hook works
// around — found by actually rendering a cold terminal-tab load, not
// just reasoning about the code.
import { useEffect } from "preact/hooks";
import { useLocation } from "wouter-preact";
import {
readLocalSetting,
useLocalSetting,
} from "@hive/shared/settings-storage.js";
export interface DynamicTab {
href: string;
label: string;
}
const TABS_KEY = "swarm-ui:dynamic-tabs";
export interface UseDynamicTabsResult {
tabs: DynamicTab[];
/** Adds `tab` if not already open (deduped by `href`) and navigates to it. Re-opening an already-open tab just navigates — it doesn't duplicate or reorder the row. */
open: (tab: DynamicTab) => void;
/** Removes the tab. If it was the active route, navigates to `fallbackHref` (default `/agents`, since agent terminal tabs are the only kind today). */
close: (href: string, fallbackHref?: string) => void;
}
export function useDynamicTabs(): UseDynamicTabsResult {
const [tabs, setTabs] = useLocalSetting<DynamicTab[]>(TABS_KEY, []);
const [location, navigate] = useLocation();
// Load-bearing, not decoration — a same-tab pub/sub race, confirmed by
// actually rendering a cold load of `/agents/:name/terminal` (headless
// chromium) and finding the tab row silently empty without this.
// `useLocalSetting` subscribes inside a `useEffect`, and mount effects
// fire children-before-parents (Preact mirrors React here). `Shell`
// wraps `AgentTerminalPage` as a descendant, so on a direct/reload
// load both mount in the same commit — `AgentTerminalPage`'s own
// "register this tab" effect (a call to `open()` below) can fire and
// notify subscribers *before* `Shell`'s own `useLocalSetting`
// subscription (set up in ITS mount effect, which per that ordering
// runs after the child's) even exists to catch it. `Shell`'s first
// `tabs` snapshot then silently stays stale forever, since there's no
// second write coming to re-trigger it.
//
// Fix: re-read storage on every `location` change, not just on a
// pub/sub notification. This effect is *this hook's own* mount effect,
// so it's guaranteed to run after any descendant's mount effect that
// just wrote — by the time it fires, a same-commit write from a child
// has already landed. Costs a redundant, idempotent write-back on
// every navigation across every `useDynamicTabs()` call site — traded
// deliberately for not touching `@hive/shared/settings-storage.ts`
// itself (a primitive theme/motion overrides also depend on) for a fix
// specific to this one hook's own race.
useEffect(() => {
setTabs(readLocalSetting(TABS_KEY, []));
// eslint-disable-next-line react-hooks/exhaustive-deps -- `setTabs` is
// a fresh function identity every render (`useLocalSetting` doesn't
// memoize it); including it would re-run this every render instead
// of once per navigation.
}, [location]);
function open(tab: DynamicTab) {
if (!tabs.some((t) => t.href === tab.href)) {
setTabs([...tabs, tab]);
}
navigate(tab.href);
}
function close(href: string, fallbackHref = "/agents") {
setTabs(tabs.filter((t) => t.href !== href));
if (location === href) navigate(fallbackHref);
}
return { tabs, open, close };
}