Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
iris
7897b6af7f agent: fix badges+pills overflow instead of wrap at narrow widths
mara, live, on the 500px screenshot: 'does not overflow properly.'

Real cause: .agent-header-pills had flex-shrink: 0 (fine for app.js's
2-3-small-pills case, which never needed to shrink) — once badges
joined that column last round, that told the flex layout 'give this
its full natural unwrapped width no matter what', so once that width
exceeded the viewport it just overflowed past the edge instead of
ever getting narrow enough to trigger its own flex-wrap. Dropping
flex-shrink: 0 (default 1) is the fix.

Tried pairing it with min-width: 0 first — made it worse. An unset
min-width still floors shrinking at the container's own min-content
(the widest single wrapped child, e.g. one badge — a reasonable
floor); min-width: 0 removes that floor entirely, so the container
shrinks past what its content needs and the content overflows its own
box sideways, landing on top of .agent-header-main instead of
wrapping cleanly. Confirmed reproducible at 320px, not a one-off
capture glitch, before settling on the flex-shrink-only fix.

Verified 500px (the width mara flagged) and 1400px both clean, no
overflow, no overlap. Below ~450px there's now a real mathematical
floor — icon + main's shortest-unbreakable-word + pills' widest-single-
badge together exceed the viewport — that's a genuine follow-up scope
(icon-only badges or similar at that point), not something this fix
claims to solve; flagging rather than silently leaving it implied.

tsc n/a (CSS-only change), build clean, both pre-push lints clean.
2026-08-28 22:54:15 +02:00
iris
d7e70fd195 agent: badges join the pills cluster, not the other way around
mara, live, correcting the prior round: 'oh i meant move badges to
where the dropdowns are not the other way around. this way the height
does not change even on widescreen.'

Prior commit nested pills INTO a new row inside .agent-header-main
(badges + pills sharing a row under the title). Backwards from what
she wanted: badges now join .agent-header-pills instead — the same
header-level column pills already lived in, which already handles a
variable child count via flex-wrap + justify-content: flex-end.
.agent-header-main goes back to just the title + hive-label rows,
which don't wrap in practice, so its height stays stable regardless of
how many badges/pills are showing.

The ResizeObserver dynamic-height fix from the prior commit is
untouched and still does the real work of keeping .agent-status-
overlay's offset correct if anything does wrap — this is a pure
layout-preference change on top of that, not a new bugfix.

Verified at 500/1024/1400px — badges+pills form one right-aligned
cluster next to the title, no overlap, no ResizeObserver console
error. tsc --noEmit clean, build clean, both pre-push lints clean.
2026-08-28 22:47:49 +02:00
iris
f49e236c74 agent: dynamic header height, badges+pills one row, composer alignment
mara, live: 'login panel still looks like it is behind', 'bottom input
row text not aligned / inconsistent sizes', 'can we put the badges on
the same row as the links button etc? then the header would be more
compact.'

- Real fix for the overlap this time, not another band-aid: the
  header's downstream offset (.agent-status-overlay, .agent-main's
  scroll padding) was reading a STATIC --agent-header-h guess (6em).
  Every prior fix on this PR removed one specific contributor to the
  header wrapping past that guess (meta-nav's inline list, then
  disconnected badge/pills clusters) but the guess itself was always
  the actual defect — any content that can wrap an extra line breaks
  it at whatever width triggers it, and no width is safe to promise.
  Header.tsx now measures its own rendered height via ResizeObserver
  and writes it to a new --agent-header-real-h var; downstream
  consumers prefer that over the static guess. Deliberately a SEPARATE
  var from --agent-header-h, not an overwrite: .agent-header's own
  min-height also reads that var, so overwriting it in place created
  an actual ResizeObserver feedback loop (confirmed live on the first
  pass of this fix, fixed by splitting the var).
- Badges (StatusChips) + pills (inbox/todos/links/overflow) now share
  one row (.agent-state-row) instead of two visually disconnected
  clusters (old app.js markup's 3-column layout, which the new page
  had also carried over) — pills pushed to the row's right edge via a
  margin-left: auto rule scoped to that nesting, so app.js's own
  still-live separate-column usage of the same classes is untouched.
- Composer text alignment: real cause was .prompt (1em) and
  .submit-hint (0.8em) each getting a different-sized default line-box
  under align-items: flex-start, so the same nominal padding-top
  landed at different absolute offsets. align-items: center sidesteps
  the whole line-box-size mismatch. This one's shared with app.js too
  (same markup/classes) — same bug there, now fixed for both.

Verified at 480/500/768/1024/1400px — no overlap, no ResizeObserver
loop, badges+pills share one row, composer aligned. tsc --noEmit
clean, build clean, both pre-push lints clean.
2026-08-28 22:30:35 +02:00
iris
3ce961e5bd agent: meta-nav popout + header height fix + badge row consistency
mara, live review: 'the login card looks like it is behind the header',
'badges and interactive elements on same row have inconsistent size',
'the links should have the same popout as the links in the nav bar of
swarm-ui.' All three, addressed:

- Root cause of the overlap: MetaNav rendered every link inline in the
  header's title row, a variable-width list that can grow the row past
  the fixed --agent-header-h the rest of the page (position: fixed
  header, position: absolute content below it) is offset against, so
  the header visually covers whatever's underneath it once it's
  actually taller than assumed. Fix: MetaNav is now a single
  fixed-size trigger (reuses @hive/shared's Badge, icon-only, same
  shape as the header's existing overflow trigger) in the pills
  cluster, not a variable-width list in the title row. A single badge
  can't grow the row regardless of how many links the backend sends.
  Verified at 1024px/1400px (no overlap, was previously untested below
  1400px) — the deeper fragility (StatusChips' badge row itself can
  still wrap on a genuinely narrow/mobile viewport and hit the same
  class of bug) is real but predates this PR and is out of scope here.
- The popover mara asked for: same visual language as swarm-ui's own
  LinksMenu (Shell/LinksMenu.tsx) and this page's Dropdown/OverflowMenu
  — quiet icon trigger, popover on click. Not built from @hive/shared's
  Dropdown itself (its items are always <button>s for command dispatch,
  which would lose real <a> link semantics — ctrl/middle-click, copy
2026-08-28 22:05:13 +02:00
iris
df78236ce4 agent: meta-nav, favicon fallback, document.title
Ports the 3 remaining gaps argus's lost-functionality audit found beyond
login: the header meta-nav strip (stats/screen/forge/config +
hyperhive.dashboardLinks extras — the only on-page path to those, not
just polish), the header icon's /favicon.svg fallback on a broken
image, and the browser tab title update. All three are straight ports
of app.js's existing logic (refreshState's meta-links loop,
bindHeaderIconFallback, setHeader's document.title block), same
kind -> URL resolution rules, same dataset.fallback loop guard, same
qualified_label/hive_name fallback chain — no new backend fields
needed, hive_agent::web_ui::state::StateSnapshot already serves
links/forge_public_url and useAgentState's whole-payload cast already
threads them to Root.

New <MetaNav> component (reuses agent.css's existing .agent-nav/
.agent-nav-link rules verbatim, no new CSS) renders in Header's title
row via a new nav prop. Favicon fallback lives in Header itself
(onError handler on the .agent-icon <img>). document.title is a
useEffect in Root keyed on label/qualified_label/hive_name.

tsc --noEmit clean, build clean, both pre-push lints clean. Screenshot
verifies meta-nav renders 4 links (stats/forge/config/extra, forge
kind resolving against a mocked forge_public_url) and the favicon
fallback firing against a deliberately-404'd /icon.
2026-08-28 22:05:13 +02:00
iris
b56696d898 agent: LoginFlow — one card, matching Dropdown's popup visual language
mara: "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 faithful port
of app.js's el().append()-per-line but not what a hand-authored
component should do. Wraps each state's content in one .login-card
div instead, styled to match Dropdown's .ui-dropdown (@hive/shared)
background/border/radius/shadow values exactly, so this reads as the
same popup family as every other floating surface in the app rather
than a one-off. Not the Dropdown component itself — its options.map
render is menu-item-shaped, not a fit for a login form + output pane.

agent.css's .agent-status-overlay > * rule is untouched (app.js's
still-live markup depends on it verbatim until the index.html
cutover) — .login-card coexists with it rather than editing it,
correctly overriding on equal specificity since main.css loads after
agent.css.
2026-08-28 22:05:13 +02:00
iris
ae94fff5af agent: LoginFlow — the needs_login recovery UI app.js's rewrite was missing
Ports app.js's renderNeedsLoginIdle/renderLoginInProgress as real
functional Preact UI, not just a StatusChip label: a start-login
button (POST login/start), the OAuth-URL link once claude emits it,
a code-paste form (POST login/code, masked + reveal toggle) with
cancel (POST login/cancel), and the streamed process output. Without
this, an agent needing re-login (post-/logout, credential rotation)
had no web-UI path back in through the new page — the gap I flagged
before the cutover; mara: "1 - do it now".

Reuses agent.css's existing .agent-status-overlay/.status-needs-login/
.btn-login/.loginform*/.diff rules verbatim (same class names as the
old markup), matching Header.tsx's precedent — no new CSS file needed.
Added SessionView to types.ts (mirrors hive-agent's SessionView struct
exactly: url/output/finished/exit_note) and lib/loginAction.ts (same
{ok,detail} fetch shape as modelEffort.ts — same-origin POSTs, no CORS
concerns like pauseAction.ts's dashboard-origin actions).

Screenshot-verified all 3 states (idle, in-progress-with-url, finished
with error) against a mock server.
2026-08-28 22:05:13 +02:00
iris
48a1745bb5 agent: OverflowMenu — trimmed to rebuild + dashboard link
Deliberately trimmed vs. app.js's `populateOverflowMenu`, which held
dashboard-link/rebuild/new-session/logout/model-picker/effort-picker
all in one flat list — the design guide's own named junk-drawer
anti-example. Model/effort already moved to StatusChips' own
Badge+Dropdown controls; new-session/logout now live as TermInput
slash commands (typed, with their own two-step confirm). What's left —
rebuild + a dashboard back-link — is genuinely menu-shaped (rare, not
tied to any other visible control), so it's still a `⋯` menu, just a
much shorter one.

Reuses `@hive/shared`'s Badge+Dropdown pair (same anchored-popover
shape as StatusChips' model/effort pickers), not a bespoke popover.
Rebuild's confirm is the same "select once to arm, select again to
fire" idea as the slash commands, adapted to a menu click. New
`lib/rebuildAction.ts` — same cross-origin-needs-a-real-form-submit
reasoning as pauseAction.ts (hive-c0re's `/api/rebuild/{name}` has no
CORS header either).

Found + fixed a real layout gap in the shared Dropdown while
screenshot-verifying: its CSS anchors left-edge-to-left-edge, correct
for a picker with room to its right, but the overflow trigger is the
header's right-most element, so the menu ran off-screen. Fixed with a
scoped override in this component's own CSS (`.header-overflow-anchor
.ui-dropdown { left: auto; right: 0; }`) rather than changing the
shared default, which is still correct for every other caller.
2026-08-28 22:05:13 +02:00
iris
d9249a36bf agent: TermInput — composer + slash commands
Ported from app.js's `renderTermInput`/`handleSlashCommand`/
`completeSlash`: prompt + auto-growing textarea, Enter sends (Shift+Enter
newline), Tab cycles slash-command completion. Same command set
(/help, /clear, /cancel, /compact, /model, /effort, /new-session,
/logout), same routes (`api/cancel`, `api/compact`, `api/new-session`,
`api/logout`, `send`) via new `lib/termActions.ts` (same `{ok, detail}`
shape as modelEffort.ts's POST helper).

One deliberate UX change: `/new-session` and `/logout` used to pop the
old shadow-DOM `themedConfirm` modal before firing — this rewrite's
destructive actions all avoid that widget family already (SidePanel,
useConfirmClick), and a modal doesn't fit a text-input flow anyway.
Typing the command once arms it (a local note explains what confirming
does); typing it again fires it — a keyboard-native two-step confirm.

`/help`/`/clear` need to reach into LiveStream's row list (local-only
echo rows, never sent anywhere) without lifting that state up to Root —
`useLiveStream` gained `pushLocalNote`/`clearLocal`, exposed off
`LiveStream` via `forwardRef`+`useImperativeHandle` (preact/compat),
same shape as app.js's old `termAPI` object but scoped as a ref handle
instead of a module-level variable.

Screenshot-verified end to end: typed "/help" + Enter into a real
mounted composer, confirmed the textarea clears and the local note rows
(command list) append to the live pane.
2026-08-28 22:05:13 +02:00
iris
190d810762 agent: TodosPanel/InboxPanel flyouts + header pills
New Preact `SidePanel` drawer — deliberately not the shared
`<hive-side-panel>` shadow-DOM custom element, same rationale as
StatusChips' Badge/Dropdown. Drops that element's drag-to-resize +
localStorage width persistence for this first slice (fixed width via
CSS); reuses agent.css's existing `.agent-inbox`/`.inbox-*`/
`.side-panel-empty` content classes verbatim, only the drawer chrome
itself is new (SidePanel.css). Kept mounted regardless of open/closed
state (toggles the `open` prop) rather than conditionally rendered, so
the slide/fade CSS transitions actually fire on both open and close.

- InboxPanel: renders `state.inbox` (already polled by useAgentState)
  + a "mark all read" action — plain `fetch()` to the dashboard's
  `/api/agent/{name}/mark-all-read` (JSON response, same as app.js;
  unlike pause/resume this one doesn't need a form-submit workaround).
- TodosPanel: new `useTodos` hook (same 4s poll cadence as app.js's
  `refreshTodos`) + bulk select/mark-done via `api/todos/mark-done`.
- Both destructive-but-recoverable actions use a new `useConfirmClick`
  two-click-arm hook instead of the old shadow-DOM `themedConfirm`
  modal — lighter weight, no backdrop/dialog machinery needed for a
  "did you mean to click that" nudge.
- HeaderPill: the inbox/todos count triggers in the header's right
  cluster; Header.tsx gained a `pills` slot for them (mirrors the old
  markup's `.agent-header-pills` third column).
- types.ts: added `InboxRow`/`TodoRow`, mirroring
  `hive_sh4re::inbox::{InboxRow, LooseEnd}` (todo variant only —
  `/api/todos` never returns the others).

Screenshot-verified against a mock server exercising both panels.
2026-08-28 22:05:13 +02:00
iris
4a55a9a6e8 agent: fix CI — drop tracker tag in checks.nix comment, regenerate npmDepsHash
- nix/checks.nix: 'hyperhive#3685' in a source comment trips the
  tracker-tag lint (prose-only, per hive-rules.md) — argus caught it on
  review. Reworded without the ref.
- frontend/npm-deps-hash: the earlier scaffold commit
  (be18f460) added preact/typescript to agent/package.json, which
  regenerated package-lock.json but never re-derived this hash in
  lockstep — nix flake check's frontend derivation was failing on a
  hash mismatch. Regenerated via
  'nix run nixpkgs#prefetch-npm-deps -- frontend/package-lock.json'.
2026-08-28 22:05:13 +02:00
iris
99c614cd54 agent: address argus review notes — stale comment + CI typecheck wiring
- StatusChips.tsx: drop the stale 'useAgentState lands in a later
  commit' comment now that Root.tsx already wires it.
- nix/checks.nix: add agent-typecheck, same shape as swarm-ui-typecheck,
  so a real TS error in the agent package fails CI instead of just
  esbuild-stripping silently.
2026-08-28 22:05:13 +02:00
iris
8521c6becb agent: LiveStream/Row/DetailsRow — Preact live event pane
Ports the row taxonomy in docs/terminal-rendering.md (app.js's
renderStream/renderRichToolUse/renderToolResult/renderTaskEvent) to
real Preact components + hooks:

- lib/streamRow.ts: plain-data StreamRow model (one row = one line/
  panel), lib/classifyEvent.ts: raw stream-json event -> StreamRow[],
  almost entirely dispatching on the backend's precomputed _icon/
  _summary/_category/_body/_body_type fields, same as the old client.
- lib/markdown.ts, lib/linkify.tsx: sanitized-markdown + auto-link
  helpers, same behavior as app.js's mdNode/terminal.js's linkify.
- hooks/useLiveStream.ts: backfill + SSE + seq-dedupe + coalescing,
  reduced to a plain StreamRow[] — deliberately has no opinion on
  scroll position, only on what rows exist.
- components/Row.tsx: renders one StreamRow (flat or details).
- components/LiveStream.tsx: owns the scrollable DOM node + a from-
  scratch sticky-bottom implementation — not the old MutationObserver
  + rAF snap-animation + smoothScrollingUntil gate. stickToBottom is
  plain state driven by the scroll handler; snapping is an instant
  scrollTop write in a useLayoutEffect that runs after Preact has
  already committed the new rows, so it always sees the real
  scrollHeight. A load-older prepend is told apart from a normal
  append via an explicit ref set right before calling loadMore(),
  rather than inferred from DOM mutation shape after the fact.

Wired into Root.tsx below Header/StatusChips; turn_start/turn_end
also nudge useAgentState's refresh() for a snappier badge update than
the plain poll interval.

Reuses @hive/shared/terminal.css's existing row-kind classes as-is —
the taxonomy's visual language isn't what changed, the component
model underneath it is.
2026-08-28 22:05:13 +02:00
iris
908f479372 agent: wire Header/StatusChips to real /api/state polling
- useAgentState hook: polls GET /api/state (4s interval for now — see
  its file comment for why this isn't yet the SSE-triggered + login-
  only-timer cadence the old page used; that lands with the live
  stream + term-input commit, which is when clobbering the operator's
  in-progress input actually becomes a risk).
- format.ts: fmtTokens/fmtAge, same output shapes as app.js's.
- modelEffort.ts: POST /api/model + /api/effort (same-origin, plain
  fetch).
- dashboardBase.ts + pauseAction.ts: pause/resume POST to the
  *dashboard's* origin via a real <form> submit, kept unchanged from
  app.js — a cross-origin fetch needs CORS headers hive-c0re doesn't
  send, a form submit sidesteps that same as it already did.
- Root.tsx: wires it all together, including app.js's "any non-online
  status forces the turn-state badge to offline" behavior.

Screenshot-verified against a mock GET /api/state (real fetch, not
hardcoded props) + the real agent.css/theme.css/colors.css.

Builds + tsc --noEmit clean.
2026-08-28 22:05:13 +02:00
iris
93b34ebb9b agent: scaffold Preact rewrite, Header + StatusChips first slice
Adds a new Preact/TSX build alongside the existing app.js (esbuild
entry `main.tsx` → dist/static/main.{js,css}, same jsx/tsconfig shape
swarm-ui already uses) and the first real page slice: Header +
StatusChips, composing the Badge/Dropdown components from the prior
commit's PR. Not wired into index.html yet — app.js keeps rendering
the live page untouched while this fills in component by component
(state polling, the live SSE stream, login flow, inbox/todos, term
input) in follow-up commits on this branch.

StatusChips folds the model/effort pickers and pause into the badge
row itself (each badge IS its own control), replacing the old
overflow-menu-only pickers — the concrete fix the design guide already
names this page as needing. Presentational only for now (props, not
live data) so it's reviewable against sample data before being wired
to /api/state.

Screenshot-verified against the real agent.css/theme.css/colors.css
(headless chromium, sample data) — renders correctly.

Builds + tsc --noEmit clean.
2026-08-28 22:05:13 +02:00
43 changed files with 2697 additions and 17 deletions

View file

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

View file

@ -991,7 +991,11 @@
"@hive/shared": "*", "@hive/shared": "*",
"chart.js": "4.5.1", "chart.js": "4.5.1",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"marked": "18.0.6" "marked": "18.0.6",
"preact": "10.29.8"
},
"devDependencies": {
"typescript": "7.0.2"
} }
}, },
"packages/dashboard": { "packages/dashboard": {

View file

@ -47,6 +47,25 @@ await build({
loader: { '.css': 'text' }, 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 // Bundle the CSS. `colors.css` re-exports the standalone base16 palette
// (the theme swap contract — its own output file so a swap replaces only // (the theme swap contract — its own output file so a swap replaces only
// it, no bundle rebuild); `theme.css` is the semantic derivation layer; // it, no bundle rebuild); `theme.css` is the semantic derivation layer;

View file

@ -5,12 +5,17 @@
"description": "hive-ag3nt per-container web UI. Bundled by esbuild into a static dist; served by the in-container Rust binary at runtime via tower_http::ServeDir. Per-agent additions are layered on top via the hyperhive.frontend.extraFiles nix option.", "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", "type": "module",
"scripts": { "scripts": {
"build": "node ./build.mjs" "build": "node ./build.mjs",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@hive/shared": "*", "@hive/shared": "*",
"chart.js": "4.5.1", "chart.js": "4.5.1",
"dompurify": "^3.2.4", "dompurify": "^3.2.4",
"marked": "18.0.6" "marked": "18.0.6",
"preact": "10.29.8"
},
"devDependencies": {
"typescript": "7.0.2"
} }
} }

View file

@ -0,0 +1,200 @@
// <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,15 +129,30 @@ body.agent-shell {
gap: 0.5em; gap: 0.5em;
} }
/* Right cluster — flyout pills stacked / inline with the overflow /* Right cluster — flyout pills, and (new Preact page only) the status
trigger. Vertically centred against the full-height icon, no badges (`StatusChips`) too — mara, live: "move badges to where the
wrap; pills can drop to a row of their own under crowding via dropdowns are". `flex-wrap: wrap` alone doesn't help if this
the flex-wrap of `.agent-header-pills` itself. */ container can't itself shrink: the old `flex-shrink: 0` here (fine
for app.js's 2-3-small-pills case, which never needed to shrink)
told the flex layout "give this its full natural width no matter
what," so once badges made that natural width wider than the
viewport, it just overflowed past the edge instead of ever getting
narrow enough to trigger its own wrap — mara, live, on the 500px
screenshot: "does not overflow properly". Dropping `flex-shrink: 0`
(default is `1`) is the whole fix — deliberately NOT pairing it
with `min-width: 0` (tried that first, made it worse: an unset
`min-width` still floors shrinking at this container's own
min-content — the widest single wrapped child, e.g. one badge, not
the full unwrapped row — which is the correct floor; `min-width: 0`
removes that floor entirely, so at a narrow enough viewport the
container shrinks past what its own content needs and the content
overflows its box sideways, landing on top of `.agent-header-main`
instead of wrapping cleanly — confirmed live, reproducible at
320px, not a one-off capture glitch). */
.agent-header-pills { .agent-header-pills {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5em; gap: 0.5em;
flex-shrink: 0;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
align-self: center; align-self: center;
@ -357,10 +372,16 @@ h2, h3 {
/* Login flow overlay: only rendered when status != online. Sits /* Login flow overlay: only rendered when status != online. Sits
centred over the (likely-empty) terminal area; doesn't take chrome centred over the (likely-empty) terminal area; doesn't take chrome
space in the normal online flow. */ space in the normal online flow. `--agent-header-real-h` (set by
the new Preact page's `Header.tsx` via ResizeObserver, falling back
to the static `--agent-header-h` guess before the first
measurement / on the old app.js page, which never sets it) is the
header's ACTUAL rendered height, not an assumed one — see
Header.tsx's file comment for why this matters (a static guess is
wrong the moment the header wraps an extra line on some viewport). */
.agent-status-overlay { .agent-status-overlay {
position: absolute; position: absolute;
top: calc(var(--agent-header-h) + 1.5em); top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 1.5em);
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
max-width: 44em; max-width: 44em;
@ -716,9 +737,9 @@ pre.diff {
and last rows reachable with extra padding inside the scroll and last rows reachable with extra padding inside the scroll
area. scroll-padding-* keeps anchor-jumps (the `↓ N new` pill, area. scroll-padding-* keeps anchor-jumps (the `↓ N new` pill,
focus restore) clear of the floats too. */ focus restore) clear of the floats too. */
padding-top: calc(var(--agent-header-h) + 0.8em); padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
padding-bottom: calc(var(--agent-composer-h) + 0.8em); padding-bottom: calc(var(--agent-composer-h) + 0.8em);
scroll-padding-top: calc(var(--agent-header-h) + 0.8em); scroll-padding-top: calc(var(--agent-header-real-h, var(--agent-header-h)) + 0.8em);
scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em); scroll-padding-bottom: calc(var(--agent-composer-h) + 0.8em);
overflow: auto; overflow: auto;
} }
@ -739,7 +760,21 @@ pre.diff {
rules below stay scoped to whichever ancestor owns it. */ rules below stay scoped to whichever ancestor owns it. */
.term-input .sendform-term { .term-input .sendform-term {
display: flex; display: flex;
align-items: flex-start; /* mara, live: "bottom input row text not aligned / inconsistent
sizes." Real cause: `.prompt` (1em, no line-height override) and
`.submit-hint` (0.8em, no line-height override) each get a
browser-default line-box at a different em-relative size, so
`align-items: flex-start` (their old value) pinned each one's
line-box top edge to the row's top — same nominal `padding-top`
but a DIFFERENT absolute offset per element, since 0.25em means
something different at 1em vs 0.8em font-size. `center` sidesteps
the whole "line-box for text this size" mismatch by aligning each
item's own box middle instead of a top edge that was never
actually equal. Still correct once the textarea grows multi-line
(a common composer-bar convention — Slack/Discord centre their
send affordance against a grown box too, not pin it to the first
line). */
align-items: center;
gap: 0.5em; gap: 0.5em;
/* The dashed in-frame separator is dropped — see the /* The dashed in-frame separator is dropped — see the
.agent-composer .term-input override above for the floating-bar .agent-composer .term-input override above for the floating-bar
@ -747,9 +782,6 @@ pre.diff {
border-top: 1px dashed var(--purple-dim); border-top: 1px dashed var(--purple-dim);
padding-top: 0.5em; padding-top: 0.5em;
} }
.term-input .prompt, .term-input .submit-hint {
padding-top: 0.25em;
}
.term-input .prompt { .term-input .prompt {
color: var(--green); color: var(--green);
text-shadow: 0 0 6px color-mix(in srgb, var(--green) 60%, transparent); text-shadow: 0 0 6px color-mix(in srgb, var(--green) 60%, transparent);

View file

@ -0,0 +1,99 @@
// <Header> — the agent page's identity strip: icon, glyphic title, the
// "swarm / hive" label row, and a slot for the status row
// (`<StatusChips>` — ../status-chips/, kept separate so this component
// has no opinion on what badges exist). Same three-column shape as the
// old `#agent-header` markup (icon · main · pills) — see the `pills`
// prop doc below for what moved into that third column and why.
// Reuses `agent.css`'s existing `.agent-header*`/`.agent-icon` rules
// (loaded globally by index.html) rather than a component-scoped
// stylesheet — no new visual language needed for the chrome itself.
// Measures its own rendered height via ResizeObserver, writes it to
// `--agent-header-real-h` (agent.css's downstream consumers —
// `.agent-status-overlay`, `.agent-main`'s scroll padding — prefer
// this over the static `--agent-header-h` guess). A static guess was
// the root cause of two "login card behind the header" reports on
// this PR (mara, live): any header content that can wrap an extra
// line breaks the guess at whatever width triggers it, and no width
// can be promised safe. Deliberately a SEPARATE var, not an overwrite
// of `--agent-header-h` in place: `.agent-header`'s own `min-height`
// also reads that var — overwriting it in place would make the
// header's own size react to its own just-measured height every
// tick, a real ResizeObserver feedback loop (confirmed live on the
// first pass of this fix). `--agent-header-h` stays the static floor;
// `--agent-header-real-h` is output-only.
import { useEffect, useRef } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
// Icon 404s when the agent has no `hyperhive.icon` override (no
// bundled server-side default any more — see
// `hive_agent::web_ui::screen::serve_icon`). Ports app.js's
// `bindHeaderIconFallback` exactly: fire-and-forget load, swap to
// `/favicon.svg` on failure, `dataset.fallback` guards against a
// 404-on-the-fallback-itself loop.
function handleIconError(e: Event) {
const img = e.currentTarget as HTMLImageElement;
if (img.dataset.fallback) return;
img.dataset.fallback = '1';
img.src = '/favicon.svg';
}
export interface HeaderProps {
label: string;
hiveLabel?: string | null;
/** The status/badge row (`<StatusChips>`) — lives in the
* `.agent-header-pills` cluster now, alongside `pills` (see below),
* not stacked as its own row under the title. */
children?: ComponentChildren;
/** Flyout triggers (inbox/todos/links pills, the overflow menu
* button) — and, since this round, `children` (the status badges)
* too, both in `.agent-header-pills`, the SAME header-level column
* the old markup used for pills alone. First pass of this ask
* nested pills into a row inside `.agent-header-main` instead —
* backwards, mara corrected live: "i meant move badges to where the
* dropdowns are, not the other way around. this way the height does
* not change even on widescreen" — `.agent-header-main` now only
* ever holds the title + hive-label rows, which don't wrap in
* practice, so it stays a stable height; only this pills-cluster
* column (already used to absorbing a variable pill count) grows to
* fit badges+pills together. Meta-nav (`<MetaNav>`) lives in here
* too, as a fixed-size trigger — not the old markup's inline list in
* the title row (`<nav id="meta-links">`, docs/web-ui/agent.md). */
pills?: ComponentChildren;
}
export function Header({ label, hiveLabel, children, pills }: HeaderProps) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
const el = ref.current;
if (!el || typeof ResizeObserver === 'undefined') return;
// getBoundingClientRect() (not ResizeObserver's own contentRect) —
// we want the full visual box padding+border included, the thing
// content below the fixed header actually needs to clear, not the
// content-box-only measurement ResizeObserver's entry defaults to.
const ro = new ResizeObserver(() => {
document.documentElement.style.setProperty('--agent-header-real-h', `${el.getBoundingClientRect().height}px`);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return (
<header class="agent-header" ref={ref}>
<img class="agent-icon" src="icon" alt="" onError={handleIconError} />
<div class="agent-header-main">
<div class="agent-header-row agent-header-title-row">
<h2 class="agent-header-title">◆ {label} ◆</h2>
</div>
{hiveLabel ? <div class="agent-header-row agent-hive-row">{hiveLabel}</div> : null}
</div>
{children || pills ? (
<div class="agent-header-pills">
{children}
{pills}
</div>
) : null}
</header>
);
}

View file

@ -0,0 +1,26 @@
// <HeaderPill> — the inbox/todos flyout triggers in the header's right
// cluster. Reuses agent.css's existing `.hive-pill.header-pill…` rules
// (loaded globally) — same "layout is a like-for-like port, the
// component model is the change" approach as Header.tsx. Hidden
// (renders nothing) at count 0, matching the old page's `pill.hidden =
// count === 0`.
export interface HeaderPillProps {
kind: 'inbox' | 'todos';
icon: string;
label: string;
count: number;
onClick: () => void;
}
export function HeaderPill({ kind, icon, label, count, onClick }: HeaderPillProps) {
if (count === 0) return null;
return (
<button type="button" class={`hive-pill header-pill header-pill-${kind}`} onClick={onClick} title={`open ${label} flyout`}>
<span class="header-pill-icon" aria-hidden="true">
{icon}
</span>
<span class="header-pill-label">{label}</span>
<span class="header-pill-count">{count}</span>
</button>
);
}

View file

@ -0,0 +1,86 @@
// <InboxPanel> — content rendered inside <SidePanel> for the inbox
// flyout. "mark all read" POSTs to the *dashboard's* origin (not this
// agent's own backend) via a plain `fetch()`, same as app.js's
// `buildInboxMarkAllRow` — unlike pause/resume this one's fine as a
// fetch even when the two are genuinely cross-origin, because
// `hive-c0re`'s `/api/agent/{name}/mark-all-read` route replies with
// JSON, not a redirect; in the common gateway-fronted deployment
// `dashboardBase` already resolves to `location.origin` anyway (see
// dashboardBase.ts), so it's same-origin in practice.
import { useState } from 'preact/hooks';
import { useConfirmClick } from '../lib/useConfirmClick.js';
import type { InboxRow } from '../types.js';
export interface InboxPanelProps {
rows: InboxRow[];
label: string;
dashboardBase: string;
onCleared: () => void;
}
function fmtTs(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toISOString().replace('T', ' ').slice(5, 19);
}
export function InboxPanel({ rows, label, dashboardBase, onCleared }: InboxPanelProps) {
const [status, setStatus] = useState('');
const [busy, setBusy] = useState(false);
async function markAllRead() {
if (!dashboardBase || !label) {
setStatus('dashboard url / label unknown');
return;
}
setBusy(true);
setStatus('clearing…');
try {
const resp = await fetch(`${dashboardBase}api/agent/${encodeURIComponent(label)}/mark-all-read`, {
method: 'POST',
});
if (resp.ok) {
const data = await resp.json().catch(() => ({}));
const n = Number((data as { marked?: number }).marked) || 0;
setStatus(`✓ marked ${n} as read`);
onCleared();
} else {
setStatus(`failed: http ${resp.status}`);
}
} catch (err) {
setStatus(`failed: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setBusy(false);
}
}
const { armed, trigger } = useConfirmClick(markAllRead);
if (!rows.length) {
return <p class="side-panel-empty">inbox empty.</p>;
}
return (
<div class="agent-inbox">
<div class="inbox-mark-all-row">
<button
type="button"
class="inbox-mark-all-btn"
onClick={trigger}
disabled={busy}
title="mark every queued message for this agent as read — drains the host broker's pending + delivered-unacked rows. history shown here is the most-recent-N regardless of state, so the list itself stays visible."
>
{armed ? 'sure? click again' : '✓ mark all read'}
</button>
<span class="inbox-mark-status">{status}</span>
</div>
<ul>
{rows.map((m) => (
<li key={m.id} class={m.in_reply_to != null ? 'inbox-reply' : undefined}>
{m.in_reply_to != null && <span class="inbox-reply-tag">↳ reply · </span>}
<span class="inbox-ts">{fmtTs(m.at)}</span> <span class="inbox-from">{m.from}</span>{' '}
<span class="inbox-sep">→</span> <span class="inbox-body">{m.body}</span>
</li>
))}
</ul>
</div>
);
}

View file

@ -0,0 +1,127 @@
// Live event pane — Preact owner of the DOM node `useLiveStream`'s rows
// get rendered into. Deliberately NOT the old MutationObserver + rAF
// snap-animation + `smoothScrollingUntil` gate machinery
// (@hive/shared/terminal.js) — mara explicitly flagged that the old
// page's autoscroll breaks when new events land while the operator has
// scrolled up, and not to copy it 1:1. This is the "do it properly the
// preact way" version:
//
// - `stickToBottom` is plain state, updated synchronously from the
// scroll handler (near-bottom → true, scrolled away → false). No
// separate "am I mid-animation" gate to fight with it — there's no
// animation to fight, snapping is an instant `scrollTop` write in a
// `useLayoutEffect` that runs once after Preact has already
// committed the new rows to the DOM, so it always sees the real
// `scrollHeight`, never a stale pre-mutation one.
// - A "load older" prepend is told apart from a normal append via
// `prependingRef` (set immediately before calling `loadMore()`) so
// the effect can choose the right response: viewport-preserving
// scrollTop compensation for a prepend, stick-to-bottom-or-count-
// unseen for an append. The old code inferred this from DOM
// mutation shape after the fact; here the caller already knows
// which one it did, so there's nothing to infer.
import { useLayoutEffect, useRef, useState, useImperativeHandle } from 'preact/hooks';
import { forwardRef } from 'preact/compat';
import { useLiveStream } from '../hooks/useLiveStream.js';
import { Row } from './Row.js';
const NEAR_BOTTOM_PX = 48;
const LOAD_MORE_SCROLL_PX = 80;
export interface LiveStreamProps {
/** Forwarded to useLiveStream — fires on live turn_start/turn_end so
* the caller can refresh `/api/state` sooner than its poll interval. */
onLiveTurnBoundary?: () => void;
}
/** Imperative escape hatch for TermInput's local-only slash commands
* (`/help`, `/clear`) — same shape as app.js's old `termAPI` object,
* kept as a ref handle rather than lifting the whole row array up to
* Root so LiveStream/useLiveStream still own their state privately. */
export interface LiveStreamHandle {
pushNote: (text: string) => void;
clear: () => void;
}
export const LiveStream = forwardRef<LiveStreamHandle, LiveStreamProps>(function LiveStream(
{ onLiveTurnBoundary },
ref,
) {
const { rows, hasMore, loadingMore, loadMore, pushLocalNote, clearLocal } = useLiveStream({ onLiveTurnBoundary });
useImperativeHandle(ref, () => ({ pushNote: pushLocalNote, clear: clearLocal }), [pushLocalNote, clearLocal]);
const logRef = useRef<HTMLDivElement>(null);
const [stickToBottom, setStickToBottom] = useState(true);
const [unseen, setUnseen] = useState(0);
const prevRowCount = useRef(0);
const prependingRef = useRef(false);
const preScrollHeightRef = useRef(0);
function isNearBottom(el: HTMLDivElement): boolean {
return el.scrollHeight - el.scrollTop - el.clientHeight <= NEAR_BOTTOM_PX;
}
function handleScroll() {
const el = logRef.current;
if (!el) return;
const nearBottom = isNearBottom(el);
setStickToBottom(nearBottom);
if (nearBottom) setUnseen(0);
if (el.scrollTop <= LOAD_MORE_SCROLL_PX && hasMore && !loadingMore) startLoadMore();
}
function startLoadMore() {
const el = logRef.current;
if (!el || loadingMore || !hasMore) return;
prependingRef.current = true;
preScrollHeightRef.current = el.scrollHeight;
loadMore();
}
function jumpToBottom() {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight - el.clientHeight;
setStickToBottom(true);
setUnseen(0);
}
// Runs after every commit that changed `rows` — i.e. after the browser
// has already laid out the new/coalesced content, so `scrollHeight`
// below is always current.
useLayoutEffect(() => {
const el = logRef.current;
if (!el) return;
const added = rows.length - prevRowCount.current;
prevRowCount.current = rows.length;
if (prependingRef.current) {
prependingRef.current = false;
el.scrollTop += el.scrollHeight - preScrollHeightRef.current;
return;
}
if (stickToBottom) {
el.scrollTop = el.scrollHeight - el.clientHeight;
} else if (added > 0) {
setUnseen((n) => n + added);
}
}, [rows, stickToBottom]);
return (
<div className="terminal-wrap">
<div className="live terminal" ref={logRef} onScroll={handleScroll}>
{hasMore && (
<button type="button" className="load-more-pill" disabled={loadingMore} onClick={startLoadMore}>
{loadingMore ? '↑ loading…' : '↑ load older'}
</button>
)}
{rows.map((row) => (
<Row key={row.key} row={row} />
))}
</div>
{unseen > 0 && (
<button type="button" className="tail-pill visible" onClick={jumpToBottom}>
↓ {unseen} new
</button>
)}
</div>
);
});

View file

@ -0,0 +1,25 @@
/* .login-card — one elevated-surface card for the whole login-recovery
overlay, replacing the old markup's per-`<p>`/`<form>` boxing
(`agent.css`'s `.agent-status-overlay > *`, still needed there
verbatim since app.js's still-live markup depends on it until the
index.html cutover — this file coexists with it rather than editing
it). Same visual language as `Dropdown`'s `.ui-dropdown`
(`@hive/shared/dropdown/Dropdown.css`) — background/border/radius/
shadow values match exactly, so this reads as the same "popup" family
as every other floating surface in the app, just laid out as a static
block instead of an anchored, position:absolute menu (that part of
`Dropdown` is specific to hanging under a trigger and doesn't apply
here — `.agent-status-overlay` already centres this card itself). */
.login-card {
display: flex;
flex-direction: column;
gap: 0.6em;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 0.5em;
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35);
padding: 1em 1.2em;
}
.login-card > * {
margin: 0;
}

View file

@ -0,0 +1,171 @@
// <LoginFlow> — the `needs_login_idle` / `needs_login_in_progress`
// overlay. Ports app.js's `renderNeedsLoginIdle`/`renderLoginInProgress`
// as real functional UI, not just a status label: an agent whose
// credentials got rotated (or hit `/logout`) has NO other way back
// online, so this is load-bearing, not cosmetic. Reuses agent.css's
// `.status-needs-login`/`.btn-login`/`.loginform`/`.loginform-reveal`
// rules verbatim (same class names as the old markup).
//
// mara (screenshot review): "reuse the popup component for the login,
// the floating separate panels should be one card." The old markup's
// `.agent-status-overlay > *` rule boxed every direct-child `<p>`/
// `<form>` separately (a fine translation of `el().append()`-per-line,
// but not what a hand-authored component should do) — this renders one
// `.login-card` instead, styled with the same "elevated surface" look
// `Dropdown`'s `.ui-dropdown` already established (`@hive/shared/
// dropdown/Dropdown.css`), not `Dropdown` itself (its `options.map`
// render is menu-item-shaped, not a generic content container — not a
// fit for a login form + output pane).
import { useState } from 'preact/hooks';
import type { SessionView } from '../types.js';
import { postLoginStart, postLoginCode, postLoginCancel } from '../lib/loginAction.js';
import './LoginFlow.css';
function LoginIdle({ onStarted }: { onStarted: () => void }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function submit(e: Event) {
e.preventDefault();
setBusy(true);
setError(null);
postLoginStart().then((r) => {
setBusy(false);
if (r.ok) onStarted();
else setError(r.detail ?? 'failed');
});
}
return (
<div class="login-card">
<p class="status-needs-login">◌ NEEDS L0G1N</p>
<p>
No Claude session in <code>~/.claude/</code>. The harness is up but the turn loop is
paused until you log in.
</p>
<form onSubmit={submit}>
<button type="submit" class="btn btn-login" disabled={busy}>
◆ ST4RT L0G1N
</button>
</form>
{error ? <p class="meta">error: {error}</p> : null}
<p class="meta">
Spawns <code>claude auth login</code> over plain stdio pipes. The OAuth URL will appear
here when claude emits it; paste the resulting code back into the form below.
</p>
</div>
);
}
function LoginInProgress({ session, onDone }: { session: SessionView | null; onDone: () => void }) {
const [code, setCode] = useState('');
const [reveal, setReveal] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const finished = session?.finished ?? false;
function submitCode(e: Event) {
e.preventDefault();
if (!code) return;
setBusy(true);
setError(null);
// Success: the next `/api/state` poll flips `status` back to
// `online` (or leaves it `needs_login_in_progress` on a bad code,
// with the process's own stderr showing up in `output` below) —
// nothing local to update here either way.
postLoginCode(code).then((r) => {
setBusy(false);
if (!r.ok) setError(r.detail ?? 'failed');
});
}
function cancel(e: Event) {
e.preventDefault();
setBusy(true);
postLoginCancel().then(() => {
setBusy(false);
onDone();
});
}
return (
<div class="login-card">
<p class="status-needs-login">◌ L0G1N 1N PR0GRESS</p>
{session?.url ? (
<>
<p>
▶{' '}
<a href={session.url} target="_blank" rel="noreferrer">
{session.url}
</a>
</p>
<p class="meta">
open this URL in a browser, complete the OAuth flow, paste the resulting code below.
</p>
</>
) : (
<p class="meta">waiting for claude to emit an OAuth URL on stdout… (output below)</p>
)}
{!finished ? (
<form class="loginform" onSubmit={submitCode}>
<input
name="code"
type={reveal ? 'text' : 'password'}
placeholder="paste OAuth code here (hidden)"
required
autocomplete="one-time-code"
spellcheck={false}
value={code}
onInput={(e) => setCode((e.target as HTMLInputElement).value)}
/>
<button
type="button"
class="loginform-reveal"
title="show / hide pasted code"
aria-label="show / hide pasted OAuth code"
aria-pressed={reveal}
onClick={() => setReveal((r) => !r)}
>
👁
</button>
<button type="submit" class="btn btn-login" disabled={busy}>
◆ S3ND C0DE
</button>
</form>
) : null}
<form style={{ marginTop: '0.4em' }} onSubmit={cancel}>
<button type="submit" class="btn btn-cancel" disabled={busy}>
cancel + kill
</button>
</form>
{finished ? (
<p class="status-needs-login">
claude process exited: {session?.exit_note || 'exited'}. Start over if needed.
</p>
) : null}
{error ? <p class="meta">error: {error}</p> : null}
<h3>output</h3>
<pre class="diff">{session?.output || ''}</pre>
</div>
);
}
export function LoginFlow({
status,
session,
onRefresh,
}: {
status: 'needs_login_idle' | 'needs_login_in_progress';
session: SessionView | null;
onRefresh: () => void;
}) {
return (
<div class="agent-status-overlay">
{status === 'needs_login_idle' ? (
<LoginIdle onStarted={onRefresh} />
) : (
<LoginInProgress session={session} onDone={onRefresh} />
)}
</div>
);
}

View file

@ -0,0 +1,42 @@
/* <MetaNav> popover — matches `@hive/shared`'s `Dropdown` (`.ui-dropdown`)
values exactly (background/border/radius/shadow, item padding/hover),
same "reuse the values, not the component" call `.login-card`
(LoginFlow.css) already made, for the same reason: this popover's
items are real `<a>` tags, not `Dropdown`'s command-dispatch
`<button>`s. Right-anchored, same as OverflowMenu.css's
`.header-overflow-anchor` — MetaNav sits in the same right-hand
pills cluster, so a left anchor would push it off-screen the same
way. */
.meta-nav-anchor {
position: relative;
display: inline-block;
}
.meta-nav-popover {
position: absolute;
top: calc(100% + 0.25em);
right: 0;
left: auto;
z-index: 20;
display: flex;
flex-direction: column;
min-width: 10em;
padding: 0.25em;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 0.5em;
box-shadow: 0 0.4em 1em rgba(0, 0, 0, 0.35);
}
.meta-nav-item {
display: flex;
align-items: center;
gap: 0.5em;
min-height: 2.75em;
padding: 0.4em 0.7em;
border-radius: 0.35em;
color: var(--fg);
text-decoration: none;
white-space: nowrap;
}
.meta-nav-item:hover {
background: var(--border);
}

View file

@ -0,0 +1,91 @@
// <MetaNav> — the header's meta-nav trigger: a single fixed-size Badge
// ("🔗") in the pills cluster that opens a popover listing
// stats/screen/forge/config + any `hyperhive.dashboardLinks` extras,
// sourced from the backend's `agent_links()` (the single source of
// truth — same list also feeds the dashboard card's icon strip).
//
// v1 of this rendered every link inline in the header's title row —
// mara, live: "the login card looks like it is behind the header"
// (a variable-width link list can grow the title row past the fixed
// `--agent-header-h` the rest of the page is offset against — see
// Header.tsx's comment) and "the links should have the same popout as
// the links in the nav bar of swarm-ui." This is that: same shape as
// swarm-ui's own `LinksMenu` (Shell/LinksMenu.tsx) — one quiet icon
// trigger, a popover on click, real `<a>` items (not a command-dispatch
// button list) so ctrl/middle-click and "copy link address" keep
// working. Trigger itself reuses `@hive/shared`'s `Badge` (icon-only,
// same shape as the header's `⋯` overflow trigger — same touch target,
// same hover/expanded treatment, one visual language for every header
// trigger) rather than a bespoke button. The popover isn't built from
// `@hive/shared`'s `Dropdown` — its items are always `<button>`s
// (command dispatch), which would lose real link semantics — but
// matches its `.ui-dropdown` visual values exactly (see MetaNav.css),
// the same "reuse the values, not the component" call `LoginFlow`'s
// `.login-card` already made for the same reason.
import { useEffect, useRef, useState } from 'preact/hooks';
import { Badge } from '@hive/shared/badge.js';
import type { AgentLink } from '../types.js';
import './MetaNav.css';
export interface MetaNavProps {
links: AgentLink[];
forgePublicUrl: string | null;
}
export function MetaNav({ links, forgePublicUrl }: MetaNavProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// Same "outside click or Escape closes" contract as Dropdown/OverflowMenu.
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) setOpen(false);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') setOpen(false);
}
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
// Same kind -> URL resolution as app.js's old refreshState meta-links
// loop: `forge` needs `forgePublicUrl` set or the link is dropped
// entirely (never guessed from `<host>:3000`); `external` is already
// absolute; `container` is a same-origin path.
const visible = links.filter((lnk) => lnk.kind !== 'forge' || forgePublicUrl);
if (visible.length === 0) return null;
return (
<div class="meta-nav-anchor" ref={rootRef}>
<Badge value="🔗" title="agent links" onClick={() => setOpen((o) => !o)} expanded={open} />
{open ? (
<div class="meta-nav-popover" role="menu" aria-label="agent links">
{visible.map((lnk) => {
const href = lnk.kind === 'forge' ? `${forgePublicUrl}${lnk.url}` : lnk.url;
return (
<a
key={lnk.url}
class="meta-nav-item"
href={href}
target="_blank"
rel="noopener"
role="menuitem"
onClick={() => setOpen(false)}
>
{lnk.icon ? (
<span aria-hidden="true">{lnk.icon}</span>
) : null}
{lnk.label}
</a>
);
})}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,19 @@
/* Anchors <Dropdown> under the "⋯" trigger — same positioning
contract as StatusChips.css's `.status-chip-anchor`, kept as its own
rule here rather than reused across components (see this file's
sibling .tsx comment) so OverflowMenu doesn't depend on StatusChips'
incidental CSS. */
.header-overflow-anchor {
position: relative;
display: inline-block;
}
/* @hive/shared's Dropdown.css anchors left-edge-to-left-edge, correct
for a picker with room to its right (StatusChips' model/effort). The
overflow trigger is the header's right-most element instead, so a
left anchor pushes the menu off-screen — flip to right-edge-to-
right-edge here, scoped to this one consumer rather than changing
the shared default (which is still correct for every other caller). */
.header-overflow-anchor .ui-dropdown {
left: auto;
right: 0;
}

View file

@ -0,0 +1,66 @@
// <OverflowMenu> — the header's `⋯` trigger. Deliberately trimmed vs.
// app.js's `populateOverflowMenu`: that one held dashboard-link/rebuild/
// new-session/logout/model-picker/effort-picker all in one flat list —
// the design guide's own named anti-example for a junk drawer. The
// model/effort pickers already moved to StatusChips' own Badge+Dropdown
// controls (this rewrite's first commit); new-session/logout now live
// as TermInput slash commands (typed, not menu-clicked, and already
// have their own two-step confirm). What's left here — rebuild + a
// dashboard back-link — are genuinely menu-shaped (rare, not tied to
// any other visible control), so they're still a `⋯` menu, just a much
// shorter one.
//
// Reuses `@hive/shared`'s `Badge`+`Dropdown` — same anchored-popover
// pair StatusChips' model/effort pickers use, not a bespoke popover.
// Rebuild's confirm is the same "select once to arm, select again to
// fire" pattern as TermInput's `/new-session`/`/logout` — adapted here
// to a menu click instead of a typed repeat, but the same "requires
// deliberate repetition, not a modal" idea.
import { useState } from 'preact/hooks';
import { Badge } from '@hive/shared/badge.js';
import { Dropdown, type DropdownOption } from '@hive/shared/dropdown.js';
import { submitRebuild } from '../lib/rebuildAction.js';
import './OverflowMenu.css';
export interface OverflowMenuProps {
label: string;
dashboardBase: string;
}
export function OverflowMenu({ label, dashboardBase }: OverflowMenuProps) {
const [open, setOpen] = useState(false);
const [armed, setArmed] = useState(false);
function close() {
setOpen(false);
setArmed(false);
}
const options: DropdownOption[] = [
{ value: 'dashboard', label: '↑ dashboard' },
{ value: 'rebuild', label: armed ? '↻ rebuild — click again to confirm' : '↻ rebuild container' },
];
function onSelect(value: string) {
if (value === 'dashboard') {
window.open(`${dashboardBase}dashboard.html`, '_blank', 'noopener');
close();
return;
}
if (value === 'rebuild') {
if (armed) {
submitRebuild(dashboardBase, label);
close();
} else {
setArmed(true);
}
}
}
return (
<div class="header-overflow-anchor">
<Badge value="⋯" title="more actions" onClick={() => setOpen((o) => !o)} expanded={open} />
<Dropdown open={open} options={options} label="more actions" onSelect={onSelect} onClose={close} />
</div>
);
}

View file

@ -0,0 +1,71 @@
// Renders one `StreamRow` — flat `<div class="row …">` or expandable
// `<details class="row …">`, matching @hive/shared/terminal.css's
// existing row-kind classes exactly (see docs/terminal-rendering.md).
// Reuses that stylesheet as-is (imported once by LiveStream.tsx) — the
// taxonomy's visual language isn't what mara asked to change, the
// component *model* underneath it is.
import { useEffect, useRef } from 'preact/hooks';
import type { StreamRow } from '../lib/streamRow.js';
import { linkifyToNodes } from '../lib/linkify.js';
import { renderMarkdown } from '../lib/markdown.js';
function MarkdownBody({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null);
const html = renderMarkdown(text);
useEffect(() => {
// marked autolinks URLs but leaves them same-tab — open externally
// so a click never navigates the terminal away.
ref.current?.querySelectorAll('a[href]').forEach((a) => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
}, [html]);
// eslint-disable-next-line react/no-danger -- sanitized by DOMPurify in renderMarkdown
return <div className="md" ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
}
function DiffBody({ text }: { text: string }) {
const lines = String(text).split('\n');
return (
<pre className="tool-body diff-body">
{lines.map((line, i) => {
const cls = line.startsWith('+ ') ? 'diff-add' : line.startsWith('- ') ? 'diff-del' : 'diff-ctx';
return (
<span key={i} className={cls}>
{line}
{'\n'}
</span>
);
})}
</pre>
);
}
export function Row({ row }: { row: StreamRow }) {
if (row.details) {
return (
<details className={`row ${row.cssClass}`} open={row.defaultOpen || undefined}>
<summary>
{row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>}
<span className="summary-text">{row.text}</span>
</summary>
{row.diffBody != null && <DiffBody text={row.diffBody} />}
{row.markdownBody != null && <MarkdownBody text={row.markdownBody} />}
{row.plainBody != null && <pre className="tool-body">{linkifyToNodes(row.plainBody)}</pre>}
</details>
);
}
return (
<div className={`row ${row.cssClass}`}>
{row.icon != null && row.icon !== '' && <span className="row-glyph">{row.icon}</span>}
{row.text != null && linkifyToNodes(row.text)}
{row.meta?.map((m) => (
<span key={m.cls} className={m.cls}>
{m.text}
</span>
))}
{row.childText != null && <div className={row.childText.cls}>{row.childText.text}</div>}
{row.markdownBody != null && <MarkdownBody text={row.markdownBody} />}
</div>
);
}

View file

@ -0,0 +1,80 @@
/* Drawer chrome for <SidePanel> — backdrop, drawer box, header, close
button. Content styling (.agent-inbox, .inbox-*, .side-panel-empty)
already ships globally via agent.css; the two rules at the bottom
here re-target agent.css's old `hive-side-panel .agent-inbox …`
flatten-when-inside-a-panel overrides at this component's own class
instead, since this is a plain Preact tree, not a custom element. */
.agent-side-panel-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 60;
opacity: 0;
pointer-events: none;
transition: opacity 160ms ease;
}
.agent-side-panel-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.agent-side-panel-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(26em, 96vw);
background: var(--bg-elev);
border-left: 1px solid var(--border);
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.4);
z-index: 61;
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 200ms ease;
}
.agent-side-panel-drawer.open {
transform: translateX(0);
}
.agent-side-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6em 0.8em;
border-bottom: 1px solid var(--border);
color: var(--fg);
}
.agent-side-panel-title {
font-weight: bold;
letter-spacing: 0.03em;
}
.agent-side-panel-close {
background: none;
border: 0;
color: var(--muted);
font-size: 1.1em;
line-height: 1;
cursor: pointer;
padding: 0.3em 0.5em;
min-width: 2.75em;
min-height: 2.75em;
}
.agent-side-panel-close:hover {
color: var(--fg);
}
.agent-side-panel-body {
flex: 1;
overflow-y: auto;
padding: 0.8em;
}
.agent-side-panel-body .agent-inbox {
margin: 0;
font-size: inherit;
color: var(--fg);
}
.agent-side-panel-body .agent-inbox ul {
background: transparent;
border-left: 0;
padding: 0;
max-height: none;
overflow: visible;
}

View file

@ -0,0 +1,55 @@
// <SidePanel> — the slide-in drawer for the inbox/todos flyouts.
// Deliberately a new Preact component, not the shared
// `<hive-side-panel>` shadow-DOM custom element (@hive/shared/
// side-panel.js) — same rationale as StatusChips' Badge/Dropdown:
// mara's ask was real Preact components for this rewrite, not a port
// of the old widget family. Drops that element's drag-to-resize +
// localStorage width persistence for this first slice (fixed width via
// CSS, see SidePanel.css) — a real simplification, not an oversight;
// revisit if a fixed width proves cramped in practice.
//
// Content styling (`.agent-inbox`, `.inbox-*`, `.side-panel-empty`)
// already ships globally via `agent.css` and is reused verbatim by
// InboxPanel/TodosPanel — only the drawer chrome itself is new here.
import { useEffect } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import './SidePanel.css';
export interface SidePanelProps {
open: boolean;
title: string;
onClose: () => void;
children?: ComponentChildren;
}
export function SidePanel({ open, title, onClose, children }: SidePanelProps) {
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open, onClose]);
return (
<>
<div class={`agent-side-panel-backdrop${open ? ' open' : ''}`} onClick={onClose} aria-hidden="true" />
<aside
class={`agent-side-panel-drawer${open ? ' open' : ''}`}
role="dialog"
aria-modal="true"
aria-hidden={!open}
aria-label={title}
>
<div class="agent-side-panel-head">
<span class="agent-side-panel-title">{title}</span>
<button type="button" class="agent-side-panel-close" title="close (esc)" onClick={onClose}>
✕
</button>
</div>
<div class="agent-side-panel-body">{children}</div>
</aside>
</>
);
}

View file

@ -0,0 +1,31 @@
/* <StatusChips> layout. Colours all come from Badge/Dropdown's own CSS
(@hive/shared) — nothing here overrides a base16 slot. */
.status-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em;
}
.status-chip-anchor {
position: relative;
display: inline-block;
}
/* mara, live, on a screenshot of this row: "badges and interactive
elements on same row have inconsistent size." Real cause: Badge.css's
`.ui-badge-interactive` grows to a 2.75em WCAG touch-target floor
(model/effort pickers, pause) while a plain display `<span>` badge
(alive, state, ctx, cost — no `onClick`) stays compact, so the row
reads as two different pill sizes even though every badge here is
the same `<Badge>` component. Scoped to this row rather than
Badge.css itself — display-only badges elsewhere (e.g. the dashboard)
have no reason to grow, this is specifically about badges and
buttons sitting side by side in one row needing to read as one
family. */
.status-chips .ui-badge {
min-height: 2.75em;
padding-inline: 0.8em;
}
.status-chips-last-turn {
font-size: 0.8em;
color: var(--muted);
}

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