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

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