diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index 2e3d8655..40e3532b 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -1,36 +1,102 @@ -// — 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 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 = { + 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 = { + idle: { glyph: '💤', text: 'idle', tone: 'neutral' }, + thinking: { glyph: '🧠', text: 'thinking', tone: 'accent' }, + compacting: { glyph: '📦', text: 'compacting', tone: 'warning' }, +}; +const STATE_TOOLTIPS: Record = { + 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 ( +
+ {}} + effort="" + availableEfforts={[]} + onSelectEffort={() => {}} + paused={false} + onTogglePause={() => {}} + /> +
+ ); + } + + 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 ( -
+
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'); + }} />
); diff --git a/frontend/packages/agent/src/hooks/useAgentState.ts b/frontend/packages/agent/src/hooks/useAgentState.ts new file mode 100644 index 00000000..99e08b57 --- /dev/null +++ b/frontend/packages/agent/src/hooks/useAgentState.ts @@ -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(null); + const [error, setError] = useState(null); + const timerRef = useRef | 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 }; +} diff --git a/frontend/packages/agent/src/lib/dashboardBase.ts b/frontend/packages/agent/src/lib/dashboardBase.ts new file mode 100644 index 00000000..9bdb60a1 --- /dev/null +++ b/frontend/packages/agent/src/lib/dashboardBase.ts @@ -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//` 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}/`; +} diff --git a/frontend/packages/agent/src/lib/format.ts b/frontend/packages/agent/src/lib/format.ts new file mode 100644 index 00000000..11201359 --- /dev/null +++ b/frontend/packages/agent/src/lib/format.ts @@ -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'; +} diff --git a/frontend/packages/agent/src/lib/modelEffort.ts b/frontend/packages/agent/src/lib/modelEffort.ts new file mode 100644 index 00000000..f52bb135 --- /dev/null +++ b/frontend/packages/agent/src/lib/modelEffort.ts @@ -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); diff --git a/frontend/packages/agent/src/lib/pauseAction.ts b/frontend/packages/agent/src/lib/pauseAction.ts new file mode 100644 index 00000000..060358fd --- /dev/null +++ b/frontend/packages/agent/src/lib/pauseAction.ts @@ -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 `
` 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(); +}