Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/agent/src/Root.tsx
iris 8d257e9362 agent ui: restore a clickable logout affordance to the status menu
mara: 'hive ui agent term: logout button gone? must have dropped with
one of the redesigns, pls bring it back or tell me where to find it'

It did drop, silently. The old app.js overflow menu had an
unconditional 🔓 logout item (dashboard-link + rebuild + new-session +
logout); the Preact rewrite's Root.tsx comment explicitly documents
dashboard-link moving to MetaNav and rebuild being removed outright,
but says nothing about logout — it just didn't come along. `/logout`
still works (TermInput's slash-command list, type-twice-to-confirm),
but nothing in the UI points at it or offers a click path anymore.

Fix: add a 'logout' entry to the status badge's dropdown (StatusChips'
StatusMenu) — the same menu that already hosts pause/resume and
cancel-turn, reusing its exact click-then-confirm-click pattern
(danger: true, armed/confirm state reset on close). Unlike cancel-turn
it's not gated on `thinking` — always offered, matching the old
overflow menu's unconditional entry. Wired through onLogout ->
termActions.ts's existing postLogout (already used by the slash
command, so the only new code is the menu entry + prop threading).

Scoped to just what was asked: /new-session has the identical
gone-from-any-menu gap (same old overflow item, no restored click
path), left alone here and flagged separately rather than folded in.

tsc --noEmit clean, nix fmt clean.
2026-09-21 17:18:36 +02:00

362 lines
14 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, postLogout } 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()}
onLogout={() => 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();
})
}
onLogout={() =>
postLogout().then((r) => {
if (!r.ok)
liveStreamRef.current?.pushNote(
`✗ /logout 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}
</>
);
}