diff --git a/frontend/npm-deps-hash b/frontend/npm-deps-hash index fcb93e72..6014c91f 100644 --- a/frontend/npm-deps-hash +++ b/frontend/npm-deps-hash @@ -1 +1 @@ -sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI= \ No newline at end of file +sha256-9sh4G6A00NNi5YKaMQ1v0vZQj+gmGxYPhYZhlqweApM= diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ecfc32e3..8c25b577 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -991,7 +991,11 @@ "@hive/shared": "*", "chart.js": "4.5.1", "dompurify": "^3.2.4", - "marked": "18.0.6" + "marked": "18.0.6", + "preact": "10.29.8" + }, + "devDependencies": { + "typescript": "7.0.2" } }, "packages/dashboard": { diff --git a/frontend/packages/agent/build.mjs b/frontend/packages/agent/build.mjs index be0c3e3a..6e0790a8 100644 --- a/frontend/packages/agent/build.mjs +++ b/frontend/packages/agent/build.mjs @@ -47,6 +47,25 @@ await build({ loader: { '.css': 'text' }, }); +// The Preact rewrite (in progress — see main.tsx's file comment). A +// third, separate bundle rather than folding into the `app.js` build +// above: different loader needs (`.css: 'css'`, real stylesheets a +// component imports, vs. `app.js`'s raw-text shadow-DOM CSS) and JSX +// transpilation, same split swarm-ui's own build already makes. +await build({ + entryPoints: [src('main.tsx')], + outfile: staticDir('main.js'), + bundle: true, + format: 'esm', + platform: 'browser', + target: ['es2022'], + sourcemap: true, + logLevel: 'info', + jsx: 'automatic', + jsxImportSource: 'preact', + loader: { '.css': 'css' }, +}); + // Bundle the CSS. `colors.css` re-exports the standalone base16 palette // (the theme swap contract — its own output file so a swap replaces only // it, no bundle rebuild); `theme.css` is the semantic derivation layer; diff --git a/frontend/packages/agent/package.json b/frontend/packages/agent/package.json index 82da97f3..2a18ce33 100644 --- a/frontend/packages/agent/package.json +++ b/frontend/packages/agent/package.json @@ -5,12 +5,17 @@ "description": "hive-ag3nt per-container web UI. Bundled by esbuild into a static dist; served by the in-container Rust binary at runtime via tower_http::ServeDir. Per-agent additions are layered on top via the hyperhive.frontend.extraFiles nix option.", "type": "module", "scripts": { - "build": "node ./build.mjs" + "build": "node ./build.mjs", + "typecheck": "tsc --noEmit" }, "dependencies": { "@hive/shared": "*", "chart.js": "4.5.1", "dompurify": "^3.2.4", - "marked": "18.0.6" + "marked": "18.0.6", + "preact": "10.29.8" + }, + "devDependencies": { + "typescript": "7.0.2" } } diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx new file mode 100644 index 00000000..baae05ed --- /dev/null +++ b/frontend/packages/agent/src/Root.tsx @@ -0,0 +1,200 @@ +// — 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 { Header } from './components/Header.js'; +import { MetaNav } from './components/MetaNav.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 { OverflowMenu } from './components/OverflowMenu.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 type { TokenUsage } from './types.js'; + +type OpenPanel = 'inbox' | 'todos' | null; + +const ALIVE_LABELS: Record = { + 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 = { + idle: { glyph: '💤', text: 'idle', tone: 'neutral' }, + thinking: { glyph: '🧠', text: 'thinking', tone: 'accent' }, + compacting: { glyph: '📦', text: 'compacting', tone: 'warning' }, +}; +const STATE_TOOLTIPS: Record = { + 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(null); + const liveStreamRef = useRef(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 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 their data (links/forge_public_url, + * dashboard_port) — both omitted (like the old page's + * populateOverflowMenu) until the first /api/state resolves. */} + {state ? <MetaNav links={state.links} forgePublicUrl={state.forge_public_url} /> : null} + {state ? <OverflowMenu label={state.label} 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 ( + <> + <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"> + {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} + </> + ); +} diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 6776fce5..c99e9e39 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -129,15 +129,30 @@ body.agent-shell { gap: 0.5em; } -/* Right cluster — flyout pills stacked / inline with the overflow - trigger. Vertically centred against the full-height icon, no - wrap; pills can drop to a row of their own under crowding via - the flex-wrap of `.agent-header-pills` itself. */ +/* Right cluster — flyout pills, and (new Preact page only) the status + badges (`StatusChips`) too — mara, live: "move badges to where the + dropdowns are". `flex-wrap: wrap` alone doesn't help if this + container can't itself shrink: the old `flex-shrink: 0` here (fine + for app.js's 2-3-small-pills case, which never needed to shrink) + told the flex layout "give this its full natural width no matter + what," so once badges made that natural width wider than the + viewport, it just overflowed past the edge instead of ever getting + narrow enough to trigger its own wrap — mara, live, on the 500px + screenshot: "does not overflow properly". Dropping `flex-shrink: 0` + (default is `1`) is the whole fix — deliberately NOT pairing it + with `min-width: 0` (tried that first, made it worse: an unset + `min-width` still floors shrinking at this container's own + min-content — the widest single wrapped child, e.g. one badge, not + the full unwrapped row — which is the correct floor; `min-width: 0` + removes that floor entirely, so at a narrow enough viewport the + container shrinks past what its own content needs and the content + overflows its box sideways, landing on top of `.agent-header-main` + instead of wrapping cleanly — confirmed live, reproducible at + 320px, not a one-off capture glitch). */ .agent-header-pills { display: flex; align-items: center; gap: 0.5em; - flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; align-self: center; @@ -357,10 +372,16 @@ h2, h3 { /* Login flow overlay: only rendered when status != online. Sits centred over the (likely-empty) terminal area; doesn't take chrome - space in the normal online flow. */ + space in the normal online flow. `--agent-header-real-h` (set by + the new Preact page's `Header.tsx` via ResizeObserver, falling back + to the static `--agent-header-h` guess before the first + measurement / on the old app.js page, which never sets it) is the + header's ACTUAL rendered height, not an assumed one — see + Header.tsx's file comment for why this matters (a static guess is + wrong the moment the header wraps an extra line on some viewport). */ .agent-status-overlay { position: absolute; - top: calc(var(--agent-header-h) + 1.5em); + top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 1.5em); left: 50%; transform: translateX(-50%); max-width: 44em; @@ -716,9 +737,9 @@ pre.diff { and last rows reachable with extra padding inside the scroll area. scroll-padding-* keeps anchor-jumps (the `↓ N new` pill, focus restore) clear of the floats too. */ - padding-top: calc(var(--agent-header-h) + 0.8em); + padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em); padding-bottom: calc(var(--agent-composer-h) + 0.8em); - scroll-padding-top: calc(var(--agent-header-h) + 0.8em); + scroll-padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em); scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em); overflow: auto; } @@ -739,7 +760,21 @@ pre.diff { rules below stay scoped to whichever ancestor owns it. */ .term-input .sendform-term { display: flex; - align-items: flex-start; + /* mara, live: "bottom input row text not aligned / inconsistent + sizes." Real cause: `.prompt` (1em, no line-height override) and + `.submit-hint` (0.8em, no line-height override) each get a + browser-default line-box at a different em-relative size, so + `align-items: flex-start` (their old value) pinned each one's + line-box top edge to the row's top — same nominal `padding-top` + but a DIFFERENT absolute offset per element, since 0.25em means + something different at 1em vs 0.8em font-size. `center` sidesteps + the whole "line-box for text this size" mismatch by aligning each + item's own box middle instead of a top edge that was never + actually equal. Still correct once the textarea grows multi-line + (a common composer-bar convention — Slack/Discord centre their + send affordance against a grown box too, not pin it to the first + line). */ + align-items: center; gap: 0.5em; /* The dashed in-frame separator is dropped — see the .agent-composer .term-input override above for the floating-bar @@ -747,9 +782,6 @@ pre.diff { border-top: 1px dashed var(--purple-dim); padding-top: 0.5em; } -.term-input .prompt, .term-input .submit-hint { - padding-top: 0.25em; -} .term-input .prompt { color: var(--green); text-shadow: 0 0 6px color-mix(in srgb, var(--green) 60%, transparent); diff --git a/frontend/packages/agent/src/components/Header.tsx b/frontend/packages/agent/src/components/Header.tsx new file mode 100644 index 00000000..0eb80be7 --- /dev/null +++ b/frontend/packages/agent/src/components/Header.tsx @@ -0,0 +1,99 @@ +// <Header> — the agent page's identity strip: icon, glyphic title, the +// "swarm / hive" label row, and a slot for the status row +// (`<StatusChips>` — ../status-chips/, kept separate so this component +// has no opinion on what badges exist). Same three-column shape as the +// old `#agent-header` markup (icon · main · pills) — see the `pills` +// prop doc below for what moved into that third column and why. +// Reuses `agent.css`'s existing `.agent-header*`/`.agent-icon` rules +// (loaded globally by index.html) rather than a component-scoped +// stylesheet — no new visual language needed for the chrome itself. + +// Measures its own rendered height via ResizeObserver, writes it to +// `--agent-header-real-h` (agent.css's downstream consumers — +// `.agent-status-overlay`, `.agent-main`'s scroll padding — prefer +// this over the static `--agent-header-h` guess). A static guess was +// the root cause of two "login card behind the header" reports on +// this PR (mara, live): any header content that can wrap an extra +// line breaks the guess at whatever width triggers it, and no width +// can be promised safe. Deliberately a SEPARATE var, not an overwrite +// of `--agent-header-h` in place: `.agent-header`'s own `min-height` +// also reads that var — overwriting it in place would make the +// header's own size react to its own just-measured height every +// tick, a real ResizeObserver feedback loop (confirmed live on the +// first pass of this fix). `--agent-header-h` stays the static floor; +// `--agent-header-real-h` is output-only. +import { useEffect, useRef } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; + +// Icon 404s when the agent has no `hyperhive.icon` override (no +// bundled server-side default any more — see +// `hive_agent::web_ui::screen::serve_icon`). Ports app.js's +// `bindHeaderIconFallback` exactly: fire-and-forget load, swap to +// `/favicon.svg` on failure, `dataset.fallback` guards against a +// 404-on-the-fallback-itself loop. +function handleIconError(e: Event) { + const img = e.currentTarget as HTMLImageElement; + if (img.dataset.fallback) return; + img.dataset.fallback = '1'; + img.src = '/favicon.svg'; +} + +export interface HeaderProps { + label: string; + hiveLabel?: string | null; + /** The status/badge row (`<StatusChips>`) — lives in the + * `.agent-header-pills` cluster now, alongside `pills` (see below), + * not stacked as its own row under the title. */ + children?: ComponentChildren; + /** Flyout triggers (inbox/todos/links pills, the overflow menu + * button) — and, since this round, `children` (the status badges) + * too, both in `.agent-header-pills`, the SAME header-level column + * the old markup used for pills alone. First pass of this ask + * nested pills into a row inside `.agent-header-main` instead — + * backwards, mara corrected live: "i meant move badges to where the + * dropdowns are, not the other way around. this way the height does + * not change even on widescreen" — `.agent-header-main` now only + * ever holds the title + hive-label rows, which don't wrap in + * practice, so it stays a stable height; only this pills-cluster + * column (already used to absorbing a variable pill count) grows to + * fit badges+pills together. Meta-nav (`<MetaNav>`) lives in here + * too, as a fixed-size trigger — not the old markup's inline list in + * the title row (`<nav id="meta-links">`, docs/web-ui/agent.md). */ + pills?: ComponentChildren; +} + +export function Header({ label, hiveLabel, children, pills }: HeaderProps) { + const ref = useRef<HTMLElement>(null); + + useEffect(() => { + const el = ref.current; + if (!el || typeof ResizeObserver === 'undefined') return; + // getBoundingClientRect() (not ResizeObserver's own contentRect) — + // we want the full visual box padding+border included, the thing + // content below the fixed header actually needs to clear, not the + // content-box-only measurement ResizeObserver's entry defaults to. + const ro = new ResizeObserver(() => { + document.documentElement.style.setProperty('--agent-header-real-h', `${el.getBoundingClientRect().height}px`); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + return ( + <header class="agent-header" ref={ref}> + <img class="agent-icon" src="icon" alt="" onError={handleIconError} /> + <div class="agent-header-main"> + <div class="agent-header-row agent-header-title-row"> + <h2 class="agent-header-title">◆ {label} ◆</h2> + </div> + {hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null} + </div> + {children || pills ? ( + <div class="agent-header-pills"> + {children} + {pills} + </div> + ) : null} + </header> + ); +} diff --git a/frontend/packages/agent/src/components/HeaderPill.tsx b/frontend/packages/agent/src/components/HeaderPill.tsx new file mode 100644 index 00000000..1d3f7e9f --- /dev/null +++ b/frontend/packages/agent/src/components/HeaderPill.tsx @@ -0,0 +1,26 @@ +// <HeaderPill> — the inbox/todos flyout triggers in the header's right +// cluster. Reuses agent.css's existing `.hive-pill.header-pill…` rules +// (loaded globally) — same "layout is a like-for-like port, the +// component model is the change" approach as Header.tsx. Hidden +// (renders nothing) at count 0, matching the old page's `pill.hidden = +// count === 0`. +export interface HeaderPillProps { + kind: 'inbox' | 'todos'; + icon: string; + label: string; + count: number; + onClick: () => void; +} + +export function HeaderPill({ kind, icon, label, count, onClick }: HeaderPillProps) { + if (count === 0) return null; + return ( + <button type="button" class={`hive-pill header-pill header-pill-${kind}`} onClick={onClick} title={`open ${label} flyout`}> + <span class="header-pill-icon" aria-hidden="true"> + {icon} + </span> + <span class="header-pill-label">{label}</span> + <span class="header-pill-count">{count}</span> + </button> + ); +} diff --git a/frontend/packages/agent/src/components/InboxPanel.tsx b/frontend/packages/agent/src/components/InboxPanel.tsx new file mode 100644 index 00000000..188534f6 --- /dev/null +++ b/frontend/packages/agent/src/components/InboxPanel.tsx @@ -0,0 +1,86 @@ +// <InboxPanel> — content rendered inside <SidePanel> for the inbox +// flyout. "mark all read" POSTs to the *dashboard's* origin (not this +// agent's own backend) via a plain `fetch()`, same as app.js's +// `buildInboxMarkAllRow` — unlike pause/resume this one's fine as a +// fetch even when the two are genuinely cross-origin, because +// `hive-c0re`'s `/api/agent/{name}/mark-all-read` route replies with +// JSON, not a redirect; in the common gateway-fronted deployment +// `dashboardBase` already resolves to `location.origin` anyway (see +// dashboardBase.ts), so it's same-origin in practice. +import { useState } from 'preact/hooks'; +import { useConfirmClick } from '../lib/useConfirmClick.js'; +import type { InboxRow } from '../types.js'; + +export interface InboxPanelProps { + rows: InboxRow[]; + label: string; + dashboardBase: string; + onCleared: () => void; +} + +function fmtTs(unixSeconds: number): string { + return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(5, 19); +} + +export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanelProps) { + const [status, setStatus] = useState(''); + const [busy, setBusy] = useState(false); + + async function markAllRead() { + if (!dashboardBase || !label) { + setStatus('dashboard url / label unknown'); + return; + } + setBusy(true); + setStatus('clearing…'); + try { + const resp = await fetch(`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`, { + method: 'POST', + }); + if (resp.ok) { + const data = await resp.json().catch(() => ({})); + const n = Number((data as { marked?: number }).marked) || 0; + setStatus(`✓ marked ${n} as read`); + onCleared(); + } else { + setStatus(`failed: http ${resp.status}`); + } + } catch (err) { + setStatus(`failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setBusy(false); + } + } + + const { armed, trigger } = useConfirmClick(markAllRead); + + if (!rows.length) { + return <p class="side-panel-empty">inbox empty.</p>; + } + + return ( + <div class="agent-inbox"> + <div class="inbox-mark-all-row"> + <button + type="button" + class="inbox-mark-all-btn" + onClick={trigger} + disabled={busy} + title="mark every queued message for this agent as read — drains the host broker's pending + delivered-unacked rows. history shown here is the most-recent-N regardless of state, so the list itself stays visible." + > + {armed ? 'sure? click again' : '✓ mark all read'} + </button> + <span class="inbox-mark-status">{status}</span> + </div> + <ul> + {rows.map((m) => ( + <li key={m.id} class={m.in_reply_to != null ? 'inbox-reply' : undefined}> + {m.in_reply_to != null && <span class="inbox-reply-tag">↳ reply · </span>} + <span class="inbox-ts">{fmtTs(m.at)}</span> <span class="inbox-from">{m.from}</span>{' '} + <span class="inbox-sep">→</span> <span class="inbox-body">{m.body}</span> + </li> + ))} + </ul> + </div> + ); +} diff --git a/frontend/packages/agent/src/components/LiveStream.tsx b/frontend/packages/agent/src/components/LiveStream.tsx new file mode 100644 index 00000000..47b0832e --- /dev/null +++ b/frontend/packages/agent/src/components/LiveStream.tsx @@ -0,0 +1,127 @@ +// Live event pane — Preact owner of the DOM node `useLiveStream`'s rows +// get rendered into. Deliberately NOT the old MutationObserver + rAF +// snap-animation + `smoothScrollingUntil` gate machinery +// (@hive/shared/terminal.js) — mara explicitly flagged that the old +// page's autoscroll breaks when new events land while the operator has +// scrolled up, and not to copy it 1:1. This is the "do it properly the +// preact way" version: +// +// - `stickToBottom` is plain state, updated synchronously from the +// scroll handler (near-bottom → true, scrolled away → false). No +// separate "am I mid-animation" gate to fight with it — there's no +// animation to fight, snapping is an instant `scrollTop` write in a +// `useLayoutEffect` that runs once after Preact has already +// committed the new rows to the DOM, so it always sees the real +// `scrollHeight`, never a stale pre-mutation one. +// - A "load older" prepend is told apart from a normal append via +// `prependingRef` (set immediately before calling `loadMore()`) so +// the effect can choose the right response: viewport-preserving +// scrollTop compensation for a prepend, stick-to-bottom-or-count- +// unseen for an append. The old code inferred this from DOM +// mutation shape after the fact; here the caller already knows +// which one it did, so there's nothing to infer. +import { useLayoutEffect, useRef, useState, useImperativeHandle } from 'preact/hooks'; +import { forwardRef } from 'preact/compat'; +import { useLiveStream } from '../hooks/useLiveStream.js'; +import { Row } from './Row.js'; + +const NEAR_BOTTOM_PX = 48; +const LOAD_MORE_SCROLL_PX = 80; + +export interface LiveStreamProps { + /** Forwarded to useLiveStream — fires on live turn_start/turn_end so + * the caller can refresh `/api/state` sooner than its poll interval. */ + onLiveTurnBoundary?: () => void; +} + +/** Imperative escape hatch for TermInput's local-only slash commands + * (`/help`, `/clear`) — same shape as app.js's old `termAPI` object, + * kept as a ref handle rather than lifting the whole row array up to + * Root so LiveStream/useLiveStream still own their state privately. */ +export interface LiveStreamHandle { + pushNote: (text: string) => void; + clear: () => void; +} + +export const LiveStream = forwardRef<LiveStreamHandle, LiveStreamProps>(function LiveStream( + { onLiveTurnBoundary }, + ref, +) { + const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream({ onLiveTurnBoundary }); + useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]); + const logRef = useRef<HTMLDivElement>(null); + const [stickToBottom, setStickToBottom] = useState(true); + const [unseen, setUnseen] = useState(0); + const prevRowCount = useRef(0); + const prependingRef = useRef(false); + const preScrollHeightRef = useRef(0); + + function isNearBottom(el: HTMLDivElement): boolean { + return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX; + } + + function handleScroll() { + const el = logRef.current; + if (!el) return; + const nearBottom = isNearBottom(el); + setStickToBottom(nearBottom); + if (nearBottom) setUnseen(0); + if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore) startLoadMore(); + } + + function startLoadMore() { + const el = logRef.current; + if (!el || loadingMore || !hasMore) return; + prependingRef.current = true; + preScrollHeightRef.current = el.scrollHeight; + loadMore(); + } + + function jumpToBottom() { + const el = logRef.current; + if (el) el.scrollTop = el.scrollHeight - el.clientHeight; + setStickToBottom(true); + setUnseen(0); + } + + // Runs after every commit that changed `rows` — i.e. after the browser + // has already laid out the new/coalesced content, so `scrollHeight` + // below is always current. + useLayoutEffect(() => { + const el = logRef.current; + if (!el) return; + const added = rows.length - prevRowCount.current; + prevRowCount.current = rows.length; + + if (prependingRef.current) { + prependingRef.current = false; + el.scrollTop += el.scrollHeight - preScrollHeightRef.current; + return; + } + if (stickToBottom) { + el.scrollTop = el.scrollHeight - el.clientHeight; + } else if (added > 0) { + setUnseen((n) => n + added); + } + }, [rows, stickToBottom]); + + return ( + <div className="terminal-wrap"> + <div className="live terminal" ref={logRef} onScroll={handleScroll}> + {hasMore && ( + <button type="button" className="load-more-pill" disabled={loadingMore} onClick={startLoadMore}> + {loadingMore ? '↑ loading…' : '↑ load older'} + </button> + )} + {rows.map((row) => ( + <Row key={row.key} row={row} /> + ))} + </div> + {unseen > 0 && ( + <button type="button" className="tail-pill visible" onClick={jumpToBottom}> + ↓ {unseen} new + </button> + )} + </div> + ); +}); diff --git a/frontend/packages/agent/src/components/LoginFlow.css b/frontend/packages/agent/src/components/LoginFlow.css new file mode 100644 index 00000000..c2ce6018 --- /dev/null +++ b/frontend/packages/agent/src/components/LoginFlow.css @@ -0,0 +1,25 @@ +/* .login-card — one elevated-surface card for the whole login-recovery + overlay, replacing the old markup's per-`<p>`/`<form>` boxing + (`agent.css`'s `.agent-status-overlay > *`, still needed there + verbatim since app.js's still-live markup depends on it until the + index.html cutover — this file coexists with it rather than editing + it). Same visual language as `Dropdown`'s `.ui-dropdown` + (`@hive/shared/dropdown/Dropdown.css`) — background/border/radius/ + shadow values match exactly, so this reads as the same "popup" family + as every other floating surface in the app, just laid out as a static + block instead of an anchored, position:absolute menu (that part of + `Dropdown` is specific to hanging under a trigger and doesn't apply + here — `.agent-status-overlay` already centres this card itself). */ +.login-card { + display: flex; + flex-direction: column; + gap: 0.6em; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 0.5em; + box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35); + padding: 1em 1.2em; +} +.login-card > * { + margin: 0; +} diff --git a/frontend/packages/agent/src/components/LoginFlow.tsx b/frontend/packages/agent/src/components/LoginFlow.tsx new file mode 100644 index 00000000..c74c3c1b --- /dev/null +++ b/frontend/packages/agent/src/components/LoginFlow.tsx @@ -0,0 +1,171 @@ +// <LoginFlow> — the `needs_login_idle` / `needs_login_in_progress` +// overlay. Ports app.js's `renderNeedsLoginIdle`/`renderLoginInProgress` +// as real functional UI, not just a status label: an agent whose +// credentials got rotated (or hit `/logout`) has NO other way back +// online, so this is load-bearing, not cosmetic. Reuses agent.css's +// `.status-needs-login`/`.btn-login`/`.loginform`/`.loginform-reveal` +// rules verbatim (same class names as the old markup). +// +// mara (screenshot review): "reuse the popup component for the login, +// the floating separate panels should be one card." The old markup's +// `.agent-status-overlay > *` rule boxed every direct-child `<p>`/ +// `<form>` separately (a fine translation of `el().append()`-per-line, +// but not what a hand-authored component should do) — this renders one +// `.login-card` instead, styled with the same "elevated surface" look +// `Dropdown`'s `.ui-dropdown` already established (`@hive/shared/ +// dropdown/Dropdown.css`), not `Dropdown` itself (its `options.map` +// render is menu-item-shaped, not a generic content container — not a +// fit for a login form + output pane). +import { useState } from 'preact/hooks'; +import type { SessionView } from '../types.js'; +import { postLoginStart, postLoginCode, postLoginCancel } from '../lib/loginAction.js'; +import './LoginFlow.css'; + +function LoginIdle({ onStarted }: { onStarted: () => void }) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + function submit(e: Event) { + e.preventDefault(); + setBusy(true); + setError(null); + postLoginStart().then((r) => { + setBusy(false); + if (r.ok) onStarted(); + else setError(r.detail ?? 'failed'); + }); + } + + return ( + <div class="login-card"> + <p class="status-needs-login">◌ NEEDS L0G1N</p> + <p> + No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is + paused until you log in. + </p> + <form onSubmit={submit}> + <button type="submit" class="btn btn-login" disabled={busy}> + ◆ ST4RT L0G1N + </button> + </form> + {error ? <p class="meta">error: {error}</p> : null} + <p class="meta"> + Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear + here when claude emits it; paste the resulting code back into the form below. + </p> + </div> + ); +} + +function LoginInProgress({ session, onDone }: { session: SessionView | null; onDone: () => void }) { + const [code, setCode] = useState(''); + const [reveal, setReveal] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const finished = session?.finished ?? false; + + function submitCode(e: Event) { + e.preventDefault(); + if (!code) return; + setBusy(true); + setError(null); + // Success: the next `/api/state` poll flips `status` back to + // `online` (or leaves it `needs_login_in_progress` on a bad code, + // with the process's own stderr showing up in `output` below) — + // nothing local to update here either way. + postLoginCode(code).then((r) => { + setBusy(false); + if (!r.ok) setError(r.detail ?? 'failed'); + }); + } + + function cancel(e: Event) { + e.preventDefault(); + setBusy(true); + postLoginCancel().then(() => { + setBusy(false); + onDone(); + }); + } + + return ( + <div class="login-card"> + <p class="status-needs-login">◌ L0G1N 1N PR0GRESS</p> + {session?.url ? ( + <> + <p> + ▶{' '} + <a href={session.url} target="_blank" rel="noreferrer"> + {session.url} + </a> + </p> + <p class="meta"> + open this URL in a browser, complete the OAuth flow, paste the resulting code below. + </p> + </> + ) : ( + <p class="meta">waiting for claude to emit an OAuth URL on stdout… (output below)</p> + )} + {!finished ? ( + <form class="loginform" onSubmit={submitCode}> + <input + name="code" + type={reveal ? 'text' : 'password'} + placeholder="paste OAuth code here (hidden)" + required + autocomplete="one-time-code" + spellcheck={false} + value={code} + onInput={(e) => setCode((e.target as HTMLInputElement).value)} + /> + <button + type="button" + class="loginform-reveal" + title="show / hide pasted code" + aria-label="show / hide pasted OAuth code" + aria-pressed={reveal} + onClick={() => setReveal((r) => !r)} + > + 👁 + </button> + <button type="submit" class="btn btn-login" disabled={busy}> + ◆ S3ND C0DE + </button> + </form> + ) : null} + <form style={{ marginTop: '0.4em' }} onSubmit={cancel}> + <button type="submit" class="btn btn-cancel" disabled={busy}> + cancel + kill + </button> + </form> + {finished ? ( + <p class="status-needs-login"> + claude process exited: {session?.exit_note || 'exited'}. Start over if needed. + </p> + ) : null} + {error ? <p class="meta">error: {error}</p> : null} + <h3>output</h3> + <pre class="diff">{session?.output || ''}</pre> + </div> + ); +} + +export function LoginFlow({ + status, + session, + onRefresh, +}: { + status: 'needs_login_idle' | 'needs_login_in_progress'; + session: SessionView | null; + onRefresh: () => void; +}) { + return ( + <div class="agent-status-overlay"> + {status === 'needs_login_idle' ? ( + <LoginIdle onStarted={onRefresh} /> + ) : ( + <LoginInProgress session={session} onDone={onRefresh} /> + )} + </div> + ); +} diff --git a/frontend/packages/agent/src/components/MetaNav.css b/frontend/packages/agent/src/components/MetaNav.css new file mode 100644 index 00000000..3c957fa7 --- /dev/null +++ b/frontend/packages/agent/src/components/MetaNav.css @@ -0,0 +1,42 @@ +/* <MetaNav> popover — matches `@hive/shared`'s `Dropdown` (`.ui-dropdown`) + values exactly (background/border/radius/shadow, item padding/hover), + same "reuse the values, not the component" call `.login-card` + (LoginFlow.css) already made, for the same reason: this popover's + items are real `<a>` tags, not `Dropdown`'s command-dispatch + `<button>`s. Right-anchored, same as OverflowMenu.css's + `.header-overflow-anchor` — MetaNav sits in the same right-hand + pills cluster, so a left anchor would push it off-screen the same + way. */ +.meta-nav-anchor { + position: relative; + display: inline-block; +} +.meta-nav-popover { + position: absolute; + top: calc(100% + 0.25em); + right: 0; + left: auto; + z-index: 20; + display: flex; + flex-direction: column; + min-width: 10em; + padding: 0.25em; + background: var(--bg-elev); + border: 1px solid var(--border); + border-radius: 0.5em; + box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35); +} +.meta-nav-item { + display: flex; + align-items: center; + gap: 0.5em; + min-height: 2.75em; + padding: 0.4em 0.7em; + border-radius: 0.35em; + color: var(--fg); + text-decoration: none; + white-space: nowrap; +} +.meta-nav-item:hover { + background: var(--border); +} diff --git a/frontend/packages/agent/src/components/MetaNav.tsx b/frontend/packages/agent/src/components/MetaNav.tsx new file mode 100644 index 00000000..3792493a --- /dev/null +++ b/frontend/packages/agent/src/components/MetaNav.tsx @@ -0,0 +1,91 @@ +// <MetaNav> — the header's meta-nav trigger: a single fixed-size Badge +// ("🔗") in the pills cluster that opens a popover listing +// stats/screen/forge/config + any `hyperhive.dashboardLinks` extras, +// sourced from the backend's `agent_links()` (the single source of +// truth — same list also feeds the dashboard card's icon strip). +// +// v1 of this rendered every link inline in the header's title row — +// mara, live: "the login card looks like it is behind the header" +// (a variable-width link list can grow the title row past the fixed +// `--agent-header-h` the rest of the page is offset against — see +// Header.tsx's comment) and "the links should have the same popout as +// the links in the nav bar of swarm-ui." This is that: same shape as +// swarm-ui's own `LinksMenu` (Shell/LinksMenu.tsx) — one quiet icon +// trigger, a popover on click, real `<a>` items (not a command-dispatch +// button list) so ctrl/middle-click and "copy link address" keep +// working. Trigger itself reuses `@hive/shared`'s `Badge` (icon-only, +// same shape as the header's `⋯` overflow trigger — same touch target, +// same hover/expanded treatment, one visual language for every header +// trigger) rather than a bespoke button. The popover isn't built from +// `@hive/shared`'s `Dropdown` — its items are always `<button>`s +// (command dispatch), which would lose real link semantics — but +// matches its `.ui-dropdown` visual values exactly (see MetaNav.css), +// the same "reuse the values, not the component" call `LoginFlow`'s +// `.login-card` already made for the same reason. +import { useEffect, useRef, useState } from 'preact/hooks'; +import { Badge } from '@hive/shared/badge.js'; +import type { AgentLink } from '../types.js'; +import './MetaNav.css'; + +export interface MetaNavProps { + links: AgentLink[]; + forgePublicUrl: string | null; +} + +export function MetaNav({ links, forgePublicUrl }: MetaNavProps) { + const [open, setOpen] = useState(false); + const rootRef = useRef<HTMLDivElement>(null); + + // Same "outside click or Escape closes" contract as Dropdown/OverflowMenu. + useEffect(() => { + if (!open) return; + function onPointerDown(e: PointerEvent) { + if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false); + } + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false); + } + document.addEventListener('pointerdown', onPointerDown, true); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('pointerdown', onPointerDown, true); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + // Same kind -> URL resolution as app.js's old refreshState meta-links + // loop: `forge` needs `forgePublicUrl` set or the link is dropped + // entirely (never guessed from `<host>:3000`); `external` is already + // absolute; `container` is a same-origin path. + const visible = links.filter((lnk) => lnk.kind !== 'forge' || forgePublicUrl); + if (visible.length === 0) return null; + + return ( + <div class="meta-nav-anchor" ref={rootRef}> + <Badge value="🔗" title="agent links" onClick={() => setOpen((o) => !o)} expanded={open} /> + {open ? ( + <div class="meta-nav-popover" role="menu" aria-label="agent links"> + {visible.map((lnk) => { + const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url; + return ( + <a + key={lnk.url} + class="meta-nav-item" + href={href} + target="_blank" + rel="noopener" + role="menuitem" + onClick={() => setOpen(false)} + > + {lnk.icon ? ( + <span aria-hidden="true">{lnk.icon}</span> + ) : null} + {lnk.label} + </a> + ); + })} + </div> + ) : null} + </div> + ); +} diff --git a/frontend/packages/agent/src/components/OverflowMenu.css b/frontend/packages/agent/src/components/OverflowMenu.css new file mode 100644 index 00000000..987f14ae --- /dev/null +++ b/frontend/packages/agent/src/components/OverflowMenu.css @@ -0,0 +1,19 @@ +/* Anchors <Dropdown> under the "⋯" trigger — same positioning + contract as StatusChips.css's `.status-chip-anchor`, kept as its own + rule here rather than reused across components (see this file's + sibling .tsx comment) so OverflowMenu doesn't depend on StatusChips' + incidental CSS. */ +.header-overflow-anchor { + position: relative; + display: inline-block; +} +/* @hive/shared's Dropdown.css anchors left-edge-to-left-edge, correct + for a picker with room to its right (StatusChips' model/effort). The + overflow trigger is the header's right-most element instead, so a + left anchor pushes the menu off-screen — flip to right-edge-to- + right-edge here, scoped to this one consumer rather than changing + the shared default (which is still correct for every other caller). */ +.header-overflow-anchor .ui-dropdown { + left: auto; + right: 0; +} diff --git a/frontend/packages/agent/src/components/OverflowMenu.tsx b/frontend/packages/agent/src/components/OverflowMenu.tsx new file mode 100644 index 00000000..d698cb53 --- /dev/null +++ b/frontend/packages/agent/src/components/OverflowMenu.tsx @@ -0,0 +1,66 @@ +// <OverflowMenu> — the header's `⋯` trigger. Deliberately trimmed vs. +// app.js's `populateOverflowMenu`: that one held dashboard-link/rebuild/ +// new-session/logout/model-picker/effort-picker all in one flat list — +// the design guide's own named anti-example for a junk drawer. The +// model/effort pickers already moved to StatusChips' own Badge+Dropdown +// controls (this rewrite's first commit); new-session/logout now live +// as TermInput slash commands (typed, not menu-clicked, and already +// have their own two-step confirm). What's left here — rebuild + a +// dashboard back-link — are genuinely menu-shaped (rare, not tied to +// any other visible control), so they're still a `⋯` menu, just a much +// shorter one. +// +// Reuses `@hive/shared`'s `Badge`+`Dropdown` — same anchored-popover +// pair StatusChips' model/effort pickers use, not a bespoke popover. +// Rebuild's confirm is the same "select once to arm, select again to +// fire" pattern as TermInput's `/new-session`/`/logout` — adapted here +// to a menu click instead of a typed repeat, but the same "requires +// deliberate repetition, not a modal" idea. +import { useState } from 'preact/hooks'; +import { Badge } from '@hive/shared/badge.js'; +import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js'; +import { submitRebuild } from '../lib/rebuildAction.js'; +import './OverflowMenu.css'; + +export interface OverflowMenuProps { + label: string; + dashboardBase: string; +} + +export function OverflowMenu({ label, dashboardBase }: OverflowMenuProps) { + const [open, setOpen] = useState(false); + const [armed, setArmed] = useState(false); + + function close() { + setOpen(false); + setArmed(false); + } + + const options: DropdownOption[] = [ + { value: 'dashboard', label: '↑ dashboard' }, + { value: 'rebuild', label: armed ? '↻ rebuild — click again to confirm' : '↻ rebuild container' }, + ]; + + function onSelect(value: string) { + if (value === 'dashboard') { + window.open(`${dashboardBase}dashboard.html`, '_blank', 'noopener'); + close(); + return; + } + if (value === 'rebuild') { + if (armed) { + submitRebuild(dashboardBase, label); + close(); + } else { + setArmed(true); + } + } + } + + return ( + <div class="header-overflow-anchor"> + <Badge value="⋯" title="more actions" onClick={() => setOpen((o) => !o)} expanded={open} /> + <Dropdown open={open} options={options} label="more actions" onSelect={onSelect} onClose={close} /> + </div> + ); +} diff --git a/frontend/packages/agent/src/components/Row.tsx b/frontend/packages/agent/src/components/Row.tsx new file mode 100644 index 00000000..2f2c9900 --- /dev/null +++ b/frontend/packages/agent/src/components/Row.tsx @@ -0,0 +1,71 @@ +// Renders one `StreamRow` — flat `<div class="row …">` or expandable +// `<details class="row …">`, matching @hive/shared/terminal.css's +// existing row-kind classes exactly (see docs/terminal-rendering.md). +// Reuses that stylesheet as-is (imported once by LiveStream.tsx) — the +// taxonomy's visual language isn't what mara asked to change, the +// component *model* underneath it is. +import { useEffect, useRef } from 'preact/hooks'; +import type { StreamRow } from '../lib/streamRow.js'; +import { linkifyToNodes } from '../lib/linkify.js'; +import { renderMarkdown } from '../lib/markdown.js'; + +function MarkdownBody({ text }: { text: string }) { + const ref = useRef<HTMLDivElement>(null); + const html = renderMarkdown(text); + useEffect(() => { + // marked autolinks URLs but leaves them same-tab — open externally + // so a click never navigates the terminal away. + ref.current?.querySelectorAll('a[href]').forEach((a) => { + a.setAttribute('target', '_blank'); + a.setAttribute('rel', 'noopener noreferrer'); + }); + }, [html]); + // eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown + return <div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />; +} + +function DiffBody({ text }: { text: string }) { + const lines = String(text).split('\n'); + return ( + <pre className="tool-body diff-body"> + {lines.map((line, i) => { + const cls = line.startsWith('+ ') ? 'diff-add' : line.startsWith('- ') ? 'diff-del' : 'diff-ctx'; + return ( + <span key={i} className={cls}> + {line} + {'\n'} + </span> + ); + })} + </pre> + ); +} + +export function Row({ row }: { row: StreamRow }) { + if (row.details) { + return ( + <details className={`row ${row.cssClass}`} open={row.defaultOpen || undefined}> + <summary> + {row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>} + <span className="summary-text">{row.text}</span> + </summary> + {row.diffBody != null && <DiffBody text={row.diffBody} />} + {row.markdownBody != null && <MarkdownBody text={row.markdownBody} />} + {row.plainBody != null && <pre className="tool-body">{linkifyToNodes(row.plainBody)}</pre>} + </details> + ); + } + return ( + <div className={`row ${row.cssClass}`}> + {row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>} + {row.text != null && linkifyToNodes(row.text)} + {row.meta?.map((m) => ( + <span key={m.cls} className={m.cls}> + {m.text} + </span> + ))} + {row.childText != null && <div className={row.childText.cls}>{row.childText.text}</div>} + {row.markdownBody != null && <MarkdownBody text={row.markdownBody} />} + </div> + ); +} diff --git a/frontend/packages/agent/src/components/SidePanel.css b/frontend/packages/agent/src/components/SidePanel.css new file mode 100644 index 00000000..cbf84d57 --- /dev/null +++ b/frontend/packages/agent/src/components/SidePanel.css @@ -0,0 +1,80 @@ +/* Drawer chrome for <SidePanel> — backdrop, drawer box, header, close + button. Content styling (.agent-inbox, .inbox-*, .side-panel-empty) + already ships globally via agent.css; the two rules at the bottom + here re-target agent.css's old `hive-side-panel .agent-inbox …` + flatten-when-inside-a-panel overrides at this component's own class + instead, since this is a plain Preact tree, not a custom element. */ +.agent-side-panel-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 60; + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; +} +.agent-side-panel-backdrop.open { + opacity: 1; + pointer-events: auto; +} +.agent-side-panel-drawer { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(26em, 96vw); + background: var(--bg-elev); + border-left: 1px solid var(--border); + box-shadow: -8px 0 24px rgba(0, 0, 0, 0.4); + z-index: 61; + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 200ms ease; +} +.agent-side-panel-drawer.open { + transform: translateX(0); +} +.agent-side-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6em 0.8em; + border-bottom: 1px solid var(--border); + color: var(--fg); +} +.agent-side-panel-title { + font-weight: bold; + letter-spacing: 0.03em; +} +.agent-side-panel-close { + background: none; + border: 0; + color: var(--muted); + font-size: 1.1em; + line-height: 1; + cursor: pointer; + padding: 0.3em 0.5em; + min-width: 2.75em; + min-height: 2.75em; +} +.agent-side-panel-close:hover { + color: var(--fg); +} +.agent-side-panel-body { + flex: 1; + overflow-y: auto; + padding: 0.8em; +} +.agent-side-panel-body .agent-inbox { + margin: 0; + font-size: inherit; + color: var(--fg); +} +.agent-side-panel-body .agent-inbox ul { + background: transparent; + border-left: 0; + padding: 0; + max-height: none; + overflow: visible; +} diff --git a/frontend/packages/agent/src/components/SidePanel.tsx b/frontend/packages/agent/src/components/SidePanel.tsx new file mode 100644 index 00000000..3f7ec327 --- /dev/null +++ b/frontend/packages/agent/src/components/SidePanel.tsx @@ -0,0 +1,55 @@ +// <SidePanel> — the slide-in drawer for the inbox/todos flyouts. +// Deliberately a new Preact component, not the shared +// `<hive-side-panel>` shadow-DOM custom element (@hive/shared/ +// side-panel.js) — same rationale as StatusChips' Badge/Dropdown: +// mara's ask was real Preact components for this rewrite, not a port +// of the old widget family. Drops that element's drag-to-resize + +// localStorage width persistence for this first slice (fixed width via +// CSS, see SidePanel.css) — a real simplification, not an oversight; +// revisit if a fixed width proves cramped in practice. +// +// Content styling (`.agent-inbox`, `.inbox-*`, `.side-panel-empty`) +// already ships globally via `agent.css` and is reused verbatim by +// InboxPanel/TodosPanel — only the drawer chrome itself is new here. +import { useEffect } from 'preact/hooks'; +import type { ComponentChildren } from 'preact'; +import './SidePanel.css'; + +export interface SidePanelProps { + open: boolean; + title: string; + onClose: () => void; + children?: ComponentChildren; +} + +export function SidePanel({ open, title, onClose, children }: SidePanelProps) { + useEffect(() => { + if (!open) return; + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open, onClose]); + + return ( + <> + <div class={`agent-side-panel-backdrop${open ? ' open' : ''}`} onClick={onClose} aria-hidden="true" /> + <aside + class={`agent-side-panel-drawer${open ? ' open' : ''}`} + role="dialog" + aria-modal="true" + aria-hidden={!open} + aria-label={title} + > + <div class="agent-side-panel-head"> + <span class="agent-side-panel-title">{title}</span> + <button type="button" class="agent-side-panel-close" title="close (esc)" onClick={onClose}> + ✕ + </button> + </div> + <div class="agent-side-panel-body">{children}</div> + </aside> + </> + ); +} diff --git a/frontend/packages/agent/src/components/StatusChips.css b/frontend/packages/agent/src/components/StatusChips.css new file mode 100644 index 00000000..1b678f99 --- /dev/null +++ b/frontend/packages/agent/src/components/StatusChips.css @@ -0,0 +1,31 @@ +/* <StatusChips> layout. Colours all come from Badge/Dropdown's own CSS + (@hive/shared) — nothing here overrides a base16 slot. */ +.status-chips { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em; +} +.status-chip-anchor { + position: relative; + display: inline-block; +} +/* mara, live, on a screenshot of this row: "badges and interactive + elements on same row have inconsistent size." Real cause: Badge.css's + `.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor + (model/effort pickers, pause) while a plain display `<span>` badge + (alive, state, ctx, cost — no `onClick`) stays compact, so the row + reads as two different pill sizes even though every badge here is + the same `<Badge>` component. Scoped to this row rather than + Badge.css itself — display-only badges elsewhere (e.g. the dashboard) + have no reason to grow, this is specifically about badges and + buttons sitting side by side in one row needing to read as one + family. */ +.status-chips .ui-badge { + min-height: 2.75em; + padding-inline: 0.8em; +} +.status-chips-last-turn { + font-size: 0.8em; + color: var(--muted); +} diff --git a/frontend/packages/agent/src/components/StatusChips.tsx b/frontend/packages/agent/src/components/StatusChips.tsx new file mode 100644 index 00000000..72ebe367 --- /dev/null +++ b/frontend/packages/agent/src/components/StatusChips.tsx @@ -0,0 +1,146 @@ +// <StatusChips> — the agent page's status row: alive/turn-state, the +// model + effort pickers, context/cost usage, and pause/resume. This is +// the concrete fix for the design guide's own named anti-example (the +// old page's model/effort *pickers* lived in the `⋯` overflow menu while +// the current model/effort only showed as a disconnected chip) — every +// badge here IS the control for what it displays, composed from +// `@hive/shared`'s `Badge`/`Dropdown` (../../shared/src/badge, +// ../../shared/src/dropdown). Presentational only: formatting + +// selection state live in the caller (`Root.tsx`, via the +// `useAgentState` hook), so this component can still be demoed and +// reviewed against plain sample data independent of live `/api/state`. +import { useState } from 'preact/hooks'; +import { Badge, type BadgeTone } from '@hive/shared/badge.js'; +import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js'; +import './StatusChips.css'; + +export interface StatusChipsProps { + aliveLabel: string; + aliveTone: BadgeTone; + stateLabel: string; + stateTone: BadgeTone; + stateTooltip?: string; + model: string; + availableModels: string[]; + onSelectModel: (name: string) => void; + effort: string; + availableEfforts: string[]; + onSelectEffort: (level: string) => void; + ctxLabel?: string; + costLabel?: string; + paused: boolean; + onTogglePause: () => void; + lastTurnLabel?: string; +} + +const MODEL_DESCRIPTIONS: Record<string, string> = { + haiku: 'fast', + sonnet: 'balanced', + opus: 'powerful', +}; +const EFFORT_DESCRIPTIONS: Record<string, string> = { + low: '', + medium: 'default', + high: '', + xhigh: '', + max: '', +}; + +function Picker({ + label, + value, + options, + descriptions, + onSelect, +}: { + label: string; + value: string; + options: string[]; + descriptions: Record<string, string>; + onSelect: (v: string) => void; +}) { + const [open, setOpen] = useState(false); + const dropdownOptions: DropdownOption[] = options.map((name) => ({ + value: name, + label: name, + description: descriptions[name] || undefined, + })); + return ( + <div class="status-chip-anchor"> + <Badge label={label} value={value} onClick={() => setOpen((o) => !o)} expanded={open} /> + <Dropdown + open={open} + options={dropdownOptions} + activeValue={value} + label={`select ${label}`} + onSelect={(v) => { + onSelect(v); + setOpen(false); + }} + onClose={() => setOpen(false)} + /> + </div> + ); +} + +export function StatusChips({ + aliveLabel, + aliveTone, + stateLabel, + stateTone, + stateTooltip, + model, + availableModels, + onSelectModel, + effort, + availableEfforts, + onSelectEffort, + ctxLabel, + costLabel, + paused, + onTogglePause, + lastTurnLabel, +}: StatusChipsProps) { + return ( + <div class="status-chips"> + <Badge value={aliveLabel} tone={aliveTone} /> + <Badge value={stateLabel} tone={stateTone} title={stateTooltip} /> + <Picker + label="model" + value={model} + options={availableModels} + descriptions={MODEL_DESCRIPTIONS} + onSelect={onSelectModel} + /> + {availableEfforts.length ? ( + <Picker + label="effort" + value={effort} + options={availableEfforts} + descriptions={EFFORT_DESCRIPTIONS} + onSelect={onSelectEffort} + /> + ) : null} + {ctxLabel ? ( + <Badge + label="ctx" + value={ctxLabel} + title="tokens used in the current context window" + /> + ) : null} + {costLabel ? ( + <Badge + label="cost" + value={costLabel} + title="cumulative tokens billed across the last turn (sum across every inference; tool-heavy turns rebill the cached prompt per call)" + /> + ) : null} + <Badge + value={paused ? '▶ resume' : '⏸ pause'} + tone={paused ? 'warning' : 'neutral'} + onClick={onTogglePause} + /> + {lastTurnLabel ? <span class="status-chips-last-turn">{lastTurnLabel}</span> : null} + </div> + ); +} diff --git a/frontend/packages/agent/src/components/TermInput.tsx b/frontend/packages/agent/src/components/TermInput.tsx new file mode 100644 index 00000000..823817db --- /dev/null +++ b/frontend/packages/agent/src/components/TermInput.tsx @@ -0,0 +1,171 @@ +// <TermInput> — the fixed composer at the bottom of the page. Sends a +// plain message to this agent's own turn loop (`POST send`), or +// intercepts a `/slash` command locally (never sent to the agent). +// Ported from app.js's `renderTermInput`/`handleSlashCommand`/ +// `completeSlash`, with one deliberate UX change: `/new-session` and +// `/logout` used to pop the old shadow-DOM `themedConfirm` modal +// (@hive/shared/modal.js) before firing — this rewrite's destructive +// actions all avoid that widget family (see SidePanel.tsx's/ +// useConfirmClick.ts's file comments for why), and a modal doesn't fit +// a text-input flow anyway. Instead: typing the command once arms it +// (a local note explains what confirming does), typing it again within +// the same "armed" state fires it — a keyboard-native two-step confirm. +import { useRef, useState } from 'preact/hooks'; +import { postModel, postEffort } from '../lib/modelEffort.js'; +import { postCancelTurn, postCompact, postNewSession, postLogout, postSend } from '../lib/termActions.js'; + +export interface TermInputProps { + label: string; + online: boolean; + onLocalNote: (text: string) => void; + onClear: () => void; +} + +const SLASH_COMMANDS: { name: string; desc: string }[] = [ + { name: '/help', desc: 'list slash commands' }, + { name: '/clear', desc: 'wipe the terminal panel (local-only)' }, + { name: '/cancel', desc: 'SIGINT the in-flight claude turn' }, + { name: '/compact', desc: 'compact the persistent claude session' }, + { name: '/model', desc: '/model <name> — switch claude model for future turns' }, + { name: '/effort', desc: '/effort <level> — set claude effort (medium/high/xhigh) for future turns' }, + { name: '/new-session', desc: 'fresh claude session next turn (type twice to confirm)' }, + { name: '/logout', desc: 'rotate OAuth creds + park in needs-login (type twice to confirm)' }, +]; + +const MAX_PX = 12 * 16; // ~8 lines @ 1.5 line-height, 1em base + +function completeSlash(prefix: string): string | null { + const matches = SLASH_COMMANDS.filter((c) => c.name.startsWith(prefix)); + if (!matches.length) return null; + const idx = matches.findIndex((c) => c.name === prefix); + return matches[(idx + 1) % matches.length].name; +} + +export function TermInput({ label, online, onLocalNote, onClear }: TermInputProps) { + const [value, setValue] = useState(''); + const taRef = useRef<HTMLTextAreaElement>(null); + // Which destructive command is currently "armed" (typed once, waiting + // for the confirming repeat) — any other command clears it. + const armedRef = useRef<string | null>(null); + + function grow() { + const ta = taRef.current; + if (!ta) return; + ta.style.height = 'auto'; + ta.style.height = Math.min(ta.scrollHeight, MAX_PX) + 'px'; + } + + function reportFailure(label_: string, detail?: string) { + onLocalNote(`✗ ${label_} failed${detail ? ': ' + detail : ''}`); + } + + function handleSlash(line: string): boolean { + const trimmed = line.trim(); + const [cmd, ...rest] = trimmed.split(/\s+/); + const arg = rest.join(' '); + const wasArmed = armedRef.current === cmd; + armedRef.current = null; + + switch (cmd) { + case '/help': + onLocalNote('/help'); + for (const c of SLASH_COMMANDS) onLocalNote(` ${c.name.padEnd(13)} — ${c.desc}`); + return true; + case '/clear': + onClear(); + onLocalNote('· terminal cleared (local view only — server history kept)'); + return true; + case '/cancel': + postCancelTurn().then((r) => !r.ok && reportFailure('/cancel', r.detail)); + return true; + case '/compact': + postCompact().then((r) => !r.ok && reportFailure('/compact', r.detail)); + return true; + case '/new-session': + if (wasArmed) { + postNewSession().then((r) => !r.ok && reportFailure('/new-session', r.detail)); + } else { + armedRef.current = '/new-session'; + onLocalNote('⚠ drops all prior --continue context. type /new-session again to confirm.'); + } + return true; + case '/logout': + if (wasArmed) { + postLogout().then((r) => !r.ok && reportFailure('/logout', r.detail)); + } else { + armedRef.current = '/logout'; + onLocalNote('⚠ SIGINTs the current turn + rotates OAuth credentials. type /logout again to confirm.'); + } + return true; + case '/model': + if (!arg) onLocalNote('✗ /model needs a name (e.g. /model haiku, /model sonnet, /model opus)'); + else postModel(arg).then((r) => !r.ok && reportFailure('/model', r.detail)); + return true; + case '/effort': + if (!arg) onLocalNote('✗ /effort needs a level (e.g. /effort medium, /effort high, /effort xhigh)'); + else postEffort(arg).then((r) => !r.ok && reportFailure('/effort', r.detail)); + return true; + default: + onLocalNote(`✗ unknown slash command: ${cmd} — try /help`); + return true; + } + } + + function submit() { + const line = value; + if (!line.trim()) return; + setValue(''); + requestAnimationFrame(grow); + if (line.trim().startsWith('/')) { + handleSlash(line); + return; + } + armedRef.current = null; + postSend(line).then((r) => !r.ok && reportFailure('send', r.detail)); + } + + function onInput(e: Event) { + setValue((e.currentTarget as HTMLTextAreaElement).value); + grow(); + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Tab' && value.startsWith('/') && !value.includes(' ')) { + const next = completeSlash(value); + if (next) { + e.preventDefault(); + setValue(next); + } + return; + } + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { + e.preventDefault(); + submit(); + } + } + + return ( + <div class={`term-input${online ? '' : ' disabled'}`}> + <form + class="sendform-term" + onSubmit={(e) => { + e.preventDefault(); + submit(); + }} + > + <span class="prompt">operator@{label} ▸</span> + <textarea + ref={taRef} + value={value} + placeholder={`message ${label}…`} + rows={1} + disabled={!online} + autocomplete="off" + onInput={onInput} + onKeyDown={onKeyDown} + /> + <span class="submit-hint">↵ send · ⇧↵ newline · /help</span> + </form> + </div> + ); +} diff --git a/frontend/packages/agent/src/components/TodosPanel.tsx b/frontend/packages/agent/src/components/TodosPanel.tsx new file mode 100644 index 00000000..415fab96 --- /dev/null +++ b/frontend/packages/agent/src/components/TodosPanel.tsx @@ -0,0 +1,104 @@ +// <TodosPanel> — content rendered inside <SidePanel> for the todos +// flyout. "mark done" POSTs to this agent's own backend +// (`api/todos/mark-done`, same-origin), unlike InboxPanel's cross-agent +// mark-all-read. +import { useState } from 'preact/hooks'; +import type { TodoRow } from '../types.js'; + +export interface TodosPanelProps { + todos: TodoRow[]; + onCleared: () => void; +} + +function fmtAge(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + return `${Math.floor(seconds / 86400)}d`; +} + +export function TodosPanel({ todos, onCleared }: TodosPanelProps) { + const [checked, setChecked] = useState<Set<number>>(new Set()); + const [status, setStatus] = useState(''); + const [busy, setBusy] = useState(false); + + function toggle(id: number) { + setChecked((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + async function markDone() { + const ids = [...checked]; + if (!ids.length) return; + setBusy(true); + setStatus('marking…'); + try { + const resp = await fetch('api/todos/mark-done', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `ids=${encodeURIComponent(ids.join(','))}`, + }); + if (resp.ok) { + setStatus('✓ marked done'); + setChecked(new Set()); + onCleared(); + } else { + setStatus(`failed: ${await resp.text()}`); + } + } catch (err) { + setStatus(`failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setBusy(false); + } + } + + if (!todos.length) { + return <p class="side-panel-empty">no todos — all subsystem queues are clear.</p>; + } + + const allChecked = todos.length > 0 && todos.every((t) => checked.has(t.id)); + + return ( + <div class="agent-inbox"> + <div class="inbox-mark-all-row"> + <button + type="button" + class="inbox-mark-all-btn" + onClick={() => setChecked(allChecked ? new Set() : new Set(todos.map((t) => t.id)))} + > + {allChecked ? 'select none' : 'select all'} + </button> + <button type="button" class="inbox-mark-all-btn" onClick={markDone} disabled={busy || checked.size === 0}> + ✓ mark done + </button> + <span class="inbox-mark-status">{status}</span> + </div> + <ul> + {todos.map((t) => { + const cbId = `todo-cb-${t.id}`; + const label = t.source ? `${t.subsystem} · ${t.source}` : t.subsystem; + return ( + <li key={t.id}> + <input + type="checkbox" + id={cbId} + class="todo-cb" + checked={checked.has(t.id)} + onChange={() => toggle(t.id)} + />{' '} + <label for={cbId} class="inbox-from"> + {label} + </label>{' '} + <span class="inbox-ts">{fmtAge(t.age_seconds)} ago</span> + <div class="inbox-body">{t.summary}</div> + </li> + ); + })} + </ul> + </div> + ); +} diff --git a/frontend/packages/agent/src/css.d.ts b/frontend/packages/agent/src/css.d.ts new file mode 100644 index 00000000..8597bb2b --- /dev/null +++ b/frontend/packages/agent/src/css.d.ts @@ -0,0 +1,4 @@ +// Ambient module for `import './Foo.css'` side-effect imports (esbuild +// resolves these directly, see build.mjs; tsc otherwise has no idea what +// a `.css` specifier is and refuses the whole side-effect import). +declare module '*.css'; diff --git a/frontend/packages/agent/src/hooks/useAgentState.ts b/frontend/packages/agent/src/hooks/useAgentState.ts new file mode 100644 index 00000000..99e08b57 --- /dev/null +++ b/frontend/packages/agent/src/hooks/useAgentState.ts @@ -0,0 +1,78 @@ +// useAgentState — polls `GET /api/state` and exposes the latest +// snapshot + loading/error status. Mirrors app.js's old `refreshState` +// data fetch (not yet its exact re-poll cadence — see the interval +// comment below). +// +// Cadence note: the old page only re-polls on a timer while a login is +// in flight, and otherwise waits for an SSE `turn_end` event to trigger +// one-shot refreshes — this avoids clobbering the operator's half-typed +// message in the term-input field. That field doesn't exist in this +// rewrite yet (a later commit on this same PR), so there's nothing to +// clobber yet; this hook uses a flat interval for now and switches to +// the SSE-triggered model in the commit that adds TermInput + the live +// stream, matching the original behavior once it's actually needed. +import { useEffect, useRef, useState } from 'preact/hooks'; +import type { AgentState } from '../types.js'; + +const POLL_MS = 4000; +const RETRY_MS = 5000; + +export interface UseAgentStateResult { + state: AgentState | null; + error: string | null; + /** Force an immediate refresh (e.g. right after a model/effort POST). */ + refresh: () => void; +} + +export function useAgentState(): UseAgentStateResult { + const [state, setState] = useState<AgentState | null>(null); + const [error, setError] = useState<string | null>(null); + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const stoppedRef = useRef(false); + + useEffect(() => { + stoppedRef.current = false; + + async function poll() { + try { + const resp = await fetch('api/state'); + if (!resp.ok) throw new Error(`http ${resp.status}`); + const s = (await resp.json()) as AgentState; + if (stoppedRef.current) return; + setState(s); + setError(null); + timerRef.current = setTimeout(poll, POLL_MS); + } catch (err) { + if (stoppedRef.current) return; + setError(err instanceof Error ? err.message : String(err)); + timerRef.current = setTimeout(poll, RETRY_MS); + } + } + poll(); + + return () => { + stoppedRef.current = true; + if (timerRef.current) clearTimeout(timerRef.current); + }; + }, []); + + function refresh() { + if (timerRef.current) clearTimeout(timerRef.current); + // Re-trigger the same poll loop immediately; the effect's closure + // owns `poll`, so the simplest safe re-trigger from outside it is a + // fresh fetch here rather than reaching back into the effect. + stoppedRef.current = false; + fetch('api/state') + .then((resp) => { + if (!resp.ok) throw new Error(`http ${resp.status}`); + return resp.json(); + }) + .then((s: AgentState) => { + setState(s); + setError(null); + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + } + + return { state, error, refresh }; +} diff --git a/frontend/packages/agent/src/hooks/useLiveStream.ts b/frontend/packages/agent/src/hooks/useLiveStream.ts new file mode 100644 index 00000000..fbdab816 --- /dev/null +++ b/frontend/packages/agent/src/hooks/useLiveStream.ts @@ -0,0 +1,199 @@ +// Backfill + live SSE for the agent's event stream, reduced to a plain +// `StreamRow[]` — the Preact-data sibling of +// @hive/shared/terminal.js's `create()`. Scroll behaviour is +// deliberately NOT this hook's job (see components/LiveStream.tsx): +// mara flagged the old page's scroll-while-streaming bug explicitly as +// something not to copy 1:1, and keeping "what rows exist" separate +// from "where the viewport is" is what makes that fixable — this hook +// only ever appends/prepends to `rows`, the DOM-owning component +// decides whether that should move the scroll position. +// +// Same subscribe → buffer → fetch-history → seq-dedupe → flush dance as +// terminal.js's `start()`: a live event landing between EventSource-open +// and the history response resolving is buffered, not dropped or +// double-counted (seq <= history.seq AND the event's kind appeared in +// the history replay → already covered, drop it from the buffer). +import { useEffect, useRef, useState } from 'preact/hooks'; +import { classifyEvent, createClassifyCtx, type ClassifyCtx } from '../lib/classifyEvent.js'; +import type { StreamRow } from '../lib/streamRow.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- raw SSE payload, dynamically shaped +type AnyEvent = any; + +export interface UseLiveStreamOptions { + historyUrl?: string; + streamUrl?: string; + /** Fired on every LIVE (not history-replay) turn_start/turn_end, so the + * caller can trigger a snappier `/api/state` refresh than the plain + * poll interval — mirrors app.js's turn_end → refreshState()/ + * refreshTodos(). */ + onLiveTurnBoundary?: () => void; +} + +export interface UseLiveStreamResult { + rows: StreamRow[]; + hasMore: boolean; + loadingMore: boolean; + loadMore: () => void; + /** Append a local-only note row (TermInput's `/help` output, error + * feedback on a failed slash-command POST) — never sent anywhere, + * purely a client-side echo. */ + pushLocalNote: (text: string) => void; + /** Wipe the local view only (server-side history is untouched) — + * TermInput's `/clear`. */ + clearLocal: () => void; +} + +function appendRow(rows: StreamRow[], row: StreamRow): StreamRow[] { + const last = rows[rows.length - 1]; + // Coalesce in place only while the coalescible row is still the last + // one in the list — any other row landing in between starts a fresh + // one, same rule as terminal.js's makeCoalescer. + if (row.coalesceKey && last && last.coalesceKey === row.coalesceKey) { + const next = rows.slice(0, -1); + next.push({ ...row, key: last.key }); + return next; + } + return [...rows, row]; +} + +function appendMany(rows: StreamRow[], newRows: StreamRow[]): StreamRow[] { + let next = rows; + for (const r of newRows) next = appendRow(next, r); + return next; +} + +export function useLiveStream(opts: UseLiveStreamOptions = {}): UseLiveStreamResult { + const historyUrl = opts.historyUrl ?? 'events/history'; + const streamUrl = opts.streamUrl ?? 'events/stream'; + + const [rows, setRows] = useState<StreamRow[]>([]); + const [hasMore, setHasMore] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + + const ctxRef = useRef<ClassifyCtx | null>(null); + if (!ctxRef.current) ctxRef.current = createClassifyCtx(); + const minIdRef = useRef<number | null>(null); + const liveRef = useRef(false); + const bufferedRef = useRef<AnyEvent[]>([]); + const onBoundaryRef = useRef(opts.onLiveTurnBoundary); + onBoundaryRef.current = opts.onLiveTurnBoundary; + + useEffect(() => { + let cancelled = false; + + function pushLive(ev: AnyEvent) { + const newRows = classifyEvent(ev, false, ctxRef.current!); + if (newRows.length) setRows((prev) => appendMany(prev, newRows)); + if (ev.kind === 'turn_start' || ev.kind === 'turn_end') onBoundaryRef.current?.(); + } + + const es = new EventSource(streamUrl); + es.onmessage = (e) => { + let ev: AnyEvent; + try { + ev = JSON.parse(e.data); + } catch { + setRows((prev) => appendRow(prev, { + key: 'parse-err-' + Date.now(), cssClass: 'note', fromHistory: false, + text: '[parse err] ' + e.data, + })); + return; + } + if (!liveRef.current) { + bufferedRef.current.push(ev); + return; + } + pushLive(ev); + }; + es.onerror = () => { + setRows((prev) => appendRow(prev, { + key: 'conn-note', cssClass: 'note', fromHistory: false, coalesceKey: 'conn-status', + text: es.readyState === 0 /* CONNECTING */ ? '[reconnecting…]' : '[disconnected]', + })); + }; + + async function backfill() { + try { + const resp = await fetch(historyUrl); + if (!resp.ok) throw new Error('http ' + resp.status); + const body = await resp.json(); + const events: AnyEvent[] = Array.isArray(body) ? body : body.events || []; + const boundarySeq: number | null = Array.isArray(body) ? null : (body.seq ?? null); + if (!Array.isArray(body)) { + setHasMore(!!body.has_more); + if (typeof body.min_id === 'number') minIdRef.current = body.min_id; + } + const historyKinds = new Set(events.map((e) => e.kind)); + if (cancelled) return; + + let initial: StreamRow[] = []; + for (const ev of events) initial = appendMany(initial, classifyEvent(ev, true, ctxRef.current!)); + initial = events.length + ? appendRow(initial, { key: 'live-sep', cssClass: 'note', fromHistory: true, text: '─── live (older above) ───' }) + : [{ key: 'placeholder', cssClass: 'note', fromHistory: true, text: '(connected — waiting for events)' }]; + setRows(initial); + + const drained = bufferedRef.current; + bufferedRef.current = []; + liveRef.current = true; + for (const ev of drained) { + if (boundarySeq != null && typeof ev.seq === 'number' && ev.seq <= boundarySeq && historyKinds.has(ev.kind)) { + continue; + } + pushLive(ev); + } + } catch (err) { + console.warn('history backfill failed', err); + if (cancelled) return; + const drained = bufferedRef.current; + bufferedRef.current = []; + liveRef.current = true; + for (const ev of drained) pushLive(ev); + } + } + backfill(); + + return () => { + cancelled = true; + es.close(); + }; + }, [historyUrl, streamUrl]); + + async function loadMore() { + if (!hasMore || loadingMore || minIdRef.current == null) return; + setLoadingMore(true); + try { + const sep = historyUrl.includes('?') ? '&' : '?'; + const resp = await fetch(historyUrl + sep + 'before=' + minIdRef.current); + if (!resp.ok) return; + const body = await resp.json(); + const events: AnyEvent[] = Array.isArray(body) ? body : body.events || []; + setHasMore(!!body.has_more); + if (typeof body.min_id === 'number') minIdRef.current = body.min_id; + if (events.length) { + let older: StreamRow[] = []; + for (const ev of events) older = appendMany(older, classifyEvent(ev, true, ctxRef.current!)); + older = appendRow(older, { + key: 'older-sep-' + minIdRef.current, cssClass: 'note', fromHistory: true, text: '─── older above ───', + }); + setRows((prev) => [...older, ...prev]); + } + } catch (err) { + console.warn('loadMore failed', err); + } finally { + setLoadingMore(false); + } + } + + const localKeyRef = useRef(0); + function pushLocalNote(text: string) { + localKeyRef.current += 1; + setRows((prev) => appendRow(prev, { key: `local-${localKeyRef.current}`, cssClass: 'note', fromHistory: false, text })); + } + function clearLocal() { + setRows([]); + } + + return { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal }; +} diff --git a/frontend/packages/agent/src/hooks/useTodos.ts b/frontend/packages/agent/src/hooks/useTodos.ts new file mode 100644 index 00000000..6d0fc6e2 --- /dev/null +++ b/frontend/packages/agent/src/hooks/useTodos.ts @@ -0,0 +1,48 @@ +// useTodos — polls `GET /api/todos` on a light interval, same cadence +// as app.js's `setInterval(refreshTodos, 4000)`. Kept separate from +// useAgentState: todos change asynchronously (matrix syncs, bash task +// starts/completions) independent of the state snapshot, and the old +// page polled them independently too. +import { useEffect, useRef, useState } from 'preact/hooks'; +import type { TodoRow } from '../types.js'; + +const POLL_MS = 4000; + +export interface UseTodosResult { + todos: TodoRow[]; + refresh: () => void; +} + +export function useTodos(): UseTodosResult { + const [todos, setTodos] = useState<TodoRow[]>([]); + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const stoppedRef = useRef(false); + + async function poll() { + try { + const resp = await fetch('api/todos'); + if (!resp.ok) throw new Error(`http ${resp.status}`); + const body = (await resp.json()) as { todos?: TodoRow[] }; + if (stoppedRef.current) return; + // Defensive filter: the wire type is the general `LooseEnd` tagged + // enum even though this endpoint only ever emits the `todo` variant + // today — see types.ts's TodoRow comment. + setTodos((body.todos ?? []).filter((t) => t.kind === 'todo')); + } catch (err) { + console.warn('todos fetch failed', err); + if (!stoppedRef.current) setTodos([]); + } + } + + useEffect(() => { + stoppedRef.current = false; + poll(); + timerRef.current = setInterval(poll, POLL_MS); + return () => { + stoppedRef.current = true; + if (timerRef.current) clearInterval(timerRef.current); + }; + }, []); + + return { todos, refresh: poll }; +} diff --git a/frontend/packages/agent/src/lib/classifyEvent.ts b/frontend/packages/agent/src/lib/classifyEvent.ts new file mode 100644 index 00000000..07aa5784 --- /dev/null +++ b/frontend/packages/agent/src/lib/classifyEvent.ts @@ -0,0 +1,250 @@ +// Turns one raw harness/stream-json event into zero or more `StreamRow`s. +// Preact-data port of app.js's `renderStream` / `renderRichToolUse` / +// `renderToolResult` / `renderTaskEvent` — see docs/terminal-rendering.md +// for the row taxonomy this mirrors. Almost all per-tool classification +// is already done server-side (`hive-agent/src/stream_enrich.rs::enrich` +// stamps `_icon`/`_summary`/`_category`/`_body`/`_body_type`), so this +// mostly just dispatches on those fields rather than re-deriving them. +// +// One deliberate simplification vs. app.js: the client-side "compacting… +// <N>s" live override on `system/status` ticks (computed from the +// harness's local `stateSince`) isn't ported — this just shows the +// backend's `_summary` as-is. `turn_state`/`state_since` (and therefore +// the "compacting" badge itself) still update correctly via +// useAgentState's poll; only that one status row's live elapsed-seconds +// text loses its client-side tick. Revisit if that's missed in practice. +import type { StreamRow, StreamRowMeta } from './streamRow.js'; +import { fmtAge, fmtClock } from './format.js'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- stream-json +// content is dynamically-shaped JSON, same as app.js's untyped handling. +type AnyEvent = any; + +export interface ClassifyCtx { + toolNameById: Map<string, string>; + pendingTurnStartTs: { current: number | null }; + keySeq: { current: number }; +} + +export function createClassifyCtx(): ClassifyCtx { + return { toolNameById: new Map(), pendingTurnStartTs: { current: null }, keySeq: { current: 0 } }; +} + +function nextKey(ctx: ClassifyCtx): string { + ctx.keySeq.current += 1; + return 'r' + ctx.keySeq.current; +} + +function trim(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + '…' : s; +} + +export function classifyEvent(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow[] { + switch (ev.kind) { + case 'turn_start': + return [classifyTurnStart(ev, fromHistory, ctx)]; + case 'turn_end': + return [classifyTurnEnd(ev, fromHistory, ctx)]; + case 'note': + return [classifyNote(ev, fromHistory, ctx)]; + case 'stream': { + const v = { ...ev }; + delete v.kind; + return classifyStream(v, fromHistory, ctx); + } + default: + // status_changed / model_changed / effort_changed / + // token_usage_changed / turn_state_changed drive badges elsewhere + // (useAgentState's poll — see Root.tsx) rather than rows here. + return []; + } +} + +function classifyTurnStart(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow { + const meta: StreamRowMeta[] = []; + if (typeof ev.ts === 'number') { + ctx.pendingTurnStartTs.current = ev.ts; + meta.push({ cls: 'turn-time', text: '· ' + fmtClock(ev.ts) }); + } + if (ev.unread > 0) { + meta.push({ cls: 'unread-badge', text: '· ' + ev.unread + ' unread' }); + } + return { + key: nextKey(ctx), + cssClass: 'turn-start', + fromHistory, + text: '◆ TURN ← ' + ev.from, + meta, + childText: { cls: 'turn-body', text: String(ev.body ?? '') }, + }; +} + +function classifyTurnEnd(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow { + const meta: StreamRowMeta[] = []; + if (typeof ev.ts === 'number') { + let label = '· ' + fmtClock(ev.ts); + if (ctx.pendingTurnStartTs.current != null && ev.ts >= ctx.pendingTurnStartTs.current) { + label += ' · ' + fmtAge((ev.ts - ctx.pendingTurnStartTs.current) * 1000); + } + meta.push({ cls: 'turn-time', text: label }); + } + ctx.pendingTurnStartTs.current = null; + return { + key: nextKey(ctx), + cssClass: ev.ok ? 'turn-end-ok' : 'turn-end-fail', + fromHistory, + text: (ev.ok ? '✅' : '❌') + ' turn ' + (ev.ok ? 'ok' : 'fail') + (ev.note ? ' — ' + ev.note : ''), + meta, + }; +} + +function classifyNote(ev: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow { + const t = String(ev.text ?? ''); + if (t.startsWith('stderr:')) return { key: nextKey(ctx), cssClass: 'note stderr', fromHistory, text: '! ' + t }; + if (t.startsWith('operator:')) return { key: nextKey(ctx), cssClass: 'note op', fromHistory, text: '· ' + t }; + return { key: nextKey(ctx), cssClass: 'note', fromHistory, text: '· ' + t }; +} + +function classifyStream(v: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow[] { + if (v._category === 'drop') return []; + + if (v.type === 'system') { + const cat = v._category; + const summary = v._summary; + if (cat === 'thinking_tok') { + return [{ + key: nextKey(ctx), cssClass: 'note', icon: '🧠', fromHistory, + text: summary || 'thinking…', coalesceKey: 'thinking-tok', + }]; + } + if (v.subtype === 'plugin_install') { + return [{ + key: nextKey(ctx), cssClass: 'note', fromHistory, + text: summary || '⚙ plugin install', coalesceKey: 'plugin-install', + }]; + } + if (v.subtype === 'status') { + return [{ + key: nextKey(ctx), cssClass: 'note', fromHistory, + text: summary || '⚙ status', coalesceKey: 'status-tick', + }]; + } + if (cat === 'details') { + return [{ + key: nextKey(ctx), cssClass: 'note', fromHistory, details: true, + text: summary || '⚙ ' + (v.subtype || ''), plainBody: v._body || '', + }]; + } + return [{ key: nextKey(ctx), cssClass: 'note', fromHistory, text: summary || '⚙ ' + (v.subtype || 'system') }]; + } + + if (v.subtype === 'task_started' || v.subtype === 'task_notification') { + const row = classifyTaskEvent(v, fromHistory, ctx); + if (row) return [row]; + } + + if (v.type === 'assistant' && v.message && v.message.content) { + const rows: StreamRow[] = []; + for (const c of v.message.content) { + if (c.type === 'text' && c.text && String(c.text).trim()) { + rows.push({ key: nextKey(ctx), cssClass: 'text', fromHistory, markdownBody: c.text }); + } else if (c.type === 'thinking') { + const txt = String(c.thinking || c.text || '').trim(); + rows.push({ key: nextKey(ctx), cssClass: 'thinking', icon: '💭', fromHistory, text: txt || 'thinking …' }); + } else if (c.type === 'tool_use') { + if (c.id && c.name) ctx.toolNameById.set(c.id, c.name); + rows.push(classifyToolUse(c, fromHistory, ctx)); + } + } + return rows; + } + + if (v.type === 'user' && v.message && v.message.content) { + const rows: StreamRow[] = []; + for (const c of v.message.content) { + if (c.type === 'tool_result') rows.push(classifyToolResult(c, fromHistory, ctx)); + } + return rows; + } + + return [{ key: nextKey(ctx), cssClass: 'sys', fromHistory, text: '! ' + trim(JSON.stringify(v), 200) }]; +} + +// `_category === 'rich'` tools get an expandable row: diff body (Edit), +// default-open markdown body (send/ask/answer/recv-shaped), or a plain +// collapsed body — all pre-computed server-side, no per-tool JS needed. +function classifyToolUse(c: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow { + const icon = c._icon || '🔧'; + const name = c.name || ''; + if (c._category === 'rich' && c._body != null) { + const summary = c._summary || name || '?'; + if (c._body_type === 'diff') { + return { key: nextKey(ctx), cssClass: 'tool-use', fromHistory, details: true, icon, text: summary, diffBody: c._body }; + } + if (c._body_type === 'markdown') { + return { + key: nextKey(ctx), cssClass: 'tool-use', fromHistory, details: true, defaultOpen: true, + icon, text: summary, markdownBody: c._body, + }; + } + return { key: nextKey(ctx), cssClass: 'tool-use', fromHistory, details: true, icon, text: summary, plainBody: c._body }; + } + return { key: nextKey(ctx), cssClass: 'tool-use', fromHistory, icon, text: c._summary || name || '?' }; +} + +function classifyToolResult(c: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow { + const rawTxt = Array.isArray(c.content) ? c.content.map((p: AnyEvent) => p.text || '').join('') : c.content || ''; + const isError = !!c.is_error; + // Strip the <tool_use_error>…</tool_use_error> wrapper — implementation + // detail claude emits on failed tool calls, adds nothing for the operator. + const txt = isError + ? String(rawTxt).replace(/^<tool_use_error>([\s\S]*)<\/tool_use_error>$/, '$1').trim() + : String(rawTxt); + const sourceName = c.tool_use_id ? ctx.toolNameById.get(c.tool_use_id) : null; + const isMessageBearing = sourceName === 'mcp__hyperhive__recv'; + const trimmed = txt.replace(/\s+/g, ' ').trim(); + const summaryBody = (() => { + if (!trimmed) return '(empty)'; + if (trimmed.length <= 120) return trimmed; + const lines = txt.split('\n').filter((l: string) => l.length).length; + const headline = trimmed.slice(0, 90) + '…'; + return `${lines}L · ${headline}`; + })(); + if (isError) { + if (!txt.trim() || txt.length <= 120) { + return { key: nextKey(ctx), cssClass: 'tool-result error', fromHistory, text: '✗ ' + summaryBody }; + } + return { key: nextKey(ctx), cssClass: 'tool-result-block error', fromHistory, details: true, text: summaryBody, plainBody: txt }; + } + if (isMessageBearing && txt.trim()) { + return { + key: nextKey(ctx), cssClass: 'tool-result-block', fromHistory, details: true, defaultOpen: true, + text: 'recv ← ' + summaryBody, markdownBody: txt, + }; + } + if (!txt.trim() || txt.length <= 120) { + return { key: nextKey(ctx), cssClass: 'tool-result', fromHistory, text: '← ' + summaryBody }; + } + return { key: nextKey(ctx), cssClass: 'tool-result-block', fromHistory, details: true, text: summaryBody, plainBody: txt }; +} + +// Subagent (claude `Task`-tool) activity — dead path for agents today +// (`Task` is omitted from the allow-list) but kept for parity, same as +// app.js's `renderTaskEvent`. Glyph stays embedded in the row text +// (not routed through the icon column) — matches the original exactly. +function classifyTaskEvent(v: AnyEvent, fromHistory: boolean, ctx: ClassifyCtx): StreamRow | null { + const id = String(v.task_id || '').slice(0, 8); + const kind = v.task_type ? ` [${v.task_type}]` : ''; + const desc = v.description || v.summary || '(no description)'; + if (v.subtype === 'task_started') { + return { key: nextKey(ctx), cssClass: 'tool-use', fromHistory, text: `⌁ task ${id} started · ${desc}${kind}` }; + } + if (v.subtype === 'task_notification') { + const status = v.status || 'unknown'; + const glyph = status === 'completed' ? '✓' : status === 'failed' ? '✗' : '◌'; + const cssClass = status === 'completed' ? 'turn-end-ok' : status === 'failed' ? 'turn-end-fail' : 'tool-result'; + const out = v.output_file ? ` · → ${v.output_file}` : ''; + return { key: nextKey(ctx), cssClass, fromHistory, text: `⌁ task ${id} ${glyph} ${status} · ${desc}${out}` }; + } + return null; +} diff --git a/frontend/packages/agent/src/lib/dashboardBase.ts b/frontend/packages/agent/src/lib/dashboardBase.ts new file mode 100644 index 00000000..9bdb60a1 --- /dev/null +++ b/frontend/packages/agent/src/lib/dashboardBase.ts @@ -0,0 +1,11 @@ +// Resolves the host dashboard's origin, ported unchanged from app.js's +// `setHeader` — pause/resume are host-side (hive-c0re) actions, not +// this agent's own `/api/*`, so they need the *dashboard's* origin, not +// a relative path. Behind the gateway this page lives at +// `/agent/<name>/` on the dashboard's own origin (`location.origin`); +// accessed directly, it's the same host on `dashboardPort`. +export function resolveDashboardBase(dashboardPort: number): string { + return location.pathname.startsWith('/agent/') + ? location.origin + '/' + : `${location.protocol}//${location.hostname}:${dashboardPort}/`; +} diff --git a/frontend/packages/agent/src/lib/format.ts b/frontend/packages/agent/src/lib/format.ts new file mode 100644 index 00000000..37797e8c --- /dev/null +++ b/frontend/packages/agent/src/lib/format.ts @@ -0,0 +1,24 @@ +// Formatting helpers ported from app.js's `fmtTokens`/`fmtAge` (same +// output shapes — this page's operators are used to reading them). + +export function fmtTokens(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'; + if (n >= 1_000) return Math.round(n / 1000) + 'k'; + return String(n); +} + +export function fmtAge(ms: number): string { + const s = Math.floor(ms / 1000); + if (s < 60) return s + 's'; + const m = Math.floor(s / 60); + if (m < 60) return m + 'm ' + (s % 60) + 's'; + const h = Math.floor(m / 60); + return h + 'h ' + (m % 60) + 'm'; +} + +/** Wall-clock HH:MM:SS (UTC) from a unix-seconds value — labels + * turn-start / turn-end rows in the live stream (ported from app.js's + * `fmtClock`). */ +export function fmtClock(sec: number): string { + return new Date(sec * 1000).toISOString().slice(11, 19); +} diff --git a/frontend/packages/agent/src/lib/linkify.tsx b/frontend/packages/agent/src/lib/linkify.tsx new file mode 100644 index 00000000..ce021d5a --- /dev/null +++ b/frontend/packages/agent/src/lib/linkify.tsx @@ -0,0 +1,39 @@ +// Auto-linkify bare http(s) URLs in plain (non-markdown) row text — +// Preact-node port of @hive/shared/terminal.js's `linkify` (text-node +// output there, JSX fragment here). Same regex + trailing-punctuation +// strip; deliberately text-only, never innerHTML, so untrusted row text +// (matrix-relayed bodies, tool args) can't inject markup this way. +import type { JSX } from 'preact'; + +const LINKIFY_URL_RE = /https?:\/\/[^\s<>"']+/g; + +export function linkifyToNodes(text: string | null | undefined): (string | JSX.Element)[] { + const str = text == null ? '' : String(text); + if (str.indexOf('://') === -1) return [str]; + const out: (string | JSX.Element)[] = []; + let last = 0; + let m: RegExpExecArray | null; + let key = 0; + LINKIFY_URL_RE.lastIndex = 0; + while ((m = LINKIFY_URL_RE.exec(str)) !== null) { + let url = m[0]; + const trail = url.match(/[.,;:!?)\]}'"]+$/); + const tail = trail ? trail[0] : ''; + if (tail) url = url.slice(0, -tail.length); + if (m.index > last) out.push(str.slice(last, m.index)); + if (!url.slice(url.indexOf('://') + 3)) { + // Nothing past the scheme — not a real URL, emit verbatim. + out.push(m[0]); + } else { + out.push( + <a key={key++} href={url} target="_blank" rel="noopener noreferrer"> + {url} + </a>, + ); + if (tail) out.push(tail); + } + last = m.index + m[0].length; + } + if (last < str.length) out.push(str.slice(last)); + return out; +} diff --git a/frontend/packages/agent/src/lib/loginAction.ts b/frontend/packages/agent/src/lib/loginAction.ts new file mode 100644 index 00000000..afc4d0f9 --- /dev/null +++ b/frontend/packages/agent/src/lib/loginAction.ts @@ -0,0 +1,28 @@ +// POST `/login/start` / `/login/code` / `/login/cancel` — same-origin +// (this agent's own backend, `hive-agent/src/web_ui/auth.rs`), so a +// plain `fetch` is safe (unlike `pauseAction.ts`'s dashboard-origin +// actions). Same `{ ok, detail }` shape and `redirect: 'manual'` / +// broad-2xx-4xx-treated-as-ok contract as `modelEffort.ts` — none of +// these three routes actually redirect today, but the same tolerant +// check costs nothing and keeps every same-origin POST in this package +// behaving identically. +async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> { + try { + const resp = await fetch(path, { + method: 'POST', + headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined, + body, + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); + if (ok) return { ok: true }; + const detail = await resp.text().catch(() => ''); + return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` }; + } catch (err) { + return { ok: false, detail: err instanceof Error ? err.message : String(err) }; + } +} + +export const postLoginStart = () => post('login/start'); +export const postLoginCode = (code: string) => post('login/code', new URLSearchParams({ code })); +export const postLoginCancel = () => post('login/cancel'); diff --git a/frontend/packages/agent/src/lib/markdown.ts b/frontend/packages/agent/src/lib/markdown.ts new file mode 100644 index 00000000..9b363b74 --- /dev/null +++ b/frontend/packages/agent/src/lib/markdown.ts @@ -0,0 +1,25 @@ +// Markdown → sanitized HTML, ported from app.js's `mdNode`. Message +// bodies rendered into the live stream (assistant text, send/ask/answer/ +// recv payloads) are untrusted (peer-agent / matrix-relayed content, +// agent-authored files) — `marked` itself no longer sanitizes (v5+ +// dropped the built-in sanitizer), so every parse is run through +// DOMPurify before it's ever handed to `dangerouslySetInnerHTML`. +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +marked.setOptions({ breaks: true, gfm: true }); + +const ESCAPE_RE = /[&<>"]/g; +const ESCAPE_MAP: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' }; + +/** Render `text` as sanitized markdown HTML. Falls back to escaped plain + * text if `marked` throws (mirrors app.js's try/catch fallback). */ +export function renderMarkdown(text: string | null | undefined): string { + const src = String(text ?? ''); + try { + return DOMPurify.sanitize(marked.parse(src) as string); + } catch (err) { + console.warn('marked failed', err); + return src.replace(ESCAPE_RE, (c) => ESCAPE_MAP[c] ?? c); + } +} diff --git a/frontend/packages/agent/src/lib/modelEffort.ts b/frontend/packages/agent/src/lib/modelEffort.ts new file mode 100644 index 00000000..f52bb135 --- /dev/null +++ b/frontend/packages/agent/src/lib/modelEffort.ts @@ -0,0 +1,25 @@ +// POST `/api/model` / `/api/effort` — same-origin (this agent's own +// backend), so a plain `fetch` is safe unlike pause/resume (see +// pauseAction.ts). `redirect: 'manual'` + the broad "ok" check below is +// ported from app.js unchanged — the endpoints don't actually redirect +// today, but treating an opaque redirect as success costs nothing and +// matches the existing contract exactly. +async function post(path: string, field: string, value: string): Promise<{ ok: boolean; detail?: string }> { + try { + const resp = await fetch(path, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ [field]: value }), + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); + if (ok) return { ok: true }; + const detail = await resp.text().catch(() => ''); + return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` }; + } catch (err) { + return { ok: false, detail: err instanceof Error ? err.message : String(err) }; + } +} + +export const postModel = (name: string) => post('api/model', 'model', name); +export const postEffort = (level: string) => post('api/effort', 'effort', level); diff --git a/frontend/packages/agent/src/lib/pauseAction.ts b/frontend/packages/agent/src/lib/pauseAction.ts new file mode 100644 index 00000000..060358fd --- /dev/null +++ b/frontend/packages/agent/src/lib/pauseAction.ts @@ -0,0 +1,16 @@ +// Pause/resume POST to the *dashboard's* origin (hive-c0re, not this +// agent's own `/api/*`) — see dashboardBase.ts. A real `<form>` submit +// rather than `fetch`, kept unchanged from app.js: accessed directly +// (not through the gateway proxy), the dashboard is a different origin/ +// port, and a cross-origin `fetch` POST needs the response readable +// under CORS to report success/failure — a full navigation form submit +// sidesteps that (the browser reloads to whatever the endpoint +// returns) without needing hive-c0re to grow CORS headers for what's +// otherwise a same-origin action behind the gateway. +export function submitPauseResume(dashboardBase: string, label: string, verb: 'pause' | 'resume'): void { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = `${dashboardBase}api/${verb}/${label}`; + document.body.appendChild(form); + form.submit(); +} diff --git a/frontend/packages/agent/src/lib/rebuildAction.ts b/frontend/packages/agent/src/lib/rebuildAction.ts new file mode 100644 index 00000000..448dea06 --- /dev/null +++ b/frontend/packages/agent/src/lib/rebuildAction.ts @@ -0,0 +1,11 @@ +// Rebuild POST to the *dashboard's* origin — same cross-origin-needs-a- +// real-form-submit reasoning as pauseAction.ts's `submitPauseResume` +// (hive-c0re's `/api/rebuild/{name}` returns a plain 200, no CORS +// header, so a cross-origin `fetch` can't read the result). +export function submitRebuild(dashboardBase: string, label: string): void { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = `${dashboardBase}api/rebuild/${label}`; + document.body.appendChild(form); + form.submit(); +} diff --git a/frontend/packages/agent/src/lib/streamRow.ts b/frontend/packages/agent/src/lib/streamRow.ts new file mode 100644 index 00000000..e985f922 --- /dev/null +++ b/frontend/packages/agent/src/lib/streamRow.ts @@ -0,0 +1,49 @@ +// Row model for the live event stream. One `StreamRow` = one rendered +// line/panel in the terminal pane; `classifyEvent` (classifyEvent.ts) +// turns a raw harness/stream-json event into zero or more of these, and +// `<Row>` (components/Row.tsx) renders one. Kept as plain data (not JSX) +// so the append/coalesce bookkeeping in useLiveStream.ts stays pure — +// see docs/terminal-rendering.md for the exact taxonomy this mirrors. + +export interface StreamRowMeta { + cls: string; + text: string; +} + +export interface StreamRow { + /** Stable across re-renders; reused in place when a row is coalesced + * (e.g. the thinking-token counter) so Preact updates rather than + * remounts it. */ + key: string; + /** Space-joined class names appended after "row" / "row details", e.g. + * "turn-start", "tool-result error" — taken verbatim from + * @hive/shared/terminal.css's row-kind taxonomy. */ + cssClass: string; + icon?: string; + fromHistory: boolean; + /** When set, an event that maps to the same coalesceKey and lands + * while this row is still the last one in the list replaces it in + * place instead of appending a new row (thinking-token ticks, status + * ticks, plugin_install started→completed). */ + coalesceKey?: string; + /** `false`/absent → flat `<div class="row">`; `true` → `<details + * class="row">` with `.summary-text` + optional body. */ + details?: boolean; + defaultOpen?: boolean; + /** Flat-row prefix text, or the details `<summary>` text. Linkified, + * never markdown. */ + text?: string; + /** Small trailing spans after the prefix text (turn-time, unread + * count) — flat rows only. */ + meta?: StreamRowMeta[]; + /** Plain-text child block under a flat row (just the turn-start wake + * body today) — its own class, no markdown parsing. */ + childText?: StreamRowMeta; + /** Sanitized-markdown body: appended under a flat row (assistant + * text) or inside an open details row (send/ask/answer/recv bodies). */ + markdownBody?: string; + /** Plain `<pre>` body inside a details row (generic long tool output). */ + plainBody?: string; + /** `+`/`-`/context diff body inside a details row (Edit tool). */ + diffBody?: string; +} diff --git a/frontend/packages/agent/src/lib/termActions.ts b/frontend/packages/agent/src/lib/termActions.ts new file mode 100644 index 00000000..fba05593 --- /dev/null +++ b/frontend/packages/agent/src/lib/termActions.ts @@ -0,0 +1,25 @@ +// Same-origin fetch POST helpers for the term-input slash commands — +// same shape as modelEffort.ts's `post()` ({ ok, detail } rather than +// throwing), reused by TermInput.tsx. +async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> { + try { + const resp = await fetch(path, { + method: 'POST', + headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined, + body, + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); + if (ok) return { ok: true }; + const detail = await resp.text().catch(() => ''); + return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` }; + } catch (err) { + return { ok: false, detail: err instanceof Error ? err.message : String(err) }; + } +} + +export const postCancelTurn = () => post('api/cancel'); +export const postCompact = () => post('api/compact'); +export const postNewSession = () => post('api/new-session'); +export const postLogout = () => post('api/logout'); +export const postSend = (body: string) => post('send', new URLSearchParams({ body })); diff --git a/frontend/packages/agent/src/lib/useConfirmClick.ts b/frontend/packages/agent/src/lib/useConfirmClick.ts new file mode 100644 index 00000000..5960a9be --- /dev/null +++ b/frontend/packages/agent/src/lib/useConfirmClick.ts @@ -0,0 +1,35 @@ +// Lightweight two-click confirm for a destructive-but-recoverable button +// ("mark all read", "mark done") — deliberately NOT the old modal system +// (@hive/shared/modal.js's `themedConfirm`, itself built on the +// shadow-DOM `<hive-dialog>` custom element): a full backdrop+dialog is +// more machinery than a "did you mean to click that" nudge needs, and +// mara's ask for this rewrite was real Preact components, not a port of +// the old widget family. First click arms the button (caller renders a +// distinct "sure? click again" label off `armed`); a second click within +// `resetMs` fires `onConfirm`; anything else (timeout, blur) disarms. +import { useEffect, useRef, useState } from 'preact/hooks'; + +export function useConfirmClick(onConfirm: () => void, resetMs = 2500) { + const [armed, setArmed] = useState(false); + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); + + useEffect( + () => () => { + if (timerRef.current) clearTimeout(timerRef.current); + }, + [], + ); + + function trigger() { + if (armed) { + if (timerRef.current) clearTimeout(timerRef.current); + setArmed(false); + onConfirm(); + return; + } + setArmed(true); + timerRef.current = setTimeout(() => setArmed(false), resetMs); + } + + return { armed, trigger }; +} diff --git a/frontend/packages/agent/src/main.tsx b/frontend/packages/agent/src/main.tsx new file mode 100644 index 00000000..ad824058 --- /dev/null +++ b/frontend/packages/agent/src/main.tsx @@ -0,0 +1,16 @@ +// Entry point for the Preact rewrite of the per-agent terminal page +// (replacing app.js's imperative DOM). Not wired into index.html yet — +// this lands in small, reviewable slices (component by component) per +// mara's "one pr != one commit" note on the issue; `index.html`'s +// `<script>` tag swaps from `app.js` to this bundle's output only once +// the new page covers everything the old one did (state polling, the +// live SSE stream, login flow, inbox/todos flyouts, slash commands). +// +// Mounts to `#preact-root`, a sibling of the legacy markup rather than +// a full document takeover, so the two can coexist on a branch while +// this is still in progress. +import { render } from 'preact'; +import { Root } from './Root.js'; + +const root = document.getElementById('preact-root'); +if (root) render(<Root />, root); diff --git a/frontend/packages/agent/src/types.ts b/frontend/packages/agent/src/types.ts new file mode 100644 index 00000000..3eb6388b --- /dev/null +++ b/frontend/packages/agent/src/types.ts @@ -0,0 +1,81 @@ +// TS types mirroring `hive-agent/src/web_ui/state.rs::StateSnapshot` +// (the `GET /api/state` response body). Kept as a hand-written subset +// rather than a generated binding — the Rust side has no schema export +// today, and the frontend only ever reads a handful of these fields. +// Extend as more of the page gets rebuilt against real data. + +export interface AgentState { + seq: number; + label: string; + qualified_label: string; + dashboard_port: number; + status: 'online' | 'rate_limited' | 'needs_login_idle' | 'needs_login_in_progress'; + session: SessionView | null; + turn_state: string; + turn_state_since: number; + model: string; + context_window_tokens: number; + ctx_usage: TokenUsage | null; + cost_usage: TokenUsage | null; + hive_name: string | null; + swarm_name: string | null; + available_models: string[]; + effort: string; + available_efforts: string[]; + paused: boolean; + inbox: InboxRow[]; + links: AgentLink[]; + forge_public_url: string | null; +} + +// Mirrors `hive_agent::web_ui::state::AgentLink` — one meta-nav entry +// in the agent page header (stats/screen/forge/config + any +// `hyperhive.dashboardLinks` extras). `agent_links()` on the backend +// is the single source of truth for what links an agent exposes; see +// docs/web-ui/agent.md::Header for the kind → URL resolution table. +export interface AgentLink { + url: string; + icon: string; + label: string; + kind: 'container' | 'forge' | 'external'; +} + +// Mirrors `hive_agent::web_ui::state::SessionView` — populated only +// while `status === 'needs_login_in_progress'` (the in-flight +// `claude auth login` session's streamed output). +export interface SessionView { + url: string | null; + output: string; + finished: boolean; + exit_note: string | null; +} + +export interface TokenUsage { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; +} + +// Mirrors `hive_sh4re::inbox::InboxRow` — the last N messages addressed +// to this agent, newest-first. +export interface InboxRow { + id: number; + from: string; + body: string; + at: number; + in_reply_to?: number | null; +} + +// Mirrors the `kind: "todo"` variant of `hive_sh4re::inbox::LooseEnd` — +// the only variant `GET /api/todos` actually returns (a dynamic, +// subsystem-pushed todo: matrix/bash/forge are the built-in producers). +export interface TodoRow { + kind: 'todo'; + id: number; + subsystem: string; + subsystem_key?: string | null; + summary: string; + source?: string | null; + age_seconds: number; +} diff --git a/frontend/packages/agent/tsconfig.json b/frontend/packages/agent/tsconfig.json new file mode 100644 index 00000000..fa84fda7 --- /dev/null +++ b/frontend/packages/agent/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "jsxImportSource": "preact", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/nix/checks.nix b/nix/checks.nix index 0944d240..584ee408 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -175,6 +175,32 @@ in ''; }; + # Same rationale + shape as `swarm-ui-typecheck` above, for the `agent` + # package now that its ongoing Preact rewrite has grown enough real + # `.tsx`/`.ts` for a type error to matter — argus flagged the same gap + # on that PR's review. + agent-typecheck = pkgs.buildNpmPackage { + pname = "hyperhive-agent-typecheck"; + version = "0.0.0"; + src = ../frontend; + + npmDepsHash = builtins.readFile ../frontend/npm-deps-hash; + + buildPhase = '' + runHook preBuild + npm run typecheck --workspace=packages/agent + runHook postBuild + ''; + + dontNpmInstall = true; + installPhase = '' + runHook preInstall + mkdir -p $out + touch $out/ok + runHook postInstall + ''; + }; + # `hivectl` CLI reference freshness check. The committed # markdown at `docs/tools/hivectl-cli.md` is the rendered # output of the hidden `hivectl markdown-docs` subcommand