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..c6ea8838 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 — the `max-height: none` overrides + below already carry one more class in the selector than the + un-modified rules above (`.ui-agent-term-preview-full` prefixing + each), so they're strictly more specific and win regardless of + source order. */ +.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..0393f5bc 100644 --- a/frontend/packages/swarm-ui/src/shell/Shell.css +++ b/frontend/packages/swarm-ui/src/shell/Shell.css @@ -165,6 +165,64 @@ .shell-nav-indicator.shell-nav-indicator-instant { transition: none; } +/* Dynamic tabs group — inline in `.shell-nav` itself, next to the + static route links (mara, PR review: "the dynamic tab should be in + the top bar, not a new one below" — an earlier cut had this as its + own sticky row under the header; that version and the reasoning for + it are still in this file's git history if the split ever needs to + come back). `margin-left: 0.5em` + `border-left` reads as "a group + attached to the nav, not another nav item" without a second landmark + or a fixed pixel gap that'd fight `.shell-nav`'s own `gap`. */ +.shell-tabs { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5em; + margin-left: 0.5em; + padding-left: 0.75em; + border-left: 1px solid var(--border); +} +.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 `