// — 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 { ExpandDetailsSetting } from "./components/ExpandDetailsSetting.js"; import { HideDebugSetting } from "./components/HideDebugSetting.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< 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 = { 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); // Ticks the status badge's "thinking Xm Ys" age once a second, // independent of the ~4s `/api/state` poll cadence — without its own // clock the age text only advances when a poll happens to land, so it // visibly stair-steps instead of counting up, and freezes outright for // however long a poll is delayed (e.g. a backgrounded tab). const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); }, []); // 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}> <ExpandDetailsSetting /> <HideDebugSetting /> </SettingsMenu> </> ); // 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(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} /> </main> {termInput} {panel} </> ); }