#406 was the JS-split refactor: pull shared helpers into
common.js (step 1), pull the flow-only IIFEs into flow.js
(step 2), then rename the legacy combined entry from app.js
to tabs.js (step 3 — this commit) to reflect that the bundle
now owns the dashboard tabs surface only.
What moved:
- `frontend/packages/dashboard/src/app.js` → `tabs.js`
- `build.mjs` entry: `src('app.js')` → `src('tabs.js')`;
output is now `static/tabs.js`
- `index.html` `<script>` src: `/static/app.js` →
`/static/tabs.js`; the FL0W-section comment retouched
- `flow.html` reference from `/static/app.js`-as-tabs to
`/static/tabs.js`; notif + offscreen-inbox comments rewired
to point at the correct owners (common.js for NOTIF,
flow.js for renderInbox)
- `common.js`, `flow.js`, `tabs.js` headers: scrub stale
"app.js" references; document that #406 steps 2 + 3 are
done and both pages import directly from common.js
- `dashboard.css` comments: every "by app.js" → "by tabs.js"
- `docs/web-ui.md`, `docs/conventions.md`: legacy
`assets/app.js` → `assets/tabs.js` (the path prefix is
itself stale from a much-earlier rename, but consistent
with the rest of those docs)
- `CLAUDE.md` file map: refresh the dashboard package's
src/ and dist/ layouts to reflect the post-split shape
(tabs.js, flow.js, common.js, stream-worker.js)
What did NOT move:
- `frontend/packages/agent/src/app.js` (per-agent UI entry)
stays named `app.js` — it's a separate package, has only
one entry point, no split happened there
- The two "no-op when target absent" guards in renderContainers
and renderQuestions are kept as belt-and-suspenders for any
future page that adds tabs.js without the corresponding
sections; comments updated to note this rather than the
pre-split flow.html reason
Functional behaviour identical; this is a pure rename + comment
sweep.
two related flyout improvements — both small, single PR.
#450 — full-height inbox
dropped the .inbox max-height: 24em cap. the side-panel-body
already provides overflow: auto, so the cap was just clamping the
inbox list short of the available panel height on tall viewports.
the inbox now fills as much of the panel as it needs and the
panel itself scrolls.
#451 — drag-to-resize side panel
added a 6px hit-strip on the drawer's left edge. mousedown/move/
up handlers track the drag and update --side-panel-w on the
drawer; the CSS variable defaults to min(760px, 94vw) (preserving
pre-#451 behaviour) and is clamped to [320px, 96vw] so a bad
stored value can never wedge the drawer off-screen.
ergonomics
- handle is invisible at rest, mauve glow on hover + during drag
so the affordance is discoverable but doesn't compete with the
2px mauve border-left for the visual boundary.
- body.side-panel-resizing class forces ew-resize cursor + kills
user-select page-wide so the cursor doesn't flip back to
default the moment it leaves the 6px band during a fast drag.
- final width persists to localStorage (key
hyperhive:side-panel-width) so it survives reload. window
resize re-clamps so a stored width that exceeds the new 96vw
shrinks accordingly.
- handle is a separator role with aria-orientation: vertical +
aria-label for screen readers.
files
- frontend/packages/dashboard/src/dashboard.css
- .inbox: dropped max-height (#450).
- .side-panel-drawer: width = var(--side-panel-w, min(760px,
94vw)) + min/max clamp (#451).
- new .side-panel-resize + body.side-panel-resizing rules.
- frontend/packages/dashboard/src/common.js
- Panel.bind now also calls applyStoredWidth + bindResize.
- resize handle is prepended to the drawer at bind time so
every flyout (inbox, file preview, diff, journal) gets it.
validation
- npm run build --workspace=@hive/dashboard clean. CSS 40.9kb →
41.3kb. app.js + flow.js grew ~0.5kb each (resize handler).
- browser smoke test isn't possible from inside iris's container;
the resize math is straightforward (width = innerWidth -
clientX, clamped) and the CSS variable + localStorage
persistence are standard patterns.
mara on #453: address argus's two yellow nits.
bfcache restore gap
previously openStream registered a pagehide unsubscribe but never
re-subscribed on pageshow, so a bfcache restore left the consumer's
onmessage bound but no events flowing.
fix: maintain a registry of live subscriptions (Map<url, target,
route>). bind page lifecycle hooks once:
- pagehide: unsubscribe every URL, drop route listeners, invalidate
the cached SharedWorker port (it may be collected if all other
tabs closed while this page was frozen).
- pageshow { persisted: true }: get a fresh port via getSharedPort
(creates a new SharedWorker if needed), re-attach every route
listener, re-subscribe to every URL. target.readyState resets to
CONNECTING so the worker's synthetic open after subscribe fires
the consumer's onStreamOpen and triggers a refresh.
worker bundle format
stream-worker.js was bundled as format: 'esm' but loaded as
classic via new SharedWorker(url, name). today's worker has no
imports/exports so the ESM bundle is syntactically valid as a
classic script; argus's concern was that a future contributor
adding an import would silently break things.
fix: switched build.mjs to format: 'iife'. esbuild now wraps the
worker output in (() => { ... })(); any future import statement
would surface as a build error rather than ship broken code.
verified output starts with the IIFE wrapper.
other
- target.close() now reads _sharedPort lazily so close-after-bfcache
(port may have been recreated) doesn't try to postMessage on a
stale reference.
- _activeSubs.delete on close keeps the registry honest if a
consumer ever explicitly closes a stream (none do today, but the
shape stays correct).
validation: npm run build clean. stream-worker.js: 1.8 kb → 1.9 kb
(IIFE wrapper). common.js bfcache logic adds ~30 LOC inside the
existing module — bundle deltas negligible.
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.
First slice of the app.js split (#406). Pure utility / infrastructure
code that both /index.html and /flow.html use lifts out of the IIFE
into a sibling ES module:
- DOM helpers: `$`, `el`, `esc`, `form`, `fmtAgeSecs`
- Side-panel singleton (`Panel.open` / `openNamed` / `refresh` /
`close` / `bind`). The `ensure()` lazy-init makes it tolerate
being imported before the DOM element exists — `bind()` still
needs to be called once the host page is ready.
- Path linkification + file-preview side panel:
`appendLinkified`, `appendText`, `makePathLink`, plus the
internal `openFilePanel` + `fetchStateFile` + `mdNode` /
`svgImage` / `buildTabbedPreview` it depends on.
- Browser-notification module `NOTIF` (`bind`, `show`,
`renderControls`).
`app.js` now imports these from `./common.js` and the duplicated
definitions are gone. Each removal is replaced by a one-line
breadcrumb comment so a reader chasing a name from the bundled
output can find where it landed.
`truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` stay in app.js for
now — each has caller-specific phrasing ("X running", "X ago") that
doesn't generalise cleanly. Lift them when a second consumer needs
the same shape.
## Next steps (separate PRs)
- Step 2: split app.js into `tabs.js` (entry for /index.html — tab
renderers + tab routing + refreshState) and `flow.js` (entry for
/flow.html — broker terminal + inbox derived store + compose),
both importing from common.js. Updates `build.mjs` for multiple
entry points and switches each HTML file's `<script src>`.
- Step 3 (#408 follow-up): backend-side stream split so /flow.html
doesn't have to subscribe to the dashboard's mutation events at all.
## Validation
- `npm run build` clean.
- Build deltas: `app.js` 154.3kb (was 153.6kb) — bundle size bumped
slightly due to per-module overhead; same code under the hood.
Source: app.js 2603 → 2287 lines (-316); common.js 367 lines (new).
- No HTML / CSS changes. Both pages still load `/static/app.js` as
before.
Browser smoke test isn't possible from inside iris's container.
Worth eyeballing post-deploy:
- Notification toggle + send still works (NOTIF.bind, NOTIF.show)
- Side panel still opens for diff / file preview / logs (Panel)
- Path tokens in messages still render as clickable anchors that
open the file in the side panel (appendLinkified → makePathLink
→ openFilePanel)