For embedding companions (e.g. trollshell WebKitGTK) that supply their own header/input chrome around the per-agent page and only want the raw terminal feed. Comma-separated so both axes can hide in one param: ?hide=header,input. Zeroes --agent-header-h/--agent-composer-h via body classes rather than conditionally computing padding, so every dependent calc() (overlay offset, terminal scroll padding) collapses in one place.
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
// <Root> — 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 `<SettingsMenu>` (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<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)
|
|
);
|
|
}
|
|
|
|
// `?hide=header,input` — an embedding companion window (e.g. a native
|
|
// desktop shell) supplies its own header/composer chrome around this
|
|
// page, so it asks for just the terminal feed. Read once per load (the
|
|
// value can't change without a navigation); comma-separated so both
|
|
// axes can be hidden in one param.
|
|
const hideParam = new URLSearchParams(window.location.search).get("hide");
|
|
const HIDDEN = new Set(hideParam ? hideParam.split(",").filter(Boolean) : []);
|
|
|
|
export function Root() {
|
|
useApplyThemeOverride(THEME_KEY);
|
|
useApplyMotionOverride(MOTION_KEY);
|
|
const { state, refresh } = useAgentState();
|
|
const { todos, refresh: refreshTodos } = useTodos();
|
|
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
|
const liveStreamRef = useRef<LiveStreamHandle>(null);
|
|
const hideHeader = HIDDEN.has("header");
|
|
const hideInput = HIDDEN.has("input");
|
|
// Body classes, not inline styles: agent.css's `--agent-header-h`/
|
|
// `--agent-header-real-h`/`--agent-composer-h` custom properties already
|
|
// drive every dependent calc() (overlay offset, terminal scroll padding)
|
|
// — zeroing them here collapses all of that in one place instead of
|
|
// touching each rule.
|
|
useEffect(() => {
|
|
document.body.classList.toggle("agent-hide-header", hideHeader);
|
|
document.body.classList.toggle("agent-hide-input", hideInput);
|
|
}, [hideHeader, hideInput]);
|
|
|
|
// 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 <title> 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 = hideInput ? null : (
|
|
<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 (
|
|
<>
|
|
{hideHeader ? null : (
|
|
<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 (
|
|
<>
|
|
{hideHeader ? null : (
|
|
<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}
|
|
</>
|
|
);
|
|
}
|