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 ( -
- +
+
+ + {!fullHeight && ( + } + value="expand" + title={`open ${agentName}'s terminal in its own tab`} + onClick={() => + openTab({ + href: `/agents/${encodeURIComponent(agentName)}/terminal`, + label: agentName, + }) + } + /> + )} +
- {header && ( + {header && showHeaderBadges && (
— 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 `