agent: cutover — index.html now serves the Preact rewrite, app.js deleted

mara: 'i merged this without realizing you still did not delete the
old code. pls follow up with ripping out the old code.'

- index.html: dropped the old static header/main/composer/overflow-
  menu markup and app.js's <script> tag. Now just <div id=preact-root>
  + <script src=static/main.js>. main.css's <link> lands after
  agent.css's — cascade order argus flagged matters for LoginFlow's
  .login-card / MetaNav's popover to win against agent.css's legacy
  rules, confirmed explicitly rather than assumed at this exact step.
- build.mjs: app.js dropped from the esbuild entryPoints (stats.js
  keeps its own bundle, unaffected — separate page, separate script).
- frontend/packages/agent/src/app.js deleted (1717 lines). screen.html
  has its own inline <script>, untouched — never depended on app.js.
- docs/web-ui/agent.md: rewrote the Header section to describe the
  real Preact component tree and the badges+pills-together layout
  (deferred from the earlier commits on this PR specifically so it
  wouldn't describe a hybrid state — this is that promised follow-up).
  Touched up the one other app.js-specific mention in the endpoints
  section. Left the Main/composer/side-panel/live-view/slash-command
  sections alone — behavior there is unchanged, verified faithfully
  ported throughout this PR's earlier commits.

Verified against the REAL dist/index.html (not the dev-preview
harness) — a scratch mock server serving the actual built output
end-to-end, screenshotted clean. tsc --noEmit clean, build clean, both
pre-push lints clean.
This commit is contained in:
iris 2026-08-28 23:14:52 +02:00
commit fa2b360e74
5 changed files with 124 additions and 1936 deletions

View file

@ -10,120 +10,98 @@ panel for flyouts and long content.
## Header ## Header
Preact component tree (`Header.tsx` + `StatusChips.tsx` +
`MetaNav.tsx` + `OverflowMenu.tsx` + `HeaderPill.tsx`, wired together
in `Root.tsx`) — see `frontend/packages/agent/src/components/`. This
section describes the rendered result, not the DOM ids the pre-Preact
page used (there are none any more — every element is component
output, not something a selector reaches by id).
**Fixed-overlay header** (`<header class="agent-header">`): frosted **Fixed-overlay header** (`<header class="agent-header">`): frosted
glass — `backdrop-filter: blur` lets scrolled terminal rows show glass — `backdrop-filter: blur` lets scrolled terminal rows show
through. Three flex columns: through. Measures its own rendered height via `ResizeObserver`
(`Header.tsx`) and writes it to a CSS custom property the content
below reads for its offset — a fixed `6em` guess used to be baked into
`agent.css`, which silently broke (content overlapping the header) the
moment any row of badges/pills wrapped onto an extra line at some
viewport width; measuring instead of guessing closes that bug class
structurally rather than for one specific trigger. Two columns:
- **Agent icon** (`<img class="agent-icon">`): fixed-size square - **Agent icon** (`<img class="agent-icon">`): fixed-size square
identity anchor — `width: 5em; height: 5em` with explicit pixel identity anchor — `width: 5em; height: 5em` with explicit pixel
sizing so the `<img>`'s intrinsic (large) dimensions don't push sizing so the `<img>`'s intrinsic (large) dimensions don't push
the parent flex container open via `align-items: stretch`-driven the parent flex container open via `align-items: stretch`-driven
height feedback. 5em ≈ header content area (header `min-height: 6em` height feedback. Falls back to the dimmed hyperhive mark
minus `2 × 0.5em` padding). `align-self: flex-start` keeps the (`/favicon.svg`) on load error (`/icon` 404s when the agent has no
icon stuck to the top so a state-row line-wrap doesn't drag it `hyperhive.icon` override — there is no server-side default image).
down with it. Falls back to the dimmed hyperhive mark on load - **Main column** (`.agent-header-main`, `Header.tsx`): two rows —
error. title (`◆ <label> ◆`) and, when set, the "swarm / hive" identity
- **Main column** (`.agent-header-main`): two rows. line. Nothing else lives here; both rows are short, fixed-shape text
- Row 1 (`.agent-header-title-row`): title (`<h2 id="title">`) + that doesn't wrap in practice, so this column's height stays stable
meta-nav (`<nav id="meta-links">`). Meta-nav renders regardless of how many badges or pills are showing (a design
backend-supplied `StateSnapshot.links` as icon-only anchors — always correction mid-review — the first pass put the variable-width
`📊 stats` (`kind = Container`); `🖥 screen` when VNC is enabled; content here instead, which is exactly the kind of thing the
`⬡ forge` (profile) + `↳ config` (agent-configs mirror) when the `ResizeObserver` measurement above exists to catch even when a
agent has a forge account; any `hyperhive.dashboardLinks` extras layout choice reintroduces it).
(`kind = External`). A `↑ dashboard` link is prepended by the JS - **Pills cluster** (`.agent-header-pills`, right-aligned): status
so the host dashboard is one click away. Links come from badges (`StatusChips.tsx`) and flyout triggers together in one row —
`StateSnapshot.links` (served by `GET /api/state`); the same set a deliberate choice (not the historical default) so the header reads
also appears in `DashboardState.links` (`GET /api/dashboard-state`) as one identity zone + one status/actions zone rather than several
for the dashboard card's icon strip. Both are produced by separate clusters:
`agent_links()` in hive-ag3nt — the single source of truth. - **Alive badge**: `● alive` (green) / `⊘ rate limited` (red) /
Each `AgentLink.kind` resolves `◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`.
differently in the frontend: `Container` → same-origin path - **State badge**: `💤 idle` / `🧠 thinking` / `📦 compacting` /
(the agent page is itself container-local); `Forge` `○ offline` / `… booting` + age suffix.
`state.forge_public_url + url` (sourced from - **Model badge** (`model · <name> ▾`): a real picker — click opens
`services.hyperhive.forge.publicUrl`), and the link is omitted a `Dropdown` of `state.available_models`, selecting one POSTs
entirely when that's unset — never guessed from `<host>:3000`; `/api/model` immediately (same endpoint the `/model <name>` slash
`External` → already absolute. command uses). No longer buried in the overflow menu — the design
All anchors are built via `el()` — agent-declared icon / guide's own named anti-example (control disconnected from
label / url strings never reach `innerHTML` (XSS-safe by display) this rewrite exists to fix.
construction). - **Effort badge** (`effort · <level> ▾`): same shape, `/api/effort`,
- Row 2 (`.agent-state-row`): alive badge + state badge + model chip shown when `state.available_efforts` is non-empty.
+ ctx badge + cost badge + last-turn chip + cancel button. - **Ctx / cost badges**: `ctx · 142k` (last inference's prompt size,
- Alive badge: `● alive` (green) / `⊘ rate limited` (red) / tooltip shows % of context window) / `cost · 1.3M` (cumulative
`◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`. tokens billed across every inference in the last turn).
Driven by `LiveEvent::StatusChanged`. - **Pause badge** (`⏸ pause` / `▶ resume`): toggles via the same
- State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` / hive-c0re-owned `/api/pause/<name>` / `/api/resume/<name>`
`○ offline` / `… booting` + age suffix. Driven by endpoints the dashboard uses (pausing needs a write this
`LiveEvent::TurnStateChanged ({ state, since_unix })`. unprivileged process can't make directly).
- Model chip: `model · <name>`. Driven by `LiveEvent::ModelChanged`. - **Inbox / todos pills** (`📬 inbox · N` / `📋 todos · N`): hidden
- Ctx badge: `ctx · 142k` — last inference's prompt size. when empty; click opens the matching flyout in the side panel.
Tooltip shows % of window when `context_window_tokens` is known. - **Links badge** (`🔗`): opens a popover listing this agent's
- Cost badge: `cost · 1.3M` — cumulative tokens billed across every `agent_links()`-sourced navigation (stats, screen when VNC is
inference in the last turn (tool-heavy turns rebill the cached enabled, forge profile + config-repo mirror when the agent has a
prefix per call — cost signal, not size signal). forge account, any `hyperhive.dashboardLinks` extras) as real
- Both driven by `LiveEvent::TokenUsageChanged { ctx, cost }` at `<a>` elements — not a `Dropdown`-style command list, so
turn-end. ctrl/middle-click and "copy link address" keep working. Each
- `■ cancel turn` (visible while thinking) → `POST /api/cancel`. `AgentLink.kind` resolves differently: `container` → same-origin
- **Right cluster** (`.agent-header-pills`): flyout pills + overflow. path; `forge``state.forge_public_url + url`, and the link is
- **Inbox pill** (`📬 inbox · N`): hidden when empty; click opens dropped entirely when that's unset (never guessed from
the inbox flyout in the side panel. `<host>:3000`); `external` → already absolute. Same source
- **Todos pill** (`📋 todos · N`): hidden when empty; click opens (`GET /api/state`'s `links` field) also feeds
the todos flyout — see the todos-flyout paragraph below `DashboardState.links` for the dashboard card's icon strip —
(`GET /api/todos`, refreshed on cold load + every `turn_end`). `agent_links()` in hive-agent is the single source of truth for
- **Overflow button** (`⋯`): always visible. Opens a frosted popover both.
(`#overflow-menu`, positioned outside the header to escape any - **Overflow badge** (`⋯`): a `Dropdown` with two rows —
stacking context) with five management rows followed by a model `↑ dashboard` (link) and `↻ rebuild container` (select-twice
quick-picker section: `↑ dashboard` (link), `↻ rebuild container` confirm, same action as the dashboard R3BU1LD button). Everything
(POST confirm, same action as the dashboard R3BU1LD button), else that used to live here (model/effort pickers, new-session,
`↻ new claude session` (POST confirm → `POST /api/new-session`; logout) has a better home now: pickers are real badges above, and
next turn drops `--continue`), `🔓 logout` (POST confirm → `POST `/new-session` / `/logout` are typed slash commands with their own
/api/logout`; SIGINTs any in-flight turn, wipes OAuth credential type-twice confirm (see below) — a modal doesn't fit a text-input
files, flips the agent to `needs_login` — session history flow, and burying rare-but-important actions in one flat menu was
preserved), and `⏸ pause agent` / `▶ resume agent` (POST confirm → the design guide's own named anti-example.
hive-c0re's `/api/pause/<name>` / `/api/resume/<name>` — the same - No header cancel-turn button any more — `/cancel` (slash command,
endpoints the dashboard's own `<hive-agent-menu>` uses, since below) is the only path; the turn-loop state badge already shows
pausing is a hive-c0re-owned write this unprivileged process can't `thinking` as the discoverability cue.
make directly). The label + target endpoint track `state.paused`
(this agent's own `/api/state`, a direct stat of the harness's
local pause marker — see `docs/persistence.md`), refreshed on every
snapshot so a pause/resume triggered from the *dashboard* while
this page is open doesn't leave the menu item showing the wrong
action. All destructive actions require one extra click to
acknowledge — rare ops shouldn't live in the primary state strip.
Below a separator, a **model quick-picker** section labelled
`model` renders one button per model in the operator-configured
list. The list is driven by `state.available_models` (sourced from
the `HIVE_AVAILABLE_MODELS` env var, injected by the
`services.hyperhive.availableModels` NixOS option; defaults to
`["haiku", "sonnet", "opus"]` when unset). Well-known aliases get
a parenthetical description (`haiku (fast)`, `sonnet (balanced)`,
`opus (powerful)`); operator-declared custom names show as-is.
Clicking a button POSTs `/api/model` with the alias (same path as
the `/model <name>` slash command). The button for the
currently-active model is highlighted via the `active` class;
`renderModelChip` keeps the picker state in sync with live
`model_changed` events so it stays accurate when the model is
changed from another session. Clicking the already-active model
closes the menu without an extra POST.
A second separator + **effort quick-picker** section labelled
`effort` appears when the backend declares effort levels in
`state.available_efforts`. One button per level (e.g. `low`,
`medium`, `high`, `xhigh`, `max`); clicking POSTs `/api/effort` (same endpoint as
the `/effort <level>` slash command). The active level's button is
highlighted; `renderEffortChip` keeps the picker in sync with
`StateSnapshot.effort` from the cold-load snapshot.
The popover's display rules are scoped to `:not([hidden])` so the
`[hidden]` HTML attribute's UA `display: none` isn't overridden by
the author CSS's `display: flex` — the popover stays hidden until
JS removes the attribute.
`/api/state` is fetched once on cold load (+ while Values throughout come from `GET /api/state`'s cold-load snapshot,
`status === 'needs_login_in_progress'`); all other updates arrive via kept in sync afterwards by the SSE stream (see Live view below) —
SSE. Snapshot includes `context_window_tokens` for the ctx badge `context_window_tokens` for the ctx badge tooltip, `qualified_label`
tooltip, and `qualified_label` — the hive-qualified agent name (the hive-qualified `name@domain` form, used for the browser tab title
(`name@domain` form when `HYPERHIVE_HIVE_DOMAIN` is set, otherwise so two tabs from different hives are distinguishable; the header's own
just `name`). The frontend uses `qualified_label` to set the browser `◆ <label> ◆` stays short).
tab title so two tabs from different hives are distinguishable; the
header `<h2 id="title">` stays short.
## Main content ## Main content
@ -344,8 +322,9 @@ shaped).
error. Missing files (already logged out) are treated as error. Missing files (already logged out) are treated as
idempotent. idempotent.
- `GET /api/state` — cold-load snapshot (`StateSnapshot`) consumed by - `GET /api/state` — cold-load snapshot (`StateSnapshot`) consumed by
`app.js` on page load and while `status === 'needs_login_in_progress'`. the frontend (`useAgentState.ts`) on page load and, on a poll, while
Includes `turn_state`, `context_window_tokens`, `qualified_label`, `status === 'needs_login_in_progress'`. Includes `turn_state`,
`context_window_tokens`, `qualified_label`,
`available_models`, `effort`, `available_efforts`, `links`, and `available_models`, `effort`, `available_efforts`, `links`, and
other fields described inline throughout this document. All other fields described inline throughout this document. All
subsequent state updates arrive via SSE. subsequent state updates arrive via SSE.

View file

@ -1,14 +1,18 @@
// esbuild build for @hive/agent. Output layout (`dist/`): // esbuild build for @hive/agent. Output layout (`dist/`):
// //
// dist/index.html served at GET / // dist/index.html served at GET / (the Preact rewrite,
// hyperhive#3685 — app.js is deleted)
// dist/stats.html served at GET /stats // dist/stats.html served at GET /stats
// dist/screen.html served at GET /screen // dist/screen.html served at GET /screen
// dist/static/app.js served at /static/app.js (ESM bundle,
// pulls in @hive/shared + marked)
// dist/static/app.js.map source map sibling
// dist/static/stats.js served at /static/stats.js (pulls in // dist/static/stats.js served at /static/stats.js (pulls in
// chart.js/auto) // chart.js/auto)
// dist/static/stats.js.map source map sibling // dist/static/stats.js.map source map sibling
// dist/static/main.js served at /static/main.js (the Preact
// bundle — see main.tsx)
// dist/static/main.js.map source map sibling
// dist/static/main.css served at /static/main.css (bundled
// component CSS — see main.tsx's build
// step below)
// dist/static/agent.css served at /static/agent.css (@import // dist/static/agent.css served at /static/agent.css (@import
// resolved from @hive/shared) // resolved from @hive/shared)
// //
@ -30,10 +34,9 @@ const staticDir = (p) => resolve(here, 'dist', 'static', p);
rmSync(dist(''), { recursive: true, force: true }); rmSync(dist(''), { recursive: true, force: true });
mkdirSync(staticDir(''), { recursive: true }); mkdirSync(staticDir(''), { recursive: true });
// Two JS entries: the main app + the stats page. Both bundle their // The stats page's own bundle — chart.js/auto, its own deps.
// own deps so each page can be loaded independently.
await build({ await build({
entryPoints: [src('app.js'), src('stats.js')], entryPoints: [src('stats.js')],
outdir: staticDir(''), outdir: staticDir(''),
bundle: true, bundle: true,
format: 'esm', format: 'esm',
@ -47,11 +50,13 @@ await build({
loader: { '.css': 'text' }, loader: { '.css': 'text' },
}); });
// The Preact rewrite (in progress — see main.tsx's file comment). A // The main agent page (hyperhive#3685's Preact rewrite — app.js,
// third, separate bundle rather than folding into the `app.js` build // which used to share this build step, is deleted). A separate bundle
// above: different loader needs (`.css: 'css'`, real stylesheets a // from stats.js above rather than folded in: different loader needs
// component imports, vs. `app.js`'s raw-text shadow-DOM CSS) and JSX // (`.css: 'css'`, real stylesheets a component imports, vs. the raw-
// transpilation, same split swarm-ui's own build already makes. // text shadow-DOM CSS `stats.js` still needs for `@hive/shared/
// modal.js`) and JSX transpilation, same split swarm-ui's own build
// already makes.
await build({ await build({
entryPoints: [src('main.tsx')], entryPoints: [src('main.tsx')],
outfile: staticDir('main.js'), outfile: staticDir('main.js'),

File diff suppressed because it is too large Load diff

View file

@ -8,98 +8,21 @@
<link rel="stylesheet" href="static/colors.css"> <link rel="stylesheet" href="static/colors.css">
<link rel="stylesheet" href="static/theme.css"> <link rel="stylesheet" href="static/theme.css">
<link rel="stylesheet" href="static/agent.css"> <link rel="stylesheet" href="static/agent.css">
<!-- main.css (bundled component CSS: Badge/Dropdown/LoginFlow/MetaNav/
StatusChips/OverflowMenu/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> </head>
<body class="agent-shell"> <body class="agent-shell">
<!-- Fixed-overlay header — see docs/web-ui.md::Per-agent page <!-- The Preact rewrite owns everything below — header, terminal,
for the three-column layout (icon · main · pills). All asset composer, side panel. See main.tsx/Root.tsx for the component
/ API hrefs are relative — see docs/web-ui.md::Per-agent tree; docs/web-ui/agent.md for the design. -->
relative paths for the document-baseURI resolution model. --> <div id="preact-root"></div>
<header class="agent-header" id="agent-header">
<img class="agent-icon" src="icon" alt="">
<div class="agent-header-main"> <script type="module" src="static/main.js" defer></script>
<div class="agent-header-row agent-header-title-row">
<h2 id="title">◆ … ◆</h2>
<!-- Meta-nav: backend-supplied links (stats / screen / forge /
…) plus a client-injected `↑ dashboard` link prepended in
setHeader so the host dashboard stays one click away
without a separate button styled differently. -->
<nav class="meta agent-nav" id="meta-links"></nav>
</div>
<!-- Hive identity line: "swarm / hive" label. Hidden until JS
populates it from /api/state (hive_name + swarm_name). -->
<div class="agent-header-row agent-hive-row">
<span class="agent-hive-label" hidden></span>
</div>
<div id="state-row" class="agent-state-row agent-header-row">
<span id="alive-badge" class="hive-pill status-badge status-loading" title="harness reachability"></span>
<span id="state-badge" class="hive-pill state-badge state-loading">… booting</span>
<span id="model-chip" class="hive-pill-sm model-chip" hidden></span>
<span id="effort-chip" class="hive-pill-sm effort-chip" hidden></span>
<span id="ctx-badge" class="hive-pill-sm ctx-badge" hidden title="tokens used in the current context window"></span>
<span id="cost-badge" class="hive-pill-sm ctx-badge" hidden title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)"></span>
<span id="last-turn" class="last-turn" hidden></span>
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
</div>
</div>
<!-- Right cluster: flyout triggers + overflow menu. Pills stay
hidden until their list is non-empty; the overflow `⋯` is
always visible. Rebuild + new-session + logout live inside
it — see docs/web-ui.md::Per-agent page (Overflow button)
for the rare-destructive-extra-click rationale. -->
<div class="agent-header-pills">
<button type="button" id="inbox-pill" class="hive-pill header-pill header-pill-inbox" hidden
title="open inbox flyout">
<span class="header-pill-icon" aria-hidden="true">📬</span>
<span class="header-pill-label">inbox</span>
<span class="header-pill-count" id="inbox-count">0</span>
</button>
<button type="button" id="todos-pill" class="hive-pill header-pill header-pill-todos" hidden
title="open todos flyout">
<span class="header-pill-icon" aria-hidden="true">📋</span>
<span class="header-pill-label">todos</span>
<span class="header-pill-count" id="todos-count">0</span>
</button>
<button type="button" id="overflow-btn" class="overflow-btn"
aria-haspopup="menu" aria-expanded="false"
title="more actions">⋯</button>
</div>
</header>
<!-- Overflow popover. Sits outside the header so the header's
`overflow: hidden`-adjacent ancestors don't clip it; positioned
in JS relative to the overflow button (top-right anchor). -->
<div id="overflow-menu" class="overflow-menu" role="menu" hidden></div>
<!-- Main content area. The terminal fills it edge-to-edge and
scrolls behind the floating header + composer. The `#status`
overlay renders only when login is required (transient first-
time-setup state); otherwise the terminal owns the screen. -->
<main class="agent-main" id="agent-main">
<div id="status" class="agent-status-overlay"></div>
<div class="terminal-wrap">
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
</div>
</main>
<!-- Fixed-overlay composer. Same frosted-glass treatment as the
header for symmetric framing. Empty until the harness sets up
the textarea via `renderTermInput`. -->
<footer class="agent-composer" id="agent-composer">
<div id="term-input" class="term-input"></div>
</footer>
<!-- Slide-in side panel (inbox / todos flyouts) is a <hive-side-panel>
element (@hive/shared/side-panel.js) — the Panel singleton in
app.js creates + appends it to <body> lazily on first use, so
nothing needs to be pre-declared here. -->
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
the marked npm package into app.js. -->
<script type="module" src="static/app.js" defer></script>
</body> </body>
</html> </html>

View file

@ -1,14 +1,12 @@
// Entry point for the Preact rewrite of the per-agent terminal page // Entry point for the Preact rewrite of the per-agent terminal page
// (replacing app.js's imperative DOM). Not wired into index.html yet — // (replacing app.js's imperative DOM). Landed in small, reviewable
// this lands in small, reviewable slices (component by component) per // slices (component by component) per mara's "one pr != one commit"
// mara's "one pr != one commit" note on the issue; `index.html`'s // note on the issue; now the sole page — app.js is deleted,
// `<script>` tag swaps from `app.js` to this bundle's output only once // `index.html`'s `<script>` tag points here.
// the new page covers everything the old one did (state polling, the
// live SSE stream, login flow, inbox/todos flyouts, slash commands).
// //
// Mounts to `#preact-root`, a sibling of the legacy markup rather than // Mounts to `#preact-root`, the only content `index.html`'s `<body>`
// a full document takeover, so the two can coexist on a branch while // declares now — no more coexisting-with-legacy-markup sibling div,
// this is still in progress. // that was only needed while both pages built in parallel.
import { render } from 'preact'; import { render } from 'preact';
import { Root } from './Root.js'; import { Root } from './Root.js';