mara, reviewing PR#3780's showExpandDetails boolean prop: "you cannot just add it like this - if we have more and more options there in different places we will keep accumulating cruft in the shared component." SettingsMenu now exposes a plain children slot instead; the agent page owns its expand-tool-output row entirely (state, storage, markup) in its own ExpandDetailsSetting component and passes it in, so the shared component never learns that setting exists.
249 lines
11 KiB
TypeScript
249 lines
11 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 { 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);
|
|
|
|
// 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 />
|
|
</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(Date.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} onLiveTurnBoundary={refresh} />
|
|
</main>
|
|
{termInput}
|
|
{panel}
|
|
</>
|
|
);
|
|
}
|