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.
478 lines
20 KiB
TypeScript
478 lines
20 KiB
TypeScript
// <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>
|
||
);
|
||
}
|