hyperhive#4008, mara: 'add a setting to not display verbose output... like the grey colored debug stuff.' Same shape as the existing expand-tool-output preference (ExpandDetailsSetting.tsx): a new shared/src/prefs.ts key pair (getHideDebugPref/setHideDebugPref), a new settings-menu-row component owning its own state (HideDebugSetting.tsx, not a prop threaded through the shared SettingsMenu component — per mara's earlier review on the first one, more per-page options as props there is how that component accumulates cruft), mounted next to ExpandDetailsSetting in Root.tsx. Row.tsx skips (returns null for, not CSS display:none) any TermMsg whose level is 'debug' when the pref is set — matches the muted 'debug' row this issue is about (see docs/web-ui/terminal-rendering.md's Levels table). Read live per-row, same as expand-details, so toggling applies to newly streamed rows in an already-open tab without a reload; already -rendered rows are unaffected either way, same non-retroactive precedent the existing preference already sets.
329 lines
12 KiB
TypeScript
329 lines
12 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)
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
// 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 = (
|
|
<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}
|
|
</>
|
|
);
|
|
}
|