Ported from app.js's `renderTermInput`/`handleSlashCommand`/
`completeSlash`: prompt + auto-growing textarea, Enter sends (Shift+Enter
newline), Tab cycles slash-command completion. Same command set
(/help, /clear, /cancel, /compact, /model, /effort, /new-session,
/logout), same routes (`api/cancel`, `api/compact`, `api/new-session`,
`api/logout`, `send`) via new `lib/termActions.ts` (same `{ok, detail}`
shape as modelEffort.ts's POST helper).
One deliberate UX change: `/new-session` and `/logout` used to pop the
old shadow-DOM `themedConfirm` modal before firing — this rewrite's
destructive actions all avoid that widget family already (SidePanel,
useConfirmClick), and a modal doesn't fit a text-input flow anyway.
Typing the command once arms it (a local note explains what confirming
does); typing it again fires it — a keyboard-native two-step confirm.
`/help`/`/clear` need to reach into LiveStream's row list (local-only
echo rows, never sent anywhere) without lifting that state up to Root —
`useLiveStream` gained `pushLocalNote`/`clearLocal`, exposed off
`LiveStream` via `forwardRef`+`useImperativeHandle` (preact/compat),
same shape as app.js's old `termAPI` object but scoped as a ref handle
instead of a module-level variable.
Screenshot-verified end to end: typed "/help" + Enter into a real
mounted composer, confirmed the textarea clears and the local note rows
(command list) append to the live pane.
167 lines
7.1 KiB
TypeScript
167 lines
7.1 KiB
TypeScript
// <Root> — root of the Preact rewrite. Wires `Header` + `StatusChips`
|
|
// to the real `/api/state` snapshot via `useAgentState`. Still a slice,
|
|
// not the whole page — see main.tsx's file comment for what's left
|
|
// (the live SSE stream, login flow, inbox/todos, term input, overflow).
|
|
import { useRef, useState } from 'preact/hooks';
|
|
import type { BadgeTone } from '@hive/shared/badge.js';
|
|
import { Header } from './components/Header.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 { 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 type { TokenUsage } from './types.js';
|
|
|
|
type OpenPanel = 'inbox' | 'todos' | null;
|
|
|
|
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() {
|
|
const { state, refresh } = useAgentState();
|
|
const { todos, refresh: refreshTodos } = useTodos();
|
|
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
|
|
const liveStreamRef = useRef<LiveStreamHandle>(null);
|
|
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')} />
|
|
</>
|
|
);
|
|
// 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
|
|
aliveLabel="… connecting"
|
|
aliveTone="neutral"
|
|
stateLabel="… booting"
|
|
stateTone="neutral"
|
|
model=""
|
|
availableModels={[]}
|
|
onSelectModel={() => {}}
|
|
effort=""
|
|
availableEfforts={[]}
|
|
onSelectEffort={() => {}}
|
|
paused={false}
|
|
onTogglePause={() => {}}
|
|
/>
|
|
</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);
|
|
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
|
|
aliveLabel={`${alive.glyph} ${alive.text}`}
|
|
aliveTone={alive.tone}
|
|
stateLabel={`${turnDef.glyph} ${turnDef.text} · ${stateAge}`}
|
|
stateTone={turnDef.tone}
|
|
stateTooltip={STATE_TOOLTIPS[effectiveTurnState]}
|
|
model={state.model}
|
|
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');
|
|
}}
|
|
/>
|
|
</Header>
|
|
<main className="agent-main">
|
|
<LiveStream ref={liveStreamRef} onLiveTurnBoundary={refresh} />
|
|
</main>
|
|
{termInput}
|
|
{panel}
|
|
</>
|
|
);
|
|
}
|