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
differently in the frontend: `Container` → same-origin path
(the agent page is itself container-local); `Forge`
`state.forge_public_url + url` (sourced from
`services.hyperhive.forge.publicUrl`), and the link is omitted
entirely when that's unset — never guessed from `<host>:3000`;
`External` → already absolute.
All anchors are built via `el()` — agent-declared icon /
label / url strings never reach `innerHTML` (XSS-safe by
construction).
- Row 2 (`.agent-state-row`): alive badge + state badge + model chip
+ ctx badge + cost badge + last-turn chip + cancel button.
- Alive badge: `● alive` (green) / `⊘ rate limited` (red) /
`◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`. `◌ needs login` / `◌ logging in` / `○ offline` / `… connecting`.
Driven by `LiveEvent::StatusChanged`. - **State badge**: `💤 idle` / `🧠 thinking` / `📦 compacting` /
- State badge: `💤 idle` / `🧠 thinking` / `📦 compacting` / `○ offline` / `… booting` + age suffix.
`○ offline` / `… booting` + age suffix. Driven by - **Model badge** (`model · <name> ▾`): a real picker — click opens
`LiveEvent::TurnStateChanged ({ state, since_unix })`. a `Dropdown` of `state.available_models`, selecting one POSTs
- Model chip: `model · <name>`. Driven by `LiveEvent::ModelChanged`. `/api/model` immediately (same endpoint the `/model <name>` slash
- Ctx badge: `ctx · 142k` — last inference's prompt size. command uses). No longer buried in the overflow menu — the design
Tooltip shows % of window when `context_window_tokens` is known. guide's own named anti-example (control disconnected from
- Cost badge: `cost · 1.3M` — cumulative tokens billed across every display) this rewrite exists to fix.
inference in the last turn (tool-heavy turns rebill the cached - **Effort badge** (`effort · <level> ▾`): same shape, `/api/effort`,
prefix per call — cost signal, not size signal). shown when `state.available_efforts` is non-empty.
- Both driven by `LiveEvent::TokenUsageChanged { ctx, cost }` at - **Ctx / cost badges**: `ctx · 142k` (last inference's prompt size,
turn-end. tooltip shows % of context window) / `cost · 1.3M` (cumulative
- `■ cancel turn` (visible while thinking) → `POST /api/cancel`. tokens billed across every inference in the last turn).
- **Right cluster** (`.agent-header-pills`): flyout pills + overflow. - **Pause badge** (`⏸ pause` / `▶ resume`): toggles via the same
- **Inbox pill** (`📬 inbox · N`): hidden when empty; click opens hive-c0re-owned `/api/pause/<name>` / `/api/resume/<name>`
the inbox flyout in the side panel. endpoints the dashboard uses (pausing needs a write this
- **Todos pill** (`📋 todos · N`): hidden when empty; click opens unprivileged process can't make directly).
the todos flyout — see the todos-flyout paragraph below - **Inbox / todos pills** (`📬 inbox · N` / `📋 todos · N`): hidden
(`GET /api/todos`, refreshed on cold load + every `turn_end`). when empty; click opens the matching flyout in the side panel.
- **Overflow button** (`⋯`): always visible. Opens a frosted popover - **Links badge** (`🔗`): opens a popover listing this agent's
(`#overflow-menu`, positioned outside the header to escape any `agent_links()`-sourced navigation (stats, screen when VNC is
stacking context) with five management rows followed by a model enabled, forge profile + config-repo mirror when the agent has a
quick-picker section: `↑ dashboard` (link), `↻ rebuild container` forge account, any `hyperhive.dashboardLinks` extras) as real
(POST confirm, same action as the dashboard R3BU1LD button), `<a>` elements — not a `Dropdown`-style command list, so
`↻ new claude session` (POST confirm → `POST /api/new-session`; ctrl/middle-click and "copy link address" keep working. Each
next turn drops `--continue`), `🔓 logout` (POST confirm → `POST `AgentLink.kind` resolves differently: `container` → same-origin
/api/logout`; SIGINTs any in-flight turn, wipes OAuth credential path; `forge``state.forge_public_url + url`, and the link is
files, flips the agent to `needs_login` — session history dropped entirely when that's unset (never guessed from
preserved), and `⏸ pause agent` / `▶ resume agent` (POST confirm → `<host>:3000`); `external` → already absolute. Same source
hive-c0re's `/api/pause/<name>` / `/api/resume/<name>` — the same (`GET /api/state`'s `links` field) also feeds
endpoints the dashboard's own `<hive-agent-menu>` uses, since `DashboardState.links` for the dashboard card's icon strip —
pausing is a hive-c0re-owned write this unprivileged process can't `agent_links()` in hive-agent is the single source of truth for
make directly). The label + target endpoint track `state.paused` both.
(this agent's own `/api/state`, a direct stat of the harness's - **Overflow badge** (`⋯`): a `Dropdown` with two rows —
local pause marker — see `docs/persistence.md`), refreshed on every `↑ dashboard` (link) and `↻ rebuild container` (select-twice
snapshot so a pause/resume triggered from the *dashboard* while confirm, same action as the dashboard R3BU1LD button). Everything
this page is open doesn't leave the menu item showing the wrong else that used to live here (model/effort pickers, new-session,
action. All destructive actions require one extra click to logout) has a better home now: pickers are real badges above, and
acknowledge — rare ops shouldn't live in the primary state strip. `/new-session` / `/logout` are typed slash commands with their own
Below a separator, a **model quick-picker** section labelled type-twice confirm (see below) — a modal doesn't fit a text-input
`model` renders one button per model in the operator-configured flow, and burying rare-but-important actions in one flat menu was
list. The list is driven by `state.available_models` (sourced from the design guide's own named anti-example.
the `HIVE_AVAILABLE_MODELS` env var, injected by the - No header cancel-turn button any more — `/cancel` (slash command,
`services.hyperhive.availableModels` NixOS option; defaults to below) is the only path; the turn-loop state badge already shows
`["haiku", "sonnet", "opus"]` when unset). Well-known aliases get `thinking` as the discoverability cue.
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';