dashboard: rename src/app.js → src/tabs.js (closes #406)

#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.
This commit is contained in:
iris 2026-05-26 19:39:27 +02:00
commit a2c5348ca4
10 changed files with 69 additions and 57 deletions

View file

@ -118,9 +118,11 @@ frontend/ npm workspaces (esbuild → static dist). Built
+ base typography (was hive-fr0nt). ES module
exporting { create, linkify }; pure JS, no IIFE
globals; consumed by dashboard + agent.
packages/dashboard/ @hive/dashboard SPA: src/{index.html, app.js,
dashboard.css} + build.mjs → dist/{index.html,
static/{app.js, dashboard.css}}.
packages/dashboard/ @hive/dashboard SPA: src/{index.html, tabs.js,
flow.html, flow.js, common.js, dashboard.css} +
build.mjs → dist/{index.html, flow.html,
static/{tabs.js, flow.js, dashboard.css,
stream-worker.js}}.
packages/agent/ @hive/agent default per-container UI: src/
{index, stats, screen}.html + {app, stats}.js
+ agent.css → dist/{*.html, static/*}.

View file

@ -34,7 +34,7 @@ in `hive-c0re::dashboard_events::DashboardEvent`.
## Async forms
Dashboard + per-agent mutating forms carry `data-async`; a delegated
`submit` listener in `assets/app.js` intercepts, shows a spinner,
`submit` listener in `assets/tabs.js` intercepts, shows a spinner,
POSTs `application/x-www-form-urlencoded` (axum's `Form` extractor
rejects multipart), calls `refreshState()` on success. New mutating
forms should add `data-async` and optionally `data-confirm` (for a

View file

@ -67,7 +67,7 @@ listener: read `data-confirm`, swap the button to a spinner, POST
re-render), call `refreshState()`. State shapes live in
`dashboard.rs::StateSnapshot` and `web_ui.rs::StateSnapshot` — when
adding state fields, plumb through the snapshot struct and the
relevant `assets/app.js` render function.
relevant `assets/tabs.js` render function.
**Focus preservation:** `refreshState` checks whether
`document.activeElement` sits inside one of the managed sections
@ -298,7 +298,7 @@ the tooltip says "select … for bulk actions" or "deselect … (or press
Esc to clear all)". The `<img>` points at `<url>/icon`; load failure
falls back to the dimmed hyperhive mark (`/favicon.svg`). The card
body sits to the right with three stacked lines
(`assets/app.js::renderContainers`).
(`assets/tabs.js::renderContainers`).
- Line 1: agent name (link → new tab), m1nd/ag3nt chip, an
**icon-only nav strip** populated async from the agent backend
@ -382,7 +382,7 @@ every render before the bar appears.
### Approval card
Each pending approval renders as a card (`assets/app.js::
Each pending approval renders as a card (`assets/tabs.js::
renderApprovals`) with three stacked sections:
- **identity header** — glyph, `#id`, agent, kind chip, (for

View file

@ -2,11 +2,11 @@
//
// dist/index.html served by the Rust router at GET /
// dist/flow.html served at GET /flow.html
// dist/static/app.js /index.html entry — tab renderers +
// dist/static/tabs.js /index.html entry — tab renderers +
// tab routing + refreshState
// dist/static/flow.js /flow.html entry — broker terminal +
// operator inbox + @-mention composer
// dist/static/{app,flow}.js.map source map siblings
// dist/static/{tabs,flow}.js.map source map siblings
// dist/static/dashboard.css served at /static/dashboard.css
// (@import resolved from @hive/shared)
//
@ -35,7 +35,7 @@ mkdirSync(staticDir(''), { recursive: true });
// follow-up once asset sizes warrant it). esbuild writes each entry
// to `static/<name>.js` based on the entryPoint basename.
await build({
entryPoints: [src('app.js'), src('flow.js')],
entryPoints: [src('tabs.js'), src('flow.js')],
outdir: staticDir(''),
bundle: true,
format: 'esm',
@ -47,7 +47,7 @@ await build({
// Stream-worker entry (#448). Lives in a separate bundle: SharedWorker
// scripts run in a different global (`self` is the worker scope, no
// `window`) so they can't be inlined into app.js / flow.js. Output is
// `window`) so they can't be inlined into tabs.js / flow.js. Output is
// at `static/stream-worker.js`; common.js's `openStream` references
// `/static/stream-worker.js` as the SharedWorker URL. `format: 'iife'`
// matches the classic-script load (`new SharedWorker(url, name)` with

View file

@ -1,14 +1,13 @@
// Shared dashboard helpers — extracted from app.js as step 1 of the
// #406 split. These bits are used by both the tab dashboard
// (index.html) and the flow page (flow.html): pure DOM helpers, the
// side-panel singleton, the OS-notification module, and the
// path-link / file-preview infrastructure for the side panel.
// Shared dashboard helpers — extracted from the original monolithic
// dashboard JS as step 1 of the #406 split. These bits are used by
// both the tab dashboard (index.html) and the flow page (flow.html):
// pure DOM helpers, the side-panel singleton, the OS-notification
// module, and the path-link / file-preview infrastructure for the
// side panel.
//
// Both pages currently still load `app.js` as their single entry
// point; this module is bundled inline by esbuild via the import
// below. The follow-up step splits app.js into per-page entry
// points (tabs.js + flow.js), at which point both will import from
// here directly.
// Each page now has its own entry point — `./tabs.js` for index.html,
// `./flow.js` for flow.html — and both import from here directly
// (#406 steps 2 + 3 complete; #406 closed).
import { linkify as termLinkify } from '@hive/shared/terminal.js';
@ -53,7 +52,7 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
return f;
};
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` stay in app.js
// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` stay in tabs.js
// for now — each has display-specific phrasing ("X running", "X ago")
// tied to its caller, so they don't generalise cleanly. We can lift
// them when a second consumer needs the same shape.
@ -62,7 +61,7 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
// Returns an EventSource-shaped object backed by a SharedWorker that
// holds ONE upstream `new EventSource(url)` and fans events out to
// every connected tab. Replaces direct `new EventSource(url)` at the
// dashboard's two consumer sites (app.js inline + flow.js via
// dashboard's two consumer sites (tabs.js inline + flow.js via
// terminal.js's `streamFactory` option) so N hyperhive tabs share
// ONE backend connection — way under the browser's per-host
// connection cap, immune to per-tab throttling that drops the SSE

View file

@ -108,7 +108,7 @@ body.dashboard-shell {
}
/* Tab pane visibility show only the active one. The .tab-pane-active
class is set by app.js based on the URL hash; default (no hash)
class is set by tabs.js based on the URL hash; default (no hash)
resolves to SW4RM. */
.tab-pane { display: none; }
.tab-pane.tab-pane-active { display: block; }
@ -190,7 +190,7 @@ a:hover {
transition: opacity 200ms ease, border-color 200ms ease;
}
/* Topology indent (#363). Each depth level shifts the row right by one
step; the .tree-prefix span (drawn by app.js::treePrefix) carries
step; the .tree-prefix span (drawn by tabs.js::treePrefix) carries
the / glyph and any continuation lines that thread through
ancestor columns. When every container has parent=null (pre-#361
state) `[data-depth]` is absent on every row and these rules are
@ -1592,7 +1592,7 @@ body.flow-shell .tabbar .tab.active.tab-link {
}
/* Tail pill ( N new): bottom offset clears the floating composer.
z-index escapes the stacking context the .terminal-wrap's
backdrop-filter creates (issue #375) app.js anchors the pill
backdrop-filter creates (issue #375) tabs.js anchors the pill
on .flow-main now (not .terminal-wrap), so this z-index reaches
the root stacking context and properly floats above the
composer at z-index 30. */
@ -1861,6 +1861,6 @@ body.flow-shell .tabbar .tab.active.tab-link {
/* Pad the dashboard body so the sticky bar doesn't cover the
bottom of the agent list. ~3.4em covers the bar's vertical
footprint with breathing room; only applies when the bar is
visible (`body.has-selection`, toggled by app.js when the
visible (`body.has-selection`, toggled by tabs.js when the
selection set is non-empty). */
body.dashboard-shell.has-selection { padding-bottom: 4.5em; }

View file

@ -41,7 +41,8 @@
</a>
<!-- Notif controls cohabit with the tabs (always-on chrome).
Same IDs as on the dashboard so app.js's NOTIF binding
Same IDs as on the dashboard so the shared NOTIF binding
(from common.js, imported by both tabs.js and flow.js)
picks them up unchanged. -->
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
@ -84,7 +85,7 @@
</div>
</footer>
<!-- Inbox rendered offscreen — kept in the DOM so app.js's
<!-- Inbox rendered offscreen — kept in the DOM so flow.js's
renderInbox keeps working unchanged. The pill click handler
opens the side panel which displays a clone of the list. The
legacy section heading would otherwise be visible; hidden
@ -113,7 +114,7 @@
<!-- Flow-specific bundle (#406 step 2). Contains the broker
terminal init, the operator-inbox derived store, the inbox
pill flyout, and the @-mention composer. Tab renderers etc.
live in `/static/app.js` which /flow.html doesn't need. -->
live in `/static/tabs.js` which /flow.html doesn't load. -->
<script type="module" src="/static/flow.js" defer></script>
</body>
</html>

View file

@ -1,4 +1,6 @@
// /flow.html entry point (#406 step 2 — flow-specific split from app.js).
// /flow.html entry point (#406 step 2 — flow-specific split from the
// previous combined entry; #406 step 3 renamed that combined entry
// from `app.js` to `tabs.js`).
//
// Owns the full-page broker terminal, the operator-inbox derived store
// (populated from the broker stream), the inbox pill flyout, and the
@ -6,10 +8,10 @@
// panel, OS notifications, path linkification) from `./common.js`.
//
// Does NOT contain the dashboard's tab renderers, mutation-event
// dispatchers, or refreshState — that's `./app.js` (the legacy entry
// kept until step 3 renames it to `tabs.js`). For now the flow page
// runs purely on the broker stream + an initial /api/state fetch
// (compose autocomplete needs the live container list).
// dispatchers, or refreshState — that's `./tabs.js`, loaded only by
// /index.html. The flow page runs purely on the broker stream + an
// initial /api/state fetch (compose autocomplete needs the live
// container list).
import { create as termCreate } from '@hive/shared/terminal.js';
import {

View file

@ -173,7 +173,7 @@
<!-- FL0W: lives on its own page now (`/flow.html`). The
message-flow + inbox + compose DOM only exists there — when
app.js boots on this page the corresponding renderers
tabs.js boots on this page the corresponding renderers
no-op silently (each guard is `if (!el) return`). -->
</main>
@ -216,9 +216,11 @@
title="clear selection (esc)">✕ clear</button>
</div>
<!-- Single bundled entry. esbuild folds @hive/shared/terminal.js and
the marked npm package into app.js; load order is preserved by
the module bundler. -->
<script type="module" src="/static/app.js" defer></script>
<!-- Single bundled entry (#406 step 3 — renamed from app.js to
tabs.js since this bundle is the dashboard *tabs* surface only;
flow.html has its own flow.js bundle). esbuild folds
@hive/shared/terminal.js and the marked npm package into
tabs.js; load order is preserved by the module bundler. -->
<script type="module" src="/static/tabs.js" defer></script>
</body>
</html>

View file

@ -1,16 +1,18 @@
// /index.html entry point: tab renderers + tab routing + refreshState
// + notification deltas. Reads /api/state on cold load and after every
// async-form submit; live updates run through `applyXxx` mutation
// handlers triggered by the dashboard event stream. (Live SSE
// subscription on /index.html is still missing — see #406 step 3 / #408.)
// handlers triggered by the dashboard event stream.
//
// #406 step 1: pure helpers + side panel + OS notifications + path
// linkification moved to `./common.js`.
// #406 step 2: the flow-only IIFEs (operator inbox, inbox-pill, broker
// terminal, @-mention composer) moved to `./flow.js`. /flow.html now
// loads `flow.js` as its own bundle entry; this file (still named
// `app.js` for the moment; rename to `tabs.js` is the last step
// of #406) is loaded only by /index.html.
// terminal, @-mention composer) moved to `./flow.js`. /flow.html loads
// `flow.js` as its own bundle entry; this file is loaded only by
// /index.html.
// #406 step 3: file renamed from `app.js` → `tabs.js` since it owns
// the dashboard *tabs* surface only (the FL0W page has its own bundle).
// Live SSE subscription is wired through `openStream` from common.js
// (#406 step 3 hookup; see also #408 for stream-side filtering).
import { marked } from 'marked';
import {
@ -446,14 +448,17 @@ window.marked = marked;
function renderContainers(s) {
const root = $('containers-section');
// #containers-section only exists on /index.html. The same
// bundled app.js runs on /flow.html (which has the broker
// terminal but no container list) and on any future pages —
// `container_state_changed` SSE events arrive on every page and
// route through `applyContainerStateChanged → renderContainersFromState`,
// so without this guard the renderer throws `root is null` on
// every event (#399). Matches the no-op-when-target-absent
// convention the other renderers (renderTombstones, etc.) follow.
// #containers-section only exists on /index.html. tabs.js is the
// bundle for that page only (#406 step 3 — /flow.html loads
// flow.js instead), but historical context: pre-split the
// `container_state_changed` SSE handler routed through
// `applyContainerStateChanged → renderContainersFromState` on
// every page that loaded the single combined bundle, and the
// guard prevented a `root is null` throw on /flow.html (#399).
// Today it's belt-and-suspenders for any future page that adds
// tabs.js without a #containers-section. Matches the
// no-op-when-target-absent convention the other renderers
// (renderTombstones, etc.) follow.
if (!root) return;
root.innerHTML = '';
@ -1102,9 +1107,10 @@ window.marked = marked;
function renderQuestions() {
const root = $('questions-section');
// #questions-section only lives on /index.html (Y3R C4LL tab);
// no-op on /flow.html etc. — the bundled app.js runs everywhere
// and `question_added` / `question_resolved` SSE events route
// through here (#399).
// no-op when missing (#406 step 3 — only /index.html loads
// tabs.js, but kept as belt-and-suspenders for any future page
// adding it without that section). `question_added` /
// `question_resolved` SSE events route through here (#399).
if (!root) return;
root.innerHTML = '';
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);