Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/shell/Shell.tsx
iris 5193a101a6 swarm-ui: a review runs page lists the review classifier's recent runs
One card per run, newest first, from GET /api/review-classifier/runs: repo,
PR, head commit, trigger, when, how long, how many classifications and the
outcome as a badge. A card opens to the try-out's result table, the overall
verdict, the reason and the posted review's link. TryOutResult takes the
answer it shows rather than a whole try-out result, and its styles move to
their own file.
2026-10-12 23:21:38 +02:00

478 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// <Shell> — 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 `<SettingsMenu>`) 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 `<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
// 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>
);
}