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

@ -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 };
}