Adds @hive/shared/tabs.js — createTabStrip(tabbar, {defaultId, onShow}):
a hash-routed tab strip that resolves controls by `[data-tab="<id>"]`
inside the passed container and panels by `[data-tab-pane="<id>"]`,
toggling `.hive-tab--active` + aria-selected + the panel's `hidden`, and
firing onShow(id) for per-tab side-effects. Always hash-routed (deep-
linkable + back/forward), no element-resolver callbacks (convention over
config), per the #1464 design review.
Wires it up: @hive/shared exports `./tabs.js` + `./tabs.css`, and
common.css @imports tabs.css (component structure, not a swap target, so
inlining is fine — unlike theme.css).
Migrates the logs sub-tabs as the first consumer: markup uses
`.hive-tab`/`data-tab`/`data-tab-pane`, logs.css drops the duplicated
base styles (keeps only its `flex:1` layout delta), and logs.js swaps its
activeTab/showTab/hashchange for createTabStrip (onShow lazy-loads the
SYSTEM tab). Behaviour-preserving. aria-selected is now standardised.
First piece of the generic tabs component: @hive/shared/tabs.css carries
the base .hive-tabbar / .hive-tab / .hive-tab--active styles (lifted from
the logs sub-tab pattern, the most generic of the three tab UIs). Colours
use the semantic theme vars so it inherits theme-swap safety.
Not yet consumed — the createTabStrip JS helper + the per-call-site
migrations (logs, agent window-tabs, dashboard tabbar) follow. Count
pills + the dashboard's responsive overflow menu will layer on top in
the per-page CSS rather than the shared base.
Split the palette into two standalone stylesheets:
- colors.css — the 16 base16 slots (--base00..--base0F). This is the
entire theme swap contract; a generator (e.g. a stylix base16 scheme,
which is natively base00-base0F) replaces only this file.
- theme.css — the semantic layer (--bg, --purple, …) derived from the
base16 slots via var()/color-mix. Never changes on a swap.
Every page links colors.css then theme.css; theme.css does NOT @import
colors.css (that would re-bake the slots into it) — they're separate
dist outputs so a swap touches just colors.css. Pixel-identical refactor:
base16 defaults are Catppuccin Mocha and the three off-slot vars
(--crust, --muted, --subtext0) derive via color-mix reproducing their
exact prior hexes.
Wires colors.css through both build.mjs CSS entry lists, the
@hive/shared exports map, and all 7 page templates. css-vars.md + the
frontend.nix output-list comment updated.
Replace ~92 hardcoded Catppuccin rgba literals across the dashboard,
agent, and shared terminal stylesheets with color-mix() over the theme
:root vars, so a stylix-generated theme.css recolours the whole UI, not
just the elements that already referenced the vars. Pixel-identical
under the default palette (same RGB + alpha; color-mix(in srgb, C N%,
transparent) == rgba(C, N/100)).
Also fixes four var(--mauve, #cba6f7) usages: --mauve is undefined (the
var is --purple), so they were silently falling back to the hardcoded
hex and would not have recoloured on a theme swap. The bare crust hex in
terminal.css and the checkerboard-gradient hexes in common.css are
var-derived too.
Pure black/white drop-shadow/frost scrims are left as literals (not
theme colours). Two off-palette surface tints (dim-purple schedule rows,
warm-amber inline-button hover) are also left, with a comment, pending a
dedicated named var. Correct-var fallbacks like var(--red, #f38ba8) are
left as-is since they already recolour via the var.
Phase 1 of stylix integration (#1457): extract the Catppuccin palette
into a dedicated, separately-linked stylesheet so a theme swap can
replace just that file without rebuilding the rest of the frontend.
- shared/src/theme.css (new): the `:root` palette, moved out of
base.css (which now holds only the body typography it references).
- shared/package.json: export `./theme.css`.
- dashboard/src/theme.css + agent/src/theme.css (new): one-line
re-exports of @hive/shared/theme.css so each package's esbuild emits
its own standalone `dist/static/theme.css` (palette is NOT inlined
into the page bundles).
- both build.mjs: add theme.css to the CSS build list.
- every page (dashboard index/flow/logs, agent index/stats/screen):
link `theme.css` first, ahead of the page CSS, so the `:root` vars
resolve for everything.
- docs/web-ui/css-vars.md: document the split + the no-rebuild rationale.
Behaviour-neutral — same colours, just relocated. Verified both
`npm run build` outputs: theme.css emits standalone (383b) with the
palette; no `--*` palette defs duplicated into common.css/agent.css.
Phase 2 (nix derivation that swaps theme.css from stylix colours) is a
follow-up; touches nix/frontend.nix, coordinating with damocles.
Part of #1457.
Two cooperating fixes:
1. overflow-anchor: none on .live — disables browser scroll anchoring
so the manual scrollTop compensation in loadMore() is the sole
mechanism. Without this the browser auto-adjusts scrollTop when
rows are prepended above the viewport, then our += delta doubles
the compensation, causing the erratic jump.
2. updateLoadMoreBtn() moved before the beforeH baseline capture —
if histHasMore becomes false the load-more button is removed here
rather than after the delta is applied, so the button height is
already baked into beforeH and doesn't shift the viewport post-
compensation.
Instead of requiring a click on the load-more pill, trigger loadMore()
automatically when the user scrolls within LOAD_MORE_SCROLL_PX (80px)
of the top of the log. The pill stays as a visual indicator that more
history is available.
mara on #448: "firefox disconnects bc of too many tabs. needs bg
service worker". picked SharedWorker over full Service Worker:
smaller change, addresses the actual problem (shared connection
across tabs), no offline-cache scope creep.
architecture
per-tab `new EventSource('/dashboard/stream')` replaced with a
SharedWorker-backed facade. one SharedWorker instance per origin
holds ONE upstream EventSource and fans every server-sent event
out to every connected tab via MessagePort. N hyperhive tabs now
share ONE backend connection, immune to Firefox's per-tab SSE
throttling under many-open-tabs pressure.
wire protocol (port.postMessage):
tab → worker
{ kind: 'subscribe', url: '/dashboard/stream' }
{ kind: 'unsubscribe', url: '/dashboard/stream' }
worker → tab
{ kind: 'open', url }
{ kind: 'message', url, data: '<raw SSE data>' }
{ kind: 'error', url }
subscription tracking is per (port, url). a late subscriber that
joins after the upstream is already OPEN gets a synthetic 'open'
event so its onStreamOpen handler still runs (triggers the
snapshot re-sync that recovers events lost during the join gap).
unsubscribing the last port for a URL closes the upstream
EventSource so we don't leak idle streams.
files
- frontend/packages/dashboard/src/stream-worker.js: new — the
worker. multi-URL multiplexing via Map<url, {es, ports}>.
- frontend/packages/dashboard/src/common.js: new exported helper
openStream(url) — returns an EventSource-shaped facade backed
by the SharedWorker. graceful fallback to direct EventSource
when SharedWorker is unavailable.
- frontend/packages/dashboard/src/app.js: replaces the inline
new EventSource('/dashboard/stream') with openStream.
- frontend/packages/dashboard/src/flow.js: passes
streamFactory: openStream to termCreate so the broker
terminal's SSE goes through the worker too.
- frontend/packages/shared/src/terminal.js: accepts an optional
streamFactory(url) option. default unchanged — non-dashboard
consumers (per-agent UI) keep using direct EventSource.
- frontend/packages/dashboard/build.mjs: new esbuild entry for
stream-worker.js → dist/static/stream-worker.js (separate
bundle because SharedWorker scripts run in a different global
scope and can't be inlined into app.js).
scope kept tight
- per-agent UI's /events/stream stays on direct EventSource. the
agent UI's tab count per agent is typically 1; SharedWorker
helps when you have N tabs hitting the SAME stream and the
per-agent stream URLs differ. if mara wants the agent UI to
share its workers too it's a separate small PR.
- no offline-cache, no push notifications — those need full
Service Worker; explicit non-goal here per the design Q.
validation
- npm run build --workspace=@hive/dashboard clean.
- stream-worker.js bundle: 1.8 kb.
- app.js: 154 kb → 158 kb. flow.js: 29.9 kb → 32 kb.
- browser smoke test isn't possible from inside iris's container;
the EventSource-shaped facade preserves the exact onmessage /
onopen / onerror surface the existing IIFE consumers use.
Mara: *can we make this really fast to not be a problem? like its
okay if it is pretty fast*.
`scrollTo({ behavior: 'smooth' })` runs ~500ms in Chromium/Firefox
— "still smooth, but visibly slow." Swap it for a custom rAF loop
with an ease-out cubic over 140ms. Distances under 24px short-
circuit to instant — animating a 12px nudge is just jitter.
Each call cancels the previous rAF before starting a new one, so a
burst of mutations coalesces into one ride to the latest bottom
instead of two animations fighting over scrollTop.
Re-reads `scrollHeight - clientHeight` each frame so a renderer
mutation landing mid-animation (the common case — `api.row`
returned, then renderer appended badge + body) extends the
destination smoothly rather than landing short.
`smoothScrollingUntil` gate shrinks accordingly (140 + 80 = 220ms)
— still protects the scroll handler from flipping stickToBottom
on the intermediate scroll events the rAF fires.
Mara: "New scroll down behavior works, but jumps instead of scroll."
The autoscroll path in `afterAppend` + the MutationObserver re-snap
+ the tail-pill click handler all set `log.scrollTop = log.scrollHeight`
— instant jump. Reads as jerky on long mutations where the row
height grew a lot between the initial append and the body fill.
Switch to `log.scrollTo({ top: log.scrollHeight, behavior: 'smooth' })`
via a new `snapToBottom()` helper. All three call sites (afterAppend,
MO callback, pill click) route through it.
## Smooth-scroll vs `stickToBottom` flicker
`behavior: 'smooth'` fires a stream of scroll events as the position
eases toward the target. Without guarding, the scroll handler reads
the intermediate position, fails `isNearBottom()`, flips
`stickToBottom` to false — the next MO callback then skips the snap
and strands the operator mid-scroll.
Add a `smoothScrollingUntil` timestamp gate: every `snapToBottom()`
call (without `immediate`) re-arms it to `now + 800ms` (~Chromium /
Firefox smooth-scroll animation duration + headroom). The scroll
handler ignores events while the gate is active. Bursts of writes
coalesce into one smooth ride to the latest bottom rather than a
sequence of half-cancelled animations.
## Backfill replay
`currentNoAnim` is true during history backfill — the operator never
sees the intermediate positions there, so smooth scroll is just
wasted animation. `snapToBottom(immediate=true)` (and the noAnim
branch in afterAppend) falls back to instant scroll for that path.
Same shared `@hive/shared/terminal.js` is used by the dashboard +
per-agent terminal + flow page; all three inherit the change.
#375 captured `wasNearBottom` BEFORE the row's initial append so
the autoscroll wouldn't be tricked by the new row's own height.
That's still right, but it leaves a second gap: renderers commonly
call `api.row(cls, text)` to build the shell, then mutate the
returned element by appending more children (badges, multi-line
turn bodies, tool-result panes). The initial scrollTop assignment
in afterAppend only sees the row's INITIAL height — once the
renderer adds the body, the row's grown past the visible bottom
and the operator's stranded scrolled to the row's TOP, breaking
stick-to-bottom for every subsequent event.
Mara's symptom: two lines of text appear, the view scrolls only
one row, the terminal isn't at the bottom anymore, and the next
event doesn't auto-scroll because `isNearBottom()` now returns
false.
Fix: add a `stickToBottom` boolean (updated synchronously from the
scroll event handler) + a MutationObserver on the log subtree
(`childList`, `subtree`, `characterData`). On any mutation, if
stickToBottom is true, re-snap to bottom. MutationObserver
batches mutations into one microtask callback per synchronous
block, so it runs once per renderer call regardless of how many
children the renderer appends after `api.row` returned.
Programmatic `scrollTop = scrollHeight` doesn't trigger MO (the
scroll isn't a DOM mutation), so no feedback loop. Operator
scrolling up still flips stickToBottom to false via the existing
scroll handler — MO becomes a no-op until they scroll back.
Same shared `@hive/shared/terminal.js` powers the dashboard's
flow page + per-agent terminal, so both pages inherit the fix.
Two bugs on the agent terminal page after the #362 overhaul:
## 1. `↓ N new` pill clipped by the composer
The fixed-overlay composer (z-index 30, agent.css) sits in the
root stacking context. The pill — `position: absolute` inside
`.live.terminal` with no z-index — defaults to its document-order
position in the body's stacking order, which the composer covers.
Fix: bump `.agent-main .tail-pill { z-index: 35 }` so the pill
participates in the root stacking context above the composer.
Scoped to the agent-page overlay layout — the shared `.tail-pill`
rule stays untouched (the dashboard's in-page layout doesn't need
the bump).
## 2. Autoscroll-on-new-message not firing when the operator was
already at the bottom
`afterAppend()` in terminal.js was calling `isNearBottom()` AFTER
appending the new row. The new row's own height is already in
`scrollHeight` at that point, so for any row taller than the
NEAR_BOTTOM_PX threshold (48px — easily passed by a multi-line
message body, a tool-result summary, a markdown block), the check
returns false and the pill shows + scroll stays put.
Fix: capture `wasNearBottom = isNearBottom()` BEFORE the
`log.appendChild(...)` in each of `row` / `details` /
`detailsDiff`, pass it into `afterAppend(wasNearBottom)`. Now the
auto-scroll triggers whenever the operator was visually at the
bottom an instant before the row landed, regardless of the new
row's height.
Same shared `@hive/shared/terminal.js` is used by the dashboard
+ per-agent UI + the upcoming /flow.html page, so both pages
inherit the fix.
## Validation
`npm run build` clean.
Bundle deltas: shared terminal bundle re-inlined into both consumers
unchanged in size (the wasNearBottom variable is a single bool, no
measurable delta). Agent CSS +0.1kb (z-index property).
Browser smoke test isn't possible from inside iris's container —
worth eyeballing post-deploy:
- With the operator scrolled to bottom, a tall message lands and
the view scrolls to keep it visible (instead of pinning the
pill).
- The pill appears above the composer when the operator is
scrolled up and new messages land.
Closes#375.
Phase 1 of the backend/frontend code split (#273). Additive — no
existing code is touched; the legacy hive-c0re/assets, hive-ag3nt/
assets and hive-fr0nt/assets trees stay in place until the Rust
cutover later in this branch.
Layout:
frontend/package.json npm workspaces root
frontend/packages/shared/ @hive/shared
src/{base,terminal}.css + terminal.js (ES module)
src/index.js re-exports terminal.js
frontend/packages/dashboard/ @hive/dashboard
src/{index.html, app.js, dashboard.css} ported from hive-c0re/assets
build.mjs esbuild config → dist/
frontend/packages/agent/ @hive/agent
src/{index,stats,screen}.html + agent.css
+ {app,stats}.js ported from hive-ag3nt/assets
build.mjs esbuild config → dist/
Changes vs the existing assets:
- terminal.js is an ES module exporting { create, linkify } instead
of assigning to window.HiveTerminal. The dashboard / agent app.js
files re-expose them on window so the IIFE bodies keep working
unchanged through Phase 1; the global aliases can be dropped in a
follow-up once the IIFEs are unwrapped.
- marked is imported from the marked@4.3.0 npm package (replacing
the vendored hive-fr0nt/assets/marked.umd.js bundle).
- chart.js is imported from chart.js@4.4.4 (replacing the jsDelivr
CDN script tag on the per-agent stats page — page now works
offline / on operator machines without internet egress).
- dashboard.css and agent.css both gain @import lines at the top
that pull base.css + terminal.css from @hive/shared, replacing
the runtime string concatenation in serve_css.
- index.html / stats.html collapse from three / two script tags to
one type="module" tag pointing at the bundled output.
package-lock.json is intentionally omitted from this commit — npm
isn't available in the iris container yet (approval pending) and the
lockfile will land in the next commit on this branch once the
toolchain is in place. The PR will not be opened until it's there.
Phase 2 (nix derivations), Phase 3 (container plumbing + the
hyperhive.frontend.extraFiles option for per-agent layering), and
Phase 4 (Rust cutover to tower_http::ServeDir, delete hive-fr0nt
+ legacy assets dirs) land as follow-up commits on this same
branch.
Refs #273.