Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: hives are a folded card on the agents page, and hide developer details also hides the components tab

The hives nav tab and route go, with no redirect; the roster is a card above
the agents panel that starts folded and is read on the first open. The
settings toggle is renamed from hide debug output to hide developer details,
as it now also drops the components tab (the page still answers at
/components); the stored preference keeps its key.
This commit is contained in:
iris 2026-10-26 16:39:39 +01:00 • committed by mara
commit 70917b7dd0
12 changed files with 110 additions and 49 deletions

View file

@ -20,21 +20,20 @@ the highlighted one. **Escape**, **Tab** or a click elsewhere shut the list with
changing anything. Typing the start of an option's name picks it, also with the list
shut. A dropdown inside a dialog opens above the dialog and the dialog doesn't cut it off.
| route | what |
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); 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, permissions; create agents (with their permissions, optionally from an [archetype](archetypes.md)), 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 |
| `/alerts` | the alerts the swarm's Grafana holds now: the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token |
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient; a folded **broadcast** card above the feed sends one message to several agents at once |
| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows |
| `/issues` | a cross-repo issue report |
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
| `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed |
| route | what |
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | the start page: the swarm's name over a digital-rain backdrop, plus a paperclip offering to set up an agent while no `ruth` exists (it opens the create form with `ruth` filled in); the swarm name in the header links here |
| `/agents` | every agent, under a folded **hives** card with the hive directory and each hive's last reported status: status, config PR, wanted state, inference settings, resource caps, permissions; create agents (with their permissions, optionally from an [archetype](archetypes.md)), 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 |
| `/alerts` | the alerts the swarm's Grafana holds now: the firing ones, with a button for the pending, no-data and error ones and a link to Grafana's own alert list; read through the controller with a Viewer service-account token |
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient; a folded **broadcast** card above the feed sends one message to several agents at once |
| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows |
| `/issues` | a cross-repo issue report |
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
| `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed |
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
Pages that refresh on a timer share one polling rule: the page sends no new
@ -95,10 +94,11 @@ stream with an error status, the browser gives up and the badge reads
**disconnected**. The **reconnect** button beside it opens the stream again, and
the stream replays the rows. The messages page works the same way.
The settings menu in the header has a **hide debug output** toggle. It drops
The settings menu in the header has a **hide developer details** toggle. It drops
the muted debug rows (thinking, coalesced ticks, harness chatter) from every
terminal, the preview and the tabs, and rows already on screen hide or show as
you flip it. The browser keeps the choice for the swarm UI, apart from the
you flip it. It also hides the **components** tab, a page for developers, which
still answers at `/components`. The browser keeps the choice for the swarm UI, apart from the
per-agent pages'.
A terminal's own tab takes the agent's icon as its browser-tab icon, so tabs of

View file

@ -51,7 +51,7 @@ that motivated the change.
| `warn` | amber, left rule | stderr, an unrecognised event shape, API retries |
| `error` | red, left rule | turn failed, a tool result with `is_error: true` |
The settings menu's "hide debug output" toggle (`getHideDebugPref()`,
The settings menu's "hide developer details" toggle (`getHideDebugPref()`,
`@hive/shared/prefs.js`) skips `debug`-level rows entirely rather than
muting them further — client-side only, no reload. The per-agent page and
the swarm UI both have the toggle, as one shared row

View file

@ -1,4 +1,4 @@
// Settings-menu row: "hide debug output". Rendered as a `<SettingsMenu>`
// Settings-menu row: "hide developer details". Rendered as a `<SettingsMenu>`
// child (`./SettingsMenu.js`) by each page that has a terminal, the
// per-agent page and the swarm UI, rather than as a prop on the menu: the
// caller owns its row. Both read the same preference (`../prefs.js`), which
@ -11,7 +11,7 @@ export function HideDebugSetting() {
const [hideDebug, setHideDebugState] = useState(() => getHideDebugPref());
return (
<label class="settings-menu-row">
<span>hide debug output</span>
<span>hide developer details</span>
<input
type="checkbox"
checked={hideDebug}

View file

@ -58,7 +58,7 @@ function DiffBody({ text }: { text: string }) {
);
}
// A `debug` row, which follows the "hide debug output" preference as it
// A `debug` row, which follows the "hide developer details" preference as it
// changes. Only debug rows subscribe, so a long terminal does not hold a
// listener per row.
function DebugRow({ row }: { row: TermRow }) {
@ -67,7 +67,7 @@ function DebugRow({ row }: { row: TermRow }) {
() => subscribeHideDebugPref(() => setHidden(getHideDebugPref())),
[],
);
// "hide debug output" is a skip, not a dimmer — a hidden debug row
// "hide developer details" is a skip, not a dimmer — a hidden debug row
// still occupies no DOM node at all, same as it never streamed, rather
// than a CSS `display: none` that would keep it in the layout/DOM for
// no benefit.

View file

@ -13,7 +13,6 @@ import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
import { NotificationsPage } from "./pages/notifications/NotificationsPage.js";
import { ComponentsPage } from "./pages/ComponentsPage.js";
import { JobsPage } from "./pages/JobsPage.js";
import { HivesPage } from "./pages/HivesPage.js";
import { IssueReportPage } from "./pages/IssueReportPage.js";
import { ReviewPage } from "./pages/review/ReviewPage.js";
import { SchedulesPage } from "./pages/schedules/SchedulesPage.js";
@ -34,7 +33,6 @@ export function App() {
<Shell>
<Switch>
<Route path="/" component={StartPage} />
<Route path="/hives" component={HivesPage} />
<Route path="/alerts" component={AlertsPage} />
<Route path="/agents" component={AgentsPage} />
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />

View file

@ -1,7 +1,6 @@
// <StartPage> — `/`, the swarm's landing page: the swarm's name over the
// digital-rain backdrop, plus the setup assistant while the swarm has no
// `ruth`. The header's swarm name links here. The hive roster that used to be
// `/` is now `/hives`.
// `ruth`. The header's swarm name links here. The hive roster is a card on the agents page.
import { MatrixRain } from "../shell/MatrixRain.js";
import { useSwarmName } from "../shell/useSwarmName.js";
import { SetupAssistant } from "./SetupAssistant.js";

View file

@ -36,3 +36,14 @@
.ui-agents-view-toggle button:not(.active):hover {
color: var(--fg);
}
/* The folded hives card over the agents and details panels. */
.ui-agents-hives {
margin-block-end: 1em;
}
/* The roster's refresh picker, at the top right of the open card. */
.ui-hives-refresh {
display: flex;
justify-content: flex-end;
margin-block-end: 0.5em;
}

View file

@ -38,6 +38,7 @@ import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { FilterableView } from "../../ui/filterable-view/FilterableView.js";
import { Panel } from "../../ui/panel/Panel.js";
import { HivesCard } from "./HivesCard.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import {
RefreshIntervalPicker,
@ -255,6 +256,9 @@ export function AgentsPage() {
return (
<>
<div class="ui-agents-hives">
<HivesCard />
</div>
<SplitView
primary={
<Panel

View file

@ -1,4 +1,5 @@
// <HivesPage> — the hive roster, `/hives`' content. Fetches
// <HivesCard> — the hive roster, a card on the agents page that starts folded.
// 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` +
@ -15,15 +16,15 @@
import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Panel } from "../ui/panel/Panel.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import { Disclosure } from "../../ui/disclosure/Disclosure.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import {
RefreshIntervalPicker,
type RefreshIntervalMs,
} from "../ui/refresh-interval/RefreshInterval.js";
import { fetchJson, usePolled } from "../ui/polled/usePolled.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
import { domainCell } from "./hive-domain.js";
} from "../../ui/refresh-interval/RefreshInterval.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { Table, type TableColumn } from "../../ui/table/Table.js";
import { domainCell } from "../hive-domain.js";
type Freshness = "fresh" | "stale" | "never_reported" | "unknown";
@ -103,14 +104,16 @@ const COLUMNS: TableColumn<HiveStatus>[] = [
},
];
// 30s default: this page has no inputs to interrupt, and the whole
// 30s default: this card has no inputs to interrupt, and the whole
// point of a refresh-interval control is "no manual reload needed" — an
// operator who wants it off still can, but the out-of-the-box behaviour
// should actually solve the staleness problem rather than require an
// opt-in every visit.
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
export function HivesPage() {
// The roster is read on the first open and followed after, so an agents page
// where the card is never opened does not poll it.
function HiveRoster() {
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
const { data: hives, error } = usePolled(intervalMs, (signal) =>
@ -118,17 +121,14 @@ export function HivesPage() {
);
return (
<Panel
title="hives"
icon="🐝"
actions={
<>
<div class="ui-hives-refresh">
<RefreshIntervalPicker
id="hives-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
}
>
</div>
{error ? (
<ApiErrorPanel
context="failed to load the hive roster"
@ -145,6 +145,25 @@ export function HivesPage() {
storageKey="swarm-ui:hives:table-filters"
/>
) : null}
</Panel>
</>
);
}
export function HivesCard() {
const [open, setOpen] = useState(false);
const [built, setBuilt] = useState(false);
return (
<Disclosure
title="hives"
icon="🐝"
hint="the swarm's hives and when each last reported"
open={open}
onToggle={(next) => {
setOpen(next);
if (next) setBuilt(true);
}}
>
{built ? <HiveRoster /> : null}
</Disclosure>
);
}

View file

@ -31,6 +31,7 @@ import type { ComponentChildren } from "preact";
import { Link, useLocation } from "wouter-preact";
import { LinksMenu } from "./LinksMenu.js";
import { UserMenu } from "./UserMenu.js";
import { useHideDebug } from "./useHideDebug.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";
@ -49,7 +50,6 @@ const THEME_KEY = "swarm-ui:theme-override";
const MOTION_KEY = "swarm-ui:motion-override";
const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
{ href: "/hives", label: "hives", accent: "var(--purple)" },
{ href: "/agents", label: "agents", accent: "var(--green)" },
{ href: "/alerts", label: "alerts", accent: "var(--red)" },
{ href: "/jobs", label: "jobs", accent: "var(--pink)" },
@ -209,6 +209,13 @@ export function Shell({ children }: { children: ComponentChildren }) {
// rather than reusing `NavLink`.
const { tabs, close: closeTab } = useDynamicTabs();
// The components tab is a developer page: it goes while "hide developer
// details" is on. The page itself still answers at `/components`.
const hideDeveloper = useHideDebug();
const navItems = hideDeveloper
? NAV_ITEMS.filter((item) => item.href !== "/components")
: NAV_ITEMS;
// 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,
@ -226,7 +233,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
// scroll-offset bookkeeping needed.
function measureIndex(index: number): IndicatorRect | null {
const navEl = navRef.current;
const item = NAV_ITEMS[index];
const item = navItems[index];
const textEl = item ? textRefs.current[item.href] : null;
if (!navEl || !item || !textEl) return null;
const navRect = navEl.getBoundingClientRect();
@ -245,8 +252,14 @@ export function Shell({ children }: { children: ComponentChildren }) {
// [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).
// The list changed (the components tab came or went), so the settled index
// no longer names the same item: land directly on the active one again.
useEffect(() => {
const targetIndex = NAV_ITEMS.findIndex((item) => item.href === location);
settledIndexRef.current = -1;
}, [hideDeveloper]);
useEffect(() => {
const targetIndex = navItems.findIndex((item) => item.href === location);
if (targetIndex === -1) {
settledIndexRef.current = -1;
setIndicator(null);
@ -278,7 +291,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
cancelled = true;
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
};
}, [location]);
}, [location, hideDeveloper]);
// Fires the render *after* the indicator's first non-null commit —
// deliberately one tick behind, not folded into the effect above,
@ -323,7 +336,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
// 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 ??
navItems.find((item) => item.href === location)?.label ??
tabs.find((tab) => tab.href === location)?.label;
useEffect(() => {
document.title = pageLabel ? `${pageLabel} · ${brand}` : brand;
@ -355,7 +368,7 @@ export function Shell({ children }: { children: ComponentChildren }) {
</span>
</Link>
<nav class="shell-nav" ref={navRef}>
{NAV_ITEMS.map((item) => (
{navItems.map((item) => (
<NavLink
key={item.href}
href={item.href}

View file

@ -0,0 +1,17 @@
// The "hide developer details" preference as a value that follows its toggle:
// this tab's, through `setHideDebugPref`, and another tab's, through the
// `storage` event. The header uses it to hide the components tab.
import { useEffect, useState } from "preact/hooks";
import {
getHideDebugPref,
subscribeHideDebugPref,
} from "@hive/shared/prefs.js";
export function useHideDebug(): boolean {
const [hide, setHide] = useState(getHideDebugPref);
useEffect(
() => subscribeHideDebugPref(() => setHide(getHideDebugPref())),
[],
);
return hide;
}

View file

@ -8,7 +8,7 @@ test("the start page is full-bleed", () => {
});
test("the hive directory moved off / and is not full-bleed", () => {
assert.equal(isFullBleedRoute("/hives"), false);
assert.equal(isFullBleedRoute("/agents"), false);
});
test("an agent's terminal and a subagent's are full-bleed", () => {