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:
parent
93b34ebb9b
commit
908f479372
6 changed files with 239 additions and 26 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue