// — page chrome every swarm-ui route mounts inside: a header // bar (swarm branding) plus a nav row linking the app's real routes. // Preact-native styling — `./Shell.css` imported right here, not // wired centrally, so the component and its styles travel together // (esbuild folds every imported `.css` reachable from `main.tsx` into // one `main.css` companion output, see build.mjs) — deliberately not // @hive/shared's chrome.css page-header pattern, which is the per-hive // MPA dashboard's visual language. This package is a clean field, not // an inheritor of that look. // // Route list lives here, not prop-drilled from App — one small SPA // has exactly one place that needs to know its own nav, and this is // it. Grows additively as real routes land (a swarm-wide agent roster // is next); no speculative entries. // // Page-switch animation, two pieces (motion-guard rules live in // Shell.css): `.shell-body` remounts on every navigation // (`key={location}`) to replay a plain fade-in, and a single shared // underline (`.shell-nav-indicator`) hops through every nav item it // passes over on its way to the new active one, not just a straight // two-point tween — a nav hives→components jump visibly touches "new // agent" and "jobs" in between, position and colour together. Accent // is a discrete cycle through the existing base16 chromatic slots // (`NAV_ITEMS`' `accent` field), not a continuous hue rotation — // everything here still derives from base16, same rule the rest of // the palette follows. `.shell-brand` rides the identical accent value // (`navAccent` below), so the header reads as one accent changing, not // the underline alone. import { useEffect, useRef, useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; import { Link, useLocation } from "wouter-preact"; import { LinksMenu } from "./LinksMenu.js"; import { UserMenu } from "./UserMenu.js"; import { SettingsMenu } from "@hive/shared/settings-menu.js"; import { HideDebugSetting } from "@hive/shared/hide-debug-setting.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 — // kept here, not derived, so the two mount points below (the // `useApply*` effects and ``) always agree on which // browser-local key they're reading/writing. Theme defaults to `'dark'`, // not `'system'` — see `@hive/shared/theme-apply.js`'s file comment. const THEME_KEY = "swarm-ui:theme-override"; const MOTION_KEY = "swarm-ui:motion-override"; const NAV_ITEMS: { href: string; label: string; accent: string }[] = [ { href: "/", label: "hives", accent: "var(--purple)" }, { href: "/agents", label: "agents", accent: "var(--green)" }, { href: "/jobs", label: "jobs", accent: "var(--pink)" }, { href: "/schedules", label: "schedules", accent: "var(--cyan)" }, { href: "/issues", label: "issues", accent: "var(--yellow)" }, { href: "/review-runs", label: "review runs", accent: "var(--amber)" }, { href: "/review-tryout", label: "review try-out", accent: "var(--red)" }, { href: "/components", label: "components", accent: "var(--blue)" }, ]; // Routes that opt out of `.shell-body`'s 60em readable-line-length cap // — mara screenshotted the issue report's 8-column table getting // clipped; that width is deliberate for the rest of the UI's cards/ // forms, but too narrow for a wide table. `.shell-body-wide` (Shell.css) is a // modifier alongside the base class, not a replacement, so every other // route's layout is untouched. `/agents` joined for the same reason — // its own table view (plus the list+detail split) hits the same cap. const WIDE_BODY_ROUTES = new Set([ "/issues", "/agents", "/review-runs", "/review-tryout", ]); // `/agents/:name/terminal` (and a subagent's, under // `/agents/:name/subagents/:subagent/`) is full-bleed: the terminal fills // the whole viewport below the header bar, edge to edge (`shell-full` / // `shell-body-full` in Shell.css). It can't join the `Set` above — it's a // parameterized path, one literal string can't match every agent name. const FULL_BLEED_ROUTE_PATTERN = /^\/agents\/[^/]+(\/subagents\/[^/]+)?\/terminal$/; function isFullBleedRoute(location: string): boolean { return FULL_BLEED_ROUTE_PATTERN.test(location); } function isWideBodyRoute(location: string): boolean { return WIDE_BODY_ROUTES.has(location); } // Static fallback — matches `index.html`'s `` 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 // same generic label the pre-fetch page already showed, not a blank. const DEFAULT_BRAND = "hyperhive swarm"; interface IndicatorRect { left: number; top: number; width: number; accent: string; } // One hop's travel time — also the interval between hops, so each leg // finishes (per `.shell-nav-indicator`'s own CSS transition duration, // kept equal to this) before the next one starts rather than the two // blending into a single averaged easing curve. const HOP_MS = 140; // `data-motion`/`prefers-reduced-motion` gate for the JS-driven hop // sequence — the CSS-only pieces (the fade, the indicator's own // transition) gate via the three-rule pattern in Shell.css, but a // multi-step `setTimeout` sequence has no CSS equivalent to hook, so it // checks the same two sources directly. `data-motion` takes precedence // over the OS preference either direction (reduce *or* allow), same // override semantics `@hive/shared/motion-apply.js` establishes. function prefersReducedMotion(): boolean { const override = document.documentElement.dataset.motion; if (override === "reduce") return true; if (override === "allow") return false; return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } function NavLink({ href, label, active, accent, textRef, }: { href: string; label: string; active: boolean; accent: string; textRef: (el: HTMLSpanElement | null) => void; }) { return ( <Link href={href} className="shell-nav-link"> {/* Touch target (min-height) lives on the <a> so the whole row is tappable. The underline lives on the shared `.shell-nav-indicator`, not here — this span is still what gets measured to position it, so it hugs just the text rather than the full-height tappable box. */} <span ref={textRef} class={"shell-nav-link-text" + (active ? " shell-nav-link-active" : "")} // Inline, not a CSS rule: the glow colour is this item's own // `accent` (NAV_ITEMS), a different value per nav item, the // same reason `.shell-nav-indicator`'s own colour is set this // way rather than in Shell.css. style={ active ? { textShadow: `0 0 8px color-mix(in srgb, ${accent} 45%, transparent)`, } : undefined } > {label} </span> </Link> ); } // 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(); const navRef = useRef<HTMLElement | null>(null); const textRefs = useRef<Record<string, HTMLSpanElement | null>>({}); const [indicator, setIndicator] = useState<IndicatorRect | null>(null); // The nav-item index the indicator is currently sitting at (or mid-hop // from) — distinct from `location`'s own index, since the two only // agree once every intermediate hop has landed. `-1` = not settled // anywhere yet (first mount) or on a route with no nav item (404). const settledIndexRef = useRef(-1); // False until the indicator's very first real position has committed — // gates `.shell-nav-indicator-instant` (see Shell.css) so that first // placement snaps instead of *transitioning in* from the unmounted // `left:0/width:0/transparent` fallback, which read as "no underline at // all" on a fresh page load (reported live by mara, reproduced here with // a zoomed pixel-level screenshot check — real, not a rendering fluke). // Every subsequent change (a real navigation) still animates normally. const [indicatorSettledOnce, setIndicatorSettledOnce] = useState(false); // Open agent-terminal tabs — rendered inline in `.shell-nav` itself // (mara, PR review: "the dynamic tab should be in the top bar, not a // new one below" — an earlier cut had these as their own row under // the header; see the git history on this file for that version and // why it was reverted). Entirely separate from the fixed `NAV_ITEMS` // list above, so they get their own small component (`DynamicTabLink`) // rather than reusing `NavLink`. 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, // and `useLocalSetting`'s same-tab subscription // (`@hive/shared/settings-storage.js`) means `SettingsMenu` changing // the stored value re-runs these effects without either component // needing a reference to the other. useApplyThemeOverride(THEME_KEY); useApplyMotionOverride(MOTION_KEY); // Measures nav item `index`'s rect relative to `.shell-nav`. // `getBoundingClientRect()` on both elements at the same tick keeps // this scroll-position-agnostic — a difference of two // viewport-relative rects is scroll-invariant, no separate // scroll-offset bookkeeping needed. function measureIndex(index: number): IndicatorRect | null { const navEl = navRef.current; const item = NAV_ITEMS[index]; const textEl = item ? textRefs.current[item.href] : null; if (!navEl || !item || !textEl) return null; const navRect = navEl.getBoundingClientRect(); const textRect = textEl.getBoundingClientRect(); return { left: textRect.left - navRect.left, top: textRect.bottom - navRect.top, width: textRect.width, accent: item.accent, }; } // Drives the indicator through every nav item between where it was // and the new active one — see the file-top comment for why (mara: // "navigating from a to c animates [a's colour] -> [b's colour] -> // [c's colour]"). Re-runs on navigation; a separate resize listener // below re-measures the already-settled position without re-hopping // (a reflow isn't a navigation). useEffect(() => { const targetIndex = NAV_ITEMS.findIndex((item) => item.href === location); if (targetIndex === -1) { settledIndexRef.current = -1; setIndicator(null); return; } const fromIndex = settledIndexRef.current; // First mount, a route with no nav item just left, or reduced // motion: land directly, no intermediate hops to sweep through. if (fromIndex === -1 || prefersReducedMotion()) { settledIndexRef.current = targetIndex; setIndicator(measureIndex(targetIndex)); return; } const step = targetIndex > fromIndex ? 1 : -1; let cancelled = false; let timeoutId: number | undefined; function hop(current: number) { const next = current + step; setIndicator(measureIndex(next)); settledIndexRef.current = next; if (next !== targetIndex) { timeoutId = window.setTimeout(() => { if (!cancelled) hop(next); }, HOP_MS); } } hop(fromIndex); return () => { cancelled = true; if (timeoutId !== undefined) window.clearTimeout(timeoutId); }; }, [location]); // Fires the render *after* the indicator's first non-null commit — // deliberately one tick behind, not folded into the effect above, // because the instant/animated class has to still read "instant" on // the very render where `indicator` itself first goes non-null (that's // the snap this exists for); only from the render after that should // transitions be armed. useEffect(() => { if (indicator && !indicatorSettledOnce) setIndicatorSettledOnce(true); }, [indicator, indicatorSettledOnce]); // Re-measures the current (already-settled) position on resize — the // nav's own `flex-wrap` means a link's position genuinely changes at // narrow widths, not just its route. Not part of the hop effect above // since a reflow shouldn't restart the sweep animation. useEffect(() => { function onResize() { if (settledIndexRef.current === -1) return; setIndicator(measureIndex(settledIndexRef.current)); } window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); // Same re-measure, for the same "a reflow, not a navigation" reason — // now that the dynamic tabs render inline in `.shell-nav` (see the // `useDynamicTabs()` comment above), *closing* a background tab (one // that isn't the active route) shrinks the row without any navigation // firing to trigger the hop effect's own re-measure. Opening one // doesn't need this: `useDynamicTabs().open()` always navigates, so // the hop effect already covers that case. useEffect(() => { if (settledIndexRef.current === -1) return; setIndicator(measureIndex(settledIndexRef.current)); }, [tabs.length]); // Fetched once here, not per-page: every route mounts inside one // `<Shell>`, and the swarm's name doesn't change within a page // visit. A fetch failure is silently ignored — `swarmName` just stays // `null` and the header/title fall back to `DEFAULT_BRAND`, same as // an operator who never configured a name; this label is cosmetic, // not worth an `ApiErrorPanel`. useEffect(() => { (async () => { const r = await fetch("/api/swarm"); if (!r.ok) return; const data = (await r.json()) as { name: string | null }; if (data.name) setSwarmName(data.name); })().catch(() => { /* cosmetic only — see comment above */ }); }, []); const brand = swarmName ?? DEFAULT_BRAND; // Per-route label so browser tabs of different pages tell apart: a // top-level route takes its nav label, a terminal route takes the // label its page registered as a dynamic tab. An unmatched path gets // the bare brand. const pageLabel = NAV_ITEMS.find((item) => item.href === location)?.label ?? tabs.find((tab) => tab.href === location)?.label; useEffect(() => { document.title = pageLabel ? `${pageLabel} · ${brand}` : brand; }, [brand, pageLabel]); // Same value the indicator itself is drawn with, including mid-sweep — // the brand text rides the identical hop sequence rather than // computing its own, so "the header's accent" reads as one thing // changing, not two things that happen to agree at rest. const navAccent = indicator?.accent ?? "var(--purple)"; return ( <div class={"shell" + (isFullBleedRoute(location) ? " shell-full" : "")}> <header class="shell-header"> <span class="shell-brand" // Same glow recipe as the active nav tab / panel titles, riding // `navAccent` — the same value `color` already uses here — so // the brand's glow tracks the identical hop sequence as its own // text colour instead of introducing a second, disagreeing // accent source. style={{ color: navAccent, textShadow: `0 0 8px color-mix(in srgb, ${navAccent} 45%, transparent)`, }} > {brand} </span> <nav class="shell-nav" ref={navRef}> {NAV_ITEMS.map((item) => ( <NavLink key={item.href} href={item.href} label={item.label} active={item.href === location} accent={item.accent} textRef={(el) => { textRefs.current[item.href] = el; }} /> ))} {/* Shared sliding underline — see the file-top comment. Hidden (zero width) rather than unmounted when there's no active nav item (404), so it doesn't pop in with a stale position the next time a real nav item becomes active. */} <span class={ "shell-nav-indicator" + (indicatorSettledOnce ? "" : " shell-nav-indicator-instant") } style={{ left: `${indicator?.left ?? 0}px`, top: `${indicator?.top ?? 0}px`, width: `${indicator?.width ?? 0}px`, backgroundColor: indicator?.accent ?? "transparent", }} /> {/* Open agent-terminal tabs — inline in the same nav row as the static routes (mara's review call, see the `useDynamicTabs()` comment above). Only rendered when there's something to show — an always-present empty group would just be dead chrome for the far more common case of no open terminal tabs. `role="group"`, not a second `<nav>`: there's already exactly one nav landmark in this header. */} {tabs.length > 0 && ( <div class="shell-tabs" role="group" 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)} /> ))} </div> )} </nav> {/* Single `margin-left: auto` on the wrapper, not on each menu individually — two adjacent flex items both set to `margin-left: auto` split the available space between them instead of sitting flush together at the right edge. */} <div class="shell-header-actions"> <SettingsMenu themeKey={THEME_KEY} motionKey={MOTION_KEY}> <HideDebugSetting /> </SettingsMenu> <LinksMenu /> <UserMenu /> </div> </header> {/* 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), not a swap — `.shell-body`'s padding/animation rules still apply, only the max-width cap changes. */} <div class={ "shell-body" + (isWideBodyRoute(location) ? " shell-body-wide" : "") + (isFullBleedRoute(location) ? " shell-body-full" : "") } key={location} > {children} </div> </div> ); }