// — root of the Preact rewrite. Wires `Header` + `StatusChips` // to the real `/api/state` snapshot via `useAgentState`. Covers // everything `app.js` did, including the `needs_login_idle`/ // `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with // no credentials has no other web-UI path back online, so this isn't // optional polish. import { useEffect, useRef, useState } from 'preact/hooks'; import type { BadgeTone } from '@hive/shared/badge.js'; import { SettingsMenu } from '@hive/shared/settings-menu.js'; import { useApplyThemeOverride } from '@hive/shared/theme-apply.js'; import { useApplyMotionOverride } from '@hive/shared/motion-apply.js'; import { Header } from './components/Header.js'; import { MetaNav } from './components/MetaNav.js'; import { StatusChips } from './components/StatusChips.js'; import { LiveStream, type LiveStreamHandle } from './components/LiveStream.js'; import { HeaderPill } from './components/HeaderPill.js'; import { SidePanel } from './components/SidePanel.js'; import { InboxPanel } from './components/InboxPanel.js'; import { TodosPanel } from './components/TodosPanel.js'; import { TermInput } from './components/TermInput.js'; import { LoginFlow } from './components/LoginFlow.js'; import { useAgentState } from './hooks/useAgentState.js'; import { useTodos } from './hooks/useTodos.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 { postCancelTurn } from './lib/termActions.js'; import type { TokenUsage } from './types.js'; type OpenPanel = 'inbox' | 'todos' | null; // Storage keys this page owns for `@hive/shared`'s settings mechanism — // kept here, not derived, so `useApplyThemeOverride`/`useApplyMotionOverride` // (mounted once below) and `` (in `pills`, further down) // always agree on which browser-local key they're reading/writing. const THEME_KEY = 'agent:theme-override'; const MOTION_KEY = 'agent:motion-override'; 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() { useApplyThemeOverride(THEME_KEY); useApplyMotionOverride(MOTION_KEY); const { state, refresh } = useAgentState(); const { todos, refresh: refreshTodos } = useTodos(); const [openPanel, setOpenPanel] = useState(null); const liveStreamRef = useRef(null); // Browser tab title — ports app.js's setHeader logic verbatim: prefer // the human display name + hive_name ("iris // pr1ma") so tabs read // naturally; fall back to the qualified domain label for multi-hive // disambiguation when hive_name is unset, then to plain label for // single-hive deploys. Without this every agent's tab reads the // static index.html forever — can't tell tabs apart with // several open. useEffect(() => { if (!state) return; const tab = state.qualified_label && state.qualified_label !== state.label ? state.qualified_label : state.label; document.title = state.hive_name ? `${state.label} // ${state.hive_name}` : `${tab} // hyperhive`; }, [state?.label, state?.qualified_label, state?.hive_name]); const termInput = ( <footer class="agent-composer"> <TermInput label={state?.label ?? '…'} online={state?.status === 'online'} onLocalNote={(text) => liveStreamRef.current?.pushNote(text)} onClear={() => liveStreamRef.current?.clear()} /> </footer> ); const pills = ( <> <HeaderPill kind="inbox" icon="📬" label="inbox" count={state?.inbox.length ?? 0} onClick={() => setOpenPanel('inbox')} /> <HeaderPill kind="todos" icon="📋" label="todos" count={todos.length} onClick={() => setOpenPanel('todos')} /> {/* Needs a loaded snapshot for its data (links/forge_public_url, * dashboard_port) — omitted until the first /api/state resolves, * same as the old page's populateOverflowMenu (now deleted — * dashboard-link moved here, rebuild removed outright, see * MetaNav.tsx's file comment). */} {state ? ( <MetaNav links={state.links} forgePublicUrl={state.forge_public_url} dashboardBase={resolveDashboardBase(state.dashboard_port)} /> ) : null} <SettingsMenu themeKey={THEME_KEY} motionKey={MOTION_KEY} /> </> ); // Kept mounted regardless of `openPanel` (open/closed toggles just the // `open` prop) rather than conditionally rendering the whole // component — SidePanel's slide/fade CSS transitions only fire on a // class change after mount, not on an already-open initial render, so // mount-on-open would skip both the open AND the close animation. const panelTitle = openPanel === 'inbox' ? `inbox · ${state?.inbox.length ?? 0}` : openPanel === 'todos' ? `todos · ${todos.length}` : ''; const panel = ( <SidePanel open={openPanel !== null} title={panelTitle} onClose={() => setOpenPanel(null)}> {openPanel === 'inbox' ? ( <InboxPanel rows={state?.inbox ?? []} label={state?.label ?? ''} dashboardBase={state ? resolveDashboardBase(state.dashboard_port) : ''} onCleared={refresh} /> ) : openPanel === 'todos' ? ( <TodosPanel todos={todos} onCleared={refreshTodos} /> ) : null} </SidePanel> ); if (!state) { return ( <> <Header label="…" pills={pills}> <StatusChips statusLabel="… connecting" statusTone="neutral" model="" availableModels={[]} onSelectModel={() => {}} effort="" availableEfforts={[]} onSelectEffort={() => {}} paused={false} onTogglePause={() => {}} thinking={false} onCancelTurn={() => Promise.resolve()} /> </Header> <main className="agent-main"> <LiveStream ref={liveStreamRef} /> </main> {termInput} {panel} </> ); } 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); // Consolidated status badge (mara: "why do we have separate alive // badge? ... one badge that shows thinking / idle / paused"). // Not online → the alive-table's own reading (rate-limited/needs-login/ // offline), no age (nothing to age). Online + paused → 'paused' // overrides the turn-state reading, since a paused agent won't be // thinking regardless of its last turn_state. Online + running → // turn-state + age, same as before. const primaryStatus = state.status !== 'online' ? { glyph: alive.glyph, text: alive.text, tone: alive.tone, tooltip: undefined as string | undefined } : state.paused ? { glyph: '⏸', text: 'paused', tone: 'warning' as BadgeTone, tooltip: undefined } : { glyph: turnDef.glyph, text: `${turnDef.text} · ${stateAge}`, tone: turnDef.tone, tooltip: STATE_TOOLTIPS[effectiveTurnState], }; const ctx = tokenTotal(state.ctx_usage); const cost = tokenTotal(state.cost_usage); return ( <> <Header label={state.label} hiveLabel={[state.swarm_name, state.hive_name].filter(Boolean).join(' / ') || null} pills={pills} > <StatusChips statusLabel={`${primaryStatus.glyph} ${primaryStatus.text}`} statusTone={primaryStatus.tone} statusTooltip={primaryStatus.tooltip} model={state.model} resolvedModel={state.resolved_model ?? undefined} 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').then( () => refresh(), ); }} thinking={effectiveTurnState === 'thinking'} onCancelTurn={() => postCancelTurn().then((r) => { if (!r.ok) liveStreamRef.current?.pushNote(`✗ /cancel failed${r.detail ? ': ' + r.detail : ''}`); refresh(); }) } /> </Header> <main className="agent-main"> {state.status === 'needs_login_idle' || state.status === 'needs_login_in_progress' ? ( <LoginFlow status={state.status} session={state.session} onRefresh={refresh} /> ) : null} <LiveStream ref={liveStreamRef} onLiveTurnBoundary={refresh} /> </main> {termInput} {panel} </> ); }