treefmt: apply prettier

Pure `nix fmt` output from the commit before this one — no hand edits.
203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs.

Reproduce with `nix develop -c nix fmt` on the parent commit; the result
should be byte-identical to this tree.

None of the 13 `.prettierignore` entries appears here — verified by
intersecting the changed-file list against the ignore file, with a
control proving the intersection finds a match when one exists.
This commit is contained in:
atlas 2026-09-02 14:29:33 +02:00
commit 39b95c2ede
203 changed files with 10090 additions and 6085 deletions

View file

@ -21,33 +21,33 @@
// `tower_http::ServeDir` fallback; the layout above keeps every URL
// the HTML references reachable without rewriting paths.
import { build } from 'esbuild';
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from "esbuild";
import { mkdirSync, copyFileSync, rmSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const src = (p) => resolve(here, 'src', p);
const dist = (p) => resolve(here, 'dist', p);
const staticDir = (p) => resolve(here, 'dist', 'static', p);
const src = (p) => resolve(here, "src", p);
const dist = (p) => resolve(here, "dist", p);
const staticDir = (p) => resolve(here, "dist", "static", p);
rmSync(dist(''), { recursive: true, force: true });
mkdirSync(staticDir(''), { recursive: true });
rmSync(dist(""), { recursive: true, force: true });
mkdirSync(staticDir(""), { recursive: true });
// The stats page's own bundle — chart.js/auto, its own deps.
await build({
entryPoints: [src('stats.js')],
outdir: staticDir(''),
entryPoints: [src("stats.js")],
outdir: staticDir(""),
bundle: true,
format: 'esm',
platform: 'browser',
target: ['es2022'],
format: "esm",
platform: "browser",
target: ["es2022"],
sourcemap: true,
logLevel: 'info',
logLevel: "info",
// `@hive/shared/modal.js` imports its shadow-DOM component CSS as raw
// text (see dashboard/build.mjs's matching comment) — same reasoning
// applies here.
loader: { '.css': 'text' },
loader: { ".css": "text" },
});
// The main agent page (hyperhive#3685's Preact rewrite — app.js,
@ -58,17 +58,17 @@ await build({
// modal.js`) and JSX transpilation, same split swarm-ui's own build
// already makes.
await build({
entryPoints: [src('main.tsx')],
outfile: staticDir('main.js'),
entryPoints: [src("main.tsx")],
outfile: staticDir("main.js"),
bundle: true,
format: 'esm',
platform: 'browser',
target: ['es2022'],
format: "esm",
platform: "browser",
target: ["es2022"],
sourcemap: true,
logLevel: 'info',
jsx: 'automatic',
jsxImportSource: 'preact',
loader: { '.css': 'css' },
logLevel: "info",
jsx: "automatic",
jsxImportSource: "preact",
loader: { ".css": "css" },
});
// Bundle the CSS. `colors.css` re-exports the standalone base16 palette
@ -76,18 +76,18 @@ await build({
// it, no bundle rebuild); `theme.css` is the semantic derivation layer;
// `agent.css`'s @import lines pull in shared base.css + terminal.css from
// the @hive/shared workspace dep.
for (const entry of ['colors.css', 'theme.css', 'agent.css']) {
for (const entry of ["colors.css", "theme.css", "agent.css"]) {
await build({
entryPoints: [src(entry)],
outfile: staticDir(entry),
bundle: true,
loader: { '.css': 'css' },
logLevel: 'info',
loader: { ".css": "css" },
logLevel: "info",
});
}
for (const html of ['index.html', 'stats.html', 'screen.html']) {
for (const html of ["index.html", "stats.html", "screen.html"]) {
copyFileSync(src(html), dist(html));
}
console.log('agent build ok →', dist(''));
console.log("agent build ok →", dist(""));

View file

@ -4,55 +4,61 @@
// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with
// no credentials has no other web-UI path back online, so this isn't
// optional polish.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { BadgeTone } from '@hive/shared/badge.js';
import { SettingsMenu } from '@hive/shared/settings-menu.js';
import { useApplyThemeOverride } from '@hive/shared/theme-apply.js';
import { useApplyMotionOverride } from '@hive/shared/motion-apply.js';
import { Header } from './components/Header.js';
import { MetaNav } from './components/MetaNav.js';
import { ExpandDetailsSetting } from './components/ExpandDetailsSetting.js';
import { StatusChips } from './components/StatusChips.js';
import { LiveStream, type LiveStreamHandle } from './components/LiveStream.js';
import { HeaderPill } from './components/HeaderPill.js';
import { SidePanel } from './components/SidePanel.js';
import { InboxPanel } from './components/InboxPanel.js';
import { TodosPanel } from './components/TodosPanel.js';
import { TermInput } from './components/TermInput.js';
import { LoginFlow } from './components/LoginFlow.js';
import { useAgentState } from './hooks/useAgentState.js';
import { useTodos } from './hooks/useTodos.js';
import { fmtAge, fmtTokens } from './lib/format.js';
import { resolveDashboardBase } from './lib/dashboardBase.js';
import { submitPauseResume } from './lib/pauseAction.js';
import { postModel, postEffort } from './lib/modelEffort.js';
import { postCancelTurn } from './lib/termActions.js';
import type { TokenUsage } from './types.js';
import { useEffect, useRef, useState } from "preact/hooks";
import type { BadgeTone } from "@hive/shared/badge.js";
import { SettingsMenu } from "@hive/shared/settings-menu.js";
import { useApplyThemeOverride } from "@hive/shared/theme-apply.js";
import { useApplyMotionOverride } from "@hive/shared/motion-apply.js";
import { Header } from "./components/Header.js";
import { MetaNav } from "./components/MetaNav.js";
import { ExpandDetailsSetting } from "./components/ExpandDetailsSetting.js";
import { StatusChips } from "./components/StatusChips.js";
import { LiveStream, type LiveStreamHandle } from "./components/LiveStream.js";
import { HeaderPill } from "./components/HeaderPill.js";
import { SidePanel } from "./components/SidePanel.js";
import { InboxPanel } from "./components/InboxPanel.js";
import { TodosPanel } from "./components/TodosPanel.js";
import { TermInput } from "./components/TermInput.js";
import { LoginFlow } from "./components/LoginFlow.js";
import { useAgentState } from "./hooks/useAgentState.js";
import { useTodos } from "./hooks/useTodos.js";
import { fmtAge, fmtTokens } from "./lib/format.js";
import { resolveDashboardBase } from "./lib/dashboardBase.js";
import { submitPauseResume } from "./lib/pauseAction.js";
import { postModel, postEffort } from "./lib/modelEffort.js";
import { postCancelTurn } from "./lib/termActions.js";
import type { TokenUsage } from "./types.js";
type OpenPanel = 'inbox' | 'todos' | null;
type OpenPanel = "inbox" | "todos" | null;
// Storage keys this page owns for `@hive/shared`'s settings mechanism —
// kept here, not derived, so `useApplyThemeOverride`/`useApplyMotionOverride`
// (mounted once below) and `<SettingsMenu>` (in `pills`, further down)
// always agree on which browser-local key they're reading/writing.
const THEME_KEY = 'agent:theme-override';
const MOTION_KEY = 'agent:motion-override';
const THEME_KEY = "agent:theme-override";
const MOTION_KEY = "agent:motion-override";
const ALIVE_LABELS: Record<string, { glyph: string; text: string; tone: BadgeTone }> = {
online: { glyph: '●', text: 'alive', tone: 'positive' },
rate_limited: { glyph: '⊘', text: 'rate limited', tone: 'warning' },
needs_login_idle: { glyph: '◌', text: 'needs login', tone: 'warning' },
needs_login_in_progress: { glyph: '◌', text: 'logging in', tone: 'warning' },
const ALIVE_LABELS: Record<
string,
{ glyph: string; text: string; tone: BadgeTone }
> = {
online: { glyph: "●", text: "alive", tone: "positive" },
rate_limited: { glyph: "⊘", text: "rate limited", tone: "warning" },
needs_login_idle: { glyph: "◌", text: "needs login", tone: "warning" },
needs_login_in_progress: { glyph: "◌", text: "logging in", tone: "warning" },
};
const STATE_LABELS: Record<string, { glyph: string; text: string; tone: BadgeTone }> = {
idle: { glyph: '💤', text: 'idle', tone: 'neutral' },
thinking: { glyph: '🧠', text: 'thinking', tone: 'accent' },
compacting: { glyph: '📦', text: 'compacting', tone: 'warning' },
const STATE_LABELS: Record<
string,
{ glyph: string; text: string; tone: BadgeTone }
> = {
idle: { glyph: "💤", text: "idle", tone: "neutral" },
thinking: { glyph: "🧠", text: "thinking", tone: "accent" },
compacting: { glyph: "📦", text: "compacting", tone: "warning" },
};
const STATE_TOOLTIPS: Record<string, string> = {
offline: 'harness unreachable or claude not logged in',
idle: 'turn loop running, no claude invocation in flight',
thinking: 'claude is executing the current turn',
offline: "harness unreachable or claude not logged in",
idle: "turn loop running, no claude invocation in flight",
thinking: "claude is executing the current turn",
compacting: "operator-triggered /compact running on the persistent session",
};
@ -61,7 +67,11 @@ const STATE_TOOLTIPS: Record<string, string> = {
// title breakdown, not folded into the headline number).
function tokenTotal(u: TokenUsage | null): number | null {
if (!u) return null;
return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
return (
(u.input_tokens ?? 0) +
(u.cache_read_input_tokens ?? 0) +
(u.cache_creation_input_tokens ?? 0)
);
}
export function Root() {
@ -96,13 +106,15 @@ export function Root() {
state.qualified_label && state.qualified_label !== state.label
? state.qualified_label
: state.label;
document.title = state.hive_name ? `${state.label} // ${state.hive_name}` : `${tab} // hyperhive`;
document.title = state.hive_name
? `${state.label} // ${state.hive_name}`
: `${tab} // hyperhive`;
}, [state?.label, state?.qualified_label, state?.hive_name]);
const termInput = (
<footer class="agent-composer">
<TermInput
label={state?.label ?? '…'}
online={state?.status === 'online'}
label={state?.label ?? "…"}
online={state?.status === "online"}
onLocalNote={(text) => liveStreamRef.current?.pushNote(text)}
onClear={() => liveStreamRef.current?.clear()}
/>
@ -111,8 +123,20 @@ export function Root() {
const pills = (
<>
<HeaderPill kind="inbox" icon="📬" label="inbox" count={state?.inbox.length ?? 0} onClick={() => setOpenPanel('inbox')} />
<HeaderPill kind="todos" icon="📋" label="todos" count={todos.length} onClick={() => setOpenPanel('todos')} />
<HeaderPill
kind="inbox"
icon="📬"
label="inbox"
count={state?.inbox.length ?? 0}
onClick={() => setOpenPanel("inbox")}
/>
<HeaderPill
kind="todos"
icon="📋"
label="todos"
count={todos.length}
onClick={() => setOpenPanel("todos")}
/>
{/* Needs a loaded snapshot for its data (links/forge_public_url,
* dashboard_port) omitted until the first /api/state resolves,
* same as the old page's populateOverflowMenu (now deleted
@ -135,17 +159,28 @@ export function Root() {
// component — SidePanel's slide/fade CSS transitions only fire on a
// class change after mount, not on an already-open initial render, so
// mount-on-open would skip both the open AND the close animation.
const panelTitle = openPanel === 'inbox' ? `inbox · ${state?.inbox.length ?? 0}` : openPanel === 'todos' ? `todos · ${todos.length}` : '';
const panelTitle =
openPanel === "inbox"
? `inbox · ${state?.inbox.length ?? 0}`
: openPanel === "todos"
? `todos · ${todos.length}`
: "";
const panel = (
<SidePanel open={openPanel !== null} title={panelTitle} onClose={() => setOpenPanel(null)}>
{openPanel === 'inbox' ? (
<SidePanel
open={openPanel !== null}
title={panelTitle}
onClose={() => setOpenPanel(null)}
>
{openPanel === "inbox" ? (
<InboxPanel
rows={state?.inbox ?? []}
label={state?.label ?? ''}
dashboardBase={state ? resolveDashboardBase(state.dashboard_port) : ''}
label={state?.label ?? ""}
dashboardBase={
state ? resolveDashboardBase(state.dashboard_port) : ""
}
onCleared={refresh}
/>
) : openPanel === 'todos' ? (
) : openPanel === "todos" ? (
<TodosPanel todos={todos} onCleared={refreshTodos} />
) : null}
</SidePanel>
@ -179,13 +214,22 @@ export function Root() {
);
}
const alive = ALIVE_LABELS[state.status] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
const alive = ALIVE_LABELS[state.status] ?? {
glyph: "○",
text: "offline",
tone: "negative" as BadgeTone,
};
// Mirrors app.js's refreshState: any non-'online' status forces the
// turn-state badge to 'offline' regardless of the server's last-known
// turn_state (login/rate-limit takes visual priority over a stale
// idle/thinking reading from before the harness went offline).
const effectiveTurnState = state.status === 'online' ? state.turn_state : 'offline';
const turnDef = STATE_LABELS[effectiveTurnState] ?? { glyph: '○', text: 'offline', tone: 'negative' as BadgeTone };
const effectiveTurnState =
state.status === "online" ? state.turn_state : "offline";
const turnDef = STATE_LABELS[effectiveTurnState] ?? {
glyph: "○",
text: "offline",
tone: "negative" as BadgeTone,
};
const stateAge = fmtAge(now - state.turn_state_since * 1000);
// Consolidated status badge (mara: "why do we have separate alive
// badge? ... one badge that shows thinking / idle / paused").
@ -195,10 +239,20 @@ export function Root() {
// thinking regardless of its last turn_state. Online + running →
// turn-state + age, same as before.
const primaryStatus =
state.status !== 'online'
? { glyph: alive.glyph, text: alive.text, tone: alive.tone, tooltip: undefined as string | undefined }
state.status !== "online"
? {
glyph: alive.glyph,
text: alive.text,
tone: alive.tone,
tooltip: undefined as string | undefined,
}
: state.paused
? { glyph: '⏸', text: 'paused', tone: 'warning' as BadgeTone, tooltip: undefined }
? {
glyph: "⏸",
text: "paused",
tone: "warning" as BadgeTone,
tooltip: undefined,
}
: {
glyph: turnDef.glyph,
text: `${turnDef.text} · ${stateAge}`,
@ -212,7 +266,10 @@ export function Root() {
<>
<Header
label={state.label}
hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null}
hiveLabel={
[state.swarm_name, state.hive_name].filter(Boolean).join(" / ") ||
null
}
pills={pills}
>
<StatusChips
@ -234,22 +291,32 @@ export function Root() {
costLabel={cost !== null ? fmtTokens(cost) : undefined}
paused={state.paused}
onTogglePause={() => {
submitPauseResume(resolveDashboardBase(state.dashboard_port), state.label, state.paused ? 'resume' : 'pause').then(
() => refresh(),
);
submitPauseResume(
resolveDashboardBase(state.dashboard_port),
state.label,
state.paused ? "resume" : "pause",
).then(() => refresh());
}}
thinking={effectiveTurnState === 'thinking'}
thinking={effectiveTurnState === "thinking"}
onCancelTurn={() =>
postCancelTurn().then((r) => {
if (!r.ok) liveStreamRef.current?.pushNote(`✗ /cancel failed${r.detail ? ': ' + r.detail : ''}`);
if (!r.ok)
liveStreamRef.current?.pushNote(
`✗ /cancel failed${r.detail ? ": " + r.detail : ""}`,
);
refresh();
})
}
/>
</Header>
<main className="agent-main">
{state.status === 'needs_login_idle' || state.status === 'needs_login_in_progress' ? (
<LoginFlow status={state.status} session={state.session} onRefresh={refresh} />
{state.status === "needs_login_idle" ||
state.status === "needs_login_in_progress" ? (
<LoginFlow
status={state.status}
session={state.session}
onRefresh={refresh}
/>
) : null}
<LiveStream ref={liveStreamRef} />
</main>

View file

@ -30,7 +30,11 @@
--agent-frost-blur: blur(12px) saturate(140%);
}
html, body { height: 100%; margin: 0; }
html,
body {
height: 100%;
margin: 0;
}
/* Legacy in-page layout retained for the sibling stats page
(`stats.html`) which doesn't apply `body.agent-shell` and stays
@ -58,7 +62,9 @@ body:not(.agent-shell):not(.screen-shell) {
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--purple) 45%, transparent));
filter: drop-shadow(
0 0 6px color-mix(in srgb, var(--purple) 45%, transparent)
);
}
body.agent-shell {
@ -69,9 +75,11 @@ body.agent-shell {
/* Subtle radial accent to give the otherwise-flat full-screen
surface some depth and reinforce the vibec0re mood. */
background:
radial-gradient(ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
transparent 60%),
radial-gradient(
ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
transparent 60%
),
var(--bg);
}
@ -158,7 +166,8 @@ body.agent-shell {
align-self: center;
}
h2, h3 {
h2,
h3 {
color: var(--purple);
text-transform: uppercase;
letter-spacing: 0.15em;
@ -190,7 +199,10 @@ h2, h3 {
padding: 0.1em 0.35em;
border-radius: 3px;
text-shadow: 0 0 4px color-mix(in srgb, var(--cyan) 40%, transparent);
transition: color 0.15s ease, text-shadow 0.15s ease, background 0.15s ease;
transition:
color 0.15s ease,
text-shadow 0.15s ease,
background 0.15s ease;
}
.agent-nav-link:hover {
color: var(--fg);
@ -230,7 +242,9 @@ h2, h3 {
width: calc(100% - 3em);
z-index: 10;
}
.agent-status-overlay:empty { display: none; }
.agent-status-overlay:empty {
display: none;
}
.agent-status-overlay > * {
background: var(--bg-elev);
border: 1px solid var(--purple-dim);
@ -263,15 +277,31 @@ h2, h3 {
border-top: 0;
padding-top: 0;
}
.meta { color: var(--muted); font-size: 0.85em; }
.status-online { color: var(--green); text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent); }
.status-needs-login { color: var(--amber); text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent); }
code { background: color-mix(in srgb, var(--purple) 12%, transparent); padding: 0.05em 0.3em; border-radius: 2px; }
.meta {
color: var(--muted);
font-size: 0.85em;
}
.status-online {
color: var(--green);
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent);
}
.status-needs-login {
color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent);
}
code {
background: color-mix(in srgb, var(--purple) 12%, transparent);
padding: 0.05em 0.3em;
border-radius: 2px;
}
a {
color: var(--cyan);
text-shadow: 0 0 4px color-mix(in srgb, var(--cyan) 50%, transparent);
}
a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan) 90%, transparent); }
a:hover {
color: var(--fg);
text-shadow: 0 0 12px color-mix(in srgb, var(--cyan) 90%, transparent);
}
.btn {
font-family: inherit;
font-size: 1em;
@ -284,15 +314,25 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
}
.btn {
text-shadow: 0 0 4px currentColor;
transition: box-shadow 0.15s ease, text-shadow 0.15s ease;
transition:
box-shadow 0.15s ease,
text-shadow 0.15s ease;
}
.btn:hover {
background: color-mix(in srgb, var(--fg) 6%, transparent);
text-shadow: 0 0 10px currentColor;
box-shadow: 0 0 10px -2px currentColor;
}
.btn-login { color: var(--amber); border-color: var(--amber); }
.btn-cancel { color: var(--red); border-color: var(--red); font-size: 0.85em; padding: 0.15em 0.6em; }
.btn-login {
color: var(--amber);
border-color: var(--amber);
}
.btn-cancel {
color: var(--red);
border-color: var(--red);
font-size: 0.85em;
padding: 0.15em 0.6em;
}
/* Orphaned rules left here as a tombstone so a search for the
class name finds them. Live consumers gone:
- `.btn-rebuild` was a per-agent header chip, then briefly an
@ -301,26 +341,41 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
outright now, no replacement.
- `.btn-send` was a green send-button variant the dashboard's
compose form that used it is retired. */
.sendform { display: flex; gap: 0.6em; margin-top: 0.5em; }
.sendform {
display: flex;
gap: 0.6em;
margin-top: 0.5em;
}
.sendform input {
font-family: inherit; font-size: 1em;
font-family: inherit;
font-size: 1em;
background: rgba(255, 255, 255, 0.04);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
flex: 1;
}
.sendform input:focus { outline: 1px solid var(--purple); }
.loginform { display: flex; gap: 0.6em; margin-top: 0.5em; align-items: stretch; }
.sendform input:focus {
outline: 1px solid var(--purple);
}
.loginform {
display: flex;
gap: 0.6em;
margin-top: 0.5em;
align-items: stretch;
}
.loginform input {
font-family: inherit; font-size: 1em;
font-family: inherit;
font-size: 1em;
background: rgba(255, 255, 255, 0.04);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
flex: 1;
}
.loginform input:focus { outline: 1px solid var(--purple); }
.loginform input:focus {
outline: 1px solid var(--purple);
}
/* Show / hide toggle for the masked OAuth-code input. Quiet by
default (muted border + transparent bg), lights amber on hover
@ -338,7 +393,10 @@ a:hover { color: var(--fg); text-shadow: 0 0 12px color-mix(in srgb, var(--cyan)
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
transition:
color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.loginform-reveal:hover,
.loginform-reveal:focus-visible {
@ -373,8 +431,12 @@ pre.diff {
letter-spacing: 0.05em;
list-style: none;
}
.agent-inbox > summary::marker { content: ''; }
.agent-inbox[open] > summary > span::before { content: ''; }
.agent-inbox > summary::marker {
content: "";
}
.agent-inbox[open] > summary > span::before {
content: "";
}
.agent-inbox ul {
list-style: none;
padding: 0.4em 0.8em;
@ -392,9 +454,18 @@ pre.diff {
padding: 0.4em 0;
display: block;
}
.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; margin-left: 0.5em; }
.agent-inbox .inbox-from { color: var(--amber); }
.agent-inbox .inbox-sep { color: var(--muted); margin-left: 0.4em; }
.agent-inbox .inbox-ts {
color: var(--muted);
font-size: 0.9em;
margin-left: 0.5em;
}
.agent-inbox .inbox-from {
color: var(--amber);
}
.agent-inbox .inbox-sep {
color: var(--muted);
margin-left: 0.4em;
}
/* Todos flyout per-row checkbox (bulk mark-done) sits inline before the
existing `.inbox-from` label, same row. */
.agent-inbox .todo-cb {
@ -411,9 +482,16 @@ pre.diff {
border-left: 2px solid var(--purple-dim);
}
/* Running bash-tasks flyout rows (buildBashTasksList). */
.agent-inbox .bash-task-status { font-weight: bold; font-size: 0.9em; }
.agent-inbox .bash-task-running { color: var(--green); }
.agent-inbox .bash-task-pending { color: var(--blue); }
.agent-inbox .bash-task-status {
font-weight: bold;
font-size: 0.9em;
}
.agent-inbox .bash-task-running {
color: var(--green);
}
.agent-inbox .bash-task-pending {
color: var(--blue);
}
.agent-inbox .bash-task-cmd {
font-family: monospace;
font-size: 0.92em;
@ -423,7 +501,10 @@ pre.diff {
border-left: 2px solid var(--border);
margin-left: 0.4em;
}
.agent-inbox .inbox-reply-tag { color: var(--muted); font-size: 0.85em; }
.agent-inbox .inbox-reply-tag {
color: var(--muted);
font-size: 0.85em;
}
/* Bulk-action header row: "mark all read" above the recent-messages
list in the inbox flyout, and "select all / select none / mark
@ -473,8 +554,12 @@ pre.diff {
.effort-chip {
border-color: var(--purple-dim);
}
.model-chip { color: var(--cyan); }
.effort-chip { color: var(--amber); }
.model-chip {
color: var(--cyan);
}
.effort-chip {
color: var(--amber);
}
/* Context-window badge. Mirrors Claude Code's bottom-right "N tokens"
chip single primary number (total prompt tokens in use), full
breakdown on hover. Sized/coloured like a peer of model-chip so
@ -489,13 +574,28 @@ pre.diff {
/* Harness reachability badge. Markup carries `hive-pill` alongside
`status-badge` (shared pill.css supplies the shape); colour
communicates the actual reachability state. */
.status-badge.status-loading { color: var(--muted); border-color: var(--purple-dim); }
.status-badge.status-online { color: var(--green); border-color: var(--green);
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent); }
.status-badge.status-rate-limited { color: var(--red); border-color: var(--red);
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 55%, transparent); }
.status-badge.status-needs-login { color: var(--amber); border-color: var(--amber); }
.status-badge.status-offline { color: var(--muted); border-color: var(--muted); }
.status-badge.status-loading {
color: var(--muted);
border-color: var(--purple-dim);
}
.status-badge.status-online {
color: var(--green);
border-color: var(--green);
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 55%, transparent);
}
.status-badge.status-rate-limited {
color: var(--red);
border-color: var(--red);
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 55%, transparent);
}
.status-badge.status-needs-login {
color: var(--amber);
border-color: var(--amber);
}
.status-badge.status-offline {
color: var(--muted);
border-color: var(--muted);
}
/* Orphaned tombstone `.btn-dashlink` chip that lived beside the
title moved into the overflow menu, then into `MetaNav`'s `🔗`
popover (the ` dashboard` item) once the overflow menu itself was
@ -512,26 +612,34 @@ pre.diff {
supplies the shape); this rule is only the transition + state-
specific colour below. */
.state-badge {
transition: color 280ms ease, border-color 280ms ease,
box-shadow 280ms ease, background 280ms ease;
transition:
color 280ms ease,
border-color 280ms ease,
box-shadow 280ms ease,
background 280ms ease;
}
.state-badge.state-loading {
color: var(--muted); border-color: var(--purple-dim);
color: var(--muted);
border-color: var(--purple-dim);
}
.state-badge.state-offline {
color: var(--muted); border-color: var(--muted);
color: var(--muted);
border-color: var(--muted);
}
.state-badge.state-idle {
color: var(--cyan); border-color: var(--cyan);
color: var(--cyan);
border-color: var(--cyan);
text-shadow: 0 0 6px color-mix(in srgb, var(--cyan) 55%, transparent);
}
.state-badge.state-thinking {
color: var(--amber); border-color: var(--amber);
color: var(--amber);
border-color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 65%, transparent);
animation: badge-pulse 1.8s ease-in-out infinite;
}
.state-badge.state-compacting {
color: var(--purple); border-color: var(--purple);
color: var(--purple);
border-color: var(--purple);
text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 65%, transparent);
animation: badge-pulse 1.8s ease-in-out infinite;
}
@ -539,9 +647,21 @@ pre.diff {
animation: state-flash 600ms ease-out;
}
@keyframes state-flash {
0% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
60% { box-shadow: 0 0 18px -4px currentColor, 0 0 4px 0 currentColor; }
100% { box-shadow: 0 0 0 0 currentColor, 0 0 0 0 currentColor; }
0% {
box-shadow:
0 0 0 0 currentColor,
0 0 0 0 currentColor;
}
60% {
box-shadow:
0 0 18px -4px currentColor,
0 0 4px 0 currentColor;
}
100% {
box-shadow:
0 0 0 0 currentColor,
0 0 0 0 currentColor;
}
}
/* Full-screen overrides for the shared terminal rules. The base
`.terminal-wrap` (in shared/src/terminal.css) ships a crust-on-
@ -571,7 +691,9 @@ pre.diff {
focus restore) clear of the floats too. */
padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
padding-bottom: calc(var(--agent-composer-h) + 0.8em);
scroll-padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
scroll-padding-top: calc(
var(--agent-header-real-h, var(--agent-header-h)) + 0.8em
);
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em);
overflow: auto;
}
@ -635,10 +757,21 @@ pre.diff {
line-height: 1.4;
min-height: 1.4em;
}
.term-input textarea::placeholder { color: var(--muted); }
.term-input .submit-hint { color: var(--muted); font-size: 0.8em; flex: 0 0 auto; }
.term-input.disabled .prompt { color: var(--muted); text-shadow: none; }
.term-input.disabled textarea { color: var(--muted); }
.term-input textarea::placeholder {
color: var(--muted);
}
.term-input .submit-hint {
color: var(--muted);
font-size: 0.8em;
flex: 0 0 auto;
}
.term-input.disabled .prompt {
color: var(--muted);
text-shadow: none;
}
.term-input.disabled textarea {
color: var(--muted);
}
/* side panel (singleton drawer)
Inbox + loose-ends details open here instead of expanding inline. The
@ -658,7 +791,9 @@ pre.diff {
body.side-panel-resizing {
user-select: none;
}
body.side-panel-resizing * { cursor: ew-resize !important; }
body.side-panel-resizing * {
cursor: ew-resize !important;
}
/* Inbox / loose-ends lists rendered into the side-panel body: strip
the inbox-only chrome (background, border-left) here and let the
panel body's own padding own the framing. */
@ -774,9 +909,16 @@ hive-side-panel .agent-inbox ul {
font-size: 0.95rem;
font-weight: normal;
}
.stats-card .chart-wrap { position: relative; height: 220px; }
.stats-card.wide { grid-column: 1 / -1; }
.stats-card.wide .chart-wrap { height: 260px; }
.stats-card .chart-wrap {
position: relative;
height: 220px;
}
.stats-card.wide {
grid-column: 1 / -1;
}
.stats-card.wide .chart-wrap {
height: 260px;
}
/* ─── /screen page ─── */
/* Full-screen VNC canvas viewer (screen.html). All rules scoped to
@ -802,8 +944,14 @@ body.screen-shell {
border-radius: 4px;
cursor: pointer;
}
.tbtn.active { color: var(--green); border-color: var(--green); }
.tbtn:disabled { opacity: 0.4; cursor: default; }
.tbtn.active {
color: var(--green);
border-color: var(--green);
}
.tbtn:disabled {
opacity: 0.4;
cursor: default;
}
/* Connection status chip at the right end of the screen page header. */
#status {
@ -811,13 +959,19 @@ body.screen-shell {
font-size: 0.75rem;
color: var(--subtext0);
}
#status.connected { color: var(--green); }
#status.error { color: var(--red); }
#status.connected {
color: var(--green);
}
#status.error {
color: var(--red);
}
/* RFB debug log — fixed panel at the bottom, hidden by default. */
#debug-log {
position: fixed;
bottom: 0; left: 0; right: 0;
bottom: 0;
left: 0;
right: 0;
max-height: 40vh;
overflow-y: auto;
background: color-mix(in srgb, var(--crust) 95%, transparent);
@ -828,10 +982,20 @@ body.screen-shell {
z-index: 100;
display: none; /* toggled by the debug button */
}
#debug-log .dbg-line { color: var(--subtext0); margin: 1px 0; white-space: pre; }
#debug-log .dbg-line.err { color: var(--red); }
#debug-log .dbg-line.ok { color: var(--green); }
#debug-log .dbg-line.send { color: var(--blue); }
#debug-log .dbg-line {
color: var(--subtext0);
margin: 1px 0;
white-space: pre;
}
#debug-log .dbg-line.err {
color: var(--red);
}
#debug-log .dbg-line.ok {
color: var(--green);
}
#debug-log .dbg-line.send {
color: var(--blue);
}
/* Canvas container — centres the VNC framebuffer. */
#canvas-wrap {
@ -844,11 +1008,21 @@ body.screen-shell {
background: var(--crust);
}
/* Fit mode: canvas scaled in JS to fill the container. */
#canvas-wrap.fit { align-items: center; overflow: hidden; }
#canvas-wrap canvas { display: block; cursor: default; }
#canvas-wrap.fit {
align-items: center;
overflow: hidden;
}
#canvas-wrap canvas {
display: block;
cursor: default;
}
/* Pin the canvas to the JS-calculated size; prevents flex-item
min-width:auto from silently expanding beyond the scaled size. */
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
#canvas-wrap.fit canvas {
flex: none;
min-width: 0;
min-height: 0;
}
/* Transient flash message (copy-text, disconnect, etc.). */
#msg {

View file

@ -8,8 +8,11 @@
// will keep accumulating cruft in the shared component." This file owns
// its own state/storage entirely; `SettingsMenu` just renders it as a
// child, no different from any other consumer's markup.
import { useState } from 'preact/hooks';
import { getExpandDetailsPref, setExpandDetailsPref } from '@hive/shared/prefs.js';
import { useState } from "preact/hooks";
import {
getExpandDetailsPref,
setExpandDetailsPref,
} from "@hive/shared/prefs.js";
export function ExpandDetailsSetting() {
// Plain localStorage read, not a hook-managed override like
@ -17,7 +20,9 @@ export function ExpandDetailsSetting() {
// (`@hive/shared/prefs.js`) are the existing shared-storage-key pair
// the terminal itself already reads live (no round-trip needed here
// beyond re-rendering this row's own checkbox state on toggle).
const [expandDetails, setExpandDetailsState] = useState(() => getExpandDetailsPref());
const [expandDetails, setExpandDetailsState] = useState(() =>
getExpandDetailsPref(),
);
return (
<label class="settings-menu-row">
<span>expand tool output</span>

View file

@ -22,8 +22,8 @@
// tick, a real ResizeObserver feedback loop (confirmed live on the
// first pass of this fix). `--agent-header-h` stays the static floor;
// `--agent-header-real-h` is output-only.
import { useEffect, useRef } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { useEffect, useRef } from "preact/hooks";
import type { ComponentChildren } from "preact";
// Icon 404s when the agent has no `hyperhive.icon` override (no
// bundled server-side default any more — see
@ -34,8 +34,8 @@ import type { ComponentChildren } from 'preact';
function handleIconError(e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.dataset.fallback) return;
img.dataset.fallback = '1';
img.src = '/favicon.svg';
img.dataset.fallback = "1";
img.src = "/favicon.svg";
}
export interface HeaderProps {
@ -62,13 +62,16 @@ export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === 'undefined') return;
if (!el || typeof ResizeObserver === "undefined") return;
// getBoundingClientRect() (not ResizeObserver's own contentRect) —
// we want the full visual box padding+border included, the thing
// content below the fixed header actually needs to clear, not the
// content-box-only measurement ResizeObserver's entry defaults to.
const ro = new ResizeObserver(() => {
document.documentElement.style.setProperty('--agent-header-real-h', `${el.getBoundingClientRect().height}px`);
document.documentElement.style.setProperty(
"--agent-header-real-h",
`${el.getBoundingClientRect().height}px`,
);
});
ro.observe(el);
return () => ro.disconnect();
@ -81,7 +84,9 @@ export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
<div class="agent-header-row agent-header-title-row">
<h2 class="agent-header-title"> {label} </h2>
</div>
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null}
{hiveLabel ? (
<div class="agent-header-row agent-hive-row">{hiveLabel}</div>
) : null}
</div>
{children || pills ? (
<div class="agent-header-pills">

View file

@ -8,22 +8,28 @@
// count === 0` in the old page) and picking the inbox/todos tone so
// the count reads amber/green like it always has (`Badge`'s `tone`
// colours the value, which here is the count).
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
export interface HeaderPillProps {
kind: 'inbox' | 'todos';
kind: "inbox" | "todos";
icon: string;
label: string;
count: number;
onClick: () => void;
}
const TONE: Record<HeaderPillProps['kind'], BadgeTone> = {
inbox: 'warning',
todos: 'positive',
const TONE: Record<HeaderPillProps["kind"], BadgeTone> = {
inbox: "warning",
todos: "positive",
};
export function HeaderPill({ kind, icon, label, count, onClick }: HeaderPillProps) {
export function HeaderPill({
kind,
icon,
label,
count,
onClick,
}: HeaderPillProps) {
if (count === 0) return null;
return (
<Badge

View file

@ -7,9 +7,9 @@
// JSON, not a redirect; in the common gateway-fronted deployment
// `dashboardBase` already resolves to `location.origin` anyway (see
// dashboardBase.ts), so it's same-origin in practice.
import { useState } from 'preact/hooks';
import { useConfirmClick } from '../lib/useConfirmClick.js';
import type { InboxRow } from '../types.js';
import { useState } from "preact/hooks";
import { useConfirmClick } from "../lib/useConfirmClick.js";
import type { InboxRow } from "../types.js";
export interface InboxPanelProps {
rows: InboxRow[];
@ -19,24 +19,35 @@ export interface InboxPanelProps {
}
function fmtTs(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(5, 19);
return new Date(unixSeconds * 1000)
.toISOString()
.replace("T", " ")
.slice(5, 19);
}
export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanelProps) {
const [status, setStatus] = useState('');
export function InboxPanel({
rows,
label,
dashboardBase,
onCleared,
}: InboxPanelProps) {
const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false);
async function markAllRead() {
if (!dashboardBase || !label) {
setStatus('dashboard url / label unknown');
setStatus("dashboard url / label unknown");
return;
}
setBusy(true);
setStatus('clearing…');
setStatus("clearing…");
try {
const resp = await fetch(`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`, {
method: 'POST',
});
const resp = await fetch(
`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`,
{
method: "POST",
},
);
if (resp.ok) {
const data = await resp.json().catch(() => ({}));
const n = Number((data as { marked?: number }).marked) || 0;
@ -68,16 +79,23 @@ export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanel
disabled={busy}
title="mark every queued message for this agent as read — drains the host broker's pending + delivered-unacked rows. history shown here is the most-recent-N regardless of state, so the list itself stays visible."
>
{armed ? 'sure? click again' : '✓ mark all read'}
{armed ? "sure? click again" : "✓ mark all read"}
</button>
<span class="inbox-mark-status">{status}</span>
</div>
<ul>
{rows.map((m) => (
<li key={m.id} class={m.in_reply_to != null ? 'inbox-reply' : undefined}>
{m.in_reply_to != null && <span class="inbox-reply-tag"> reply · </span>}
<span class="inbox-ts">{fmtTs(m.at)}</span> <span class="inbox-from">{m.from}</span>{' '}
<span class="inbox-sep"></span> <span class="inbox-body">{m.body}</span>
<li
key={m.id}
class={m.in_reply_to != null ? "inbox-reply" : undefined}
>
{m.in_reply_to != null && (
<span class="inbox-reply-tag"> reply · </span>
)}
<span class="inbox-ts">{fmtTs(m.at)}</span>{" "}
<span class="inbox-from">{m.from}</span>{" "}
<span class="inbox-sep"></span>{" "}
<span class="inbox-body">{m.body}</span>
</li>
))}
</ul>

View file

@ -20,10 +20,15 @@
// unseen for an append. The old code inferred this from DOM
// mutation shape after the fact; here the caller already knows
// which one it did, so there's nothing to infer.
import { useLayoutEffect, useRef, useState, useImperativeHandle } from 'preact/hooks';
import { forwardRef } from 'preact/compat';
import { useLiveStream } from '../hooks/useLiveStream.js';
import { Row } from './Row.js';
import {
useLayoutEffect,
useRef,
useState,
useImperativeHandle,
} from "preact/hooks";
import { forwardRef } from "preact/compat";
import { useLiveStream } from "../hooks/useLiveStream.js";
import { Row } from "./Row.js";
const NEAR_BOTTOM_PX = 48;
const LOAD_MORE_SCROLL_PX = 80;
@ -37,82 +42,99 @@ export interface LiveStreamHandle {
clear: () => void;
}
export const LiveStream = forwardRef<LiveStreamHandle, object>(function LiveStream(_props, ref) {
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream();
useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]);
const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const [unseen, setUnseen] = useState(0);
const prevRowCount = useRef(0);
const prependingRef = useRef(false);
const preScrollHeightRef = useRef(0);
export const LiveStream = forwardRef<LiveStreamHandle, object>(
function LiveStream(_props, ref) {
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } =
useLiveStream();
useImperativeHandle(
ref,
() => ({ pushNote: pushLocalNote, clear: clearLocal }),
[pushLocalNote, clearLocal],
);
const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const [unseen, setUnseen] = useState(0);
const prevRowCount = useRef(0);
const prependingRef = useRef(false);
const preScrollHeightRef = useRef(0);
function isNearBottom(el: HTMLDivElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
}
function handleScroll() {
const el = logRef.current;
if (!el) return;
const nearBottom = isNearBottom(el);
setStickToBottom(nearBottom);
if (nearBottom) setUnseen(0);
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore) startLoadMore();
}
function startLoadMore() {
const el = logRef.current;
if (!el || loadingMore || !hasMore) return;
prependingRef.current = true;
preScrollHeightRef.current = el.scrollHeight;
loadMore();
}
function jumpToBottom() {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
setStickToBottom(true);
setUnseen(0);
}
// Runs after every commit that changed `rows` — i.e. after the browser
// has already laid out the new/coalesced content, so `scrollHeight`
// below is always current.
useLayoutEffect(() => {
const el = logRef.current;
if (!el) return;
const added = rows.length - prevRowCount.current;
prevRowCount.current = rows.length;
if (prependingRef.current) {
prependingRef.current = false;
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
return;
function isNearBottom(el: HTMLDivElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
}
if (stickToBottom) {
el.scrollTop = el.scrollHeight - el.clientHeight;
} else if (added > 0) {
setUnseen((n) => n + added);
}
}, [rows, stickToBottom]);
return (
<div className="terminal-wrap">
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
{hasMore && (
<button type="button" className="load-more-pill" disabled={loadingMore} onClick={startLoadMore}>
{loadingMore ? '↑ loading…' : '↑ load older'}
function handleScroll() {
const el = logRef.current;
if (!el) return;
const nearBottom = isNearBottom(el);
setStickToBottom(nearBottom);
if (nearBottom) setUnseen(0);
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore)
startLoadMore();
}
function startLoadMore() {
const el = logRef.current;
if (!el || loadingMore || !hasMore) return;
prependingRef.current = true;
preScrollHeightRef.current = el.scrollHeight;
loadMore();
}
function jumpToBottom() {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
setStickToBottom(true);
setUnseen(0);
}
// Runs after every commit that changed `rows` — i.e. after the browser
// has already laid out the new/coalesced content, so `scrollHeight`
// below is always current.
useLayoutEffect(() => {
const el = logRef.current;
if (!el) return;
const added = rows.length - prevRowCount.current;
prevRowCount.current = rows.length;
if (prependingRef.current) {
prependingRef.current = false;
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
return;
}
if (stickToBottom) {
el.scrollTop = el.scrollHeight - el.clientHeight;
} else if (added > 0) {
setUnseen((n) => n + added);
}
}, [rows, stickToBottom]);
return (
<div className="terminal-wrap">
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
{hasMore && (
<button
type="button"
className="load-more-pill"
disabled={loadingMore}
onClick={startLoadMore}
>
{loadingMore ? "↑ loading…" : "↑ load older"}
</button>
)}
{rows.map((row) => (
<Row key={row.key} row={row} />
))}
</div>
{unseen > 0 && (
<button
type="button"
className="tail-pill visible"
onClick={jumpToBottom}
>
{unseen} new
</button>
)}
{rows.map((row) => (
<Row key={row.key} row={row} />
))}
</div>
{unseen > 0 && (
<button type="button" className="tail-pill visible" onClick={jumpToBottom}>
{unseen} new
</button>
)}
</div>
);
});
);
},
);

View file

@ -16,10 +16,14 @@
// dropdown/Dropdown.css`), not `Dropdown` itself (its `options.map`
// render is menu-item-shaped, not a generic content container — not a
// fit for a login form + output pane).
import { useState } from 'preact/hooks';
import type { SessionView } from '../types.js';
import { postLoginStart, postLoginCode, postLoginCancel } from '../lib/loginAction.js';
import './LoginFlow.css';
import { useState } from "preact/hooks";
import type { SessionView } from "../types.js";
import {
postLoginStart,
postLoginCode,
postLoginCancel,
} from "../lib/loginAction.js";
import "./LoginFlow.css";
function LoginIdle({ onStarted }: { onStarted: () => void }) {
const [busy, setBusy] = useState(false);
@ -32,7 +36,7 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
postLoginStart().then((r) => {
setBusy(false);
if (r.ok) onStarted();
else setError(r.detail ?? 'failed');
else setError(r.detail ?? "failed");
});
}
@ -40,8 +44,8 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
<div class="login-card">
<p class="status-needs-login"> NEEDS L0G1N</p>
<p>
No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is
paused until you log in.
No Claude session in <code>~/.claude/</code>. The harness is up but the
turn loop is paused until you log in.
</p>
<form onSubmit={submit}>
<button type="submit" class="btn btn-login" disabled={busy}>
@ -50,15 +54,22 @@ function LoginIdle({ onStarted }: { onStarted: () => void }) {
</form>
{error ? <p class="meta">error: {error}</p> : null}
<p class="meta">
Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear
here when claude emits it; paste the resulting code back into the form below.
Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth
URL will appear here when claude emits it; paste the resulting code back
into the form below.
</p>
</div>
);
}
function LoginInProgress({ session, onDone }: { session: SessionView | null; onDone: () => void }) {
const [code, setCode] = useState('');
function LoginInProgress({
session,
onDone,
}: {
session: SessionView | null;
onDone: () => void;
}) {
const [code, setCode] = useState("");
const [reveal, setReveal] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -75,7 +86,7 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
// nothing local to update here either way.
postLoginCode(code).then((r) => {
setBusy(false);
if (!r.ok) setError(r.detail ?? 'failed');
if (!r.ok) setError(r.detail ?? "failed");
});
}
@ -94,23 +105,26 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
{session?.url ? (
<>
<p class="login-url">
{' '}
{" "}
<a href={session.url} target="_blank" rel="noreferrer">
{session.url}
</a>
</p>
<p class="meta">
open this URL in a browser, complete the OAuth flow, paste the resulting code below.
open this URL in a browser, complete the OAuth flow, paste the
resulting code below.
</p>
</>
) : (
<p class="meta">waiting for claude to emit an OAuth URL on stdout (output below)</p>
<p class="meta">
waiting for claude to emit an OAuth URL on stdout (output below)
</p>
)}
{!finished ? (
<form class="loginform" onSubmit={submitCode}>
<input
name="code"
type={reveal ? 'text' : 'password'}
type={reveal ? "text" : "password"}
placeholder="paste OAuth code here (hidden)"
required
autocomplete="one-time-code"
@ -133,19 +147,20 @@ function LoginInProgress({ session, onDone }: { session: SessionView | null; onD
</button>
</form>
) : null}
<form style={{ marginTop: '0.4em' }} onSubmit={cancel}>
<form style={{ marginTop: "0.4em" }} onSubmit={cancel}>
<button type="submit" class="btn btn-cancel" disabled={busy}>
cancel + kill
</button>
</form>
{finished ? (
<p class="status-needs-login">
claude process exited: {session?.exit_note || 'exited'}. Start over if needed.
claude process exited: {session?.exit_note || "exited"}. Start over if
needed.
</p>
) : null}
{error ? <p class="meta">error: {error}</p> : null}
<h3>output</h3>
<pre class="diff">{session?.output || ''}</pre>
<pre class="diff">{session?.output || ""}</pre>
</div>
);
}
@ -155,13 +170,13 @@ export function LoginFlow({
session,
onRefresh,
}: {
status: 'needs_login_idle' | 'needs_login_in_progress';
status: "needs_login_idle" | "needs_login_in_progress";
session: SessionView | null;
onRefresh: () => void;
}) {
return (
<div class="agent-status-overlay">
{status === 'needs_login_idle' ? (
{status === "needs_login_idle" ? (
<LoginIdle onStarted={onRefresh} />
) : (
<LoginInProgress session={session} onDone={onRefresh} />

View file

@ -21,11 +21,11 @@
// matching its `.ui-dropdown` visual values exactly (see MetaNav.css) —
// the same "reuse the values, not the component" call `LoginFlow`'s
// `.login-card` makes for the same reason.
import { useEffect, useRef, useState } from 'preact/hooks';
import { Badge } from '@hive/shared/badge.js';
import { LinkIcon } from '@hive/shared/icons.js';
import type { AgentLink } from '../types.js';
import './MetaNav.css';
import { useEffect, useRef, useState } from "preact/hooks";
import { Badge } from "@hive/shared/badge.js";
import { LinkIcon } from "@hive/shared/icons.js";
import type { AgentLink } from "../types.js";
import "./MetaNav.css";
export interface MetaNavProps {
links: AgentLink[];
@ -35,7 +35,11 @@ export interface MetaNavProps {
dashboardBase: string;
}
export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps) {
export function MetaNav({
links,
forgePublicUrl,
dashboardBase,
}: MetaNavProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
@ -43,16 +47,21 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false);
if (
rootRef.current &&
e.target instanceof Node &&
!rootRef.current.contains(e.target)
)
setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
if (e.key === "Escape") setOpen(false);
}
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown);
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
@ -60,7 +69,7 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
// loop: `forge` needs `forgePublicUrl` set or the link is dropped
// entirely (never guessed from `<host>:3000`); `external` is already
// absolute; `container` is a same-origin path.
const visible = links.filter((lnk) => lnk.kind !== 'forge' || forgePublicUrl);
const visible = links.filter((lnk) => lnk.kind !== "forge" || forgePublicUrl);
// No early-return-on-empty: the dashboard link below is always
// present, so the trigger always has at least one item.
@ -86,7 +95,8 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
🧭 dashboard
</a>
{visible.map((lnk) => {
const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url;
const href =
lnk.kind === "forge" ? `${forgePublicUrl}${lnk.url}` : lnk.url;
return (
<a
key={lnk.url}
@ -97,9 +107,7 @@ export function MetaNav({ links, forgePublicUrl, dashboardBase }: MetaNavProps)
role="menuitem"
onClick={() => setOpen(false)}
>
{lnk.icon ? (
<span aria-hidden="true">{lnk.icon}</span>
) : null}
{lnk.icon ? <span aria-hidden="true">{lnk.icon}</span> : null}
{lnk.label}
</a>
);

View file

@ -6,11 +6,11 @@
// a body is an expandable details row gated by the operator's
// expand-tool-output preference. No separate classification step —
// mara: "StreamRow should now match what the server sends in TermMsg."
import { useEffect, useRef } from 'preact/hooks';
import type { TermRow } from '../lib/termMsg.js';
import { linkifyToNodes } from '../lib/linkify.js';
import { renderMarkdown } from '../lib/markdown.js';
import { getExpandDetailsPref } from '@hive/shared/prefs.js';
import { useEffect, useRef } from "preact/hooks";
import type { TermRow } from "../lib/termMsg.js";
import { linkifyToNodes } from "../lib/linkify.js";
import { renderMarkdown } from "../lib/markdown.js";
import { getExpandDetailsPref } from "@hive/shared/prefs.js";
function MarkdownBody({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null);
@ -18,25 +18,31 @@ function MarkdownBody({ text }: { text: string }) {
useEffect(() => {
// marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates the terminal away.
ref.current?.querySelectorAll('a[href]').forEach((a) => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
ref.current?.querySelectorAll("a[href]").forEach((a) => {
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener noreferrer");
});
}, [html]);
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
return <div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
return (
<div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />
);
}
function DiffBody({ text }: { text: string }) {
const lines = String(text).split('\n');
const lines = String(text).split("\n");
return (
<pre className="tool-body diff-body">
{lines.map((line, i) => {
const cls = line.startsWith('+ ') ? 'diff-add' : line.startsWith('- ') ? 'diff-del' : 'diff-ctx';
const cls = line.startsWith("+ ")
? "diff-add"
: line.startsWith("- ")
? "diff-del"
: "diff-ctx";
return (
<span key={i} className={cls}>
{line}
{'\n'}
{"\n"}
</span>
);
})}
@ -45,8 +51,10 @@ function DiffBody({ text }: { text: string }) {
}
export function Row({ row }: { row: TermRow }) {
const cssClass = 'level-' + row.level;
const icon = row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>;
const cssClass = "level-" + row.level;
const icon = row.icon != null && row.icon !== "" && (
<span className="row-glyph">{row.icon}</span>
);
if (row.body == null) {
return (
@ -59,7 +67,7 @@ export function Row({ row }: { row: TermRow }) {
// Empty summary + markdown body → the body itself is the whole row
// (assistant text), no summary prefix line, never collapsible.
if (row.body_format === 'markdown' && row.summary === '') {
if (row.body_format === "markdown" && row.summary === "") {
return (
<div className={`row ${cssClass}`}>
{icon}
@ -69,14 +77,19 @@ export function Row({ row }: { row: TermRow }) {
}
return (
<details className={`row ${cssClass}`} open={getExpandDetailsPref() || undefined}>
<details
className={`row ${cssClass}`}
open={getExpandDetailsPref() || undefined}
>
<summary>
{icon}
<span className="summary-text">{row.summary}</span>
</summary>
{row.body_format === 'diff' && <DiffBody text={row.body} />}
{row.body_format === 'markdown' && <MarkdownBody text={row.body} />}
{row.body_format == null && <pre className="tool-body">{linkifyToNodes(row.body)}</pre>}
{row.body_format === "diff" && <DiffBody text={row.body} />}
{row.body_format === "markdown" && <MarkdownBody text={row.body} />}
{row.body_format == null && (
<pre className="tool-body">{linkifyToNodes(row.body)}</pre>
)}
</details>
);
}

View file

@ -11,9 +11,9 @@
// Content styling (`.agent-inbox`, `.inbox-*`, `.side-panel-empty`)
// already ships globally via `agent.css` and is reused verbatim by
// InboxPanel/TodosPanel — only the drawer chrome itself is new here.
import { useEffect } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import './SidePanel.css';
import { useEffect } from "preact/hooks";
import type { ComponentChildren } from "preact";
import "./SidePanel.css";
export interface SidePanelProps {
open: boolean;
@ -26,17 +26,21 @@ export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
if (e.key === "Escape") onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
return (
<>
<div class={`agent-side-panel-backdrop${open ? ' open' : ''}`} onClick={onClose} aria-hidden="true" />
<div
class={`agent-side-panel-backdrop${open ? " open" : ""}`}
onClick={onClose}
aria-hidden="true"
/>
<aside
class={`agent-side-panel-drawer${open ? ' open' : ''}`}
class={`agent-side-panel-drawer${open ? " open" : ""}`}
role="dialog"
aria-modal="true"
aria-hidden={!open}
@ -44,7 +48,12 @@ export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
>
<div class="agent-side-panel-head">
<span class="agent-side-panel-title">{title}</span>
<button type="button" class="agent-side-panel-close" title="close (esc)" onClick={onClose}>
<button
type="button"
class="agent-side-panel-close"
title="close (esc)"
onClick={onClose}
>
</button>
</div>

View file

@ -10,10 +10,10 @@
// selection state live in the caller (`Root.tsx`, via the
// `useAgentState` hook), so this component can still be demoed and
// reviewed against plain sample data independent of live `/api/state`.
import { useRef, useState } from 'preact/hooks';
import { Badge, type BadgeTone } from '@hive/shared/badge.js';
import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js';
import './StatusChips.css';
import { useRef, useState } from "preact/hooks";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import "./StatusChips.css";
export interface StatusChipsProps {
statusLabel: string;
@ -40,16 +40,16 @@ export interface StatusChipsProps {
}
const MODEL_DESCRIPTIONS: Record<string, string> = {
haiku: 'fast',
sonnet: 'balanced',
opus: 'powerful',
haiku: "fast",
sonnet: "balanced",
opus: "powerful",
};
const EFFORT_DESCRIPTIONS: Record<string, string> = {
low: '',
medium: 'default',
high: '',
xhigh: '',
max: '',
low: "",
medium: "default",
high: "",
xhigh: "",
max: "",
};
function Picker({
@ -76,7 +76,13 @@ function Picker({
}));
return (
<div class="status-chip-anchor" ref={anchorRef}>
<Badge label={label} value={value} onClick={() => setOpen((o) => !o)} expanded={open} title={title} />
<Badge
label={label}
value={value}
onClick={() => setOpen((o) => !o)}
expanded={open}
title={title}
/>
<Dropdown
open={open}
options={dropdownOptions}
@ -110,7 +116,13 @@ function StatusMenu({
onCancelTurn,
}: Pick<
StatusChipsProps,
'statusLabel' | 'statusTone' | 'statusTooltip' | 'paused' | 'onTogglePause' | 'thinking' | 'onCancelTurn'
| "statusLabel"
| "statusTone"
| "statusTooltip"
| "paused"
| "onTogglePause"
| "thinking"
| "onCancelTurn"
>) {
const [open, setOpen] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
@ -121,11 +133,13 @@ function StatusMenu({
setConfirmCancel(false);
}
const options: DropdownOption[] = [{ value: 'toggle-pause', label: paused ? '▶ resume' : '⏸ pause' }];
const options: DropdownOption[] = [
{ value: "toggle-pause", label: paused ? "▶ resume" : "⏸ pause" },
];
if (thinking) {
options.push({
value: 'cancel-turn',
label: confirmCancel ? '■ click again to confirm' : '■ cancel turn',
value: "cancel-turn",
label: confirmCancel ? "■ click again to confirm" : "■ cancel turn",
danger: true,
});
}
@ -144,7 +158,7 @@ function StatusMenu({
options={options}
label="agent status"
onSelect={(value) => {
if (value === 'toggle-pause') {
if (value === "toggle-pause") {
onTogglePause();
close();
return;
@ -225,7 +239,9 @@ export function StatusChips({
title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"
/>
) : null}
{lastTurnLabel ? <span class="status-chips-last-turn">{lastTurnLabel}</span> : null}
{lastTurnLabel ? (
<span class="status-chips-last-turn">{lastTurnLabel}</span>
) : null}
</div>
);
}

View file

@ -10,9 +10,15 @@
// a text-input flow anyway. Instead: typing the command once arms it
// (a local note explains what confirming does), typing it again within
// the same "armed" state fires it — a keyboard-native two-step confirm.
import { useRef, useState } from 'preact/hooks';
import { postModel, postEffort } from '../lib/modelEffort.js';
import { postCancelTurn, postCompact, postNewSession, postLogout, postSend } from '../lib/termActions.js';
import { useRef, useState } from "preact/hooks";
import { postModel, postEffort } from "../lib/modelEffort.js";
import {
postCancelTurn,
postCompact,
postNewSession,
postLogout,
postSend,
} from "../lib/termActions.js";
export interface TermInputProps {
label: string;
@ -22,14 +28,26 @@ export interface TermInputProps {
}
const SLASH_COMMANDS: { name: string; desc: string }[] = [
{ name: '/help', desc: 'list slash commands' },
{ name: '/clear', desc: 'wipe the terminal panel (local-only)' },
{ name: '/cancel', desc: 'SIGINT the in-flight claude turn' },
{ name: '/compact', desc: 'compact the persistent claude session' },
{ name: '/model', desc: '/model <name> — switch claude model for future turns' },
{ name: '/effort', desc: '/effort <level> — set claude effort (medium/high/xhigh) for future turns' },
{ name: '/new-session', desc: 'fresh claude session next turn (type twice to confirm)' },
{ name: '/logout', desc: 'rotate OAuth creds + park in needs-login (type twice to confirm)' },
{ name: "/help", desc: "list slash commands" },
{ name: "/clear", desc: "wipe the terminal panel (local-only)" },
{ name: "/cancel", desc: "SIGINT the in-flight claude turn" },
{ name: "/compact", desc: "compact the persistent claude session" },
{
name: "/model",
desc: "/model <name> — switch claude model for future turns",
},
{
name: "/effort",
desc: "/effort <level> — set claude effort (medium/high/xhigh) for future turns",
},
{
name: "/new-session",
desc: "fresh claude session next turn (type twice to confirm)",
},
{
name: "/logout",
desc: "rotate OAuth creds + park in needs-login (type twice to confirm)",
},
];
const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base
@ -41,8 +59,13 @@ function completeSlash(prefix: string): string | null {
return matches[(idx + 1) % matches.length].name;
}
export function TermInput({ label, online, onLocalNote, onClear }: TermInputProps) {
const [value, setValue] = useState('');
export function TermInput({
label,
online,
onLocalNote,
onClear,
}: TermInputProps) {
const [value, setValue] = useState("");
const taRef = useRef<HTMLTextAreaElement>(null);
// Which destructive command is currently "armed" (typed once, waiting
// for the confirming repeat) — any other command clears it.
@ -51,59 +74,82 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
function grow() {
const ta = taRef.current;
if (!ta) return;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px';
ta.style.height = "auto";
ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + "px";
}
function reportFailure(label_: string, detail?: string) {
onLocalNote(`${label_} failed${detail ? ': ' + detail : ''}`);
onLocalNote(`${label_} failed${detail ? ": " + detail : ""}`);
}
function handleSlash(line: string): boolean {
const trimmed = line.trim();
const [cmd, ...rest] = trimmed.split(/\s+/);
const arg = rest.join(' ');
const arg = rest.join(" ");
const wasArmed = armedRef.current === cmd;
armedRef.current = null;
switch (cmd) {
case '/help':
onLocalNote('/help');
for (const c of SLASH_COMMANDS) onLocalNote(` ${c.name.padEnd(13)}${c.desc}`);
case "/help":
onLocalNote("/help");
for (const c of SLASH_COMMANDS)
onLocalNote(` ${c.name.padEnd(13)}${c.desc}`);
return true;
case '/clear':
case "/clear":
onClear();
onLocalNote('· terminal cleared (local view only — server history kept)');
onLocalNote(
"· terminal cleared (local view only — server history kept)",
);
return true;
case '/cancel':
postCancelTurn().then((r) => !r.ok && reportFailure('/cancel', r.detail));
case "/cancel":
postCancelTurn().then(
(r) => !r.ok && reportFailure("/cancel", r.detail),
);
return true;
case '/compact':
postCompact().then((r) => !r.ok && reportFailure('/compact', r.detail));
case "/compact":
postCompact().then((r) => !r.ok && reportFailure("/compact", r.detail));
return true;
case '/new-session':
case "/new-session":
if (wasArmed) {
postNewSession().then((r) => !r.ok && reportFailure('/new-session', r.detail));
postNewSession().then(
(r) => !r.ok && reportFailure("/new-session", r.detail),
);
} else {
armedRef.current = '/new-session';
onLocalNote('⚠ drops all prior --continue context. type /new-session again to confirm.');
armedRef.current = "/new-session";
onLocalNote(
"⚠ drops all prior --continue context. type /new-session again to confirm.",
);
}
return true;
case '/logout':
case "/logout":
if (wasArmed) {
postLogout().then((r) => !r.ok && reportFailure('/logout', r.detail));
postLogout().then((r) => !r.ok && reportFailure("/logout", r.detail));
} else {
armedRef.current = '/logout';
onLocalNote('⚠ SIGINTs the current turn + rotates OAuth credentials. type /logout again to confirm.');
armedRef.current = "/logout";
onLocalNote(
"⚠ SIGINTs the current turn + rotates OAuth credentials. type /logout again to confirm.",
);
}
return true;
case '/model':
if (!arg) onLocalNote('✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)');
else postModel(arg).then((r) => !r.ok && reportFailure('/model', r.detail));
case "/model":
if (!arg)
onLocalNote(
"✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)",
);
else
postModel(arg).then(
(r) => !r.ok && reportFailure("/model", r.detail),
);
return true;
case '/effort':
if (!arg) onLocalNote('✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)');
else postEffort(arg).then((r) => !r.ok && reportFailure('/effort', r.detail));
case "/effort":
if (!arg)
onLocalNote(
"✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)",
);
else
postEffort(arg).then(
(r) => !r.ok && reportFailure("/effort", r.detail),
);
return true;
default:
onLocalNote(`✗ unknown slash command: ${cmd} — try /help`);
@ -114,14 +160,14 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
function submit() {
const line = value;
if (!line.trim()) return;
setValue('');
setValue("");
requestAnimationFrame(grow);
if (line.trim().startsWith('/')) {
if (line.trim().startsWith("/")) {
handleSlash(line);
return;
}
armedRef.current = null;
postSend(line).then((r) => !r.ok && reportFailure('send', r.detail));
postSend(line).then((r) => !r.ok && reportFailure("send", r.detail));
}
function onInput(e: Event) {
@ -130,7 +176,7 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Tab' && value.startsWith('/') && !value.includes(' ')) {
if (e.key === "Tab" && value.startsWith("/") && !value.includes(" ")) {
const next = completeSlash(value);
if (next) {
e.preventDefault();
@ -138,14 +184,14 @@ export function TermInput({ label, online, onLocalNote, onClear }: TermInputProp
}
return;
}
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault();
submit();
}
}
return (
<div class={`term-input${online ? '' : ' disabled'}`}>
<div class={`term-input${online ? "" : " disabled"}`}>
<form
class="sendform-term"
onSubmit={(e) => {

View file

@ -2,8 +2,8 @@
// flyout. "mark done" POSTs to this agent's own backend
// (`api/todos/mark-done`, same-origin), unlike InboxPanel's cross-agent
// mark-all-read.
import { useState } from 'preact/hooks';
import type { TodoRow } from '../types.js';
import { useState } from "preact/hooks";
import type { TodoRow } from "../types.js";
export interface TodosPanelProps {
todos: TodoRow[];
@ -19,7 +19,7 @@ function fmtAge(seconds: number): string {
export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
const [checked, setChecked] = useState<Set<number>>(new Set());
const [status, setStatus] = useState('');
const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false);
function toggle(id: number) {
@ -35,15 +35,15 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
const ids = [...checked];
if (!ids.length) return;
setBusy(true);
setStatus('marking…');
setStatus("marking…");
try {
const resp = await fetch('api/todos/mark-done', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `ids=${encodeURIComponent(ids.join(','))}`,
const resp = await fetch("api/todos/mark-done", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `ids=${encodeURIComponent(ids.join(","))}`,
});
if (resp.ok) {
setStatus('✓ marked done');
setStatus("✓ marked done");
setChecked(new Set());
onCleared();
} else {
@ -57,7 +57,9 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
}
if (!todos.length) {
return <p class="side-panel-empty">no todos all subsystem queues are clear.</p>;
return (
<p class="side-panel-empty">no todos all subsystem queues are clear.</p>
);
}
const allChecked = todos.length > 0 && todos.every((t) => checked.has(t.id));
@ -68,11 +70,18 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
<button
type="button"
class="inbox-mark-all-btn"
onClick={() => setChecked(allChecked ? new Set() : new Set(todos.map((t) => t.id)))}
onClick={() =>
setChecked(allChecked ? new Set() : new Set(todos.map((t) => t.id)))
}
>
{allChecked ? 'select none' : 'select all'}
{allChecked ? "select none" : "select all"}
</button>
<button type="button" class="inbox-mark-all-btn" onClick={markDone} disabled={busy || checked.size === 0}>
<button
type="button"
class="inbox-mark-all-btn"
onClick={markDone}
disabled={busy || checked.size === 0}
>
mark done
</button>
<span class="inbox-mark-status">{status}</span>
@ -89,10 +98,10 @@ export function TodosPanel({ todos, onCleared }: TodosPanelProps) {
class="todo-cb"
checked={checked.has(t.id)}
onChange={() => toggle(t.id)}
/>{' '}
/>{" "}
<label for={cbId} class="inbox-from">
{label}
</label>{' '}
</label>{" "}
<span class="inbox-ts">{fmtAge(t.age_seconds)} ago</span>
<div class="inbox-body">{t.summary}</div>
</li>

View file

@ -1,4 +1,4 @@
// Ambient module for `import './Foo.css'` side-effect imports (esbuild
// resolves these directly, see build.mjs; tsc otherwise has no idea what
// a `.css` specifier is and refuses the whole side-effect import).
declare module '*.css';
declare module "*.css";

View file

@ -5,8 +5,8 @@
// the UI derives from it, like the turn-state badge's elapsed-time text —
// goes stale until something proactively refetches; without this the
// only way back to a live reading was a full page reload.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { AgentState } from '../types.js';
import { useEffect, useRef, useState } from "preact/hooks";
import type { AgentState } from "../types.js";
const POLL_MS = 4000;
const RETRY_MS = 5000;
@ -38,7 +38,7 @@ export function useAgentState(): UseAgentStateResult {
async function poll() {
try {
const resp = await fetch('api/state');
const resp = await fetch("api/state");
if (!resp.ok) throw new Error(`http ${resp.status}`);
const s = (await resp.json()) as AgentState;
if (stoppedRef.current) return;
@ -80,10 +80,10 @@ export function useAgentState(): UseAgentStateResult {
// component that calls this hook.
useEffect(() => {
function onVisible() {
if (document.visibilityState === 'visible') refresh();
if (document.visibilityState === "visible") refresh();
}
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, []);
return { state, error, refresh };

View file

@ -15,8 +15,8 @@
// initial history page, drop it from the buffer). `seq` alone is the
// whole dedup signal, see `TermEnvelope`'s doc in hive-agent's
// `web_ui/stream.rs`.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { TermEnvelope, TermRow } from '../lib/termMsg.js';
import { useEffect, useRef, useState } from "preact/hooks";
import type { TermEnvelope, TermRow } from "../lib/termMsg.js";
export interface UseLiveStreamOptions {
historyUrl?: string;
@ -50,9 +50,11 @@ function appendRow(rows: TermRow[], row: TermRow): TermRow[] {
return [...rows, row];
}
export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamResult {
const historyUrl = opts.historyUrl ?? 'events/history';
const streamUrl = opts.streamUrl ?? 'events/stream';
export function useLiveStream(
opts: UseLiveStreamOptions = {},
): UseLiveStreamResult {
const historyUrl = opts.historyUrl ?? "events/history";
const streamUrl = opts.streamUrl ?? "events/stream";
const [rows, setRows] = useState<TermRow[]>([]);
const [hasMore, setHasMore] = useState(false);
@ -61,12 +63,16 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
const keySeqRef = useRef(0);
function nextKey(): string {
keySeqRef.current += 1;
return 'r' + keySeqRef.current;
return "r" + keySeqRef.current;
}
function toRows(env: TermEnvelope, fromHistory: boolean): TermRow[] {
return env.msgs.map((m) => ({ ...m, key: nextKey(), fromHistory }));
}
function appendEnvelope(rows: TermRow[], env: TermEnvelope, fromHistory: boolean): TermRow[] {
function appendEnvelope(
rows: TermRow[],
env: TermEnvelope,
fromHistory: boolean,
): TermRow[] {
let next = rows;
for (const row of toRows(env, fromHistory)) next = appendRow(next, row);
return next;
@ -89,9 +95,14 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
try {
env = JSON.parse(e.data);
} catch {
setRows((prev) => appendRow(prev, {
key: 'parse-err-' + Date.now(), level: 'warn', summary: '[parse err] ' + e.data, fromHistory: false,
}));
setRows((prev) =>
appendRow(prev, {
key: "parse-err-" + Date.now(),
level: "warn",
summary: "[parse err] " + e.data,
fromHistory: false,
}),
);
return;
}
if (!liveRef.current) {
@ -101,30 +112,54 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
pushLive(env);
};
es.onerror = () => {
setRows((prev) => appendRow(prev, {
key: 'conn-note', level: 'warn', fromHistory: false, coalesce_key: 'conn-status',
summary: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]',
}));
setRows((prev) =>
appendRow(prev, {
key: "conn-note",
level: "warn",
fromHistory: false,
coalesce_key: "conn-status",
summary:
es.readyState === 0 /* CONNECTING */
? "[reconnecting…]"
: "[disconnected]",
}),
);
};
async function backfill() {
try {
const resp = await fetch(historyUrl);
if (!resp.ok) throw new Error('http ' + resp.status);
if (!resp.ok) throw new Error("http " + resp.status);
const body = await resp.json();
const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || [];
const boundarySeq: number | null = Array.isArray(body) ? null : (body.seq ?? null);
const events: TermEnvelope[] = Array.isArray(body)
? body
: body.events || [];
const boundarySeq: number | null = Array.isArray(body)
? null
: (body.seq ?? null);
if (!Array.isArray(body)) {
setHasMore(!!body.has_more);
if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
if (typeof body.min_id === "number") minIdRef.current = body.min_id;
}
if (cancelled) return;
let initial: TermRow[] = [];
for (const env of events) initial = appendEnvelope(initial, env, true);
initial = events.length
? appendRow(initial, { key: 'live-sep', level: 'debug', fromHistory: true, summary: '─── live (older above) ───' })
: [{ key: 'placeholder', level: 'debug', fromHistory: true, summary: '(connected — waiting for events)' }];
? appendRow(initial, {
key: "live-sep",
level: "debug",
fromHistory: true,
summary: "─── live (older above) ───",
})
: [
{
key: "placeholder",
level: "debug",
fromHistory: true,
summary: "(connected — waiting for events)",
},
];
setRows(initial);
const drained = bufferedRef.current;
@ -133,11 +168,16 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
for (const env of drained) {
// Already covered by the initial history page — drop it from
// the buffer rather than rendering it twice.
if (boundarySeq != null && typeof env.seq === 'number' && env.seq <= boundarySeq) continue;
if (
boundarySeq != null &&
typeof env.seq === "number" &&
env.seq <= boundarySeq
)
continue;
pushLive(env);
}
} catch (err) {
console.warn('history backfill failed', err);
console.warn("history backfill failed", err);
if (cancelled) return;
const drained = bufferedRef.current;
bufferedRef.current = [];
@ -157,30 +197,42 @@ export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamRes
if (!hasMore || loadingMore || minIdRef.current == null) return;
setLoadingMore(true);
try {
const sep = historyUrl.includes('?') ? '&' : '?';
const resp = await fetch(historyUrl + sep + 'before=' + minIdRef.current);
const sep = historyUrl.includes("?") ? "&" : "?";
const resp = await fetch(historyUrl + sep + "before=" + minIdRef.current);
if (!resp.ok) return;
const body = await resp.json();
const events: TermEnvelope[] = Array.isArray(body) ? body : body.events || [];
const events: TermEnvelope[] = Array.isArray(body)
? body
: body.events || [];
setHasMore(!!body.has_more);
if (typeof body.min_id === 'number') minIdRef.current = body.min_id;
if (typeof body.min_id === "number") minIdRef.current = body.min_id;
if (events.length) {
let older: TermRow[] = [];
for (const env of events) older = appendEnvelope(older, env, true);
older = appendRow(older, {
key: 'older-sep-' + minIdRef.current, level: 'debug', fromHistory: true, summary: '─── older above ───',
key: "older-sep-" + minIdRef.current,
level: "debug",
fromHistory: true,
summary: "─── older above ───",
});
setRows((prev) => [...older, ...prev]);
}
} catch (err) {
console.warn('loadMore failed', err);
console.warn("loadMore failed", err);
} finally {
setLoadingMore(false);
}
}
function pushLocalNote(text: string) {
setRows((prev) => appendRow(prev, { key: 'local-' + nextKey(), level: 'info', fromHistory: false, summary: text }));
setRows((prev) =>
appendRow(prev, {
key: "local-" + nextKey(),
level: "info",
fromHistory: false,
summary: text,
}),
);
}
function clearLocal() {
setRows([]);

View file

@ -3,8 +3,8 @@
// useAgentState: todos change asynchronously (matrix syncs, bash task
// starts/completions) independent of the state snapshot, and the old
// page polled them independently too.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { TodoRow } from '../types.js';
import { useEffect, useRef, useState } from "preact/hooks";
import type { TodoRow } from "../types.js";
const POLL_MS = 4000;
@ -20,16 +20,16 @@ export function useTodos(): UseTodosResult {
async function poll() {
try {
const resp = await fetch('api/todos');
const resp = await fetch("api/todos");
if (!resp.ok) throw new Error(`http ${resp.status}`);
const body = (await resp.json()) as { todos?: TodoRow[] };
if (stoppedRef.current) return;
// Defensive filter: the wire type is the general `LooseEnd` tagged
// enum even though this endpoint only ever emits the `todo` variant
// today — see types.ts's TodoRow comment.
setTodos((body.todos ?? []).filter((t) => t.kind === 'todo'));
setTodos((body.todos ?? []).filter((t) => t.kind === "todo"));
} catch (err) {
console.warn('todos fetch failed', err);
console.warn("todos fetch failed", err);
if (!stoppedRef.current) setTodos([]);
}
}

View file

@ -1,28 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive agent</title>
<link rel="icon" type="image/svg+xml" href="icon">
<link rel="stylesheet" href="static/colors.css">
<link rel="stylesheet" href="static/theme.css">
<link rel="stylesheet" href="static/agent.css">
<!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive agent</title>
<link rel="icon" type="image/svg+xml" href="icon" />
<link rel="stylesheet" href="static/colors.css" />
<link rel="stylesheet" href="static/theme.css" />
<link rel="stylesheet" href="static/agent.css" />
<!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/
StatusChips/SidePanel/...) MUST load after agent.css —
`.login-card`/`.meta-nav-popover`/etc. share class-selector
specificity with agent.css's legacy rules, so load order is what
decides which one wins the cascade (see LoginFlow.css/MetaNav.css
file comments). -->
<link rel="stylesheet" href="static/main.css">
</head>
<body class="agent-shell">
<!-- The Preact rewrite owns everything below — header, terminal,
<link rel="stylesheet" href="static/main.css" />
</head>
<body class="agent-shell">
<!-- The Preact rewrite owns everything below — header, terminal,
composer, side panel. See main.tsx/Root.tsx for the component
tree; docs/web-ui/agent.md for the design. -->
<div id="preact-root"></div>
<div id="preact-root"></div>
<script type="module" src="static/main.js" defer></script>
</body>
<script type="module" src="static/main.js" defer></script>
</body>
</html>

View file

@ -5,7 +5,7 @@
// `/agent/<name>/` on the dashboard's own origin (`location.origin`);
// accessed directly, it's the same host on `dashboardPort`.
export function resolveDashboardBase(dashboardPort: number): string {
return location.pathname.startsWith('/agent/')
? location.origin + '/'
return location.pathname.startsWith("/agent/")
? location.origin + "/"
: `${location.protocol}//${location.hostname}:${dashboardPort}/`;
}

View file

@ -2,16 +2,16 @@
// output shapes — this page's operators are used to reading them).
export function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1_000) return Math.round(n / 1000) + 'k';
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return Math.round(n / 1000) + "k";
return String(n);
}
export function fmtAge(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
if (s < 60) return s + "s";
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ' + (s % 60) + 's';
if (m < 60) return m + "m " + (s % 60) + "s";
const h = Math.floor(m / 60);
return h + 'h ' + (m % 60) + 'm';
return h + "h " + (m % 60) + "m";
}

View file

@ -3,13 +3,15 @@
// output there, JSX fragment here). Same regex + trailing-punctuation
// strip; deliberately text-only, never innerHTML, so untrusted row text
// (matrix-relayed bodies, tool args) can't inject markup this way.
import type { JSX } from 'preact';
import type { JSX } from "preact";
const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g;
export function linkifyToNodes(text: string | null | undefined): (string | JSX.Element)[] {
const str = text == null ? '' : String(text);
if (str.indexOf('://') === -1) return [str];
export function linkifyToNodes(
text: string | null | undefined,
): (string | JSX.Element)[] {
const str = text == null ? "" : String(text);
if (str.indexOf("://") === -1) return [str];
const out: (string | JSX.Element)[] = [];
let last = 0;
let m: RegExpExecArray | null;
@ -18,10 +20,10 @@ export function linkifyToNodes(text: string | null | undefined): (string | JSX.E
while ((m = LINKIFY_URL_RE.exec(str)) !== null) {
let url = m[0];
const trail = url.match(/[.,;:!?)\]}'"]+$/);
const tail = trail ? trail[0] : '';
const tail = trail ? trail[0] : "";
if (tail) url = url.slice(0, -tail.length);
if (m.index > last) out.push(str.slice(last, m.index));
if (!url.slice(url.indexOf('://') + 3)) {
if (!url.slice(url.indexOf("://") + 3)) {
// Nothing past the scheme — not a real URL, emit verbatim.
out.push(m[0]);
} else {

View file

@ -6,23 +6,38 @@
// these three routes actually redirect today, but the same tolerant
// check costs nothing and keeps every same-origin POST in this package
// behaving identically.
async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> {
async function post(
path: string,
body?: URLSearchParams,
): Promise<{ ok: boolean; detail?: string }> {
try {
const resp = await fetch(path, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined,
method: "POST",
headers: body
? { "Content-Type": "application/x-www-form-urlencoded" }
: undefined,
body,
redirect: 'manual',
redirect: "manual",
});
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400);
const ok =
resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (ok) return { ok: true };
const detail = await resp.text().catch(() => '');
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` };
const detail = await resp.text().catch(() => "");
return {
ok: false,
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
};
} catch (err) {
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
return {
ok: false,
detail: err instanceof Error ? err.message : String(err),
};
}
}
export const postLoginStart = () => post('login/start');
export const postLoginCode = (code: string) => post('login/code', new URLSearchParams({ code }));
export const postLoginCancel = () => post('login/cancel');
export const postLoginStart = () => post("login/start");
export const postLoginCode = (code: string) =>
post("login/code", new URLSearchParams({ code }));
export const postLoginCancel = () => post("login/cancel");

View file

@ -4,22 +4,27 @@
// agent-authored files) — `marked` itself no longer sanitizes (v5+
// dropped the built-in sanitizer), so every parse is run through
// DOMPurify before it's ever handed to `dangerouslySetInnerHTML`.
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { marked } from "marked";
import DOMPurify from "dompurify";
marked.setOptions({ breaks: true, gfm: true });
const ESCAPE_RE = /[&<>"]/g;
const ESCAPE_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
const ESCAPE_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
};
/** Render `text` as sanitized markdown HTML. Falls back to escaped plain
* text if `marked` throws (mirrors app.js's try/catch fallback). */
export function renderMarkdown(text: string | null | undefined): string {
const src = String(text ?? '');
const src = String(text ?? "");
try {
return DOMPurify.sanitize(marked.parse(src) as string);
} catch (err) {
console.warn('marked failed', err);
console.warn("marked failed", err);
return src.replace(ESCAPE_RE, (c) => ESCAPE_MAP[c] ?? c);
}
}

View file

@ -4,22 +4,36 @@
// ported from app.js unchanged — the endpoints don't actually redirect
// today, but treating an opaque redirect as success costs nothing and
// matches the existing contract exactly.
async function post(path: string, field: string, value: string): Promise<{ ok: boolean; detail?: string }> {
async function post(
path: string,
field: string,
value: string,
): Promise<{ ok: boolean; detail?: string }> {
try {
const resp = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ [field]: value }),
redirect: 'manual',
redirect: "manual",
});
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400);
const ok =
resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (ok) return { ok: true };
const detail = await resp.text().catch(() => '');
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` };
const detail = await resp.text().catch(() => "");
return {
ok: false,
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
};
} catch (err) {
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
return {
ok: false,
detail: err instanceof Error ? err.message : String(err),
};
}
}
export const postModel = (name: string) => post('api/model', 'model', name);
export const postEffort = (level: string) => post('api/effort', 'effort', level);
export const postModel = (name: string) => post("api/model", "model", name);
export const postEffort = (level: string) =>
post("api/effort", "effort", level);

View file

@ -14,10 +14,14 @@
// navigates the whole page to whatever hive-c0re's endpoint returns —
// here, the literal text "ok". Fire-and-forget `fetch` keeps the click
// on the agent page.
export async function submitPauseResume(dashboardBase: string, label: string, verb: 'pause' | 'resume'): Promise<void> {
export async function submitPauseResume(
dashboardBase: string,
label: string,
verb: "pause" | "resume",
): Promise<void> {
await fetch(`${dashboardBase}api/${verb}/${label}`, {
method: 'POST',
mode: 'no-cors',
credentials: 'include',
method: "POST",
mode: "no-cors",
credentials: "include",
}).catch(() => {});
}

View file

@ -1,25 +1,40 @@
// Same-origin fetch POST helpers for the term-input slash commands —
// same shape as modelEffort.ts's `post()` ({ ok, detail } rather than
// throwing), reused by TermInput.tsx.
async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> {
async function post(
path: string,
body?: URLSearchParams,
): Promise<{ ok: boolean; detail?: string }> {
try {
const resp = await fetch(path, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined,
method: "POST",
headers: body
? { "Content-Type": "application/x-www-form-urlencoded" }
: undefined,
body,
redirect: 'manual',
redirect: "manual",
});
const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400);
const ok =
resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (ok) return { ok: true };
const detail = await resp.text().catch(() => '');
return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` };
const detail = await resp.text().catch(() => "");
return {
ok: false,
detail: `http ${resp.status}${detail ? " — " + detail : ""}`,
};
} catch (err) {
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
return {
ok: false,
detail: err instanceof Error ? err.message : String(err),
};
}
}
export const postCancelTurn = () => post('api/cancel');
export const postCompact = () => post('api/compact');
export const postNewSession = () => post('api/new-session');
export const postLogout = () => post('api/logout');
export const postSend = (body: string) => post('send', new URLSearchParams({ body }));
export const postCancelTurn = () => post("api/cancel");
export const postCompact = () => post("api/compact");
export const postNewSession = () => post("api/new-session");
export const postLogout = () => post("api/logout");
export const postSend = (body: string) =>
post("send", new URLSearchParams({ body }));

View file

@ -3,14 +3,14 @@
// a `TermMsg` close to as-is (see components/Row.tsx); there's no
// separate client-side row model or classification step any more —
// mara: "StreamRow should now match what the server sends in TermMsg."
export type Level = 'debug' | 'info' | 'warn' | 'error';
export type Level = "debug" | "info" | "warn" | "error";
export interface TermMsg {
icon?: string;
level: Level;
summary: string;
body?: string;
body_format?: 'markdown' | 'diff';
body_format?: "markdown" | "diff";
coalesce_key?: string;
}

View file

@ -7,7 +7,7 @@
// the old widget family. First click arms the button (caller renders a
// distinct "sure? click again" label off `armed`); a second click within
// `resetMs` fires `onConfirm`; anything else (timeout, blur) disarms.
import { useEffect, useRef, useState } from 'preact/hooks';
import { useEffect, useRef, useState } from "preact/hooks";
export function useConfirmClick(onConfirm: () => void, resetMs = 2500) {
const [armed, setArmed] = useState(false);

View file

@ -7,8 +7,8 @@
// Mounts to `#preact-root`, the only content `index.html`'s `<body>`
// declares now — no more coexisting-with-legacy-markup sibling div,
// that was only needed while both pages built in parallel.
import { render } from 'preact';
import { Root } from './Root.js';
import { render } from "preact";
import { Root } from "./Root.js";
const root = document.getElementById('preact-root');
const root = document.getElementById("preact-root");
if (root) render(<Root />, root);

File diff suppressed because it is too large Load diff

View file

@ -1,52 +1,84 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive agent — stats</title>
<link rel="icon" type="image/svg+xml" href="icon">
<link rel="stylesheet" href="static/colors.css">
<link rel="stylesheet" href="static/theme.css">
<link rel="stylesheet" href="static/agent.css">
</head>
<body>
<header class="page-header">
<a class="page-back" id="back-link" href="./">← live</a>
<a class="page-back" id="dashboard-link" href="#">dashboard ↗</a>
<span class="page-title" id="title">◆ … ◆</span>
</header>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive agent — stats</title>
<link rel="icon" type="image/svg+xml" href="icon" />
<link rel="stylesheet" href="static/colors.css" />
<link rel="stylesheet" href="static/theme.css" />
<link rel="stylesheet" href="static/agent.css" />
</head>
<body>
<header class="page-header">
<a class="page-back" id="back-link" href="./">← live</a>
<a class="page-back" id="dashboard-link" href="#">dashboard ↗</a>
<span class="page-title" id="title">◆ … ◆</span>
</header>
<div class="window-tabs" id="window-tabs" role="tablist">
<button type="button" data-tab="1h">last 1h</button>
<button type="button" data-tab="4h">last 4h</button>
<button type="button" data-tab="24h">last 24h</button>
<button type="button" data-tab="3d">last 3d</button>
<button type="button" data-tab="7d">last 7d</button>
<button type="button" data-tab="30d">last 30d</button>
<button type="button" data-tab="all">all</button>
</div>
<div class="window-tabs" id="window-tabs" role="tablist">
<button type="button" data-tab="1h">last 1h</button>
<button type="button" data-tab="4h">last 4h</button>
<button type="button" data-tab="24h">last 24h</button>
<button type="button" data-tab="3d">last 3d</button>
<button type="button" data-tab="7d">last 7d</button>
<button type="button" data-tab="30d">last 30d</button>
<button type="button" data-tab="all">all</button>
</div>
<div class="summary" id="summary"></div>
<div class="summary" id="summary"></div>
<div class="stats-grid">
<div class="stats-card wide"><h3>turns per bucket</h3><div class="chart-wrap"><canvas id="chart-turns"></canvas></div></div>
<div class="stats-card wide"><h3>turn duration (ms) — p50 / p95 / avg</h3><div class="chart-wrap"><canvas id="chart-duration"></canvas></div></div>
<div class="stats-card wide"><h3>context tokens (last inference per turn) — avg / max</h3><div class="chart-wrap"><canvas id="chart-ctx"></canvas></div></div>
<div class="stats-card wide"><h3>token cost per bucket (sum across inferences)</h3><div class="chart-wrap"><canvas id="chart-cost"></canvas></div></div>
<div class="stats-card wide"><h3>turns by model per bucket — model drives token cost</h3><div class="chart-wrap"><canvas id="chart-model"></canvas></div></div>
<div class="stats-card"><h3>top tools</h3><div class="chart-wrap"><canvas id="chart-tools"></canvas></div></div>
<!-- "favorite tools": most-run shell commands. Hidden until the
<div class="stats-grid">
<div class="stats-card wide">
<h3>turns per bucket</h3>
<div class="chart-wrap"><canvas id="chart-turns"></canvas></div>
</div>
<div class="stats-card wide">
<h3>turn duration (ms) — p50 / p95 / avg</h3>
<div class="chart-wrap"><canvas id="chart-duration"></canvas></div>
</div>
<div class="stats-card wide">
<h3>context tokens (last inference per turn) — avg / max</h3>
<div class="chart-wrap"><canvas id="chart-ctx"></canvas></div>
</div>
<div class="stats-card wide">
<h3>token cost per bucket (sum across inferences)</h3>
<div class="chart-wrap"><canvas id="chart-cost"></canvas></div>
</div>
<div class="stats-card wide">
<h3>turns by model per bucket — model drives token cost</h3>
<div class="chart-wrap"><canvas id="chart-model"></canvas></div>
</div>
<div class="stats-card">
<h3>top tools</h3>
<div class="chart-wrap"><canvas id="chart-tools"></canvas></div>
</div>
<!-- "favorite tools": most-run shell commands. Hidden until the
bash_commands capture (hive-bash-daemon) has recorded data, so the
card never shows a permanently-empty doughnut. -->
<div class="stats-card" id="card-bash" hidden><h3>favorite tools (bash)</h3><div class="chart-wrap"><canvas id="chart-bash"></canvas></div></div>
<div class="stats-card"><h3>wake source mix</h3><div class="chart-wrap"><canvas id="chart-wake"></canvas></div></div>
<div class="stats-card"><h3>result mix</h3><div class="chart-wrap"><canvas id="chart-result"></canvas></div></div>
<div class="stats-card wide"><h3>result trend per bucket — errors / rate-limits / compactions over time</h3><div class="chart-wrap"><canvas id="chart-result-trend"></canvas></div></div>
</div>
<div class="stats-card" id="card-bash" hidden>
<h3>favorite tools (bash)</h3>
<div class="chart-wrap"><canvas id="chart-bash"></canvas></div>
</div>
<div class="stats-card">
<h3>wake source mix</h3>
<div class="chart-wrap"><canvas id="chart-wake"></canvas></div>
</div>
<div class="stats-card">
<h3>result mix</h3>
<div class="chart-wrap"><canvas id="chart-result"></canvas></div>
</div>
<div class="stats-card wide">
<h3>
result trend per bucket — errors / rate-limits / compactions over time
</h3>
<div class="chart-wrap"><canvas id="chart-result-trend"></canvas></div>
</div>
</div>
<!-- Chart.js is now bundled into stats.js by esbuild (npm dep
<!-- Chart.js is now bundled into stats.js by esbuild (npm dep
chart.js@4.4.4), so the page works offline / on operator
machines without internet egress. No SRI hash to maintain. -->
<script type="module" src="static/stats.js" defer></script>
</body>
<script type="module" src="static/stats.js" defer></script>
</body>
</html>

View file

@ -2,8 +2,8 @@
// once on load, then /api/stats?window=... for the chart data — re-fetches
// when the operator clicks a window tab.
import Chart from 'chart.js/auto';
import { createTabStrip } from '@hive/shared/tabs.js';
import Chart from "chart.js/auto";
import { createTabStrip } from "@hive/shared/tabs.js";
// Expose for the IIFE below — pre-split this was a window global from
// the jsDelivr CDN script tag. esbuild now bundles chart.js into
@ -12,26 +12,36 @@ import { createTabStrip } from '@hive/shared/tabs.js';
window.Chart = Chart;
(function () {
'use strict';
"use strict";
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const cssVar = (name) =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const palette = {
bg: cssVar('--bg'),
bgElev: cssVar('--bg-elev'),
fg: cssVar('--fg'),
muted: cssVar('--muted'),
purple: cssVar('--purple'),
cyan: cssVar('--cyan'),
pink: cssVar('--pink'),
amber: cssVar('--amber'),
green: cssVar('--green'),
red: cssVar('--red'),
border: cssVar('--border'),
bg: cssVar("--bg"),
bgElev: cssVar("--bg-elev"),
fg: cssVar("--fg"),
muted: cssVar("--muted"),
purple: cssVar("--purple"),
cyan: cssVar("--cyan"),
pink: cssVar("--pink"),
amber: cssVar("--amber"),
green: cssVar("--green"),
red: cssVar("--red"),
border: cssVar("--border"),
};
// Distinct hues for categorical charts (top tools / wake mix / result mix).
const wheel = [palette.purple, palette.cyan, palette.pink, palette.amber,
palette.green, palette.red, '#94e2d5', '#f9e2af',
'#74c7ec', '#b4befe'];
const wheel = [
palette.purple,
palette.cyan,
palette.pink,
palette.amber,
palette.green,
palette.red,
"#94e2d5",
"#f9e2af",
"#74c7ec",
"#b4befe",
];
// Apply Catppuccin defaults globally so each Chart inherits without per-call
// overrides. Chart.js v4 reads these on chart construction.
@ -42,16 +52,16 @@ window.Chart = Chart;
Chart.defaults.plugins.legend.labels.color = palette.fg;
const charts = {};
let currentWindow = '24h';
let currentWindow = "24h";
function fmtMs(ms) {
if (!Number.isFinite(ms) || ms <= 0) return '0';
if (ms < 1000) return ms.toFixed(0) + 'ms';
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + 's';
if (!Number.isFinite(ms) || ms <= 0) return "0";
if (ms < 1000) return ms.toFixed(0) + "ms";
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + "s";
}
function fmtInt(n) {
if (!Number.isFinite(n)) return '0';
if (!Number.isFinite(n)) return "0";
return new Intl.NumberFormat().format(Math.round(n));
}
@ -74,18 +84,21 @@ window.Chart = Chart;
destroy(canvasId);
const cv = document.getElementById(canvasId);
if (!cv) return;
const ctx = cv.getContext('2d');
const ctx = cv.getContext("2d");
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.fillStyle = palette.muted;
ctx.font = '12px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = "12px monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(msg, cv.width / 2, cv.height / 2);
}
// Sum the four token streams across every bucket in the window.
function tokenTotals(s) {
let input = 0, output = 0, cacheRead = 0, cacheCreation = 0;
let input = 0,
output = 0,
cacheRead = 0,
cacheCreation = 0;
for (const b of s.buckets || []) {
input += b.input_tokens || 0;
output += b.output_tokens || 0;
@ -96,54 +109,58 @@ window.Chart = Chart;
}
function renderSummary(s) {
const root = document.getElementById('summary');
const root = document.getElementById("summary");
root.replaceChildren();
const chips = [
['turns', fmtInt(s.turn_count)],
['avg duration', fmtMs(s.duration_summary.avg_ms)],
['p50 duration', fmtMs(s.duration_summary.p50_ms)],
['p95 duration', fmtMs(s.duration_summary.p95_ms)],
['window', s.window],
["turns", fmtInt(s.turn_count)],
["avg duration", fmtMs(s.duration_summary.avg_ms)],
["p50 duration", fmtMs(s.duration_summary.p50_ms)],
["p95 duration", fmtMs(s.duration_summary.p95_ms)],
["window", s.window],
];
// Token-efficiency chips: cache hit-rate (cached input vs all
// input-side tokens) and average tokens billed per turn.
const t = tokenTotals(s);
const inputSide = t.input + t.cacheRead + t.cacheCreation;
if (inputSide > 0) {
chips.push(['cache hit-rate', (100 * t.cacheRead / inputSide).toFixed(1) + '%']);
chips.push([
"cache hit-rate",
((100 * t.cacheRead) / inputSide).toFixed(1) + "%",
]);
}
if (s.turn_count > 0) {
const perTurn = (t.input + t.output + t.cacheRead + t.cacheCreation) / s.turn_count;
chips.push(['tokens/turn', fmtInt(perTurn)]);
const perTurn =
(t.input + t.output + t.cacheRead + t.cacheCreation) / s.turn_count;
chips.push(["tokens/turn", fmtInt(perTurn)]);
}
if (s.reminder_stats) {
chips.push(
['reminders scheduled', fmtInt(s.reminder_stats.scheduled)],
['reminders delivered', fmtInt(s.reminder_stats.delivered)],
['reminders pending', fmtInt(s.reminder_stats.pending)],
["reminders scheduled", fmtInt(s.reminder_stats.scheduled)],
["reminders delivered", fmtInt(s.reminder_stats.delivered)],
["reminders pending", fmtInt(s.reminder_stats.pending)],
);
}
// Session count: fresh claude sessions started in the window (each
// new-session or auto-compaction-fallback mints one). Omitted until
// the sessions table exists (older db).
if (typeof s.session_count === 'number') {
chips.push(['sessions', fmtInt(s.session_count)]);
if (typeof s.session_count === "number") {
chips.push(["sessions", fmtInt(s.session_count)]);
}
// First-turn ctx: input tokens of the most recent fresh session's
// first turn — the cold system-prompt + CLAUDE.md cost, a sprawl
// proxy. Omitted from the JSON (and so absent here) until the
// per-session capture has data.
if (typeof s.first_turn_ctx === 'number') {
chips.push(['first-turn ctx', fmtInt(s.first_turn_ctx)]);
if (typeof s.first_turn_ctx === "number") {
chips.push(["first-turn ctx", fmtInt(s.first_turn_ctx)]);
}
for (const [label, value] of chips) {
const chip = document.createElement('span');
chip.className = 'chip';
const l = document.createElement('span');
l.className = 'label';
const chip = document.createElement("span");
chip.className = "chip";
const l = document.createElement("span");
l.className = "label";
l.textContent = label;
const v = document.createElement('span');
v.className = 'value';
const v = document.createElement("span");
v.className = "value";
v.textContent = value;
chip.append(l, v);
root.append(chip);
@ -151,54 +168,67 @@ window.Chart = Chart;
}
function renderTurnsChart(s) {
const id = 'chart-turns';
const id = "chart-turns";
destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
const data = s.buckets.map((b) => b.turn_count);
charts[id] = new Chart(document.getElementById(id), {
type: 'bar',
type: "bar",
data: {
labels,
datasets: [{
label: 'turns',
data,
backgroundColor: palette.purple,
borderColor: palette.purple,
borderWidth: 1,
}],
datasets: [
{
label: "turns",
data,
backgroundColor: palette.purple,
borderColor: palette.purple,
borderWidth: 1,
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { grid: { color: palette.border } },
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { precision: 0 } },
y: {
beginAtZero: true,
grid: { color: palette.border },
ticks: { precision: 0 },
},
},
},
});
}
function renderDurationChart(s) {
const id = 'chart-duration';
const id = "chart-duration";
destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
const ds = (label, color, key) => ({
label, data: s.buckets.map((b) => b[key]),
borderColor: color, backgroundColor: color + '33',
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
label,
data: s.buckets.map((b) => b[key]),
borderColor: color,
backgroundColor: color + "33",
tension: 0.25,
pointRadius: 0,
borderWidth: 2,
spanGaps: true,
});
charts[id] = new Chart(document.getElementById(id), {
type: 'line',
type: "line",
data: {
labels,
datasets: [
ds('p50', palette.cyan, 'p50_duration_ms'),
ds('p95', palette.pink, 'p95_duration_ms'),
ds('avg', palette.amber, 'avg_duration_ms'),
ds("p50", palette.cyan, "p50_duration_ms"),
ds("p95", palette.pink, "p95_duration_ms"),
ds("avg", palette.amber, "avg_duration_ms"),
],
},
options: {
responsive: true, maintainAspectRatio: false,
responsive: true,
maintainAspectRatio: false,
scales: {
x: { grid: { color: palette.border } },
y: {
@ -212,77 +242,108 @@ window.Chart = Chart;
}
function renderCtxChart(s) {
const id = 'chart-ctx';
const id = "chart-ctx";
destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
charts[id] = new Chart(document.getElementById(id), {
type: 'line',
type: "line",
data: {
labels,
datasets: [
{
label: 'avg ctx',
label: "avg ctx",
data: s.buckets.map((b) => b.avg_ctx_tokens),
borderColor: palette.cyan,
backgroundColor: palette.cyan + '33',
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
backgroundColor: palette.cyan + "33",
tension: 0.25,
pointRadius: 0,
borderWidth: 2,
spanGaps: true,
},
{
label: 'max ctx',
label: "max ctx",
data: s.buckets.map((b) => b.max_ctx_tokens),
borderColor: palette.amber,
backgroundColor: palette.amber + '33',
tension: 0.25, pointRadius: 0, borderWidth: 2, spanGaps: true,
backgroundColor: palette.amber + "33",
tension: 0.25,
pointRadius: 0,
borderWidth: 2,
spanGaps: true,
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
responsive: true,
maintainAspectRatio: false,
scales: {
x: { grid: { color: palette.border } },
y: { beginAtZero: true, grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
y: {
beginAtZero: true,
grid: { color: palette.border },
ticks: { callback: (v) => fmtInt(v) },
},
},
},
});
}
function renderCostChart(s) {
const id = 'chart-cost';
const id = "chart-cost";
destroy(id);
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
// Stacked bars: cache_read (cheap) / cache_creation / input / output.
// Highlights "what's actually getting billed at full rate" vs cache hits.
charts[id] = new Chart(document.getElementById(id), {
type: 'bar',
type: "bar",
data: {
labels,
datasets: [
{ label: 'cache_read', data: s.buckets.map((b) => b.cache_read_input_tokens),
backgroundColor: palette.muted },
{ label: 'cache_creation', data: s.buckets.map((b) => b.cache_creation_input_tokens),
backgroundColor: palette.cyan },
{ label: 'input', data: s.buckets.map((b) => b.input_tokens),
backgroundColor: palette.amber },
{ label: 'output', data: s.buckets.map((b) => b.output_tokens),
backgroundColor: palette.pink },
{
label: "cache_read",
data: s.buckets.map((b) => b.cache_read_input_tokens),
backgroundColor: palette.muted,
},
{
label: "cache_creation",
data: s.buckets.map((b) => b.cache_creation_input_tokens),
backgroundColor: palette.cyan,
},
{
label: "input",
data: s.buckets.map((b) => b.input_tokens),
backgroundColor: palette.amber,
},
{
label: "output",
data: s.buckets.map((b) => b.output_tokens),
backgroundColor: palette.pink,
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
responsive: true,
maintainAspectRatio: false,
scales: {
x: { stacked: true, grid: { color: palette.border } },
y: { stacked: true, beginAtZero: true,
grid: { color: palette.border }, ticks: { callback: (v) => fmtInt(v) } },
y: {
stacked: true,
beginAtZero: true,
grid: { color: palette.border },
ticks: { callback: (v) => fmtInt(v) },
},
},
},
});
}
function renderModelChart(s) {
const id = 'chart-model';
const id = "chart-model";
destroy(id);
const models = s.models || [];
if (!models.length) { paintEmpty(id, 'no turns in window'); return; }
if (!models.length) {
paintEmpty(id, "no turns in window");
return;
}
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
// One stacked series per model. Model choice drives token cost,
// so this lines up against the cost chart above it.
@ -292,28 +353,36 @@ window.Chart = Chart;
backgroundColor: wheel[i % wheel.length],
}));
charts[id] = new Chart(document.getElementById(id), {
type: 'bar',
type: "bar",
data: { labels, datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } },
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "top", labels: { boxWidth: 12 } } },
scales: {
x: { stacked: true, grid: { color: palette.border } },
y: { stacked: true, beginAtZero: true,
grid: { color: palette.border }, ticks: { precision: 0 } },
y: {
stacked: true,
beginAtZero: true,
grid: { color: palette.border },
ticks: { precision: 0 },
},
},
},
});
}
function renderResultTrendChart(s) {
const id = 'chart-result-trend';
const id = "chart-result-trend";
destroy(id);
// Series = the result kinds seen in the window (same order +
// colours as the result-mix doughnut). One stacked bar series per
// kind, so error / rate-limit / compaction spikes line up in time.
const kinds = (s.result_mix || []).map((kc) => kc.key);
if (!kinds.length) { paintEmpty(id, 'no results'); return; }
if (!kinds.length) {
paintEmpty(id, "no results");
return;
}
const labels = s.buckets.map((b) => bucketLabel(b.ts, s.bucket_seconds));
const datasets = kinds.map((k, i) => ({
label: k,
@ -321,15 +390,20 @@ window.Chart = Chart;
backgroundColor: wheel[i % wheel.length],
}));
charts[id] = new Chart(document.getElementById(id), {
type: 'bar',
type: "bar",
data: { labels, datasets },
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { boxWidth: 12 } } },
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "top", labels: { boxWidth: 12 } } },
scales: {
x: { stacked: true, grid: { color: palette.border } },
y: { stacked: true, beginAtZero: true,
grid: { color: palette.border }, ticks: { precision: 0 } },
y: {
stacked: true,
beginAtZero: true,
grid: { color: palette.border },
ticks: { precision: 0 },
},
},
},
});
@ -345,11 +419,22 @@ window.Chart = Chart;
const data = items.map((kc) => kc.count);
const colors = items.map((_, i) => wheel[i % wheel.length]);
charts[canvasId] = new Chart(document.getElementById(canvasId), {
type: 'doughnut',
data: { labels, datasets: [{ data, backgroundColor: colors, borderColor: palette.bg, borderWidth: 2 }] },
type: "doughnut",
data: {
labels,
datasets: [
{
data,
backgroundColor: colors,
borderColor: palette.bg,
borderWidth: 2,
},
],
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } },
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: "right", labels: { boxWidth: 12 } } },
},
});
}
@ -360,30 +445,30 @@ window.Chart = Chart;
// doughnut. Runs independently of turn_count (a bash task is tied to
// a turn, but we don't want to couple the two reads).
function renderBashCard(s) {
const card = document.getElementById('card-bash');
const card = document.getElementById("card-bash");
const items = s.bash_breakdown || [];
if (!items.length) {
if (card) card.hidden = true;
destroy('chart-bash');
destroy("chart-bash");
return;
}
if (card) card.hidden = false;
renderKeyCount('chart-bash', items, 'no bash commands');
renderKeyCount("chart-bash", items, "no bash commands");
}
function render(s) {
renderSummary(s);
renderBashCard(s);
if (s.turn_count === 0) {
paintEmpty('chart-turns', 'no turns in window');
paintEmpty('chart-duration', 'no turns in window');
paintEmpty('chart-ctx', 'no turns in window');
paintEmpty('chart-cost', 'no turns in window');
paintEmpty('chart-model', 'no turns in window');
paintEmpty('chart-tools', 'no tool calls');
paintEmpty('chart-wake', 'no wakes');
paintEmpty('chart-result', 'no results');
paintEmpty('chart-result-trend', 'no results');
paintEmpty("chart-turns", "no turns in window");
paintEmpty("chart-duration", "no turns in window");
paintEmpty("chart-ctx", "no turns in window");
paintEmpty("chart-cost", "no turns in window");
paintEmpty("chart-model", "no turns in window");
paintEmpty("chart-tools", "no tool calls");
paintEmpty("chart-wake", "no wakes");
paintEmpty("chart-result", "no results");
paintEmpty("chart-result-trend", "no results");
return;
}
renderTurnsChart(s);
@ -391,51 +476,63 @@ window.Chart = Chart;
renderCtxChart(s);
renderCostChart(s);
renderModelChart(s);
renderKeyCount('chart-tools', s.tool_breakdown, 'no tool calls');
renderKeyCount('chart-wake', s.wake_mix, 'no wakes');
renderKeyCount('chart-result', s.result_mix, 'no results');
renderKeyCount("chart-tools", s.tool_breakdown, "no tool calls");
renderKeyCount("chart-wake", s.wake_mix, "no wakes");
renderKeyCount("chart-result", s.result_mix, "no results");
renderResultTrendChart(s);
}
async function loadStats() {
try {
const resp = await fetch('api/stats?window=' + encodeURIComponent(currentWindow));
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch(
"api/stats?window=" + encodeURIComponent(currentWindow),
);
if (!resp.ok) throw new Error("http " + resp.status);
const snap = await resp.json();
render(snap);
} catch (e) {
document.getElementById('summary').textContent = 'stats fetch failed: ' + e;
document.getElementById("summary").textContent =
"stats fetch failed: " + e;
}
}
async function loadIdentity() {
try {
const resp = await fetch('api/state');
const resp = await fetch("api/state");
if (!resp.ok) return;
const s = await resp.json();
document.title = 'stats · ' + s.label;
document.getElementById('title').textContent = '◆ ' + s.label + ' ◆';
const dl = document.getElementById('dashboard-link');
document.title = "stats · " + s.label;
document.getElementById("title").textContent = "◆ " + s.label + " ◆";
const dl = document.getElementById("dashboard-link");
// When accessed via hive-gateway the page lives at `/agent/<name>/`
// on the same origin as the dashboard. Detect via path prefix
// rather than the direct port (which is unreachable or wrong scheme
// behind HTTPS TLS termination). The dashboard SPA lives at
// `dashboard.html` — the `/` root now serves the H0M3 menu hub.
dl.href = window.location.pathname.startsWith('/agent/')
? window.location.origin + '/dashboard.html'
: 'http://' + window.location.hostname + ':' + s.dashboard_port + '/dashboard.html';
} catch (_) { /* non-fatal */ }
dl.href = window.location.pathname.startsWith("/agent/")
? window.location.origin + "/dashboard.html"
: "http://" +
window.location.hostname +
":" +
s.dashboard_port +
"/dashboard.html";
} catch (_) {
/* non-fatal */
}
}
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener("DOMContentLoaded", () => {
loadIdentity();
// The shared hash-routed tab strip drives the stat-window selector,
// making it deep-linkable (#1h / #24h / …). onShow updates the
// window + reloads; the strip's initial show fires onShow once, so
// there's no separate loadStats() call here (avoids a double fetch).
createTabStrip(document.getElementById('window-tabs'), {
createTabStrip(document.getElementById("window-tabs"), {
defaultId: currentWindow,
onShow: (w) => { currentWindow = w; loadStats(); },
onShow: (w) => {
currentWindow = w;
loadStats();
},
});
});
})();

View file

@ -9,7 +9,11 @@ export interface AgentState {
label: string;
qualified_label: string;
dashboard_port: number;
status: 'online' | 'rate_limited' | 'needs_login_idle' | 'needs_login_in_progress';
status:
| "online"
| "rate_limited"
| "needs_login_idle"
| "needs_login_in_progress";
session: SessionView | null;
turn_state: string;
turn_state_since: number;
@ -38,7 +42,7 @@ export interface AgentLink {
url: string;
icon: string;
label: string;
kind: 'container' | 'forge' | 'external';
kind: "container" | "forge" | "external";
}
// Mirrors `hive_agent::web_ui::state::SessionView` — populated only
@ -72,7 +76,7 @@ export interface InboxRow {
// the only variant `GET /api/todos` actually returns (a dynamic,
// subsystem-pushed todo: matrix/bash/forge are the built-in producers).
export interface TodoRow {
kind: 'todo';
kind: "todo";
id: number;
subsystem: string;
subsystem_key?: string | null;

View file

@ -48,32 +48,41 @@
// prefix) so it never shadows the exact-match /dashboard/stream +
// /dashboard/history SSE routes registered before the ServeDir fallback.
import { build } from 'esbuild';
import { mkdirSync, copyFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from "esbuild";
import { mkdirSync, copyFileSync, rmSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const src = (p) => resolve(here, 'src', p);
const dist = (p) => resolve(here, 'dist', p);
const staticDir = (p) => resolve(here, 'dist', 'static', p);
const src = (p) => resolve(here, "src", p);
const dist = (p) => resolve(here, "dist", p);
const staticDir = (p) => resolve(here, "dist", "static", p);
rmSync(dist(''), { recursive: true, force: true });
mkdirSync(staticDir(''), { recursive: true });
rmSync(dist(""), { recursive: true, force: true });
mkdirSync(staticDir(""), { recursive: true });
// Bundle the JS entries. ES-module output, browser target, no minify
// (line-aligned source aids debugging; minification belongs in a later
// follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/<name>.js` based on the entryPoint basename.
await build({
entryPoints: [src('tabs.js'), src('flow.js'), src('logs.js'), src('home.js'), src('stats.js'), src('core.js'), src('builds.js'), src('credentials.js')],
outdir: staticDir(''),
entryPoints: [
src("tabs.js"),
src("flow.js"),
src("logs.js"),
src("home.js"),
src("stats.js"),
src("core.js"),
src("builds.js"),
src("credentials.js"),
],
outdir: staticDir(""),
bundle: true,
format: 'esm',
platform: 'browser',
target: ['es2022'],
format: "esm",
platform: "browser",
target: ["es2022"],
sourcemap: true,
logLevel: 'info',
logLevel: "info",
// `@hive/shared/modal.js` and `hive-btn.js` import their shadow-DOM
// component CSS (hive-dialog.css, hive-toast.css, hive-btn.css) as raw
// text via a plain `import css from './foo.css'` — the `text` loader turns
@ -82,15 +91,15 @@ await build({
// file any other way, so this doesn't collide with the separate
// page-stylesheet bundling below (`loader: { '.css': 'css' }`), which
// runs as its own esbuild invocation over different entry points.
loader: { '.css': 'text' },
loader: { ".css": "text" },
// `@hive/shared/jobq-graph.js` resolves to a real `.jsx` file
// (`JobqGraph.jsx`), pulled in transitively by `builds.js` — esbuild
// already picks the `jsx` loader for `.jsx` by extension, this just
// sets the transform mode to match swarm-ui's (which also authors
// this file). No other entry here uses JSX today; this doesn't turn
// any plain `.js` file into one, `.js` still parses as plain JS.
jsx: 'automatic',
jsxImportSource: 'preact',
jsx: "automatic",
jsxImportSource: "preact",
});
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker
@ -106,14 +115,14 @@ await build({
// the IIFE format will surface it as a build error rather than
// silently shipping broken code.
await build({
entryPoints: [src('stream-worker.js')],
outdir: staticDir(''),
entryPoints: [src("stream-worker.js")],
outdir: staticDir(""),
bundle: true,
format: 'iife',
platform: 'browser',
target: ['es2022'],
format: "iife",
platform: "browser",
target: ["es2022"],
sourcemap: true,
logLevel: 'info',
logLevel: "info",
});
// Bundle CSS — one entry per page. esbuild resolves @import including
@ -122,18 +131,39 @@ await build({
// so a swap replaces only it) + theme.css (the semantic derivation
// layer) + common.css (shared typography, badges, buttons, inbox, side
// panel) plus its own page-specific bundle.
for (const entry of ['colors.css', 'theme.css', 'common.css', 'dashboard.css', 'flow.css', 'logs.css', 'home.css', 'stats.css', 'core.css', 'builds.css', 'credentials.css']) {
for (const entry of [
"colors.css",
"theme.css",
"common.css",
"dashboard.css",
"flow.css",
"logs.css",
"home.css",
"stats.css",
"core.css",
"builds.css",
"credentials.css",
]) {
await build({
entryPoints: [src(entry)],
outfile: staticDir(entry),
bundle: true,
loader: { '.css': 'css' },
logLevel: 'info',
loader: { ".css": "css" },
logLevel: "info",
});
}
for (const html of ['index.html', 'dashboard.html', 'flow.html', 'logs.html', 'stats.html', 'core.html', 'builds.html', 'credentials.html']) {
for (const html of [
"index.html",
"dashboard.html",
"flow.html",
"logs.html",
"stats.html",
"core.html",
"builds.html",
"credentials.html",
]) {
copyFileSync(src(html), dist(html));
}
console.log('dashboard build ok →', dist(''));
console.log("dashboard build ok →", dist(""));

View file

@ -22,9 +22,9 @@
// pure positioning rule which is now generic and lives in `<hive-menu>`'s
// own shadow-scoped `.menu-dropdown`).
import { el } from '@hive/shared/dom.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect import
import { el } from "@hive/shared/dom.js";
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
import "@hive/shared/hive-menu.js"; // registers <hive-menu> — side-effect import
// Single-agent POST helper shared by all menu items. `flags` is an object
// of boolean query params to set truthy (e.g. `{ graceful: true }` or
@ -34,25 +34,32 @@ import '@hive/shared/hive-menu.js'; // registers <hive-menu> — side-effect imp
async function agentMenuPost(actionPath, name, body, flags) {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(flags || {})) {
if (v) params.set(k, 'true');
if (v) params.set(k, "true");
}
const qs = params.toString();
const url = actionPath + encodeURIComponent(name) + (qs ? '?' + qs : '');
const url = actionPath + encodeURIComponent(name) + (qs ? "?" + qs : "");
try {
const resp = await fetch(url, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {},
method: "POST",
headers: body
? { "Content-Type": "application/x-www-form-urlencoded" }
: {},
body: body ? new URLSearchParams(body) : undefined,
redirect: 'manual',
redirect: "manual",
});
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
const ok =
resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
const text = await resp.text().catch(() => "");
themedToast(
"action failed: " + resp.status + (text ? "\n\n" + text : ""),
{ type: "error" },
);
}
} catch (err) {
themedToast('action failed: ' + err, { type: 'error' });
themedToast("action failed: " + err, { type: "error" });
}
}
@ -67,40 +74,58 @@ class HiveAgentMenu extends HTMLElement {
const { c, forgeBase } = this._opts || {};
const btn = el('button', {
type: 'button',
class: 'agent-menu-btn',
title: `actions for ${c.name}`,
'aria-label': `actions for ${c.name}`,
'aria-haspopup': 'menu',
'aria-expanded': 'false',
}, '⋮');
const dropdown = el('ul', { class: 'agent-menu-dropdown', role: 'menu' });
const btn = el(
"button",
{
type: "button",
class: "agent-menu-btn",
title: `actions for ${c.name}`,
"aria-label": `actions for ${c.name}`,
"aria-haspopup": "menu",
"aria-expanded": "false",
},
"⋮",
);
const dropdown = el("ul", { class: "agent-menu-dropdown", role: "menu" });
const close = () => this._menu.close();
const menuItem = (label, opts) => {
const li = el('li', { role: 'presentation' });
const item = el('button', {
type: 'button',
class: 'agent-menu-item',
role: 'menuitem',
}, label);
item.addEventListener('click', async () => {
const li = el("li", { role: "presentation" });
const item = el(
"button",
{
type: "button",
class: "agent-menu-item",
role: "menuitem",
},
label,
);
item.addEventListener("click", async () => {
close();
let flags = {};
if (opts.confirm) {
const checkboxes = [];
if (opts.graceful) {
checkboxes.push({ name: 'graceful', label: opts.gracefulLabel || 'stop gracefully — let the agent finish its turn and flush state before the container stops' });
checkboxes.push({
name: "graceful",
label:
opts.gracefulLabel ||
"stop gracefully — let the agent finish its turn and flush state before the container stops",
});
}
if (opts.paused) {
checkboxes.push({ name: 'paused', label: opts.pausedLabel || 'start paused — come up without driving turns until resumed' });
checkboxes.push({
name: "paused",
label:
opts.pausedLabel ||
"start paused — come up without driving turns until resumed",
});
}
const r = await themedConfirm({
message: opts.confirm,
danger: true,
confirmLabel: opts.confirmLabel || 'confirm',
confirmLabel: opts.confirmLabel || "confirm",
checkboxes,
});
if (!r) return;
@ -112,18 +137,23 @@ class HiveAgentMenu extends HTMLElement {
return li;
};
const menuSep = () => el('li', { class: 'agent-menu-sep', role: 'separator' });
const menuSep = () =>
el("li", { class: "agent-menu-sep", role: "separator" });
// Navigation link item (opens in same tab by default).
const menuLink = (label, href, title) => {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href,
role: 'menuitem',
title: title || '',
}, label);
a.addEventListener('click', close);
const li = el("li", { role: "presentation" });
const a = el(
"a",
{
class: "agent-menu-item",
href,
role: "menuitem",
title: title || "",
},
label,
);
a.addEventListener("click", close);
li.append(a);
return li;
};
@ -131,21 +161,28 @@ class HiveAgentMenu extends HTMLElement {
// Show only actions that are applicable in the current state.
if (c.running) {
dropdown.append(
menuItem('↺ R3ST4RT', {
action: '/api/restart/',
menuItem("↺ R3ST4RT", {
action: "/api/restart/",
confirm: `restart ${c.name}?`,
graceful: true,
gracefulLabel: 'restart gracefully — let the agent finish its turn and flush state before the container restarts',
gracefulLabel:
"restart gracefully — let the agent finish its turn and flush state before the container restarts",
}),
menuItem("■ ST0P", {
action: "/api/kill/",
confirm: `stop ${c.name}?`,
confirmLabel: "■ stop",
graceful: true,
}),
menuItem('■ ST0P', { action: '/api/kill/', confirm: `stop ${c.name}?`, confirmLabel: '■ stop', graceful: true }),
);
} else {
dropdown.append(
menuItem('▶ ST4RT', {
action: '/api/start/',
menuItem("▶ ST4RT", {
action: "/api/start/",
confirm: `start ${c.name}?`,
paused: true,
pausedLabel: 'start paused — come up without driving turns until resumed',
pausedLabel:
"start paused — come up without driving turns until resumed",
}),
);
}
@ -153,55 +190,70 @@ class HiveAgentMenu extends HTMLElement {
// paused; a paused running agent keeps its container but drives no turns.
if (c.paused) {
dropdown.append(
menuItem('▶ R3SUM3', { action: '/api/resume/', confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.` }),
menuItem("▶ R3SUM3", {
action: "/api/resume/",
confirm: `resume ${c.name}? the turn loop restarts and drains queued messages.`,
}),
);
} else {
dropdown.append(
menuItem('⏸ P4US3', { action: '/api/pause/', confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.` }),
menuItem("⏸ P4US3", {
action: "/api/pause/",
confirm: `pause ${c.name}? parks the turn loop — inbox messages queue unacked.`,
}),
);
}
dropdown.append(
menuSep(),
menuItem('↻ R3BU1LD', { action: '/api/rebuild/', confirm: `rebuild ${c.name}? hot-reloads the container.` }),
menuItem("↻ R3BU1LD", {
action: "/api/rebuild/",
confirm: `rebuild ${c.name}? hot-reloads the container.`,
}),
menuSep(),
// Deep-link to the AGENT log tab pre-filtered to this container.
// The ?agent= param is read by logs.js on load and pre-selects this
// agent's journal without extra clicks.
menuLink('journal logs →',
menuLink(
"journal logs →",
`/logs.html?agent=${encodeURIComponent(c.name)}#agent`,
`view ${c.name} journal logs`),
`view ${c.name} journal logs`,
),
);
dropdown.append(
menuSep(),
menuItem('DESTR0Y', {
action: '/api/destroy/',
menuItem("DESTR0Y", {
action: "/api/destroy/",
confirm: `destroy ${c.name}? container removed; state + creds kept.`,
}),
menuItem('PURG3', {
action: '/api/destroy/',
body: { purge: 'on' },
menuItem("PURG3", {
action: "/api/destroy/",
body: { purge: "on" },
confirm: `PURGE ${c.name}? WIPES container, config history, claude creds, and notes. no undo.`,
}),
);
if (c.deployed_sha && forgeBase) {
const li = el('li', { role: 'presentation' });
const a = el('a', {
class: 'agent-menu-item',
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
target: '_blank',
rel: 'noopener',
role: 'menuitem',
title: 'deployed config commit on forge',
}, `deployed:${c.deployed_sha}`);
const li = el("li", { role: "presentation" });
const a = el(
"a",
{
class: "agent-menu-item",
href: `${forgeBase}/agent-configs/${encodeURIComponent(c.name)}/commit/${c.deployed_sha}`,
target: "_blank",
rel: "noopener",
role: "menuitem",
title: "deployed config commit on forge",
},
`deployed:${c.deployed_sha}`,
);
li.append(a);
dropdown.append(menuSep(), li);
}
this._menu = document.createElement('hive-menu');
this._menu = document.createElement("hive-menu");
this._menu._opts = { trigger: btn, content: dropdown };
this.append(this._menu);
}
}
customElements.define('hive-agent-menu', HiveAgentMenu);
customElements.define("hive-agent-menu", HiveAgentMenu);

View file

@ -24,4 +24,6 @@ body.builds-shell {
padding: 1.2em 1.5em 2em;
}
.builds-pane[hidden] { display: none; }
.builds-pane[hidden] {
display: none;
}

View file

@ -1,73 +1,103 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // BU1LDS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/colors.css">
<link rel="stylesheet" href="/static/theme.css">
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/builds.css">
</head>
<body class="builds-shell">
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // BU1LDS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/builds.css" />
</head>
<body class="builds-shell">
<!-- BU1LDS: the build lifecycle hub — rebuild queue, live build log,
meta inputs, and build log history on one page. Carved out of the
old /core.html (rebuild queue + meta inputs) and /logs.html (BUILD
tab) so the full build lifecycle is in one place. Same minimal
chrome as /core.html — a `← home` back-link + a <hive-tab-strip>
sub-tab nav. Default sub-tab is the rebuild queue. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar builds-tabbar" id="builds-tabbar" prefix="builds"
role="tablist"></hive-tab-strip>
</header>
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip
class="hive-tabbar builds-tabbar"
id="builds-tabbar"
prefix="builds"
role="tablist"
></hive-tab-strip>
</header>
<main class="builds-main">
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
<main class="builds-main">
<!-- R3BU1LD QU3U3: pending + running rebuilds, meta-updates, and
first-spawns. Rendered from GET /api/jobq/graph by the
`JobqGraph` Preact component (@hive/shared/jobq-graph.js);
`rebuild_queue_changed` over /api/dashboard/stream is the
refresh trigger and carries no payload of its own. Default tab. -->
<section class="builds-pane" id="builds-pane-rebuild" data-tab-pane="rebuild"
role="tabpanel" aria-labelledby="builds-tab-rebuild">
<p class="meta">pending + running rebuilds, meta-updates, and first-spawns. one runs at a time; meta-update cascades nest under their parent. dedup: re-enqueueing a still-queued op collapses into the existing entry.</p>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<!-- Live build log of the currently-running rebuild (one runs at a
<section
class="builds-pane"
id="builds-pane-rebuild"
data-tab-pane="rebuild"
role="tabpanel"
aria-labelledby="builds-tab-rebuild"
>
<p class="meta">
pending + running rebuilds, meta-updates, and first-spawns. one runs
at a time; meta-update cascades nest under their parent. dedup:
re-enqueueing a still-queued op collapses into the existing entry.
</p>
<div id="rebuild-queue-section">
<p class="meta">loading…</p>
</div>
<!-- Live build log of the currently-running rebuild (one runs at a
time). Managed by renderRebuildLiveLog in builds.js, separate from
rebuild-queue-section so the queue's row re-render never disturbs
the open SSE stream. -->
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
</section>
<div id="rebuild-live-log" class="rebuild-live-log" hidden></div>
</section>
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<section class="builds-pane" id="builds-pane-meta" data-tab-pane="meta"
role="tabpanel" aria-labelledby="builds-tab-meta">
<p class="meta">select inputs to <code>nix flake update</code> in <code>/meta/</code>. selected agents rebuild in sequence after the lock bump; the submitting agent learns each outcome via the usual <code>rebuilt</code> system event.</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- M3T4 1NPUTS: select inputs to nix flake update in /meta/. -->
<section
class="builds-pane"
id="builds-pane-meta"
data-tab-pane="meta"
role="tabpanel"
aria-labelledby="builds-tab-meta"
>
<p class="meta">
select inputs to <code>nix flake update</code> in <code>/meta/</code>.
selected agents rebuild in sequence after the lock bump; the
submitting agent learns each outcome via the usual
<code>rebuilt</code> system event.
</p>
<div id="meta-inputs-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- BUILD L0GS: all-agents build log history (moved from /logs.html).
<!-- BUILD L0GS: all-agents build log history (moved from /logs.html).
Click a row to expand stdout + stderr. Live builds stream in real
time. Lazy-loaded on first tab activation; auto-refreshes when
rebuild_queue_changed fires. Deep-link: ?id=N#buildlogs. -->
<section class="builds-pane" id="builds-pane-buildlogs" data-tab-pane="buildlogs"
role="tabpanel" aria-labelledby="builds-tab-buildlogs">
<p class="meta">all-agents build log history. click a row to expand stdout + stderr. live builds stream in real time.</p>
<div class="logs-toolbar">
<button type="button" class="btn btn-restart" id="build-refresh">↻ refresh</button>
</div>
<div id="build-list"><p class="meta">loading…</p></div>
</section>
<section
class="builds-pane"
id="builds-pane-buildlogs"
data-tab-pane="buildlogs"
role="tabpanel"
aria-labelledby="builds-tab-buildlogs"
>
<p class="meta">
all-agents build log history. click a row to expand stdout + stderr.
live builds stream in real time.
</p>
<div class="logs-toolbar">
<button type="button" class="btn btn-restart" id="build-refresh">
↻ refresh
</button>
</div>
<div id="build-list"><p class="meta">loading…</p></div>
</section>
</main>
</main>
<script type="module" src="/static/builds.js" defer></script>
</body>
<script type="module" src="/static/builds.js" defer></script>
</body>
</html>

View file

@ -10,14 +10,20 @@
// renderers here are direct copies from core.js / logs.js with only the
// deep-link URL and count-pill id adjusted.
import { $, fmtAgeSecs, openStream, openBuildLogStream, initServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import { bindAsyncForms } from '@hive/shared/forms.js';
import { themedConfirm } from '@hive/shared/modal.js';
import { h, render } from 'preact';
import { fmtAgo, fmtDuration, truncate } from './util.js';
import '@hive/shared/hive-tab-strip.js';
import { JobqGraph } from '@hive/shared/jobq-graph.js';
import {
$,
fmtAgeSecs,
openStream,
openBuildLogStream,
initServerWarnings,
} from "./common.js";
import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
import { themedConfirm } from "@hive/shared/modal.js";
import { h, render } from "preact";
import { fmtAgo, fmtDuration, truncate } from "./util.js";
import "@hive/shared/hive-tab-strip.js";
import { JobqGraph } from "@hive/shared/jobq-graph.js";
// ─── derived state ───────────────────────────────────────────────────────────
let metaInputsState = [];
@ -36,93 +42,122 @@ function syncFromSnapshot(s) {
// ─── meta inputs ─────────────────────────────────────────────────────────────
function renderMetaInputs(s) {
const root = $('meta-inputs-section');
const root = $("meta-inputs-section");
if (!root) return;
// Snapshot ticked checkboxes before wiping so a concurrent
// MetaInputsChanged doesn't silently clear a pending selection.
const checkedInputs = new Set(
Array.from(root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked'))
.map((cb) => cb.dataset.metaInput),
Array.from(
root.querySelectorAll('input[type="checkbox"][data-meta-input]:checked'),
).map((cb) => cb.dataset.metaInput),
);
root.replaceChildren();
const inputs = s.meta_inputs || [];
if (!inputs.length) {
root.append(el('p', { class: 'empty' }, 'meta repo not seeded yet'));
root.append(el("p", { class: "empty" }, "meta repo not seeded yet"));
return;
}
if (metaUpdateRunning) {
root.append(el('p', { class: 'meta-update-running' },
'⏳ meta-update running — flake lock bump + affected agents rebuilding. '
+ 'watch the agent cards for per-rebuild progress.'));
root.append(
el(
"p",
{ class: "meta-update-running" },
"⏳ meta-update running — flake lock bump + affected agents rebuilding. " +
"watch the agent cards for per-rebuild progress.",
),
);
}
const f = el('form', {
method: 'POST',
action: '/api/meta-update',
class: 'meta-inputs-form',
'data-async': '',
'data-no-refresh': '',
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
const f = el("form", {
method: "POST",
action: "/api/meta-update",
class: "meta-inputs-form",
"data-async": "",
"data-no-refresh": "",
"data-confirm":
"update selected meta flake inputs + rebuild affected agents?",
});
const bulk = el('div', { class: 'meta-inputs-bulk' });
const selAll = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select all');
const selNone = el('button', { type: 'button', class: 'meta-bulk-btn' }, 'select none');
bulk.append('bulk: ', selAll, ' ', selNone);
const bulk = el("div", { class: "meta-inputs-bulk" });
const selAll = el(
"button",
{ type: "button", class: "meta-bulk-btn" },
"select all",
);
const selNone = el(
"button",
{ type: "button", class: "meta-bulk-btn" },
"select none",
);
bulk.append("bulk: ", selAll, " ", selNone);
f.append(bulk);
const ul = el('ul', { class: 'meta-inputs' });
const ul = el("ul", { class: "meta-inputs" });
for (const inp of inputs) {
const depth = (inp.name.match(/\//g) || []).length;
const leaf = inp.name.slice(inp.name.lastIndexOf('/') + 1);
const li = el('li');
if (depth > 0) li.style.marginLeft = (depth * 1.3) + 'em';
const id = 'meta-input-' + inp.name.replace(/[^a-z0-9-]/gi, '_');
const cb = el('input', {
type: 'checkbox',
name: 'meta_input_' + inp.name,
const leaf = inp.name.slice(inp.name.lastIndexOf("/") + 1);
const li = el("li");
if (depth > 0) li.style.marginLeft = depth * 1.3 + "em";
const id = "meta-input-" + inp.name.replace(/[^a-z0-9-]/gi, "_");
const cb = el("input", {
type: "checkbox",
name: "meta_input_" + inp.name,
id,
value: inp.name,
'data-meta-input': inp.name,
"data-meta-input": inp.name,
});
if (checkedInputs.has(inp.name)) cb.checked = true;
const label = el('label', { for: id, title: inp.name });
const label = el("label", { for: id, title: inp.name });
label.append(cb);
if (depth > 0) label.append(el('span', { class: 'meta-input-twig' }, '└ '));
if (depth > 0) label.append(el("span", { class: "meta-input-twig" }, "└ "));
label.append(
el('span', { class: 'meta-input-name' }, leaf), ' ',
el('code', { class: 'meta-input-rev' }, inp.rev.slice(0, 12)), ' ',
el('span', { class: 'meta-input-ts' }, fmtAgo(inp.last_modified)),
el("span", { class: "meta-input-name" }, leaf),
" ",
el("code", { class: "meta-input-rev" }, inp.rev.slice(0, 12)),
" ",
el("span", { class: "meta-input-ts" }, fmtAgo(inp.last_modified)),
);
if (inp.url) {
label.append(' ', el('span', { class: 'meta-input-url', title: inp.url },
'· ' + truncate(inp.url, 48)));
label.append(
" ",
el(
"span",
{ class: "meta-input-url", title: inp.url },
"· " + truncate(inp.url, 48),
),
);
}
li.append(label);
ul.append(li);
}
f.append(ul);
const hidden = el('input', { type: 'hidden', name: 'inputs', value: '' });
const hidden = el("input", { type: "hidden", name: "inputs", value: "" });
f.append(hidden);
const btn = el('button', {
type: 'submit',
class: 'btn btn-meta-update',
disabled: '',
}, metaUpdateRunning ? '⏳ UPD4T1NG…' : '◆ UPD4TE & R3BU1LD');
const btn = el(
"button",
{
type: "submit",
class: "btn btn-meta-update",
disabled: "",
},
metaUpdateRunning ? "⏳ UPD4T1NG…" : "◆ UPD4TE & R3BU1LD",
);
f.append(btn);
function refreshDisabled() {
const any = f.querySelectorAll('input[data-meta-input]:checked').length > 0;
if (any && !metaUpdateRunning) btn.removeAttribute('disabled');
else btn.setAttribute('disabled', '');
const any = f.querySelectorAll("input[data-meta-input]:checked").length > 0;
if (any && !metaUpdateRunning) btn.removeAttribute("disabled");
else btn.setAttribute("disabled", "");
}
f.addEventListener('change', refreshDisabled);
f.addEventListener("change", refreshDisabled);
function setAllChecked(val) {
for (const b of f.querySelectorAll('input[data-meta-input]')) b.checked = val;
for (const b of f.querySelectorAll("input[data-meta-input]"))
b.checked = val;
refreshDisabled();
}
selAll.addEventListener('click', () => setAllChecked(true));
selNone.addEventListener('click', () => setAllChecked(false));
f.addEventListener('submit', () => {
const selected = Array.from(f.querySelectorAll('input[data-meta-input]:checked'))
.map((b) => b.dataset.metaInput);
hidden.value = selected.join(',');
selAll.addEventListener("click", () => setAllChecked(true));
selNone.addEventListener("click", () => setAllChecked(false));
f.addEventListener("submit", () => {
const selected = Array.from(
f.querySelectorAll("input[data-meta-input]:checked"),
).map((b) => b.dataset.metaInput);
hidden.value = selected.join(",");
});
root.append(f);
}
@ -150,41 +185,50 @@ function renderMetaInputs(s) {
// tracked children out from under its diffing rather than let it
// update them minimally).
function mountRebuildQueue() {
const root = $('rebuild-queue-section');
const root = $("rebuild-queue-section");
if (!root) return;
root.replaceChildren();
renderRebuildQueue();
}
function renderRebuildQueue() {
const root = $('rebuild-queue-section');
const root = $("rebuild-queue-section");
if (!root) return;
render(h(JobqGraph, {
endpoint: '/api/jobq/graph',
cancellable: true,
refreshToken: jobqGraphToken,
onUpdate: (nodes) => {
jobqNodes = nodes || [];
renderRebuildLiveLog();
updateRebuildCount();
},
onCancel: async (id) => {
const node = jobqNodes.find((n) => n.id === id);
const label = node ? node.payload.label : 'node ' + id;
if (!(await themedConfirm({
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
danger: true, confirmLabel: '✕ cancel',
}))) return;
try {
const r = await fetch('/api/rebuild-queue/' + id + '/cancel', { method: 'POST' });
if (!r.ok) throw new Error('http ' + r.status);
// No manual refresh: cancel flips node state, which fires
// rebuild_queue_changed over SSE — the existing handler below
// already bumps jobqGraphToken and re-renders on that tick.
} catch (err) {
console.error('cancel failed', err);
}
},
}), root);
render(
h(JobqGraph, {
endpoint: "/api/jobq/graph",
cancellable: true,
refreshToken: jobqGraphToken,
onUpdate: (nodes) => {
jobqNodes = nodes || [];
renderRebuildLiveLog();
updateRebuildCount();
},
onCancel: async (id) => {
const node = jobqNodes.find((n) => n.id === id);
const label = node ? node.payload.label : "node " + id;
if (
!(await themedConfirm({
message: `cancel ${label}? a group root cancels the whole subtree; a mid-tree node cancels just that branch.`,
danger: true,
confirmLabel: "✕ cancel",
}))
)
return;
try {
const r = await fetch("/api/rebuild-queue/" + id + "/cancel", {
method: "POST",
});
if (!r.ok) throw new Error("http " + r.status);
// No manual refresh: cancel flips node state, which fires
// rebuild_queue_changed over SSE — the existing handler below
// already bumps jobqGraphToken and re-renders on that tick.
} catch (err) {
console.error("cancel failed", err);
}
},
}),
root,
);
}
// ─── running-rebuild live log ─────────────────────────────────────────────────
@ -196,13 +240,16 @@ function renderRebuildQueue() {
// {stdout, stderr}) rather than SSE — a running node's log re-fetches on the
// rebuild_queue_changed tick; a terminal node's log is static (one last fetch
// on transition, then done).
let liveLogId = null; // current node id being shown
let liveLogDone = false; // true once the node left 'Running'
let liveLogId = null; // current node id being shown
let liveLogDone = false; // true once the node left 'Running'
let liveLogCollapsed = false;
let liveLogPollTimer = null;
function clearLiveLogPoll() {
if (liveLogPollTimer) { clearInterval(liveLogPollTimer); liveLogPollTimer = null; }
if (liveLogPollTimer) {
clearInterval(liveLogPollTimer);
liveLogPollTimer = null;
}
}
// First running node with a log, in wire order (root-then-subtree per
@ -210,26 +257,39 @@ function clearLiveLogPoll() {
// Gate on build_log_id so lock/noop/store-only nodes don't open a blank
// panel.
function findLiveBuild() {
return jobqNodes.find((n) => n.state === 'Running' && n.payload.data && n.payload.data.build_log_id != null)
|| null;
return (
jobqNodes.find(
(n) =>
n.state === "Running" &&
n.payload.data &&
n.payload.data.build_log_id != null,
) || null
);
}
async function fetchAndRenderLiveLog(nodeId, pre) {
try {
const r = await fetch('/api/build-log/' + nodeId);
const r = await fetch("/api/build-log/" + nodeId);
if (!r.ok) return;
const data = await r.json();
const text = [data.stdout, data.stderr ? '--- stderr ---\n' + data.stderr : ''].filter(Boolean).join('\n');
const text = [
data.stdout,
data.stderr ? "--- stderr ---\n" + data.stderr : "",
]
.filter(Boolean)
.join("\n");
if (pre.textContent !== text) {
const atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
pre.textContent = text;
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
} catch { /* network blip — ignore, next poll will retry */ }
} catch {
/* network blip — ignore, next poll will retry */
}
}
function renderRebuildLiveLog() {
const root = $('rebuild-live-log');
const root = $("rebuild-live-log");
if (!root) return;
const liveNode = findLiveBuild();
@ -237,22 +297,26 @@ function renderRebuildLiveLog() {
clearLiveLogPoll();
liveLogId = null;
liveLogDone = false;
if (!root.hidden) { root.hidden = true; root.replaceChildren(); }
if (!root.hidden) {
root.hidden = true;
root.replaceChildren();
}
return;
}
// Same node, already polling — just let the timer tick (or do a final
// fetch if the node just went non-running and we haven't marked done yet).
if (liveNode.id === liveLogId) {
if (!liveLogDone && liveNode.state !== 'Running') {
if (!liveLogDone && liveNode.state !== "Running") {
clearLiveLogPoll();
liveLogDone = true;
const pre = root.querySelector('.rebuild-live-log-output');
const badge = root.querySelector('.rebuild-live-log-badge');
const pre = root.querySelector(".rebuild-live-log-output");
const badge = root.querySelector(".rebuild-live-log-badge");
if (pre) fetchAndRenderLiveLog(liveNode.id, pre);
if (badge) {
const ok = liveNode.state !== 'Failed';
badge.className = 'rebuild-live-log-badge ' + (ok ? 'rll-ok' : 'rll-fail');
const ok = liveNode.state !== "Failed";
badge.className =
"rebuild-live-log-badge " + (ok ? "rll-ok" : "rll-fail");
badge.textContent = liveNode.state;
}
}
@ -266,54 +330,84 @@ function renderRebuildLiveLog() {
root.hidden = false;
root.replaceChildren();
const pre = el('pre', { class: 'rebuild-live-log-output' }, '');
const pre = el("pre", { class: "rebuild-live-log-output" }, "");
pre.hidden = liveLogCollapsed;
const badge = el('span', { class: 'rebuild-live-log-badge rll-running' }, 'live');
const toggle = el('button', {
type: 'button',
class: 'rebuild-live-log-toggle',
'aria-expanded': String(!liveLogCollapsed),
title: liveLogCollapsed ? 'expand live log' : 'collapse live log',
}, liveLogCollapsed ? '▸' : '▾');
toggle.addEventListener('click', () => {
const badge = el(
"span",
{ class: "rebuild-live-log-badge rll-running" },
"live",
);
const toggle = el(
"button",
{
type: "button",
class: "rebuild-live-log-toggle",
"aria-expanded": String(!liveLogCollapsed),
title: liveLogCollapsed ? "expand live log" : "collapse live log",
},
liveLogCollapsed ? "▸" : "▾",
);
toggle.addEventListener("click", () => {
liveLogCollapsed = !liveLogCollapsed;
pre.hidden = liveLogCollapsed;
toggle.textContent = liveLogCollapsed ? '▸' : '▾';
toggle.setAttribute('aria-expanded', String(!liveLogCollapsed));
toggle.title = liveLogCollapsed ? 'expand live log' : 'collapse live log';
toggle.textContent = liveLogCollapsed ? "▸" : "▾";
toggle.setAttribute("aria-expanded", String(!liveLogCollapsed));
toggle.title = liveLogCollapsed ? "expand live log" : "collapse live log";
});
const liveAgent = liveNode.payload.data && liveNode.payload.data.agent;
const header = el('div', { class: 'rebuild-live-log-header' },
toggle, ' ',
el('span', { class: 'rebuild-live-log-title' }, 'live build log — '),
const header = el(
"div",
{ class: "rebuild-live-log-header" },
toggle,
" ",
el("span", { class: "rebuild-live-log-title" }, "live build log — "),
// Label the specific node's agent, not the whole group's agent set.
el('code', { class: 'rqe-agent' }, liveAgent || ''),
' ', el('span', { class: 'rqe-kind' }, liveNode.payload.label),
' ', badge, ' ',
el('a', {
class: 'rebuild-live-log-raw',
href: '/api/build-log/' + liveNode.id + '/raw',
download: 'build-log-' + liveNode.id + '.txt',
}, '↓ raw'),
el("code", { class: "rqe-agent" }, liveAgent || ""),
" ",
el("span", { class: "rqe-kind" }, liveNode.payload.label),
" ",
badge,
" ",
el(
"a",
{
class: "rebuild-live-log-raw",
href: "/api/build-log/" + liveNode.id + "/raw",
download: "build-log-" + liveNode.id + ".txt",
},
"↓ raw",
),
);
root.append(header, pre);
// Start polling.
fetchAndRenderLiveLog(liveNode.id, pre);
liveLogPollTimer = setInterval(() => fetchAndRenderLiveLog(liveNode.id, pre), 2000);
liveLogPollTimer = setInterval(
() => fetchAndRenderLiveLog(liveNode.id, pre),
2000,
);
}
// ─── rebuild-queue count pill ─────────────────────────────────────────────────
function updateRebuildCount() {
const pill = $('builds-tab-count-rebuild');
const pill = $("builds-tab-count-rebuild");
if (!pill) return;
// Pending/Running/Finishing = in flight (Finishing = own work done, a
// sub-node still running — still counts). Root nodes only: each is one
// queue entry.
const n = jobqNodes.filter((n) => n.parent == null
&& (n.state === 'Pending' || n.state === 'Running' || n.state === 'Finishing')).length;
if (n > 0) { pill.textContent = String(n); pill.hidden = false; }
else { pill.hidden = true; }
const n = jobqNodes.filter(
(n) =>
n.parent == null &&
(n.state === "Pending" ||
n.state === "Running" ||
n.state === "Finishing"),
).length;
if (n > 0) {
pill.textContent = String(n);
pill.hidden = false;
} else {
pill.hidden = true;
}
}
// ─── render-all (cold load + any full re-render) ──────────────────────────────
@ -330,43 +424,63 @@ function renderAll() {
// 2s) when rebuild_queue_changed fires while the BUILD L0GS tab is active.
// Deep-link: ?id=N#buildlogs auto-expands the target row.
const buildList = $('build-list');
const buildRefresh = $('build-refresh');
const buildList = $("build-list");
const buildRefresh = $("build-refresh");
let buildLogsLoaded = false;
let buildTabs; // set in init
function fmtTs(unixSecs) {
if (!unixSecs) return '';
if (!unixSecs) return "";
const age = Math.floor(Date.now() / 1000) - unixSecs;
return fmtAgeSecs(Math.max(0, age)) + ' ago';
return fmtAgeSecs(Math.max(0, age)) + " ago";
}
async function fetchBuild() {
if (!buildList) return;
buildList.replaceChildren();
buildList.append(el('p', { class: 'meta' }, 'loading…'));
buildList.append(el("p", { class: "meta" }, "loading…"));
try {
const resp = await fetch('/api/build-logs?limit=30');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/build-logs?limit=30");
if (!resp.ok) throw new Error("http " + resp.status);
const rows = await resp.json();
buildList.replaceChildren();
if (!rows || rows.length === 0) {
buildList.append(el('p', { class: 'meta' }, '(no build logs yet)'));
buildList.append(el("p", { class: "meta" }, "(no build logs yet)"));
return;
}
const ul = el('ul', { class: 'build-logs-list' });
const ul = el("ul", { class: "build-logs-list" });
for (const h of rows) {
const li = el('li', { class: 'build-logs-item', 'data-log-id': String(h.id) });
const li = el("li", {
class: "build-logs-item",
"data-log-id": String(h.id),
});
const live = !h.status;
const ok = h.status === 'ok';
const statusClass = live ? 'hive-pill-sm badge-running' : ok ? 'hive-pill-sm badge-ok' : 'hive-pill-sm badge-fail';
const statusLabel = live ? 'live' : ok ? 'ok' : 'fail';
const age = h.finished_at ? fmtTs(h.finished_at) : (live ? '' : fmtTs(h.started_at));
const runtime = h.runtime_secs != null
? el('span', { class: 'build-logs-runtime meta' }, fmtDuration(Math.max(0, h.runtime_secs)))
const ok = h.status === "ok";
const statusClass = live
? "hive-pill-sm badge-running"
: ok
? "hive-pill-sm badge-ok"
: "hive-pill-sm badge-fail";
const statusLabel = live ? "live" : ok ? "ok" : "fail";
const age = h.finished_at
? fmtTs(h.finished_at)
: live
? el('span', { class: 'build-logs-runtime meta build-logs-live-dur' }, '…')
: el('span', { class: 'build-logs-runtime meta' }, '');
? ""
: fmtTs(h.started_at);
const runtime =
h.runtime_secs != null
? el(
"span",
{ class: "build-logs-runtime meta" },
fmtDuration(Math.max(0, h.runtime_secs)),
)
: live
? el(
"span",
{ class: "build-logs-runtime meta build-logs-live-dur" },
"…",
)
: el("span", { class: "build-logs-runtime meta" }, "");
let durTimer = null;
if (live && h.started_at) {
@ -378,78 +492,106 @@ async function fetchBuild() {
durTimer = setInterval(updateDur, 1000);
}
const rowBtn = el('button', {
type: 'button',
class: 'build-logs-row-btn',
'aria-expanded': 'false',
},
el('span', { class: statusClass }, statusLabel),
el('span', { class: 'build-logs-agent' }, h.agent),
const rowBtn = el(
"button",
{
type: "button",
class: "build-logs-row-btn",
"aria-expanded": "false",
},
el("span", { class: statusClass }, statusLabel),
el("span", { class: "build-logs-agent" }, h.agent),
runtime,
el('span', { class: 'build-logs-kind' }, h.kind),
el('span', { class: 'build-logs-age meta' }, age),
el('span', { class: 'build-logs-cmdline meta' }, h.cmdline),
el("span", { class: "build-logs-kind" }, h.kind),
el("span", { class: "build-logs-age meta" }, age),
el("span", { class: "build-logs-cmdline meta" }, h.cmdline),
);
const detail = el('div', { class: 'build-logs-detail' });
const detail = el("div", { class: "build-logs-detail" });
detail.hidden = true;
let streamEs = null;
rowBtn.addEventListener('click', async () => {
const expanded = rowBtn.getAttribute('aria-expanded') === 'true';
rowBtn.setAttribute('aria-expanded', String(!expanded));
rowBtn.addEventListener("click", async () => {
const expanded = rowBtn.getAttribute("aria-expanded") === "true";
rowBtn.setAttribute("aria-expanded", String(!expanded));
detail.hidden = expanded;
if (expanded) {
if (streamEs) { streamEs.close(); streamEs = null; }
if (streamEs) {
streamEs.close();
streamEs = null;
}
return;
}
if (detail.dataset.loaded) return;
detail.replaceChildren();
detail.append(
el('a', {
href: '/api/build-logs/id/' + h.id + '/raw',
download: 'build-log-' + h.id + '.txt',
class: 'build-logs-dl',
}, '↓ download raw'),
el(
"a",
{
href: "/api/build-logs/id/" + h.id + "/raw",
download: "build-log-" + h.id + ".txt",
class: "build-logs-dl",
},
"↓ download raw",
),
);
if (live) {
const pre = el('pre', { class: 'build-logs-output build-logs-live' }, '');
const badge = el('span', { class: 'build-logs-live-badge hive-pill-sm badge-running' }, 'live');
const pre = el(
"pre",
{ class: "build-logs-output build-logs-live" },
"",
);
const badge = el(
"span",
{ class: "build-logs-live-badge hive-pill-sm badge-running" },
"live",
);
detail.append(badge, pre);
streamEs = openBuildLogStream(h.id, pre, {
onDone: (status) => {
if (durTimer) { clearInterval(durTimer); durTimer = null; }
if (durTimer) {
clearInterval(durTimer);
durTimer = null;
}
if (h.started_at) {
const elapsed = Math.floor(Date.now() / 1000) - h.started_at;
runtime.textContent = fmtDuration(Math.max(0, elapsed));
}
badge.className = status === 'ok' ? 'hive-pill-sm badge-ok' : 'hive-pill-sm badge-fail';
badge.className =
status === "ok"
? "hive-pill-sm badge-ok"
: "hive-pill-sm badge-fail";
badge.textContent = status;
streamEs = null;
detail.dataset.loaded = '1';
detail.dataset.loaded = "1";
},
onError: () => {
if (durTimer) { clearInterval(durTimer); durTimer = null; }
badge.textContent = 'stream error';
badge.className = 'hive-pill-sm badge-fail';
if (durTimer) {
clearInterval(durTimer);
durTimer = null;
}
badge.textContent = "stream error";
badge.className = "hive-pill-sm badge-fail";
streamEs = null;
},
});
} else {
const pre = el('pre', { class: 'build-logs-output' }, 'fetching…');
const pre = el("pre", { class: "build-logs-output" }, "fetching…");
detail.append(pre);
try {
const r2 = await fetch('/api/build-logs/id/' + h.id);
const r2 = await fetch("/api/build-logs/id/" + h.id);
if (!r2.ok) {
pre.textContent = 'error ' + r2.status;
pre.textContent = "error " + r2.status;
} else {
const full = await r2.json();
const out = [full.stdout, full.stderr].filter(Boolean).join('\n--- stderr ---\n');
pre.textContent = out || '(empty)';
const out = [full.stdout, full.stderr]
.filter(Boolean)
.join("\n--- stderr ---\n");
pre.textContent = out || "(empty)";
}
detail.dataset.loaded = '1';
detail.dataset.loaded = "1";
} catch (err) {
pre.textContent = 'fetch failed: ' + err;
pre.textContent = "fetch failed: " + err;
}
}
});
@ -458,24 +600,24 @@ async function fetchBuild() {
}
buildList.append(ul);
// Deep-link: ?id=N auto-expands the target row.
const deepId = new URLSearchParams(location.search).get('id');
const deepId = new URLSearchParams(location.search).get("id");
if (deepId) {
const target = ul.querySelector('[data-log-id="' + deepId + '"]');
if (target) {
const btn = target.querySelector('.build-logs-row-btn');
const btn = target.querySelector(".build-logs-row-btn");
if (btn) {
btn.click();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
}
} catch (err) {
buildList.replaceChildren();
buildList.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
buildList.append(el("p", { class: "meta" }, "fetch failed: " + err));
}
}
if (buildRefresh) buildRefresh.addEventListener('click', fetchBuild);
if (buildRefresh) buildRefresh.addEventListener("click", fetchBuild);
// ─── SSE handlers ─────────────────────────────────────────────────────────────
let buildRefreshTimer = null;
@ -488,7 +630,7 @@ const SSE_HANDLERS = {
jobqGraphToken += 1;
renderRebuildQueue();
// Auto-refresh build log list when the queue changes and BUILD L0GS is active.
if (buildTabs && buildTabs.active() === 'buildlogs') {
if (buildTabs && buildTabs.active() === "buildlogs") {
if (buildRefreshTimer) clearTimeout(buildRefreshTimer);
buildRefreshTimer = setTimeout(fetchBuild, 2000);
}
@ -506,7 +648,7 @@ const SSE_HANDLERS = {
// ─── boot ─────────────────────────────────────────────────────────────────────
async function refreshState() {
try {
const resp = await fetch('/api/state');
const resp = await fetch("/api/state");
if (resp.ok) syncFromSnapshot(await resp.json());
} catch {
// best-effort
@ -521,15 +663,19 @@ async function init() {
// fetch needed here, unlike meta inputs below.
mountRebuildQueue();
buildTabs = document.getElementById('builds-tabbar').configure({
buildTabs = document.getElementById("builds-tabbar").configure({
tabs: [
{ id: 'rebuild', label: 'R3BU1LD QU3U3', badgeId: 'builds-tab-count-rebuild' },
{ id: 'meta', label: 'M3T4 1NPUTS' },
{ id: 'buildlogs', label: 'BUILD L0GS' },
{
id: "rebuild",
label: "R3BU1LD QU3U3",
badgeId: "builds-tab-count-rebuild",
},
{ id: "meta", label: "M3T4 1NPUTS" },
{ id: "buildlogs", label: "BUILD L0GS" },
],
defaultId: 'rebuild',
defaultId: "rebuild",
onShow: (id) => {
if (id === 'buildlogs' && !buildLogsLoaded) {
if (id === "buildlogs" && !buildLogsLoaded) {
buildLogsLoaded = true;
fetchBuild();
}
@ -544,12 +690,16 @@ async function init() {
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).
const es = openStream(
'/api/dashboard/stream?kinds=rebuild_queue_changed,meta_inputs_changed,meta_update_running',
"/api/dashboard/stream?kinds=rebuild_queue_changed,meta_inputs_changed,meta_update_running",
);
if (es) {
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
try {
ev = JSON.parse(e.data);
} catch {
return;
}
const h = SSE_HANDLERS[ev.kind];
if (h) h(ev);
};

View file

@ -10,16 +10,17 @@
// live-mutation paths call an injected `onCountsChanged` callback the entry
// registers once via `initCall`.
import { $, form, appendLinkified } from './common.js';
import { el } from '@hive/shared/dom.js';
import { epochSec, fmtAgo, fmtDuration } from './util.js';
import { $, form, appendLinkified } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { epochSec, fmtAgo, fmtDuration } from "./util.js";
// Registered by the dashboard entry at boot; defaults to a no-op so the
// module is safe to call before wiring.
let onCountsChanged = () => {};
export function initCall(opts = {}) {
if (typeof opts.onCountsChanged === 'function') onCountsChanged = opts.onCountsChanged;
if (typeof opts.onCountsChanged === "function")
onCountsChanged = opts.onCountsChanged;
}
// ─── operator inbox — unread agent→operator messages ────────────
@ -31,50 +32,66 @@ export function initCall(opts = {}) {
// Count folds into the Y3R C4LL pill + browser-title prefix.
let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first
export function operatorInboxCount() { return operatorInbox.length; }
export function operatorInboxCount() {
return operatorInbox.length;
}
export async function refreshOperatorInbox() {
try {
const r = await fetch('/api/operator-inbox');
const r = await fetch("/api/operator-inbox");
if (r.ok) {
const data = await r.json();
operatorInbox = Array.isArray(data.messages) ? data.messages : [];
}
} catch { /* keep prior list on transient failure */ }
} catch {
/* keep prior list on transient failure */
}
renderOperatorInbox();
onCountsChanged();
}
function renderOperatorInbox() {
const root = $('operator-inbox-section');
const root = $("operator-inbox-section");
if (!root) return;
root.replaceChildren();
if (!operatorInbox.length) {
root.append(el('p', { class: 'meta' }, 'no unread messages'));
root.append(el("p", { class: "meta" }, "no unread messages"));
return;
}
const mark = el('button', { type: 'button', class: 'btn', id: 'op-inbox-mark-read' },
`✓ mark all read (${operatorInbox.length})`);
mark.addEventListener('click', markOperatorInboxRead);
root.append(el('div', { class: 'inbox-toolbar' }, mark));
const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19);
const ul = el('ul', { class: 'inbox' });
const mark = el(
"button",
{ type: "button", class: "btn", id: "op-inbox-mark-read" },
`✓ mark all read (${operatorInbox.length})`,
);
mark.addEventListener("click", markOperatorInboxRead);
root.append(el("div", { class: "inbox-toolbar" }, mark));
const fmt = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 19);
const ul = el("ul", { class: "inbox" });
for (const m of operatorInbox) {
const body = el('span', { class: 'msg-body' });
const body = el("span", { class: "msg-body" });
appendLinkified(body, m.body, m.file_refs);
ul.append(el('li', {},
el('span', { class: 'msg-ts' }, fmt(m.at)), ' ',
el('span', { class: 'msg-from' }, m.from), ' ',
el('span', { class: 'msg-sep' }, '→ '),
body,
));
ul.append(
el(
"li",
{},
el("span", { class: "msg-ts" }, fmt(m.at)),
" ",
el("span", { class: "msg-from" }, m.from),
" ",
el("span", { class: "msg-sep" }, "→ "),
body,
),
);
}
root.append(ul);
}
async function markOperatorInboxRead() {
try { await fetch('/api/agent/operator/mark-all-read', { method: 'POST' }); }
catch { /* best-effort; the next refresh reconciles */ }
try {
await fetch("/api/agent/operator/mark-all-read", { method: "POST" });
} catch {
/* best-effort; the next refresh reconciles */
}
operatorInbox = [];
renderOperatorInbox();
onCountsChanged();
@ -86,7 +103,10 @@ async function markOperatorInboxRead() {
export function operatorInboxAppendFromEvent(ev) {
if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return;
operatorInbox.unshift({
id: ev.id, from: ev.from, body: ev.body, at: ev.at,
id: ev.id,
from: ev.from,
body: ev.body,
at: ev.at,
file_refs: ev.file_refs || [],
});
if (operatorInbox.length > 100) operatorInbox.length = 100;
@ -95,14 +115,16 @@ export function operatorInboxAppendFromEvent(ev) {
}
// ─── approvals — the operator config-change / spawn approval queue ────────
const APPROVAL_TAB_KEY = 'hyperhive:approvals:tab';
const APPROVAL_TAB_KEY = "hyperhive:approvals:tab";
// Derived approval state — cold-loaded from /api/state, then mutated
// live by `approval_added` / `approval_resolved` dashboard events.
// `pending` is the open queue (newest-first); `history` is the last
// 30 resolved rows.
const APPROVAL_HISTORY_LIMIT = 30;
const approvalsState = { pending: [], history: [] };
export function activeApprovalCount() { return approvalsState.pending.length; }
export function activeApprovalCount() {
return approvalsState.pending.length;
}
export function syncApprovalsFromSnapshot(s) {
approvalsState.pending = (s.approvals || []).slice();
approvalsState.history = (s.approval_history || []).slice();
@ -122,8 +144,8 @@ export function applyApprovalAdded(ev) {
// approval was queued just now, so client-now is accurate — and
// consistent with how fmtAgo compares everything to client-now.
// A later /api/state cold-load swaps in the server value.
requested_at: ev.requested_at != null
? ev.requested_at : Math.floor(Date.now() / 1000),
requested_at:
ev.requested_at != null ? ev.requested_at : Math.floor(Date.now() / 1000),
};
if (existing >= 0) approvalsState.pending[existing] = row;
else approvalsState.pending.push(row);
@ -154,7 +176,7 @@ export function applyApprovalResolved(ev) {
renderApprovals();
}
export function renderApprovals() {
const root = $('approvals-section');
const root = $("approvals-section");
// #approvals-section only lives on /dashboard.html (Y3R C4LL tab);
// no-op elsewhere — `approval_added` / `approval_resolved` SSE
// events route through here on every page that loads the bundle.
@ -163,42 +185,42 @@ export function renderApprovals() {
const pending = approvalsState.pending;
const history = approvalsState.history;
const active = localStorage.getItem(APPROVAL_TAB_KEY) || 'pending';
const tabs = el('div', { class: 'approval-tabs' });
const active = localStorage.getItem(APPROVAL_TAB_KEY) || "pending";
const tabs = el("div", { class: "approval-tabs" });
const pendingTab = el(
'button',
"button",
{
type: 'button',
class: 'approval-tab' + (active === 'pending' ? ' active' : ''),
type: "button",
class: "approval-tab" + (active === "pending" ? " active" : ""),
},
`pending · ${pending.length}`,
);
const historyTab = el(
'button',
"button",
{
type: 'button',
class: 'approval-tab' + (active === 'history' ? ' active' : ''),
type: "button",
class: "approval-tab" + (active === "history" ? " active" : ""),
},
`history · ${history.length}`,
);
pendingTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'pending');
pendingTab.addEventListener("click", () => {
localStorage.setItem(APPROVAL_TAB_KEY, "pending");
renderApprovals();
});
historyTab.addEventListener('click', () => {
localStorage.setItem(APPROVAL_TAB_KEY, 'history');
historyTab.addEventListener("click", () => {
localStorage.setItem(APPROVAL_TAB_KEY, "history");
renderApprovals();
});
tabs.append(pendingTab, historyTab);
root.append(tabs);
if (active === 'history') {
if (active === "history") {
renderApprovalHistory(root, history);
return;
}
if (!pending.length) {
root.append(el('p', { class: 'empty' }, 'queue empty'));
root.append(el("p", { class: "empty" }, "queue empty"));
return;
}
// forge link base — only when the hive-forge container is up.
@ -208,81 +230,143 @@ export function renderApprovals() {
// below already gates on forgeBase being truthy.
const forgeBase = (fs && fs.forge_present && fs.forge_public_url) || null;
const ul = el('ul', { class: 'approvals' });
const ul = el("ul", { class: "approvals" });
for (const a of pending) {
const isInit = a.kind === 'init_config';
const isMergePr = a.kind === 'merge_config_pr';
const isUpdateMeta = a.kind === 'update_meta_inputs';
const isSchedule = a.kind === 'schedule_prompt';
const li = el('li', { class: 'approval-card' });
const isInit = a.kind === "init_config";
const isMergePr = a.kind === "merge_config_pr";
const isUpdateMeta = a.kind === "update_meta_inputs";
const isSchedule = a.kind === "schedule_prompt";
const li = el("li", { class: "approval-card" });
// ── identity header ──────────────────────────────────────────
const head = el('div', { class: 'approval-head' },
el('span', { class: 'glyph' }, isMergePr ? '⇒' : isUpdateMeta ? '↻' : isSchedule ? '⏱' : '⊕'),
el('span', { class: 'id' }, '#' + a.id),
el('span', { class: 'agent' }, a.agent),
el('span', { class: 'kind' + ((isMergePr || isUpdateMeta || isSchedule) ? '' : ' kind-spawn') },
isMergePr ? 'merge-pr' : isUpdateMeta ? 'meta-update' : isSchedule ? 'schedule' : isInit ? 'init' : 'spawn'),
const head = el(
"div",
{ class: "approval-head" },
el(
"span",
{ class: "glyph" },
isMergePr ? "⇒" : isUpdateMeta ? "↻" : isSchedule ? "⏱" : "⊕",
),
el("span", { class: "id" }, "#" + a.id),
el("span", { class: "agent" }, a.agent),
el(
"span",
{
class:
"kind" +
(isMergePr || isUpdateMeta || isSchedule ? "" : " kind-spawn"),
},
isMergePr
? "merge-pr"
: isUpdateMeta
? "meta-update"
: isSchedule
? "schedule"
: isInit
? "init"
: "spawn",
),
);
if (isMergePr && a.sha_short) head.append(el('code', {}, a.sha_short));
if (isMergePr && a.sha_short) head.append(el("code", {}, a.sha_short));
// When the approval was requested — relative time, right-aligned.
// Goes amber once it's been pending an hour so a stale request is
// obvious at a glance (see docs/web-ui.md::Approval card).
if (a.requested_at != null) {
const requestedSec = epochSec(a.requested_at);
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec));
head.append(el('span', {
class: 'approval-ts' + (ageSec >= 3600 ? ' stale' : ''),
title: 'requested ' + new Date(a.requested_at).toLocaleString(),
'data-requested-at': String(requestedSec),
}, 'requested ' + fmtAgo(a.requested_at)));
head.append(
el(
"span",
{
class: "approval-ts" + (ageSec >= 3600 ? " stale" : ""),
title: "requested " + new Date(a.requested_at).toLocaleString(),
"data-requested-at": String(requestedSec),
},
"requested " + fmtAgo(a.requested_at),
),
);
}
li.append(head);
// ── what-changed body ────────────────────────────────────────
const body = el('div', { class: 'approval-body' });
const body = el("div", { class: "approval-body" });
if (a.description) {
body.append(el('div', { class: 'approval-description' }, a.description));
body.append(el("div", { class: "approval-description" }, a.description));
}
if (isMergePr) {
// PR-based config deploy: link to the reviewed PR on the forge.
// The config diff lives on the forge PR itself.
const drill = el('div', { class: 'drill-ins' });
const drill = el("div", { class: "drill-ins" });
if (forgeBase && a.pr_number != null) {
drill.append(el('a', {
class: 'panel-trigger', target: '_blank', rel: 'noopener',
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
title: 'review this config PR on the hive forge',
}, '↳ review PR on forge ↗'));
drill.append(
el(
"a",
{
class: "panel-trigger",
target: "_blank",
rel: "noopener",
href: `${forgeBase}/agent-configs/${a.agent}/pulls/${a.pr_number}`,
title: "review this config PR on the hive forge",
},
"↳ review PR on forge ↗",
),
);
}
body.append(drill);
} else if (isUpdateMeta) {
let inputs;
try { inputs = JSON.parse(a.commit_ref || '[]'); } catch (_) { inputs = []; }
body.append(el('span', { class: 'meta' },
inputs.length
? 'bump flake inputs: ' + inputs.join(', ')
: 'bump all flake inputs'));
try {
inputs = JSON.parse(a.commit_ref || "[]");
} catch (_) {
inputs = [];
}
body.append(
el(
"span",
{ class: "meta" },
inputs.length
? "bump flake inputs: " + inputs.join(", ")
: "bump all flake inputs",
),
);
} else if (isSchedule) {
let payload;
try { payload = JSON.parse(a.commit_ref || '{}'); } catch (_) { payload = {}; }
const targets = (payload.targets || []).join(', ');
try {
payload = JSON.parse(a.commit_ref || "{}");
} catch (_) {
payload = {};
}
const targets = (payload.targets || []).join(", ");
const firstFire = payload.first_fire_at_unix
? new Date(payload.first_fire_at_unix * 1000).toLocaleString()
: '?';
: "?";
const cadence = payload.interval_seconds
? ' · ↻ every ' + fmtDuration(payload.interval_seconds)
: ' · one-shot';
body.append(el('div', { class: 'meta' }, '→ ' + targets + ' · first: ' + firstFire + cadence));
? " · ↻ every " + fmtDuration(payload.interval_seconds)
: " · one-shot";
body.append(
el(
"div",
{ class: "meta" },
"→ " + targets + " · first: " + firstFire + cadence,
),
);
if (payload.body) {
const excerpt = payload.body.length > 80 ? payload.body.slice(0, 80) + '…' : payload.body;
body.append(el('div', { class: 'approval-description' }, excerpt));
const excerpt =
payload.body.length > 80
? payload.body.slice(0, 80) + "…"
: payload.body;
body.append(el("div", { class: "approval-description" }, excerpt));
}
} else {
body.append(el('span', { class: 'meta' },
isInit
? 'scaffold proposed config repo — submitting agent customises agent.nix before spawn'
: 'new sub-agent — container will be created on approve'));
body.append(
el(
"span",
{ class: "meta" },
isInit
? "scaffold proposed config repo — submitting agent customises agent.nix before spawn"
: "new sub-agent — container will be created on approve",
),
);
}
li.append(body);
@ -291,16 +375,32 @@ export function renderApprovals() {
// handler stashes it into a hidden `note` input that rides along
// on the POST and is surfaced to the submitting agent via
// HelperEvent::ApprovalResolved { note }.
const denyForm = el('form', {
method: 'POST', action: '/api/deny/' + a.id,
class: 'inline', 'data-async': '', 'data-no-refresh': '',
'data-prompt': 'reason for denying (optional, sent to submitter):',
const denyForm = el("form", {
method: "POST",
action: "/api/deny/" + a.id,
class: "inline",
"data-async": "",
"data-no-refresh": "",
"data-prompt": "reason for denying (optional, sent to submitter):",
});
denyForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, 'DENY'));
li.append(el('div', { class: 'approval-actions' },
form('/api/approve/' + a.id, 'btn-approve', '◆ APPR0VE', null, {}, { noRefresh: true }),
denyForm,
));
denyForm.append(
el("button", { type: "submit", class: "btn btn-deny" }, "DENY"),
);
li.append(
el(
"div",
{ class: "approval-actions" },
form(
"/api/approve/" + a.id,
"btn-approve",
"◆ APPR0VE",
null,
{},
{ noRefresh: true },
),
denyForm,
),
);
ul.append(li);
}
@ -309,31 +409,52 @@ export function renderApprovals() {
function renderApprovalHistory(root, history) {
if (!history.length) {
root.append(el('p', { class: 'empty' }, 'no resolved approvals yet'));
root.append(el("p", { class: "empty" }, "no resolved approvals yet"));
return;
}
const ul = el('ul', { class: 'approvals approvals-history' });
const ul = el("ul", { class: "approvals approvals-history" });
for (const a of history) {
const li = el('li');
const row = el('div', { class: 'row' });
const glyph = a.status === 'approved' ? '✓'
: a.status === 'denied' ? '✗'
: a.status === 'cancelled' ? '⊘'
: '⚠';
const li = el("li");
const row = el("div", { class: "row" });
const glyph =
a.status === "approved"
? "✓"
: a.status === "denied"
? "✗"
: a.status === "cancelled"
? "⊘"
: "⚠";
row.append(
el('span', { class: 'glyph glyph-' + a.status }, glyph), ' ',
el('span', { class: 'id' }, '#' + a.id), ' ',
el('span', { class: 'agent' }, a.agent), ' ',
el('span', { class: 'kind' }, a.kind === 'merge_config_pr' ? 'merge-pr' : a.kind === 'update_meta_inputs' ? 'meta-update' : a.kind === 'schedule_prompt' ? 'schedule' : a.kind === 'init_config' ? 'init' : 'spawn'), ' ',
el("span", { class: "glyph glyph-" + a.status }, glyph),
" ",
el("span", { class: "id" }, "#" + a.id),
" ",
el("span", { class: "agent" }, a.agent),
" ",
el(
"span",
{ class: "kind" },
a.kind === "merge_config_pr"
? "merge-pr"
: a.kind === "update_meta_inputs"
? "meta-update"
: a.kind === "schedule_prompt"
? "schedule"
: a.kind === "init_config"
? "init"
: "spawn",
),
" ",
);
if (a.sha_short) row.append(el('code', {}, a.sha_short), ' ');
if (a.sha_short) row.append(el("code", {}, a.sha_short), " ");
row.append(
el('span', { class: 'status status-' + a.status }, a.status), ' ',
el('span', { class: 'msg-ts' }, fmtAgo(a.resolved_at)),
el("span", { class: "status status-" + a.status }, a.status),
" ",
el("span", { class: "msg-ts" }, fmtAgo(a.resolved_at)),
);
li.append(row);
if (a.note) {
li.append(el('div', { class: 'history-note' }, a.note));
li.append(el("div", { class: "history-note" }, a.note));
}
ul.append(li);
}

View file

@ -13,7 +13,8 @@
Element-level rules shared across all three pages (index, flow,
logs). These must not reference page-specific chrome classes. */
h1, h2 {
h1,
h2 {
color: var(--purple);
text-transform: uppercase;
letter-spacing: 0.15em;
@ -26,9 +27,17 @@ h1, h2 {
white-space: nowrap;
margin-bottom: 0.5em;
}
ul { list-style: none; padding-left: 0; }
li { padding: 0.5em 0; }
.glyph { color: var(--purple); margin-right: 0.5em; }
ul {
list-style: none;
padding-left: 0;
}
li {
padding: 0.5em 0;
}
.glyph {
color: var(--purple);
margin-right: 0.5em;
}
a {
color: var(--cyan);
text-decoration: none;
@ -51,10 +60,25 @@ code {
/* shared inline labels
.meta / .id / .agent / .empty appear in message rows, build-log
toolbars, and side-panel content rendered by common.js. */
.meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; }
.id { color: var(--pink); font-weight: bold; margin-right: 0.4em; }
.agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; }
.empty { color: var(--muted); font-style: italic; }
.meta {
color: var(--muted);
font-size: 0.85em;
margin-left: 0.4em;
}
.id {
color: var(--pink);
font-weight: bold;
margin-right: 0.4em;
}
.agent {
color: var(--amber);
font-weight: bold;
margin-right: 0.6em;
}
.empty {
color: var(--muted);
font-style: italic;
}
/* status badges
Semantic colour variants only shape comes from `hive-pill-sm`
@ -63,48 +87,69 @@ code {
(swarm.js/core.js/builds.js). Used on container rows and build-log
rows. */
.badge-warn {
color: var(--amber); border-color: var(--amber);
color: var(--amber);
border-color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent);
}
.badge-rate-limited {
color: var(--red); border-color: var(--red);
color: var(--red);
border-color: var(--red);
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 50%, transparent);
}
.badge-muted {
color: var(--muted); border-color: var(--purple-dim);
color: var(--muted);
border-color: var(--purple-dim);
background: color-mix(in srgb, var(--muted) 8%, transparent);
}
.badge-loose-ends {
color: var(--purple); border-color: var(--purple);
color: var(--purple);
border-color: var(--purple);
text-shadow: 0 0 6px color-mix(in srgb, var(--purple) 40%, transparent);
}
/* Paused agent: turn loop parked, container still up. Clickable (btn-inline):
clicking sends POST /api/resume/{name} so the badge doubles as a resume button. */
.badge-paused {
color: var(--yellow); border-color: var(--yellow);
color: var(--yellow);
border-color: var(--yellow);
background: color-mix(in srgb, var(--yellow) 10%, transparent);
}
/* Active Claude model badge on dashboard container rows. */
.badge-model {
color: var(--blue); border-color: var(--blue);
color: var(--blue);
border-color: var(--blue);
opacity: 0.8;
}
/* Context-window usage badges on dashboard container rows. */
.badge-ctx-ok {
color: var(--green); border-color: var(--green);
color: var(--green);
border-color: var(--green);
opacity: 0.85;
}
.badge-ctx-caution {
color: var(--amber); border-color: var(--amber);
color: var(--amber);
border-color: var(--amber);
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent);
}
.badge-ctx-warn {
color: var(--red); border-color: var(--red);
color: var(--red);
border-color: var(--red);
text-shadow: 0 0 6px color-mix(in srgb, var(--red) 50%, transparent);
}
.badge-ok { background: color-mix(in srgb, var(--green) 12%, transparent); color: var(--green); border-color: var(--green); }
.badge-fail { background: color-mix(in srgb, var(--red) 12%, transparent); color: var(--red); border-color: var(--red); }
.badge-running { background: color-mix(in srgb, var(--amber) 12%, transparent); color: var(--amber); border-color: var(--amber); }
.badge-ok {
background: color-mix(in srgb, var(--green) 12%, transparent);
color: var(--green);
border-color: var(--green);
}
.badge-fail {
background: color-mix(in srgb, var(--red) 12%, transparent);
color: var(--red);
border-color: var(--red);
}
.badge-running {
background: color-mix(in srgb, var(--amber) 12%, transparent);
color: var(--amber);
border-color: var(--amber);
}
/* buttons
.btn base + semantic colour/size modifiers. Logs page uses
@ -145,26 +190,89 @@ code {
text-shadow: none;
box-shadow: none;
}
.btn-approve { color: var(--green); border-color: var(--green); }
.btn-deny { color: var(--red); border-color: var(--red); }
.btn-destroy { color: var(--red); border-color: var(--red); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-rebuild { color: var(--amber); border-color: var(--amber); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-restart { color: var(--cyan); border-color: var(--cyan); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-stop { color: var(--pink); border-color: var(--pink); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-approve {
color: var(--green);
border-color: var(--green);
}
.btn-deny {
color: var(--red);
border-color: var(--red);
}
.btn-destroy {
color: var(--red);
border-color: var(--red);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-rebuild {
color: var(--amber);
border-color: var(--amber);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-restart {
color: var(--cyan);
border-color: var(--cyan);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-stop {
color: var(--pink);
border-color: var(--pink);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-start {
color: var(--green);
border-color: var(--green);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
/* Same yellow as .badge-paused (the paused-state pill) pause enters
that state, so the trigger and the resulting badge read as one
colour, not two unrelated ones. Resume reads as green like
.btn-start: both are "go" actions, back to the turn loop running. */
.btn-pause { color: var(--yellow); border-color: var(--yellow); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-resume { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-pause {
color: var(--yellow);
border-color: var(--yellow);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-resume {
color: var(--green);
border-color: var(--green);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
/* M0V3 affordance (selection bar) mauve reads as "structural
change" rather than the destructive red / amber chrome of
destroy / rebuild. See docs/web-ui.md::Selection bar. */
.btn-move { color: var(--purple); border-color: var(--purple); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
.btn-spawn { color: var(--amber); border-color: var(--amber); }
.btn-fire-now { color: var(--purple); border-color: var(--purple); }
.btn-move {
color: var(--purple);
border-color: var(--purple);
font-size: 0.75em;
padding: 0.15em 0.5em;
margin-left: 0.6em;
}
.btn-talk {
color: var(--cyan);
border-color: var(--cyan);
}
.btn-spawn {
color: var(--amber);
border-color: var(--amber);
}
.btn-fire-now {
color: var(--purple);
border-color: var(--purple);
}
/* Post-fire flash: report renders directly on the button for ~1.5s
so the operator sees ok/failed/missing/consumed counts inline
without a modal. Green when at least one ok; muted otherwise. */
@ -177,13 +285,24 @@ code {
/* Inline edit button on each schedule row. Yellow reads as a
parallel destructive-adjacent action (edit changes state, but
isn't deletion). */
.btn-edit-schedule { color: var(--yellow); border-color: var(--yellow); }
.btn-edit-schedule {
color: var(--yellow);
border-color: var(--yellow);
}
/* file-preview / diff panel (common.js Panel)
Side-panel content rendered by openFilePanel / buildTabbedPreview
in common.js; all three pages load the Panel singleton. */
.diff-panel { display: flex; flex-direction: column; gap: 0.6em; }
.diff-base-tabs { display: flex; flex-wrap: wrap; gap: 0.4em; }
.diff-panel {
display: flex;
flex-direction: column;
gap: 0.6em;
}
.diff-base-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
}
.diff-base-tab {
background: transparent;
border: 1px solid var(--border);
@ -193,14 +312,18 @@ code {
padding: 0.2em 0.7em;
cursor: pointer;
}
.diff-base-tab:hover { color: var(--fg); }
.diff-base-tab:hover {
color: var(--fg);
}
.diff-base-tab.active {
color: var(--purple);
border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 8%, transparent);
}
/* Image / tabbed file preview */
.preview-host { margin-top: 0.5em; }
.preview-host {
margin-top: 0.5em;
}
.img-preview {
display: block;
max-width: 100%;
@ -209,7 +332,8 @@ code {
border: 1px solid var(--border);
border-radius: 4px;
/* checkerboard so transparent regions of the image read clearly */
background: repeating-conic-gradient(var(--border) 0% 25%, var(--bg) 0% 50%) 50% / 18px 18px;
background: repeating-conic-gradient(var(--border) 0% 25%, var(--bg) 0% 50%)
50% / 18px 18px;
}
/* Path linkification agents drop pointer strings into messages;
@ -219,7 +343,9 @@ code {
text-decoration: underline dotted;
cursor: pointer;
}
.path-link:hover { color: var(--amber); }
.path-link:hover {
color: var(--amber);
}
/* File-preview body — rendered inside the side panel. */
.path-preview-body {
background: var(--bg);
@ -255,11 +381,24 @@ code {
gap: 0.5em;
align-items: baseline;
}
.inbox li:last-child { border-bottom: 0; }
.inbox .msg-ts { color: var(--muted); font-size: 0.85em; }
.inbox .msg-from { color: var(--amber); }
.inbox .msg-sep { color: var(--muted); }
.inbox .msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
.inbox li:last-child {
border-bottom: 0;
}
.inbox .msg-ts {
color: var(--muted);
font-size: 0.85em;
}
.inbox .msg-from {
color: var(--amber);
}
.inbox .msg-sep {
color: var(--muted);
}
.inbox .msg-body {
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
}
/* `#msgflow` is a shared `.live` pane inside `.terminal-wrap`. The
msgrow / msg-* rules below power both the dashboard CALL tab and
the /flow.html full-page terminal. */
@ -275,8 +414,12 @@ code {
flex: 1 1 100%;
min-width: 0;
}
.live .msgrow.sent .msg-arrow { color: var(--cyan); }
.live .msgrow.delivered .msg-arrow { color: var(--green); }
.live .msgrow.sent .msg-arrow {
color: var(--cyan);
}
.live .msgrow.delivered .msg-arrow {
color: var(--green);
}
/* Reply-thread rendering: indented border-left + muted reply tag. */
.live .msgrow.msg-reply {
padding-left: 1.2em;
@ -294,18 +437,41 @@ code {
text-shadow: none;
font-weight: normal;
}
.msg-reply-tag a:hover { color: var(--fg); }
.msg-reply-tag a:hover {
color: var(--fg);
}
@keyframes msg-highlight-fade {
from { background: color-mix(in srgb, var(--purple) 18%, transparent); }
to { background: transparent; }
from {
background: color-mix(in srgb, var(--purple) 18%, transparent);
}
to {
background: transparent;
}
}
.msg-highlight {
animation: msg-highlight-fade 1.5s ease-out forwards;
}
.msg-ts {
color: var(--muted);
font-size: 0.85em;
}
.msg-arrow {
font-weight: bold;
}
.msg-from {
color: var(--amber);
}
.msg-sep {
color: var(--muted);
}
.msg-to {
color: var(--pink);
}
.msg-body {
color: var(--fg);
white-space: pre-wrap;
word-break: break-word;
}
.msg-highlight { animation: msg-highlight-fade 1.5s ease-out forwards; }
.msg-ts { color: var(--muted); font-size: 0.85em; }
.msg-arrow { font-weight: bold; }
.msg-from { color: var(--amber); }
.msg-sep { color: var(--muted); }
.msg-to { color: var(--pink); }
.msg-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; }
/* operator compose box
Sits inside `.terminal-wrap` on both /flow.html and the dashboard
@ -339,8 +505,12 @@ code {
max-height: 8em;
padding: 0;
}
.op-compose-input:focus { outline: none; }
.op-compose-input::placeholder { color: var(--muted); }
.op-compose-input:focus {
outline: none;
}
.op-compose-input::placeholder {
color: var(--muted);
}
.op-compose-suggest {
position: absolute;
left: 3em;
@ -387,15 +557,29 @@ code {
body.side-panel-resizing {
user-select: none;
}
body.side-panel-resizing * { cursor: ew-resize !important; }
hive-side-panel .md { color: var(--fg); line-height: 1.5; }
hive-side-panel .md > :first-child { margin-top: 0; }
hive-side-panel .md > :last-child { margin-bottom: 0; }
hive-side-panel .md p { margin: 0.5em 0; }
body.side-panel-resizing * {
cursor: ew-resize !important;
}
hive-side-panel .md {
color: var(--fg);
line-height: 1.5;
}
hive-side-panel .md > :first-child {
margin-top: 0;
}
hive-side-panel .md > :last-child {
margin-bottom: 0;
}
hive-side-panel .md p {
margin: 0.5em 0;
}
hive-side-panel .md h1,
hive-side-panel .md h2,
hive-side-panel .md h3,
hive-side-panel .md h4 { color: var(--purple); margin: 0.9em 0 0.4em; }
hive-side-panel .md h4 {
color: var(--purple);
margin: 0.9em 0 0.4em;
}
hive-side-panel .md code {
background: var(--bg);
border: 1px solid var(--border);
@ -410,17 +594,29 @@ hive-side-panel .md pre {
overflow-x: auto;
margin: 0.5em 0;
}
hive-side-panel .md pre code { background: none; border: none; padding: 0; }
hive-side-panel .md a { color: var(--cyan); }
hive-side-panel .md pre code {
background: none;
border: none;
padding: 0;
}
hive-side-panel .md a {
color: var(--cyan);
}
hive-side-panel .md ul,
hive-side-panel .md ol { margin: 0.4em 0; padding-left: 1.5em; }
hive-side-panel .md ol {
margin: 0.4em 0;
padding-left: 1.5em;
}
hive-side-panel .md blockquote {
border-left: 3px solid var(--purple-dim);
padding-left: 0.8em;
margin: 0.4em 0;
color: var(--muted);
}
hive-side-panel .md table { border-collapse: collapse; margin: 0.5em 0; }
hive-side-panel .md table {
border-collapse: collapse;
margin: 0.5em 0;
}
hive-side-panel .md th,
hive-side-panel .md td {
border: 1px solid var(--border);
@ -435,7 +631,9 @@ hive-side-panel .md td {
with no negative-margin breakout hacks. Only matches on pages that opt
in by adding the wrapper (dashboard.html, index.html); FL0W / L0GS /
ST4TS are full-bleed with their own `.<page>-main` padding. */
.page-content { padding: 0 1.5em; }
.page-content {
padding: 0 1.5em;
}
/* sticky top region
One sticky container per page holding the warning banner above the
@ -458,7 +656,9 @@ hive-side-panel .md td {
the full width edge-to-edge; the dashboard + H0M3 are full-bleed at the
body level (their 1.5em gutter lives on an inner `.page-content`
wrapper), so the banner is full-width for free. */
.server-warnings[hidden] { display: none; }
.server-warnings[hidden] {
display: none;
}
.server-warn {
text-align: center;
padding: 0.4em 1em;

View file

@ -3,35 +3,55 @@
// the OS-notification module, and the path-link / file-preview
// infrastructure for the side panel.
import { linkify as termLinkify } from '@hive/shared/terminal.js';
import { el } from '@hive/shared/dom.js';
import '@hive/shared/side-panel.js'; // registers <hive-side-panel> — side-effect import
import '@hive/shared/hive-warn.js'; // registers <hive-warn> — side-effect import
import DOMPurify from 'dompurify';
import { linkify as termLinkify } from "@hive/shared/terminal.js";
import { el } from "@hive/shared/dom.js";
import "@hive/shared/side-panel.js"; // registers <hive-side-panel> — side-effect import
import "@hive/shared/hive-warn.js"; // registers <hive-warn> — side-effect import
import DOMPurify from "dompurify";
// ─── helpers ────────────────────────────────────────────────────────────
export const $ = (id) => document.getElementById(id);
export const fmtAgeSecs = (s) => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m`
: s < 86400 ? `${Math.floor(s/3600)}h` : `${Math.floor(s/86400)}d`;
export const fmtAgeSecs = (s) =>
s < 60
? `${s}s`
: s < 3600
? `${Math.floor(s / 60)}m`
: s < 86400
? `${Math.floor(s / 3600)}h`
: `${Math.floor(s / 86400)}d`;
export const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c])
);
export const esc = (s) =>
String(s).replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts = {}) => {
const f = el('form', {
method: 'POST', action, class: 'inline', 'data-async': '',
...(confirmMsg ? { 'data-confirm': confirmMsg } : {}),
export const form = (
action,
btnClass,
btnLabel,
confirmMsg,
extra = {},
opts = {},
) => {
const f = el("form", {
method: "POST",
action,
class: "inline",
"data-async": "",
...(confirmMsg ? { "data-confirm": confirmMsg } : {}),
// Endpoints whose mutation fires a DashboardEvent (and whose
// derived store applies it live) opt out of the post-submit
// /api/state refetch. See the async-form handler.
...(opts.noRefresh ? { 'data-no-refresh': '' } : {}),
...(opts.noRefresh ? { "data-no-refresh": "" } : {}),
});
for (const [name, value] of Object.entries(extra)) {
f.append(el('input', { type: 'hidden', name, value }));
f.append(el("input", { type: "hidden", name, value }));
}
f.append(el('button', { type: 'submit', class: 'btn ' + btnClass }, btnLabel));
f.append(
el("button", { type: "submit", class: "btn " + btnClass }, btnLabel),
);
return f;
};
@ -55,8 +75,8 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
// Consumer API: assign `onmessage` / `onopen` / `onerror`; `.close()`
// drops the subscription (the worker closes the upstream when the last
// subscriber leaves).
const SHARED_WORKER_PATH = '/static/stream-worker.js';
const SHARED_WORKER_NAME = 'hyperhive-stream';
const SHARED_WORKER_PATH = "/static/stream-worker.js";
const SHARED_WORKER_NAME = "hyperhive-stream";
// One SharedWorker port per page, reused by all openStream calls on
// that page. Invalidated on `pagehide` so a bfcache restore picks up
@ -64,13 +84,16 @@ const SHARED_WORKER_NAME = 'hyperhive-stream';
// closed while this page was frozen.
let _sharedPort = null;
function makeSharedPort() {
if (typeof SharedWorker === 'undefined') return null;
if (typeof SharedWorker === "undefined") return null;
try {
const sw = new SharedWorker(SHARED_WORKER_PATH, SHARED_WORKER_NAME);
sw.port.start();
return sw.port;
} catch (err) {
console.warn('SharedWorker unavailable, falling back to direct EventSource:', err);
console.warn(
"SharedWorker unavailable, falling back to direct EventSource:",
err,
);
return null;
}
}
@ -87,19 +110,27 @@ function getSharedPort() {
const WORKER_DEAD_THRESHOLD_MS = 90_000;
const WORKER_WATCHDOG_INTERVAL_MS = 15_000;
let _lastWorkerActivityAt = 0;
function noteWorkerActivity() { _lastWorkerActivityAt = Date.now(); }
function noteWorkerActivity() {
_lastWorkerActivityAt = Date.now();
}
let _watchdogTimer = null;
function startWorkerWatchdog() {
if (_watchdogTimer != null) return;
_watchdogTimer = setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
if (
typeof document !== "undefined" &&
document.visibilityState !== "visible"
)
return;
if (!_activeSubs.size) return;
if (!_sharedPort) return;
const sinceLast = Date.now() - _lastWorkerActivityAt;
if (sinceLast < WORKER_DEAD_THRESHOLD_MS) return;
console.warn(
'hyperhive-stream worker silent for ' + Math.round(sinceLast / 1000)
+ 's, presumed dead — re-subscribing on a fresh port');
"hyperhive-stream worker silent for " +
Math.round(sinceLast / 1000) +
"s, presumed dead — re-subscribing on a fresh port",
);
rebindOnFreshPort();
}, WORKER_WATCHDOG_INTERVAL_MS);
}
@ -109,7 +140,9 @@ function rebindOnFreshPort() {
// is the source of truth for what we need to re-attach.
if (_sharedPort) {
for (const sub of _activeSubs.values()) {
try { _sharedPort.removeEventListener('message', sub.route); } catch {}
try {
_sharedPort.removeEventListener("message", sub.route);
} catch {}
}
}
_sharedPort = null;
@ -117,8 +150,10 @@ function rebindOnFreshPort() {
if (!port) return; // SharedWorker unsupported / unavailable — nothing to do
for (const [url, sub] of _activeSubs) {
sub.target.readyState = 0; // CONNECTING — worker will (re-)fire 'open'
port.addEventListener('message', sub.route);
try { port.postMessage({ kind: 'subscribe', url }); } catch {}
port.addEventListener("message", sub.route);
try {
port.postMessage({ kind: "subscribe", url });
} catch {}
}
// Reset the activity clock so the watchdog gives the fresh worker
// a full window to settle before re-triggering.
@ -143,32 +178,41 @@ function bindLifecycleOnce() {
if (_lifecycleBound) return;
_lifecycleBound = true;
startWorkerWatchdog();
window.addEventListener('pagehide', () => {
window.addEventListener("pagehide", () => {
if (!_sharedPort) return;
for (const url of _activeSubs.keys()) {
try { _sharedPort.postMessage({ kind: 'unsubscribe', url }); }
catch { /* port dead — worker side already cleaned up */ }
try {
_sharedPort.postMessage({ kind: "unsubscribe", url });
} catch {
/* port dead — worker side already cleaned up */
}
}
// Drop port routes too; the bfcache-restore path will re-add
// them on a fresh port. Leaving stale routes on a dead port
// would just keep a closure alive without cost, but cleaning
// up keeps the registry shape honest.
for (const sub of _activeSubs.values()) {
try { _sharedPort.removeEventListener('message', sub.route); }
catch { /* same */ }
try {
_sharedPort.removeEventListener("message", sub.route);
} catch {
/* same */
}
}
_sharedPort = null;
});
window.addEventListener('pageshow', (ev) => {
window.addEventListener("pageshow", (ev) => {
if (!ev.persisted) return; // cold load — openStream just bound listeners
if (!_activeSubs.size) return;
const port = getSharedPort();
if (!port) return; // SharedWorker really gone; fallback already in place
for (const [url, sub] of _activeSubs) {
sub.target.readyState = 0; // CONNECTING — the worker will fire 'open'
port.addEventListener('message', sub.route);
try { port.postMessage({ kind: 'subscribe', url }); }
catch { /* port dead immediately — skip */ }
port.addEventListener("message", sub.route);
try {
port.postMessage({ kind: "subscribe", url });
} catch {
/* port dead immediately — skip */
}
}
});
}
@ -191,10 +235,16 @@ export function openStream(url) {
close() {
const p = _sharedPort;
if (p) {
try { p.postMessage({ kind: 'unsubscribe', url }); }
catch { /* port dead */ }
try { p.removeEventListener('message', route); }
catch { /* same */ }
try {
p.postMessage({ kind: "unsubscribe", url });
} catch {
/* port dead */
}
try {
p.removeEventListener("message", route);
} catch {
/* same */
}
}
_activeSubs.delete(url);
},
@ -204,29 +254,38 @@ export function openStream(url) {
// before the URL filter, since heartbeat pings carry no URL.
noteWorkerActivity();
const m = e.data;
if (!m || m.kind === 'ping') return;
if (!m || m.kind === "ping") return;
if (m.url !== url) return;
if (m.kind === 'open') {
if (m.kind === "open") {
target.readyState = 1; // OPEN
if (target.onopen) {
try { target.onopen({ target }); }
catch (err) { console.error('openStream onopen threw', err); }
try {
target.onopen({ target });
} catch (err) {
console.error("openStream onopen threw", err);
}
}
} else if (m.kind === 'message') {
} else if (m.kind === "message") {
if (target.onmessage) {
try { target.onmessage({ data: m.data, target }); }
catch (err) { console.error('openStream onmessage threw', err); }
try {
target.onmessage({ data: m.data, target });
} catch (err) {
console.error("openStream onmessage threw", err);
}
}
} else if (m.kind === 'error') {
} else if (m.kind === "error") {
if (target.onerror) {
try { target.onerror({ target }); }
catch (err) { console.error('openStream onerror threw', err); }
try {
target.onerror({ target });
} catch (err) {
console.error("openStream onerror threw", err);
}
}
}
};
_activeSubs.set(url, { target, route });
port.addEventListener('message', route);
port.postMessage({ kind: 'subscribe', url });
port.addEventListener("message", route);
port.postMessage({ kind: "subscribe", url });
// Seed the activity clock so the watchdog has a baseline (would
// otherwise compare against 0 and trigger immediately).
noteWorkerActivity();
@ -244,33 +303,41 @@ export function openStream(url) {
// (re)connect, so a CONNECTING reconnect resets the <pre> to avoid doubling.
export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
let atBottom = true;
pre.addEventListener('scroll', () => {
pre.addEventListener("scroll", () => {
atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 40;
});
let stderrSeen = false;
const es = new EventSource('/api/build-logs/id/' + id + '/stream');
const es = new EventSource("/api/build-logs/id/" + id + "/stream");
es.onmessage = (e) => {
let frame;
try { frame = JSON.parse(e.data); } catch { return; }
try {
frame = JSON.parse(e.data);
} catch {
return;
}
if (frame.stdout_append) {
pre.textContent += frame.stdout_append;
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
if (frame.stderr_append) {
if (!stderrSeen) { pre.textContent += '\n--- stderr ---\n'; stderrSeen = true; }
if (!stderrSeen) {
pre.textContent += "\n--- stderr ---\n";
stderrSeen = true;
}
pre.textContent += frame.stderr_append;
if (atBottom) pre.scrollTop = pre.scrollHeight;
}
if (frame.done) {
es.close();
if (onDone) onDone(frame.status || 'done');
if (onDone) onDone(frame.status || "done");
}
};
es.onerror = () => {
// CONNECTING = the browser is auto-reconnecting; the stream replays from
// the start, so clear the <pre> to avoid duplicated output and wait.
if (es.readyState === EventSource.CONNECTING) {
pre.textContent = ''; stderrSeen = false;
pre.textContent = "";
stderrSeen = false;
return;
}
es.close();
@ -295,7 +362,7 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
// slotted content via a plain `hive-side-panel .md …` tag-name selector
// (no compatibility class needed — the element's own tag name already
// uniquely identifies it in the light DOM).
export const sidePanel = document.createElement('hive-side-panel');
export const sidePanel = document.createElement("hive-side-panel");
document.body.append(sidePanel);
// ─── path linkification ─────────────────────────────────────────────────
@ -309,45 +376,54 @@ document.body.append(sidePanel);
// to translate it). Prefer `/agents/<name>/state/...` in agent
// outputs and the link will resolve.
async function fetchStateFile(path) {
const resp = await fetch('/api/state-file?path=' + encodeURIComponent(path));
const resp = await fetch("/api/state-file?path=" + encodeURIComponent(path));
const text = await resp.text();
if (!resp.ok) throw new Error(text || ('http ' + resp.status));
if (!resp.ok) throw new Error(text || "http " + resp.status);
return text;
}
// A 2-tab file preview: a "rendered" tab (default) + a raw-text tab.
// `renderRendered()` produces the rendered-tab node fresh on each
// switch; `plainText` backs the raw tab; `plainLabel` names it.
function buildTabbedPreview(renderRendered, plainText, plainLabel) {
const tabs = el('div', { class: 'diff-base-tabs' });
const host = el('div', { class: 'preview-host' });
const tabs = el("div", { class: "diff-base-tabs" });
const host = el("div", { class: "preview-host" });
function show(mode) {
for (const b of tabs.children) {
b.classList.toggle('active', b.dataset.mode === mode);
b.classList.toggle("active", b.dataset.mode === mode);
}
host.replaceChildren(mode === 'plain'
? el('pre', { class: 'path-preview-body' }, plainText)
: renderRendered());
host.replaceChildren(
mode === "plain"
? el("pre", { class: "path-preview-body" }, plainText)
: renderRendered(),
);
}
for (const [mode, label] of [['rendered', 'rendered'], ['plain', plainLabel]]) {
const b = el('button',
{ type: 'button', class: 'diff-base-tab', 'data-mode': mode }, label);
b.addEventListener('click', () => show(mode));
for (const [mode, label] of [
["rendered", "rendered"],
["plain", plainLabel],
]) {
const b = el(
"button",
{ type: "button", class: "diff-base-tab", "data-mode": mode },
label,
);
b.addEventListener("click", () => show(mode));
tabs.append(b);
}
show('rendered');
return el('div', {}, tabs, host);
show("rendered");
return el("div", {}, tabs, host);
}
// Rendered <img> for an SVG, loaded via an <img> data: URI —
// <img>-loaded SVG runs in the browser's secure static mode (no
// scripts, no external fetches), so an untrusted SVG from an
// agent's state dir can't execute code in the dashboard.
function svgImage(text) {
const img = el('img', { class: 'img-preview', alt: 'SVG preview' });
img.addEventListener('error', () => {
img.replaceWith(el('div', { class: 'meta' },
'(could not render — see the source tab)'));
const img = el("img", { class: "img-preview", alt: "SVG preview" });
img.addEventListener("error", () => {
img.replaceWith(
el("div", { class: "meta" }, "(could not render — see the source tab)"),
);
});
img.src = 'data:image/svg+xml,' + encodeURIComponent(text);
img.src = "data:image/svg+xml," + encodeURIComponent(text);
return img;
}
// Marked-rendered markdown node (raw text fallback if `marked`
@ -357,15 +433,15 @@ function svgImage(text) {
// tags that `marked` itself no longer strips (v5+ dropped the built-in
// sanitizer).
function mdNode(text) {
const div = el('div', { class: 'md' });
if (window.marked && typeof window.marked.parse === 'function') {
const div = el("div", { class: "md" });
if (window.marked && typeof window.marked.parse === "function") {
window.marked.setOptions({ breaks: true, gfm: true });
div.innerHTML = DOMPurify.sanitize(window.marked.parse(text));
// marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates away from the dashboard.
div.querySelectorAll('a[href]').forEach((a) => {
a.target = '_blank';
a.rel = 'noopener noreferrer';
div.querySelectorAll("a[href]").forEach((a) => {
a.target = "_blank";
a.rel = "noopener noreferrer";
});
} else {
div.textContent = text;
@ -381,38 +457,53 @@ const RASTER_RE = /\.(png|jpe?g|gif|webp|bmp|ico|avif)$/i;
// render as an <img>; every other file stays raw text in a <pre>.
async function openFilePanel(path) {
if (RASTER_RE.test(path)) {
const img = el('img', { class: 'img-preview', alt: path });
img.addEventListener('error', () => {
img.replaceWith(el('pre', { class: 'path-preview-body' },
'(could not load image — it may be missing or over the preview size cap)'));
const img = el("img", { class: "img-preview", alt: path });
img.addEventListener("error", () => {
img.replaceWith(
el(
"pre",
{ class: "path-preview-body" },
"(could not load image — it may be missing or over the preview size cap)",
),
);
});
img.src = '/api/state-file?path=' + encodeURIComponent(path);
sidePanel.open('↳ ' + path, img);
img.src = "/api/state-file?path=" + encodeURIComponent(path);
sidePanel.open("↳ " + path, img);
return;
}
const isMd = /\.(md|markdown)$/i.test(path);
const isSvg = /\.svg$/i.test(path);
const view = el('div');
view.textContent = '(fetching…)';
sidePanel.open('↳ ' + path, view);
const view = el("div");
view.textContent = "(fetching…)";
sidePanel.open("↳ " + path, view);
try {
const text = await fetchStateFile(path);
if (isSvg) {
view.replaceChildren(buildTabbedPreview(() => svgImage(text), text, 'source'));
view.replaceChildren(
buildTabbedPreview(() => svgImage(text), text, "source"),
);
} else if (isMd) {
view.replaceChildren(buildTabbedPreview(() => mdNode(text), text, 'plain'));
view.replaceChildren(
buildTabbedPreview(() => mdNode(text), text, "plain"),
);
} else {
view.replaceChildren(el('pre', { class: 'path-preview-body' }, text));
view.replaceChildren(el("pre", { class: "path-preview-body" }, text));
}
} catch (e) {
view.textContent = 'error: ' + (e.message || e);
view.textContent = "error: " + (e.message || e);
}
}
export function makePathLink(path) {
const anchor = el('a', {
href: '#', class: 'path-link', title: 'open ' + path + ' in panel',
}, path);
anchor.addEventListener('click', (e) => {
const anchor = el(
"a",
{
href: "#",
class: "path-link",
title: "open " + path + " in panel",
},
path,
);
anchor.addEventListener("click", (e) => {
e.preventDefault();
openFilePanel(path);
});
@ -453,7 +544,11 @@ export function appendLinkified(parent, text, refs) {
for (const t of tokens) {
const idx = str.indexOf(t, i);
if (idx === -1) continue;
if (bestStart === -1 || idx < bestStart || (idx === bestStart && t.length > bestToken.length)) {
if (
bestStart === -1 ||
idx < bestStart ||
(idx === bestStart && t.length > bestToken.length)
) {
bestStart = idx;
bestToken = t;
}
@ -479,55 +574,63 @@ export function appendLinkified(parent, text, refs) {
// localhost) — on other origins the API is unavailable and we hide
// the controls.
export const NOTIF = (() => {
const supported = typeof Notification !== 'undefined';
const MUTED_KEY = 'hyperhive.notify.muted';
const isMuted = () => localStorage.getItem(MUTED_KEY) === '1';
const setMuted = (v) => v
? localStorage.setItem(MUTED_KEY, '1')
: localStorage.removeItem(MUTED_KEY);
const supported = typeof Notification !== "undefined";
const MUTED_KEY = "hyperhive.notify.muted";
const isMuted = () => localStorage.getItem(MUTED_KEY) === "1";
const setMuted = (v) =>
v
? localStorage.setItem(MUTED_KEY, "1")
: localStorage.removeItem(MUTED_KEY);
function renderControls() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
const status = $('notif-status');
const enable = $("notif-enable");
const mute = $("notif-mute");
const unmute = $("notif-unmute");
const status = $("notif-status");
if (!enable || !mute || !unmute || !status) return;
if (!supported) {
enable.hidden = mute.hidden = unmute.hidden = true;
status.hidden = false;
status.textContent = 'notifications unsupported in this browser';
status.textContent = "notifications unsupported in this browser";
return;
}
const perm = Notification.permission;
enable.hidden = perm === 'granted';
mute.hidden = perm !== 'granted' || isMuted();
unmute.hidden = perm !== 'granted' || !isMuted();
status.hidden = perm !== 'denied';
if (perm === 'denied') status.textContent = 'notifications blocked — grant in site settings';
enable.hidden = perm === "granted";
mute.hidden = perm !== "granted" || isMuted();
unmute.hidden = perm !== "granted" || !isMuted();
status.hidden = perm !== "denied";
if (perm === "denied")
status.textContent = "notifications blocked — grant in site settings";
}
function bind() {
const enable = $('notif-enable');
const mute = $('notif-mute');
const unmute = $('notif-unmute');
const enable = $("notif-enable");
const mute = $("notif-mute");
const unmute = $("notif-unmute");
if (!supported || !enable || !mute || !unmute) return;
enable.addEventListener('click', async () => {
enable.addEventListener("click", async () => {
await Notification.requestPermission();
renderControls();
});
mute.addEventListener('click', () => { setMuted(true); renderControls(); });
unmute.addEventListener('click', () => { setMuted(false); renderControls(); });
mute.addEventListener("click", () => {
setMuted(true);
renderControls();
});
unmute.addEventListener("click", () => {
setMuted(false);
renderControls();
});
renderControls();
}
function show(title, body, tag) {
if (!supported) {
console.debug('notify: Notification API not supported');
console.debug("notify: Notification API not supported");
return;
}
if (Notification.permission !== 'granted') {
console.debug('notify: permission not granted', Notification.permission);
if (Notification.permission !== "granted") {
console.debug("notify: permission not granted", Notification.permission);
return;
}
if (isMuted()) {
console.debug('notify: muted');
console.debug("notify: muted");
return;
}
try {
@ -537,12 +640,15 @@ export const NOTIF = (() => {
// because that one tag would replace itself on every fire.
const n = new Notification(title, {
body,
tag: tag || ('hyperhive:' + Date.now()),
tag: tag || "hyperhive:" + Date.now(),
});
n.onclick = () => { window.focus(); n.close(); };
console.debug('notify: shown', title, 'tag=', tag);
n.onclick = () => {
window.focus();
n.close();
};
console.debug("notify: shown", title, "tag=", tag);
} catch (err) {
console.warn('notification show failed', err);
console.warn("notification show failed", err);
}
}
return { bind, show, renderControls };
@ -564,11 +670,11 @@ export const NOTIF = (() => {
// page's existing chrome element; pages without a chrome (e.g. the H0M3
// hub) get a banner-only sticky region at the top of <body>.
function ensureStickyTop() {
let top = document.querySelector('.sticky-top');
let top = document.querySelector(".sticky-top");
if (top) return top;
top = document.createElement('div');
top.className = 'sticky-top';
const chrome = document.querySelector('.dashboard-chrome, .page-header');
top = document.createElement("div");
top.className = "sticky-top";
const chrome = document.querySelector(".dashboard-chrome, .page-header");
if (chrome && chrome.parentNode) {
chrome.parentNode.insertBefore(top, chrome);
top.append(chrome);
@ -579,12 +685,12 @@ function ensureStickyTop() {
}
function ensureServerWarningsBar() {
let bar = document.getElementById('server-warnings');
let bar = document.getElementById("server-warnings");
if (!bar) {
bar = document.createElement('div');
bar.id = 'server-warnings';
bar.className = 'server-warnings';
bar.setAttribute('role', 'alert');
bar = document.createElement("div");
bar.id = "server-warnings";
bar.className = "server-warnings";
bar.setAttribute("role", "alert");
bar.hidden = true;
ensureStickyTop().prepend(bar);
}
@ -601,10 +707,12 @@ export function renderServerWarnings(warnings) {
return;
}
for (const w of warnings) {
const row = el('div', {
class: 'server-warn server-warn-' + (w && w.level === 'crit' ? 'crit' : 'warn'),
const row = el("div", {
class:
"server-warn server-warn-" +
(w && w.level === "crit" ? "crit" : "warn"),
});
appendText(row, '⚠ ' + ((w && w.message) || ''));
appendText(row, "⚠ " + ((w && w.message) || ""));
bar.append(row);
}
bar.hidden = false;
@ -616,8 +724,10 @@ export function renderServerWarnings(warnings) {
/// live updates instead.
export function initServerWarnings() {
ensureServerWarningsBar();
fetch('/api/state')
fetch("/api/state")
.then((r) => (r.ok ? r.json() : null))
.then((s) => renderServerWarnings(s && s.server_warnings))
.catch(() => { /* non-fatal: no banner if the snapshot is unreachable */ });
.catch(() => {
/* non-fatal: no banner if the snapshot is unreachable */
});
}

View file

@ -20,7 +20,9 @@ body.core-shell {
/* createTabStrip toggles the native `hidden` attribute on inactive
panes; ensure it wins over any inherited display. */
.core-pane[hidden] { display: none; }
.core-pane[hidden] {
display: none;
}
/* K3PT ST4T3 container cards
core.html doesn't load dashboard.css (that's the operator SPA),
@ -57,7 +59,9 @@ body.core-shell {
font-size: 1.05em;
font-weight: bold;
}
.container-row.tombstone .head .name { color: var(--muted); }
.container-row.tombstone .head .name {
color: var(--muted);
}
.container-row .head .meta {
margin-left: auto;
font-size: 0.88em;
@ -68,4 +72,7 @@ body.core-shell {
flex-wrap: wrap;
gap: 0.4em;
}
.container-row .actions form.inline { display: inline-block; margin: 0; }
.container-row .actions form.inline {
display: inline-block;
margin: 0;
}

View file

@ -1,18 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // C0R3</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/colors.css">
<link rel="stylesheet" href="/static/theme.css">
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/core.css">
</head>
<body class="core-shell">
<!-- C0R3: the host/coordinator surface. Standalone page (served at
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // C0R3</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/core.css" />
</head>
<body class="core-shell">
<!-- C0R3: the host/coordinator surface. Standalone page (served at
/core.html) carved out of the dashboard's old SYST3M tab so the
dashboard tab strip stays lean. Same minimal chrome as
/logs.html — a `← home` back-link to the H0M3 hub + a
@ -24,41 +23,62 @@
(`hive-ci`, `hive-forge`, `hive-gateway`, `hive-matrix`) have no
dashboard panel — `hivectl stop`/`start`/`restart` is the only
control surface. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar core-tabbar" id="core-tabbar" prefix="core"
role="tablist"></hive-tab-strip>
</header>
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip
class="hive-tabbar core-tabbar"
id="core-tabbar"
prefix="core"
role="tablist"
></hive-tab-strip>
</header>
<main class="core-main">
<!-- K3PT ST4T3: tombstoned-agent kept state + stale permission entries.
<main class="core-main">
<!-- K3PT ST4T3: tombstoned-agent kept state + stale permission entries.
tombstones-section shows destroyed agents (purge button).
tombstones-stale-perms is lazy-loaded on tab activation and shows
agents with explicit capability/tool-group entries but no live
container (typically renamed/deleted agents whose JSON entries
persisted). Each stale entry gets a "✕ clear perms" button. -->
<section class="core-pane" id="core-pane-kept" data-tab-pane="kept"
role="tabpanel" aria-labelledby="core-tab-kept">
<p class="meta">kept state from previously tombstoned agents — recreating an agent with the same name reuses it.</p>
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
<div id="tombstones-stale-perms"></div>
</section>
<section
class="core-pane"
id="core-pane-kept"
data-tab-pane="kept"
role="tabpanel"
aria-labelledby="core-tab-kept"
>
<p class="meta">
kept state from previously tombstoned agents — recreating an agent
with the same name reuses it.
</p>
<div id="tombstones-section">
<p class="meta">loading…</p>
</div>
<div id="tombstones-stale-perms"></div>
</section>
<!-- C0NT41N3R L04D: live cpu + memory per agent container, from
<!-- C0NT41N3R L04D: live cpu + memory per agent container, from
cgroup v2 on the host. Polled only while this sub-tab is active. -->
<section class="core-pane" id="core-pane-load" data-tab-pane="load"
role="tabpanel" aria-labelledby="core-tab-load">
<p class="meta">live cpu + memory per agent container, from cgroup v2 on the host. cpu is % of total host capacity (all cores), sampled over ~200ms each refresh; polled every 5s while this tab is open. network is omitted on purpose — agents share the host netns, so there is no per-container counter (see <code>docs/networking/network.md</code>).</p>
<div id="container-load-section">
<p class="meta">loading…</p>
</div>
</section>
<section
class="core-pane"
id="core-pane-load"
data-tab-pane="load"
role="tabpanel"
aria-labelledby="core-tab-load"
>
<p class="meta">
live cpu + memory per agent container, from cgroup v2 on the host. cpu
is % of total host capacity (all cores), sampled over ~200ms each
refresh; polled every 5s while this tab is open. network is omitted on
purpose — agents share the host netns, so there is no per-container
counter (see <code>docs/networking/network.md</code>).
</p>
<div id="container-load-section">
<p class="meta">loading…</p>
</div>
</section>
</main>
</main>
<script type="module" src="/static/core.js" defer></script>
</body>
<script type="module" src="/static/core.js" defer></script>
</body>
</html>

View file

@ -9,10 +9,10 @@
// (the same broker event channel the dashboard uses), maintaining its
// own copy of the tombstones state.
import { $, form, openStream, initServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import { asyncBtn, bindAsyncForms } from '@hive/shared/forms.js';
import '@hive/shared/hive-tab-strip.js';
import { $, form, openStream, initServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { asyncBtn, bindAsyncForms } from "@hive/shared/forms.js";
import "@hive/shared/hive-tab-strip.js";
// ─── derived state (own copies; this bundle has its own runtime) ──────────
let tombstonesState = [];
@ -26,60 +26,75 @@ function syncFromSnapshot(s) {
// ─── kept state (tombstones) ──────────────────────────────────────────────
function renderTombstones(s) {
const root = $('tombstones-section');
const root = $("tombstones-section");
if (!root) return;
root.replaceChildren();
if (!s.tombstones || !s.tombstones.length) {
root.append(el('p', { class: 'empty' }, 'no kept state — clean'));
root.append(el("p", { class: "empty" }, "no kept state — clean"));
return;
}
const fmtBytes = (n) => {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + ' MB';
return (n / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
if (n < 1024) return n + " B";
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
if (n < 1024 * 1024 * 1024) return (n / (1024 * 1024)).toFixed(1) + " MB";
return (n / (1024 * 1024 * 1024)).toFixed(2) + " GB";
};
const fmtAgeDays = (ts) => {
if (!ts) return '?';
if (!ts) return "?";
const d = Math.floor((Date.now() / 1000 - ts) / 86400);
if (d <= 0) return 'today';
if (d === 1) return '1 day ago';
return d + ' days ago';
if (d <= 0) return "today";
if (d === 1) return "1 day ago";
return d + " days ago";
};
// Only shown alongside actual rows — a caveat over an empty list is noise.
// Wording is deliberately about what the list *is* rather than what it
// isn't: nothing records a destroy, so "container absent" is the only thing
// the backend can actually tell.
const warn = el('hive-warn', { level: 'warning' });
const warn = el("hive-warn", { level: "warning" });
warn.append(
el('strong', {}, 'shows every agent whose container is absent'),
' — not only destroyed ones. An agent part-way through being spawned ' +
'looks identical here, because nothing records a destroy. Check it is ' +
'really gone before you PURG3.',
el("strong", {}, "shows every agent whose container is absent"),
" — not only destroyed ones. An agent part-way through being spawned " +
"looks identical here, because nothing records a destroy. Check it is " +
"really gone before you PURG3.",
);
root.append(warn);
const ul = el('ul', { class: 'containers' });
const ul = el("ul", { class: "containers" });
for (const t of s.tombstones) {
const li = el('li', { class: 'container-row tombstone' });
const head = el('div', { class: 'head' });
const li = el("li", { class: "container-row tombstone" });
const head = el("div", { class: "head" });
head.append(
el('span', { class: 'name' }, t.name),
el("span", { class: "name" }, t.name),
// Was `destroyed`, which the backend cannot actually know — see the
// caveat above. `offline` is what the absence of a container proves.
el('span', { class: 'hive-pill-sm badge-muted' }, 'offline'),
el("span", { class: "hive-pill-sm badge-muted" }, "offline"),
);
if (t.has_creds)
head.append(
el("span", { class: "hive-pill-sm badge-muted" }, "creds kept"),
);
head.append(
el(
"span",
{ class: "meta" },
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`,
),
);
if (t.has_creds) head.append(el('span', { class: 'hive-pill-sm badge-muted' }, 'creds kept'));
head.append(el('span', { class: 'meta' },
`${fmtBytes(t.state_bytes)} · ${fmtAgeDays(t.last_seen)}`));
li.append(head);
const actions = el('div', { class: 'actions' });
actions.append(form(
'/api/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
'PURGE ' + t.name + '? config history, claude creds, '
+ 'and notes are all WIPED. no undo.',
{}, { noRefresh: true },
));
const actions = el("div", { class: "actions" });
actions.append(
form(
"/api/purge-tombstone/" + t.name,
"btn-destroy",
"PURG3",
"PURGE " +
t.name +
"? config history, claude creds, " +
"and notes are all WIPED. no undo.",
{},
{ noRefresh: true },
),
);
li.append(actions);
ul.append(li);
}
@ -98,37 +113,53 @@ let stalePermsLoaded = false;
function renderStalePerms(root, ghosts) {
root.replaceChildren();
if (!ghosts.length) return;
root.append(el('p', { class: 'tombstones-stale-heading' }, 'stale permission entries'));
root.append(el('p', { class: 'meta' },
'agents with explicit capability or tool-group entries but no live container '
+ 'or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).'));
const errP = el('p', { class: 'tombstones-stale-err', hidden: true });
const ul = el('ul', { class: 'tombstones-stale-list' });
root.append(
el("p", { class: "tombstones-stale-heading" }, "stale permission entries"),
);
root.append(
el(
"p",
{ class: "meta" },
"agents with explicit capability or tool-group entries but no live container " +
"or kept state (typically renamed or manually-deleted agents whose JSON entries persisted).",
),
);
const errP = el("p", { class: "tombstones-stale-err", hidden: true });
const ul = el("ul", { class: "tombstones-stale-list" });
for (const name of ghosts) {
const li = el('li', { class: 'tombstones-stale-row' });
li.append(el('span', { class: 'tombstones-stale-name' }, name));
li.append(el('span', { class: 'hive-pill-sm badge-muted' }, 'stale perms'));
const btn = el('button', {
type: 'button',
class: 'btn btn-destroy',
title: 'remove explicit capability and tool-group entries for ' + name,
}, '✕ clear perms');
btn.addEventListener('click', () => asyncBtn(btn, async () => {
errP.hidden = true;
try {
const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' });
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
errP.textContent = 'failed to clear perms for ' + name + ': ' + msg;
const li = el("li", { class: "tombstones-stale-row" });
li.append(el("span", { class: "tombstones-stale-name" }, name));
li.append(el("span", { class: "hive-pill-sm badge-muted" }, "stale perms"));
const btn = el(
"button",
{
type: "button",
class: "btn btn-destroy",
title: "remove explicit capability and tool-group entries for " + name,
},
"✕ clear perms",
);
btn.addEventListener("click", () =>
asyncBtn(btn, async () => {
errP.hidden = true;
try {
const resp = await fetch(
"/api/permissions/" + encodeURIComponent(name),
{ method: "DELETE" },
);
if (!resp.ok) {
const msg = await resp.text().catch(() => String(resp.status));
errP.textContent = "failed to clear perms for " + name + ": " + msg;
errP.hidden = false;
return;
}
await fetchAndRenderStalePerms();
} catch (err) {
errP.textContent = "failed to clear perms for " + name + ": " + err;
errP.hidden = false;
return;
}
await fetchAndRenderStalePerms();
} catch (err) {
errP.textContent = 'failed to clear perms for ' + name + ': ' + err;
errP.hidden = false;
}
}));
}),
);
li.append(btn);
ul.append(li);
}
@ -140,16 +171,18 @@ function renderStalePerms(root, ghosts) {
// but are absent from both the live roster and the kept-state tombstones.
// One call, no client-side roster cache, always authoritative.
async function fetchAndRenderStalePerms() {
const root = $('tombstones-stale-perms');
const root = $("tombstones-stale-perms");
if (!root) return;
try {
const resp = await fetch('/api/permissions/stale');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/permissions/stale");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderStalePerms(root, data.stale || []);
} catch (err) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'failed to load stale perm data: ' + err));
root.append(
el("p", { class: "meta" }, "failed to load stale perm data: " + err),
);
}
stalePermsLoaded = true;
}
@ -158,21 +191,25 @@ async function fetchAndRenderStalePerms() {
let containerLoadTimer = null;
function cloadFmtBytes(n) {
if (!Number.isFinite(n) || n <= 0) return '0';
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let i = 0; let v = n;
while (v >= 1024 && i < u.length - 1) { v /= 1024; i += 1; }
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + ' ' + u[i];
if (!Number.isFinite(n) || n <= 0) return "0";
const u = ["B", "KiB", "MiB", "GiB", "TiB"];
let i = 0;
let v = n;
while (v >= 1024 && i < u.length - 1) {
v /= 1024;
i += 1;
}
return v.toFixed(v < 10 && i > 0 ? 1 : 0) + " " + u[i];
}
function cloadMeter(pct) {
const p = Math.max(0, Math.min(100, pct));
const cls = p >= 90 ? 'hot' : (p >= 70 ? 'warn' : '');
const m = document.createElement('span');
m.className = 'cload-meter';
m.title = p.toFixed(0) + '%';
const fill = document.createElement('span');
fill.className = 'fill' + (cls ? ' ' + cls : '');
fill.style.width = p + '%';
const cls = p >= 90 ? "hot" : p >= 70 ? "warn" : "";
const m = document.createElement("span");
m.className = "cload-meter";
m.title = p.toFixed(0) + "%";
const fill = document.createElement("span");
fill.className = "fill" + (cls ? " " + cls : "");
fill.style.width = p + "%";
m.append(fill);
return m;
}
@ -183,12 +220,14 @@ let lastLoadRows = [];
function renderContainerLoad(rows) {
lastLoadRows = rows;
const root = $('container-load-section');
const root = $("container-load-section");
if (!root) return;
if (!Array.isArray(rows) || rows.length === 0) {
root.replaceChildren();
const p = document.createElement('p'); p.className = 'meta';
p.textContent = 'no running agent containers'; root.append(p);
const p = document.createElement("p");
p.className = "meta";
p.textContent = "no running agent containers";
root.append(p);
return;
}
@ -197,83 +236,105 @@ function renderContainerLoad(rows) {
// resolved by the server) next to the live cgroup readings.
const cvByName = new Map(containersState.map((c) => [c.name, c]));
const table = document.createElement('table');
table.className = 'hive-stats-table';
const table = document.createElement("table");
table.className = "hive-stats-table";
// "cpu cap" / "mem cap" show the configured ceilings from ContainerView
// (effective drop-in values, take effect on next start/restart).
// "limit" remains the live cgroup memory ceiling from /api/container-resources.
table.innerHTML = '<thead><tr><th>agent</th><th>cpu</th><th>memory</th>'
+ '<th>peak</th><th>limit</th><th>disk</th>'
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">cpu cap</th>'
+ '<th class="cload-cap-th" title="configured ceiling — takes effect on next start">mem cap</th>'
+ '<th></th></tr></thead>';
const tb = document.createElement('tbody');
table.innerHTML =
"<thead><tr><th>agent</th><th>cpu</th><th>memory</th>" +
"<th>peak</th><th>limit</th><th>disk</th>" +
'<th class="cload-cap-th" title="configured ceiling — takes effect on next start">cpu cap</th>' +
'<th class="cload-cap-th" title="configured ceiling — takes effect on next start">mem cap</th>' +
"<th></th></tr></thead>";
const tb = document.createElement("tbody");
for (const r of rows) {
const cv = cvByName.get(r.name);
const tr = document.createElement('tr');
const name = document.createElement('td'); name.textContent = r.name; tr.append(name);
const cpu = document.createElement('td'); cpu.className = 'num';
cpu.append((Number(r.cpu_pct) || 0).toFixed(1) + '%', cloadMeter(Number(r.cpu_pct) || 0));
const tr = document.createElement("tr");
const name = document.createElement("td");
name.textContent = r.name;
tr.append(name);
const cpu = document.createElement("td");
cpu.className = "num";
cpu.append(
(Number(r.cpu_pct) || 0).toFixed(1) + "%",
cloadMeter(Number(r.cpu_pct) || 0),
);
tr.append(cpu);
const mem = document.createElement('td'); mem.className = 'num';
const mem = document.createElement("td");
mem.className = "num";
const memCur = Number(r.mem_current_bytes) || 0;
if (r.mem_max_bytes) {
mem.append(cloadFmtBytes(memCur), cloadMeter(100 * memCur / r.mem_max_bytes));
mem.append(
cloadFmtBytes(memCur),
cloadMeter((100 * memCur) / r.mem_max_bytes),
);
} else {
mem.textContent = cloadFmtBytes(memCur);
}
tr.append(mem);
const peak = document.createElement('td'); peak.className = 'num';
peak.textContent = r.mem_peak_bytes ? cloadFmtBytes(Number(r.mem_peak_bytes)) : '—';
const peak = document.createElement("td");
peak.className = "num";
peak.textContent = r.mem_peak_bytes
? cloadFmtBytes(Number(r.mem_peak_bytes))
: "—";
tr.append(peak);
const lim = document.createElement('td'); lim.className = 'num';
lim.textContent = r.mem_max_bytes ? cloadFmtBytes(Number(r.mem_max_bytes)) : '∞';
const lim = document.createElement("td");
lim.className = "num";
lim.textContent = r.mem_max_bytes
? cloadFmtBytes(Number(r.mem_max_bytes))
: "∞";
tr.append(lim);
// Disk: on-disk footprint (state dir + container writable rootfs, shared
// nix store excluded). Sampled out-of-band every few minutes server-side,
// so it's null until the first sample lands — show an em-dash then.
const disk = document.createElement('td'); disk.className = 'num';
disk.title = 'state dir + container writable rootfs (shared nix store excluded); sampled every few minutes';
disk.textContent = (r.disk_bytes != null) ? cloadFmtBytes(Number(r.disk_bytes)) : '—';
const disk = document.createElement("td");
disk.className = "num";
disk.title =
"state dir + container writable rootfs (shared nix store excluded); sampled every few minutes";
disk.textContent =
r.disk_bytes != null ? cloadFmtBytes(Number(r.disk_bytes)) : "—";
tr.append(disk);
// Configured CPU / memory caps from ContainerView (resolved effective
// values: per-agent override when set, hive-wide default otherwise).
const cpuCap = document.createElement('td'); cpuCap.className = 'num cload-cap';
cpuCap.title = 'configured ceiling — takes effect on next start';
cpuCap.textContent = cv?.cpu_quota || '—';
const cpuCap = document.createElement("td");
cpuCap.className = "num cload-cap";
cpuCap.title = "configured ceiling — takes effect on next start";
cpuCap.textContent = cv?.cpu_quota || "—";
tr.append(cpuCap);
const memCap = document.createElement('td'); memCap.className = 'num cload-cap';
memCap.title = 'configured ceiling — takes effect on next start';
memCap.textContent = cv?.memory_max || '—';
const memCap = document.createElement("td");
memCap.className = "num cload-cap";
memCap.title = "configured ceiling — takes effect on next start";
memCap.textContent = cv?.memory_max || "—";
tr.append(memCap);
// S3T button toggles the inline edit row for this agent.
const actTd = document.createElement('td');
const setBtn = document.createElement('button');
setBtn.type = 'button';
setBtn.className = 'btn btn-sm cload-set-btn';
setBtn.textContent = 'S3T';
setBtn.title = 'set CPU / memory cap for ' + r.name;
const actTd = document.createElement("td");
const setBtn = document.createElement("button");
setBtn.type = "button";
setBtn.className = "btn btn-sm cload-set-btn";
setBtn.textContent = "S3T";
setBtn.title = "set CPU / memory cap for " + r.name;
tr.append(actTd);
actTd.append(setBtn);
tb.append(tr);
// Inline edit row (hidden by default, toggled by the S3T button).
const editRow = document.createElement('tr');
editRow.className = 'cload-edit-row';
const editRow = document.createElement("tr");
editRow.className = "cload-edit-row";
editRow.hidden = true;
const editTd = document.createElement('td');
const editTd = document.createElement("td");
editTd.colSpan = 9;
editTd.className = 'cload-edit-cell';
editTd.className = "cload-edit-cell";
const editForm = document.createElement('form');
editForm.className = 'cload-edit-form';
editForm.addEventListener('submit', async (e) => {
const editForm = document.createElement("form");
editForm.className = "cload-edit-form";
editForm.addEventListener("submit", async (e) => {
e.preventDefault();
const cpuInput = editForm.querySelector('.cload-cpu-input');
const memInput = editForm.querySelector('.cload-mem-input');
const errSpan = editForm.querySelector('.cload-edit-err');
const cpuInput = editForm.querySelector(".cload-cpu-input");
const memInput = editForm.querySelector(".cload-mem-input");
const errSpan = editForm.querySelector(".cload-edit-err");
const submitBtn = editForm.querySelector('[type="submit"]');
errSpan.hidden = true;
submitBtn.disabled = true;
@ -283,11 +344,13 @@ function renderContainerLoad(rows) {
memory_max: memInput.value.trim(),
});
const resp = await fetch(
'/api/resource-limits/' + encodeURIComponent(r.name),
{ method: 'POST', body },
"/api/resource-limits/" + encodeURIComponent(r.name),
{ method: "POST", body },
);
if (!resp.ok) {
errSpan.textContent = await resp.text().catch(() => 'error ' + resp.status);
errSpan.textContent = await resp
.text()
.catch(() => "error " + resp.status);
errSpan.hidden = false;
return;
}
@ -295,7 +358,7 @@ function renderContainerLoad(rows) {
// ContainerView data (cpu_quota/memory_max) to containersState,
// triggering a re-render of the cap columns via refreshContainerLoad.
editRow.hidden = true;
setBtn.textContent = 'S3T';
setBtn.textContent = "S3T";
} catch (err) {
errSpan.textContent = String(err);
errSpan.hidden = false;
@ -304,44 +367,50 @@ function renderContainerLoad(rows) {
}
});
const cpuLabel = document.createElement('label');
cpuLabel.className = 'cload-edit-label';
cpuLabel.textContent = 'cpu quota';
const cpuInput = document.createElement('input');
cpuInput.type = 'text'; cpuInput.className = 'cload-cpu-input';
cpuInput.placeholder = cv ? cv.cpu_quota : 'e.g. 200%';
cpuInput.title = 'systemd CPUQuota= value (e.g. "400%"). empty = use hive default';
const cpuLabel = document.createElement("label");
cpuLabel.className = "cload-edit-label";
cpuLabel.textContent = "cpu quota";
const cpuInput = document.createElement("input");
cpuInput.type = "text";
cpuInput.className = "cload-cpu-input";
cpuInput.placeholder = cv ? cv.cpu_quota : "e.g. 200%";
cpuInput.title =
'systemd CPUQuota= value (e.g. "400%"). empty = use hive default';
cpuLabel.append(cpuInput);
const memLabel = document.createElement('label');
memLabel.className = 'cload-edit-label';
memLabel.textContent = 'mem max';
const memInput = document.createElement('input');
memInput.type = 'text'; memInput.className = 'cload-mem-input';
memInput.placeholder = cv ? cv.memory_max : 'e.g. 8G';
memInput.title = 'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default';
const memLabel = document.createElement("label");
memLabel.className = "cload-edit-label";
memLabel.textContent = "mem max";
const memInput = document.createElement("input");
memInput.type = "text";
memInput.className = "cload-mem-input";
memInput.placeholder = cv ? cv.memory_max : "e.g. 8G";
memInput.title =
'systemd MemoryMax= value (e.g. "8G", "50%", "infinity"). empty = use hive default';
memLabel.append(memInput);
const submitBtn = document.createElement('button');
submitBtn.type = 'submit'; submitBtn.className = 'btn btn-restart cload-save-btn';
submitBtn.textContent = 'S4V3';
const submitBtn = document.createElement("button");
submitBtn.type = "submit";
submitBtn.className = "btn btn-restart cload-save-btn";
submitBtn.textContent = "S4V3";
const hintSpan = document.createElement('span');
hintSpan.className = 'meta cload-edit-hint';
hintSpan.textContent = '↺ restart to apply to a running container';
const hintSpan = document.createElement("span");
hintSpan.className = "meta cload-edit-hint";
hintSpan.textContent = "↺ restart to apply to a running container";
const errSpan = document.createElement('span');
errSpan.className = 'cload-edit-err'; errSpan.hidden = true;
const errSpan = document.createElement("span");
errSpan.className = "cload-edit-err";
errSpan.hidden = true;
editForm.append(cpuLabel, memLabel, submitBtn, hintSpan, errSpan);
editTd.append(editForm);
editRow.append(editTd);
tb.append(editRow);
setBtn.addEventListener('click', () => {
setBtn.addEventListener("click", () => {
const open = !editRow.hidden;
editRow.hidden = open;
setBtn.textContent = open ? 'S3T' : '✕';
setBtn.textContent = open ? "S3T" : "✕";
});
}
table.append(tb);
@ -350,15 +419,17 @@ function renderContainerLoad(rows) {
async function refreshContainerLoad() {
try {
const resp = await fetch('/api/container-resources');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/container-resources");
if (!resp.ok) throw new Error("http " + resp.status);
renderContainerLoad(await resp.json());
} catch (e) {
const root = $('container-load-section');
const root = $("container-load-section");
if (root) {
root.replaceChildren();
const p = document.createElement('p'); p.className = 'meta';
p.textContent = 'container load fetch failed: ' + e; root.append(p);
const p = document.createElement("p");
p.className = "meta";
p.textContent = "container load fetch failed: " + e;
root.append(p);
}
}
}
@ -368,7 +439,10 @@ function startContainerLoadPolling() {
containerLoadTimer = setInterval(refreshContainerLoad, 5000);
}
function stopContainerLoadPolling() {
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
if (containerLoadTimer) {
clearInterval(containerLoadTimer);
containerLoadTimer = null;
}
}
// ─── render-all (cold load + any full re-render) ──────────────────────────
@ -412,7 +486,7 @@ const SSE_HANDLERS = {
// purge actions that opt out of `data-no-refresh`).
async function refreshState() {
try {
const resp = await fetch('/api/state');
const resp = await fetch("/api/state");
if (resp.ok) syncFromSnapshot(await resp.json());
} catch {
// best-effort: the page keeps its last-rendered state
@ -431,18 +505,18 @@ async function init() {
// Hash-routed sub-tab strip; default K3PT ST4T3. Container-load
// polling runs only while the LOAD sub-tab is open (cpu is a short
// two-sample read each refresh on the server).
document.getElementById('core-tabbar').configure({
document.getElementById("core-tabbar").configure({
tabs: [
{ id: 'kept', label: 'K3PT ST4T3' },
{ id: 'load', label: 'C0NT41N3R L04D' },
{ id: "kept", label: "K3PT ST4T3" },
{ id: "load", label: "C0NT41N3R L04D" },
],
defaultId: 'kept',
defaultId: "kept",
onShow: (id) => {
if (id === 'load') startContainerLoadPolling();
if (id === "load") startContainerLoadPolling();
else stopContainerLoadPolling();
// Lazy-load stale-perms on first K3PT ST4T3 activation; always
// re-fetch on subsequent visits in case perms changed.
if (id === 'kept') fetchAndRenderStalePerms();
if (id === "kept") fetchAndRenderStalePerms();
},
});
@ -454,13 +528,17 @@ async function init() {
// (subscription discipline, part 1 of the dashboard-event-stream-
// split issue).
const es = openStream(
'/api/dashboard/stream?kinds=tombstones_changed,container_state_changed,' +
'capabilities_changed,tool_groups_changed',
"/api/dashboard/stream?kinds=tombstones_changed,container_state_changed," +
"capabilities_changed,tool_groups_changed",
);
if (es) {
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
try {
ev = JSON.parse(e.data);
} catch {
return;
}
const h = SSE_HANDLERS[ev.kind];
if (h) h(ev);
};

View file

@ -25,9 +25,13 @@ body.cred-shell {
padding: 1.2em 1.25rem 3rem;
}
.cred-pane[hidden] { display: none; }
.cred-pane[hidden] {
display: none;
}
.gh-status { margin: 0.5rem 0 1.2rem; }
.gh-status {
margin: 0.5rem 0 1.2rem;
}
.gh-status-line {
display: flex;
align-items: center;
@ -39,10 +43,18 @@ body.cred-shell {
border-radius: 50%;
flex: none;
}
.gh-dot.present { background: var(--green); }
.gh-dot.absent { background: var(--muted); }
.gh-status-text.present { color: var(--green); }
.gh-status-text.absent { color: var(--muted); }
.gh-dot.present {
background: var(--green);
}
.gh-dot.absent {
background: var(--muted);
}
.gh-status-text.present {
color: var(--green);
}
.gh-status-text.absent {
color: var(--muted);
}
.ma-field {
display: flex;
@ -50,7 +62,10 @@ body.cred-shell {
gap: 0.25rem;
margin: 0.55rem 0;
}
.ma-field > span { font-size: 0.8rem; color: var(--muted); }
.ma-field > span {
font-size: 0.8rem;
color: var(--muted);
}
.ma-field input,
.ma-field select {
padding: 0.4rem 0.5rem;
@ -72,13 +87,28 @@ body.cred-shell {
padding: 0.45rem 0.75rem 0.6rem;
margin: 0.85rem 0;
}
.ma-mode legend { font-size: 0.8rem; color: var(--muted); padding: 0 0.3rem; }
.ma-mode label { margin-right: 1.3rem; cursor: pointer; }
.ma-mode legend {
font-size: 0.8rem;
color: var(--muted);
padding: 0 0.3rem;
}
.ma-mode label {
margin-right: 1.3rem;
cursor: pointer;
}
.ma-modefields { margin: 0.4rem 0; }
.ma-modefields {
margin: 0.4rem 0;
}
.ma-list { margin: 0.5rem 0 1.2rem; }
.ma-accounts { list-style: none; padding: 0; margin: 0; }
.ma-list {
margin: 0.5rem 0 1.2rem;
}
.ma-accounts {
list-style: none;
padding: 0;
margin: 0;
}
.ma-account {
display: flex;
align-items: center;
@ -96,10 +126,16 @@ body.cred-shell {
`offline`/`stale` are the amber states (provisioned-not-live / container-down
daemon-down). `absent` = no token. */
.ma-dot.ok,
.ma-dot.live { background: var(--green); }
.ma-dot.live {
background: var(--green);
}
.ma-dot.offline,
.ma-dot.stale { background: var(--amber); }
.ma-dot.absent { background: var(--muted); }
.ma-dot.stale {
background: var(--amber);
}
.ma-dot.absent {
background: var(--muted);
}
/* `live stale-age`: snapshot still says live but the daemon heartbeat stalled
(> ~90s). Keep the green hue but dim + desaturate so it reads "was online,
now uncertain" visually distinct from the solid amber container-down
@ -109,23 +145,52 @@ body.cred-shell {
opacity: 0.4;
filter: saturate(0.45);
}
.ma-name { font-weight: 600; color: var(--fg); }
.ma-uid { color: var(--muted); font-size: 0.8rem; margin-left: 0.4em; }
.ma-hs { color: var(--muted); font-size: 0.85rem; }
.ma-status { margin-left: auto; font-size: 0.8rem; }
.ma-name {
font-weight: 600;
color: var(--fg);
}
.ma-uid {
color: var(--muted);
font-size: 0.8rem;
margin-left: 0.4em;
}
.ma-hs {
color: var(--muted);
font-size: 0.85rem;
}
.ma-status {
margin-left: auto;
font-size: 0.8rem;
}
.ma-status.ok,
.ma-status.live { color: var(--green); }
.ma-status.live {
color: var(--green);
}
.ma-status.offline,
.ma-status.stale { color: var(--amber); }
.ma-status.absent { color: var(--muted); }
.ma-status.live.stale-age { color: var(--green); opacity: 0.6; }
.ma-status.stale {
color: var(--amber);
}
.ma-status.absent {
color: var(--muted);
}
.ma-status.live.stale-age {
color: var(--green);
opacity: 0.6;
}
.ma-result {
margin-top: 0.7rem;
font-size: 0.9rem;
min-height: 1.2em;
}
.ma-result.ok { color: var(--green); }
.ma-result.err { color: var(--red); }
.ma-result.ok {
color: var(--green);
}
.ma-result.err {
color: var(--red);
}
.ma-list .err { color: var(--red); font-size: 0.9rem; }
.ma-list .err {
color: var(--red);
font-size: 0.9rem;
}

View file

@ -1,156 +1,253 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // CR3D3NTIALS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/colors.css">
<link rel="stylesheet" href="/static/theme.css">
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/credentials.css">
</head>
<body class="cred-shell">
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // CR3D3NTIALS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/credentials.css" />
</head>
<body class="cred-shell">
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
logs.html (MATRIX / GITHUB instead of AGENT/INFRA/SYSTEM). Back
link points to the H0M3 hub (served at /). -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar cred-tabbar" id="cred-tabbar" prefix="cred"
role="tablist"></hive-tab-strip>
</header>
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip
class="hive-tabbar cred-tabbar"
id="cred-tabbar"
prefix="cred"
role="tablist"
></hive-tab-strip>
</header>
<main class="cred-main">
<!-- Agent picker: shared across both tabs (one agent selected at a
<main class="cred-main">
<!-- Agent picker: shared across both tabs (one agent selected at a
time drives both the matrix account list and the github status). -->
<h3>◇ agent</h3>
<label class="ma-field">
<span>agent</span>
<select id="ma-agent"></select>
</label>
<h3>◇ agent</h3>
<label class="ma-field">
<span>agent</span>
<select id="ma-agent"></select>
</label>
<!-- MATRIX tab: unchanged from the old /matrix-accounts.html, just
<!-- MATRIX tab: unchanged from the old /matrix-accounts.html, just
moved under a tab pane. -->
<section class="cred-pane" id="cred-pane-matrix" data-tab-pane="matrix"
role="tabpanel" aria-labelledby="cred-tab-matrix">
<p class="meta">provision or log in an <strong>external</strong> matrix account for an agent and store its access token. the token is written to the agent's <code>matrixAccounts.&lt;account&gt;.tokenFile</code> by the host coordinator &mdash; it is never displayed back on this page.</p>
<section
class="cred-pane"
id="cred-pane-matrix"
data-tab-pane="matrix"
role="tabpanel"
aria-labelledby="cred-tab-matrix"
>
<p class="meta">
provision or log in an <strong>external</strong> matrix account for an
agent and store its access token. the token is written to the agent's
<code>matrixAccounts.&lt;account&gt;.tokenFile</code> by the host
coordinator &mdash; it is never displayed back on this page.
</p>
<h3>◇ provisioned accounts</h3>
<p class="meta">accounts that have a stored token (provision one below to add it here); a config-declared account that hasn't been provisioned yet won't appear until it has a token. status reflects whether a <em>token is stored</em>, not a live session &mdash; a true online/offline indicator is a follow-up that needs the daemon's account registry.</p>
<div id="ma-list" class="ma-list"><p class="meta">select an agent to see its matrix accounts.</p></div>
<h3>◇ provision / log in</h3>
<form id="ma-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>account name</span>
<input type="text" name="account" placeholder="e.g. public" required>
</label>
<label class="ma-field">
<span>homeserver</span>
<input type="text" name="homeserver" placeholder="https://matrix.org" required>
</label>
<fieldset class="ma-mode">
<legend>login method</legend>
<label><input type="radio" name="mode" value="password" checked> password</label>
<label><input type="radio" name="mode" value="token"> existing token</label>
</fieldset>
<div id="ma-pw-fields" class="ma-modefields">
<label class="ma-field">
<span>user id</span>
<input type="text" name="user_id" placeholder="@user:matrix.org" autocomplete="username">
</label>
<label class="ma-field">
<span>password</span>
<input type="password" name="password" autocomplete="new-password">
</label>
<h3>◇ provisioned accounts</h3>
<p class="meta">
accounts that have a stored token (provision one below to add it
here); a config-declared account that hasn't been provisioned yet
won't appear until it has a token. status reflects whether a
<em>token is stored</em>, not a live session &mdash; a true
online/offline indicator is a follow-up that needs the daemon's
account registry.
</p>
<div id="ma-list" class="ma-list">
<p class="meta">select an agent to see its matrix accounts.</p>
</div>
<div id="ma-token-fields" class="ma-modefields" hidden>
<h3>◇ provision / log in</h3>
<form id="ma-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off">
<span>account name</span>
<input
type="text"
name="account"
placeholder="e.g. public"
required
/>
</label>
<label class="ma-field">
<span>user id <span class="meta">(optional &mdash; derived via whoami)</span></span>
<input type="text" name="user_id" placeholder="@user:matrix.org">
<span>homeserver</span>
<input
type="text"
name="homeserver"
placeholder="https://matrix.org"
required
/>
</label>
</div>
<button type="submit" class="btn btn-spawn">log in &amp; store token</button>
<p id="ma-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<fieldset class="ma-mode">
<legend>login method</legend>
<label
><input type="radio" name="mode" value="password" checked />
password</label
>
<label
><input type="radio" name="mode" value="token" /> existing
token</label
>
</fieldset>
<!-- GITHUB tab: single-account PAT paste. No login flow — the
<div id="ma-pw-fields" class="ma-modefields">
<label class="ma-field">
<span>user id</span>
<input
type="text"
name="user_id"
placeholder="@user:matrix.org"
autocomplete="username"
/>
</label>
<label class="ma-field">
<span>password</span>
<input
type="password"
name="password"
autocomplete="new-password"
/>
</label>
</div>
<div id="ma-token-fields" class="ma-modefields" hidden>
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off" />
</label>
<label class="ma-field">
<span
>user id
<span class="meta"
>(optional &mdash; derived via whoami)</span
></span
>
<input
type="text"
name="user_id"
placeholder="@user:matrix.org"
/>
</label>
</div>
<button type="submit" class="btn btn-spawn">
log in &amp; store token
</button>
<p id="ma-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<!-- GITHUB tab: single-account PAT paste. No login flow — the
operator pastes an existing PAT for a dedicated bot account.
Security-warning banner + a link to generate a PAT. -->
<section class="cred-pane" id="cred-pane-github" data-tab-pane="github"
role="tabpanel" aria-labelledby="cred-tab-github" hidden>
<hive-warn level="warning">
⚠ use a <strong>dedicated bot account</strong>, not a human's &mdash;
and a <strong>minimally-scoped</strong> personal access token (only
the repos/scopes the agent actually needs, e.g. <code>repo</code> +
<code>workflow</code>). the container boundary is the enforcement:
anything within the token's scopes is reachable if the agent is
ever compromised. the token is injected into the agent's state dir
and is <strong>never displayed back</strong> on this page.
</hive-warn>
<section
class="cred-pane"
id="cred-pane-github"
data-tab-pane="github"
role="tabpanel"
aria-labelledby="cred-tab-github"
hidden
>
<hive-warn level="warning">
⚠ use a <strong>dedicated bot account</strong>, not a human's &mdash;
and a <strong>minimally-scoped</strong> personal access token (only
the repos/scopes the agent actually needs, e.g. <code>repo</code> +
<code>workflow</code>). the container boundary is the enforcement:
anything within the token's scopes is reachable if the agent is ever
compromised. the token is injected into the agent's state dir and is
<strong>never displayed back</strong> on this page.
</hive-warn>
<h3>◇ status</h3>
<div id="gh-status" class="gh-status"><p class="meta">select an agent to see its github credential status.</p></div>
<h3>◇ status</h3>
<div id="gh-status" class="gh-status">
<p class="meta">
select an agent to see its github credential status.
</p>
</div>
<h3>◇ provision</h3>
<p class="meta">generate a token at
<a href="https://github.com/settings/tokens" target="_blank" rel="noopener">github.com/settings/tokens</a>
and paste it below. one account per agent &mdash; pasting a new token replaces the stored one.</p>
<form id="gh-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>personal access token</span>
<input type="password" name="token" autocomplete="off" required>
</label>
<button type="submit" class="btn btn-spawn">store token</button>
<p id="gh-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<h3>◇ provision</h3>
<p class="meta">
generate a token at
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener"
>github.com/settings/tokens</a
>
and paste it below. one account per agent &mdash; pasting a new token
replaces the stored one.
</p>
<form id="gh-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>personal access token</span>
<input type="password" name="token" autocomplete="off" required />
</label>
<button type="submit" class="btn btn-spawn">store token</button>
<p id="gh-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
<!-- FORGES tab: external Forgejo/Gitea/Codeberg-compatible forges.
Entirely dashboard-provisioned, no host-side nix config &mdash; same
shape as GITHUB plus a base-URL field (like MATRIX's homeserver).
The operator creates a token on the external forge themselves
(however that forge lets them) and pastes label + URL + token
below. No remote account minting/revoking &mdash; purely local. -->
<section class="cred-pane" id="cred-pane-forges" data-tab-pane="forges"
role="tabpanel" aria-labelledby="cred-tab-forges" hidden>
<p class="meta">store a <strong>label + base URL + access token</strong> for an external Forgejo/Gitea/Codeberg-compatible forge, per agent. no account is created on the remote forge &mdash; create the token there yourself first. the token is never displayed back on this page.</p>
<section
class="cred-pane"
id="cred-pane-forges"
data-tab-pane="forges"
role="tabpanel"
aria-labelledby="cred-tab-forges"
hidden
>
<p class="meta">
store a <strong>label + base URL + access token</strong> for an
external Forgejo/Gitea/Codeberg-compatible forge, per agent. no
account is created on the remote forge &mdash; create the token there
yourself first. the token is never displayed back on this page.
</p>
<h3>◇ provisioned forges</h3>
<div id="ef-list" class="ef-list"><p class="meta">select an agent to see its forge accounts.</p></div>
<h3>◇ provisioned forges</h3>
<div id="ef-list" class="ef-list">
<p class="meta">select an agent to see its forge accounts.</p>
</div>
<h3>◇ add forge account</h3>
<form id="ef-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>label</span>
<input type="text" name="label" placeholder="e.g. codeberg" required>
</label>
<label class="ma-field">
<span>base url</span>
<input type="text" name="base_url" placeholder="https://codeberg.org" required>
</label>
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off" required>
</label>
<button type="submit" class="btn btn-spawn">store account</button>
<p id="ef-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
<h3>◇ add forge account</h3>
<form id="ef-form" class="ma-form" autocomplete="off">
<label class="ma-field">
<span>label</span>
<input
type="text"
name="label"
placeholder="e.g. codeberg"
required
/>
</label>
<label class="ma-field">
<span>base url</span>
<input
type="text"
name="base_url"
placeholder="https://codeberg.org"
required
/>
</label>
<label class="ma-field">
<span>access token</span>
<input type="password" name="token" autocomplete="off" required />
</label>
<button type="submit" class="btn btn-spawn">store account</button>
<p id="ef-result" class="ma-result" aria-live="polite"></p>
</form>
</section>
</main>
</main>
<script type="module" src="/static/credentials.js" defer></script>
</body>
<script type="module" src="/static/credentials.js" defer></script>
</body>
</html>

View file

@ -19,11 +19,11 @@
// themselves and pastes it in, same trust model as GITHUB.
// Per-tab detail comments live next to their section below.
import { $, esc, fmtAgeSecs, renderServerWarnings } from './common.js';
import { el } from '@hive/shared/dom.js';
import '@hive/shared/hive-tab-strip.js';
import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import { readApiError, problemMessage } from '@hive/shared/api-error.js';
import { $, esc, fmtAgeSecs, renderServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import "@hive/shared/hive-tab-strip.js";
import { themedConfirm, themedToast } from "@hive/shared/modal.js";
import { readApiError, problemMessage } from "@hive/shared/api-error.js";
let agents = [];
// agent name → container running (bool), from /api/state. Cross-referenced by
@ -35,7 +35,7 @@ const containerRunning = new Map();
async function loadState() {
try {
const resp = await fetch('/api/state');
const resp = await fetch("/api/state");
if (!resp.ok) return;
const s = await resp.json();
renderServerWarnings(s.server_warnings);
@ -43,7 +43,7 @@ async function loadState() {
// object carrying `.name` + `.running`); there is no top-level `agents`
// field, so the picker stays compatible with both string + object shapes.
const containers = (s.containers || [])
.map((a) => (typeof a === 'string' ? { name: a } : a))
.map((a) => (typeof a === "string" ? { name: a } : a))
.filter((c) => c && c.name);
agents = containers.map((c) => c.name).sort();
containerRunning.clear();
@ -56,14 +56,14 @@ async function loadState() {
}
function renderAgentPicker() {
const sel = $('ma-agent');
const sel = $("ma-agent");
sel.replaceChildren();
if (!agents.length) {
sel.append(el('option', { value: '' }, '— no agents —'));
sel.append(el("option", { value: "" }, "— no agents —"));
return;
}
sel.append(el('option', { value: '' }, '— select agent —'));
for (const a of agents) sel.append(el('option', { value: a }, a));
sel.append(el("option", { value: "" }, "— select agent —"));
for (const a of agents) sel.append(el("option", { value: a }, a));
}
// Shape-agnostic error-body parsing (shared by both tabs' submit handlers)
@ -86,91 +86,116 @@ function renderAgentPicker() {
// v1 backend (no `live` field) falls back to token-present rendering.
async function loadAccounts(agent) {
const list = $('ma-list');
const list = $("ma-list");
if (!agent) {
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its matrix accounts.'));
list.replaceChildren(
el("p", { class: "meta" }, "select an agent to see its matrix accounts."),
);
return;
}
list.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
list.replaceChildren(el("p", { class: "meta" }, "loading…"));
let data;
try {
const resp = await fetch('/api/matrix-accounts?agent=' + encodeURIComponent(agent));
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const resp = await fetch(
"/api/matrix-accounts?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
data = await resp.json();
} catch (err) {
list.replaceChildren(el('p', { class: 'err' },
'could not load accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
list.replaceChildren(
el(
"p",
{ class: "err" },
"could not load accounts: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return;
}
const accounts = data.accounts || [];
const asOf = typeof data.as_of_unix === 'number' ? data.as_of_unix : null;
const asOf = typeof data.as_of_unix === "number" ? data.as_of_unix : null;
// `false` only when the container is explicitly down; `undefined` (unknown,
// e.g. a failed /api/state read) is treated as not-down so we never flag a
// false stale.
const running = containerRunning.get(agent);
// The daemon force-rewrites its snapshot every ~30s, so `as_of_unix` advances
// while it's alive — this is a heartbeat, and a stalled value is meaningful.
const ageSecs = asOf != null
? Math.max(0, Math.floor(Date.now() / 1000) - asOf)
: null;
const asOfText = asOf != null
? 'matrix snapshot · live as of ' + fmtAgeSecs(ageSecs) + ' ago'
: 'no daemon snapshot yet';
const ageSecs =
asOf != null ? Math.max(0, Math.floor(Date.now() / 1000) - asOf) : null;
const asOfText =
asOf != null
? "matrix snapshot · live as of " + fmtAgeSecs(ageSecs) + " ago"
: "no daemon snapshot yet";
// 3 missed ~30s heartbeats. Past this a `live` snapshot whose container is
// NOT down means the daemon stopped publishing (dead/wedged) — dim its dot.
const STALE_AGE_SECS = 90;
const staleByAge = ageSecs != null && ageSecs > STALE_AGE_SECS;
list.replaceChildren();
if (!accounts.length) {
list.append(el('p', { class: 'meta' }, 'no matrix accounts configured for this agent.'));
list.append(
el(
"p",
{ class: "meta" },
"no matrix accounts configured for this agent.",
),
);
return;
}
const ul = el('ul', { class: 'ma-accounts' });
const ul = el("ul", { class: "ma-accounts" });
for (const acc of accounts) {
const present = !!acc.token_present;
// 3-state dot. `live` is absent on the v1 backend (pre BE-4); when
// undefined, fall back to the v1 token-present rendering so the page
// degrades cleanly before the snapshot backend deploys.
let cls; let statusText; let dotTitle;
let cls;
let statusText;
let dotTitle;
if (acc.live === undefined) {
cls = present ? 'ok' : 'absent';
statusText = present ? 'token stored ✓' : 'no token';
dotTitle = present ? 'token stored' : 'no token yet';
cls = present ? "ok" : "absent";
statusText = present ? "token stored ✓" : "no token";
dotTitle = present ? "token stored" : "no token yet";
} else if (acc.live && running === false) {
// container down ⟹ daemon down ⟹ a "live" snapshot is stale.
cls = 'stale';
statusText = 'container stopped';
dotTitle = 'container is stopped — live status is stale. ' + asOfText;
cls = "stale";
statusText = "container stopped";
dotTitle = "container is stopped — live status is stale. " + asOfText;
} else if (acc.live && staleByAge) {
// Snapshot says live, but the heartbeat (snapshot mtime = as_of) hasn't
// advanced in > ~90s while the container is NOT down — the daemon stopped
// publishing, so the "live" is no longer trustworthy. Keep the green
// family but dim it (distinct from the amber container-down 'stale').
cls = 'live stale-age';
statusText = 'online · no heartbeat';
dotTitle = 'snapshot says live but the daemon heartbeat stalled '
+ fmtAgeSecs(ageSecs) + ' ago (publishes every ~30s) — likely dead or wedged. '
+ asOfText;
cls = "live stale-age";
statusText = "online · no heartbeat";
dotTitle =
"snapshot says live but the daemon heartbeat stalled " +
fmtAgeSecs(ageSecs) +
" ago (publishes every ~30s) — likely dead or wedged. " +
asOfText;
} else if (acc.live) {
cls = 'live';
statusText = 'online ✓';
cls = "live";
statusText = "online ✓";
dotTitle = asOfText;
} else if (present) {
cls = 'offline';
statusText = 'token stored · offline';
dotTitle = 'provisioned but not live. ' + asOfText;
cls = "offline";
statusText = "token stored · offline";
dotTitle = "provisioned but not live. " + asOfText;
} else {
cls = 'absent';
statusText = 'no token';
dotTitle = 'no token yet';
cls = "absent";
statusText = "no token";
dotTitle = "no token yet";
}
ul.append(el('li', { class: 'ma-account' },
el('span', { class: 'ma-dot ' + cls, title: dotTitle }),
el('span', { class: 'ma-name' }, acc.name || '(unnamed)'),
acc.user_id ? el('span', { class: 'ma-uid' }, acc.user_id) : null,
el('span', { class: 'ma-hs' }, acc.homeserver || '—'),
el('span', { class: 'ma-status ' + cls, title: asOfText }, statusText),
));
ul.append(
el(
"li",
{ class: "ma-account" },
el("span", { class: "ma-dot " + cls, title: dotTitle }),
el("span", { class: "ma-name" }, acc.name || "(unnamed)"),
acc.user_id ? el("span", { class: "ma-uid" }, acc.user_id) : null,
el("span", { class: "ma-hs" }, acc.homeserver || "—"),
el("span", { class: "ma-status " + cls, title: asOfText }, statusText),
),
);
}
list.append(ul);
}
@ -181,72 +206,90 @@ async function loadAccounts(agent) {
// both — would be submitted).
function toggleModeFields() {
const mode = document.querySelector('input[name="mode"]:checked');
const value = mode ? mode.value : 'password';
const pw = $('ma-pw-fields');
const tok = $('ma-token-fields');
pw.hidden = value !== 'password';
tok.hidden = value !== 'token';
pw.querySelectorAll('input').forEach((i) => { i.disabled = pw.hidden; });
tok.querySelectorAll('input').forEach((i) => { i.disabled = tok.hidden; });
const value = mode ? mode.value : "password";
const pw = $("ma-pw-fields");
const tok = $("ma-token-fields");
pw.hidden = value !== "password";
tok.hidden = value !== "token";
pw.querySelectorAll("input").forEach((i) => {
i.disabled = pw.hidden;
});
tok.querySelectorAll("input").forEach((i) => {
i.disabled = tok.hidden;
});
}
function clearSecrets(formEl) {
formEl.querySelectorAll('input[type="password"], input[name="token"]')
.forEach((i) => { i.value = ''; });
formEl
.querySelectorAll('input[type="password"], input[name="token"]')
.forEach((i) => {
i.value = "";
});
}
async function submitLogin(e) {
e.preventDefault();
const formEl = e.target;
const out = $('ma-result');
out.className = 'ma-result';
out.textContent = '';
const out = $("ma-result");
out.className = "ma-result";
out.textContent = "";
const agent = $('ma-agent').value;
const agent = $("ma-agent").value;
if (!agent) {
out.className = 'ma-result err';
out.textContent = 'select an agent first.';
out.className = "ma-result err";
out.textContent = "select an agent first.";
return;
}
const fd = new FormData(formEl);
fd.set('agent', agent);
fd.set("agent", agent);
const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = 'logging in…';
btn.textContent = "logging in…";
try {
const resp = await fetch('/api/matrix-account-login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
const resp = await fetch("/api/matrix-account-login", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd),
});
if (resp.ok) {
// Success is 200 + JSON { ok, user_id }.
let body = {};
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ }
try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) {
out.className = 'ma-result ok';
out.textContent = '✓ logged in as ' + (body.user_id || '(unknown)') + ' — token stored.';
out.className = "ma-result ok";
out.textContent =
"✓ logged in as " +
(body.user_id || "(unknown)") +
" — token stored.";
clearSecrets(formEl);
loadAccounts(agent);
} else {
out.className = 'ma-result err';
out.textContent = '✗ login failed (unexpected response).';
out.className = "ma-result err";
out.textContent = "✗ login failed (unexpected response).";
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('login failed (HTTP ' + resp.status + ')'));
out.className = "ma-result err";
out.textContent =
"✗ " + (msg || "login failed (HTTP " + resp.status + ")");
clearSecrets(formEl);
}
} catch (err) {
out.className = 'ma-result err';
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
out.className = "ma-result err";
out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally {
btn.disabled = false;
btn.textContent = orig;
@ -256,81 +299,111 @@ async function submitLogin(e) {
// ─── GITHUB tab ─────────────────────────────────────────────────────────
async function loadGithubStatus(agent) {
const status = $('gh-status');
const status = $("gh-status");
if (!agent) {
status.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its github credential status.'));
status.replaceChildren(
el(
"p",
{ class: "meta" },
"select an agent to see its github credential status.",
),
);
return;
}
status.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
status.replaceChildren(el("p", { class: "meta" }, "loading…"));
let data;
try {
const resp = await fetch('/api/github-account?agent=' + encodeURIComponent(agent));
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const resp = await fetch(
"/api/github-account?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
data = await resp.json();
} catch (err) {
status.replaceChildren(el('p', { class: 'err' },
'could not load status: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
status.replaceChildren(
el(
"p",
{ class: "err" },
"could not load status: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return;
}
const present = !!data.present;
status.replaceChildren(el('div', { class: 'gh-status-line' },
el('span', { class: 'gh-dot ' + (present ? 'present' : 'absent') }),
el('span', { class: 'gh-status-text ' + (present ? 'present' : 'absent') },
present ? 'token stored ✓' : 'not set'),
));
status.replaceChildren(
el(
"div",
{ class: "gh-status-line" },
el("span", { class: "gh-dot " + (present ? "present" : "absent") }),
el(
"span",
{ class: "gh-status-text " + (present ? "present" : "absent") },
present ? "token stored ✓" : "not set",
),
),
);
}
async function submitGithub(e) {
e.preventDefault();
const formEl = e.target;
const out = $('gh-result');
out.className = 'ma-result';
out.textContent = '';
const out = $("gh-result");
out.className = "ma-result";
out.textContent = "";
const agent = $('ma-agent').value;
const agent = $("ma-agent").value;
if (!agent) {
out.className = 'ma-result err';
out.textContent = 'select an agent first.';
out.className = "ma-result err";
out.textContent = "select an agent first.";
return;
}
const fd = new FormData(formEl);
fd.set('agent', agent);
fd.set("agent", agent);
const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = 'storing…';
btn.textContent = "storing…";
try {
const resp = await fetch('/api/github-account', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
const resp = await fetch("/api/github-account", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd),
});
if (resp.ok) {
let body = {};
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ }
try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) {
out.className = 'ma-result ok';
out.textContent = '✓ token stored.';
out.className = "ma-result ok";
out.textContent = "✓ token stored.";
clearSecrets(formEl);
loadGithubStatus(agent);
} else {
out.className = 'ma-result err';
out.textContent = '✗ store failed (unexpected response).';
out.className = "ma-result err";
out.textContent = "✗ store failed (unexpected response).";
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
out.className = "ma-result err";
out.textContent =
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
clearSecrets(formEl);
}
} catch (err) {
out.className = 'ma-result err';
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
out.className = "ma-result err";
out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally {
btn.disabled = false;
btn.textContent = orig;
@ -347,39 +420,56 @@ async function submitGithub(e) {
// forge themselves.
async function loadForgeAccounts(agent) {
const list = $('ef-list');
const list = $("ef-list");
if (!agent) {
list.replaceChildren(el('p', { class: 'meta' }, 'select an agent to see its forge accounts.'));
list.replaceChildren(
el("p", { class: "meta" }, "select an agent to see its forge accounts."),
);
return;
}
list.replaceChildren(el('p', { class: 'meta' }, 'loading…'));
list.replaceChildren(el("p", { class: "meta" }, "loading…"));
let forges;
try {
const resp = await fetch('/api/extra-forges?agent=' + encodeURIComponent(agent));
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const resp = await fetch(
"/api/extra-forges?agent=" + encodeURIComponent(agent),
);
if (!resp.ok) throw new Error("HTTP " + resp.status);
forges = (await resp.json()).forges || [];
} catch (err) {
list.replaceChildren(el('p', { class: 'err' },
'could not load forge accounts: ' + esc(String(err)) + ' (the backend endpoint may not be deployed yet).'));
list.replaceChildren(
el(
"p",
{ class: "err" },
"could not load forge accounts: " +
esc(String(err)) +
" (the backend endpoint may not be deployed yet).",
),
);
return;
}
list.replaceChildren();
if (!forges.length) {
list.replaceChildren(el('p', { class: 'meta' }, 'no forge accounts stored for this agent.'));
list.replaceChildren(
el("p", { class: "meta" }, "no forge accounts stored for this agent."),
);
return;
}
const ul = el('ul', { class: 'ma-accounts' });
const ul = el("ul", { class: "ma-accounts" });
for (const forge of forges) {
const btn = el('button', { class: 'btn', type: 'button' }, 'remove');
btn.addEventListener('click', () => onForgeRemoveClick(agent, forge, btn));
ul.append(el('li', { class: 'ma-account' },
el('span', { class: 'ma-dot ok' }),
el('span', { class: 'ma-name' }, forge.label),
el('span', { class: 'ma-hs' }, forge.base_url || '—'),
el('span', { class: 'ma-status ok' }, 'token stored ✓'),
btn,
));
const btn = el("button", { class: "btn", type: "button" }, "remove");
btn.addEventListener("click", () => onForgeRemoveClick(agent, forge, btn));
ul.append(
el(
"li",
{ class: "ma-account" },
el("span", { class: "ma-dot ok" }),
el("span", { class: "ma-name" }, forge.label),
el("span", { class: "ma-hs" }, forge.base_url || "—"),
el("span", { class: "ma-status ok" }, "token stored ✓"),
btn,
),
);
}
list.append(ul);
}
@ -388,18 +478,22 @@ async function onForgeRemoveClick(agent, forge, btn) {
const r = await themedConfirm({
message: `remove ${agent}'s stored token for ${forge.label}? this only deletes the local copy — nothing changes on the remote forge.`,
danger: true,
confirmLabel: '⊘ remove',
confirmLabel: "⊘ remove",
});
if (!r) return;
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = 'removing…';
btn.textContent = "removing…";
try {
const resp = await fetch('/api/extra-forge-account', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ agent, label: forge.label, action: 'remove' }),
const resp = await fetch("/api/extra-forge-account", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
agent,
label: forge.label,
action: "remove",
}),
});
if (resp.ok) {
loadForgeAccounts(agent);
@ -408,66 +502,76 @@ async function onForgeRemoveClick(agent, forge, btn) {
const msg = problemMessage(await readApiError(resp));
btn.textContent = orig;
btn.disabled = false;
themedToast('✗ ' + (msg || ('remove failed (HTTP ' + resp.status + ')')), { type: 'error' });
themedToast("✗ " + (msg || "remove failed (HTTP " + resp.status + ")"), {
type: "error",
});
} catch (err) {
btn.textContent = orig;
btn.disabled = false;
themedToast('✗ request failed: ' + String(err), { type: 'error' });
themedToast("✗ request failed: " + String(err), { type: "error" });
}
}
async function submitForgeAccount(e) {
e.preventDefault();
const formEl = e.target;
const out = $('ef-result');
out.className = 'ma-result';
out.textContent = '';
const out = $("ef-result");
out.className = "ma-result";
out.textContent = "";
const agent = $('ma-agent').value;
const agent = $("ma-agent").value;
if (!agent) {
out.className = 'ma-result err';
out.textContent = 'select an agent first.';
out.className = "ma-result err";
out.textContent = "select an agent first.";
return;
}
const fd = new FormData(formEl);
fd.set('agent', agent);
fd.set('action', 'add');
fd.set("agent", agent);
fd.set("action", "add");
const btn = formEl.querySelector('button[type="submit"]');
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = 'storing…';
btn.textContent = "storing…";
try {
const resp = await fetch('/api/extra-forge-account', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
const resp = await fetch("/api/extra-forge-account", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(fd),
});
if (resp.ok) {
let body = {};
try { body = await resp.json(); } catch { /* tolerate odd 2xx body */ }
try {
body = await resp.json();
} catch {
/* tolerate odd 2xx body */
}
if (body.ok) {
out.className = 'ma-result ok';
out.textContent = '✓ forge account stored.';
out.className = "ma-result ok";
out.textContent = "✓ forge account stored.";
clearSecrets(formEl);
loadForgeAccounts(agent);
} else {
out.className = 'ma-result err';
out.textContent = '✗ store failed (unexpected response).';
out.className = "ma-result err";
out.textContent = "✗ store failed (unexpected response).";
clearSecrets(formEl);
}
} else {
const msg = problemMessage(await readApiError(resp));
out.className = 'ma-result err';
out.textContent = '✗ ' + (msg || ('store failed (HTTP ' + resp.status + ')'));
out.className = "ma-result err";
out.textContent =
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
clearSecrets(formEl);
}
} catch (err) {
out.className = 'ma-result err';
out.textContent = '✗ request failed: ' + String(err) + ' (the backend endpoint may not be deployed yet).';
out.className = "ma-result err";
out.textContent =
"✗ request failed: " +
String(err) +
" (the backend endpoint may not be deployed yet).";
} finally {
btn.disabled = false;
btn.textContent = orig;
@ -485,24 +589,27 @@ async function onAgentChange(agent) {
async function init() {
await loadState();
renderAgentPicker();
$('ma-agent').addEventListener('change', (e) => onAgentChange(e.target.value));
document.querySelectorAll('input[name="mode"]')
.forEach((r) => r.addEventListener('change', toggleModeFields));
$("ma-agent").addEventListener("change", (e) =>
onAgentChange(e.target.value),
);
document
.querySelectorAll('input[name="mode"]')
.forEach((r) => r.addEventListener("change", toggleModeFields));
toggleModeFields();
$('ma-form').addEventListener('submit', submitLogin);
$('gh-form').addEventListener('submit', submitGithub);
$('ef-form').addEventListener('submit', submitForgeAccount);
$("ma-form").addEventListener("submit", submitLogin);
$("gh-form").addEventListener("submit", submitGithub);
$("ef-form").addEventListener("submit", submitForgeAccount);
document.getElementById('cred-tabbar').configure({
document.getElementById("cred-tabbar").configure({
tabs: [
{ id: 'matrix', label: 'MATRIX' },
{ id: 'github', label: 'GITHUB' },
{ id: 'forges', label: 'FORGES' },
{ id: "matrix", label: "MATRIX" },
{ id: "github", label: "GITHUB" },
{ id: "forges", label: "FORGES" },
],
defaultId: 'matrix',
defaultId: "matrix",
});
onAgentChange('');
onAgentChange("");
}
init();

View file

@ -56,7 +56,9 @@ body.dashboard-shell {
font-size: 0.82em;
white-space: nowrap;
}
.dash-home-back:hover { text-decoration: underline; }
.dash-home-back:hover {
text-decoration: underline;
}
.banner-thin {
text-align: center;
margin: 0;
@ -95,7 +97,9 @@ body.dashboard-shell {
border: 0;
border-radius: 4px;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
transition:
color 0.15s ease,
background 0.15s ease;
}
.tabbar .tab:hover {
color: var(--fg);
@ -105,7 +109,10 @@ body.dashboard-shell {
color: var(--purple);
background: var(--border);
}
.tab-label { font-weight: bold; white-space: nowrap; }
.tab-label {
font-weight: bold;
white-space: nowrap;
}
.tab-count {
display: inline-block;
background: var(--purple-dim);
@ -124,11 +131,12 @@ body.dashboard-shell {
color: var(--red);
}
/* Tab pane visibility createTabStrip (@hive/shared/tabs.js) sets the
`hidden` attribute on every inactive pane (resolved from the URL hash;
default no hash is SW4RM). Only the active pane stays visible. */
.tab-pane[hidden] { display: none; }
.tab-pane[hidden] {
display: none;
}
/* FL0W is a separate page (`/flow.html`) its full-viewport
styling lives in flow.css. */
@ -148,14 +156,20 @@ body.dashboard-shell {
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--purple) 45%, transparent));
filter: drop-shadow(
0 0 6px color-mix(in srgb, var(--purple) 45%, transparent)
);
}
.banner.active {
animation: banner-shimmer 1.8s linear infinite;
}
@keyframes banner-shimmer {
from { background-position: 200% 0; }
to { background-position: -100% 0; }
from {
background-position: 200% 0;
}
to {
background-position: -100% 0;
}
}
.role {
@ -172,24 +186,44 @@ body.dashboard-shell {
/* Container rows: a full-height square agent icon on the left, the
identity / actions / drill-in lines stacked in the card body on the
right. Pending rows dim everything except the pending indicator. */
.containers { display: flex; flex-direction: column; gap: 0.4em; }
.containers {
display: flex;
flex-direction: column;
gap: 0.4em;
}
.container-row {
padding: 0.6em 0.8em;
border: 1px solid var(--border);
border-radius: 4px;
background: color-mix(in srgb, var(--bg-elev) 55%, transparent);
transition: opacity 200ms ease, border-color 200ms ease;
transition:
opacity 200ms ease,
border-color 200ms ease;
}
/* Topology indent ladder. See docs/web-ui.md::Topology tree (Indent
+ lane geometry paragraph) for the 1.8em-per-depth-level
rationale + CSS-attr()-not-yet-portable caveat. */
.container-row[data-depth] { position: relative; }
.container-row[data-depth="1"] { margin-left: 1.8em; }
.container-row[data-depth="2"] { margin-left: 3.6em; }
.container-row[data-depth="3"] { margin-left: 5.4em; }
.container-row[data-depth="4"] { margin-left: 7.2em; }
.container-row[data-depth="5"] { margin-left: 9em; }
.container-row[data-depth="6"] { margin-left: 10.8em; }
.container-row[data-depth] {
position: relative;
}
.container-row[data-depth="1"] {
margin-left: 1.8em;
}
.container-row[data-depth="2"] {
margin-left: 3.6em;
}
.container-row[data-depth="3"] {
margin-left: 5.4em;
}
.container-row[data-depth="4"] {
margin-left: 7.2em;
}
.container-row[data-depth="5"] {
margin-left: 9em;
}
.container-row[data-depth="6"] {
margin-left: 10.8em;
}
/* Tree prefix lanes — DOM-painted, not text-glyph-painted. */
.container-row .tree-prefix {
position: absolute;
@ -201,19 +235,31 @@ body.dashboard-shell {
user-select: none;
color: var(--purple-dim);
}
.container-row[data-depth="1"] .tree-prefix { left: -1.8em; }
.container-row[data-depth="2"] .tree-prefix { left: -3.6em; }
.container-row[data-depth="3"] .tree-prefix { left: -5.4em; }
.container-row[data-depth="4"] .tree-prefix { left: -7.2em; }
.container-row[data-depth="5"] .tree-prefix { left: -9em; }
.container-row[data-depth="6"] .tree-prefix { left: -10.8em; }
.container-row[data-depth="1"] .tree-prefix {
left: -1.8em;
}
.container-row[data-depth="2"] .tree-prefix {
left: -3.6em;
}
.container-row[data-depth="3"] .tree-prefix {
left: -5.4em;
}
.container-row[data-depth="4"] .tree-prefix {
left: -7.2em;
}
.container-row[data-depth="5"] .tree-prefix {
left: -9em;
}
.container-row[data-depth="6"] .tree-prefix {
left: -10.8em;
}
.tree-prefix .tree-lane {
flex: 0 0 1.8em;
position: relative;
}
.tree-prefix .lane-line::before,
.tree-prefix .lane-joint::before {
content: '';
content: "";
position: absolute;
left: 0.6em;
top: 0;
@ -225,7 +271,7 @@ body.dashboard-shell {
height: 3.1em;
}
.tree-prefix .lane-joint::after {
content: '';
content: "";
position: absolute;
left: 0.6em;
top: 3.1em;
@ -246,7 +292,9 @@ body.dashboard-shell {
border-radius: 6px;
background-color: color-mix(in srgb, var(--crust) 60%, transparent);
cursor: pointer;
transition: box-shadow 120ms ease, transform 120ms ease;
transition:
box-shadow 120ms ease,
transform 120ms ease;
}
.container-row:not(.tombstone) > .container-icon:hover {
box-shadow: 0 0 0 2px var(--purple);
@ -261,7 +309,9 @@ body.dashboard-shell {
background: color-mix(in srgb, var(--purple) 6%, transparent);
}
.container-row.selected > .container-icon {
box-shadow: 0 0 0 2px var(--purple), 0 0 12px -4px var(--purple);
box-shadow:
0 0 0 2px var(--purple),
0 0 12px -4px var(--purple);
}
.container-row:not(.tombstone) > .container-icon > .container-icon-img {
position: absolute;
@ -352,17 +402,24 @@ hive-agent-menu {
driven only now see swarm.js's pending-badge derivation comment;
there is no separate queued-but-not-running row state to tell apart
from this one anymore). */
.container-row.pending-running .actions { opacity: 0.4; pointer-events: none; }
.container-row.pending-running .actions {
opacity: 0.4;
pointer-events: none;
}
.container-row.pending-running {
border-color: var(--amber);
background: color-mix(in srgb, var(--amber) 5%, transparent);
}
@keyframes container-icon-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.container-row.pending-running > .container-icon::after {
content: '';
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
@ -382,7 +439,9 @@ hive-agent-menu {
font-size: 1.05em;
font-weight: bold;
}
.container-row .head .meta { margin-left: auto; }
.container-row .head .meta {
margin-left: auto;
}
.container-row .head .nav-strip {
display: inline-flex;
align-items: center;
@ -396,7 +455,9 @@ hive-agent-menu {
padding: 0.15em 0.35em;
border-radius: 3px;
text-decoration: none;
transition: background 0.12s ease, color 0.12s ease;
transition:
background 0.12s ease,
color 0.12s ease;
}
.nav-link:hover {
background: color-mix(in srgb, var(--purple) 12%, transparent);
@ -408,7 +469,10 @@ hive-agent-menu {
flex-wrap: wrap;
gap: 0.4em;
}
.container-row .actions form.inline { display: inline-block; margin: 0; }
.container-row .actions form.inline {
display: inline-block;
margin: 0;
}
.agent-status {
font-size: 0.82em;
@ -433,7 +497,9 @@ hive-agent-menu {
overflow: hidden;
overflow-wrap: anywhere;
}
.agent-status .status-icon { opacity: 0.65; }
.agent-status .status-icon {
opacity: 0.65;
}
.agent-status .status-age {
flex: 0 0 auto;
opacity: 0.5;
@ -446,7 +512,9 @@ hive-agent-menu {
background: color-mix(in srgb, var(--bg-elev) 35%, transparent);
opacity: 0.85;
}
.container-row.tombstone .name { color: var(--muted); }
.container-row.tombstone .name {
color: var(--muted);
}
/* K3PT ST4T3 caveat and the port-collision banner both moved to the
shared <hive-warn> component amber/no-pulse for
@ -497,13 +565,22 @@ hive-agent-menu {
animation: badge-pulse 1.6s ease-in-out infinite;
}
@keyframes badge-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* Pending approval: a card with three stacked sections identity
header, what-changed body, decision actions. */
.approvals { list-style: none; padding: 0; margin: 0.4em 0 0; }
.approvals {
list-style: none;
padding: 0;
margin: 0.4em 0 0;
}
.approval-card {
background: var(--bg-elev);
border: 1px solid var(--border);
@ -543,7 +620,9 @@ hive-agent-menu {
padding-top: 0.45em;
border-top: 1px solid var(--border);
}
.approval-actions form.inline { display: inline; }
.approval-actions form.inline {
display: inline;
}
/* Inline drill-in triggers (logs / config repo / view diff). */
.drill-ins {
display: flex;
@ -551,7 +630,9 @@ hive-agent-menu {
gap: 0.15em 1.1em;
margin-top: 0.4em;
}
.drill-ins .panel-trigger { margin-top: 0; }
.drill-ins .panel-trigger {
margin-top: 0;
}
.approval-tabs {
display: flex;
@ -567,9 +648,14 @@ hive-agent-menu {
letter-spacing: 0.08em;
padding: 0.25em 0.9em;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
transition:
color 0.15s ease,
border-color 0.15s ease,
background 0.15s ease;
}
.approval-tab:hover {
color: var(--fg);
}
.approval-tab:hover { color: var(--fg); }
.approval-tab.active {
color: var(--purple);
border-color: var(--purple);
@ -587,18 +673,43 @@ hive-agent-menu {
padding: 0.35em 0.8em;
margin-bottom: 0.4em;
}
.approvals-history li:has(.glyph-approved) { border-left-color: var(--green); }
.approvals-history li:has(.glyph-denied) { border-left-color: var(--red); }
.approvals-history li:has(.glyph-failed) { border-left-color: var(--amber); }
.approvals-history .status { font-size: 0.85em; padding: 0 0.5em; }
.status-approved { color: var(--green); }
.status-denied { color: var(--red); }
.status-failed { color: var(--amber); }
.status-cancelled { color: var(--muted); }
.glyph-approved { color: var(--green); }
.glyph-denied { color: var(--red); }
.glyph-failed { color: var(--amber); }
.glyph-cancelled { color: var(--muted); }
.approvals-history li:has(.glyph-approved) {
border-left-color: var(--green);
}
.approvals-history li:has(.glyph-denied) {
border-left-color: var(--red);
}
.approvals-history li:has(.glyph-failed) {
border-left-color: var(--amber);
}
.approvals-history .status {
font-size: 0.85em;
padding: 0 0.5em;
}
.status-approved {
color: var(--green);
}
.status-denied {
color: var(--red);
}
.status-failed {
color: var(--amber);
}
.status-cancelled {
color: var(--muted);
}
.glyph-approved {
color: var(--green);
}
.glyph-denied {
color: var(--red);
}
.glyph-failed {
color: var(--amber);
}
.glyph-cancelled {
color: var(--muted);
}
.history-note {
margin-left: 1.8em;
@ -608,9 +719,14 @@ hive-agent-menu {
white-space: pre-wrap;
word-break: break-word;
}
ul form.inline { display: inline-block; }
ul form.inline {
display: inline-block;
}
.role-pending { color: var(--amber); border-color: var(--amber); }
.role-pending {
color: var(--amber);
border-color: var(--amber);
}
.btn-inline {
font-family: inherit;
background: transparent;
@ -619,7 +735,9 @@ ul form.inline { display: inline-block; }
}
/* Off-palette warm-amber hover tint: not an exact theme var (brighter than
--amber), left as a literal pending a dedicated named var. */
.btn-inline:hover { background: rgba(255, 184, 77, 0.1); }
.btn-inline:hover {
background: rgba(255, 184, 77, 0.1);
}
.kind {
display: inline-block;
margin-left: 0.4em;
@ -631,8 +749,13 @@ ul form.inline { display: inline-block; }
letter-spacing: 0.1em;
text-transform: uppercase;
}
.kind-spawn { color: var(--amber); border-color: var(--amber); }
details { margin-top: 0.5em; }
.kind-spawn {
color: var(--amber);
border-color: var(--amber);
}
details {
margin-top: 0.5em;
}
summary {
cursor: pointer;
color: var(--muted);
@ -640,7 +763,9 @@ summary {
text-transform: uppercase;
letter-spacing: 0.1em;
}
summary:hover { color: var(--purple); }
summary:hover {
color: var(--purple);
}
.diff {
background: var(--bg-elev);
border: 1px solid var(--border);
@ -652,12 +777,25 @@ summary:hover { color: var(--purple); }
color: var(--muted);
white-space: pre;
}
.diff span { display: block; }
.diff .diff-add { color: var(--green); }
.diff .diff-del { color: var(--red); }
.diff .diff-hunk { color: var(--cyan); }
.diff .diff-file { color: var(--purple); font-weight: bold; }
.diff .diff-ctx { color: var(--fg); }
.diff span {
display: block;
}
.diff .diff-add {
color: var(--green);
}
.diff .diff-del {
color: var(--red);
}
.diff .diff-hunk {
color: var(--cyan);
}
.diff .diff-file {
color: var(--purple);
font-weight: bold;
}
.diff .diff-ctx {
color: var(--fg);
}
footer {
margin-top: 4em;
@ -665,7 +803,9 @@ footer {
color: var(--muted);
font-size: 0.9em;
}
footer a { color: var(--purple); }
footer a {
color: var(--purple);
}
footer .banner-thin {
margin-bottom: 0.8em;
}
@ -674,8 +814,15 @@ footer .banner-thin {
Agents × capabilities matrix in the P3RM1SS10NS tab. Same layout
as the tool-groups table below. Horizontally scrollable on narrow
viewports. */
.cap-table-wrap { overflow-x: auto; margin-top: 0.5em; }
.cap-table { border-collapse: collapse; font-size: 0.82em; min-width: 100%; }
.cap-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.cap-table {
border-collapse: collapse;
font-size: 0.82em;
min-width: 100%;
}
.cap-table th,
.cap-table td {
padding: 0.35em 0.6em;
@ -714,16 +861,48 @@ footer .banner-thin {
Agents × tool-groups matrix in the P3RM1SS10NS tab. Same layout
as the capabilities table above. Horizontally scrollable on narrow
viewports. */
.tg-table-wrap { overflow-x: auto; margin-top: 0.5em; }
.tg-table { border-collapse: collapse; font-size: 0.82em; min-width: 100%; }
.tg-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.tg-table {
border-collapse: collapse;
font-size: 0.82em;
min-width: 100%;
}
.tg-table th,
.tg-table td { padding: 0.35em 0.6em; border: 1px solid var(--border); text-align: center; vertical-align: middle; }
.tg-table thead th { background: var(--bg-elev); color: var(--muted); letter-spacing: 0.05em; white-space: nowrap; }
.tg-agent-col { text-align: left !important; min-width: 8em; }
.tg-group-col { min-width: 5em; }
.tg-agent-name { color: var(--fg); font-weight: 600; }
.tg-cb { cursor: pointer; width: 1em; height: 1em; accent-color: var(--purple); }
.tg-row:hover td { background: var(--bg-elev); }
.tg-table td {
padding: 0.35em 0.6em;
border: 1px solid var(--border);
text-align: center;
vertical-align: middle;
}
.tg-table thead th {
background: var(--bg-elev);
color: var(--muted);
letter-spacing: 0.05em;
white-space: nowrap;
}
.tg-agent-col {
text-align: left !important;
min-width: 8em;
}
.tg-group-col {
min-width: 5em;
}
.tg-agent-name {
color: var(--fg);
font-weight: 600;
}
.tg-cb {
cursor: pointer;
width: 1em;
height: 1em;
accent-color: var(--purple);
}
.tg-row:hover td {
background: var(--bg-elev);
}
/* permissions save bar (save-all)
One page-level save button for the whole P3RM1SS10NS tab. Sticks to
@ -747,10 +926,14 @@ footer .banner-thin {
opacity: 0.45;
cursor: not-allowed;
}
.perm-save-err { color: var(--red); }
.perm-save-err {
color: var(--red);
}
/* Stale permission entry (agent not in the live roster) */
.perm-row-stale td { opacity: 0.7; }
.perm-row-stale td {
opacity: 0.7;
}
.perm-stale-label {
font-size: 0.75em;
color: var(--muted);
@ -768,8 +951,13 @@ footer .banner-thin {
font-family: inherit;
opacity: 0.8;
}
.perm-remove-btn:hover { opacity: 1; }
.perm-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.perm-remove-btn:hover {
opacity: 1;
}
.perm-remove-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* scheduled prompts tab
Creation form at the top, list of queued schedule cards below. */
@ -816,7 +1004,10 @@ footer .banner-thin {
border-radius: 3px;
padding: 0.35em 0.5em;
}
.schedule-field textarea { resize: vertical; min-height: 4em; }
.schedule-field textarea {
resize: vertical;
min-height: 4em;
}
.schedule-targets {
display: flex;
flex-wrap: wrap;
@ -833,13 +1024,17 @@ footer .banner-thin {
cursor: pointer;
background: color-mix(in srgb, var(--bg-elev) 40%, transparent);
}
.schedule-target-chip:hover { border-color: var(--purple-dim); }
.schedule-target-chip:hover {
border-color: var(--purple-dim);
}
.schedule-target-chip:has(input:checked) {
border-color: var(--purple);
color: var(--purple);
background: color-mix(in srgb, var(--purple) 8%, transparent);
}
.schedule-target-chip input { margin: 0; }
.schedule-target-chip input {
margin: 0;
}
.schedule-actions {
display: flex;
gap: 0.5em;
@ -886,9 +1081,15 @@ footer .banner-thin {
font-size: 0.9em;
font-variant-numeric: tabular-nums;
}
.schedule-interval-preview-oneshot { color: var(--muted); font-style: italic; }
.schedule-interval-preview-oneshot {
color: var(--muted);
font-style: italic;
}
.schedules-table-wrap { overflow-x: auto; margin-top: 0.5em; }
.schedules-table-wrap {
overflow-x: auto;
margin-top: 0.5em;
}
.schedules-table {
min-width: 100%;
border-collapse: collapse;
@ -908,13 +1109,26 @@ footer .banner-thin {
letter-spacing: 0.05em;
white-space: nowrap;
}
.schedules-table-id { width: 3em; text-align: center; }
.schedules-table-id {
width: 3em;
text-align: center;
}
/* next / every columns: sized to typical content (short durations).
Previous 8em / 7em wasted horizontal space. */
.schedules-table-next-col { width: 5.5em; white-space: nowrap; }
.schedules-table-every-col { width: 5em; white-space: nowrap; }
.schedules-table-body-th { min-width: 12em; }
.schedules-table-actions-th { width: 7em; }
.schedules-table-next-col {
width: 5.5em;
white-space: nowrap;
}
.schedules-table-every-col {
width: 5em;
white-space: nowrap;
}
.schedules-table-body-th {
min-width: 12em;
}
.schedules-table-actions-th {
width: 7em;
}
/* Agent-name columns: 90° vertical text (writing-mode) so names are fully
readable without truncation. Previously used a -45° CSS transform which
clipped names mid-glyph. writing-mode + rotate(180deg) is in-flow and
@ -930,7 +1144,7 @@ footer .banner-thin {
overflow: hidden;
}
.schedules-table-agent-th > div {
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.85em;
color: var(--fg);
/* Rotate 90° bottom-to-top (conventional rotated column header). */
@ -953,17 +1167,28 @@ footer .banner-thin {
text-decoration: line-through;
opacity: 0.75;
}
.schedules-table-row-cancelled td { opacity: 0.55; }
.schedules-table-row-paused td { opacity: 0.75; }
.sched-paused-label { color: var(--yellow); font-size: 0.9em; }
.btn-pause-schedule { color: var(--teal); }
.btn-resume-schedule { color: var(--green); }
.schedules-table-row-cancelled td {
opacity: 0.55;
}
.schedules-table-row-paused td {
opacity: 0.75;
}
.sched-paused-label {
color: var(--yellow);
font-size: 0.9em;
}
.btn-pause-schedule {
color: var(--teal);
}
.btn-resume-schedule {
color: var(--green);
}
.schedules-table-body-cell {
max-width: 30em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.9em;
}
.schedules-table-check {
@ -1019,7 +1244,7 @@ footer .banner-thin {
background: var(--bg);
border: 1px solid var(--border);
color: var(--fg);
font-family: ui-monospace, 'JetBrains Mono', monospace;
font-family: ui-monospace, "JetBrains Mono", monospace;
font-size: 0.85em;
padding: 0.2em 0.4em;
border-radius: 2px;
@ -1111,13 +1336,18 @@ footer .banner-thin {
flex-wrap: wrap;
gap: 0.6em;
padding: 0.55em 1em;
background: var(--flow-frost-bg, color-mix(in srgb, var(--bg) 74%, transparent));
background: var(
--flow-frost-bg,
color-mix(in srgb, var(--bg) 74%, transparent)
);
-webkit-backdrop-filter: blur(12px) saturate(140%);
backdrop-filter: blur(12px) saturate(140%);
border-top: 1px solid var(--purple);
box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.4);
}
.selection-bar[hidden] { display: none; }
.selection-bar[hidden] {
display: none;
}
.selection-count {
color: var(--purple);
font-weight: bold;
@ -1150,7 +1380,9 @@ footer .banner-thin {
}
/* Pad the dashboard body so the sticky bar doesn't cover the
bottom of the agent list. Only applies when bar is visible. */
body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
body.dashboard-shell.has-selection {
padding-bottom: 4.5em;
}
.move-picker {
display: inline-flex;
@ -1178,4 +1410,3 @@ body.dashboard-shell.has-selection { padding-bottom: 4.5em; }
shared `.hive-stats-table` moved to common.css (the SYST3M
C0NT41N3R L04D table in system-sections.css / on /core.html still
uses it). */

View file

@ -1,199 +1,291 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // h1ve-c0re</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/colors.css">
<link rel="stylesheet" href="/static/theme.css">
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/dashboard.css">
</head>
<body class="dashboard-shell">
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // h1ve-c0re</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/dashboard.css" />
</head>
<body class="dashboard-shell">
<!-- Sticky chrome — just the tab strip. The "WE ARE THE WIRED"
slug lives at the page footer below `<main>`; chrome is
navigation only. Tabs route via the URL hash so F5 / back-
button / shared links keep you on the same view. JS owns
the show/hide. -->
<header class="dashboard-chrome">
<!-- Back to the H0M3 menu hub (served at /). Sub-pages and the
<header class="dashboard-chrome">
<!-- Back to the H0M3 menu hub (served at /). Sub-pages and the
dashboard all link back to the landing page rather than to each
other — H0M3 is the single navigation hub. -->
<a class="dash-home-back" href="/" title="back to the H0M3 menu">← home</a>
<nav class="tabbar" id="tabbar" role="tablist">
<a class="tab" id="tab-swarm" href="#swarm" role="tab"
aria-controls="tab-pane-swarm"
data-tab="swarm">
<span class="tab-label">◆ SW4RM ◆</span>
<span class="tab-count" id="tab-count-swarm" hidden></span>
</a>
<a class="tab" id="tab-call" href="#call" role="tab"
aria-controls="tab-pane-call"
data-tab="call">
<span class="tab-label">◆ Y3R C4LL ◆</span>
<span class="tab-count tab-count-attn" id="tab-count-call" hidden></span>
</a>
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
<a class="dash-home-back" href="/" title="back to the H0M3 menu"
>← home</a
>
<nav class="tabbar" id="tabbar" role="tablist">
<a
class="tab"
id="tab-swarm"
href="#swarm"
role="tab"
aria-controls="tab-pane-swarm"
data-tab="swarm"
>
<span class="tab-label">◆ SW4RM ◆</span>
<span class="tab-count" id="tab-count-swarm" hidden></span>
</a>
<a
class="tab"
id="tab-call"
href="#call"
role="tab"
aria-controls="tab-pane-call"
data-tab="call"
>
<span class="tab-label">◆ Y3R C4LL ◆</span>
<span
class="tab-count tab-count-attn"
id="tab-count-call"
hidden
></span>
</a>
<!-- SYST3M moved to its own standalone page (/core.html, "C0R3"),
reached from the H0M3 hub — keeps the dashboard tab strip lean.
The rebuild-queue STATE still lives in tabs.js (it drives the
"building…" badges on SW4RM agent cards); only the SYST3M tab +
its section renderers moved out. -->
<!-- P3RM1SS10NS: per-agent capability grants and tool-group
<!-- P3RM1SS10NS: per-agent capability grants and tool-group
assignments. Both tables are fetched on tab activation. -->
<a class="tab" id="tab-permissions" href="#permissions" role="tab"
aria-controls="tab-pane-permissions"
data-tab="permissions">
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
</a>
<a
class="tab"
id="tab-permissions"
href="#permissions"
role="tab"
aria-controls="tab-pane-permissions"
data-tab="permissions"
>
<span class="tab-label">◆ P3RM1SS10NS ◆</span>
</a>
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
<!-- SCH3DUL3S: scheduled-prompts surface. List of queued
schedules + an operator-direct creation form. Count pill
mirrors the active (non-cancelled) schedule count; hidden
when zero. -->
<a class="tab" id="tab-schedules" href="#schedules" role="tab"
aria-controls="tab-pane-schedules"
data-tab="schedules">
<span class="tab-label">◆ SCH3DUL3S ◆</span>
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<a
class="tab"
id="tab-schedules"
href="#schedules"
role="tab"
aria-controls="tab-pane-schedules"
data-tab="schedules"
>
<span class="tab-label">◆ SCH3DUL3S ◆</span>
<span class="tab-count" id="tab-count-schedules" hidden></span>
</a>
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
<!-- ST4TS lives on its own page now (`/stats.html`), reached from
the H0M3 hub — the hive-wide rollup is a standalone read-only
view, not part of the operational tab strip. -->
<!-- (Peer hives are no longer a tab — they render as a headline
<!-- (Peer hives are no longer a tab — they render as a headline
under SW4RM. See tab-pane-swarm.) -->
<!-- FL0W, L0GS, and M4TR1X are separate pages reachable from the
<!-- FL0W, L0GS, and M4TR1X are separate pages reachable from the
H0M3 hub (served at /), not from the dashboard tab strip — the
strip holds only real in-page tabs now. Contextual deep-links
into the log viewer (an agent's logs, a build entry's log)
still live inside the relevant dashboard content. -->
</nav>
</header>
</nav>
</header>
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
<!-- Tab panes. createTabStrip (@hive/shared/tabs.js) keeps exactly one
visible by toggling the `hidden` attribute, resolved from the URL
hash (default SW4RM). Panes start `hidden` to avoid a flash before
the script runs. -->
<!-- Padded content wrapper: carries the 1.5em side gutter
<!-- Padded content wrapper: carries the 1.5em side gutter
(.page-content, common.css) while <body> stays full-bleed so the
sticky chrome above + the footer below span the full width. -->
<div class="page-content">
<main class="dashboard-main">
<!-- SW4RM: the swarm itself. Container cards (the central thing
<div class="page-content">
<main class="dashboard-main">
<!-- SW4RM: the swarm itself. Container cards (the central thing
the operator looks at) and rebuild queue / cascade visualisation
that drives them. No inline `C0NTAINERS` h2 heading + divider
— the tab label SW4RM already says it. -->
<section class="tab-pane" id="tab-pane-swarm" data-tab-pane="swarm" hidden
role="tabpanel" aria-labelledby="tab-swarm">
<!-- Swarm / hive identity headline. Populated by refreshState from
<section
class="tab-pane"
id="tab-pane-swarm"
data-tab-pane="swarm"
hidden
role="tabpanel"
aria-labelledby="tab-swarm"
>
<!-- Swarm / hive identity headline. Populated by refreshState from
hive_name + swarm_name; stays hidden when neither is set. -->
<h2 id="swarm-identity" hidden></h2>
<!-- JobqRollup mount point, kept outside #containers-section
<h2 id="swarm-identity" hidden></h2>
<!-- JobqRollup mount point, kept outside #containers-section
deliberately — that section is wiped + rebuilt on every
container-list render (see swarm.js::renderContainers), which
would tear down and remount the Preact tree on every
container-state tick. Mounted once by swarm.js::initJobqRollup,
refreshed via its own handle rather than by re-rendering. -->
<div id="jobq-rollup-section"></div>
<div id="containers-section">
<p class="meta">loading…</p>
</div>
</section>
<div id="jobq-rollup-section"></div>
<div id="containers-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- Y3R C4LL: things blocked on operator decision — the approval
<!-- Y3R C4LL: things blocked on operator decision — the approval
queue; surfaces full bodies inline so the operator can decide
without leaving the pane. -->
<section class="tab-pane" id="tab-pane-call" data-tab-pane="call" hidden
role="tabpanel" aria-labelledby="tab-call">
<!-- 1NB0X: unread agent→operator messages. Fetched on
<section
class="tab-pane"
id="tab-pane-call"
data-tab-pane="call"
hidden
role="tabpanel"
aria-labelledby="tab-call"
>
<!-- 1NB0X: unread agent→operator messages. Fetched on
tab activation + cold load, appended live from the broker
stream, cleared via "mark all read". Count folds into the
Y3R C4LL pill so messages aren't missed. -->
<h2>◆ 1NB0X ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="operator-inbox-section">
<p class="meta">loading…</p>
</div>
<h2>◆ 1NB0X ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<div id="operator-inbox-section">
<p class="meta">loading…</p>
</div>
<h2>◆ P3NDING APPR0VALS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="approvals-section">
<p class="meta">loading…</p>
</div>
<h2>◆ P3NDING APPR0VALS ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<div id="approvals-section">
<p class="meta">loading…</p>
</div>
<!-- Operator-local preferences (browser notifications). Was its own
<!-- Operator-local preferences (browser notifications). Was its own
S3TT1NGS page (`/settings.html`); mara moved it in here since a
standalone page for one toggle didn't earn its own tile. State
lives in the browser's localStorage — NOTIF.bind() (common.js)
wires the buttons, same dispatch NOTIF.show() already used for
approval/inbox notifications above. -->
<h2>◆ PR3F3R3NC3S ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">operator-local preferences. these live in the browser's localStorage — they do not sync between devices and do not survive a profile wipe.</p>
<h3>◇ browser notifications</h3>
<p class="meta">desktop notifications for new approvals and broker messages addressed to you. requires a secure context (https or localhost). mute silences the notifications without revoking the OS-level permission.</p>
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>
<button type="button" id="notif-unmute" class="btn btn-notif" hidden>🔔 unmute</button>
<span id="notif-status" class="meta" hidden></span>
</div>
</section>
<h2>◆ PR3F3R3NC3S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
operator-local preferences. these live in the browser's localStorage
— they do not sync between devices and do not survive a profile
wipe.
</p>
<h3>◇ browser notifications</h3>
<p class="meta">
desktop notifications for new approvals and broker messages
addressed to you. requires a secure context (https or localhost).
mute silences the notifications without revoking the OS-level
permission.
</p>
<div id="notif-row" class="notif-row">
<button
type="button"
id="notif-enable"
class="btn btn-notif"
hidden
>
🔔 enable notifications
</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>
🔕 mute
</button>
<button
type="button"
id="notif-unmute"
class="btn btn-notif"
hidden
>
🔔 unmute
</button>
<span id="notif-status" class="meta" hidden></span>
</div>
</section>
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):
meta inputs, rebuild queue, kept state, container load. Reached
from the H0M3 hub. -->
<!-- P3RM1SS10NS: per-agent capability grants + tool-group
<!-- P3RM1SS10NS: per-agent capability grants + tool-group
assignments. Both tables are column-driven from the backend
(GET /api/capabilities, GET /api/tool-groups) so new entries
require no UI change. Edits stage in-browser across both
matrices; the single page-level "save all" button POSTs every
changed agent to /api/permissions in one atomic batch (caps +
groups coalesce into one rebuild per agent). -->
<section class="tab-pane" id="tab-pane-permissions" data-tab-pane="permissions" hidden
role="tabpanel" aria-labelledby="tab-permissions">
<h2>◆ C4P4B1L1T13S ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">per-agent capability grants. capabilities unlock gated MCP tools and system access. toggle any cells across both tables, then hit "save all" at the bottom — each changed agent rebuilds exactly once.</p>
<div id="capabilities-section">
<p class="meta">loading…</p>
</div>
<section
class="tab-pane"
id="tab-pane-permissions"
data-tab-pane="permissions"
hidden
role="tabpanel"
aria-labelledby="tab-permissions"
>
<h2>◆ C4P4B1L1T13S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
per-agent capability grants. capabilities unlock gated MCP tools and
system access. toggle any cells across both tables, then hit "save
all" at the bottom — each changed agent rebuilds exactly once.
</p>
<div id="capabilities-section">
<p class="meta">loading…</p>
</div>
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
<!-- T00L GR0UPS: per-agent tool-group permission matrix. Rows = agents,
cols = tool groups fetched from GET /api/tool-groups.
Checking / unchecking is staged in the UI; the single page-level
"save all" button POSTs every changed agent to
/api/permissions in one atomic batch, coalescing caps + groups
per agent into one rebuild each.
Absent agents default to the role default (shown in parens). -->
<h2>◆ T00L GR0UPS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">per-agent tool-group permissions. columns are filled from the backend — adding a new group requires no UI change. agents without an explicit entry use the role default (agents: messaging, meta, inbox, execution).</p>
<div id="tool-groups-section">
<p class="meta">loading…</p>
</div>
<h2>◆ T00L GR0UPS ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
per-agent tool-group permissions. columns are filled from the
backend — adding a new group requires no UI change. agents without
an explicit entry use the role default (agents: messaging, meta,
inbox, execution).
</p>
<div id="tool-groups-section">
<p class="meta">loading…</p>
</div>
<!-- Page-level save bar (save-all issue): one button for the whole
<!-- Page-level save bar (save-all issue): one button for the whole
permissions page. Staged checkbox edits across BOTH matrices are
diffed against their render-time baselines and POSTed together to
/api/permissions; caps + groups for one agent coalesce into a
single rebuild. Disabled until something is dirty. The atomic
batch either fully lands (queued ✓ → rebuilding) or fully fails
(error note, nothing applied). -->
<div class="perm-save-bar">
<button type="button" id="perm-save-all" class="btn btn-approve" disabled>save all</button>
<span id="perm-save-note" class="meta"></span>
</div>
</section>
<div class="perm-save-bar">
<button
type="button"
id="perm-save-all"
class="btn btn-approve"
disabled
>
save all
</button>
<span id="perm-save-note" class="meta"></span>
</div>
</section>
<!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded
<!-- SCH3DUL3S: scheduled prompts. Creation + edit are folded
into the same table — empty bottom row is the create form
(fill cells, click ), inline-edit-row expands on the `✎`
toggle for existing schedules. Schedules list driven by
@ -203,60 +295,91 @@
`schedules_changed` SSE; tab activation re-fetches as a
safety net for disconnect windows.
See docs/web-ui.md::SCH3DUL3S tab. -->
<section class="tab-pane" id="tab-pane-schedules" data-tab-pane="schedules" hidden
role="tabpanel" aria-labelledby="tab-schedules">
<h2>◆ SCH3DUL3S ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p class="meta">all schedules currently in the table. fill the bottom row to queue a new schedule (operator-direct, no approval gate; recurring when an interval is set). cancel a single target with the row button or the whole schedule with <code>✕ cancel all</code>.</p>
<div id="schedules-section">
<p class="meta">loading…</p>
</div>
<section
class="tab-pane"
id="tab-pane-schedules"
data-tab-pane="schedules"
hidden
role="tabpanel"
aria-labelledby="tab-schedules"
>
<h2>◆ SCH3DUL3S ◆</h2>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p class="meta">
all schedules currently in the table. fill the bottom row to queue a
new schedule (operator-direct, no approval gate; recurring when an
interval is set). cancel a single target with the row button or the
whole schedule with <code>✕ cancel all</code>.
</p>
<div id="schedules-section">
<p class="meta">loading…</p>
</div>
</section>
</section>
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
<!-- ST4TS: hive-wide turn-stats aggregate lives on its own page now
(`/stats.html`), reached from the H0M3 hub. The markup + the
hive-stats render JS moved there; when tabs.js boots on the
dashboard the renderers are simply gone (no stats tab to
activate). -->
<!-- FL0W: lives on its own page now (`/flow.html`). The
<!-- FL0W: lives on its own page now (`/flow.html`). The
message-flow + inbox + compose DOM only exists there — when
tabs.js boots on this page the corresponding renderers
no-op silently (each guard is `if (!el) return`). -->
</main>
</div>
<!-- /.page-content -->
</main>
</div><!-- /.page-content -->
<footer>
<pre class="banner banner-thin">
░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre
>
<div class="divider">
══════════════════════════════════════════════════════════════
</div>
<p>
▲△▲
<a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a>
▲△▲ hive-c0re on this host ▲△▲
</p>
</footer>
<footer>
<pre class="banner banner-thin">░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░</pre>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<p>▲△▲ <a href="https://forge.darkest.space/hyperhive/hyperhive">hyperhive</a> ▲△▲ hive-c0re on this host ▲△▲</p>
</footer>
<!-- Slide-in detail panel (clicked file previews, approval diffs,
<!-- Slide-in detail panel (clicked file previews, approval diffs,
journald logs, applied config) is a <hive-side-panel> element
(@hive/shared/side-panel.js) — the Panel singleton in common.js
creates + appends it to <body> lazily on first use, so nothing
needs to be pre-declared here. -->
<!-- Selection action bar. Sticky-bottom strip that slides into
<!-- Selection action bar. Sticky-bottom strip that slides into
view when one or more agent cards is selected (click the icon
to toggle). See docs/web-ui.md::Selection bar for the bulk
action gating + clear semantics. -->
<div id="selection-bar" class="selection-bar" hidden role="toolbar"
aria-label="bulk agent actions">
<span class="selection-count" id="selection-count"></span>
<span class="selection-names" id="selection-names"></span>
<span class="selection-actions" id="selection-actions"></span>
<button type="button" class="btn selection-clear" id="selection-clear"
title="clear selection (esc)">✕ clear</button>
</div>
<div
id="selection-bar"
class="selection-bar"
hidden
role="toolbar"
aria-label="bulk agent actions"
>
<span class="selection-count" id="selection-count"></span>
<span class="selection-names" id="selection-names"></span>
<span class="selection-actions" id="selection-actions"></span>
<button
type="button"
class="btn selection-clear"
id="selection-clear"
title="clear selection (esc)"
>
✕ clear
</button>
</div>
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
<!-- Single bundled entry — tabs.js is the dashboard tabs surface;
flow.html has its own flow.js bundle. esbuild folds
@hive/shared/terminal.js and the marked npm package into
tabs.js. -->
<script type="module" src="/static/tabs.js" defer></script>
</body>
<script type="module" src="/static/tabs.js" defer></script>
</body>
</html>

View file

@ -23,9 +23,11 @@ body.flow-shell {
height: 100vh;
overflow: hidden;
background:
radial-gradient(ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
transparent 60%),
radial-gradient(
ellipse 80% 60% at 50% 0%,
color-mix(in srgb, var(--purple) 6%, transparent) 0%,
transparent 60%
),
var(--bg);
}
@ -101,5 +103,9 @@ body.flow-shell .flow-main-slim {
padding: 0.25em 0.5em;
margin-left: auto;
}
.flow-filter:focus { outline: 1px solid var(--purple); }
.msgrow.flow-hidden { display: none; }
.flow-filter:focus {
outline: 1px solid var(--purple);
}
.msgrow.flow-hidden {
display: none;
}

View file

@ -1,54 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // FL0W</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/colors.css">
<link rel="stylesheet" href="/static/theme.css">
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/flow.css">
</head>
<body class="flow-shell">
<!-- Minimal chrome: just a back link to the H0M3 hub (served at /).
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // FL0W</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/flow.css" />
</head>
<body class="flow-shell">
<!-- Minimal chrome: just a back link to the H0M3 hub (served at /).
No full tabbar — the flow page is a dedicated full-viewport
terminal surface; navigating back to the menu is the only chrome
needed. Pages link back to H0M3, not the dashboard. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<span class="page-title">FL0W</span>
<select id="flow-agent-filter" class="flow-filter" title="filter timeline by agent" aria-label="filter timeline by agent">
<option value="">all agents</option>
</select>
</header>
<header class="page-header">
<a class="page-back" href="/">← home</a>
<span class="page-title">FL0W</span>
<select
id="flow-agent-filter"
class="flow-filter"
title="filter timeline by agent"
aria-label="filter timeline by agent"
>
<option value="">all agents</option>
</select>
</header>
<!-- Main content: the full-viewport terminal. Padded for the
<!-- Main content: the full-viewport terminal. Padded for the
overlay header + composer so the first/last rows stay
reachable. -->
<main class="flow-main flow-main-slim">
<div class="terminal-wrap">
<div id="msgflow" class="live terminal"><div class="meta">connecting…</div></div>
</div>
</main>
<main class="flow-main flow-main-slim">
<div class="terminal-wrap">
<div id="msgflow" class="live terminal">
<div class="meta">connecting…</div>
</div>
</div>
</main>
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
<!-- Fixed-overlay composer at the bottom. Same frosted treatment
as the header — symmetric framing, terminal goes edge-to-edge
between them. -->
<footer class="flow-composer">
<div id="op-compose" class="op-compose">
<span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span>
<textarea id="op-compose-input" class="op-compose-input"
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
rows="1" autocomplete="off"></textarea>
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
</div>
</footer>
<footer class="flow-composer">
<div id="op-compose" class="op-compose">
<span id="op-compose-prompt" class="op-compose-prompt">@—&gt;</span>
<textarea
id="op-compose-input"
class="op-compose-input"
placeholder="@agent message… (enter sends, shift+enter newline, tab completes @-mention)"
rows="1"
autocomplete="off"
></textarea>
<div id="op-compose-suggest" class="op-compose-suggest" hidden></div>
</div>
</footer>
<!-- Flow-specific bundle. Contains the broker terminal init + the
<!-- Flow-specific bundle. Contains the broker terminal init + the
@-mention composer. Tab renderers etc. live in
`/static/tabs.js` which /flow.html doesn't load. -->
<script type="module" src="/static/flow.js" defer></script>
</body>
<script type="module" src="/static/flow.js" defer></script>
</body>
</html>

View file

@ -11,15 +11,16 @@
// initial /api/state fetch (compose autocomplete needs the live
// container list).
import { create as termCreate } from '@hive/shared/terminal.js';
import { create as termCreate } from "@hive/shared/terminal.js";
import {
$,
NOTIF,
appendLinkified,
openStream, initServerWarnings,
} from './common.js';
import { el } from '@hive/shared/dom.js';
import { epochSec } from './util.js';
openStream,
initServerWarnings,
} from "./common.js";
import { el } from "@hive/shared/dom.js";
import { epochSec } from "./util.js";
(() => {
NOTIF.bind();
@ -32,11 +33,16 @@ import { epochSec } from './util.js';
// by the same `container_state_changed` / `container_removed` events
// the dashboard would handle.
const flowContainers = new Map();
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
if (!s || !Array.isArray(s.containers)) return;
for (const c of s.containers) flowContainers.set(c.name, c);
populateAgentFilter();
}).catch(() => { /* graceful: compose just shows `*` and nothing else */ });
fetch("/api/state")
.then((r) => (r.ok ? r.json() : null))
.then((s) => {
if (!s || !Array.isArray(s.containers)) return;
for (const c of s.containers) flowContainers.set(c.name, c);
populateAgentFilter();
})
.catch(() => {
/* graceful: compose just shows `*` and nothing else */
});
// ─── agent filter ───────────────────────────────────────────────
// A select in the FL0W header narrows the timeline to messages involving
@ -46,40 +52,40 @@ import { epochSec } from './util.js';
// filter re-scans existing rows. Selection persists in localStorage so a
// reload / tab-switch keeps the view. Uses `$('msgflow')` for row access
// so it works regardless of the message-flow IIFE's local scope.
let agentFilter = localStorage.getItem('flow-agent-filter') || '';
let agentFilter = localStorage.getItem("flow-agent-filter") || "";
function rowMatchesFilter(from, to) {
return !agentFilter || from === agentFilter || to === agentFilter;
}
function applyAgentFilter() {
const flow = $('msgflow');
const flow = $("msgflow");
if (!flow) return;
for (const row of flow.children) {
const { from, to } = row.dataset;
if (from === undefined && to === undefined) continue; // non-message row
row.classList.toggle('flow-hidden', !rowMatchesFilter(from, to));
row.classList.toggle("flow-hidden", !rowMatchesFilter(from, to));
}
}
function populateAgentFilter() {
const sel = $('flow-agent-filter');
const sel = $("flow-agent-filter");
if (!sel) return;
const names = [...flowContainers.keys()].sort();
sel.replaceChildren();
sel.append(el('option', { value: '' }, 'all agents'));
for (const n of names) sel.append(el('option', { value: n }, n));
sel.append(el("option", { value: "" }, "all agents"));
for (const n of names) sel.append(el("option", { value: n }, n));
// Preserve a saved selection even if that agent isn't in the live
// container list (yet / anymore) so the filter doesn't silently reset.
if (agentFilter && !names.includes(agentFilter)) {
sel.append(el('option', { value: agentFilter }, agentFilter));
sel.append(el("option", { value: agentFilter }, agentFilter));
}
sel.value = agentFilter;
}
{
const sel = $('flow-agent-filter');
const sel = $("flow-agent-filter");
if (sel) {
sel.addEventListener('change', () => {
sel.addEventListener("change", () => {
agentFilter = sel.value;
if (agentFilter) localStorage.setItem('flow-agent-filter', agentFilter);
else localStorage.removeItem('flow-agent-filter');
if (agentFilter) localStorage.setItem("flow-agent-filter", agentFilter);
else localStorage.removeItem("flow-agent-filter");
applyAgentFilter();
});
}
@ -91,7 +97,7 @@ import { epochSec } from './util.js';
// side effects (banner pulse, OS notifications on operator-bound
// traffic).
(() => {
const flow = $('msgflow');
const flow = $("msgflow");
if (!flow) return;
flow.replaceChildren();
const tsFmt = (ts) => new Date(ts).toISOString().slice(11, 19);
@ -100,13 +106,16 @@ import { epochSec } from './util.js';
// in the flow chrome — `pulseBanner` no-ops on /flow.html since
// there's no element to find. Kept for parity if a future chrome
// change reintroduces a banner.
const banner = document.querySelector('.banner');
const banner = document.querySelector(".banner");
let bannerOffTimer = null;
function pulseBanner() {
if (!banner) return;
banner.classList.add('active');
banner.classList.add("active");
if (bannerOffTimer) clearTimeout(bannerOffTimer);
bannerOffTimer = setTimeout(() => banner.classList.remove('active'), 4000);
bannerOffTimer = setTimeout(
() => banner.classList.remove("active"),
4000,
);
}
// Map of broker row id → rendered row element. Lets reply rows add
// a visual "↳ in reply to" indicator that links back to the parent.
@ -116,55 +125,60 @@ import { epochSec } from './util.js';
function renderMsg(ev, api, glyph) {
const isReply = ev.in_reply_to != null;
const cls = 'msgrow ' + ev.kind + (isReply ? ' msg-reply' : '');
const row = api.row(cls, '');
const cls = "msgrow " + ev.kind + (isReply ? " msg-reply" : "");
const row = api.row(cls, "");
// Build via DOM so path anchors stay live + escape rules are
// automatic (text nodes don't need esc()).
const ts = document.createElement('span');
ts.className = 'msg-ts'; ts.textContent = tsFmt(ev.at);
const arrow = document.createElement('span');
arrow.className = 'msg-arrow'; arrow.textContent = glyph;
const from = document.createElement('span');
from.className = 'msg-from'; from.textContent = ev.from;
const sep = document.createElement('span');
sep.className = 'msg-sep'; sep.textContent = '→';
const to = document.createElement('span');
to.className = 'msg-to'; to.textContent = ev.to;
const body = document.createElement('span');
body.className = 'msg-body';
const ts = document.createElement("span");
ts.className = "msg-ts";
ts.textContent = tsFmt(ev.at);
const arrow = document.createElement("span");
arrow.className = "msg-arrow";
arrow.textContent = glyph;
const from = document.createElement("span");
from.className = "msg-from";
from.textContent = ev.from;
const sep = document.createElement("span");
sep.className = "msg-sep";
sep.textContent = "→";
const to = document.createElement("span");
to.className = "msg-to";
to.textContent = ev.to;
const body = document.createElement("span");
body.className = "msg-body";
appendLinkified(body, ev.body, ev.file_refs);
// Reply thread indicator: a small "↳ reply to <from>" hint that
// shows which message this is responding to. If we have the parent
// in our row map, clicking scrolls it into view.
if (isReply) {
const replyTag = document.createElement('span');
replyTag.className = 'msg-reply-tag';
const replyTag = document.createElement("span");
replyTag.className = "msg-reply-tag";
const parentRow = msgRowMap.get(ev.in_reply_to);
if (parentRow) {
const link = document.createElement('a');
link.href = '#';
link.textContent = '↳ reply';
link.title = 'scroll to parent message';
link.addEventListener('click', (e) => {
const link = document.createElement("a");
link.href = "#";
link.textContent = "↳ reply";
link.title = "scroll to parent message";
link.addEventListener("click", (e) => {
e.preventDefault();
parentRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
parentRow.classList.add('msg-highlight');
setTimeout(() => parentRow.classList.remove('msg-highlight'), 1500);
parentRow.scrollIntoView({ behavior: "smooth", block: "nearest" });
parentRow.classList.add("msg-highlight");
setTimeout(() => parentRow.classList.remove("msg-highlight"), 1500);
});
replyTag.append(link);
} else {
replyTag.textContent = '↳ reply';
replyTag.textContent = "↳ reply";
}
row.prepend(replyTag);
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
} else {
row.append(ts, ' ', arrow, ' ', from, ' ', sep, ' ', to, ' ', body);
row.append(ts, " ", arrow, " ", from, " ", sep, " ", to, " ", body);
}
// Tag with the participants so the agent filter can match
// on `from`/`to`, and hide the row up-front if a filter is active.
row.dataset.from = ev.from;
row.dataset.to = ev.to;
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add('flow-hidden');
if (!rowMatchesFilter(ev.from, ev.to)) row.classList.add("flow-hidden");
// Register this row so future replies can reference it.
if (ev.id != null && ev.id > 0) msgRowMap.set(ev.id, row);
return row;
@ -193,13 +207,13 @@ import { epochSec } from './util.js';
if (ev.id == null || ev.id <= 0) return false;
const s = recentSent.get(ev.id);
if (!s || epochSec(ev.at) - s.at > COLLAPSE_SECS) return false;
const arrow = s.row.querySelector('.msg-arrow');
if (arrow) arrow.textContent = '✓';
const arrow = s.row.querySelector(".msg-arrow");
if (arrow) arrow.textContent = "✓";
// Re-style the row as delivered (green ✓) — the collapsed line now
// represents the delivered state; it was sent + delivered as one.
s.row.classList.remove('sent');
s.row.classList.add('delivered');
s.row.title = 'sent + delivered';
s.row.classList.remove("sent");
s.row.classList.add("delivered");
s.row.title = "sent + delivered";
recentSent.delete(ev.id);
return true;
}
@ -207,11 +221,11 @@ import { epochSec } from './util.js';
// default `.terminal-wrap` parent — see docs/web-ui.md::Per-agent
// page (Terminal-wrap) for the backdrop-filter stacking-context
// gotcha (same shape on the flow page).
const flowMain = document.querySelector('.flow-main');
const flowMain = document.querySelector(".flow-main");
termCreate({
logEl: flow,
pillAnchor: flowMain,
historyUrl: '/api/dashboard/history',
historyUrl: "/api/dashboard/history",
// Server-side filter — only the kinds this page actually renders
// or routes (sent/delivered → broker terminal,
// container_state_changed/_removed → local autocomplete cache).
@ -220,18 +234,19 @@ import { epochSec } from './util.js';
// JSON-serialise is skipped entirely on irrelevant kinds. The
// dashboard tabs page (tabs.js) keeps the unfiltered subscribe
// since it routes every mutation kind into its derived stores.
streamUrl: '/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed',
streamUrl:
"/api/dashboard/stream?kinds=sent,delivered,container_state_changed,container_removed",
// Route through the SharedWorker — see docs/web-ui.md (SSE
// multiplexing paragraph). Worker keys on the full URL incl.
// query string, so this filtered subscribe is its own upstream
// and won't accidentally share with tabs.js's wider subscribe.
streamFactory: openStream,
renderers: {
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, '→')),
sent: (ev, api) => rememberSent(ev, renderMsg(ev, api, "→")),
delivered: (ev, api) => {
// Fold into the matching sent row when it just happened;
// otherwise render the delivery as its own line.
if (!collapseDelivered(ev)) renderMsg(ev, api, '✓');
if (!collapseDelivered(ev)) renderMsg(ev, api, "✓");
},
// Maintain the local containers cache from the same stream
// (compose autocomplete reads from `flowContainers`). The
@ -259,21 +274,27 @@ import { epochSec } from './util.js';
// are never replayed, so without this the compose autocomplete
// could drift stale.
onStreamOpen: () => {
fetch('/api/state').then((r) => r.ok ? r.json() : null).then((s) => {
if (!s || !Array.isArray(s.containers)) return;
flowContainers.clear();
for (const c of s.containers) flowContainers.set(c.name, c);
}).catch(() => {});
fetch("/api/state")
.then((r) => (r.ok ? r.json() : null))
.then((s) => {
if (!s || !Array.isArray(s.containers)) return;
flowContainers.clear();
for (const c of s.containers) flowContainers.set(c.name, c);
})
.catch(() => {});
},
onLiveEvent: (ev) => {
pulseBanner();
if (ev.kind === 'sent' && ev.to === 'operator') {
if (ev.kind === "sent" && ev.to === "operator") {
NOTIF.show(
'◆ ' + ev.from + ' → operator',
String(ev.body || '').slice(0, 200),
"◆ " + ev.from + " → operator",
String(ev.body || "").slice(0, 200),
// Unique-per-arrival tag so a burst stacks instead of
// overwriting itself in the OS notification center.
'hyperhive:msg:' + ev.at + ':' + Math.random().toString(36).slice(2, 6),
"hyperhive:msg:" +
ev.at +
":" +
Math.random().toString(36).slice(2, 6),
);
}
},
@ -282,28 +303,27 @@ import { epochSec } from './util.js';
// ─── compose: @-mention with sticky recipient ───────────────────────────
(() => {
const input = $('op-compose-input');
const prompt = $('op-compose-prompt');
const suggest = $('op-compose-suggest');
const input = $("op-compose-input");
const prompt = $("op-compose-prompt");
const suggest = $("op-compose-suggest");
if (!input || !prompt || !suggest) return;
const STORAGE_KEY = 'hyperhive:op-compose:to';
let stickyTo = localStorage.getItem(STORAGE_KEY) || '';
const STORAGE_KEY = "hyperhive:op-compose:to";
let stickyTo = localStorage.getItem(STORAGE_KEY) || "";
let suggestActive = -1;
function renderPrompt() {
prompt.textContent = stickyTo ? `@${stickyTo}>` : '@—>';
prompt.textContent = stickyTo ? `@${stickyTo}>` : "@—>";
}
function knownAgents() {
// Read live from the flow-local containers cache so newly-spawned
// agents become addressable without a manual reload.
const names = Array.from(flowContainers.values())
.map((c) => c.name);
const names = Array.from(flowContainers.values()).map((c) => c.name);
// `*` fans out to every registered agent (server-side
// broadcast_send).
names.unshift('*');
names.unshift("*");
return names;
}
function autosize() {
input.style.height = 'auto';
input.style.height = "auto";
input.style.height = `${input.scrollHeight}px`;
}
/// Parse "@name body…" — return {to, body} when the input opens
@ -320,12 +340,15 @@ import { epochSec } from './util.js';
}
function renderSuggest(matches) {
suggest.replaceChildren();
if (!matches.length) { hideSuggest(); return; }
if (!matches.length) {
hideSuggest();
return;
}
for (let i = 0; i < matches.length; i += 1) {
const item = document.createElement('div');
item.className = 'item' + (i === suggestActive ? ' active' : '');
item.textContent = '@' + matches[i];
item.addEventListener('mousedown', (e) => {
const item = document.createElement("div");
item.className = "item" + (i === suggestActive ? " active" : "");
item.textContent = "@" + matches[i];
item.addEventListener("mousedown", (e) => {
e.preventDefault();
applySuggestion(matches[i]);
});
@ -338,7 +361,7 @@ import { epochSec } from './util.js';
const v = input.value;
const m = v.match(/^@(\S*)/);
if (m) {
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, '');
input.value = `@${name} ` + v.slice(m[0].length).replace(/^\s+/, "");
} else {
input.value = `@${name} ` + v;
}
@ -353,11 +376,20 @@ import { epochSec } from './util.js';
// input — switching recipient is always "redirect this whole
// line." Mid-message @-mentions stay literal.
const m = v.match(/^@(\S*)/);
if (!m) { hideSuggest(); return; }
if (!m) {
hideSuggest();
return;
}
const partial = m[1].toLowerCase();
const matches = knownAgents().filter((n) => n.toLowerCase().startsWith(partial));
if (!matches.length) { hideSuggest(); return; }
if (suggestActive < 0 || suggestActive >= matches.length) suggestActive = 0;
const matches = knownAgents().filter((n) =>
n.toLowerCase().startsWith(partial),
);
if (!matches.length) {
hideSuggest();
return;
}
if (suggestActive < 0 || suggestActive >= matches.length)
suggestActive = 0;
renderSuggest(matches);
}
async function submit() {
@ -373,20 +405,20 @@ import { epochSec } from './util.js';
to = stickyTo;
body = raw;
} else {
flashError('no recipient — start with @name to address a message');
flashError("no recipient — start with @name to address a message");
return;
}
if (!body) return;
const fd = new FormData();
fd.append('to', to);
fd.append('body', body);
fd.append("to", to);
fd.append("body", body);
input.disabled = true;
try {
// /op-send returns 200. The SSE channel carries the resulting
// MessageEvent → the terminal renders the sent row on its own;
// no /api/state refetch needed.
const resp = await fetch('/api/op-send', {
method: 'POST',
const resp = await fetch("/api/op-send", {
method: "POST",
body: new URLSearchParams(fd),
});
if (!resp.ok) {
@ -401,17 +433,17 @@ import { epochSec } from './util.js';
}
stickyTo = to;
localStorage.setItem(STORAGE_KEY, to);
input.value = '';
input.value = "";
autosize();
renderPrompt();
input.focus();
}
function flashError(msg) {
const flow = $('msgflow');
const flow = $("msgflow");
if (!flow) return;
const row = document.createElement('div');
row.className = 'msgrow meta';
row.textContent = '✗ ' + msg;
const row = document.createElement("div");
row.className = "msgrow meta";
row.textContent = "✗ " + msg;
// Append at the bottom so the error is visible — the terminal
// renders newest-last, so inserting before firstChild would place
// the error at the top (oldest end) and hide it from view.
@ -420,43 +452,46 @@ import { epochSec } from './util.js';
const wrap = flow.parentElement;
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}
input.addEventListener('input', () => { autosize(); updateSuggest(); });
input.addEventListener('keydown', (e) => {
input.addEventListener("input", () => {
autosize();
updateSuggest();
});
input.addEventListener("keydown", (e) => {
if (!suggest.hidden) {
if (e.key === 'ArrowDown') {
const items = suggest.querySelectorAll('.item');
if (e.key === "ArrowDown") {
const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive + 1) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === 'ArrowUp') {
const items = suggest.querySelectorAll('.item');
if (e.key === "ArrowUp") {
const items = suggest.querySelectorAll(".item");
suggestActive = (suggestActive - 1 + items.length) % items.length;
renderSuggest(Array.from(items).map((i) => i.textContent.slice(1)));
e.preventDefault();
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
const active = suggest.querySelector('.item.active');
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
const active = suggest.querySelector(".item.active");
if (active) {
applySuggestion(active.textContent.slice(1));
e.preventDefault();
return;
}
}
if (e.key === 'Escape') {
if (e.key === "Escape") {
hideSuggest();
e.preventDefault();
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
});
input.addEventListener('blur', () => {
input.addEventListener("blur", () => {
// Defer so a click on a suggestion item (mousedown) lands first.
setTimeout(hideSuggest, 100);
});

View file

@ -57,13 +57,17 @@ body.home-shell {
background: color-mix(in srgb, var(--bg-elev) 55%, transparent);
text-decoration: none;
color: var(--fg);
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease,
transition:
color 0.15s ease,
background 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.home-tile:hover {
border-color: var(--purple);
background: color-mix(in srgb, var(--purple) 6%, transparent);
box-shadow: 0 -2px 14px -6px color-mix(in srgb, var(--purple) 50%, transparent);
box-shadow: 0 -2px 14px -6px
color-mix(in srgb, var(--purple) 50%, transparent);
}
/* Icon + label share the top row of each tile; the description sits

View file

@ -7,14 +7,14 @@
// It also drives the decorative matrix-rain backdrop (#matrix-rain) —
// see startMatrixRain() at the foot.
import { renderServerWarnings } from './common.js';
import { renderServerWarnings } from "./common.js";
const $ = (id) => document.getElementById(id);
async function init() {
let state;
try {
const resp = await fetch('/api/state');
const resp = await fetch("/api/state");
if (!resp.ok) return;
state = await resp.json();
} catch {
@ -23,10 +23,10 @@ async function init() {
renderServerWarnings(state.server_warnings);
const ident = $('hive-identity');
const ident = $("hive-identity");
if (ident && (state.swarm_name || state.hive_name)) {
const parts = [state.swarm_name, state.hive_name].filter(Boolean);
ident.textContent = parts.join(' / ');
ident.textContent = parts.join(" / ");
ident.hidden = false;
}
@ -41,10 +41,12 @@ async function init() {
// rather than truncating from the tail, so two different builds don't
// render as the same string. Falls back to a plain head-slice for a
// non-store path (e.g. a bare local dir during dev).
const rev = $('hive-rev');
const rev = $("hive-rev");
if (rev && state.hyperhive_rev) {
const storeMatch = state.hyperhive_rev.match(/^\/nix\/store\/([^-]+)/);
const short = storeMatch ? storeMatch[1].slice(0, 12) : state.hyperhive_rev.slice(0, 12);
const short = storeMatch
? storeMatch[1].slice(0, 12)
: state.hyperhive_rev.slice(0, 12);
rev.textContent = `rev ${short}`;
rev.title = state.hyperhive_rev;
rev.hidden = false;
@ -61,34 +63,40 @@ init();
// steppy cadence is the look and costs little CPU); paused while the tab is
// hidden, and skipped entirely under prefers-reduced-motion.
function startMatrixRain() {
const canvas = $('matrix-rain');
const canvas = $("matrix-rain");
if (!canvas || !canvas.getContext) return;
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const ctx = canvas.getContext('2d');
if (
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
return;
const ctx = canvas.getContext("2d");
// getComputedStyle on a custom property returns its *declared* value
// (e.g. "var(--base0B)") unresolved, so resolve to a concrete rgb() by
// probing a real `color` computation instead.
function resolveColor(varName, fallback) {
const probe = document.createElement('span');
const probe = document.createElement("span");
probe.style.color = `var(${varName})`;
probe.style.display = 'none';
probe.style.display = "none";
document.body.appendChild(probe);
const c = getComputedStyle(probe).color;
probe.remove();
return /^rgb/.test(c) ? c : fallback;
}
const glyphColor = resolveColor('--green', 'rgb(166, 227, 161)');
const glyphColor = resolveColor("--green", "rgb(166, 227, 161)");
// Fade-trail = the bg at low alpha. Pull r,g,b numerically rather than
// string-rewriting the rgb() — robust to whatever rgb()/rgba() spacing
// getComputedStyle hands back, so the trail always fades (never fills
// solid) even if the format shifts.
const bgRGB = resolveColor('--bg', 'rgb(30, 30, 46)').match(/(\d+)[^\d]+(\d+)[^\d]+(\d+)/);
const bgRGB = resolveColor("--bg", "rgb(30, 30, 46)").match(
/(\d+)[^\d]+(\d+)[^\d]+(\d+)/,
);
const fadeColor = bgRGB
? `rgba(${bgRGB[1]}, ${bgRGB[2]}, ${bgRGB[3]}, 0.09)`
: 'rgba(30, 30, 46, 0.09)';
: "rgba(30, 30, 46, 0.09)";
const GLYPHS = 'アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦';
const GLYPHS = "アイウエオカキクケコサシスセソタチツテト0123456789:=*+-<>¦";
const CELL = 16; // glyph cell size (px)
let cols = 0;
let drops = [];
@ -109,18 +117,28 @@ function startMatrixRain() {
for (let i = 0; i < cols; i++) {
const ch = GLYPHS[(Math.random() * GLYPHS.length) | 0];
ctx.fillText(ch, i * CELL, drops[i] * CELL);
if (drops[i] * CELL > canvas.height && Math.random() > 0.975) drops[i] = 0;
if (drops[i] * CELL > canvas.height && Math.random() > 0.975)
drops[i] = 0;
drops[i]++;
}
}
let timer = null;
const play = () => { if (!timer) timer = setInterval(tick, 55); };
const pause = () => { if (timer) { clearInterval(timer); timer = null; } };
const play = () => {
if (!timer) timer = setInterval(tick, 55);
};
const pause = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
resize();
window.addEventListener('resize', resize);
document.addEventListener('visibilitychange', () => (document.hidden ? pause() : play()));
window.addEventListener("resize", resize);
document.addEventListener("visibilitychange", () =>
document.hidden ? pause() : play(),
);
play();
}

View file

@ -1,112 +1,122 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // h0m3</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/colors.css">
<link rel="stylesheet" href="/static/theme.css">
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/home.css">
</head>
<body class="home-shell">
<!-- Decorative matrix-rain backdrop: a dimmed full-viewport canvas of
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // h0m3</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/home.css" />
</head>
<body class="home-shell">
<!-- Decorative matrix-rain backdrop: a dimmed full-viewport canvas of
falling glyphs behind everything (home.js drives it). aria-hidden +
pointer-events:none so it's purely cosmetic; glyph/bg colours are
read from the stylix palette at runtime so a theme swap re-colours
it, and it's disabled under prefers-reduced-motion. -->
<canvas id="matrix-rain" aria-hidden="true"></canvas>
<canvas id="matrix-rain" aria-hidden="true"></canvas>
<!-- H0M3: the menu hub. A plain grid of links to every top-level
<!-- H0M3: the menu hub. A plain grid of links to every top-level
surface. This is the page served at `/` — the landing page — with
the dashboard relocated to /dashboard.html. No tabbar / SSE —
it's a static portal. -->
<!-- Padded content wrapper: carries the 1.5em side gutter
<!-- Padded content wrapper: carries the 1.5em side gutter
(.page-content, common.css) while <body> stays full-bleed so the
server-warnings banner spans the full width. -->
<div class="page-content">
<header class="home-header">
<p class="banner-thin" id="hive-identity" hidden></p>
<pre class="banner">░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre>
<p class="banner-thin" id="hive-rev" hidden></p>
</header>
<div class="page-content">
<header class="home-header">
<p class="banner-thin" id="hive-identity" hidden></p>
<pre class="banner">
░▒▓█▓▒░ ░▒▓█▓▒░ H Y P E R H I V E · H0M3 ░▒▓█▓▒░ ░▒▓█▓▒░</pre
>
<p class="banner-thin" id="hive-rev" hidden></p>
</header>
<main class="home-main">
<nav class="home-menu" aria-label="hyperhive surfaces">
<main class="home-main">
<nav class="home-menu" aria-label="hyperhive surfaces">
<a class="home-tile" href="/dashboard.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🖥</span>
<span class="home-tile-label">Dashboard</span>
</span>
<span class="home-tile-desc"
>containers · approvals · permissions · schedules · system</span
>
</a>
<a class="home-tile" href="/dashboard.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🖥</span>
<span class="home-tile-label">Dashboard</span>
</span>
<span class="home-tile-desc">containers · approvals · permissions · schedules · system</span>
</a>
<a class="home-tile" href="/flow.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📡</span>
<span class="home-tile-label">Flow</span>
</span>
<span class="home-tile-desc">live all-agents message firehose</span>
</a>
<a class="home-tile" href="/flow.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📡</span>
<span class="home-tile-label">Flow</span>
</span>
<span class="home-tile-desc">live all-agents message firehose</span>
</a>
<a class="home-tile" href="/builds.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔨</span>
<span class="home-tile-label">Builds</span>
</span>
<span class="home-tile-desc"
>rebuild queue · meta inputs · build logs</span
>
</a>
<a class="home-tile" href="/builds.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔨</span>
<span class="home-tile-label">Builds</span>
</span>
<span class="home-tile-desc">rebuild queue · meta inputs · build logs</span>
</a>
<a class="home-tile" href="/logs.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📜</span>
<span class="home-tile-label">Logs</span>
</span>
<span class="home-tile-desc">agent · infra · system logs</span>
</a>
<a class="home-tile" href="/logs.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📜</span>
<span class="home-tile-label">Logs</span>
</span>
<span class="home-tile-desc">agent · infra · system logs</span>
</a>
<a class="home-tile" href="/stats.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📊</span>
<span class="home-tile-label">Stats</span>
</span>
<span class="home-tile-desc"
>hive-wide turn stats · cost · model mix</span
>
</a>
<a class="home-tile" href="/stats.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">📊</span>
<span class="home-tile-label">Stats</span>
</span>
<span class="home-tile-desc">hive-wide turn stats · cost · model mix</span>
</a>
<a class="home-tile" href="/core.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true"></span>
<span class="home-tile-label">Core</span>
</span>
<span class="home-tile-desc">kept state · container load</span>
</a>
<a class="home-tile" href="/core.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true"></span>
<span class="home-tile-label">Core</span>
</span>
<span class="home-tile-desc">kept state · container load</span>
</a>
<a class="home-tile" href="/credentials.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔑</span>
<span class="home-tile-label">Credentials</span>
</span>
<span class="home-tile-desc"
>provision per-agent matrix + github accounts</span
>
</a>
<a class="home-tile" href="/credentials.html">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🔑</span>
<span class="home-tile-label">Credentials</span>
</span>
<span class="home-tile-desc">provision per-agent matrix + github accounts</span>
</a>
<!-- API tile: the OpenAPI spec + Swagger UI are always served by
<!-- API tile: the OpenAPI spec + Swagger UI are always served by
hive-c0re itself (docs/web-ui/dashboard.md::Dashboard
endpoints), so this tile is never gated/hidden. -->
<a class="home-tile" href="/api/docs">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🧬</span>
<span class="home-tile-label">API</span>
</span>
<span class="home-tile-desc">interactive OpenAPI spec (Swagger UI)</span>
</a>
<a class="home-tile" href="/api/docs">
<span class="home-tile-head">
<span class="home-tile-icon" aria-hidden="true">🧬</span>
<span class="home-tile-label">API</span>
</span>
<span class="home-tile-desc"
>interactive OpenAPI spec (Swagger UI)</span
>
</a>
</nav>
</main>
</div>
<!-- /.page-content -->
</nav>
</main>
</div><!-- /.page-content -->
<script type="module" src="/static/home.js" defer></script>
</body>
<script type="module" src="/static/home.js" defer></script>
</body>
</html>

View file

@ -33,7 +33,9 @@ body.logs-shell {
flex-direction: column;
}
.logs-pane[hidden] { display: none; }
.logs-pane[hidden] {
display: none;
}
.logs-pane {
flex: 1 1 0;
min-height: 0;
@ -75,9 +77,15 @@ body.logs-shell {
padding: 0.25em 0.5em;
font-size: 0.85em;
}
.journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
.journal-refresh {
font-size: 0.75em;
padding: 0.15em 0.5em;
}
/* "fetched N ago" chip next to the refresh button on AGENT + SYSTEM tabs. */
.logs-fetch-ts { font-size: 0.8em; color: var(--muted); }
.logs-fetch-ts {
font-size: 0.8em;
color: var(--muted);
}
.journal-output {
margin: 0;
background: var(--crust);
@ -108,7 +116,10 @@ body.logs-shell {
gap: 0.5em;
align-items: center;
}
.build-logs-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
.build-logs-refresh {
font-size: 0.75em;
padding: 0.15em 0.5em;
}
.build-logs-list {
list-style: none;
padding: 0;
@ -124,7 +135,9 @@ body.logs-shell {
font-size: 0.85em;
padding: 0.3em 0;
}
.build-logs-error { color: var(--red); }
.build-logs-error {
color: var(--red);
}
.build-logs-item {
border-bottom: 1px solid var(--border);
padding: 0.15em 0;
@ -145,9 +158,15 @@ body.logs-shell {
cursor: pointer;
border-radius: 3px;
}
.build-logs-row-btn:hover { background: var(--bg-elev); }
.build-logs-kind { font-weight: 600; }
.build-logs-age { font-size: 0.88em; }
.build-logs-row-btn:hover {
background: var(--bg-elev);
}
.build-logs-kind {
font-weight: 600;
}
.build-logs-age {
font-size: 0.88em;
}
.build-logs-cmdline {
color: var(--muted);
font-size: 0.82em;
@ -156,7 +175,9 @@ body.logs-shell {
white-space: nowrap;
max-width: 40em;
}
.build-logs-detail { padding: 0 0.4em 0.4em; }
.build-logs-detail {
padding: 0 0.4em 0.4em;
}
.build-logs-output {
margin: 0.3em 0 0;
padding: 0.4em 0.6em;
@ -181,16 +202,32 @@ body.logs-shell {
margin-top: 0.3em;
display: none;
}
.build-logs-dl:not([hidden]) { display: inline-block; }
.build-logs-dl:hover { color: var(--fg); border-color: var(--purple-dim); }
.build-logs-dl:not([hidden]) {
display: inline-block;
}
.build-logs-dl:hover {
color: var(--fg);
border-color: var(--purple-dim);
}
.build-logs-live-badge { margin-bottom: 0.4em; }
.build-logs-live-badge {
margin-bottom: 0.4em;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.build-logs-live-badge.badge-running {
animation: live-pulse 1.4s ease-in-out infinite;
}
.build-logs-live-badge.badge-running { animation: live-pulse 1.4s ease-in-out infinite; }
.build-logs-runtime { font-size: 0.85em; color: var(--muted); }
.build-logs-runtime {
font-size: 0.85em;
color: var(--muted);
}

View file

@ -1,82 +1,112 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // LOGS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/colors.css">
<link rel="stylesheet" href="/static/theme.css">
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/logs.css">
</head>
<body class="logs-shell">
<!-- Minimal chrome: back link + sub-tab strip.
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // LOGS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/logs.css" />
</head>
<body class="logs-shell">
<!-- Minimal chrome: back link + sub-tab strip.
Same pattern as flow.html — no full dashboard tabbar. Back link
points to the H0M3 hub (served at /), not the dashboard.
Three sub-tabs: AGENT, INFRA, SYSTEM. Build log history has
moved to /builds.html (the build lifecycle hub). -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip class="hive-tabbar logs-tabbar" id="logs-tabbar" prefix="logs"
role="tablist"></hive-tab-strip>
</header>
<header class="page-header">
<a class="page-back" href="/">← home</a>
<hive-tab-strip
class="hive-tabbar logs-tabbar"
id="logs-tabbar"
prefix="logs"
role="tablist"
></hive-tab-strip>
</header>
<main class="logs-main">
<!-- AGENT: journald viewer for a specific agent container.
<main class="logs-main">
<!-- AGENT: journald viewer for a specific agent container.
Agent selector + unit filter + line count. Backed by
GET /api/journal/{agent}?unit=<unit>&lines=N. -->
<section class="logs-pane" id="logs-pane-agent" data-tab-pane="agent"
role="tabpanel" aria-labelledby="logs-tab-agent">
<div class="logs-toolbar">
<select id="agent-select" class="journal-unit"></select>
<select id="agent-unit-select" class="journal-unit">
<option value="hive-agent.service">hive-agent.service</option>
<option value="hive-mcp-http.service">hive-mcp-http.service</option>
<option value="hive-bash-daemon.service">hive-bash-daemon.service</option>
<option value="hive-matrix-daemon.service">hive-matrix-daemon.service</option>
<option value="">(full machine journal)</option>
</select>
<button type="button" class="btn btn-restart" id="agent-refresh">↻ refresh</button>
<span id="agent-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="agent-output" class="journal-output">select an agent above</pre>
</section>
<section
class="logs-pane"
id="logs-pane-agent"
data-tab-pane="agent"
role="tabpanel"
aria-labelledby="logs-tab-agent"
>
<div class="logs-toolbar">
<select id="agent-select" class="journal-unit"></select>
<select id="agent-unit-select" class="journal-unit">
<option value="hive-agent.service">hive-agent.service</option>
<option value="hive-mcp-http.service">hive-mcp-http.service</option>
<option value="hive-bash-daemon.service">
hive-bash-daemon.service
</option>
<option value="hive-matrix-daemon.service">
hive-matrix-daemon.service
</option>
<option value="">(full machine journal)</option>
</select>
<button type="button" class="btn btn-restart" id="agent-refresh">
↻ refresh
</button>
<span id="agent-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="agent-output" class="journal-output">
select an agent above</pre
>
</section>
<!-- INFRA: journald viewer for infrastructure containers (hive-ci,
<!-- INFRA: journald viewer for infrastructure containers (hive-ci,
hive-forge, hive-gateway, hive-matrix). Always fetches the full
machine journal — no unit filter (infra containers don't run the
per-agent hive daemons). Backed by GET /api/journal/{name}?lines=N.
Deep-link: ?agent=hive-ci routes here instead of the AGENT tab. -->
<section class="logs-pane" id="logs-pane-infra" data-tab-pane="infra"
role="tabpanel" aria-labelledby="logs-tab-infra">
<div class="logs-toolbar">
<select id="infra-select" class="journal-unit"></select>
<button type="button" class="btn btn-restart" id="infra-refresh">↻ refresh</button>
<span id="infra-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="infra-output" class="journal-output">select a container above</pre>
</section>
<section
class="logs-pane"
id="logs-pane-infra"
data-tab-pane="infra"
role="tabpanel"
aria-labelledby="logs-tab-infra"
>
<div class="logs-toolbar">
<select id="infra-select" class="journal-unit"></select>
<button type="button" class="btn btn-restart" id="infra-refresh">
↻ refresh
</button>
<span id="infra-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="infra-output" class="journal-output">
select a container above</pre
>
</section>
<!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon
<!-- SYSTEM: host-side service logs. Shows the hive-c0re daemon
journal via GET /api/journal-host?unit=hive-c0re.service. -->
<section class="logs-pane" id="logs-pane-system" data-tab-pane="system"
role="tabpanel" aria-labelledby="logs-tab-system">
<div class="logs-toolbar">
<select id="system-unit-select" class="journal-unit">
<option value="hive-c0re.service">hive-c0re.service</option>
<option value="hive-priv.service">hive-priv.service</option>
</select>
<button type="button" class="btn btn-restart" id="system-refresh">↻ refresh</button>
<span id="system-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="system-output" class="journal-output">loading…</pre>
</section>
<section
class="logs-pane"
id="logs-pane-system"
data-tab-pane="system"
role="tabpanel"
aria-labelledby="logs-tab-system"
>
<div class="logs-toolbar">
<select id="system-unit-select" class="journal-unit">
<option value="hive-c0re.service">hive-c0re.service</option>
<option value="hive-priv.service">hive-priv.service</option>
</select>
<button type="button" class="btn btn-restart" id="system-refresh">
↻ refresh
</button>
<span id="system-fetch-ts" class="meta logs-fetch-ts" hidden></span>
</div>
<pre id="system-output" class="journal-output">loading…</pre>
</section>
</main>
</main>
<script type="module" src="/static/logs.js" defer></script>
</body>
<script type="module" src="/static/logs.js" defer></script>
</body>
</html>

View file

@ -12,11 +12,9 @@
// Last-fetched timestamp is shown next to the refresh button on AGENT,
// INFRA, and SYSTEM tabs so the operator knows how stale the output is.
import {
$, fmtAgeSecs, initServerWarnings,
} from './common.js';
import { el } from '@hive/shared/dom.js';
import '@hive/shared/hive-tab-strip.js';
import { $, fmtAgeSecs, initServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import "@hive/shared/hive-tab-strip.js";
(() => {
initServerWarnings();
@ -34,34 +32,43 @@ import '@hive/shared/hive-tab-strip.js';
// Format a last-fetched timestamp: "fetched just now" / "fetched 3m ago".
function fmtFetchTs(fetchedAt) {
const ageSecs = Math.floor((Date.now() - fetchedAt) / 1000);
return 'fetched ' + (ageSecs < 5 ? 'just now' : fmtAgeSecs(ageSecs) + ' ago');
return (
"fetched " + (ageSecs < 5 ? "just now" : fmtAgeSecs(ageSecs) + " ago")
);
}
// ─── AGENT tab ────────────────────────────────────────────────────────
const agentSelect = $('agent-select');
const agentUnitSelect = $('agent-unit-select');
const agentRefresh = $('agent-refresh');
const agentOutput = $('agent-output');
const agentFetchTs = $('agent-fetch-ts');
const agentSelect = $("agent-select");
const agentUnitSelect = $("agent-unit-select");
const agentRefresh = $("agent-refresh");
const agentOutput = $("agent-output");
const agentFetchTs = $("agent-fetch-ts");
let agentFetching = false;
let agentLastFetch = 0;
async function fetchAgent() {
if (!agentSelect || !agentOutput) return;
const name = agentSelect.value;
if (!name) { agentOutput.textContent = 'select an agent above'; return; }
if (!name) {
agentOutput.textContent = "select an agent above";
return;
}
if (agentFetching) return;
agentFetching = true;
agentOutput.textContent = 'fetching…';
agentOutput.textContent = "fetching…";
if (agentFetchTs) agentFetchTs.hidden = true;
const unit = agentUnitSelect ? agentUnitSelect.value : '';
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
const unit = agentUnitSelect ? agentUnitSelect.value : "";
const params = new URLSearchParams({ lines: "500" });
if (unit) params.set("unit", unit);
try {
const resp = await fetch('/api/journal/' + encodeURIComponent(name) + '?' + params);
const resp = await fetch(
"/api/journal/" + encodeURIComponent(name) + "?" + params,
);
const text = await resp.text();
agentOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
agentOutput.textContent = resp.ok
? text || "(empty)"
: "error " + resp.status + "\n" + text;
agentOutput.scrollTop = agentOutput.scrollHeight;
if (resp.ok) {
agentLastFetch = Date.now();
@ -71,41 +78,48 @@ import '@hive/shared/hive-tab-strip.js';
}
}
} catch (err) {
agentOutput.textContent = 'fetch failed: ' + err;
agentOutput.textContent = "fetch failed: " + err;
} finally {
agentFetching = false;
}
}
if (agentSelect) agentSelect.addEventListener('change', fetchAgent);
if (agentUnitSelect) agentUnitSelect.addEventListener('change', fetchAgent);
if (agentRefresh) agentRefresh.addEventListener('click', fetchAgent);
if (agentSelect) agentSelect.addEventListener("change", fetchAgent);
if (agentUnitSelect) agentUnitSelect.addEventListener("change", fetchAgent);
if (agentRefresh) agentRefresh.addEventListener("click", fetchAgent);
// ─── INFRA tab ────────────────────────────────────────────────────────
// Infra containers (hive-ci, hive-forge, hive-gateway, hive-matrix) don't
// run the per-agent hive daemons, so the unit filter is inapplicable. We
// always fetch the full machine journal for them.
const infraSelect = $('infra-select');
const infraRefresh = $('infra-refresh');
const infraOutput = $('infra-output');
const infraFetchTs = $('infra-fetch-ts');
const infraSelect = $("infra-select");
const infraRefresh = $("infra-refresh");
const infraOutput = $("infra-output");
const infraFetchTs = $("infra-fetch-ts");
let infraFetching = false;
let infraLastFetch = 0;
async function fetchInfra() {
if (!infraSelect || !infraOutput) return;
const name = infraSelect.value;
if (!name) { infraOutput.textContent = 'select a container above'; return; }
if (!name) {
infraOutput.textContent = "select a container above";
return;
}
if (infraFetching) return;
infraFetching = true;
infraOutput.textContent = 'fetching…';
infraOutput.textContent = "fetching…";
if (infraFetchTs) infraFetchTs.hidden = true;
const params = new URLSearchParams({ lines: '500' });
const params = new URLSearchParams({ lines: "500" });
try {
const resp = await fetch('/api/journal/' + encodeURIComponent(name) + '?' + params);
const resp = await fetch(
"/api/journal/" + encodeURIComponent(name) + "?" + params,
);
const text = await resp.text();
infraOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
infraOutput.textContent = resp.ok
? text || "(empty)"
: "error " + resp.status + "\n" + text;
infraOutput.scrollTop = infraOutput.scrollHeight;
if (resp.ok) {
infraLastFetch = Date.now();
@ -115,20 +129,20 @@ import '@hive/shared/hive-tab-strip.js';
}
}
} catch (err) {
infraOutput.textContent = 'fetch failed: ' + err;
infraOutput.textContent = "fetch failed: " + err;
} finally {
infraFetching = false;
}
}
if (infraSelect) infraSelect.addEventListener('change', fetchInfra);
if (infraRefresh) infraRefresh.addEventListener('click', fetchInfra);
if (infraSelect) infraSelect.addEventListener("change", fetchInfra);
if (infraRefresh) infraRefresh.addEventListener("click", fetchInfra);
// Fixed allowlist — the four hive infra services never change at
// runtime, and there's no dashboard API exposing just the name list
// (the one that used to, `/api/state`'s `infra_containers` field, was
// start/stop-panel-only and is gone). Mirrors `hive_priv_sock::InfraContainer::ALL`.
const INFRA_NAMES = ['hive-ci', 'hive-forge', 'hive-gateway', 'hive-matrix'];
const INFRA_NAMES = ["hive-ci", "hive-forge", "hive-gateway", "hive-matrix"];
// ─── container list init ──────────────────────────────────────────────
// Fetch /api/state once to populate the AGENT selector (agents only);
@ -138,59 +152,64 @@ import '@hive/shared/hive-tab-strip.js';
async function loadContainerLists() {
if (infraSelect) {
infraSelect.replaceChildren();
infraSelect.append(el('option', { value: '' }, '— select container —'));
infraSelect.append(el("option", { value: "" }, "— select container —"));
for (const name of INFRA_NAMES) {
infraSelect.append(el('option', { value: name }, name));
infraSelect.append(el("option", { value: name }, name));
}
}
try {
const resp = await fetch('/api/state');
const resp = await fetch("/api/state");
if (!resp.ok) return;
const state = await resp.json();
// Populate AGENT selector (agents only — no infra optgroup).
if (agentSelect) {
agentSelect.replaceChildren();
agentSelect.append(el('option', { value: '' }, '— select agent —'));
for (const c of (state.containers || [])) {
agentSelect.append(el('option', { value: c.name }, c.name));
agentSelect.append(el("option", { value: "" }, "— select agent —"));
for (const c of state.containers || []) {
agentSelect.append(el("option", { value: c.name }, c.name));
}
}
// Deep-link: honour ?agent= and ?unit= URL params.
const urlAgent = new URLSearchParams(location.search).get('agent');
const urlUnit = new URLSearchParams(location.search).get('unit');
const urlAgent = new URLSearchParams(location.search).get("agent");
const urlUnit = new URLSearchParams(location.search).get("unit");
if (urlAgent) {
if (INFRA_NAMES.includes(urlAgent)) {
// Route to INFRA tab.
logTabs.show('infra');
logTabs.show("infra");
if (infraSelect) {
infraSelect.value = urlAgent;
fetchInfra();
}
} else if (agentSelect) {
// Route to AGENT tab.
const found = Array.from(agentSelect.options).some((o) => o.value === urlAgent);
const found = Array.from(agentSelect.options).some(
(o) => o.value === urlAgent,
);
if (found) {
agentSelect.value = urlAgent;
if (urlUnit && agentUnitSelect) {
const unitFound = Array.from(agentUnitSelect.options)
.some((o) => o.value === urlUnit);
const unitFound = Array.from(agentUnitSelect.options).some(
(o) => o.value === urlUnit,
);
if (unitFound) agentUnitSelect.value = urlUnit;
}
fetchAgent();
}
}
}
} catch { /**/ }
} catch {
/**/
}
}
// ─── SYSTEM tab ───────────────────────────────────────────────────────
const systemUnitSelect = $('system-unit-select');
const systemRefresh = $('system-refresh');
const systemOutput = $('system-output');
const systemFetchTs = $('system-fetch-ts');
const systemUnitSelect = $("system-unit-select");
const systemRefresh = $("system-refresh");
const systemOutput = $("system-output");
const systemFetchTs = $("system-fetch-ts");
let systemFetching = false;
let systemLastFetch = 0;
@ -198,15 +217,19 @@ import '@hive/shared/hive-tab-strip.js';
if (!systemOutput) return;
if (systemFetching) return;
systemFetching = true;
systemOutput.textContent = 'fetching…';
systemOutput.textContent = "fetching…";
if (systemFetchTs) systemFetchTs.hidden = true;
const unit = systemUnitSelect ? systemUnitSelect.value : 'hive-c0re.service';
const params = new URLSearchParams({ lines: '500' });
if (unit) params.set('unit', unit);
const unit = systemUnitSelect
? systemUnitSelect.value
: "hive-c0re.service";
const params = new URLSearchParams({ lines: "500" });
if (unit) params.set("unit", unit);
try {
const resp = await fetch('/api/journal-host?' + params);
const resp = await fetch("/api/journal-host?" + params);
const text = await resp.text();
systemOutput.textContent = resp.ok ? (text || '(empty)') : 'error ' + resp.status + '\n' + text;
systemOutput.textContent = resp.ok
? text || "(empty)"
: "error " + resp.status + "\n" + text;
systemOutput.scrollTop = systemOutput.scrollHeight;
if (resp.ok) {
systemLastFetch = Date.now();
@ -216,14 +239,15 @@ import '@hive/shared/hive-tab-strip.js';
}
}
} catch (err) {
systemOutput.textContent = 'fetch failed: ' + err;
systemOutput.textContent = "fetch failed: " + err;
} finally {
systemFetching = false;
}
}
if (systemUnitSelect) systemUnitSelect.addEventListener('change', fetchSystem);
if (systemRefresh) systemRefresh.addEventListener('click', fetchSystem);
if (systemUnitSelect)
systemUnitSelect.addEventListener("change", fetchSystem);
if (systemRefresh) systemRefresh.addEventListener("click", fetchSystem);
// ─── init ─────────────────────────────────────────────────────────────
@ -231,15 +255,15 @@ import '@hive/shared/hive-tab-strip.js';
// needs are defined. Its initial show() paints the active pane and, if
// the deep-linked tab is SYSTEM, kicks off the lazy fetch via onShow.
// Default: AGENT.
logTabs = document.getElementById('logs-tabbar').configure({
logTabs = document.getElementById("logs-tabbar").configure({
tabs: [
{ id: 'agent', label: 'AGENT' },
{ id: 'infra', label: 'INFRA' },
{ id: 'system', label: 'SYSTEM' },
{ id: "agent", label: "AGENT" },
{ id: "infra", label: "INFRA" },
{ id: "system", label: "SYSTEM" },
],
defaultId: 'agent',
defaultId: "agent",
onShow: (id) => {
if (id === 'system') fetchSystem();
if (id === "system") fetchSystem();
},
});
loadContainerLists();
@ -257,5 +281,4 @@ import '@hive/shared/hive-tab-strip.js';
systemFetchTs.textContent = fmtFetchTs(systemLastFetch);
}
}, 30_000);
})();

View file

@ -21,10 +21,10 @@
// coalesces caps+groups per agent into ONE queue entry (one rebuild, no
// double-rebuild). Batch is atomic — saved→rebuilding only fires on a clean 200.
import { $ } from './common.js';
import { el } from '@hive/shared/dom.js';
import { containersState } from './state.js';
import { asyncBtn } from '@hive/shared/forms.js';
import { $ } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { containersState } from "./state.js";
import { asyncBtn } from "@hive/shared/forms.js";
// ── SSE re-render guards ────────────────────────────────────────────
// Skip the live re-render when the operator has unsaved edits in that
@ -33,14 +33,14 @@ import { asyncBtn } from '@hive/shared/forms.js';
// and the post-save re-fetch are the recovery paths; both clear dirty.
export function applyCapabilitiesChanged(ev) {
const root = $('capabilities-section');
const root = $("capabilities-section");
if (!root) return;
if (root.contains(document.activeElement)) return;
if (sectionHasDirty(root)) return;
renderCapabilities(root, ev);
}
export function applyToolGroupsChanged(ev) {
const root = $('tool-groups-section');
const root = $("tool-groups-section");
if (!root) return;
if (root.contains(document.activeElement)) return;
if (sectionHasDirty(root)) return;
@ -50,25 +50,25 @@ export function applyToolGroupsChanged(ev) {
// A section is dirty if any checkbox diverges from its render-time
// baseline. Cheap DOM scan; no module-level mirror to drift.
function sectionHasDirty(root) {
for (const cb of root.querySelectorAll('input[type=checkbox]')) {
if (cb.checked !== (cb.dataset.baseline === '1')) return true;
for (const cb of root.querySelectorAll("input[type=checkbox]")) {
if (cb.checked !== (cb.dataset.baseline === "1")) return true;
}
return false;
}
export async function fetchAndRenderCapabilities() {
const root = $('capabilities-section');
const root = $("capabilities-section");
if (!root) return;
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'loading…'));
root.append(el("p", { class: "meta" }, "loading…"));
try {
const resp = await fetch('/api/capabilities');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/capabilities");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderCapabilities(root, data);
} catch (err) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
root.append(el("p", { class: "meta" }, "fetch failed: " + err));
}
}
@ -76,7 +76,7 @@ function renderCapabilities(root, data) {
root.replaceChildren();
const { caps, descriptions = {}, assignments, effective = {} } = data;
if (!caps || !caps.length) {
root.append(el('p', { class: 'meta' }, '(no capabilities defined)'));
root.append(el("p", { class: "meta" }, "(no capabilities defined)"));
updateSaveBar();
return;
}
@ -84,33 +84,36 @@ function renderCapabilities(root, data) {
// Agent rows: prefer the backend roster (every manageable agent,
// default-perms included). Fall back to the live-container explicit
// union for older payloads that don't carry `agents`.
const agentNames = (data.agents && data.agents.length)
? [...data.agents]
: [...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
])].sort();
const agentNames =
data.agents && data.agents.length
? [...data.agents]
: [
...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
]),
].sort();
if (!agentNames.length) {
root.append(el('p', { class: 'meta' }, '(no agents)'));
root.append(el("p", { class: "meta" }, "(no agents)"));
updateSaveBar();
return;
}
const wrap = el('div', { class: 'cap-table-wrap' });
const table = el('table', { class: 'cap-table' });
const wrap = el("div", { class: "cap-table-wrap" });
const table = el("table", { class: "cap-table" });
// Header row.
const thead = el('thead');
const hrow = el('tr');
hrow.append(el('th', { class: 'cap-agent-col' }, 'agent'));
const thead = el("thead");
const hrow = el("tr");
hrow.append(el("th", { class: "cap-agent-col" }, "agent"));
for (const c of caps) {
hrow.append(el('th', { class: 'cap-col', title: descriptions[c] || c }, c));
hrow.append(el("th", { class: "cap-col", title: descriptions[c] || c }, c));
}
thead.append(hrow);
table.append(thead);
const tbody = el('tbody');
const tbody = el("tbody");
for (const name of agentNames) {
// Effective caps (explicit-or-default) drive the checkboxes so
// default-perms agents show their real grants, not blank.
@ -119,19 +122,26 @@ function renderCapabilities(root, data) {
// includes stopped-but-configured containers — so a temporarily-stopped
// agent is NOT stale. Only destroyed/renamed agents are absent here.
const isStale = !containersState.has(name);
const tr = el('tr', { class: 'cap-row' + (isStale ? ' perm-row-stale' : ''), 'data-agent': name });
const tr = el("tr", {
class: "cap-row" + (isStale ? " perm-row-stale" : ""),
"data-agent": name,
});
// Agent name cell.
const nameTd = el('td', { class: 'cap-agent-col' });
nameTd.append(el('span', { class: 'cap-agent-name' }, name));
const nameTd = el("td", { class: "cap-agent-col" });
nameTd.append(el("span", { class: "cap-agent-name" }, name));
if (isStale) {
nameTd.append(el('span', { class: 'perm-stale-label' }, '(not running)'));
const removeBtn = el('button', {
type: 'button',
class: 'perm-remove-btn',
title: 'remove stale permission entries for ' + name,
}, '✕ remove');
removeBtn.addEventListener('click', () => clearStaleAgent(name, root));
nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)"));
const removeBtn = el(
"button",
{
type: "button",
class: "perm-remove-btn",
title: "remove stale permission entries for " + name,
},
"✕ remove",
);
removeBtn.addEventListener("click", () => clearStaleAgent(name, root));
nameTd.append(removeBtn);
}
tr.append(nameTd);
@ -139,16 +149,16 @@ function renderCapabilities(root, data) {
// One checkbox per capability.
for (const c of caps) {
const checked = assigned.includes(c);
const td = el('td', { class: 'cap-col' });
const cb = el('input', {
type: 'checkbox',
class: 'cap-cb',
'data-cap': c,
'data-baseline': checked ? '1' : '0',
'aria-label': c,
const td = el("td", { class: "cap-col" });
const cb = el("input", {
type: "checkbox",
class: "cap-cb",
"data-cap": c,
"data-baseline": checked ? "1" : "0",
"aria-label": c,
});
cb.checked = checked;
cb.addEventListener('change', onCellToggle);
cb.addEventListener("change", onCellToggle);
td.append(cb);
tr.append(td);
}
@ -162,18 +172,18 @@ function renderCapabilities(root, data) {
}
export async function fetchAndRenderToolGroups() {
const root = $('tool-groups-section');
const root = $("tool-groups-section");
if (!root) return;
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'loading…'));
root.append(el("p", { class: "meta" }, "loading…"));
try {
const resp = await fetch('/api/tool-groups');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/tool-groups");
if (!resp.ok) throw new Error("http " + resp.status);
const data = await resp.json();
renderToolGroups(root, data);
} catch (err) {
root.replaceChildren();
root.append(el('p', { class: 'meta' }, 'fetch failed: ' + err));
root.append(el("p", { class: "meta" }, "fetch failed: " + err));
}
}
@ -181,40 +191,45 @@ function renderToolGroups(root, data) {
root.replaceChildren();
const { groups, descriptions = {}, assignments, effective = {} } = data;
if (!groups || !groups.length) {
root.append(el('p', { class: 'meta' }, '(no tool groups defined)'));
root.append(el("p", { class: "meta" }, "(no tool groups defined)"));
updateSaveBar();
return;
}
// Agent rows: prefer the backend roster (default-perms agents included);
// fall back to the live-container explicit union for older payloads.
const agentNames = (data.agents && data.agents.length)
? [...data.agents]
: [...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
])].sort();
const agentNames =
data.agents && data.agents.length
? [...data.agents]
: [
...new Set([
...Array.from(containersState.keys()),
...Object.keys(assignments),
]),
].sort();
if (!agentNames.length) {
root.append(el('p', { class: 'meta' }, '(no agents)'));
root.append(el("p", { class: "meta" }, "(no agents)"));
updateSaveBar();
return;
}
const wrap = el('div', { class: 'tg-table-wrap' });
const table = el('table', { class: 'tg-table' });
const wrap = el("div", { class: "tg-table-wrap" });
const table = el("table", { class: "tg-table" });
// Header row.
const thead = el('thead');
const hrow = el('tr');
hrow.append(el('th', { class: 'tg-agent-col' }, 'agent'));
const thead = el("thead");
const hrow = el("tr");
hrow.append(el("th", { class: "tg-agent-col" }, "agent"));
for (const g of groups) {
hrow.append(el('th', { class: 'tg-group-col', title: descriptions[g] || g }, g));
hrow.append(
el("th", { class: "tg-group-col", title: descriptions[g] || g }, g),
);
}
thead.append(hrow);
table.append(thead);
const tbody = el('tbody');
const tbody = el("tbody");
for (const name of agentNames) {
// Effective groups (explicit-or-role-default) drive the checkboxes so
// a default agent shows its real groups, not blank — and saving keeps
@ -226,38 +241,47 @@ function renderToolGroups(root, data) {
// includes stopped-but-configured containers — only destroyed/renamed
// agents are absent.
const isStale = !containersState.has(name);
const tr = el('tr', { class: 'tg-row' + (isStale ? ' perm-row-stale' : ''), 'data-agent': name });
const tr = el("tr", {
class: "tg-row" + (isStale ? " perm-row-stale" : ""),
"data-agent": name,
});
// Agent name cell.
const nameTd = el('td', { class: 'tg-agent-col' });
nameTd.append(el('span', { class: 'tg-agent-name' }, name));
const nameTd = el("td", { class: "tg-agent-col" });
nameTd.append(el("span", { class: "tg-agent-name" }, name));
if (isStale) {
nameTd.append(el('span', { class: 'perm-stale-label' }, '(not running)'));
const removeBtn = el('button', {
type: 'button',
class: 'perm-remove-btn',
title: 'remove stale permission entries for ' + name,
}, '✕ remove');
removeBtn.addEventListener('click', () => clearStaleAgent(name, root));
nameTd.append(el("span", { class: "perm-stale-label" }, "(not running)"));
const removeBtn = el(
"button",
{
type: "button",
class: "perm-remove-btn",
title: "remove stale permission entries for " + name,
},
"✕ remove",
);
removeBtn.addEventListener("click", () => clearStaleAgent(name, root));
nameTd.append(removeBtn);
} else if (!hasExplicit) {
nameTd.append(el('span', { class: 'meta tg-default-label' }, '(default)'));
nameTd.append(
el("span", { class: "meta tg-default-label" }, "(default)"),
);
}
tr.append(nameTd);
// One checkbox per group.
for (const g of groups) {
const checked = assigned.includes(g);
const td = el('td', { class: 'tg-group-col' });
const cb = el('input', {
type: 'checkbox',
class: 'tg-cb',
'data-group': g,
'data-baseline': checked ? '1' : '0',
'aria-label': g,
const td = el("td", { class: "tg-group-col" });
const cb = el("input", {
type: "checkbox",
class: "tg-cb",
"data-group": g,
"data-baseline": checked ? "1" : "0",
"aria-label": g,
});
cb.checked = checked;
cb.addEventListener('change', onCellToggle);
cb.addEventListener("change", onCellToggle);
td.append(cb);
tr.append(td);
}
@ -285,8 +309,22 @@ function onCellToggle() {
// a full replacement → omitting it leaves that file alone).
function collectChanges() {
const byAgent = new Map(); // agent -> { capabilities?, tool_groups? }
collectSection($('capabilities-section'), '.cap-row', '.cap-cb', 'cap', 'capabilities', byAgent);
collectSection($('tool-groups-section'), '.tg-row', '.tg-cb', 'group', 'tool_groups', byAgent);
collectSection(
$("capabilities-section"),
".cap-row",
".cap-cb",
"cap",
"capabilities",
byAgent,
);
collectSection(
$("tool-groups-section"),
".tg-row",
".tg-cb",
"group",
"tool_groups",
byAgent,
);
const changes = [];
for (const [agent, obj] of byAgent) changes.push({ agent, ...obj });
return changes;
@ -300,7 +338,7 @@ function collectSection(root, rowSel, cbSel, dataKey, field, byAgent) {
let dirty = false;
const selected = [];
for (const cb of tr.querySelectorAll(cbSel)) {
if (cb.checked !== (cb.dataset.baseline === '1')) dirty = true;
if (cb.checked !== (cb.dataset.baseline === "1")) dirty = true;
if (cb.checked) selected.push(cb.dataset[dataKey]);
}
if (dirty) {
@ -312,13 +350,14 @@ function collectSection(root, rowSel, cbSel, dataKey, field, byAgent) {
}
function updateSaveBar() {
const btn = $('perm-save-all');
const btn = $("perm-save-all");
if (!btn) return;
// Don't stomp a transient saving/rebuilding label.
if (btn.dataset.busy === '1') return;
if (btn.dataset.busy === "1") return;
const n = collectChanges().length;
btn.disabled = n === 0;
btn.textContent = n === 0 ? 'save all' : `save all (${n} agent${n === 1 ? '' : 's'})`;
btn.textContent =
n === 0 ? "save all" : `save all (${n} agent${n === 1 ? "" : "s"})`;
}
// Remove all explicit permission entries for a stale (non-running)
@ -328,20 +367,27 @@ function updateSaveBar() {
// the delete so the row disappears immediately.
async function clearStaleAgent(name, sectionRoot) {
const btn = sectionRoot
? sectionRoot.querySelector(`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`)
? sectionRoot.querySelector(
`[data-agent="${CSS.escape(name)}"] .perm-remove-btn`,
)
: null;
const doDelete = async () => {
try {
const resp = await fetch('/api/permissions/' + encodeURIComponent(name), { method: 'DELETE' });
const resp = await fetch("/api/permissions/" + encodeURIComponent(name), {
method: "DELETE",
});
if (!resp.ok) {
const text = await resp.text().catch(() => resp.status);
setSaveNote('failed to remove ' + name + ': ' + text, true);
setSaveNote("failed to remove " + name + ": " + text, true);
return;
}
// Re-fetch both sections so the stale row disappears.
await Promise.all([fetchAndRenderCapabilities(), fetchAndRenderToolGroups()]);
await Promise.all([
fetchAndRenderCapabilities(),
fetchAndRenderToolGroups(),
]);
} catch (err) {
setSaveNote('failed to remove ' + name + ': ' + err, true);
setSaveNote("failed to remove " + name + ": " + err, true);
}
};
// asyncBtn guards double-submit; fall through without guard when there
@ -352,38 +398,41 @@ async function clearStaleAgent(name, sectionRoot) {
}
function clearSaveStatus() {
const note = $('perm-save-note');
if (note) { note.textContent = ''; note.classList.remove('perm-save-err'); }
const note = $("perm-save-note");
if (note) {
note.textContent = "";
note.classList.remove("perm-save-err");
}
}
function setSaveNote(text, isErr) {
const note = $('perm-save-note');
const note = $("perm-save-note");
if (!note) return;
note.textContent = text;
note.classList.toggle('perm-save-err', !!isErr);
note.classList.toggle("perm-save-err", !!isErr);
}
async function saveAll() {
const btn = $('perm-save-all');
const btn = $("perm-save-all");
if (!btn) return;
const changes = collectChanges();
if (!changes.length) return;
btn.dataset.busy = '1';
btn.dataset.busy = "1";
btn.disabled = true;
btn.textContent = 'saving…';
btn.textContent = "saving…";
clearSaveStatus();
try {
const r = await fetch('/api/permissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
const r = await fetch("/api/permissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ changes }),
});
if (!r.ok) {
const txt = await r.text();
btn.dataset.busy = '';
btn.dataset.busy = "";
btn.disabled = false;
btn.textContent = 'save all';
setSaveNote('save failed: ' + (txt || ('http ' + r.status)), true);
btn.textContent = "save all";
setSaveNote("save failed: " + (txt || "http " + r.status), true);
updateSaveBar();
return;
}
@ -391,18 +440,18 @@ async function saveAll() {
// re-fetch both tables once the queue worker has committed — that
// resets baselines (dirty clears) and the bar disables itself.
const n = changes.length;
btn.textContent = 'queued ✓';
setSaveNote(`rebuilding ${n} agent${n === 1 ? '' : 's'}`, false);
btn.textContent = "queued ✓";
setSaveNote(`rebuilding ${n} agent${n === 1 ? "" : "s"}`, false);
setTimeout(() => {
btn.dataset.busy = '';
btn.dataset.busy = "";
fetchAndRenderCapabilities();
fetchAndRenderToolGroups();
}, 900);
} catch (err) {
btn.dataset.busy = '';
btn.dataset.busy = "";
btn.disabled = false;
btn.textContent = 'save all';
setSaveNote('save failed: ' + String(err), true);
btn.textContent = "save all";
setSaveNote("save failed: " + String(err), true);
updateSaveBar();
}
}
@ -411,9 +460,9 @@ async function saveAll() {
// after the DOM is ready (the button lives in the static permissions
// pane markup, so it exists before any fetch).
export function initPermissions() {
const btn = $('perm-save-all');
const btn = $("perm-save-all");
if (btn && !btn.dataset.bound) {
btn.dataset.bound = '1';
btn.addEventListener('click', saveAll);
btn.dataset.bound = "1";
btn.addEventListener("click", saveAll);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,55 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hyperhive // ST4TS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/static/colors.css">
<link rel="stylesheet" href="/static/theme.css">
<link rel="stylesheet" href="/static/common.css">
<link rel="stylesheet" href="/static/stats.css">
</head>
<body class="stats-shell">
<!-- Minimal chrome: back link + title. Same pattern as flow.html /
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>hyperhive // ST4TS</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/static/colors.css" />
<link rel="stylesheet" href="/static/theme.css" />
<link rel="stylesheet" href="/static/common.css" />
<link rel="stylesheet" href="/static/stats.css" />
</head>
<body class="stats-shell">
<!-- Minimal chrome: back link + title. Same pattern as flow.html /
logs.html — no full dashboard tabbar. Back link points to the
H0M3 hub (served at /), not the dashboard. -->
<header class="page-header">
<a class="page-back" href="/">← home</a>
<span class="page-title">ST4TS</span>
<!-- Window selector lives in the header so it behaves like a tab strip
<header class="page-header">
<a class="page-back" href="/">← home</a>
<span class="page-title">ST4TS</span>
<!-- Window selector lives in the header so it behaves like a tab strip
(matches the pattern on /logs.html). Hash-routed via createTabStrip. -->
<nav class="hive-stats-windows" id="hive-stats-windows" role="tablist">
<button type="button" class="btn" data-tab="1h">1h</button>
<button type="button" class="btn" data-tab="4h">4h</button>
<button type="button" class="btn" data-tab="24h">24h</button>
<button type="button" class="btn" data-tab="3d">3d</button>
<button type="button" class="btn" data-tab="7d">7d</button>
<button type="button" class="btn" data-tab="30d">30d</button>
<button type="button" class="btn" data-tab="all">all</button>
</nav>
</header>
<nav class="hive-stats-windows" id="hive-stats-windows" role="tablist">
<button type="button" class="btn" data-tab="1h">1h</button>
<button type="button" class="btn" data-tab="4h">4h</button>
<button type="button" class="btn" data-tab="24h">24h</button>
<button type="button" class="btn" data-tab="3d">3d</button>
<button type="button" class="btn" data-tab="7d">7d</button>
<button type="button" class="btn" data-tab="30d">30d</button>
<button type="button" class="btn" data-tab="all">all</button>
</nav>
</header>
<main class="stats-main">
<p class="meta">hive-wide turn statistics, aggregated across every agent over the selected window. <strong>cost is a rough estimate</strong> from approximate per-model list prices — it drifts and is a ballpark, not a bill.</p>
<main class="stats-main">
<p class="meta">
hive-wide turn statistics, aggregated across every agent over the
selected window. <strong>cost is a rough estimate</strong> from
approximate per-model list prices — it drifts and is a ballpark, not a
bill.
</p>
<div class="hive-stats-chips" id="hive-stats-summary"></div>
<h3>◇ busiest agents</h3>
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
<h3>◇ model mix (turns across the swarm)</h3>
<div id="hive-stats-models"></div>
<!-- "favorite tools": most-run bash commands across the swarm.
<div class="hive-stats-chips" id="hive-stats-summary"></div>
<h3>◇ busiest agents</h3>
<div id="hive-stats-agents"><p class="meta">loading…</p></div>
<h3>◇ model mix (turns across the swarm)</h3>
<div id="hive-stats-models"></div>
<!-- "favorite tools": most-run bash commands across the swarm.
Header + list hidden until the hive-bash-daemon capture has
recorded data, so the section never shows an empty block. -->
<h3 id="hive-stats-bash-h" hidden>◇ favorite tools (bash commands across the swarm)</h3>
<div id="hive-stats-bash" hidden></div>
<!-- most-triggered skills across the swarm. Header + list hidden until
<h3 id="hive-stats-bash-h" hidden>
◇ favorite tools (bash commands across the swarm)
</h3>
<div id="hive-stats-bash" hidden></div>
<!-- most-triggered skills across the swarm. Header + list hidden until
at least one agent has actually invoked a skill. -->
<h3 id="hive-stats-skills-h" hidden>◇ skill mix (invocations across the swarm)</h3>
<div id="hive-stats-skills" hidden></div>
</main>
<h3 id="hive-stats-skills-h" hidden>
◇ skill mix (invocations across the swarm)
</h3>
<div id="hive-stats-skills" hidden></div>
</main>
<script type="module" src="/static/stats.js" defer></script>
</body>
<script type="module" src="/static/stats.js" defer></script>
</body>
</html>

View file

@ -5,39 +5,45 @@
// this bundle has no chart lib; per-agent trend charts live on each
// agent's own /stats page. The window selector is a hash-routed
// createTabStrip (#1h / #24h / …), matching the per-agent stats page.
import { $, initServerWarnings } from './common.js';
import { createTabStrip } from '@hive/shared/tabs.js';
import { $, initServerWarnings } from "./common.js";
import { createTabStrip } from "@hive/shared/tabs.js";
let hiveStatsWindow = '24h';
let hiveStatsWindow = "24h";
function hsFmtInt(n) {
return Number.isFinite(n) ? new Intl.NumberFormat().format(Math.round(n)) : '0';
return Number.isFinite(n)
? new Intl.NumberFormat().format(Math.round(n))
: "0";
}
function hsFmtTokens(n) {
if (!Number.isFinite(n)) return '0';
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'k';
if (!Number.isFinite(n)) return "0";
if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
return String(Math.round(n));
}
function hsFmtUsd(n) {
if (!Number.isFinite(n)) return '$0';
if (n >= 100) return '$' + n.toFixed(0);
if (n >= 1) return '$' + n.toFixed(2);
return '$' + n.toFixed(3);
if (!Number.isFinite(n)) return "$0";
if (n >= 100) return "$" + n.toFixed(0);
if (n >= 1) return "$" + n.toFixed(2);
return "$" + n.toFixed(3);
}
function hsChip(parent, label, value, est) {
const c = document.createElement('span');
c.className = 'hive-stats-chip' + (est ? ' est' : '');
const k = document.createElement('span'); k.className = 'k'; k.textContent = label;
const v = document.createElement('span'); v.className = 'v'; v.textContent = value;
const c = document.createElement("span");
c.className = "hive-stats-chip" + (est ? " est" : "");
const k = document.createElement("span");
k.className = "k";
k.textContent = label;
const v = document.createElement("span");
v.className = "v";
v.textContent = value;
c.append(k, v);
parent.append(c);
}
function hsMeta(parent, text) {
parent.replaceChildren();
const p = document.createElement('p');
p.className = 'meta';
const p = document.createElement("p");
p.className = "meta";
p.textContent = text;
parent.append(p);
}
@ -46,13 +52,20 @@ function renderKeyCountBars(container, mix) {
container.replaceChildren();
const max = mix[0].count || 1;
for (const kc of mix) {
const row = document.createElement('div'); row.className = 'hive-stats-bar';
const lbl = document.createElement('span'); lbl.className = 'lbl'; lbl.textContent = kc.key;
const track = document.createElement('span'); track.className = 'track';
const fill = document.createElement('span'); fill.className = 'fill';
fill.style.width = Math.max(2, Math.round(100 * kc.count / max)) + '%';
const row = document.createElement("div");
row.className = "hive-stats-bar";
const lbl = document.createElement("span");
lbl.className = "lbl";
lbl.textContent = kc.key;
const track = document.createElement("span");
track.className = "track";
const fill = document.createElement("span");
fill.className = "fill";
fill.style.width = Math.max(2, Math.round((100 * kc.count) / max)) + "%";
track.append(fill);
const cnt = document.createElement('span'); cnt.className = 'cnt'; cnt.textContent = hsFmtInt(kc.count);
const cnt = document.createElement("span");
cnt.className = "cnt";
cnt.textContent = hsFmtInt(kc.count);
row.append(lbl, track, cnt);
container.append(row);
}
@ -76,43 +89,50 @@ function renderOptionalMix(containerId, headerId, mix) {
}
function renderHiveStats(s) {
const sum = $('hive-stats-summary');
const sum = $("hive-stats-summary");
if (sum) {
sum.replaceChildren();
hsChip(sum, 'window', s.window);
hsChip(sum, 'active agents', hsFmtInt(s.active_agents));
hsChip(sum, 'turns', hsFmtInt(s.total_turns));
const totalTok = (s.total_input_tokens || 0) + (s.total_output_tokens || 0)
+ (s.total_cache_read_tokens || 0) + (s.total_cache_creation_tokens || 0);
hsChip(sum, 'tokens', hsFmtTokens(totalTok));
hsChip(sum, 'input', hsFmtTokens(s.total_input_tokens));
hsChip(sum, 'output', hsFmtTokens(s.total_output_tokens));
hsChip(sum, 'cache read', hsFmtTokens(s.total_cache_read_tokens));
hsChip(sum, 'est cost', hsFmtUsd(s.est_cost_usd), true);
hsChip(sum, "window", s.window);
hsChip(sum, "active agents", hsFmtInt(s.active_agents));
hsChip(sum, "turns", hsFmtInt(s.total_turns));
const totalTok =
(s.total_input_tokens || 0) +
(s.total_output_tokens || 0) +
(s.total_cache_read_tokens || 0) +
(s.total_cache_creation_tokens || 0);
hsChip(sum, "tokens", hsFmtTokens(totalTok));
hsChip(sum, "input", hsFmtTokens(s.total_input_tokens));
hsChip(sum, "output", hsFmtTokens(s.total_output_tokens));
hsChip(sum, "cache read", hsFmtTokens(s.total_cache_read_tokens));
hsChip(sum, "est cost", hsFmtUsd(s.est_cost_usd), true);
}
const at = $('hive-stats-agents');
const at = $("hive-stats-agents");
if (at) {
const agents = s.agents || [];
if (!agents.length) {
hsMeta(at, 'no turns in window');
hsMeta(at, "no turns in window");
} else {
at.replaceChildren();
const table = document.createElement('table');
table.className = 'hive-stats-table';
table.innerHTML = '<thead><tr><th>agent</th><th>turns</th><th>input</th>'
+ '<th>output</th><th>cache read</th><th>est cost</th></tr></thead>';
const tb = document.createElement('tbody');
const table = document.createElement("table");
table.className = "hive-stats-table";
table.innerHTML =
"<thead><tr><th>agent</th><th>turns</th><th>input</th>" +
"<th>output</th><th>cache read</th><th>est cost</th></tr></thead>";
const tb = document.createElement("tbody");
for (const a of agents) {
const tr = document.createElement('tr');
const tr = document.createElement("tr");
const cells = [
a.name, hsFmtInt(a.turns), hsFmtTokens(a.input_tokens),
hsFmtTokens(a.output_tokens), hsFmtTokens(a.cache_read_tokens),
a.name,
hsFmtInt(a.turns),
hsFmtTokens(a.input_tokens),
hsFmtTokens(a.output_tokens),
hsFmtTokens(a.cache_read_tokens),
hsFmtUsd(a.est_cost_usd),
];
cells.forEach((txt, i) => {
const td = document.createElement('td');
if (i > 0) td.className = 'num';
const td = document.createElement("td");
if (i > 0) td.className = "num";
td.textContent = txt;
tr.append(td);
});
@ -123,27 +143,33 @@ function renderHiveStats(s) {
}
}
const mm = $('hive-stats-models');
const mm = $("hive-stats-models");
if (mm) {
const mix = s.model_mix || [];
if (!mix.length) hsMeta(mm, 'no turns in window');
if (!mix.length) hsMeta(mm, "no turns in window");
else renderKeyCountBars(mm, mix);
}
// "favorite tools": most-run bash commands across the swarm.
renderOptionalMix('hive-stats-bash', 'hive-stats-bash-h', s.bash_mix || []);
renderOptionalMix("hive-stats-bash", "hive-stats-bash-h", s.bash_mix || []);
// Most-triggered skills across the swarm.
renderOptionalMix('hive-stats-skills', 'hive-stats-skills-h', s.skill_mix || []);
renderOptionalMix(
"hive-stats-skills",
"hive-stats-skills-h",
s.skill_mix || [],
);
}
async function refreshHiveStats() {
try {
const resp = await fetch('/api/stats-hive?window=' + encodeURIComponent(hiveStatsWindow));
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch(
"/api/stats-hive?window=" + encodeURIComponent(hiveStatsWindow),
);
if (!resp.ok) throw new Error("http " + resp.status);
renderHiveStats(await resp.json());
} catch (e) {
const at = $('hive-stats-agents');
if (at) hsMeta(at, 'stats fetch failed: ' + e);
const at = $("hive-stats-agents");
if (at) hsMeta(at, "stats fetch failed: " + e);
}
}
@ -154,7 +180,10 @@ initServerWarnings();
// tab and the strip's `if (pane)` guard handles it. The initial show()
// fires onShow once → sets the window + does the first fetch, so no
// separate refreshHiveStats() call is needed.
createTabStrip(document.getElementById('hive-stats-windows'), {
createTabStrip(document.getElementById("hive-stats-windows"), {
defaultId: hiveStatsWindow,
onShow: (w) => { hiveStatsWindow = w; refreshHiveStats(); },
onShow: (w) => {
hiveStatsWindow = w;
refreshHiveStats();
},
});

View file

@ -33,8 +33,11 @@ const allPorts = new Set();
const PING_INTERVAL_MS = 30_000;
setInterval(() => {
for (const port of allPorts) {
try { port.postMessage({ kind: 'ping' }); }
catch { /* port dead — left in the Set; see onconnect's closing comment */ }
try {
port.postMessage({ kind: "ping" });
} catch {
/* port dead — left in the Set; see onconnect's closing comment */
}
}
}, PING_INTERVAL_MS);
@ -45,20 +48,29 @@ function getOrCreateStream(url) {
entry = { es, url, ports: new Set() };
es.onopen = () => {
for (const port of entry.ports) {
try { port.postMessage({ kind: 'open', url }); }
catch { /* port dead — cleanup happens on unsubscribe / next subscribe */ }
try {
port.postMessage({ kind: "open", url });
} catch {
/* port dead — cleanup happens on unsubscribe / next subscribe */
}
}
};
es.onmessage = (e) => {
for (const port of entry.ports) {
try { port.postMessage({ kind: 'message', url, data: e.data }); }
catch { /* same */ }
try {
port.postMessage({ kind: "message", url, data: e.data });
} catch {
/* same */
}
}
};
es.onerror = () => {
for (const port of entry.ports) {
try { port.postMessage({ kind: 'error', url }); }
catch { /* same */ }
try {
port.postMessage({ kind: "error", url });
} catch {
/* same */
}
}
};
streams.set(url, entry);
@ -81,8 +93,8 @@ self.onconnect = (connectEvent) => {
const subscribedUrls = new Set();
port.onmessage = (e) => {
const msg = e.data;
if (!msg || typeof msg.url !== 'string') return;
if (msg.kind === 'subscribe') {
if (!msg || typeof msg.url !== "string") return;
if (msg.kind === "subscribe") {
if (subscribedUrls.has(msg.url)) return; // idempotent
const entry = getOrCreateStream(msg.url);
entry.ports.add(port);
@ -93,10 +105,13 @@ self.onconnect = (connectEvent) => {
// the next reconnect. Hand the new tab the open event explicitly
// so its onStreamOpen handler runs.
if (entry.es.readyState === EventSource.OPEN) {
try { port.postMessage({ kind: 'open', url: msg.url }); }
catch { /* port dead immediately — give up */ }
try {
port.postMessage({ kind: "open", url: msg.url });
} catch {
/* port dead immediately — give up */
}
}
} else if (msg.kind === 'unsubscribe') {
} else if (msg.kind === "unsubscribe") {
if (!subscribedUrls.has(msg.url)) return;
unsubscribe(port, msg.url);
subscribedUrls.delete(msg.url);

File diff suppressed because it is too large Load diff

View file

@ -23,9 +23,17 @@
cursor: pointer;
font-size: 0.9em;
}
.meta-input-name { color: var(--amber); font-weight: bold; }
.meta-input-rev { color: var(--muted); }
.meta-input-ts { color: var(--muted); font-size: 0.85em; }
.meta-input-name {
color: var(--amber);
font-weight: bold;
}
.meta-input-rev {
color: var(--muted);
}
.meta-input-ts {
color: var(--muted);
font-size: 0.85em;
}
.meta-input-url {
color: var(--muted);
font-size: 0.85em;
@ -67,7 +75,9 @@
font-size: 0.85em;
letter-spacing: 0.08em;
cursor: pointer;
transition: box-shadow 0.15s ease, background 0.15s ease;
transition:
box-shadow 0.15s ease,
background 0.15s ease;
}
.btn-meta-update:hover:not([disabled]) {
background: color-mix(in srgb, var(--purple) 22%, transparent);
@ -105,8 +115,13 @@
align-items: baseline;
gap: 0.4em;
}
.rqe-kind { color: var(--cyan); }
.rqe-agent { color: var(--amber); font-weight: bold; }
.rqe-kind {
color: var(--cyan);
}
.rqe-agent {
color: var(--amber);
font-weight: bold;
}
.rqe-source {
font-size: 0.75em;
padding: 0.05em 0.45em;
@ -116,11 +131,25 @@
text-transform: uppercase;
letter-spacing: 0.05em;
}
.rqe-source-manual { color: var(--cyan); border-color: var(--cyan); }
.rqe-source-meta_update { color: var(--purple); border-color: var(--purple); }
.rqe-source-auto_update { color: var(--muted); }
.rqe-source-crash_recover { color: var(--amber); border-color: var(--amber); }
.rqe-source-approval { color: var(--green); border-color: var(--green); }
.rqe-source-manual {
color: var(--cyan);
border-color: var(--cyan);
}
.rqe-source-meta_update {
color: var(--purple);
border-color: var(--purple);
}
.rqe-source-auto_update {
color: var(--muted);
}
.rqe-source-crash_recover {
color: var(--amber);
border-color: var(--amber);
}
.rqe-source-approval {
color: var(--green);
border-color: var(--green);
}
/* running-rebuild live log (renderRebuildLiveLog in builds.js)
Streams the currently-running rebuild's build log inline under the queue.
Hidden (native `hidden` attr) when nothing is building. Used on
@ -149,15 +178,21 @@
font-size: 1em;
padding: 0 0.2em;
}
.rebuild-live-log-toggle:hover { color: var(--fg); }
.rebuild-live-log-title { color: var(--muted); }
.rebuild-live-log-toggle:hover {
color: var(--fg);
}
.rebuild-live-log-title {
color: var(--muted);
}
.rebuild-live-log-raw {
margin-left: auto;
color: var(--purple);
text-decoration: none;
font-size: 0.9em;
}
.rebuild-live-log-raw:hover { text-decoration: underline; }
.rebuild-live-log-raw:hover {
text-decoration: underline;
}
.rebuild-live-log-badge {
font-size: 0.75em;
font-weight: bold;
@ -184,7 +219,8 @@
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-family:
"JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-size: 0.8em;
line-height: 1.4;
color: var(--fg);

View file

@ -11,41 +11,47 @@
// `dashboard.html` each load their own bundle.
// SW4RM (containers) domain lives in `./swarm.js`.
import { marked } from 'marked';
import { marked } from "marked";
import { $, NOTIF, openStream, renderServerWarnings } from "./common.js";
import { el } from "@hive/shared/dom.js";
import { bindAsyncForms } from "@hive/shared/forms.js";
import { createTabStrip } from "@hive/shared/tabs.js";
import { containersState, syncContainersFromSnapshot } from "./state.js";
import { fmtAgo, fmtDuration } from "./util.js";
import {
$,
NOTIF,
openStream, renderServerWarnings,
} from './common.js';
import { el } from '@hive/shared/dom.js';
import { bindAsyncForms } from '@hive/shared/forms.js';
import { createTabStrip } from '@hive/shared/tabs.js';
import {
containersState, syncContainersFromSnapshot,
} from './state.js';
import { fmtAgo, fmtDuration } from './util.js';
import {
applyCapabilitiesChanged, applyToolGroupsChanged,
fetchAndRenderCapabilities, fetchAndRenderToolGroups,
applyCapabilitiesChanged,
applyToolGroupsChanged,
fetchAndRenderCapabilities,
fetchAndRenderToolGroups,
initPermissions,
} from './permissions.js';
} from "./permissions.js";
import {
applySchedulesChanged,
refreshSchedules, activeScheduleCount,
} from './schedules.js';
refreshSchedules,
activeScheduleCount,
} from "./schedules.js";
import {
initCall,
refreshOperatorInbox, operatorInboxAppendFromEvent, operatorInboxCount,
syncApprovalsFromSnapshot, applyApprovalAdded, applyApprovalResolved,
renderApprovals, activeApprovalCount,
} from './call.js';
refreshOperatorInbox,
operatorInboxAppendFromEvent,
operatorInboxCount,
syncApprovalsFromSnapshot,
applyApprovalAdded,
applyApprovalResolved,
renderApprovals,
activeApprovalCount,
} from "./call.js";
import {
initJobqRollup, syncTransientsFromSnapshot,
applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved,
applyTransientSet, applyTransientCleared,
initJobqRollup,
syncTransientsFromSnapshot,
applyRebuildQueueChanged,
applyContainerStateChanged,
applyContainerRemoved,
applyTransientSet,
applyTransientCleared,
renderContainers,
renderSelectionBar,
} from './swarm.js';
} from "./swarm.js";
// mdNode (in common.js) reads `window.marked` for the markdown side
// panel preview path. Set it here on the dashboard entry so file
@ -56,7 +62,7 @@ window.marked = marked;
// Track which items we've already notified about so a re-render
// doesn't re-fire for the same row. Keyed by stable ids; reset only
// when the page reloads.
const seenApprovals = new Set();
const seenApprovals = new Set();
let seededNotify = false;
function notifyDeltas(s) {
@ -75,11 +81,17 @@ window.marked = marked;
for (const a of approvals) {
if (seenApprovals.has(a.id)) continue;
seenApprovals.add(a.id);
const verb = a.kind === 'spawn' ? 'spawn approval'
: a.kind === 'init_config' ? 'config-init approval'
: 'config commit';
NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`,
'hyperhive:approval:' + a.id);
const verb =
a.kind === "spawn"
? "spawn approval"
: a.kind === "init_config"
? "config-init approval"
: "config commit";
NOTIF.show(
"◆ approval #" + a.id,
`${verb} for ${a.agent}`,
"hyperhive:approval:" + a.id,
);
}
}
@ -96,20 +108,21 @@ window.marked = marked;
// the renderers so this loop can refresh them without a full re-render).
setInterval(() => {
const now = Math.floor(Date.now() / 1000);
document.querySelectorAll('.approval-ts[data-requested-at]').forEach((node) => {
const requestedAt = Number(node.getAttribute('data-requested-at'));
if (!Number.isFinite(requestedAt)) return;
const ageSec = Math.max(0, now - requestedAt);
node.textContent = 'requested ' + fmtAgo(requestedAt);
node.classList.toggle('stale', ageSec >= 3600);
});
document.querySelectorAll('.sched-due[data-due-at]').forEach((node) => {
const dueAt = Number(node.getAttribute('data-due-at'));
document
.querySelectorAll(".approval-ts[data-requested-at]")
.forEach((node) => {
const requestedAt = Number(node.getAttribute("data-requested-at"));
if (!Number.isFinite(requestedAt)) return;
const ageSec = Math.max(0, now - requestedAt);
node.textContent = "requested " + fmtAgo(requestedAt);
node.classList.toggle("stale", ageSec >= 3600);
});
document.querySelectorAll(".sched-due[data-due-at]").forEach((node) => {
const dueAt = Number(node.getAttribute("data-due-at"));
if (!Number.isFinite(dueAt)) return;
const dueIn = dueAt - now;
node.textContent = dueIn <= 0
? 'overdue ' + fmtAgo(dueAt)
: fmtDuration(dueIn);
node.textContent =
dueIn <= 0 ? "overdue " + fmtAgo(dueAt) : fmtDuration(dueIn);
});
}, 1000);
@ -119,12 +132,12 @@ window.marked = marked;
// operator is typing in one of them, skip the refresh — the next
// tick (or a manual action) will pick it up after they blur.
const MANAGED_SECTION_IDS = [
'containers-section',
'inbox-section',
'approvals-section',
'schedules-section',
'capabilities-section',
'tool-groups-section',
"containers-section",
"inbox-section",
"approvals-section",
"schedules-section",
"capabilities-section",
"tool-groups-section",
];
// <details> sections that should survive a refresh need a stable
// `data-restore-key` attribute. snapshotOpenDetails walks managed
@ -138,7 +151,7 @@ window.marked = marked;
for (const id of MANAGED_SECTION_IDS) {
const sect = document.getElementById(id);
if (!sect) continue;
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
for (const d of sect.querySelectorAll("details[data-restore-key]")) {
if (d.open) open.add(d.dataset.restoreKey);
}
}
@ -149,7 +162,7 @@ window.marked = marked;
for (const id of MANAGED_SECTION_IDS) {
const sect = document.getElementById(id);
if (!sect) continue;
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
for (const d of sect.querySelectorAll("details[data-restore-key]")) {
if (open.has(d.dataset.restoreKey)) d.open = true;
}
}
@ -159,7 +172,7 @@ window.marked = marked;
const el_ = document.activeElement;
if (!el_ || el_ === document.body) return false;
const tag = el_.tagName;
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
if (tag !== "INPUT" && tag !== "TEXTAREA" && tag !== "SELECT") return false;
return MANAGED_SECTION_IDS.some((id) => {
const sect = document.getElementById(id);
return sect && sect.contains(el_);
@ -176,8 +189,8 @@ window.marked = marked;
return;
}
try {
const resp = await fetch('/api/state');
if (!resp.ok) throw new Error('http ' + resp.status);
const resp = await fetch("/api/state");
if (!resp.ok) throw new Error("http " + resp.status);
const s = await resp.json();
// Stash the latest snapshot for any sub-widget that wants a
// synchronous read (e.g. the compose autocomplete pulls agent
@ -190,19 +203,18 @@ window.marked = marked;
// come from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env vars
// (set by services.hyperhive.{hiveName,swarmName} nix options).
// When unset we fall back gracefully — the headline stays hidden.
const hiveId = $('swarm-identity');
const hiveId = $("swarm-identity");
if (hiveId) {
const hive = s.hive_name;
const swarm = s.swarm_name;
if (hive || swarm) {
const label = swarm && hive ? `${swarm} / ${hive}`
: hive || swarm;
const label = swarm && hive ? `${swarm} / ${hive}` : hive || swarm;
hiveId.textContent = label;
hiveId.hidden = false;
// Preserve any (N) call-count prefix already applied by
// refreshTabCounts so the title doesn't flicker on reload.
const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || '';
document.title = existingPrefix + label + ' // h1ve-c0re';
const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || "";
document.title = existingPrefix + label + " // h1ve-c0re";
}
}
const openDetails = snapshotOpenDetails();
@ -235,9 +247,12 @@ window.marked = marked;
// /api/state fetches are the initial cold load and the
// post-submit refetch on forms without `data-no-refresh`
// (tombstones, meta-input updates).
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
if (pollTimer) {
clearTimeout(pollTimer);
pollTimer = null;
}
} catch (err) {
console.error('refreshState failed', err);
console.error("refreshState failed", err);
// Schedule a single retry on transient errors so the page
// recovers from a brief network blip without making the
// operator reload.
@ -275,20 +290,20 @@ window.marked = marked;
// and filter client-side — the dashboard ignores broker traffic
// and the inbox ignores mutation events.
const MUTATION_HANDLERS = {
approval_added: applyApprovalAdded,
approval_added: applyApprovalAdded,
approval_resolved: applyApprovalResolved,
transient_set: applyTransientSet,
transient_set: applyTransientSet,
transient_cleared: applyTransientCleared,
container_state_changed: applyContainerStateChanged,
container_removed: applyContainerRemoved,
container_removed: applyContainerRemoved,
// rebuild_queue_changed: refreshes the SW4RM queue-summary banner
// (see swarm.js) — a payload-less push trigger, same treatment
// /builds.html gives it for its JobqGraph mount handle's .refresh()
// (its own separate subscription).
rebuild_queue_changed: applyRebuildQueueChanged,
schedules_changed: applySchedulesChanged,
capabilities_changed: applyCapabilitiesChanged,
tool_groups_changed: applyToolGroupsChanged,
rebuild_queue_changed: applyRebuildQueueChanged,
schedules_changed: applySchedulesChanged,
capabilities_changed: applyCapabilitiesChanged,
tool_groups_changed: applyToolGroupsChanged,
};
(function bindDashboardStream() {
// Route through the SharedWorker so all open hyperhive tabs on the
@ -305,24 +320,31 @@ window.marked = marked;
// `/api/dashboard/stream` subscribers before this (subscription
// discipline, part 1 of the dashboard-event-stream-split issue).
const es = openStream(
'/api/dashboard/stream?kinds=sent,approval_added,approval_resolved,' +
'transient_set,transient_cleared,' +
'container_state_changed,container_removed,rebuild_queue_changed,' +
'schedules_changed,capabilities_changed,tool_groups_changed',
"/api/dashboard/stream?kinds=sent,approval_added,approval_resolved," +
"transient_set,transient_cleared," +
"container_state_changed,container_removed,rebuild_queue_changed," +
"schedules_changed,capabilities_changed,tool_groups_changed",
);
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
try {
ev = JSON.parse(e.data);
} catch {
return;
}
// Broker `sent` frames aren't mutation events, but the operator
// inbox cares about ones addressed to "operator".
if (ev.kind === 'sent' && ev.to === 'operator') {
if (ev.kind === "sent" && ev.to === "operator") {
operatorInboxAppendFromEvent(ev);
return;
}
const h = MUTATION_HANDLERS[ev.kind];
if (!h) return; // broker rows + future kinds — dashboard doesn't care
try { h(ev); }
catch (err) { console.error('dashboard SSE handler', ev.kind, err); }
try {
h(ev);
} catch (err) {
console.error("dashboard SSE handler", ev.kind, err);
}
};
es.onopen = () => {
// Re-sync to recover events that fired during the SSE disconnect
@ -334,7 +356,7 @@ window.marked = marked;
};
es.onerror = () => {
// EventSource auto-reconnects; nothing to do beyond logging.
console.debug('dashboard SSE error, will retry');
console.debug("dashboard SSE error, will retry");
};
})();
@ -360,38 +382,41 @@ window.marked = marked;
// Re-fetch on activation as a safety net: SSE covers live mutations,
// re-sync covers disconnect windows / approval-path inserts that
// don't yet emit.
if (target === 'schedules') { refreshSchedules(); }
if (target === "schedules") {
refreshSchedules();
}
// Permissions tables: SSE covers worker-applied changes
// (capabilities_changed / tool_groups_changed); re-fetch on
// activation as a safety net for any gap between SSE events and
// the cold-load snapshot.
if (target === 'permissions') {
if (target === "permissions") {
initPermissions();
fetchAndRenderCapabilities();
fetchAndRenderToolGroups();
}
if (target === 'call') { refreshOperatorInbox(); }
if (target === "call") {
refreshOperatorInbox();
}
}
// Wire the shared tab strip now that activateTab + the lazy-load fns it
// calls are defined. The strip resolves the active tab from the hash
// (default SW4RM), toggles the active tab/pane + aria-selected, and
// fires activateTab for the per-tab side-effects on every change.
createTabStrip($('tabbar'), { defaultId: 'swarm', onShow: activateTab });
createTabStrip($("tabbar"), { defaultId: "swarm", onShow: activateTab });
// Register the Y3R C4LL domain's count callback (call.js) — its live
// mutations (inbox stream append, mark-read) trigger a tab-count refresh
// through this instead of reaching back into the coordinator directly.
initCall({ onCountsChanged: refreshTabCounts });
// Tab count pills — pure derived data from the existing state
// stores so SSE-driven updates flow through without extra plumbing.
// Set `hidden` when the count is zero so the pill doesn't draw
// attention to an empty room.
function setTabCount(tab, n) {
const el_ = $('tab-count-' + tab);
const el_ = $("tab-count-" + tab);
if (!el_) return;
el_.textContent = String(n);
el_.hidden = n <= 0;
@ -406,22 +431,20 @@ window.marked = marked;
for (const c of containersState.values()) {
if (c.needs_update) swarm++;
}
setTabCount('swarm', swarm);
setTabCount("swarm", swarm);
// Y3R C4LL — pending approvals + unread agent->operator messages.
const callCount =
activeApprovalCount() +
operatorInboxCount();
setTabCount('call', callCount);
const callCount = activeApprovalCount() + operatorInboxCount();
setTabCount("call", callCount);
// Browser tab title prefix — lets the operator see the pending
// call count without switching to the window. Strips any existing
// `(N) ` prefix before re-applying so identity-title updates
// (which run once on state load, not every tick) compose cleanly.
const rawTitle = document.title.replace(/^\(\d+\) /, '');
const rawTitle = document.title.replace(/^\(\d+\) /, "");
document.title = callCount > 0 ? `(${callCount}) ${rawTitle}` : rawTitle;
// SCH3DUL3S — count of schedules with at least one still-active
// target (whole-schedule cancellation or all-targets-cancelled
// means "not waiting on the worker"; those don't pull attention).
setTabCount('schedules', activeScheduleCount());
setTabCount("schedules", activeScheduleCount());
}
// Poll the state stores on a 1s tick to keep the pill counts in
// sync. The state stores are mutated synchronously by every SSE

View file

@ -24,35 +24,46 @@ export function paintAtomic(liveRoot, build) {
// meta inputs, turn stats) still carry unix-second numbers, so
// numbers pass through unchanged.
export function epochSec(ts) {
return typeof ts === 'number' ? ts : Math.floor(Date.parse(ts) / 1000);
return typeof ts === "number" ? ts : Math.floor(Date.parse(ts) / 1000);
}
// Relative age of a timestamp (RFC 3339 string or unix seconds),
// coarsened to one unit ("5m ago").
export function fmtAgo(ts) {
const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - epochSec(ts)));
if (ageSec < 60) return ageSec + 's ago';
if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
return Math.floor(ageSec / 86400) + 'd ago';
if (ageSec < 60) return ageSec + "s ago";
if (ageSec < 3600) return Math.floor(ageSec / 60) + "m ago";
if (ageSec < 86400) return Math.floor(ageSec / 3600) + "h ago";
return Math.floor(ageSec / 86400) + "d ago";
}
// Truncate a string to `n` chars, appending an ellipsis when clipped.
export function truncate(s, n) {
return s.length <= n ? s : s.slice(0, n - 1) + '…';
return s.length <= n ? s : s.slice(0, n - 1) + "…";
}
// Running-duration label for in-flight items ("3m 12s running").
export function fmtElapsed(secs) {
if (secs < 60) return secs + 's running';
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running';
return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running';
if (secs < 60) return secs + "s running";
if (secs < 3600)
return Math.floor(secs / 60) + "m " + (secs % 60) + "s running";
return (
Math.floor(secs / 3600) +
"h " +
Math.floor((secs % 3600) / 60) +
"m running"
);
}
// Compact duration label, two units deep ("1h 5m", "2d 3h").
export function fmtDuration(secs) {
if (secs < 60) return secs + 's';
if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
if (secs < 60) return secs + "s";
if (secs < 3600) return Math.floor(secs / 60) + "m " + (secs % 60) + "s";
if (secs < 86400)
return (
Math.floor(secs / 3600) + "h " + Math.floor((secs % 3600) / 60) + "m"
);
return (
Math.floor(secs / 86400) + "d " + Math.floor((secs % 86400) / 3600) + "h"
);
}

View file

@ -20,10 +20,10 @@
// Same shared-component shape as `JobqRollup`/`JobqGraph`:
// `render(h(ApiErrorPanel, { problem }), container)` from vanilla JS, or
// `<ApiErrorPanel problem={...} />` from swarm-ui's JSX.
import { useState } from 'preact/hooks';
import { WarnBanner } from '../warn-banner/WarnBanner.js';
import type { ProblemDetails } from '../api-error.js';
import './api-error-panel.css';
import { useState } from "preact/hooks";
import { WarnBanner } from "../warn-banner/WarnBanner.js";
import type { ProblemDetails } from "../api-error.js";
import "./api-error-panel.css";
export interface ApiErrorPanelProps {
problem: ProblemDetails;
@ -39,12 +39,16 @@ function formatForCopy(p: ProblemDetails): string {
if (p.title) lines.push(`title: ${p.title}`);
if (p.type) lines.push(`type: ${p.type}`);
if (p.detail) lines.push(`detail: ${p.detail}`);
return lines.length ? lines.join('\n') : 'request failed';
return lines.length ? lines.join("\n") : "request failed";
}
export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
const [copied, setCopied] = useState(false);
const heading = problem.title || (problem.status !== undefined ? `http ${problem.status}` : 'request failed');
const heading =
problem.title ||
(problem.status !== undefined
? `http ${problem.status}`
: "request failed");
async function copy() {
try {
@ -62,11 +66,11 @@ export function ApiErrorPanel({ problem, context }: ApiErrorPanelProps) {
<WarnBanner level="error" class="api-error-panel">
<div class="api-error-heading">
<span class="api-error-title">
{context ? `${context}: ` : ''}
{context ? `${context}: ` : ""}
{heading}
</span>
<button type="button" class="api-error-copy" onClick={copy}>
{copied ? 'copied' : 'copy'}
{copied ? "copied" : "copy"}
</button>
</div>
{problem.detail ? <p class="api-error-detail">{problem.detail}</p> : null}

View file

@ -33,17 +33,17 @@ export async function readApiError(resp: Response): Promise<ProblemDetails> {
} catch {
return { status };
}
if (raw && (raw[0] === '{' || raw[0] === '[')) {
if (raw && (raw[0] === "{" || raw[0] === "[")) {
try {
const body = JSON.parse(raw) as Record<string, unknown>;
return {
type: typeof body.type === 'string' ? body.type : undefined,
title: typeof body.title === 'string' ? body.title : undefined,
status: typeof body.status === 'number' ? body.status : status,
type: typeof body.type === "string" ? body.type : undefined,
title: typeof body.title === "string" ? body.title : undefined,
status: typeof body.status === "number" ? body.status : status,
detail:
typeof body.detail === 'string'
typeof body.detail === "string"
? body.detail
: typeof body.error === 'string'
: typeof body.error === "string"
? body.error
: undefined,
};
@ -59,5 +59,9 @@ export async function readApiError(resp: Response): Promise<ProblemDetails> {
// same fallback order `readErrorBody` used: detail (or its `error` alias,
// already folded in above) → title → a bare status line.
export function problemMessage(p: ProblemDetails): string {
return p.detail || p.title || (p.status !== undefined ? `http ${p.status}` : 'request failed');
return (
p.detail ||
p.title ||
(p.status !== undefined ? `http ${p.status}` : "request failed")
);
}

View file

@ -51,12 +51,12 @@
the settings/links "should not have the badge bg" bug on those
triggers. Verified against a real repro before landing, not just
the specificity arithmetic. */
:root[data-theme='light'] .ui-badge:not(.ui-badge-quiet) {
:root[data-theme="light"] .ui-badge:not(.ui-badge-quiet) {
background: var(--purple-dim);
-webkit-backdrop-filter: none;
backdrop-filter: none;
}
:root[data-theme='dark'] .ui-badge:not(.ui-badge-quiet) {
:root[data-theme="dark"] .ui-badge:not(.ui-badge-quiet) {
background: color-mix(in srgb, var(--purple-dim) 65%, transparent);
-webkit-backdrop-filter: blur(6px) saturate(140%);
backdrop-filter: blur(6px) saturate(140%);
@ -81,12 +81,14 @@
.ui-badge-quiet {
background: none;
}
.ui-badge-interactive[aria-expanded='true'] {
.ui-badge-interactive[aria-expanded="true"] {
background: var(--bg-elev);
outline: 1px solid var(--purple);
}
.ui-badge-label {
color: var(--muted-on-dim); /* --muted alone is too low-contrast on this fill, see theme.css */
color: var(
--muted-on-dim
); /* --muted alone is too low-contrast on this fill, see theme.css */
}
.ui-badge-value {
color: var(--fg);

View file

@ -18,10 +18,15 @@
// swarm-ui *and* the per-agent page need the interactive shape, and
// `shared` is the one package both already depend on (see
// `docs/web-ui/design-guide.md`'s component-first + junk-drawer rules).
import type { ComponentChildren } from 'preact';
import './Badge.css';
import type { ComponentChildren } from "preact";
import "./Badge.css";
export type BadgeTone = 'neutral' | 'positive' | 'warning' | 'negative' | 'accent';
export type BadgeTone =
| "neutral"
| "positive"
| "warning"
| "negative"
| "accent";
/**
* `'default'` the filled pill every status/picker badge has always
* been. `'quiet'` no permanent fill, only a background on hover/
@ -34,7 +39,7 @@ export type BadgeTone = 'neutral' | 'positive' | 'warning' | 'negative' | 'accen
* weight is orthogonal to `tone` (color semantics), so it's its own prop
* rather than a new `tone` value.
*/
export type BadgeVariant = 'default' | 'quiet';
export type BadgeVariant = "default" | "quiet";
export interface BadgeProps {
/** Dim prefix text, e.g. "model". Omit for a single-value badge like "alive". */
@ -63,8 +68,8 @@ export interface BadgeProps {
export function Badge({
label,
value,
tone = 'neutral',
variant = 'default',
tone = "neutral",
variant = "default",
icon,
onClick,
expanded,
@ -73,14 +78,14 @@ export function Badge({
title,
}: BadgeProps) {
const classes = [
'ui-badge',
"ui-badge",
`ui-badge-${tone}`,
variant === 'quiet' && 'ui-badge-quiet',
onClick && 'ui-badge-interactive',
variant === "quiet" && "ui-badge-quiet",
onClick && "ui-badge-interactive",
extraClass,
]
.filter(Boolean)
.join(' ');
.join(" ");
const content = (
<>
{icon ? (

View file

@ -18,7 +18,8 @@ body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: "JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-family:
"JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
line-height: 1.6;
}
@ -33,4 +34,11 @@ body {
animation: spin 1s linear infinite;
color: var(--amber);
}
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View file

@ -41,7 +41,9 @@
white-space: nowrap;
flex: none;
}
.page-back:hover { text-decoration: underline; }
.page-back:hover {
text-decoration: underline;
}
.page-title {
color: var(--subtext0);

View file

@ -142,7 +142,7 @@
both blocks below just re-point at the same `--mocha-baseNN` /
`--latte-baseNN` custom properties declared once at the top of this
file. */
:root[data-theme='light'] {
:root[data-theme="light"] {
--base00: var(--latte-base00);
--base01: var(--latte-base01);
--base02: var(--latte-base02);
@ -160,7 +160,7 @@
--base0E: var(--latte-base0E);
--base0F: var(--latte-base0F);
}
:root[data-theme='dark'] {
:root[data-theme="dark"] {
--base00: var(--mocha-base00);
--base01: var(--mocha-base01);
--base02: var(--mocha-base02);

View file

@ -14,8 +14,8 @@
export const el = (tag, attrs = {}, ...children) => {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') e.className = v;
else if (k === 'html') e.innerHTML = v;
if (k === "class") e.className = v;
else if (k === "html") e.innerHTML = v;
else e.setAttribute(k, v);
}
for (const c of children) {

View file

@ -39,7 +39,7 @@
background: var(--purple-dim);
}
.ui-dropdown-item-active .ui-dropdown-item-label::before {
content: '✓ ';
content: "✓ ";
color: var(--purple);
}
/* Destructive action row (cancel turn) ported from the old standalone

View file

@ -14,9 +14,9 @@
// to that box's bottom-left via CSS. No portal — a badge in a normal
// document-flow header never needs one, and skipping it keeps focus
// management simple (no re-parenting to `<body>` to reason about).
import { useEffect, useRef } from 'preact/hooks';
import type { ComponentChildren, RefObject } from 'preact';
import './Dropdown.css';
import { useEffect, useRef } from "preact/hooks";
import type { ComponentChildren, RefObject } from "preact";
import "./Dropdown.css";
export interface DropdownOption {
value: string;
@ -53,7 +53,15 @@ export interface DropdownProps {
anchorRef?: RefObject<HTMLElement>;
}
export function Dropdown({ open, options, activeValue, onSelect, onClose, label, anchorRef }: DropdownProps) {
export function Dropdown({
open,
options,
activeValue,
onSelect,
onClose,
label,
anchorRef,
}: DropdownProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -65,16 +73,16 @@ export function Dropdown({ open, options, activeValue, onSelect, onClose, label,
onClose();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
if (e.key === "Escape") onClose();
}
// `pointerdown` (not `click`) so a drag-to-select that ends outside
// still closes; capture phase so this sees the event before a
// stopPropagation() elsewhere in the tree could swallow it.
document.addEventListener('pointerdown', handlePointerDown, true);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open, onClose]);
@ -89,14 +97,16 @@ export function Dropdown({ open, options, activeValue, onSelect, onClose, label,
role="menuitemradio"
aria-checked={opt.value === activeValue}
class={
'ui-dropdown-item' +
(opt.value === activeValue ? ' ui-dropdown-item-active' : '') +
(opt.danger ? ' ui-dropdown-item-danger' : '')
"ui-dropdown-item" +
(opt.value === activeValue ? " ui-dropdown-item-active" : "") +
(opt.danger ? " ui-dropdown-item-danger" : "")
}
onClick={() => onSelect(opt.value)}
>
<span class="ui-dropdown-item-label">{opt.label}</span>
{opt.description ? <span class="ui-dropdown-item-desc">{opt.description}</span> : null}
{opt.description ? (
<span class="ui-dropdown-item-desc">{opt.description}</span>
) : null}
</button>
))}
</div>

View file

@ -51,30 +51,37 @@ export function asyncBtn(btn, fn) {
// `data-prompt-field`) are surfaced via the themed dialogs in `modal.js`
// rather than native `confirm()`/`prompt()`, and errors via `themedToast`
// rather than `alert()`, so every page gets the same in-theme experience.
import { themedConfirm, themedPrompt, themedToast } from './modal.js';
import { themedConfirm, themedPrompt, themedToast } from "./modal.js";
export function bindAsyncForms(onSuccess) {
document.addEventListener('submit', async (e) => {
document.addEventListener("submit", async (e) => {
const f = e.target;
if (!(f instanceof HTMLFormElement) || !f.hasAttribute('data-async')) return;
if (!(f instanceof HTMLFormElement) || !f.hasAttribute("data-async"))
return;
e.preventDefault();
if (f.dataset.confirm && !(await themedConfirm({ message: f.dataset.confirm }))) return;
if (
f.dataset.confirm &&
!(await themedConfirm({ message: f.dataset.confirm }))
)
return;
if (f.dataset.prompt) {
const ans = await themedPrompt({ message: f.dataset.prompt });
if (ans === null) return; // operator hit Cancel
// Drop into a hidden input named after `data-prompt-field` (or
// 'note' by default) so the value rides along on the POST.
const field = f.dataset.promptField || 'note';
const field = f.dataset.promptField || "note";
let input = f.querySelector(`input[name="${field}"]`);
if (!input) {
input = document.createElement('input');
input.type = 'hidden';
input = document.createElement("input");
input.type = "hidden";
input.name = field;
f.append(input);
}
input.value = ans;
}
const btn = f.querySelector('button[type="submit"], button:not([type]), .btn-inline');
const btn = f.querySelector(
'button[type="submit"], button:not([type]), .btn-inline',
);
// Inner action: POST, clear inputs, call onSuccess.
// Errors are surfaced via themedToast; the caller does not re-throw
// so asyncBtn's finally always runs (restoring the button).
@ -82,25 +89,37 @@ export function bindAsyncForms(onSuccess) {
let resp;
try {
resp = await fetch(f.action, {
method: f.method || 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
method: f.method || "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(new FormData(f)),
redirect: 'manual',
redirect: "manual",
});
} catch (err) {
themedToast('action failed: ' + err, { type: 'error' });
themedToast("action failed: " + err, { type: "error" });
return;
}
const ok = resp.ok || resp.type === 'opaqueredirect'
|| (resp.status >= 200 && resp.status < 400);
const ok =
resp.ok ||
resp.type === "opaqueredirect" ||
(resp.status >= 200 && resp.status < 400);
if (!ok) {
const text = await resp.text().catch(() => '');
themedToast('action failed: ' + resp.status + (text ? '\n\n' + text : ''), { type: 'error' });
const text = await resp.text().catch(() => "");
themedToast(
"action failed: " + resp.status + (text ? "\n\n" + text : ""),
{ type: "error" },
);
return;
}
// Clear text inputs whose value was just submitted.
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
if (!f.hasAttribute('data-no-refresh') && typeof onSuccess === 'function') {
f.querySelectorAll(
'input[type="text"], input:not([type]), textarea',
).forEach((i) => {
i.value = "";
});
if (
!f.hasAttribute("data-no-refresh") &&
typeof onSuccess === "function"
) {
onSuccess();
}
};

View file

@ -12,9 +12,15 @@
:host {
display: contents;
}
:host([variant="cancel"]) { color: var(--subtext0); }
:host([variant="confirm"]) { color: var(--green); }
:host([variant="danger"]) { color: var(--red); }
:host([variant="cancel"]) {
color: var(--subtext0);
}
:host([variant="confirm"]) {
color: var(--green);
}
:host([variant="danger"]) {
color: var(--red);
}
button {
font-family: inherit;

View file

@ -20,10 +20,10 @@
// `disabled`/`type` are likewise plain attributes on the host, mirrored
// onto the inner button on connect and on every attribute change.
import { attachShadowCss } from '../shadow-css.js';
import hiveBtnCss from './hive-btn.css';
import { attachShadowCss } from "../shadow-css.js";
import hiveBtnCss from "./hive-btn.css";
const OBSERVED = ['disabled', 'type'];
const OBSERVED = ["disabled", "type"];
class HiveBtn extends HTMLElement {
static get observedAttributes() {
@ -36,8 +36,8 @@ class HiveBtn extends HTMLElement {
return; // already built (e.g. re-parenting re-fires connectedCallback)
}
const root = attachShadowCss(this, hiveBtnCss, { delegatesFocus: true });
const btn = document.createElement('button');
btn.append(document.createElement('slot'));
const btn = document.createElement("button");
btn.append(document.createElement("slot"));
root.append(btn);
this._btn = btn;
this._sync();
@ -49,8 +49,8 @@ class HiveBtn extends HTMLElement {
_sync() {
if (!this._btn) return;
this._btn.disabled = this.hasAttribute('disabled');
this._btn.type = this.getAttribute('type') || 'button';
this._btn.disabled = this.hasAttribute("disabled");
this._btn.type = this.getAttribute("type") || "button";
}
}
customElements.define('hive-btn', HiveBtn);
customElements.define("hive-btn", HiveBtn);

View file

@ -35,8 +35,15 @@
letter-spacing: 0.08em;
color: var(--subtext0);
}
.message { color: var(--fg); line-height: 1.45; }
.checks { display: flex; flex-direction: column; gap: 0.4em; }
.message {
color: var(--fg);
line-height: 1.45;
}
.checks {
display: flex;
flex-direction: column;
gap: 0.4em;
}
.checkrow {
display: flex;
align-items: flex-start;
@ -46,15 +53,25 @@
font-size: 0.92em;
line-height: 1.35;
}
.checkrow .check { margin-top: 0.2em; flex: 0 0 auto; }
.checkrow .check {
margin-top: 0.2em;
flex: 0 0 auto;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.6em;
margin-top: 0.2em;
}
.promptfield { display: flex; flex-direction: column; gap: 0.4em; }
.promptlabel { color: var(--subtext0); font-size: 0.9em; }
.promptfield {
display: flex;
flex-direction: column;
gap: 0.4em;
}
.promptlabel {
color: var(--subtext0);
font-size: 0.9em;
}
.input {
font-family: inherit;
font-size: 1em;
@ -65,7 +82,9 @@
border: 1px solid var(--purple-dim);
padding: 0.4em 0.6em;
}
.input:focus { outline: 1px solid var(--green); }
.input:focus {
outline: 1px solid var(--green);
}
.textarea {
resize: vertical;
min-height: 4.5em;

View file

@ -14,17 +14,20 @@
// Imports `../hive-btn/hive-btn.js` for its side effect (registers
// `<hive-btn>`) since the dialog's own buttons are `<hive-btn>` elements.
import { el } from '../dom.js';
import { attachShadowCss } from '../shadow-css.js';
import '../hive-btn/hive-btn.js'; // registers <hive-btn> — side-effect import, no named export needed
import dialogCss from './hive-dialog.css';
import { el } from "../dom.js";
import { attachShadowCss } from "../shadow-css.js";
import "../hive-btn/hive-btn.js"; // registers <hive-btn> — side-effect import, no named export needed
import dialogCss from "./hive-dialog.css";
class HiveDialog extends HTMLElement {
connectedCallback() {
const {
title = '', message = '', content = null,
buttons = [{ label: 'ok', value: true }],
danger = false, dismissable = true,
title = "",
message = "",
content = null,
buttons = [{ label: "ok", value: true }],
danger = false,
dismissable = true,
} = this._opts || {};
const root = attachShadowCss(this, dialogCss);
@ -33,12 +36,14 @@ class HiveDialog extends HTMLElement {
const done = (value) => {
if (settled) return;
settled = true;
document.removeEventListener('keydown', onKey, true);
this.dispatchEvent(new CustomEvent('hive-dialog-close', { detail: value }));
document.removeEventListener("keydown", onKey, true);
this.dispatchEvent(
new CustomEvent("hive-dialog-close", { detail: value }),
);
this.remove();
};
const onKey = (e) => {
if (dismissable && e.key === 'Escape') {
if (dismissable && e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
done(null);
@ -50,12 +55,16 @@ class HiveDialog extends HTMLElement {
// `b.class` names the variant ('cancel' | 'confirm'); `b.danger`
// overrides it to the 'danger' look regardless (a destructive
// confirm button reads as danger, not as a plain confirm).
const variant = b.danger ? 'danger' : b.class;
const btn = el('hive-btn', {
type: 'button',
...(variant ? { variant } : {}),
}, b.label);
btn.addEventListener('click', () => done(b.value));
const variant = b.danger ? "danger" : b.class;
const btn = el(
"hive-btn",
{
type: "button",
...(variant ? { variant } : {}),
},
b.label,
);
btn.addEventListener("click", () => done(b.value));
return { spec: b, btn };
});
@ -65,21 +74,30 @@ class HiveDialog extends HTMLElement {
// this shadow root, so no cross-instance collision risk even without
// the random suffix — kept anyway since it costs nothing and guards
// against a future shared-DOM edge case (e.g. `::part()` piercing).
const labelId = 'dlg-' + Math.random().toString(36).slice(2, 9);
const titleEl = title ? el('div', { class: 'title', id: labelId }, title) : null;
const messageEl = message
? el('div', title ? { class: 'message' } : { class: 'message', id: labelId }, message)
const labelId = "dlg-" + Math.random().toString(36).slice(2, 9);
const titleEl = title
? el("div", { class: "title", id: labelId }, title)
: null;
const boxAttrs = { class: 'box', role: 'dialog', 'aria-modal': 'true' };
if (titleEl || messageEl) boxAttrs['aria-labelledby'] = labelId;
const box = el('div', boxAttrs,
const messageEl = message
? el(
"div",
title ? { class: "message" } : { class: "message", id: labelId },
message,
)
: null;
const boxAttrs = { class: "box", role: "dialog", "aria-modal": "true" };
if (titleEl || messageEl) boxAttrs["aria-labelledby"] = labelId;
const box = el(
"div",
boxAttrs,
titleEl,
messageEl,
content || null,
el('div', { class: 'actions' }, ...btnEls.map((b) => b.btn)));
el("div", { class: "actions" }, ...btnEls.map((b) => b.btn)),
);
root.append(box);
this.addEventListener('click', (e) => {
this.addEventListener("click", (e) => {
// `e.target` is retargeted to `this` (the host) for ANY click that
// originated inside the shadow tree, once it bubbles out to a
// listener attached on the host itself — per spec, retargeting
@ -94,12 +112,13 @@ class HiveDialog extends HTMLElement {
// tree was actually under the cursor, i.e. a real backdrop click.
if (dismissable && e.composedPath()[0] === this) done(null);
});
document.addEventListener('keydown', onKey, true);
document.addEventListener("keydown", onKey, true);
const focusTarget = btnEls.find((b) => b.spec.autofocus)
|| (danger ? btnEls.find((b) => !b.spec.danger) : null)
|| btnEls[btnEls.length - 1];
const focusTarget =
btnEls.find((b) => b.spec.autofocus) ||
(danger ? btnEls.find((b) => !b.spec.danger) : null) ||
btnEls[btnEls.length - 1];
if (focusTarget) focusTarget.btn.focus();
}
}
customElements.define('hive-dialog', HiveDialog);
customElements.define("hive-dialog", HiveDialog);

View file

@ -34,7 +34,7 @@
/* The trigger is the top-level `slot="trigger"` node reachable, so its
base icon-button chrome (invisible until hover/open, via
`--menu-btn-opacity`) lives here rather than duplicated per caller. */
::slotted([slot='trigger']) {
::slotted([slot="trigger"]) {
display: block;
background: none;
border: none;
@ -45,14 +45,17 @@
padding: 0.1em 0.4em;
border-radius: 4px;
opacity: var(--menu-btn-opacity, 0);
transition: opacity 120ms, background 120ms, color 120ms;
transition:
opacity 120ms,
background 120ms,
color 120ms;
}
::slotted([slot='trigger']:hover),
::slotted([slot='trigger']:focus-visible) {
::slotted([slot="trigger"]:hover),
::slotted([slot="trigger"]:focus-visible) {
background: color-mix(in srgb, var(--purple) 10%, transparent);
color: var(--purple);
outline: none;
}
::slotted([slot='trigger']:focus-visible) {
::slotted([slot="trigger"]:focus-visible) {
outline: 1px solid var(--purple);
}

View file

@ -29,9 +29,9 @@
// slotted content) tests "did this land inside any open menu" — same
// reasoning as `<hive-dialog>`'s backdrop-click check.
import { el } from '../dom.js';
import { attachShadowCss } from '../shadow-css.js';
import hiveMenuCss from './hive-menu.css';
import { el } from "../dom.js";
import { attachShadowCss } from "../shadow-css.js";
import hiveMenuCss from "./hive-menu.css";
const openInstances = new Set();
@ -46,22 +46,30 @@ export function closeAllMenus() {
}
// Close on any click outside every currently-open instance.
document.addEventListener('click', (e) => {
if (!openInstances.size) return;
const path = e.composedPath();
for (const inst of [...openInstances]) {
if (!path.includes(inst)) inst.close();
}
}, true);
document.addEventListener(
"click",
(e) => {
if (!openInstances.size) return;
const path = e.composedPath();
for (const inst of [...openInstances]) {
if (!path.includes(inst)) inst.close();
}
},
true,
);
// Close on Escape. stopImmediatePropagation so a caller's own
// selection-clear Escape handler (e.g. swarm.js's) doesn't also fire
// while a menu is open.
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && openInstances.size) {
closeAll();
e.stopImmediatePropagation();
}
}, true);
document.addEventListener(
"keydown",
(e) => {
if (e.key === "Escape" && openInstances.size) {
closeAll();
e.stopImmediatePropagation();
}
},
true,
);
class HiveMenu extends HTMLElement {
connectedCallback() {
@ -80,18 +88,18 @@ class HiveMenu extends HTMLElement {
// Project the caller's opaque nodes via named slots — see module
// header for why this has to be slotting, not a shadow-root append.
trigger.slot = 'trigger';
content.slot = 'content';
trigger.slot = "trigger";
content.slot = "content";
this.append(trigger, content);
const dropdown = el('div', { class: 'menu-dropdown', hidden: true });
dropdown.append(el('slot', { name: 'content' }));
root.append(el('slot', { name: 'trigger' }), dropdown);
const dropdown = el("div", { class: "menu-dropdown", hidden: true });
dropdown.append(el("slot", { name: "content" }));
root.append(el("slot", { name: "trigger" }), dropdown);
this._trigger = trigger;
this._dropdown = dropdown;
trigger.addEventListener('click', (e) => {
trigger.addEventListener("click", (e) => {
e.stopPropagation();
const wasOpen = openInstances.has(this);
closeAll();
@ -107,17 +115,17 @@ class HiveMenu extends HTMLElement {
open() {
this._dropdown.hidden = false;
this._trigger.setAttribute('aria-expanded', 'true');
this.style.setProperty('--menu-btn-opacity', '1');
this._trigger.setAttribute("aria-expanded", "true");
this.style.setProperty("--menu-btn-opacity", "1");
openInstances.add(this);
}
close() {
if (!openInstances.has(this)) return;
this._dropdown.hidden = true;
this._trigger.setAttribute('aria-expanded', 'false');
this.style.removeProperty('--menu-btn-opacity');
this._trigger.setAttribute("aria-expanded", "false");
this.style.removeProperty("--menu-btn-opacity");
openInstances.delete(this);
}
}
customElements.define('hive-menu', HiveMenu);
customElements.define("hive-menu", HiveMenu);

View file

@ -18,9 +18,20 @@
color: var(--fg);
white-space: pre-wrap;
opacity: 1;
transition: opacity 0.2s ease, transform 0.2s ease;
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
:host(.out) {
opacity: 0;
transform: translateX(0.5em);
}
:host(.error) {
border-left-color: var(--red);
}
:host(.info) {
border-left-color: var(--purple-dim);
}
:host(.ok) {
border-left-color: var(--green);
}
:host(.out) { opacity: 0; transform: translateX(0.5em); }
:host(.error) { border-left-color: var(--red); }
:host(.info) { border-left-color: var(--purple-dim); }
:host(.ok) { border-left-color: var(--green); }

View file

@ -9,29 +9,29 @@
// directly in the shadow root rather than via a `<slot>`, since there's no
// external light-DOM content to project.
import { attachShadowCss } from '../shadow-css.js';
import toastCss from './hive-toast.css';
import { attachShadowCss } from "../shadow-css.js";
import toastCss from "./hive-toast.css";
class HiveToast extends HTMLElement {
connectedCallback() {
const { type = 'info', duration } = this._opts || {};
const ms = duration != null ? duration : (type === 'error' ? 8000 : 4000);
const { type = "info", duration } = this._opts || {};
const ms = duration != null ? duration : type === "error" ? 8000 : 4000;
const root = attachShadowCss(this, toastCss);
this.classList.add(type);
this.setAttribute('role', type === 'error' ? 'alert' : 'status');
root.textContent = this._message || '';
this.setAttribute("role", type === "error" ? "alert" : "status");
root.textContent = this._message || "";
let removed = false;
this._remove = () => {
if (removed) return;
removed = true;
this.classList.add('out');
this.classList.add("out");
setTimeout(() => this.remove(), 200);
};
this.addEventListener('click', this._remove);
this.addEventListener("click", this._remove);
if (ms > 0) this._timer = setTimeout(this._remove, ms);
}
disconnectedCallback() {
clearTimeout(this._timer);
}
}
customElements.define('hive-toast', HiveToast);
customElements.define("hive-toast", HiveToast);

View file

@ -42,8 +42,13 @@
animation: hive-warn-pulse 2.4s ease-in-out infinite;
}
@keyframes hive-warn-pulse {
0%, 100% { box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent); }
50% { box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent); }
0%,
100% {
box-shadow: 0 0 12px -4px color-mix(in srgb, currentColor 55%, transparent);
}
50% {
box-shadow: 0 0 22px -2px color-mix(in srgb, currentColor 95%, transparent);
}
}
/* Explicit rather than relying on plain inheritance matches what

View file

@ -27,15 +27,15 @@
// `el('hive-warn', { level: 'warning' }, ...)` (JS-built)
// `el('hive-warn', { level: 'error' }, ...)` (active incident, pulses)
import { attachShadowCss } from '../shadow-css.js';
import hiveWarnCss from './hive-warn.css';
import { attachShadowCss } from "../shadow-css.js";
import hiveWarnCss from "./hive-warn.css";
class HiveWarn extends HTMLElement {
connectedCallback() {
if (this._built) return; // re-parenting re-fires connectedCallback
const root = attachShadowCss(this, hiveWarnCss);
root.append(document.createElement('slot'));
root.append(document.createElement("slot"));
this._built = true;
}
}
customElements.define('hive-warn', HiveWarn);
customElements.define("hive-warn", HiveWarn);

View file

@ -1,3 +1,3 @@
// Convenience re-export so consumers can `import { create, linkify }
// from '@hive/shared'` without naming the sub-module path.
export { create, linkify } from './terminal/terminal.js';
export { create, linkify } from "./terminal/terminal.js";

View file

@ -28,21 +28,28 @@
// loader is `text` (for unrelated shadow-DOM components' CSS-as-string
// needs), and that loader is global per call, not per-module.
import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
import { useState, useEffect, useCallback, useRef } from "preact/hooks";
// Mirrors `hive_jobq_wire::StateSchema` verbatim (variant names, no
// `rename_all`) — see that enum's own doc comment for why it's kept in
// an exhaustive match on the Rust side; this union is this file's
// equivalent contract.
type NodeState = 'Pending' | 'Running' | 'Finishing' | 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
type NodeState =
| "Pending"
| "Running"
| "Finishing"
| "Done"
| "Failed"
| "Cancelled"
| "Skipped";
type TerminalState = 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
type TerminalState = "Done" | "Failed" | "Cancelled" | "Skipped";
// Mirrors `hive_jobq_wire::GraphDep` — externally tagged on `kind`,
// values are the Rust variant names verbatim.
type GraphDep =
| { kind: 'Node'; id: number; accepts: TerminalState[] }
| { kind: 'Resource'; name: string; count: number };
| { kind: "Node"; id: number; accepts: TerminalState[] }
| { kind: "Resource"; name: string; count: number };
interface NodePayload {
label: string;
@ -69,13 +76,13 @@ interface TreeNode extends GraphNode {
}
const STATE_GLYPH: Record<NodeState, string> = {
Pending: '⏸',
Running: '▶',
Finishing: '◐',
Done: '✔',
Failed: '✖',
Cancelled: '⊘',
Skipped: '·',
Pending: "⏸",
Running: "▶",
Finishing: "◐",
Done: "✔",
Failed: "✖",
Cancelled: "⊘",
Skipped: "·",
};
// Declaration order doubles as render order for the filter checkboxes —
@ -84,12 +91,16 @@ const STATE_GLYPH: Record<NodeState, string> = {
const ALL_STATES = Object.keys(STATE_GLYPH) as NodeState[];
// Product call: "default selection filters out skipped and done."
const DEFAULT_HIDDEN_STATES = new Set<NodeState>(['Done', 'Skipped']);
const DEFAULT_HIDDEN_STATES = new Set<NodeState>(["Done", "Skipped"]);
// Non-terminal states a cancel button makes sense on. Finishing is
// included — "own logic done, children still running" is still a subtree
// worth stopping early.
const CANCELLABLE_STATES = new Set<NodeState>(['Pending', 'Running', 'Finishing']);
const CANCELLABLE_STATES = new Set<NodeState>([
"Pending",
"Running",
"Finishing",
]);
// Build a parent/child tree from the flat wire array. `parent` (structural
// grouping) defines tree shape. Sibling order follows array order, which
@ -117,7 +128,9 @@ function buildTree(nodes: GraphNode[]): TreeNode[] {
}
for (const n of byId.values()) {
n._waitsOn = (n.deps || [])
.filter((d): d is Extract<GraphDep, { kind: 'Node' }> => d.kind === 'Node')
.filter(
(d): d is Extract<GraphDep, { kind: "Node" }> => d.kind === "Node",
)
.map((d) => byId.get(d.id))
.filter((dep): dep is TreeNode => dep != null)
.map((dep) => dep.payload.label);
@ -131,17 +144,19 @@ function buildTree(nodes: GraphNode[]): TreeNode[] {
// single stringified row rather than silently dropping it).
function DataList({ data }: { data: unknown }) {
if (data == null) return null;
const isPlainObject = typeof data === 'object' && !Array.isArray(data);
const isPlainObject = typeof data === "object" && !Array.isArray(data);
const entries: [string, unknown][] = isPlainObject
? Object.entries(data as Record<string, unknown>)
: [['data', data]];
: [["data", data]];
if (!entries.length) return null;
return (
<dl class="jg-data">
{entries.map(([k, v]) => (
<>
<dt key={k + '-dt'}>{k}</dt>
<dd key={k + '-dd'}>{typeof v === 'string' ? v : JSON.stringify(v)}</dd>
<dt key={k + "-dt"}>{k}</dt>
<dd key={k + "-dd"}>
{typeof v === "string" ? v : JSON.stringify(v)}
</dd>
</>
))}
</dl>
@ -157,7 +172,7 @@ function NodeView({
cancellable: boolean;
onCancel?: (id: number) => void;
}) {
const glyph = STATE_GLYPH[n.state] || '?';
const glyph = STATE_GLYPH[n.state] || "?";
const showCancel = cancellable && CANCELLABLE_STATES.has(n.state);
// Flash the state glyph on a genuine state change (Pending → Running,
// etc.), not on mount — `prevState` starts at the node's own initial
@ -176,27 +191,41 @@ function NodeView({
return (
<div class="jg-node">
<div class="jg-row">
<span class={'jg-state jg-state-' + n.state.toLowerCase() + (flashing ? ' jg-state-flash' : '')}
title={n.state + (n.error ? ' — ' + n.error : '')}
onAnimationEnd={() => setFlashing(false)}>
<span
class={
"jg-state jg-state-" +
n.state.toLowerCase() +
(flashing ? " jg-state-flash" : "")
}
title={n.state + (n.error ? " — " + n.error : "")}
onAnimationEnd={() => setFlashing(false)}
>
{glyph}
</span>
{' '}
</span>{" "}
<span class="jg-label">{n.payload.label}</span>
{showCancel && (
<button type="button" class="jg-cancel-btn" title={'cancel ' + n.payload.label}
onClick={() => onCancel && onCancel(n.id)}>
<button
type="button"
class="jg-cancel-btn"
title={"cancel " + n.payload.label}
onClick={() => onCancel && onCancel(n.id)}
>
</button>
)}
</div>
{n._waitsOn && n._waitsOn.length > 0 && (
<div class="jg-waits-on">waits on: {n._waitsOn.join(', ')}</div>
<div class="jg-waits-on">waits on: {n._waitsOn.join(", ")}</div>
)}
<DataList data={n.payload.data} />
{n.error && <pre class="jg-error">{n.error}</pre>}
{n._children.map((child) => (
<NodeView key={child.id} n={child} cancellable={cancellable} onCancel={onCancel} />
<NodeView
key={child.id}
n={child}
cancellable={cancellable}
onCancel={onCancel}
/>
))}
</div>
);
@ -212,12 +241,20 @@ function FilterBar({
return (
<div class="jg-filter">
{ALL_STATES.map((state) => {
const id = 'jg-filter-' + state.toLowerCase();
const id = "jg-filter-" + state.toLowerCase();
return (
<label key={state} for={id} class={'jg-filter-label jg-state-' + state.toLowerCase()}>
<input type="checkbox" id={id} checked={selectedStates.has(state)}
onChange={() => onToggle(state)} />
{' '}{STATE_GLYPH[state] + ' ' + state}
<label
key={state}
for={id}
class={"jg-filter-label jg-state-" + state.toLowerCase()}
>
<input
type="checkbox"
id={id}
checked={selectedStates.has(state)}
onChange={() => onToggle(state)}
/>{" "}
{STATE_GLYPH[state] + " " + state}
</label>
);
})}
@ -229,11 +266,14 @@ function FilterBar({
// param — omitted entirely when every state is checked, so the
// unfiltered default case sends the exact same request as before this
// filter existed.
function fetchUrl(endpoint: string | undefined, selectedStates: Set<NodeState>): string | null {
function fetchUrl(
endpoint: string | undefined,
selectedStates: Set<NodeState>,
): string | null {
if (!endpoint) return null;
if (selectedStates.size >= ALL_STATES.length) return endpoint;
const url = new URL(endpoint, window.location.origin);
url.searchParams.set('states', Array.from(selectedStates).join(','));
url.searchParams.set("states", Array.from(selectedStates).join(","));
return url.pathname + url.search;
}
@ -249,7 +289,13 @@ export interface JobqGraphProps {
// change identity so the effect below re-runs, giving a host an
// explicit "refetch now" lever (bump it and re-render) without an
// imperative ref into this component.
export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, refreshToken = 0 }: JobqGraphProps) {
export function JobqGraph({
endpoint,
cancellable = false,
onUpdate,
onCancel,
refreshToken = 0,
}: JobqGraphProps) {
const [selectedStates, setSelectedStates] = useState<Set<NodeState>>(
() => new Set(ALL_STATES.filter((s) => !DEFAULT_HIDDEN_STATES.has(s))),
);
@ -259,7 +305,8 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
const toggleState = useCallback((state: NodeState) => {
setSelectedStates((prev) => {
const next = new Set(prev);
if (next.has(state)) next.delete(state); else next.add(state);
if (next.has(state)) next.delete(state);
else next.add(state);
return next;
});
}, []);
@ -271,7 +318,7 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
(async () => {
try {
const r = await fetch(url);
if (!r.ok) throw new Error('http ' + r.status);
if (!r.ok) throw new Error("http " + r.status);
const data = (await r.json()) as GraphNode[];
if (cancelled) return;
setNodes(data);
@ -282,11 +329,13 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
setError(String(err));
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedStates is a Set;
// its *contents* are what should retrigger the fetch, not its identity, and the
// string form below already changes identity exactly when contents do.
}, [endpoint, Array.from(selectedStates).sort().join(','), refreshToken]);
}, [endpoint, Array.from(selectedStates).sort().join(","), refreshToken]);
return (
<div class="jg-root">
@ -300,7 +349,12 @@ export function JobqGraph({ endpoint, cancellable = false, onUpdate, onCancel, r
<p class="jg-empty">empty</p>
) : (
buildTree(nodes).map((root) => (
<NodeView key={root.id} n={root} cancellable={cancellable} onCancel={onCancel} />
<NodeView
key={root.id}
n={root}
cancellable={cancellable}
onCancel={onCancel}
/>
))
)}
</div>

View file

@ -65,10 +65,10 @@
animation: none;
}
}
:root[data-motion='reduce'] .jg-node {
:root[data-motion="reduce"] .jg-node {
animation: none;
}
:root[data-motion='allow'] .jg-node {
:root[data-motion="allow"] .jg-node {
animation: jg-node-enter 160ms ease;
}
@ -93,13 +93,30 @@
min-width: 1.2em;
text-align: center;
}
.jg-state-pending { color: var(--muted); }
.jg-state-running { color: var(--cyan); }
.jg-state-finishing { color: var(--cyan); opacity: 0.75; }
.jg-state-done { color: var(--green); }
.jg-state-failed { color: var(--red); }
.jg-state-cancelled { color: var(--muted); text-decoration: line-through; }
.jg-state-skipped { color: var(--muted); opacity: 0.5; }
.jg-state-pending {
color: var(--muted);
}
.jg-state-running {
color: var(--cyan);
}
.jg-state-finishing {
color: var(--cyan);
opacity: 0.75;
}
.jg-state-done {
color: var(--green);
}
.jg-state-failed {
color: var(--red);
}
.jg-state-cancelled {
color: var(--muted);
text-decoration: line-through;
}
.jg-state-skipped {
color: var(--muted);
opacity: 0.5;
}
/* Short-lived pulse applied by `NodeView` (JobqGraph.tsx) exactly when
a node's own `state` value changes on an existing DOM node a mount
@ -123,10 +140,10 @@
animation: none;
}
}
:root[data-motion='reduce'] .jg-state-flash {
:root[data-motion="reduce"] .jg-state-flash {
animation: none;
}
:root[data-motion='allow'] .jg-state-flash {
:root[data-motion="allow"] .jg-state-flash {
animation: jg-state-flash 350ms ease;
}
@ -149,7 +166,9 @@
display: inline-flex;
align-items: center;
justify-content: center;
transition: color 0.15s ease, border-color 0.15s ease;
transition:
color 0.15s ease,
border-color 0.15s ease;
}
.jg-cancel-btn:hover,
.jg-cancel-btn:focus-visible {
@ -166,8 +185,13 @@
grid-template-columns: auto 1fr;
gap: 0 0.5em;
}
.jg-data dt { font-weight: 600; }
.jg-data dd { margin: 0; word-break: break-word; }
.jg-data dt {
font-weight: 600;
}
.jg-data dd {
margin: 0;
word-break: break-word;
}
.jg-waits-on {
margin: 0.1em 0 0 1.6em;

View file

@ -28,12 +28,19 @@
// `refreshToken` to force a refetch. No mount wrapper: `render` is
// already the re-render/diff entry point.
import { useState, useEffect } from 'preact/hooks';
import { useState, useEffect } from "preact/hooks";
// Mirrors `hive_jobq_wire::StateSchema` — only the subset this banner
// cares about, not the full union `JobqGraph.tsx` mirrors, since a
// rollup row's `state` is read by exact string match, not rendered.
type NodeState = 'Pending' | 'Running' | 'Finishing' | 'Done' | 'Failed' | 'Cancelled' | 'Skipped';
type NodeState =
| "Pending"
| "Running"
| "Finishing"
| "Done"
| "Failed"
| "Cancelled"
| "Skipped";
interface StateCount {
state: NodeState;
@ -47,7 +54,11 @@ export interface JobqRollupProps {
refreshToken?: number;
}
export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollupProps) {
export function JobqRollup({
endpoint,
queueHref,
refreshToken = 0,
}: JobqRollupProps) {
const [counts, setCounts] = useState<StateCount[]>([]);
useEffect(() => {
@ -66,12 +77,16 @@ export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollup
// ignore — keep the previous snapshot
}
})();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [endpoint, refreshToken]);
const byState = new Map(counts.map((c) => [c.state, c]));
const running = (byState.get('Running')?.roots ?? 0) + (byState.get('Finishing')?.roots ?? 0);
const queued = byState.get('Pending')?.roots ?? 0;
const running =
(byState.get("Running")?.roots ?? 0) +
(byState.get("Finishing")?.roots ?? 0);
const queued = byState.get("Pending")?.roots ?? 0;
if (!running && !queued) return null;
const parts: string[] = [];
@ -80,10 +95,12 @@ export function JobqRollup({ endpoint, queueHref, refreshToken = 0 }: JobqRollup
return (
<div class="jqr-summary">
<span class="jqr-glyph spinner"></span>{' '}
<strong>build queue</strong> {parts.join(' · ')}{' '}
<span class="jqr-glyph spinner"></span> <strong>build queue</strong> {" "}
{parts.join(" · ")}{" "}
{queueHref && (
<a class="jqr-link" href={queueHref}>view queue </a>
<a class="jqr-link" href={queueHref}>
view queue
</a>
)}
</div>
);

View file

@ -14,7 +14,9 @@
margin-bottom: 0.6em;
border-radius: 4px;
}
.jqr-summary strong { color: var(--amber); }
.jqr-summary strong {
color: var(--amber);
}
.jqr-link {
margin-left: auto;
color: var(--amber);
@ -22,4 +24,6 @@
font-weight: bold;
white-space: nowrap;
}
.jqr-link:hover { text-decoration: underline; }
.jqr-link:hover {
text-decoration: underline;
}

View file

@ -19,9 +19,9 @@
// Other `.btn` consumers across the app stay on the light-DOM `.btn`
// class for now — migrating them is a separate follow-up.
import { el } from './dom.js';
import './hive-dialog/hive-dialog.js'; // registers <hive-dialog> — side-effect import
import './hive-toast/hive-toast.js'; // registers <hive-toast> — side-effect import
import { el } from "./dom.js";
import "./hive-dialog/hive-dialog.js"; // registers <hive-dialog> — side-effect import
import "./hive-toast/hive-toast.js"; // registers <hive-toast> — side-effect import
// openDialog({ title, message, content, buttons, danger, dismissable })
// → Promise resolving to the clicked button's `value`, or `null` when the
@ -37,9 +37,11 @@ import './hive-toast/hive-toast.js'; // registers <hive-toast> — side-effect i
// stray Enter can't fire the destructive path), else the last button.
export function openDialog(opts = {}) {
return new Promise((resolve) => {
const dlg = document.createElement('hive-dialog');
const dlg = document.createElement("hive-dialog");
dlg._opts = opts;
dlg.addEventListener('hive-dialog-close', (e) => resolve(e.detail), { once: true });
dlg.addEventListener("hive-dialog-close", (e) => resolve(e.detail), {
once: true,
});
document.body.append(dlg);
});
}
@ -54,19 +56,31 @@ export function openDialog(opts = {}) {
// doStop(r.graceful);
export function themedConfirm(opts = {}) {
const {
title = '', message = '', danger = false,
confirmLabel = 'confirm', cancelLabel = 'cancel', checkboxes = [],
title = "",
message = "",
danger = false,
confirmLabel = "confirm",
cancelLabel = "cancel",
checkboxes = [],
} = opts;
const boxes = checkboxes.map((cb) => {
const input = el('input', { type: 'checkbox', class: 'check', name: cb.name });
const input = el("input", {
type: "checkbox",
class: "check",
name: cb.name,
});
if (cb.checked) input.checked = true;
const row = el('label', { class: 'checkrow' },
input, el('span', {}, cb.label || cb.name));
const row = el(
"label",
{ class: "checkrow" },
input,
el("span", {}, cb.label || cb.name),
);
return { input, row };
});
const content = boxes.length
? el('div', { class: 'checks' }, ...boxes.map((b) => b.row))
? el("div", { class: "checks" }, ...boxes.map((b) => b.row))
: null;
return openDialog({
@ -75,13 +89,20 @@ export function themedConfirm(opts = {}) {
content,
danger,
buttons: [
{ label: cancelLabel, value: null, class: 'cancel', autofocus: danger },
{ label: confirmLabel, value: 'confirm', danger, class: 'confirm', autofocus: !danger },
{ label: cancelLabel, value: null, class: "cancel", autofocus: danger },
{
label: confirmLabel,
value: "confirm",
danger,
class: "confirm",
autofocus: !danger,
},
],
}).then((v) => {
if (v !== 'confirm') return null;
if (v !== "confirm") return null;
const out = {};
for (let i = 0; i < boxes.length; i++) out[checkboxes[i].name] = boxes[i].input.checked;
for (let i = 0; i < boxes.length; i++)
out[checkboxes[i].name] = boxes[i].input.checked;
return out;
});
}
@ -95,10 +116,19 @@ export function themedConfirm(opts = {}) {
// (via openDialog).
export function themedPrompt(opts = {}) {
const {
title = '', message = '', label = '', placeholder = '', value = '',
confirmLabel = 'ok', cancelLabel = 'cancel',
title = "",
message = "",
label = "",
placeholder = "",
value = "",
confirmLabel = "ok",
cancelLabel = "cancel",
} = opts;
const input = el('textarea', { class: 'input textarea', rows: '3', placeholder });
const input = el("textarea", {
class: "input textarea",
rows: "3",
placeholder,
});
if (value) input.value = value;
// Enter submits (clicks the confirm button mounted by openDialog);
// Shift+Enter falls through to the textarea's default newline insert.
@ -106,24 +136,32 @@ export function themedPrompt(opts = {}) {
// lives in, so this resolves to the dialog's own `.box`/confirm button
// without leaking across instances. The confirm button is selected by
// its `variant` attribute now (hive-btn.js), not a CSS class.
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
input.closest('.box')?.querySelector('[variant="confirm"]')?.click();
input.closest(".box")?.querySelector('[variant="confirm"]')?.click();
}
});
const content = el('div', { class: 'promptfield' },
label ? el('label', { class: 'promptlabel' }, label) : null,
input);
const content = el(
"div",
{ class: "promptfield" },
label ? el("label", { class: "promptlabel" }, label) : null,
input,
);
const result = openDialog({
title,
message,
content,
buttons: [
{ label: cancelLabel, value: '__cancel__', class: 'cancel' },
{ label: confirmLabel, value: '__ok__', class: 'confirm', autofocus: true },
{ label: cancelLabel, value: "__cancel__", class: "cancel" },
{
label: confirmLabel,
value: "__ok__",
class: "confirm",
autofocus: true,
},
],
}).then((v) => (v === '__ok__' ? input.value : null));
}).then((v) => (v === "__ok__" ? input.value : null));
// Prefer focusing the field over the OK button once the dialog has mounted.
setTimeout(() => input.focus(), 0);
return result;
@ -138,24 +176,24 @@ export function themedPrompt(opts = {}) {
// component — no theming, no encapsulation need), so it's styled with a
// one-off inline style rather than a stylesheet.
export function themedToast(message, opts = {}) {
let container = document.getElementById('tc-toasts');
let container = document.getElementById("tc-toasts");
if (!container) {
container = document.createElement('div');
container.id = 'tc-toasts';
container = document.createElement("div");
container.id = "tc-toasts";
Object.assign(container.style, {
position: 'fixed',
top: '1em',
right: '1em',
zIndex: '1100',
display: 'flex',
flexDirection: 'column',
gap: '0.5em',
maxWidth: 'min(28em, 92vw)',
pointerEvents: 'none',
position: "fixed",
top: "1em",
right: "1em",
zIndex: "1100",
display: "flex",
flexDirection: "column",
gap: "0.5em",
maxWidth: "min(28em, 92vw)",
pointerEvents: "none",
});
document.body.append(container);
}
const toast = document.createElement('hive-toast');
const toast = document.createElement("hive-toast");
toast._message = message;
toast._opts = opts;
container.append(toast);

View file

@ -5,7 +5,7 @@
// `ExpandDetailsSetting` (the per-agent page's own settings popover) is
// the only writer today; `Row.tsx` is the only reader.
const EXPAND_DETAILS_KEY = 'hive-agent-expand-details';
const EXPAND_DETAILS_KEY = "hive-agent-expand-details";
// Whether a per-agent terminal's otherwise-collapsed `<details>` panels
// (long tool-results, Write/Edit diffs, …) should default open. Pure
@ -16,14 +16,14 @@ const EXPAND_DETAILS_KEY = 'hive-agent-expand-details';
// any already-open agent tab without a reload.
export function getExpandDetailsPref(): boolean {
try {
return localStorage.getItem(EXPAND_DETAILS_KEY) === '1';
return localStorage.getItem(EXPAND_DETAILS_KEY) === "1";
} catch {
return false;
}
}
export function setExpandDetailsPref(v: boolean): void {
try {
if (v) localStorage.setItem(EXPAND_DETAILS_KEY, '1');
if (v) localStorage.setItem(EXPAND_DETAILS_KEY, "1");
else localStorage.removeItem(EXPAND_DETAILS_KEY);
} catch {
/* localStorage unavailable — preference is session-only */

View file

@ -23,16 +23,16 @@
// once, high in its tree, with the SAME key strings passed to this
// component, so the two stay in sync without this component owning any
// page-specific naming decision.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import { Badge } from '../badge/Badge.js';
import { GearIcon } from '../icons.js';
import { useThemeOverride, type ThemeOverride } from './theme-apply.js';
import { useMotionOverride, type MotionOverride } from './motion-apply.js';
import './SettingsMenu.css';
import { useEffect, useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { Badge } from "../badge/Badge.js";
import { GearIcon } from "../icons.js";
import { useThemeOverride, type ThemeOverride } from "./theme-apply.js";
import { useMotionOverride, type MotionOverride } from "./motion-apply.js";
import "./SettingsMenu.css";
const THEME_OPTIONS: ThemeOverride[] = ['system', 'light', 'dark'];
const MOTION_OPTIONS: MotionOverride[] = ['system', 'allow', 'reduce'];
const THEME_OPTIONS: ThemeOverride[] = ["system", "light", "dark"];
const MOTION_OPTIONS: MotionOverride[] = ["system", "allow", "reduce"];
export interface SettingsMenuProps {
themeKey: string;
@ -51,7 +51,11 @@ export interface SettingsMenuProps {
children?: ComponentChildren;
}
export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProps) {
export function SettingsMenu({
themeKey,
motionKey,
children,
}: SettingsMenuProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const [theme, setTheme] = useThemeOverride(themeKey);
@ -61,16 +65,21 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false);
if (
rootRef.current &&
e.target instanceof Node &&
!rootRef.current.contains(e.target)
)
setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
if (e.key === "Escape") setOpen(false);
}
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown);
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
@ -89,7 +98,9 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
<span>theme</span>
<select
value={theme}
onChange={(e) => setTheme((e.target as HTMLSelectElement).value as ThemeOverride)}
onChange={(e) =>
setTheme((e.target as HTMLSelectElement).value as ThemeOverride)
}
>
{THEME_OPTIONS.map((o) => (
<option key={o} value={o}>
@ -102,7 +113,11 @@ export function SettingsMenu({ themeKey, motionKey, children }: SettingsMenuProp
<span>motion</span>
<select
value={motion}
onChange={(e) => setMotion((e.target as HTMLSelectElement).value as MotionOverride)}
onChange={(e) =>
setMotion(
(e.target as HTMLSelectElement).value as MotionOverride,
)
}
>
{MOTION_OPTIONS.map((o) => (
<option key={o} value={o}>

View file

@ -12,22 +12,28 @@
// (see swarm-ui's `shell/Shell.css` file-top comment for a worked
// example). A page with no motion-gated animation yet can still mount
// this — the attribute is simply inert until something reads it.
import { useEffect } from 'preact/hooks';
import { useLocalSetting } from './settings-storage.js';
import { useEffect } from "preact/hooks";
import { useLocalSetting } from "./settings-storage.js";
export type MotionOverride = 'system' | 'reduce' | 'allow';
export type MotionOverride = "system" | "reduce" | "allow";
export function useMotionOverride(key: string, fallback: MotionOverride = 'system') {
export function useMotionOverride(
key: string,
fallback: MotionOverride = "system",
) {
return useLocalSetting<MotionOverride>(key, fallback);
}
// Mounted once alongside `useApplyThemeOverride` — same single-mount-
// point rationale.
export function useApplyMotionOverride(key: string, fallback: MotionOverride = 'system'): void {
export function useApplyMotionOverride(
key: string,
fallback: MotionOverride = "system",
): void {
const [override] = useMotionOverride(key, fallback);
useEffect(() => {
const root = document.documentElement;
if (override === 'system') {
if (override === "system") {
delete root.dataset.motion;
} else {
root.dataset.motion = override;

View file

@ -14,7 +14,7 @@
// reading) need their own same-tab signal. The tiny module-level pub/sub
// below is that signal; it's deliberately not exported, callers only see
// the hook.
import { useEffect, useState } from 'preact/hooks';
import { useEffect, useState } from "preact/hooks";
const listeners = new Map<string, Set<() => void>>();
@ -70,10 +70,16 @@ function writeLocalSetting<T>(key: string, value: T): void {
// this component's state through the identical "react to a change"
// path every other subscriber uses — one path, not two that have to
// agree.
export function useLocalSetting<T>(key: string, fallback: T): [T, (value: T) => void] {
export function useLocalSetting<T>(
key: string,
fallback: T,
): [T, (value: T) => void] {
const [value, setValue] = useState<T>(() => readLocalSetting(key, fallback));
useEffect(() => subscribe(key, () => setValue(readLocalSetting(key, fallback))), [key]);
useEffect(
() => subscribe(key, () => setValue(readLocalSetting(key, fallback))),
[key],
);
return [value, (next: T) => writeLocalSetting(key, next)];
}

Some files were not shown because too many files have changed in this diff Show more