diff --git a/frontend/packages/shared/src/icons.tsx b/frontend/packages/shared/src/icons.tsx
index 01b2b3eb..a53b68ca 100644
--- a/frontend/packages/shared/src/icons.tsx
+++ b/frontend/packages/shared/src/icons.tsx
@@ -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 (
+
+ );
+}
+
// 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
diff --git a/frontend/packages/swarm-ui/src/App.tsx b/frontend/packages/swarm-ui/src/App.tsx
index 99380be6..8c1a482f 100644
--- a/frontend/packages/swarm-ui/src/App.tsx
+++ b/frontend/packages/swarm-ui/src/App.tsx
@@ -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() {
+
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css
index 666f90fc..69e54d53 100644
--- a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css
+++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.css
@@ -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
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx
index b6e113a1..fdd0ff48 100644
--- a/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx
+++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTermPreview.tsx
@@ -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(null);
const [stickToBottom, setStickToBottom] = useState(true);
@@ -110,14 +138,36 @@ export function AgentTermPreview({ agentName }: { agentName: string }) {
}, [rows, stickToBottom]);
return (
-
— 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;
+}
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx
new file mode 100644
index 00000000..4b1f8b9b
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx
@@ -0,0 +1,42 @@
+// — 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 `
` 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 (
+
+
+
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/shell/Shell.css b/frontend/packages/swarm-ui/src/shell/Shell.css
index fe1fd3e6..a052cb09 100644
--- a/frontend/packages/swarm-ui/src/shell/Shell.css
+++ b/frontend/packages/swarm-ui/src/shell/Shell.css
@@ -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 `
+ {/* 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 `