Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
43 changed files with 17 additions and 2697 deletions

View file

@ -1 +1 @@
sha256-9sh4G6A00NNi5YKaMQ1v0vZQj+gmGxYPhYZhlqweApM=
sha256-8mYI9b1nuQ8YNFkW/ezJwO5Qw6NJTDG+kspYozs/5cI=

View file

@ -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": {

View file

@ -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;

View file

@ -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"
}
}

View file

@ -1,200 +0,0 @@
// <Root> — root of the Preact rewrite. Wires `Header` + `StatusChips`
// to the real `/api/state` snapshot via `useAgentState`. Covers
// everything `app.js` did, including the `needs_login_idle`/
// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with
// no credentials has no other web-UI path back online, so this isn't
// optional polish.
import { useEffect, useRef, useState } from 'preact/hooks';
import type { BadgeTone } from '@hive/shared/badge.js';
import { 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<string, { glyph: string; text: string; tone: BadgeTone }> = {
online: { glyph: '●', text: 'alive', tone: 'positive' },
rate_limited: { glyph: '⊘', text: 'rate limited', tone: 'warning' },
needs_login_idle: { glyph: '◌', text: 'needs login', tone: 'warning' },
needs_login_in_progress: { glyph: '◌', text: 'logging in', tone: 'warning' },
};
const STATE_LABELS: Record<string, { glyph: string; text: string; tone: BadgeTone }> = {
idle: { glyph: '💤', text: 'idle', tone: 'neutral' },
thinking: { glyph: '🧠', text: 'thinking', tone: 'accent' },
compacting: { glyph: '📦', text: 'compacting', tone: 'warning' },
};
const STATE_TOOLTIPS: Record<string, string> = {
offline: 'harness unreachable or claude not logged in',
idle: 'turn loop running, no claude invocation in flight',
thinking: 'claude is executing the current turn',
compacting: "operator-triggered /compact running on the persistent session",
};
// `total` here matches app.js's `renderOneUsage` exactly: input + both
// cache buckets, output_tokens deliberately excluded (it's shown in the
// title breakdown, not folded into the headline number).
function tokenTotal(u: TokenUsage | null): number | null {
if (!u) return null;
return (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
}
export function Root() {
const { state, refresh } = useAgentState();
const { todos, refresh: refreshTodos } = useTodos();
const [openPanel, setOpenPanel] = useState<OpenPanel>(null);
const liveStreamRef = useRef<LiveStreamHandle>(null);
// Browser tab title — ports app.js's setHeader logic verbatim: prefer
// the human display name + hive_name ("iris // pr1ma") so tabs read
// naturally; fall back to the qualified domain label for multi-hive
// disambiguation when hive_name is unset, then to plain label for
// single-hive deploys. Without this every agent's tab reads the
// static index.html <title> forever — can't tell tabs apart with
// several open.
useEffect(() => {
if (!state) return;
const tab =
state.qualified_label && state.qualified_label !== state.label
? state.qualified_label
: state.label;
document.title = state.hive_name ? `${state.label} // ${state.hive_name}` : `${tab} // hyperhive`;
}, [state?.label, state?.qualified_label, state?.hive_name]);
const termInput = (
<footer class="agent-composer">
<TermInput
label={state?.label ?? '…'}
online={state?.status === 'online'}
onLocalNote={(text) => liveStreamRef.current?.pushNote(text)}
onClear={() => liveStreamRef.current?.clear()}
/>
</footer>
);
const pills = (
<>
<HeaderPill kind="inbox" icon="📬" label="inbox" count={state?.inbox.length ?? 0} onClick={() => setOpenPanel('inbox')} />
<HeaderPill kind="todos" icon="📋" label="todos" count={todos.length} onClick={() => setOpenPanel('todos')} />
{/* Needs a loaded snapshot for 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}
</>
);
}

View file

@ -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);

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
});

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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>
</>
);
}

View file

@ -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);
}

Some files were not shown because too many files have changed in this diff Show more