agent: wire Header/StatusChips to real /api/state polling

- useAgentState hook: polls GET /api/state (4s interval for now — see
  its file comment for why this isn't yet the SSE-triggered + login-
  only-timer cadence the old page used; that lands with the live
  stream + term-input commit, which is when clobbering the operator's
  in-progress input actually becomes a risk).
- format.ts: fmtTokens/fmtAge, same output shapes as app.js's.
- modelEffort.ts: POST /api/model + /api/effort (same-origin, plain
  fetch).
- dashboardBase.ts + pauseAction.ts: pause/resume POST to the
  *dashboard's* origin via a real <form> submit, kept unchanged from
  app.js — a cross-origin fetch needs CORS headers hive-c0re doesn't
  send, a form submit sidesteps that same as it already did.
- Root.tsx: wires it all together, including app.js's "any non-online
  status forces the turn-state badge to offline" behavior.

Screenshot-verified against a mock GET /api/state (real fetch, not
hardcoded props) + the real agent.css/theme.css/colors.css.

Builds + tsc --noEmit clean.
This commit is contained in:
iris 2026-08-28 02:07:17 +02:00
commit 908f479372
6 changed files with 239 additions and 26 deletions

View file

@ -1,36 +1,102 @@
// <Root> — root of the Preact rewrite. Currently just `Header` +
// `StatusChips` wired to local component state as a first slice (real
// `/api/state` polling + the live SSE stream land in follow-up commits
// on this same PR, per mara's "one pr != one commit" note on the
// issue). Sample values below match the shapes in `types.ts`.
import { useState } from 'preact/hooks';
// <Root> — root of the Preact rewrite. Wires `Header` + `StatusChips`
// to the real `/api/state` snapshot via `useAgentState`. Still a slice,
// not the whole page — see main.tsx's file comment for what's left
// (the live SSE stream, login flow, inbox/todos, term input, overflow).
import type { BadgeTone } from '@hive/shared/badge.js';
import { Header } from './components/Header.js';
import { StatusChips } from './components/StatusChips.js';
import { useAgentState } from './hooks/useAgentState.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 type { TokenUsage } from './types.js';
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_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',
compacting: "operator-triggered /compact running on the persistent session",
};
// `total` here matches app.js's `renderOneUsage` exactly: input + both
// cache buckets, output_tokens deliberately excluded (it's shown in the
// 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);
}
export function Root() {
const [model, setModel] = useState('sonnet');
const [effort, setEffort] = useState('high');
const [paused, setPaused] = useState(false);
const { state, refresh } = useAgentState();
if (!state) {
return (
<Header label="…">
<StatusChips
aliveLabel="… connecting"
aliveTone="neutral"
stateLabel="… booting"
stateTone="neutral"
model=""
availableModels={[]}
onSelectModel={() => {}}
effort=""
availableEfforts={[]}
onSelectEffort={() => {}}
paused={false}
onTogglePause={() => {}}
/>
</Header>
);
}
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 stateAge = fmtAge(Date.now() - state.turn_state_since * 1000);
const ctx = tokenTotal(state.ctx_usage);
const cost = tokenTotal(state.cost_usage);
return (
<Header label="IRIS" hiveLabel="constellation / pr1ma">
<Header label={state.label} hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null}>
<StatusChips
aliveLabel="alive"
aliveTone="positive"
stateLabel="idle · 6m 1s"
stateTone="neutral"
stateTooltip="turn loop running, no claude invocation in flight"
model={model}
availableModels={['haiku', 'sonnet', 'opus']}
onSelectModel={setModel}
effort={effort}
availableEfforts={['low', 'medium', 'high', 'xhigh', 'max']}
onSelectEffort={setEffort}
ctxLabel="534k"
costLabel="$1.6M"
paused={paused}
onTogglePause={() => setPaused((p) => !p)}
lastTurnLabel="last turn 8.4s"
aliveLabel={`${alive.glyph} ${alive.text}`}
aliveTone={alive.tone}
stateLabel={`${turnDef.glyph} ${turnDef.text} · ${stateAge}`}
stateTone={turnDef.tone}
stateTooltip={STATE_TOOLTIPS[effectiveTurnState]}
model={state.model}
availableModels={state.available_models}
onSelectModel={(name) => {
postModel(name).then(() => refresh());
}}
effort={state.effort}
availableEfforts={state.available_efforts}
onSelectEffort={(level) => {
postEffort(level).then(() => refresh());
}}
ctxLabel={ctx !== null ? fmtTokens(ctx) : undefined}
costLabel={cost !== null ? fmtTokens(cost) : undefined}
paused={state.paused}
onTogglePause={() => {
submitPauseResume(resolveDashboardBase(state.dashboard_port), state.label, state.paused ? 'resume' : 'pause');
}}
/>
</Header>
);

View file

@ -0,0 +1,78 @@
// useAgentState — polls `GET /api/state` and exposes the latest
// snapshot + loading/error status. Mirrors app.js's old `refreshState`
// data fetch (not yet its exact re-poll cadence — see the interval
// comment below).
//
// Cadence note: the old page only re-polls on a timer while a login is
// in flight, and otherwise waits for an SSE `turn_end` event to trigger
// one-shot refreshes — this avoids clobbering the operator's half-typed
// message in the term-input field. That field doesn't exist in this
// rewrite yet (a later commit on this same PR), so there's nothing to
// clobber yet; this hook uses a flat interval for now and switches to
// the SSE-triggered model in the commit that adds TermInput + the live
// stream, matching the original behavior once it's actually needed.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { AgentState } from '../types.js';
const POLL_MS = 4000;
const RETRY_MS = 5000;
export interface UseAgentStateResult {
state: AgentState | null;
error: string | null;
/** Force an immediate refresh (e.g. right after a model/effort POST). */
refresh: () => void;
}
export function useAgentState(): UseAgentStateResult {
const [state, setState] = useState<AgentState | null>(null);
const [error, setError] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const stoppedRef = useRef(false);
useEffect(() => {
stoppedRef.current = false;
async function poll() {
try {
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;
setState(s);
setError(null);
timerRef.current = setTimeout(poll, POLL_MS);
} catch (err) {
if (stoppedRef.current) return;
setError(err instanceof Error ? err.message : String(err));
timerRef.current = setTimeout(poll, RETRY_MS);
}
}
poll();
return () => {
stoppedRef.current = true;
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
function refresh() {
if (timerRef.current) clearTimeout(timerRef.current);
// Re-trigger the same poll loop immediately; the effect's closure
// owns `poll`, so the simplest safe re-trigger from outside it is a
// fresh fetch here rather than reaching back into the effect.
stoppedRef.current = false;
fetch('api/state')
.then((resp) => {
if (!resp.ok) throw new Error(`http ${resp.status}`);
return resp.json();
})
.then((s: AgentState) => {
setState(s);
setError(null);
})
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}
return { state, error, refresh };
}

View file

@ -0,0 +1,11 @@
// Resolves the host dashboard's origin, ported unchanged from app.js's
// `setHeader` — pause/resume are host-side (hive-c0re) actions, not
// this agent's own `/api/*`, so they need the *dashboard's* origin, not
// a relative path. Behind the gateway this page lives at
// `/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 + '/'
: `${location.protocol}//${location.hostname}:${dashboardPort}/`;
}

View file

@ -0,0 +1,17 @@
// Formatting helpers ported from app.js's `fmtTokens`/`fmtAge` (same
// 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';
return String(n);
}
export function fmtAge(ms: number): string {
const s = Math.floor(ms / 1000);
if (s < 60) return s + 's';
const m = Math.floor(s / 60);
if (m < 60) return m + 'm ' + (s % 60) + 's';
const h = Math.floor(m / 60);
return h + 'h ' + (m % 60) + 'm';
}

View file

@ -0,0 +1,25 @@
// POST `/api/model` / `/api/effort` — same-origin (this agent's own
// backend), so a plain `fetch` is safe unlike pause/resume (see
// pauseAction.ts). `redirect: 'manual'` + the broad "ok" check below is
// 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 }> {
try {
const resp = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ [field]: value }),
redirect: 'manual',
});
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 : ''}` };
} catch (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);

View file

@ -0,0 +1,16 @@
// Pause/resume POST to the *dashboard's* origin (hive-c0re, not this
// agent's own `/api/*`) — see dashboardBase.ts. A real `<form>` submit
// rather than `fetch`, kept unchanged from app.js: accessed directly
// (not through the gateway proxy), the dashboard is a different origin/
// port, and a cross-origin `fetch` POST needs the response readable
// under CORS to report success/failure — a full navigation form submit
// sidesteps that (the browser reloads to whatever the endpoint
// returns) without needing hive-c0re to grow CORS headers for what's
// otherwise a same-origin action behind the gateway.
export function submitPauseResume(dashboardBase: string, label: string, verb: 'pause' | 'resume'): void {
const form = document.createElement('form');
form.method = 'POST';
form.action = `${dashboardBase}api/${verb}/${label}`;
document.body.appendChild(form);
form.submit();
}