diff --git a/frontend/npm-deps-hash b/frontend/npm-deps-hash index 6014c91f..fcb93e72 100644 --- a/frontend/npm-deps-hash +++ b/frontend/npm-deps-hash @@ -1 +1 @@ -sha256-9sh4G6A00NNi5YKaMQ1v0vZQj+gmGxYPhYZhlqweApM= +sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI= \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c25b577..ecfc32e3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -991,11 +991,7 @@ "@hive/shared": "*", "chart.js": "4.5.1", "dompurify": "^3.2.4", - "marked": "18.0.6", - "preact": "10.29.8" - }, - "devDependencies": { - "typescript": "7.0.2" + "marked": "18.0.6" } }, "packages/dashboard": { diff --git a/frontend/packages/agent/build.mjs b/frontend/packages/agent/build.mjs index 6e0790a8..be0c3e3a 100644 --- a/frontend/packages/agent/build.mjs +++ b/frontend/packages/agent/build.mjs @@ -47,25 +47,6 @@ 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 2a18ce33..82da97f3 100644 --- a/frontend/packages/agent/package.json +++ b/frontend/packages/agent/package.json @@ -5,17 +5,12 @@ "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", - "typecheck": "tsc --noEmit" + "build": "node ./build.mjs" }, "dependencies": { "@hive/shared": "*", "chart.js": "4.5.1", "dompurify": "^3.2.4", - "marked": "18.0.6", - "preact": "10.29.8" - }, - "devDependencies": { - "typescript": "7.0.2" + "marked": "18.0.6" } } diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx deleted file mode 100644 index baae05ed..00000000 --- a/frontend/packages/agent/src/Root.tsx +++ /dev/null @@ -1,200 +0,0 @@ -// — 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 c99e9e39..6776fce5 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -129,30 +129,15 @@ body.agent-shell { gap: 0.5em; } -/* 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). */ +/* 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. */ .agent-header-pills { display: flex; align-items: center; gap: 0.5em; + flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; align-self: center; @@ -372,16 +357,10 @@ 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. `--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). */ + space in the normal online flow. */ .agent-status-overlay { position: absolute; - top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 1.5em); + top: calc(var(--agent-header-h) + 1.5em); left: 50%; transform: translateX(-50%); max-width: 44em; @@ -737,9 +716,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-real-h, var(--agent-header-h)) + 0.8em); + padding-top: calc(var(--agent-header-h) + 0.8em); padding-bottom: calc(var(--agent-composer-h) + 0.8em); - scroll-padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em); + scroll-padding-top: calc(var(--agent-header-h) + 0.8em); scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em); overflow: auto; } @@ -760,21 +739,7 @@ pre.diff { rules below stay scoped to whichever ancestor owns it. */ .term-input .sendform-term { display: flex; - /* 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; + align-items: flex-start; gap: 0.5em; /* The dashed in-frame separator is dropped — see the .agent-composer .term-input override above for the floating-bar @@ -782,6 +747,9 @@ 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 deleted file mode 100644 index 0eb80be7..00000000 --- a/frontend/packages/agent/src/components/Header.tsx +++ /dev/null @@ -1,99 +0,0 @@ -// <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 deleted file mode 100644 index 1d3f7e9f..00000000 --- a/frontend/packages/agent/src/components/HeaderPill.tsx +++ /dev/null @@ -1,26 +0,0 @@ -// <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 deleted file mode 100644 index 188534f6..00000000 --- a/frontend/packages/agent/src/components/InboxPanel.tsx +++ /dev/null @@ -1,86 +0,0 @@ -// <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 deleted file mode 100644 index 47b0832e..00000000 --- a/frontend/packages/agent/src/components/LiveStream.tsx +++ /dev/null @@ -1,127 +0,0 @@ -// 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 deleted file mode 100644 index c2ce6018..00000000 --- a/frontend/packages/agent/src/components/LoginFlow.css +++ /dev/null @@ -1,25 +0,0 @@ -/* .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 deleted file mode 100644 index c74c3c1b..00000000 --- a/frontend/packages/agent/src/components/LoginFlow.tsx +++ /dev/null @@ -1,171 +0,0 @@ -// <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 deleted file mode 100644 index 3c957fa7..00000000 --- a/frontend/packages/agent/src/components/MetaNav.css +++ /dev/null @@ -1,42 +0,0 @@ -/* <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 deleted file mode 100644 index 3792493a..00000000 --- a/frontend/packages/agent/src/components/MetaNav.tsx +++ /dev/null @@ -1,91 +0,0 @@ -// <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 deleted file mode 100644 index 987f14ae..00000000 --- a/frontend/packages/agent/src/components/OverflowMenu.css +++ /dev/null @@ -1,19 +0,0 @@ -/* 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 deleted file mode 100644 index d698cb53..00000000 --- a/frontend/packages/agent/src/components/OverflowMenu.tsx +++ /dev/null @@ -1,66 +0,0 @@ -// <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 deleted file mode 100644 index 2f2c9900..00000000 --- a/frontend/packages/agent/src/components/Row.tsx +++ /dev/null @@ -1,71 +0,0 @@ -// 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 deleted file mode 100644 index cbf84d57..00000000 --- a/frontend/packages/agent/src/components/SidePanel.css +++ /dev/null @@ -1,80 +0,0 @@ -/* 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 deleted file mode 100644 index 3f7ec327..00000000 --- a/frontend/packages/agent/src/components/SidePanel.tsx +++ /dev/null @@ -1,55 +0,0 @@ -// <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 deleted file mode 100644 index 1b678f99..00000000 --- a/frontend/packages/agent/src/components/StatusChips.css +++ /dev/null @@ -1,31 +0,0 @@ -/* <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 deleted file mode 100644 index 72ebe367..00000000 --- a/frontend/packages/agent/src/components/StatusChips.tsx +++ /dev/null @@ -1,146 +0,0 @@ -// <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 deleted file mode 100644 index 823817db..00000000 --- a/frontend/packages/agent/src/components/TermInput.tsx +++ /dev/null @@ -1,171 +0,0 @@ -// <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 deleted file mode 100644 index 415fab96..00000000 --- a/frontend/packages/agent/src/components/TodosPanel.tsx +++ /dev/null @@ -1,104 +0,0 @@ -// <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 deleted file mode 100644 index 8597bb2b..00000000 --- a/frontend/packages/agent/src/css.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -// 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 deleted file mode 100644 index 99e08b57..00000000 --- a/frontend/packages/agent/src/hooks/useAgentState.ts +++ /dev/null @@ -1,78 +0,0 @@ -// 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 deleted file mode 100644 index fbdab816..00000000 --- a/frontend/packages/agent/src/hooks/useLiveStream.ts +++ /dev/null @@ -1,199 +0,0 @@ -// 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 deleted file mode 100644 index 6d0fc6e2..00000000 --- a/frontend/packages/agent/src/hooks/useTodos.ts +++ /dev/null @@ -1,48 +0,0 @@ -// 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 deleted file mode 100644 index 07aa5784..00000000 --- a/frontend/packages/agent/src/lib/classifyEvent.ts +++ /dev/null @@ -1,250 +0,0 @@ -// 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 deleted file mode 100644 index 9bdb60a1..00000000 --- a/frontend/packages/agent/src/lib/dashboardBase.ts +++ /dev/null @@ -1,11 +0,0 @@ -// 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 deleted file mode 100644 index 37797e8c..00000000 --- a/frontend/packages/agent/src/lib/format.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 deleted file mode 100644 index ce021d5a..00000000 --- a/frontend/packages/agent/src/lib/linkify.tsx +++ /dev/null @@ -1,39 +0,0 @@ -// 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 deleted file mode 100644 index afc4d0f9..00000000 --- a/frontend/packages/agent/src/lib/loginAction.ts +++ /dev/null @@ -1,28 +0,0 @@ -// 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 deleted file mode 100644 index 9b363b74..00000000 --- a/frontend/packages/agent/src/lib/markdown.ts +++ /dev/null @@ -1,25 +0,0 @@ -// 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 deleted file mode 100644 index f52bb135..00000000 --- a/frontend/packages/agent/src/lib/modelEffort.ts +++ /dev/null @@ -1,25 +0,0 @@ -// 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 deleted file mode 100644 index 060358fd..00000000 --- a/frontend/packages/agent/src/lib/pauseAction.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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 deleted file mode 100644 index 448dea06..00000000 --- a/frontend/packages/agent/src/lib/rebuildAction.ts +++ /dev/null @@ -1,11 +0,0 @@ -// 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 deleted file mode 100644 index e985f922..00000000 --- a/frontend/packages/agent/src/lib/streamRow.ts +++ /dev/null @@ -1,49 +0,0 @@ -// 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 deleted file mode 100644 index fba05593..00000000 --- a/frontend/packages/agent/src/lib/termActions.ts +++ /dev/null @@ -1,25 +0,0 @@ -// 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 deleted file mode 100644 index 5960a9be..00000000 --- a/frontend/packages/agent/src/lib/useConfirmClick.ts +++ /dev/null @@ -1,35 +0,0 @@ -// 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 deleted file mode 100644 index ad824058..00000000 --- a/frontend/packages/agent/src/main.tsx +++ /dev/null @@ -1,16 +0,0 @@ -// 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 deleted file mode 100644 index 3eb6388b..00000000 --- a/frontend/packages/agent/src/types.ts +++ /dev/null @@ -1,81 +0,0 @@ -// 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 deleted file mode 100644 index fa84fda7..00000000 --- a/frontend/packages/agent/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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 584ee408..0944d240 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -175,32 +175,6 @@ 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