Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
iris
83b5f0bd89 swarm-ui: the start page's rain follows the app's motion setting
The rain read only the OS reduced-motion query, once, at mount. The settings
menu's motion override (data-motion on the document) wins over the OS in
either direction everywhere else, and the shell applies it from an effect
that runs after this one. Whether the rain may move is now rainAllowed(), and
the component re-evaluates it on mount, on every document attribute change
and on the media query's change, starting or stopping the interval and
clearing the canvas.
2026-10-14 18:36:52 +02:00
iris
e32b4172e4 swarm-ui: test the rain arithmetic and the swarm name loader
The rain's column maths and fade colour move from MatrixRain into
shell/rain.ts, and the request-once swarm name loader from useSwarmName
into shell/swarm-name.ts. Both are plain functions with their input
passed in, so tests cover them; the components keep the DOM and canvas
work.
2026-10-14 18:36:52 +02:00
iris
01e58219cf swarm-ui: move the shell's route layout predicates out and test them
isFullBleedRoute and isWideBodyRoute are plain functions of a path, so
they move from Shell.tsx to shell/routes.ts, where the new tests cover
the start page, the hive directory's move to /hives, the terminal
paths and the wide-body routes.
2026-10-14 18:36:52 +02:00
iris
417125b33c swarm-ui: a start page at / and the hive directory at /hives
/ is now the swarm's name over the digital-rain backdrop the hive
dashboard's home has, and the swarm name in the header links to it. The
hive directory moves to /hives, where the nav's hives tab points. The
swarm name is read once per page load by a shared hook, the header and
the start page both use it. Closes #5252
2026-10-14 18:36:52 +02:00
16 changed files with 565 additions and 66 deletions

View file

@ -9,7 +9,8 @@ domain, covers host-level detail for one hive.
| route | what |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | the hive directory, each hive with its last reported status |
| `/` | the start page: the swarm's name over a digital-rain backdrop; the swarm name in the header links here |
| `/hives` | the hive directory, each hive with its last reported status |
| `/agents` | every agent: status, config PR, wanted state, inference settings, resource caps; create agents, link forge, matrix and GitHub accounts |
| `/agents/<name>/terminal` | one agent's live terminal |
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |

View file

@ -11,6 +11,7 @@ import { IssueReportPage } from "./pages/IssueReportPage.js";
import { ReviewRunsPage } from "./pages/review-runs/ReviewRunsPage.js";
import { ReviewTryOutPage } from "./pages/review-tryout/ReviewTryOutPage.js";
import { SchedulesPage } from "./pages/schedules/SchedulesPage.js";
import { StartPage } from "./pages/StartPage.js";
import { Panel } from "./ui/panel/Panel.js";
function NotFound() {
@ -25,7 +26,8 @@ export function App() {
return (
<Shell>
<Switch>
<Route path="/" component={HivesPage} />
<Route path="/" component={StartPage} />
<Route path="/hives" component={HivesPage} />
<Route path="/agents" component={AgentsPage} />
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
<Route

View file

@ -1,4 +1,4 @@
// <HivesPage> — the hive roster, `/`'s content. Fetches
// <HivesPage> — the hive roster, `/hives`' content. Fetches
// swarm-controller's `GET /api/hives/status`, the swarm-wide status
// aggregate: one row per roster hive, freshness derived at read time
// from the status bucket. Supersedes the earlier `GET /api/hives` +

View file

@ -0,0 +1,23 @@
/* The page fills the full-bleed body; the rain canvas fills the page and the
* name sits centred over it. */
.start-page {
position: relative;
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.start-page-name {
position: relative;
margin: 0;
padding: 0 1em;
text-align: center;
font-size: clamp(2em, 8vw, 5em);
font-weight: 600;
color: var(--purple);
text-shadow:
0 0 0.4em color-mix(in srgb, var(--purple) 55%, transparent),
0 0 1.2em var(--bg);
}

View file

@ -0,0 +1,20 @@
// <StartPage> — `/`, the swarm's landing page: the swarm's name over the
// digital-rain backdrop, and nothing else. The header's swarm name links
// here. The hive roster that used to be `/` is now `/hives`.
import { MatrixRain } from "../shell/MatrixRain.js";
import { useSwarmName } from "../shell/useSwarmName.js";
import "./StartPage.css";
// Same fallback as the header and the page title, so an unnamed swarm shows
// one label everywhere.
const DEFAULT_NAME = "hyperhive swarm";
export function StartPage() {
const name = useSwarmName() ?? DEFAULT_NAME;
return (
<div class="start-page">
<MatrixRain />
<h1 class="start-page-name">{name}</h1>
</div>
);
}

View file

@ -0,0 +1,9 @@
/* Fills the nearest positioned ancestor, dimmed so it reads as backdrop. */
.matrix-rain {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0.32;
pointer-events: none;
}

View file

@ -0,0 +1,133 @@
// <MatrixRain> — the falling-glyph "digital rain" backdrop, drawn on a canvas
// that fills its positioned parent. Purely decorative, so it is hidden from
// assistive tech and takes no pointer events. The glyphs take the live
// `--green` and the fade trail the live `--bg`, so a theme swap recolours it.
// Stepped on an interval (the steppy cadence is the look, and it costs
// little), paused while the tab is hidden, and stopped (and cleared) when
// motion is reduced: the settings menu's `data-motion` override wins over
// `prefers-reduced-motion`, and is followed live. The same effect as the hive dashboard's home
// page, which is plain JS over a fixed canvas.
import { useEffect, useRef } from "preact/hooks";
import {
CELL,
columnsFor,
fadeColorOf,
glyphFor,
rainAllowed,
startDrops,
stepDrops,
} from "./rain.js";
import "./MatrixRain.css";
const STEP_MS = 55;
// `getComputedStyle` on a custom property returns its declared value
// unresolved (`var(--x)`), so resolve a concrete rgb() by computing a real
// `color` on a probe element instead.
function resolveColor(varName: string, fallback: string): string {
const probe = document.createElement("span");
probe.style.color = `var(${varName})`;
probe.style.display = "none";
document.body.appendChild(probe);
const c = getComputedStyle(probe).color;
probe.remove();
return /^rgb/.test(c) ? c : fallback;
}
export function MatrixRain() {
const ref = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = ref.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
// Resolved again whenever the theme or motion setting can have changed:
// the shell applies its overrides (`data-theme`, `data-motion`) from an
// effect that runs after this one, and a later switch from the settings
// menu changes them too.
let glyphColor = "";
let fadeColor = "";
function resolveColors() {
glyphColor = resolveColor("--green", "rgb(166, 227, 161)");
fadeColor = fadeColorOf(resolveColor("--bg", "rgb(30, 30, 46)"));
}
resolveColors();
const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const schemeQuery = window.matchMedia("(prefers-color-scheme: dark)");
let cols = 0;
let drops: number[] = [];
function resize() {
if (!canvas || !ctx) return;
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
cols = columnsFor(canvas.width);
drops = startDrops(cols, Math.random);
ctx.font = `${CELL}px monospace`; // a canvas resize resets the font
}
function tick() {
if (!canvas || !ctx) return;
ctx.fillStyle = fadeColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = glyphColor;
for (let i = 0; i < cols; i++) {
ctx.fillText(glyphFor(Math.random), i * CELL, (drops[i] ?? 0) * CELL);
}
drops = stepDrops(drops, canvas.height, Math.random);
}
const allowed = () =>
rainAllowed(document.documentElement.dataset.motion, motionQuery.matches);
let timer: number | null = null;
const play = () => {
if (timer === null) timer = window.setInterval(tick, STEP_MS);
};
const pause = () => {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
};
// Runs the rain when motion is allowed and the tab is visible; otherwise
// stops it and clears what was drawn. Safe to call on any change.
function sync() {
if (allowed() && !document.hidden) {
play();
} else {
pause();
if (!allowed() && canvas && ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
}
const onSettingsChange = () => {
resolveColors();
sync();
};
const watch = new MutationObserver(onSettingsChange);
watch.observe(document.documentElement, { attributes: true });
schemeQuery.addEventListener("change", onSettingsChange);
motionQuery.addEventListener("change", onSettingsChange);
resize();
window.addEventListener("resize", resize);
document.addEventListener("visibilitychange", sync);
sync();
return () => {
pause();
watch.disconnect();
schemeQuery.removeEventListener("change", onSettingsChange);
motionQuery.removeEventListener("change", onSettingsChange);
window.removeEventListener("resize", resize);
document.removeEventListener("visibilitychange", sync);
};
}, []);
return <canvas ref={ref} class="matrix-rain" aria-hidden="true" />;
}

View file

@ -55,6 +55,12 @@
-webkit-backdrop-filter: blur(8px) saturate(120%);
backdrop-filter: blur(8px) saturate(120%);
}
/* The swarm name links to the start page; the link adds no look of its own,
so the brand's accent and glow (on the span inside) are all that show. */
.shell-brand-link {
color: inherit;
text-decoration: none;
}
.shell-brand {
font-weight: 600;
/* Base colour only — Shell.tsx overrides it inline to the same

View file

@ -35,7 +35,9 @@ 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 { isFullBleedRoute, isWideBodyRoute } from "./routes.js";
import { useDynamicTabs } from "./useDynamicTabs.js";
import { useSwarmName } from "./useSwarmName.js";
import "./Shell.css";
// Storage keys this app owns for `@hive/shared`'s settings mechanism —
@ -47,7 +49,7 @@ 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: "/hives", 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)" },
@ -57,36 +59,6 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ 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
@ -206,7 +178,7 @@ function DynamicTabLink({
}
export function Shell({ children }: { children: ComponentChildren }) {
const [swarmName, setSwarmName] = useState<string | null>(null);
const swarmName = useSwarmName();
const [location] = useLocation();
const navRef = useRef<HTMLElement | null>(null);
const textRefs = useRef<Record<string, HTMLSpanElement | null>>({});
@ -340,23 +312,8 @@ export function Shell({ children }: { children: ComponentChildren }) {
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 */
});
}, []);
// `swarmName` stays `null` on a failed fetch or an unconfigured name, and
// the header/title fall back to `DEFAULT_BRAND`; see `useSwarmName`.
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
@ -378,20 +335,22 @@ export function Shell({ children }: { children: ComponentChildren }) {
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>
<Link href="/" class="shell-brand-link">
<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>
</Link>
<nav class="shell-nav" ref={navRef}>
{NAV_ITEMS.map((item) => (
<NavLink

View file

@ -0,0 +1,74 @@
// The digital rain's arithmetic, apart from the canvas: the glyph cell, how a
// column starts and steps, which glyph a column draws, and the translucent
// fade colour that leaves each glyph's trail. Plain functions, with the random
// source passed in, so they can be tested.
export const GLYPHS =
"アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦";
/** The glyph cell size, in px. */
export const CELL = 16;
/** How far above the top a column may start, in cells. */
const MAX_HEAD_START = 40;
/** The chance, once a column has fallen past the bottom, that it restarts at
* the top on a given step. */
const RESTART_ABOVE = 0.975;
/** The fade trail's alpha. */
const FADE_ALPHA = 0.09;
/** Whether the rain may move. The app's own motion setting (`data-motion` on
* the document, from the settings menu) wins over the OS preference in either
* direction: `reduce` stops it, `allow` lets it run on a reduce-motion OS, and
* anything else follows the OS. */
export function rainAllowed(
dataMotion: string | undefined,
osReduces: boolean,
): boolean {
if (dataMotion === "reduce") return false;
if (dataMotion === "allow") return true;
return !osReduces;
}
/** How many columns fill a canvas this wide. */
export function columnsFor(width: number): number {
return Math.max(1, Math.ceil(width / CELL));
}
/** Each column's starting row: up to `MAX_HEAD_START` cells above the top, so
* the columns do not all start falling together. */
export function startDrops(cols: number, rand: () => number): number[] {
return Array.from({ length: cols }, () =>
Math.floor(rand() * -MAX_HEAD_START),
);
}
/** One step: every column moves down a row, and one that has fallen past the
* bottom of a canvas `height` tall goes back to the top on a small chance. */
export function stepDrops(
drops: number[],
height: number,
rand: () => number,
): number[] {
return drops.map((row) => {
const restarted = row * CELL > height && rand() > RESTART_ABOVE ? 0 : row;
return restarted + 1;
});
}
/** The glyph a column draws this step. */
export function glyphFor(rand: () => number): string {
return GLYPHS[Math.floor(rand() * GLYPHS.length)] ?? "0";
}
/** The fade colour for a computed background colour: the same channels at a low
* alpha. They are read out numerically, so the trail fades whatever spacing
* `rgb()` or `rgba()` comes back in; a colour with no channels gets the
* default background's, so the trail never fills solid. */
export function fadeColorOf(background: string): string {
const m = background.match(/(\d+)[^\d]+(\d+)[^\d]+(\d+)/);
const [r, g, b] = m ? [m[1], m[2], m[3]] : [30, 30, 46];
return `rgba(${r}, ${g}, ${b}, ${FADE_ALPHA})`;
}

View file

@ -0,0 +1,35 @@
// Which layout a route gets inside the shell: the readable-width cap, the
// wide body, or full-bleed. Kept apart from `Shell.tsx` so it is plain
// functions of a path, with no DOM or CSS behind it, and can be tested.
// 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$/;
// The start page (`/`) is full-bleed too: its rain backdrop fills the whole
// body below the header.
export function isFullBleedRoute(location: string): boolean {
return location === "/" || FULL_BLEED_ROUTE_PATTERN.test(location);
}
export function isWideBodyRoute(location: string): boolean {
return WIDE_BODY_ROUTES.has(location);
}

View file

@ -0,0 +1,28 @@
// Loading the swarm's display name from `GET /api/swarm`, once. The header and
// the start page both ask for it and it does not change within a page visit,
// so the loader remembers the first request and every caller shares it. The
// label is cosmetic, so a failed request, an error response or an unconfigured
// name all answer `null` instead of throwing: callers fall back to their own
// default, as an operator who never set a name sees.
type Fetch = (url: string) => Promise<{
ok: boolean;
json: () => Promise<unknown>;
}>;
export function createSwarmNameLoader(
fetchImpl: Fetch,
): () => Promise<string | null> {
let pending: Promise<string | null> | null = null;
return () => {
if (pending === null) {
pending = (async () => {
const r = await fetchImpl("/api/swarm");
if (!r.ok) return null;
const data = (await r.json()) as { name?: string | null };
return data.name || null;
})().catch(() => null);
}
return pending;
};
}

View file

@ -0,0 +1,21 @@
// useSwarmName — the swarm's configured display name, or `null` until it
// arrives, when none is configured, or when the request fails. One request per
// page load, shared by every caller; see `swarm-name.ts`.
import { useEffect, useState } from "preact/hooks";
import { createSwarmNameLoader } from "./swarm-name.js";
const loadSwarmName = createSwarmNameLoader((url) => fetch(url));
export function useSwarmName(): string | null {
const [name, setName] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void loadSwarmName().then((n) => {
if (!cancelled && n !== null) setName(n);
});
return () => {
cancelled = true;
};
}, []);
return name;
}

View file

@ -0,0 +1,86 @@
// The digital rain's arithmetic: columns, how they start and step, the glyph
// drawn, and the fade colour. The random source is passed in, so each case is
// exact. Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
CELL,
GLYPHS,
columnsFor,
fadeColorOf,
glyphFor,
rainAllowed,
startDrops,
stepDrops,
} from "../src/shell/rain.ts";
const always = (n) => () => n;
test("the app's reduce setting stops the rain whatever the OS says", () => {
assert.equal(rainAllowed("reduce", false), false);
assert.equal(rainAllowed("reduce", true), false);
});
test("the app's allow setting runs the rain even on a reduce-motion OS", () => {
assert.equal(rainAllowed("allow", true), true);
assert.equal(rainAllowed("allow", false), true);
});
test("with no app setting the rain follows the OS", () => {
assert.equal(rainAllowed(undefined, false), true);
assert.equal(rainAllowed(undefined, true), false);
assert.equal(rainAllowed("", true), false);
});
test("a canvas gets one column per cell, rounded up, and at least one", () => {
assert.equal(columnsFor(CELL * 10), 10);
assert.equal(columnsFor(CELL * 10 + 1), 11);
assert.equal(columnsFor(0), 1);
});
test("columns start above the top, spread over 40 cells", () => {
assert.deepEqual(
startDrops(3, always(0)).map((row) => row === 0),
[true, true, true],
);
assert.deepEqual(startDrops(2, always(0.5)), [-20, -20]);
for (const row of startDrops(200, Math.random)) {
assert.ok(row <= 0 && row >= -40, String(row));
}
});
test("a step moves every column down one row", () => {
assert.deepEqual(stepDrops([-3, 0, 5], 10_000, always(0)), [-2, 1, 6]);
});
test("a column past the bottom restarts only on a high enough roll", () => {
const past = [Math.ceil(100 / CELL) + 1];
assert.deepEqual(stepDrops(past, 100, always(0.99)), [1]);
assert.deepEqual(stepDrops(past, 100, always(0.5)), [past[0] + 1]);
});
test("a column still on the canvas never restarts", () => {
assert.deepEqual(stepDrops([2], 100, always(0.99)), [3]);
});
test("a step does not change the array it was given", () => {
const drops = [1, 2];
stepDrops(drops, 100, always(0.99));
assert.deepEqual(drops, [1, 2]);
});
test("the glyph comes from the set, first and last at the ends of the roll", () => {
assert.equal(glyphFor(always(0)), GLYPHS[0]);
assert.equal(glyphFor(always(0.999999)), GLYPHS[GLYPHS.length - 1]);
});
test("the fade colour keeps the background's channels at a low alpha", () => {
assert.equal(fadeColorOf("rgb(30, 30, 46)"), "rgba(30, 30, 46, 0.09)");
assert.equal(fadeColorOf("rgb(239,241,245)"), "rgba(239, 241, 245, 0.09)");
assert.equal(fadeColorOf("rgba(1, 2, 3, 0.5)"), "rgba(1, 2, 3, 0.09)");
});
test("a colour with no channels fades from the default background", () => {
assert.equal(fadeColorOf("transparent"), "rgba(30, 30, 46, 0.09)");
assert.equal(fadeColorOf(""), "rgba(30, 30, 46, 0.09)");
});

View file

@ -0,0 +1,42 @@
// The shell's per-route layout: which paths are full-bleed, which get the
// wide body. Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import { isFullBleedRoute, isWideBodyRoute } from "../src/shell/routes.ts";
test("the start page is full-bleed", () => {
assert.equal(isFullBleedRoute("/"), true);
});
test("the hive directory moved off / and is not full-bleed", () => {
assert.equal(isFullBleedRoute("/hives"), false);
assert.equal(isWideBodyRoute("/hives"), false);
});
test("an agent's terminal and a subagent's are full-bleed", () => {
assert.equal(isFullBleedRoute("/agents/iris/terminal"), true);
assert.equal(
isFullBleedRoute("/agents/iris/subagents/scout-1/terminal"),
true,
);
});
test("the agents page and other routes are not full-bleed", () => {
for (const path of ["/agents", "/agents/iris", "/jobs", "/issues"]) {
assert.equal(isFullBleedRoute(path), false, path);
}
});
test("a terminal path with extra segments is not full-bleed", () => {
assert.equal(isFullBleedRoute("/agents/iris/terminal/extra"), false);
assert.equal(isFullBleedRoute("/agents//terminal"), false);
});
test("the wide-body routes are exactly the table pages", () => {
for (const path of ["/issues", "/agents", "/review-runs", "/review-tryout"]) {
assert.equal(isWideBodyRoute(path), true, path);
}
for (const path of ["/", "/jobs", "/schedules", "/components"]) {
assert.equal(isWideBodyRoute(path), false, path);
}
});

View file

@ -0,0 +1,60 @@
// The swarm name loader: one request however many callers, and a name that is
// absent, empty, or failed to load all answer null. Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import { createSwarmNameLoader } from "../src/shell/swarm-name.ts";
// A fetch that answers `body` as an ok response, counting its calls.
function fetching(body, ok = true) {
const f = async (url) => {
f.calls.push(url);
return { ok, json: async () => body };
};
f.calls = [];
return f;
}
test("the loader answers the configured name", async () => {
const f = fetching({ name: "constellation" });
assert.equal(await createSwarmNameLoader(f)(), "constellation");
assert.deepEqual(f.calls, ["/api/swarm"]);
});
test("every caller shares one request", async () => {
const f = fetching({ name: "constellation" });
const load = createSwarmNameLoader(f);
const names = await Promise.all([load(), load(), load()]);
assert.deepEqual(names, ["constellation", "constellation", "constellation"]);
assert.equal(f.calls.length, 1);
});
test("a null, empty or missing name answers null", async () => {
for (const body of [{ name: null }, { name: "" }, {}]) {
assert.equal(await createSwarmNameLoader(fetching(body))(), null);
}
});
test("an error response answers null", async () => {
assert.equal(
await createSwarmNameLoader(fetching({ name: "x" }, false))(),
null,
);
});
test("a failed request answers null rather than throwing", async () => {
const load = createSwarmNameLoader(async () => {
throw new Error("offline");
});
assert.equal(await load(), null);
});
test("a failed request is remembered, not retried by later callers", async () => {
let calls = 0;
const load = createSwarmNameLoader(async () => {
calls += 1;
throw new Error("offline");
});
await load();
await load();
assert.equal(calls, 1);
});