argus review: the two :root[data-theme='...'] glass-override rules
have specificity 0-3-0 (:root + [data-theme] + .ui-badge), beating
.ui-badge-quiet's 0-1-0 — so whenever a user has an explicit theme
override set (not just relying on prefers-color-scheme), these rules
reintroduced a fill on quiet badges, regressing the settings/links
trigger fix. Scope both overrides with :not(.ui-badge-quiet).
Verified with a real repro (same synthetic striped-bg test page,
before/after) rather than just the specificity arithmetic.
Translucent color-mix background + backdrop-filter blur/saturate,
same recipe as the header chrome/terminal glass (chrome.css,
terminal.css). Dark (mocha) palette only: applies the glass look to
the default filled badges, keeps light (latte) mode's plain solid
fill unchanged since a bright/busy background behind a badge would
lose legibility from the same transparency that helps on a dark
background. variant="quiet" badges are untouched (they already have
no permanent fill to frost).
Verified with a synthetic striped-background test page (raw CSS
against colors.css/theme.css/Badge.css, not the bundled app) to make
the blur/transparency visually obvious, plus real headless-chromium
screenshots of the built agent + swarm-ui dist to confirm no
regression against real content — the effect is real but subtle
there since the chrome background is flat, which is expected.
Badge's default look is a filled pill, right for status/picker chips
but wrong for an icon-only header button like the settings gear or
agent links trigger, which should read as chrome. Add variant='quiet'
(same naming/shape as Button's ButtonVariant) that drops the idle
background, keeping the existing hover/expanded feedback.
Apply it to the two icon-only Badge triggers: shared SettingsMenu
(used by both swarm-ui and the agent page) and the agent page's own
MetaNav links trigger. Add a components-page showcase sample so the
variant has a visible regression check going forward.
Fixes a regression + a longer-standing inconsistency (mara: "agent
page link icons is different from swarm ui, settings icon looks weird
since component extract").
- The `SettingsMenu` shared-component extraction swapped swarm-ui's
original inline-SVG gear trigger for a plain `⚙` text glyph (matching
agent's `MetaNav`, which was itself still on the `🔗` emoji at the
time). An emoji/text glyph is rendered by the OS/browser's own font
at that font's fixed metrics — it can never match a neighbouring
icon in size or weight, and some codepoints (the gear included)
aren't even reliably covered by every font.
- New `@hive/shared/icons.js` (`GearIcon`, `LinkIcon`) — the exact SVG
markup that used to live only in swarm-ui's `SettingsMenu`/
`LinksMenu` as two separate inline copies, now the one shared source.
- `SettingsMenu` (shared) uses `GearIcon`; agent's `MetaNav` and
swarm-ui's `LinksMenu` both use `LinkIcon` — three consumers, one
rendering path, matching stroke/viewBox/size everywhere.
Verified with real screenshots on both agent and swarm-ui — both
trigger icons render as the same crisp line-icon style now.
Review feedback on this PR (mara): "i think the component should be
shared. motion setting is missing." Both addressed:
- `settings-storage.ts` (generic localStorage hook), `theme-apply.ts`,
`motion-apply.ts`, and `SettingsMenu.tsx`/`.css` all move from
swarm-ui's `lib/`/`shell/` into `@hive/shared/src/settings/` —
agent's previous local copies are deleted outright rather than kept
as a second implementation. One component, `Badge` trigger
everywhere (already used elsewhere in swarm-ui, so not a new visual
language there either) — storage keys stay caller-owned (`themeKey`/
`motionKey` props + matching `useApplyThemeOverride`/
`useApplyMotionOverride` calls at each package's single mount point)
so agent and swarm-ui keep fully independent, non-colliding
persisted settings.
- Agent's settings menu now includes the motion row, matching
swarm-ui's. No animation in the agent package is gated behind
`data-motion` yet — same as when swarm-ui first built this plumbing
ahead of having a consumer — so it's currently inert there, ready for
whenever agent grows a motion-guarded animation.
- swarm-ui's own theme default flips to `'dark'` as part of this move
(`theme-apply.ts`'s new default), superseding PR #3715 — that PR
becomes redundant once this lands and will be closed rather than
merged, to avoid the two colliding on the same file.
Verified end-to-end with real screenshots on both pages: shared
component renders identically (Badge trigger, theme+motion rows, dark
default) on agent's mock server and a static rebuild of swarm-ui's
dist.
Ports swarm-ui's `SettingsMenu` (mara: "agent terminal page should get
the settings panel from swarm ui as well") — same shape as `MetaNav`
already in this header: a `Badge` icon trigger ("⚙"), popover, close on
outside-click/Escape.
`theme-apply.ts` + `settings-storage.ts` are near-verbatim ports of
swarm-ui's own (duplicated rather than moved into `@hive/shared` for
this pass — lower risk than reworking swarm-ui's imports in the same
change). Defaults the stored override to 'dark', not 'system', for the
same reason as swarm-ui's own default flip: `prefers-color-scheme` has
no real "unset" value, so 'system' silently reads as light for anyone
who's never touched an OS dark-mode toggle.
Motion NOT ported — agent has no animation gated behind `data-motion`
yet, so that plumbing would have nothing to control.
Verified end-to-end: default dark on a fresh load, and an explicit
localStorage override to 'light' correctly re-themes the whole page via
the existing `colors.css` `:root[data-theme='light']` block (already
shipped, previously only reachable from swarm-ui).
--muted is color-mix()'d toward --base00/--bg — correct for dimmed text
on the page background, wrong for dimmed text sitting on the elevated
--purple-dim fill (badge/dropdown-item backgrounds). Badge's label/caret
used bare --muted there and mara measured it at ~1.3:1 contrast against
her theme's badge fill, essentially invisible.
--muted-on-dim mixes toward --purple-dim instead, same contrast-floor
technique as --muted itself, just anchored to the surface it's actually
used on. Applied to .ui-badge-label/.ui-badge-caret and to Dropdown's
active-item description (same bug: its row bg is --purple-dim too).
Ceiling note: even plain --fg only reaches ~3.4:1 against --purple-dim
in mara's theme, short of formal 4.5:1 AA — that's the theme's own
limit, not something the anchor choice can fix on its own. 90% gets
close to that ceiling (~3:1, more than double the old ~1.3:1) while
keeping a hint of the label/value visual distinction.
`OverflowMenu.tsx`/`.css` were deleted (rebuild button removed outright,
dashboard link moved into `MetaNav`), but `agent.css` still carried the
whole trigger+popover+item rule set — grepped the entire `agent`
package, none of these classnames appear in any current markup.
Also fixed three tombstone comments elsewhere in the file that pointed
at the now-also-deleted `.overflow-item-*` classes as "where it moved
to" — they'd have dangled once those rules were gone too. And two stale
doc claims caught in the same sweep: `docs/web-ui/agent.md` still listed
`OverflowMenu.tsx` in the header's component tree and described "the
overflow menu" as a live effort-picker location.
Badge already covers icon+label+value+onClick -- exactly HeaderPill's
shape. mara, reviewing the first cut (a bespoke .header-pill matched to
Badge's own CSS values): "cant we reuse the badge component". HeaderPill
now renders <Badge> directly and only owns hiding at count 0 + the
inbox/todos tone (amber/green on the count, same as before). Drops the
now-unused .header-pill* CSS from agent.css entirely.
HeaderPill used the bordered/transparent .hive-pill shape while every
other chip in .agent-header-pills (StatusChips, MetaNav's trigger) is
@hive/shared's filled Badge (.ui-badge) -- the one visibly hollow pill
in a row of filled ones. Restyled .header-pill with .ui-badge's own
values (filled --purple-dim background, no border, matching radius/
padding/hover) instead of a new shape, and dropped hive-pill from the
markup since it no longer describes what this component looks like.
Badge.css's colored-value classes (positive/warning/negative/accent)
used the raw semantic accent color directly on --purple-dim, same class
of bug already fixed by hand for the bundled Latte palette (see
colors.css's comment on --latte-base08..0F) but never applied to an
arbitrary stylix-fed scheme. Blend each accent 60/40 with --fg via
color-mix() instead of using it bare -- --fg is the one color the
base16 contract already guarantees legible on every surface, so mixing
toward it gives every accent a contrast floor without trying to
compute/fix an operator's own theme.
mara (#3704): 'remove rebuild button, move link to dashboards into
links menu.'
The overflow (⋯) menu existed for exactly two items: the dashboard
back-link and a rebuild-container action. Rebuild is gone outright —
the dashboard's own R3BU1LD button already covers it, this was just a
rarely-used shortcut not worth its own menu. The dashboard link moves
into MetaNav's links popover (now the first item, above stats/forge/
config/extras) instead. With both gone, OverflowMenu had nothing left
to justify existing as a separate component — deleted along with its
CSS and the now-unused rebuildAction.ts (only consumer).
MetaNav gained a dashboardBase prop (Root.tsx already computes this
via resolveDashboardBase for InboxPanel/pause — reused, not
duplicated) and renders the dashboard link as a real <a>, same
treatment as every other item in that popover — no dangling
window.open()-only affordance.
Updated docs/web-ui/agent.md's Header section and the couple of
now-stale OverflowMenu references in index.html's/MetaNav.css's own
comments.
Verified: header now shows a single trailing icon-badge (was two),
popover opens with dashboard first then the agent_links() set.
tsc --noEmit clean, build clean, both pre-push lints clean.
argus, non-blocking note on PR#3702: the relative-path-resolution
example still used static/app.js as its illustrative filename after
that file was deleted in the cutover. Mechanism described was still
correct, just a stale filename a future reader could get confused
chasing. Swapped to static/main.js (the agent page's own script) and
static/stats.js (the /stats subpage example, more accurate than
main.js there since that's specifically about a subpage's own
script).
mara: 'i merged this without realizing you still did not delete the
old code. pls follow up with ripping out the old code.'
- index.html: dropped the old static header/main/composer/overflow-
menu markup and app.js's <script> tag. Now just <div id=preact-root>
+ <script src=static/main.js>. main.css's <link> lands after
agent.css's — cascade order argus flagged matters for LoginFlow's
.login-card / MetaNav's popover to win against agent.css's legacy
rules, confirmed explicitly rather than assumed at this exact step.
- build.mjs: app.js dropped from the esbuild entryPoints (stats.js
keeps its own bundle, unaffected — separate page, separate script).
- frontend/packages/agent/src/app.js deleted (1717 lines). screen.html
has its own inline <script>, untouched — never depended on app.js.
- docs/web-ui/agent.md: rewrote the Header section to describe the
real Preact component tree and the badges+pills-together layout
(deferred from the earlier commits on this PR specifically so it
wouldn't describe a hybrid state — this is that promised follow-up).
Touched up the one other app.js-specific mention in the endpoints
section. Left the Main/composer/side-panel/live-view/slash-command
sections alone — behavior there is unchanged, verified faithfully
ported throughout this PR's earlier commits.
Verified against the REAL dist/index.html (not the dev-preview
harness) — a scratch mock server serving the actual built output
end-to-end, screenshotted clean. tsc --noEmit clean, build clean, both
pre-push lints clean.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
- 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'.
- 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.
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.
- 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.