Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/agent/src/Root.tsx
iris d516bb890c remove the forge links from the hive dashboard and the agent page
Each agent row on the hive dashboard, and the agent page's link menu, linked
to the agent's forge profile and its config repo. Both are gone, with what
only they used:

- hive-agent no longer builds the two links, the `Forge` link kind, or the
  `forge_public_url` field of its state, and no longer reads
  `HIVE_FORGE_PUBLIC_URL`. The agent page's link menu and the dashboard row's
  icon strip (which skips a forge link an agent not yet rebuilt still sends)
  drop their forge handling.
- hive-c0re's dashboard state no longer carries `forge_public_url`.
- `HIVE_FORGE_PUBLIC_URL` is no longer forwarded to agents through the meta
  flake (the forwarded-variable list, its test fixture, the controller's
  settings and the VM test), so the next meta flake render differs by that
  one line. hive-c0re still reads it for the forge URL a hive publishes, so
  `swarm.forge.publicUrl` stays, with its description corrected.
- The docs stop describing the links.
2026-11-07 13:43:44 +01:00

339 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. An agent without credentials shows the
// `needs_login_idle` badge; it logs in from the swarm UI, not here.
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 { AgentUserMenu } from "./components/AgentUserMenu.js";
import { ExpandDetailsSetting } from "@hive/shared/expand-details-setting.js";
import { HideDebugSetting } from "@hive/shared/hide-debug-setting.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 { 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 {
postCancelTurn,
postLogout,
postNewSession,
} 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" },
};
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` — an embedding companion window (e.g. a native desktop
// shell) supplies its own header 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 more parts 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");
// Body classes, not inline styles: agent.css's `--agent-header-h`/
// `--agent-header-real-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);
}, [hideHeader]);
// 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 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,
* 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}
dashboardBase={resolveDashboardBase(state.dashboard_port)}
/>
) : null}
<SettingsMenu themeKey={THEME_KEY} motionKey={MOTION_KEY}>
<ExpandDetailsSetting />
<HideDebugSetting />
</SettingsMenu>
{state ? (
<AgentUserMenu
dashboardBase={resolveDashboardBase(state.dashboard_port)}
/>
) : null}
</>
);
// 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=""
paused={false}
onTogglePause={() => {}}
thinking={false}
onCancelTurn={() => Promise.resolve()}
onNewSession={() => Promise.resolve()}
onLogout={() => Promise.resolve()}
/>
</Header>
)}
<main className="agent-main">
<LiveStream ref={liveStreamRef} />
</main>
{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}
modelSource={state.model_source}
effort={state.effort}
effortSource={state.effort_source}
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();
})
}
onNewSession={() =>
postNewSession().then((r) => {
if (!r.ok)
liveStreamRef.current?.pushNote(
`✗ /new-session failed${r.detail ? ": " + r.detail : ""}`,
);
refresh();
})
}
onLogout={() =>
postLogout().then((r) => {
if (!r.ok)
liveStreamRef.current?.pushNote(
`✗ /logout failed${r.detail ? ": " + r.detail : ""}`,
);
refresh();
})
}
/>
</Header>
)}
<main className="agent-main">
<LiveStream ref={liveStreamRef} />
</main>
{panel}
</>
);
}