Fixes hyperhive#2893: 'Element.attachShadow: Unable to re-attach to
existing ShadowDOM', crashing swarm.js's live-update render path.
A custom element's connectedCallback fires again on a same-document
*move* (insertBefore/append repositioning an already-connected node
runs the removal + insertion steps for its whole subtree), not just
on a fresh mount. swarm.js's row-fingerprint cache reuses + reorders
existing <li> subtrees on live updates -- reordering an unchanged,
cached row moves its already-initialised <hive-agent-menu>/<hive-menu>
without ever really detaching it from the document, so connectedCallback
re-runs full setup on an instance that's already set up. attachShadow()
throws unconditionally if the host already has a shadow root, and
HiveAgentMenu's unconditional child-menu creation would have appended a
second <hive-menu> on top of the first, doubling the dropdown, once the
shadow-attach crash itself was out of the way.
Both connectedCallbacks now bail early if already initialised
(shadowRoot present / _menu already built). Reproduced the crash and
duplicate-menu bug with an unguarded control copy of both files driven
via headless Chromium (simulating the exact row-reorder move), then
confirmed the guarded version throws nothing, keeps the same shadowRoot
object identity across the move, and doesn't duplicate the dropdown.
Same pure structural move as the previous commit, applied to the one
other remaining genuine component in shared/src (a self-contained
widget with its own behaviour + CSS, same class as hive-btn/hive-
dialog/hive-toast/hive-menu/side-panel/tabs) -- not the CSS-foundation
files (colors/theme/base/chrome.css) or the utility modules (forms.js,
dom.js, modal.js, shadow-css.js), which aren't components and don't
fit the one-dir-per-component convention.
External callers resolve terminal.js/terminal.css only through
@hive/shared's exports map, so again the two exports targets are the
only external-facing change. index.js's own internal re-export uses a
relative path within the package, so that needed updating too. Zero
call-site changes outside @hive/shared. Verified the built dashboard
(flow.js/common.css) and agent (app.js/agent.css) bundles still
resolve both files.
Pure structural move, no API or behaviour change: tabs.js/tabs.css
move into shared/src/tabs/, matching the one-dir-per-component
layout the other shared components already use (hive-btn,
hive-dialog, hive-toast, hive-menu, side-panel).
Both files are consumed exclusively through @hive/shared's
package.json exports map (./tabs.js, ./tabs.css), never by a raw
relative path, so updating the two export targets is the only
change needed -- none of the 9 call sites (dashboard tabbar, logs,
core, builds, credentials, stats x2, agent stats) touch anything.
Verified the built dashboard/agent bundles still resolve both
files correctly.
Per mara's review: '2 and maybe 1, but 3 also sounds reasonable on first
glance' (against 3 options I posted). Doing 2 and 1, leaving open()/
openNamed() as-is (option 3, tentative only).
Both dashboard/common.js and agent/app.js now export/use the
<hive-side-panel> element instance directly (sidePanel) instead of a
thin Panel = { open, openNamed, refresh, close } object that existed
purely to keep the old call-site shape unchanged. All 6 real call sites
updated to call the element's own methods directly.
The .side-panel-body class each wrapper stamped onto its own instance,
purely so common.css/agent.css's pre-existing content-styling selectors
kept matching, is gone too -- those selectors now use the element's own
tag name as the root (hive-side-panel .md, hive-side-panel .agent-inbox),
which already uniquely identifies the light-DOM instance without a
compatibility class. Verified via headless Chromium/CDP that the
tag-name selectors resolve correctly with no class needed.
Drive-by: removed an unrelated dead Panel import in call.js.
The dashboard's Panel singleton and the per-agent UI's own inline Panel
IIFE each had their own near-identical implementation of the right-side
slide-in drawer used for file previews, diffs, logs, and inbox/todo
lists. Both are now thin wrappers around a new <hive-side-panel>
shadow-DOM custom element in @hive/shared, following the same house
pattern as <hive-menu>: the element owns and builds all its structural
chrome itself (backdrop, drawer, resize handle, header, title, close
button) in connectedCallback, and only the caller's opaque content node
is projected in via a default <slot> so each package's own
content-type-specific CSS keeps reaching it.
Public API is the union of both originals: open(title, content),
openNamed(name, title, content), refresh(name, title, content),
close(), and currentOwner(). Drag-to-resize + localStorage width
persistence (ported verbatim from the dashboard's original
implementation, the only one of the two that had it) is now available
to both consumers by default — a deliberate behavior widening for the
agent UI, which didn't have resize before. Along the way, fixed a
latent bug in the ported CSS: the resize handle was setting a
--side-panel-w custom property that no width rule ever consumed, so
dragging never actually resized the drawer even though it looked wired
up; the new shared stylesheet's width rule reads it properly.
Each package's own global stylesheet keeps its content-specific rules
(common.css's .side-panel-body .md, agent.css's .side-panel-body
.agent-inbox) exactly where they were — those can never be reached from
the shared element's shadow tree, same architectural floor as
<hive-menu>'s item-row styling. Each wrapper applies a plain
'side-panel-body' compatibility class to its own <hive-side-panel>
instance so those existing selectors keep matching by ordinary
light-DOM descendant matching, with the shared element itself having no
knowledge of what that class name means.
Panel.bind() is gone from both packages' public API — the shared
element wires its own listeners in connectedCallback, so there's no
bind step left to call. tabs.js's one call site (the only bind() caller
in either package) was updated to drop it.
The two original chrome CSS blocks disagreed on several purely visual
details beyond the resize-handle rules (z-index, backdrop color, drawer
border/box-shadow, title typography) — the dashboard's values (the more
feature-complete of the two) were kept as canonical, which is a small
visible style change for the agent UI's panel chrome (thinner border,
no box-shadow, no bold purple title). Flagged for visibility since nothing
in the original two implementations called this out explicitly.
Verified with a real headless-Chromium/CDP harness (bundled the actual
component + built page CSS, served statically, drove via raw CDP) for
both usage shapes: open/close, backdrop-click dismiss, Escape dismiss,
refresh() owner-matching (no-op on wrong owner, applies on matching
owner), and drag-to-resize (drawer width updates live during drag and
persists to localStorage on release).
Per mara's review on #2881: the dropdown box chrome (background/border/
radius/shadow/min-width/white-space) and the trigger button's base
icon-button treatment are both reachable from hive-menu.css now --
the box chrome lives on hive-menu's own shadow-owned .menu-dropdown
wrapper (no slotting constraint at all), and the trigger button is
styled via ::slotted([slot='trigger']) since it's the top-level slotted
node for that slot. Item-row styling stays in the caller's stylesheet
-- ::slotted() only reaches directly-slotted elements, not their
descendants, so individual dropdown items are architecturally
unreachable from hive-menu's shadow tree. Verified interactively via
headless Chromium/CDP: trigger opacity/hover/border-radius and the
dropdown wrapper's background/border all resolve correctly, hover and
click-to-open still work.
<hive-agent-menu> bundled two concerns: the agent-specific trigger/item
list, and generic "click a trigger, get a positioned dropdown" mechanics
(shadow attach, open/close, singleton close-on-open coordination,
outside-click/Escape handling). Pulled the latter out into a new
@hive/shared/hive-menu.js (<hive-menu>), following the established
per-component-directory + ._opts-before-append shadow-DOM pattern
(<hive-dialog>). <hive-agent-menu> now just builds the "⋮" trigger and
the action list and hands them to an internal <hive-menu> instance.
<hive-menu> takes ownership of every <hive-menu> instance in the app for
singleton coordination (closeAllMenus, renamed from closeAllAgentMenus)
— a deliberate widening from the old per-agent-menu-only tracking, since
the mechanism was never agent-specific to begin with.
The one subtlety worth spelling out: <hive-menu> projects the caller's
opaque trigger/content nodes via named <slot>s rather than moving them
into its own shadow root. That's load-bearing, not cosmetic — if it
re-parented them into its own shadow tree instead, <hive-agent-menu>'s
own classes (.agent-menu-btn, .agent-menu-item, ...) would stop applying,
since a <style> only styles elements within the same shadow tree/document
it's part of, and only slotting (not re-parenting) keeps the caller's
nodes in the caller's own tree for styling purposes. That in turn made
<hive-agent-menu>'s own shadow root redundant once it wasn't the thing
positioning or owning open/close state anymore, so it's dropped in favor
of a plain light-DOM element styled by dashboard.css (already the one
page it renders on) — hive-agent-menu.css is gone, its rules folded into
dashboard.css's per-agent-menu section, minus the positioning rules that
moved into hive-menu.css as the new generic `.menu-dropdown` wrapper.
Verified with a standalone esbuild bundle + a cached nix chromium driven
over raw CDP (no puppeteer/playwright/python3 available): hover-reveal
opacity, dropdown open/close/positioning, outside-click/Escape dismissal,
and cross-instance singleton coordination all behave identically to
before the split.
Pulls the attachShadow-plus-plain-<style>-tag boilerplate that hive-dialog
and hive-toast already build per component into one shared helper, so the
next shadow-DOM custom element (the agent context menu, next commit) can
reuse it instead of re-deriving the same few lines. A plain <style>
element rather than a constructed CSSStyleSheet with adoptedStyleSheets --
each component instance builds its own fresh stylesheet per connect with
no sharing across instances, so adoptedStyleSheets buys nothing here over
the plain, universally-understood <style> tag.
#2875 (merged) fixed this on main's flat modal.js before #2793's
component-dir split landed. Porting the same one-line fix here now
instead of leaving it as a rebase landmine for whichever PR merges
second.
mara: 'i dont like js css attacher. is there a cleaner way?' — yes: each
component instance was already building its own fresh CSSStyleSheet()
per connect, no sharing across instances, so adoptedStyleSheets bought
nothing here over a plain <style> tag. Same raw-text CSS import, just a
simpler attach step.
Splits the shadow-DOM custom elements out of the flat shared/src layout
into per-component directories:
hive-btn/hive-btn.{js,css}
hive-dialog/hive-dialog.{js,css}
hive-toast/hive-toast.{js,css}
hive-dialog and hive-toast were previously defined inline inside
modal.js alongside the openDialog/themedConfirm/themedPrompt/themedToast
orchestration helpers; modal.js is now a slim entry point that imports
the two component modules for their customElements.define side effect
and keeps only the orchestration functions, which aren't components
themselves. hive-dialog.js now imports hive-btn.js directly (it's the
actual consumer that creates <hive-btn> elements), instead of modal.js
importing it on hive-dialog's behalf.
Pulled the identical shadow-root-plus-adopted-stylesheet boilerplate
(previously duplicated between modal.js's local attachShadow() and
hive-btn.js's inline version) into a shared shadow-css.js helper,
attachShadowCss(host, cssText, shadowInit), used by all three
components. Behaviorally identical — same attachShadow() options per
component, just deduplicated.
No external import paths changed: every consumer only ever imported
the package-level @hive/shared/modal.js entry point, never the
component internals directly, so this is fully internal to the shared
package. Verified with a full frontend build (dashboard + agent
bundles).
hive-dialog's dismiss-on-backdrop-click handler checked e.target === this
(the host). Shadow DOM event retargeting sets e.target to the host for
ANY click that originated inside the shadow tree once it reaches a
listener attached on the host itself, not just clicks that actually hit
the host's own rendering — so the check was true for every click inside
.box that no other element's listener consumed first (title, message, a
bare checkbox row with no button to intercept it), immediately closing
the whole dialog. Reported by mara: clicking a checkbox in a
confirmation dialog (e.g. the restart dialog) dismissed the dialog
instead of toggling the box.
Switched to e.composedPath()[0] === this, the true original target
unaffected by retargeting — true only for a genuine backdrop click.
Per review: don't use is=, it reads as a hack (and it is one — is=-upgraded
built-ins can never host a shadow root, which is what caused the crash this PR
fixes in the first place). <hive-btn> is now a normal autonomous custom element
wrapping a real <button> inside its own shadow root, so it gets its shadow
encapsulation back (matching hive-dialog/hive-toast) instead of the document-
level stylesheet workaround from the previous commit.
delegatesFocus: true on the shadow root means .focus() on the host (what
modal.js calls for autofocus) reaches the inner button directly. The inner
button's native click is a composed event, so host-level click listeners
(what modal.js/themedPrompt already use) keep working unchanged.
modal.js: el('button', { is: 'hive-btn', ... }) -> el('hive-btn', { ... }) at
the one call site. dom.js: removed the is= special case from el() entirely —
it existed only to support this one now-gone usage. Build clean.
Element.attachShadow() throws NotSupportedError unconditionally for a
customized built-in (<button is="hive-btn">): the spec only allows
autonomous custom elements or a fixed list of native tags to host a
shadow root, and explicitly excludes any is=-upgraded built-in
regardless of which tag it upgrades. button isn't on that list either
way. This made every themed dialog (any confirm/prompt, since openDialog
always renders at least one button) throw and fail to render in a real
browser, though it passed CI since nothing there exercises actual
browser DOM.
hive-dialog and hive-toast are unaffected — both are genuine autonomous
custom elements (extends HTMLElement, no is= upgrade), which are valid
shadow hosts.
Fix: hive-btn no longer calls attachShadow. Styles adopt onto document
once (module-level guard) instead of per-instance shadow root, scoped
via the [is="hive-btn"] attribute selector instead of :host — same
light-DOM approach the rest of the app's .btn consumers already use.
Native button behaviour is untouched, only the styling mechanism
changed. Build clean.
mara: 'wait the common styles is literally just the button stuff? pls
make a button component now as part of this pr and replace the usage
in the modal. replacing all usages and finally removing the btn styles
from common css is a follow up then.'
<hive-btn> (hive-btn.js) is a customized built-in <button is="hive-btn">
with its own shadow root -- extending HTMLButtonElement keeps every
native button behaviour (click/keyboard activation, :disabled, form
participation) instead of re-implementing it on a generic wrapper.
Shadow root holds only an adopted stylesheet + a <slot>, so the
button's light-DOM content (label, or asyncBtn's swapped-in spinner
span) renders through unchanged -- slotted content stays styled by the
light-DOM cascade, so the global .spinner class still applies.
Variants (cancel/confirm/danger) are a 'variant' attribute, not a CSS
class, since they're a semantic prop of the component.
Customized built-ins aren't supported in Safari/WebKit -- fine here,
the project targets recent Firefox only (same reasoning as the
original custom-elements pilot).
Wired into modal.js: HiveDialog's buttons now render as
<button is="hive-btn" variant="...">, replacing the old
component-common.css .btn copy -- deleted that file + component-
styles.js entirely (their sole purpose was giving dialog buttons a
.btn look, which hive-btn now owns properly). dom.js's el() gained
support so it can create customized built-ins the same way it
creates everything else. hive-dialog.css dropped the now-dead
.cancel/.confirm/.confirm.danger rules.
Per mara's scoping: NOT touching the other .btn consumers across the
app (dashboard/agent submit buttons, form() helper, etc.) or removing
.btn from dashboard/common.css / agent/agent.css in this PR -- that
migration + cleanup is an explicit follow-up.
Verified with a full frontend build (grepped bundled JS for hive-btn/
variant to confirm it inlines); nix fmt clean.
mara: 'i dont like sharing css via js, thats not how it should be done.'
Replaced the DIALOG_CSS/TOAST_CSS template-string constants in modal.js
and the inline CSS text in component-styles.js with three real .css
files (hive-dialog.css, hive-toast.css, component-common.css),
imported as raw text via esbuild's 'text' loader and turned into
CSSStyleSheet objects at runtime (same replaceSync() call as before --
only where the CSS text comes from changed). Both packages' build.mjs
gained a '.css': 'text' loader entry on their JS-bundling step; this
doesn't collide with the separate page-stylesheet bundling ('css'
loader), which is a different esbuild invocation over different entry
points.
No behavior change -- same adoptedStyleSheets wiring, same rules,
same output. Verified with a full frontend build (grepped the bundled
JS to confirm the CSS text inlines correctly); nix fmt clean.
Follow-up to the light-DOM custom-elements pilot (mara: 'that one landed
and works. i dont really like the css still being shared - id like that
to be split by component, with common stuff via @include').
<hive-dialog> and <hive-toast> now attach a shadow root and adopt a
component-scoped CSSStyleSheet built from a template-string constant in
modal.js (DIALOG_CSS / TOAST_CSS), plus a new shared/src/component-
styles.js sheet (currently just .btn) adopted alongside it via
adoptedStyleSheets -- the native equivalent of a Sass @include, no
preprocessor added. Theme vars keep resolving through the shadow
boundary since CSS custom properties inherit across it; only plain
class rules needed the explicit move.
Deleted the global shared/src/modal.css entirely and dropped its
@import from both dashboard/common.css and agent/agent.css -- nothing
outside modal.js renders the old .tc-* classes any more. The <hive-
dialog> element is now the backdrop itself (:host carries the fixed-
position/centering rules that used to be .tc-backdrop on a light-DOM
div); box/title/message/content/actions all render inside its shadow
root. <hive-toast> similarly styles :host directly instead of a light-
DOM div, with the message text placed straight into the shadow root
(no <slot> needed since there's no external light-DOM content to
project).
Public API (openDialog/themedConfirm/themedPrompt/themedToast)
unchanged -- no call-site changes needed anywhere in dashboard/agent.
Verified with a full frontend build; nix fmt clean.
Pilot for the components-split proposal (mara wants a look at using
custom elements now that we're recent-Firefox-only). Picked the
themed dialog system as the first candidate: most self-contained of
our existing de-facto reusable components (transient, imperative call
sites, no external render-tree coupling), and shared between the
dashboard and per-agent UI already.
<hive-dialog> replaces the manually-built tc-backdrop/tc-box tree in
openDialog — connectedCallback renders, the keydown listener and
click-outside-to-dismiss are owned by the element instead of a
closure, and the outcome is reported via a hive-dialog-close
CustomEvent rather than a hand-rolled resolve callback threaded
through the DOM tree.
<hive-toast> replaces the toast div themedToast built inline —
connectedCallback starts the auto-dismiss timer,
disconnectedCallback clears it (previously a closure-captured
setTimeout handle with no explicit cleanup on early removal).
Both are light DOM (no shadow root) — styling stays exactly where it
already lived, in modal.css's .tc-* classes, imported globally by
both packages' base stylesheets. This was the deliberate call for a
first pilot: shadow DOM would need every shared stylesheet
re-imported per instance (CSS custom properties pierce shadow
boundaries for theming, but plain class rules like .btn don't), which
is real migration cost. Light DOM validates the pattern (lifecycle
encapsulation, less manual event bookkeeping) without paying that
cost; shadow DOM is a drop-in upgrade to these same two classes if a
later pilot wants real style encapsulation.
Public API unchanged (openDialog/themedConfirm/themedPrompt/
themedToast) — every existing call site across dashboard + agent
keeps working with no changes. Verified with a full frontend build.
The dashboard has a themed modal/dialog system (modal.js: themedToast/
themedConfirm/themedPrompt) and a data-async form submit interceptor
(bindAsyncForms) that every dashboard action routes through. The
per-agent UI never adopted either — it had its own more primitive
data-async handler using native window.confirm()/alert() (8 call
sites) and a duplicated el() DOM helper.
- Moved el() out of dashboard/common.js into shared/src/dom.js.
- Moved modal.js + modal.css from dashboard/src/ to shared/src/,
updating its internal el import.
- Moved bindAsyncForms from dashboard/common.js into shared/forms.js,
alongside the asyncBtn primitive it's built on.
- Updated every dashboard file's imports to the new shared locations
(no re-export shims).
- agent.css now @imports shared/modal.css so the dialogs render
themed there too.
- agent/app.js: dropped its local el()/data-async duplicate, wired
bindAsyncForms(), and replaced all 8 window.confirm() sites with
themedConfirm (async, wrapped in a fire-and-forget IIFE where the
call site needs a synchronous boolean return, e.g. the slash-command
dispatcher).
Closes hyperhive#2791. Verified with a full frontend build
(npm run build) — both dashboard and agent bundles compile clean and
agent.css picks up the .tc-* dialog styles it previously lacked.
asyncBtn() (shared/src/forms.js) is used by both the dashboard and the
per-agent UI, but its .spinner class + @keyframes spin animation only
lived in dashboard.css. The agent UI's loading spinner rendered as a
static unstyled glyph instead of the animated amber spinner the
dashboard gets. Moved the rule to shared/base.css, which both
common.css (dashboard) and agent.css already @import.
- asyncBtn now returns fn().finally(...) so callers can await/chain it
- Move re-fetch calls inside try/catch in core.js and permissions.js so
network errors from fetchAndRenderStalePerms / fetchAndRender* are
caught instead of escaping as unhandled rejections
- clearStaleAgent returns the asyncBtn promise so the function is
properly awaitable when a button is present
- Update asyncBtn doc comment to reflect the return-value contract
add `asyncBtn(btn, fn)` to `@hive/shared/forms.js` as the single
reusable component for async button actions:
1. double-click guard: returns immediately if btn is already disabled
2. saves btn.innerHTML, replaces with spinner while in-flight
3. restores btn on resolve or reject via finally
wire it into all ad-hoc disable/spinner/restore patterns:
- common.js: bindAsyncForms uses asyncBtn internally
- core.js: 'clear perms' button
- permissions.js: clearStaleAgent
- schedules.js: saveSchedule submit, editSchedule submit
- app.js: buildAnswerForm, buildInboxMarkAllRow
fireScheduleNow in schedules.js is left with its existing childNode
save/restore because it shows a custom result flash on the button
content after a successful fire-now (the auto-restore of asyncBtn
would overwrite it); the surrounding themedConfirm dialog already
acts as a natural double-click barrier before the fetch.
saveAll in permissions.js is also left as-is: it uses a custom
'queued ✓' success label + a 900ms delay before re-fetch; the
btn.dataset.busy flag is its own double-submit guard.
When a tool call fails, claude wraps the result text in
<tool_use_error>...</tool_use_error> XML tags. The terminal was
displaying these raw, producing output like:
'<tool_use_error>File has not been read yet.</tool_use_error>'
Fix renderToolResult in app.js:
- Check c.is_error on the tool_result content block.
- Strip the <tool_use_error>...</tool_use_error> wrapper from the text.
- Render error results with a '✗' prefix under '.tool-result.error'
(flat, ≤120c) or '.tool-result-block.error' (<details>, longer text).
Add .live .tool-result.error { color: var(--red); } to terminal.css
so error results are visually distinct (red, same as turn-end-fail).
Update terminal-rendering.md row taxonomy to document the two new
error row classes.
Closes#2104.
Replaces the first-character-glyph + negative-text-indent trick (which let a
wide emoji or a leading disclosure caret knock the icon out of column) with a
genuine icon cell.
terminal.js: row() / details() / detailsDiff() take an optional `icon` that
goes in a fixed-width `.row-glyph` element (inline-block, 1.4em). Details
summaries wrap their text in a `.summary-text` span; the disclosure caret
moves to `.summary-text::before` so it leads the text, not the icon — keeping
the icon in the shared column. terminal.css carries the cell + caret rules.
app.js passes the per-tool emoji as `icon` for the flat tool-use row and every
expandable tool summary (Write/Edit/send/ask/answer/bash) plus the 💭 thinking
row, instead of string-prefixing it. A details `🖥️` now lines up under a flat
row's `🧠` regardless of emoji width. Doc: terminal-rendering.md layout
contract updated. Closes#1844.
The /logs.html header puts a flex:1 sub-tab strip (`.logs-tabbar`) next
to the ← home back-link inside the flex `.page-header`. The strip is
itself a flex container, so its default `min-width: auto` keeps it at its
intrinsic content width and — without an explicit nowrap on the parent —
it gets pushed onto its own row under the back-link.
Mirror the working dashboard chrome (`.dashboard-chrome` / `.tabbar`,
which sets `flex-wrap: nowrap`): make `.page-header` explicitly nowrap and
give `.logs-tabbar` `min-width: 0` so it shrinks to share the row instead
of wrapping. Affects every standalone page that uses `.page-header`
(flow / logs / stats / settings); the nowrap is correct for all of them.
The per-agent terminal marks turn boundaries (◆ TURN ← / ✓ turn) but
shows no time. Append a wall-clock HH:MM:SS to both the turn-start and
turn-end rows, plus the elapsed duration on turn-end, rendered as dim
metadata so the boundary glyph stays the focus.
This is the frontend half of the feature; it reads a per-event `ts`
(unix seconds) off the turn_start / turn_end events. The read is guarded
on a numeric `ts`, so until the harness surfaces per-event timestamps
the rows render exactly as before (inert-until-capture). Once the
backend attaches `ts` to the history rows + live SSE frame, the times
light up automatically for both live and scrollback, with no further
frontend change.
Per mara's review: instead of measuring the banner height in JS and
offsetting the chrome's sticky top, put the warning banner and the page
chrome in the same sticky div so they stack naturally.
common.js builds the wrapper: ensureStickyTop() wraps the page's chrome
(.dashboard-chrome / .page-header) in a single .sticky-top container and
injects the warning banner as its first child. The banner and the chrome
are no longer individually sticky — .sticky-top owns the stickiness, so
they pin together in one context instead of two top:0 stickies colliding
(the banner used to overlay the tab bar). Pages without a chrome (the
H0M3 hub) get a banner-only sticky region. No per-page markup needed; no
JS height measurement. Build green.
The tracker-tag lint's trailing class `[^0-9a-fA-F]` treated a non-hex
letter as a valid terminator, so it flagged hash-route fragments like
#24h / #1h (the stats-page window-selector routes) as tracker tags — four
false positives in the frontend. A real tracker tag is never immediately
followed by a letter, so widen the trailing class to [^0-9a-zA-Z]: still
catches every real tag (followed by space / punctuation / EOL) while
excluding any digit-run glued to a letter. Letter-bearing and 6-8-digit
hex stay excluded as before.
Also write theme.css's --crust fallback in six-digit form (#000 -> #000000)
to dodge the one remaining pure-numeric-short-hex residual the lint
documents. Pixel-identical colour. With both, the frontend tree
contributes zero lint hits; only the legacy backlog (Rust) remains.
Pattern + cases reviewed and approved by atlas on the issue.
The standalone pages (/flow, /logs, /stats, /settings) all share the same
sticky back-link header, but its CSS lived in the dashboard's common.css
under the misnomer `.logs-*` (the comment even noted it was used by more
than logs). Promote it to a first-class shared component.
- new @hive/shared/chrome.css with neutral `.page-header` / `.page-back` /
`.page-title` (cohering with the `.page-content` gutter wrapper);
exported from the shared package and @imported into common.css next to
base/terminal/tabs.css.
- rename the four pages' header markup `.logs-*` → `.page-*`.
- drop the old `.logs-*` chrome block from common.css; refresh the stale
doc comments in settings.css / stats.css / dashboard.css.
CSS-only, behaviour/visual-neutral (same rules, new names + home). The
dashboard keeps its own richer sticky `.dashboard-chrome`; unifying the
per-agent UI's `.stats-nav` chrome is a larger follow-up, deferred.
Part of #1464 step 3.
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.