Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83b5f0bd89 | ||
|
|
e32b4172e4 | ||
|
|
01e58219cf | ||
|
|
417125b33c |
16 changed files with 565 additions and 66 deletions
|
|
@ -9,7 +9,8 @@ domain, covers host-level detail for one hive.
|
||||||
|
|
||||||
| route | what |
|
| 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` | 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>/terminal` | one agent's live terminal |
|
||||||
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { IssueReportPage } from "./pages/IssueReportPage.js";
|
||||||
import { ReviewRunsPage } from "./pages/review-runs/ReviewRunsPage.js";
|
import { ReviewRunsPage } from "./pages/review-runs/ReviewRunsPage.js";
|
||||||
import { ReviewTryOutPage } from "./pages/review-tryout/ReviewTryOutPage.js";
|
import { ReviewTryOutPage } from "./pages/review-tryout/ReviewTryOutPage.js";
|
||||||
import { SchedulesPage } from "./pages/schedules/SchedulesPage.js";
|
import { SchedulesPage } from "./pages/schedules/SchedulesPage.js";
|
||||||
|
import { StartPage } from "./pages/StartPage.js";
|
||||||
import { Panel } from "./ui/panel/Panel.js";
|
import { Panel } from "./ui/panel/Panel.js";
|
||||||
|
|
||||||
function NotFound() {
|
function NotFound() {
|
||||||
|
|
@ -25,7 +26,8 @@ export function App() {
|
||||||
return (
|
return (
|
||||||
<Shell>
|
<Shell>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/" component={HivesPage} />
|
<Route path="/" component={StartPage} />
|
||||||
|
<Route path="/hives" component={HivesPage} />
|
||||||
<Route path="/agents" component={AgentsPage} />
|
<Route path="/agents" component={AgentsPage} />
|
||||||
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
|
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
|
||||||
<Route
|
<Route
|
||||||
|
|
|
||||||
|
|
@ -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
|
// swarm-controller's `GET /api/hives/status`, the swarm-wide status
|
||||||
// aggregate: one row per roster hive, freshness derived at read time
|
// aggregate: one row per roster hive, freshness derived at read time
|
||||||
// from the status bucket. Supersedes the earlier `GET /api/hives` +
|
// from the status bucket. Supersedes the earlier `GET /api/hives` +
|
||||||
|
|
|
||||||
23
frontend/packages/swarm-ui/src/pages/StartPage.css
Normal file
23
frontend/packages/swarm-ui/src/pages/StartPage.css
Normal 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);
|
||||||
|
}
|
||||||
20
frontend/packages/swarm-ui/src/pages/StartPage.tsx
Normal file
20
frontend/packages/swarm-ui/src/pages/StartPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/packages/swarm-ui/src/shell/MatrixRain.css
Normal file
9
frontend/packages/swarm-ui/src/shell/MatrixRain.css
Normal 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;
|
||||||
|
}
|
||||||
133
frontend/packages/swarm-ui/src/shell/MatrixRain.tsx
Normal file
133
frontend/packages/swarm-ui/src/shell/MatrixRain.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,12 @@
|
||||||
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
||||||
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 {
|
.shell-brand {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
/* Base colour only — Shell.tsx overrides it inline to the same
|
/* Base colour only — Shell.tsx overrides it inline to the same
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,9 @@ import { SettingsMenu } from "@hive/shared/settings-menu.js";
|
||||||
import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js";
|
import { HideDebugSetting } from "@hive/shared/hide-debug-setting.js";
|
||||||
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
|
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
|
||||||
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
|
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
|
||||||
|
import { isFullBleedRoute, isWideBodyRoute } from "./routes.js";
|
||||||
import { useDynamicTabs } from "./useDynamicTabs.js";
|
import { useDynamicTabs } from "./useDynamicTabs.js";
|
||||||
|
import { useSwarmName } from "./useSwarmName.js";
|
||||||
import "./Shell.css";
|
import "./Shell.css";
|
||||||
|
|
||||||
// Storage keys this app owns for `@hive/shared`'s settings mechanism —
|
// 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 MOTION_KEY = "swarm-ui:motion-override";
|
||||||
|
|
||||||
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
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: "/agents", label: "agents", accent: "var(--green)" },
|
||||||
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
|
||||||
{ href: "/schedules", label: "schedules", accent: "var(--cyan)" },
|
{ 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)" },
|
{ 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
|
// Static fallback — matches `index.html`'s `<title>` default, so a page
|
||||||
// never flashes something else before the fetch below resolves, and an
|
// never flashes something else before the fetch below resolves, and an
|
||||||
// operator who never set `services.hyperhive.swarm.name` sees the exact
|
// operator who never set `services.hyperhive.swarm.name` sees the exact
|
||||||
|
|
@ -206,7 +178,7 @@ function DynamicTabLink({
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Shell({ children }: { children: ComponentChildren }) {
|
export function Shell({ children }: { children: ComponentChildren }) {
|
||||||
const [swarmName, setSwarmName] = useState<string | null>(null);
|
const swarmName = useSwarmName();
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const navRef = useRef<HTMLElement | null>(null);
|
const navRef = useRef<HTMLElement | null>(null);
|
||||||
const textRefs = useRef<Record<string, HTMLSpanElement | null>>({});
|
const textRefs = useRef<Record<string, HTMLSpanElement | null>>({});
|
||||||
|
|
@ -340,23 +312,8 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
||||||
setIndicator(measureIndex(settledIndexRef.current));
|
setIndicator(measureIndex(settledIndexRef.current));
|
||||||
}, [tabs.length]);
|
}, [tabs.length]);
|
||||||
|
|
||||||
// Fetched once here, not per-page: every route mounts inside one
|
// `swarmName` stays `null` on a failed fetch or an unconfigured name, and
|
||||||
// `<Shell>`, and the swarm's name doesn't change within a page
|
// the header/title fall back to `DEFAULT_BRAND`; see `useSwarmName`.
|
||||||
// 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;
|
const brand = swarmName ?? DEFAULT_BRAND;
|
||||||
// Per-route label so browser tabs of different pages tell apart: a
|
// Per-route label so browser tabs of different pages tell apart: a
|
||||||
// top-level route takes its nav label, a terminal route takes the
|
// top-level route takes its nav label, a terminal route takes the
|
||||||
|
|
@ -378,20 +335,22 @@ export function Shell({ children }: { children: ComponentChildren }) {
|
||||||
return (
|
return (
|
||||||
<div class={"shell" + (isFullBleedRoute(location) ? " shell-full" : "")}>
|
<div class={"shell" + (isFullBleedRoute(location) ? " shell-full" : "")}>
|
||||||
<header class="shell-header">
|
<header class="shell-header">
|
||||||
<span
|
<Link href="/" class="shell-brand-link">
|
||||||
class="shell-brand"
|
<span
|
||||||
// Same glow recipe as the active nav tab / panel titles, riding
|
class="shell-brand"
|
||||||
// `navAccent` — the same value `color` already uses here — so
|
// Same glow recipe as the active nav tab / panel titles, riding
|
||||||
// the brand's glow tracks the identical hop sequence as its own
|
// `navAccent` — the same value `color` already uses here — so
|
||||||
// text colour instead of introducing a second, disagreeing
|
// the brand's glow tracks the identical hop sequence as its own
|
||||||
// accent source.
|
// text colour instead of introducing a second, disagreeing
|
||||||
style={{
|
// accent source.
|
||||||
color: navAccent,
|
style={{
|
||||||
textShadow: `0 0 8px color-mix(in srgb, ${navAccent} 45%, transparent)`,
|
color: navAccent,
|
||||||
}}
|
textShadow: `0 0 8px color-mix(in srgb, ${navAccent} 45%, transparent)`,
|
||||||
>
|
}}
|
||||||
{brand}
|
>
|
||||||
</span>
|
{brand}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
<nav class="shell-nav" ref={navRef}>
|
<nav class="shell-nav" ref={navRef}>
|
||||||
{NAV_ITEMS.map((item) => (
|
{NAV_ITEMS.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|
|
||||||
74
frontend/packages/swarm-ui/src/shell/rain.ts
Normal file
74
frontend/packages/swarm-ui/src/shell/rain.ts
Normal 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})`;
|
||||||
|
}
|
||||||
35
frontend/packages/swarm-ui/src/shell/routes.ts
Normal file
35
frontend/packages/swarm-ui/src/shell/routes.ts
Normal 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);
|
||||||
|
}
|
||||||
28
frontend/packages/swarm-ui/src/shell/swarm-name.ts
Normal file
28
frontend/packages/swarm-ui/src/shell/swarm-name.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
21
frontend/packages/swarm-ui/src/shell/useSwarmName.ts
Normal file
21
frontend/packages/swarm-ui/src/shell/useSwarmName.ts
Normal 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;
|
||||||
|
}
|
||||||
86
frontend/packages/swarm-ui/test/rain.test.mjs
Normal file
86
frontend/packages/swarm-ui/test/rain.test.mjs
Normal 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)");
|
||||||
|
});
|
||||||
42
frontend/packages/swarm-ui/test/routes.test.mjs
Normal file
42
frontend/packages/swarm-ui/test/routes.test.mjs
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
60
frontend/packages/swarm-ui/test/swarm-name.test.mjs
Normal file
60
frontend/packages/swarm-ui/test/swarm-name.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue