tabs.js shrinks from 1651 to 457 lines. swarm.js is a new 1217-line module that owns the containers/selection-bar/peer-hives domain: - Container-state apply handlers (applyContainerStateChanged/Removed) - Rebuild-queue sync + apply (syncRebuildQueueFromSnapshot, applyRebuildQueueChanged) - Transient-state sync + apply (syncTransientsFromSnapshot, applyTransientSet/Cleared) - renderContainersFromState (re-render from cached snapshot) - Per-agent context menu (buildAgentMenu, agentMenuPost, closeAllAgentMenus) - Topology tree builder (buildAgentTree, treePrefixDom) - Container row (containerRowFingerprint, buildContainerLi, renderContainers) - Selection bar (renderSelectionBar, addMoveActions, validReparentCandidates, addBulkButton) - Peer hives section (renderPeerHives) - Status-age ticker (30s setInterval for .status-age[data-set-at] spans) - CTX_WARN/CTX_CAUTION constants - Selection event listeners (Esc to clear, click on selection-clear) - Agent-menu event listeners (click outside to close, Esc to close) tabs.js (coordinator) retains: - notifyDeltas + seenApprovals/seenQuestions/seededNotify - bindAsyncForms - Cross-domain ticker (approval request-age + reminder/schedule due-at) - refreshState, pollTimer, operatorIsTyping, snapshotOpenDetails - MUTATION_HANDLERS dispatch + bindDashboardStream - activateTab, createTabStrip, initCall, refreshTabCounts, setTabCount Behaviour-preserving code-move. Build verified.
457 lines
21 KiB
JavaScript
457 lines
21 KiB
JavaScript
// /dashboard.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 (subscribed via
|
|
// `openStream` from common.js). See docs/web-ui.md::Shape (shared by
|
|
// both) for the broader contract.
|
|
//
|
|
// Pure helpers (DOM, side panel, OS notifications, path linkification)
|
|
// live in `./common.js`; the flow-page surface (operator inbox, broker
|
|
// terminal, @-mention composer) lives in `./flow.js` — `flow.html` and
|
|
// `dashboard.html` each load their own bundle.
|
|
// SW4RM (containers) domain lives in `./swarm.js`.
|
|
|
|
import { marked } from 'marked';
|
|
import {
|
|
$, el,
|
|
Panel, NOTIF,
|
|
openStream, renderServerWarnings, bindAsyncForms,
|
|
} from './common.js';
|
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
|
import {
|
|
containersState, syncContainersFromSnapshot,
|
|
} from './state.js';
|
|
import { fmtAgo, fmtDuration } from './util.js';
|
|
import {
|
|
applyCapabilitiesChanged, applyToolGroupsChanged,
|
|
fetchAndRenderCapabilities, fetchAndRenderToolGroups,
|
|
initPermissions,
|
|
} from './permissions.js';
|
|
import {
|
|
applySchedulesChanged, applyRemindersChanged,
|
|
refreshSchedules, refreshReminders, activeScheduleCount,
|
|
} from './schedules.js';
|
|
import {
|
|
initCall,
|
|
refreshOperatorInbox, operatorInboxAppendFromEvent, operatorInboxCount,
|
|
syncApprovalsFromSnapshot, applyApprovalAdded, applyApprovalResolved,
|
|
renderApprovals, activeApprovalCount,
|
|
syncQuestionsFromSnapshot, applyQuestionAdded, applyQuestionResolved,
|
|
renderQuestions, activeQuestionCount,
|
|
} from './call.js';
|
|
import {
|
|
syncRebuildQueueFromSnapshot, syncTransientsFromSnapshot,
|
|
applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved,
|
|
applyTransientSet, applyTransientCleared,
|
|
renderContainers, renderContainersFromState,
|
|
renderSelectionBar, renderPeerHives,
|
|
} from './swarm.js';
|
|
|
|
// mdNode (in common.js) reads `window.marked` for the markdown side
|
|
// panel preview path. Set it here on the dashboard entry so file
|
|
// previews work; flow.js does the same for the flow-page entry.
|
|
window.marked = marked;
|
|
|
|
(() => {
|
|
// Track which items we've already notified about so a re-render
|
|
// doesn't re-fire for the same row. Keyed by stable ids; reset only
|
|
// when the page reloads.
|
|
const seenApprovals = new Set();
|
|
const seenQuestions = new Set();
|
|
let seededNotify = false;
|
|
|
|
function notifyDeltas(s) {
|
|
const approvals = s.approvals || [];
|
|
const questions = s.questions || [];
|
|
if (!seededNotify) {
|
|
// First render after page load — fill the "seen" sets without
|
|
// firing notifications. We only want to notify on NEW items
|
|
// that arrived while the page is open. The inbox no longer
|
|
// needs seeding here: it's derived from the broker stream which
|
|
// does its own per-event notification on live arrival, and
|
|
// history-replayed events are silent by virtue of `fromHistory`.
|
|
for (const a of approvals) seenApprovals.add(a.id);
|
|
for (const q of questions) seenQuestions.add(q.id);
|
|
seededNotify = true;
|
|
return;
|
|
}
|
|
for (const a of approvals) {
|
|
if (seenApprovals.has(a.id)) continue;
|
|
seenApprovals.add(a.id);
|
|
const verb = a.kind === 'spawn' ? 'spawn approval'
|
|
: a.kind === 'init_config' ? 'config-init approval'
|
|
: 'config commit';
|
|
NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`,
|
|
'hyperhive:approval:' + a.id);
|
|
}
|
|
for (const q of questions) {
|
|
if (seenQuestions.has(q.id)) continue;
|
|
seenQuestions.add(q.id);
|
|
const targetLabel = q.target || 'operator';
|
|
NOTIF.show(`◆ ${q.asker} → ${targetLabel} asks`,
|
|
q.question.slice(0, 120),
|
|
'hyperhive:question:' + q.id);
|
|
}
|
|
}
|
|
|
|
// ─── async forms ────────────────────────────────────────────────────────
|
|
// Shared `data-async` submit interceptor (now in common.js so /core.html's
|
|
// bundle gets it too). On success it re-runs refreshState unless the form
|
|
// opts out via `data-no-refresh` (its mutation arrives via an SSE event).
|
|
bindAsyncForms(() => refreshState());
|
|
|
|
// Live ticker for approval request-age chips. Approval cards only
|
|
// re-render on `approval_added`/`approval_resolved` SSE events, so
|
|
// a request pending for an hour could still show "0s ago" without
|
|
// this ticker. Also flips `.stale` (amber highlight) at exactly 1h
|
|
// rather than only at the next re-render.
|
|
// Live countdown for reminder due-at labels and schedule next-fire
|
|
// cells. Both renderers stamp `data-due-at` on the element so this
|
|
// single ticker keeps them fresh without triggering a full re-render.
|
|
// `.reminder-due` -> "overdue X ago" / "in Y"
|
|
// `.sched-due` -> same pattern
|
|
setInterval(() => {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
document.querySelectorAll('.approval-ts[data-requested-at]').forEach((node) => {
|
|
const requestedAt = Number(node.getAttribute('data-requested-at'));
|
|
if (!Number.isFinite(requestedAt)) return;
|
|
const ageSec = Math.max(0, now - requestedAt);
|
|
node.textContent = 'requested ' + fmtAgo(requestedAt);
|
|
node.classList.toggle('stale', ageSec >= 3600);
|
|
});
|
|
document.querySelectorAll('.reminder-due[data-due-at], .sched-due[data-due-at]').forEach((node) => {
|
|
const dueAt = Number(node.getAttribute('data-due-at'));
|
|
if (!Number.isFinite(dueAt)) return;
|
|
const dueIn = dueAt - now;
|
|
node.textContent = dueIn <= 0
|
|
? 'overdue ' + fmtAgo(dueAt)
|
|
: (node.classList.contains('reminder-due') ? 'in ' : '') + fmtDuration(dueIn);
|
|
});
|
|
}, 1000);
|
|
|
|
// ─── state polling ──────────────────────────────────────────────────────
|
|
let pollTimer = null;
|
|
// Sections whose innerHTML gets blown away on each refresh. If the
|
|
// operator is typing in one of them, skip the refresh — the next
|
|
// tick (or a manual action) will pick it up after they blur.
|
|
const MANAGED_SECTION_IDS = [
|
|
'containers-section',
|
|
'questions-section',
|
|
'inbox-section',
|
|
'approvals-section',
|
|
'reminders-section',
|
|
'schedules-section',
|
|
'capabilities-section',
|
|
'tool-groups-section',
|
|
];
|
|
// <details> sections that should survive a refresh need a stable
|
|
// `data-restore-key` attribute. snapshotOpenDetails walks managed
|
|
// sections and records which keys are currently open; restoreOpenDetails
|
|
// re-applies after the render. (Long-content drill-ins — file
|
|
// previews, diffs, logs, config — open in the side panel instead,
|
|
// which lives outside the managed sections and survives re-render
|
|
// on its own.)
|
|
function snapshotOpenDetails() {
|
|
const open = new Set();
|
|
for (const id of MANAGED_SECTION_IDS) {
|
|
const sect = document.getElementById(id);
|
|
if (!sect) continue;
|
|
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
|
|
if (d.open) open.add(d.dataset.restoreKey);
|
|
}
|
|
}
|
|
return open;
|
|
}
|
|
function restoreOpenDetails(open) {
|
|
if (!open.size) return;
|
|
for (const id of MANAGED_SECTION_IDS) {
|
|
const sect = document.getElementById(id);
|
|
if (!sect) continue;
|
|
for (const d of sect.querySelectorAll('details[data-restore-key]')) {
|
|
if (open.has(d.dataset.restoreKey)) d.open = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
function operatorIsTyping() {
|
|
const el_ = document.activeElement;
|
|
if (!el_ || el_ === document.body) return false;
|
|
const tag = el_.tagName;
|
|
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
|
|
return MANAGED_SECTION_IDS.some((id) => {
|
|
const sect = document.getElementById(id);
|
|
return sect && sect.contains(el_);
|
|
});
|
|
}
|
|
|
|
async function refreshState() {
|
|
// Don't yank the form out from under the operator. Try again
|
|
// shortly on the next tick; eventually they'll blur and the
|
|
// refresh lands.
|
|
if (operatorIsTyping()) {
|
|
if (pollTimer) clearTimeout(pollTimer);
|
|
pollTimer = setTimeout(refreshState, 2000);
|
|
return;
|
|
}
|
|
try {
|
|
const resp = await fetch('/api/state');
|
|
if (!resp.ok) throw new Error('http ' + resp.status);
|
|
const s = await resp.json();
|
|
// Stash the latest snapshot for any sub-widget that wants a
|
|
// synchronous read (e.g. the compose autocomplete pulls agent
|
|
// names from here instead of refetching on every keystroke).
|
|
window.__hyperhive_state = s;
|
|
// Peer hives render as a headline section under SW4RM (no longer
|
|
// a tab); renderPeerHives shows/hides its own headline block.
|
|
renderPeerHives(s.peer_hives || []);
|
|
renderServerWarnings(s.server_warnings);
|
|
// (The M4TR1X surface is reachable from the H0M3 hub now, not the
|
|
// dashboard tab strip — home.js gates its tile on matrix_gui_enabled.)
|
|
// Hive identity: render the swarm/hive name as a headline at the
|
|
// top of the SW4RM pane + update the page title once the
|
|
// server-side display names are known. `hive_name` / `swarm_name`
|
|
// come from HYPERHIVE_HIVE_NAME / HYPERHIVE_SWARM_NAME env vars
|
|
// (set by services.hyperhive.{hiveName,swarmName} nix options).
|
|
// When unset we fall back gracefully — the headline stays hidden.
|
|
const hiveId = $('swarm-identity');
|
|
if (hiveId) {
|
|
const hive = s.hive_name;
|
|
const swarm = s.swarm_name;
|
|
if (hive || swarm) {
|
|
const label = swarm && hive ? `${swarm} / ${hive}`
|
|
: hive || swarm;
|
|
hiveId.textContent = label;
|
|
hiveId.hidden = false;
|
|
// Preserve any (N) call-count prefix already applied by
|
|
// refreshTabCounts so the title doesn't flicker on reload.
|
|
const existingPrefix = document.title.match(/^(\(\d+\) )/)?.[1] || '';
|
|
document.title = existingPrefix + label + ' // h1ve-c0re';
|
|
}
|
|
}
|
|
const openDetails = snapshotOpenDetails();
|
|
// Sync transients + containers first so renderContainers below
|
|
// sees the current derived maps (it reads from
|
|
// `transientsState` + `containersState`, not from `s.*`).
|
|
syncTransientsFromSnapshot(s);
|
|
syncContainersFromSnapshot(s);
|
|
// Rebuild-queue state feeds the SW4RM agent-card badges
|
|
// (inFlightOpsByAgent); its own panel now lives on /core.html.
|
|
syncRebuildQueueFromSnapshot(s);
|
|
renderContainers(s);
|
|
// Sync the derived approvals + questions stores from the
|
|
// snapshot, then render. Live `*_added` / `*_resolved` events
|
|
// mutate the stores directly and re-render without a snapshot
|
|
// refetch.
|
|
syncQuestionsFromSnapshot(s);
|
|
renderQuestions();
|
|
// (renderInbox now lives in ./flow.js — dashboard has no
|
|
// #inbox-section element to render into.)
|
|
syncApprovalsFromSnapshot(s);
|
|
renderApprovals();
|
|
refreshReminders();
|
|
refreshSchedules();
|
|
restoreOpenDetails(openDetails);
|
|
notifyDeltas(s);
|
|
// No periodic refresh timer. Phase 6 covers every container
|
|
// mutation with `ContainerStateChanged` / `ContainerRemoved`
|
|
// (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
|
|
// approvals + questions + transients have their own events;
|
|
// broker traffic flows through the SSE channel. The only
|
|
// /api/state fetches are the initial cold load and the
|
|
// post-submit refetch on forms without `data-no-refresh`
|
|
// (tombstones, meta-input updates).
|
|
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
|
|
} catch (err) {
|
|
console.error('refreshState failed', err);
|
|
// Schedule a single retry on transient errors so the page
|
|
// recovers from a brief network blip without making the
|
|
// operator reload.
|
|
pollTimer = setTimeout(refreshState, 5000);
|
|
}
|
|
}
|
|
refreshState();
|
|
// Cold-load the operator inbox so the Y3R C4LL pill + browser
|
|
// title reflect unread agent->operator messages immediately, before the
|
|
// operator opens the tab. Live updates arrive via the broker stream.
|
|
refreshOperatorInbox();
|
|
// NOTIF.bind() (the enable/mute/unmute toggle UI) moved to /settings.html
|
|
// with the S3TT1NGS page. The dashboard keeps NOTIF.show() for approval/
|
|
// question notifications — it reads the browser permission + localStorage
|
|
// mute flag the settings page sets, so no bind() is needed here.
|
|
Panel.bind();
|
|
|
|
// ─── live updates: dashboard event stream ──────────────────────────────
|
|
// The dashboard subscribes to /dashboard/stream for live mutation
|
|
// events so the SW4RM / Y3R C4LL / SYST3M panes update without an
|
|
// operator action triggering a refreshState.
|
|
//
|
|
// Bare `EventSource` (no terminal infrastructure needed — the
|
|
// dashboard doesn't render broker rows). Each event's `kind` is
|
|
// looked up against `MUTATION_HANDLERS`; unknown kinds (broker
|
|
// `sent` / `delivered`, anything new the backend adds) silently
|
|
// no-op. On (re)connect we kick a refreshState() to recover events
|
|
// lost during the disconnect window (same pattern as flow.js's
|
|
// onStreamOpen).
|
|
//
|
|
// Both /dashboard.html and /flow.html subscribe to `/api/dashboard/stream`
|
|
// and filter client-side — the dashboard ignores broker traffic
|
|
// and the inbox ignores mutation events.
|
|
const MUTATION_HANDLERS = {
|
|
approval_added: applyApprovalAdded,
|
|
approval_resolved: applyApprovalResolved,
|
|
question_added: applyQuestionAdded,
|
|
question_resolved: applyQuestionResolved,
|
|
transient_set: applyTransientSet,
|
|
transient_cleared: applyTransientCleared,
|
|
container_state_changed: applyContainerStateChanged,
|
|
container_removed: applyContainerRemoved,
|
|
// tombstones_changed / meta_inputs_changed / meta_update_running are
|
|
// handled on /core.html now (the SYST3M panels moved there).
|
|
// rebuild_queue_changed stays: it refreshes the SW4RM badges.
|
|
rebuild_queue_changed: applyRebuildQueueChanged,
|
|
schedules_changed: applySchedulesChanged,
|
|
reminders_changed: applyRemindersChanged,
|
|
capabilities_changed: applyCapabilitiesChanged,
|
|
tool_groups_changed: applyToolGroupsChanged,
|
|
};
|
|
(function bindDashboardStream() {
|
|
// Route through the SharedWorker so all open hyperhive tabs share
|
|
// one upstream SSE connection — see docs/web-ui.md (SSE multiplexing
|
|
// paragraph) for the design + Firefox throttling motivation.
|
|
// `openStream` returns an EventSource-shaped facade with a graceful
|
|
// direct-EventSource fallback when SharedWorker isn't supported.
|
|
const es = openStream('/api/dashboard/stream');
|
|
es.onmessage = (e) => {
|
|
let ev;
|
|
try { ev = JSON.parse(e.data); } catch { return; }
|
|
// Broker `sent` frames aren't mutation events, but the operator
|
|
// inbox cares about ones addressed to "operator".
|
|
if (ev.kind === 'sent' && ev.to === 'operator') {
|
|
operatorInboxAppendFromEvent(ev);
|
|
return;
|
|
}
|
|
const h = MUTATION_HANDLERS[ev.kind];
|
|
if (!h) return; // broker rows + future kinds — dashboard doesn't care
|
|
try { h(ev); }
|
|
catch (err) { console.error('dashboard SSE handler', ev.kind, err); }
|
|
};
|
|
es.onopen = () => {
|
|
// Re-sync to recover events that fired during the SSE disconnect
|
|
// window. Initial connect also fires onopen — the first
|
|
// refreshState() above and this one race, but refreshState is
|
|
// idempotent so the second call just overwrites with the
|
|
// freshest snapshot. Cheap on a quiescent server, fine to repeat.
|
|
refreshState();
|
|
};
|
|
es.onerror = () => {
|
|
// EventSource auto-reconnects; nothing to do beyond logging.
|
|
console.debug('dashboard SSE error, will retry');
|
|
};
|
|
})();
|
|
|
|
// ─── tab routing ───────────────────────────────────────────────────────
|
|
// Hash-based: `#swarm` / `#call` / `#system` / `#permissions` /
|
|
// `#schedules` activate the matching pane on the dashboard. Empty
|
|
// hash defaults to SW4RM. FL0W, S3TT1NGS, and ST4TS are NOT tabs —
|
|
// they're separate pages (`/flow.html`, `/settings.html`,
|
|
// `/stats.html`) reached from the H0M3 hub. Tab routing only
|
|
// applies when the tab DOM is present (e.g. not on the flow page
|
|
// itself, where these elements don't exist and the loop no-ops).
|
|
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
|
|
// tab/pane toggle + aria-selected + routing; `activateTab` runs the
|
|
// per-tab side-effects via the strip's onShow. Constructed below (after
|
|
// the lazy-load fns it calls are defined). The responsive overflow menu
|
|
// was dropped — if the strip ever runs out of room we move panes
|
|
// to their own pages rather than hide them in a dropdown.
|
|
function activateTab(target) {
|
|
// Track active tab on the body so renderSelectionBar can gate
|
|
// visibility (bar only belongs on SW4RM where agent cards live).
|
|
document.body.dataset.activeTab = target;
|
|
renderSelectionBar(Array.from(containersState.values()));
|
|
// Re-fetch on activation as a safety net: SSE covers live mutations,
|
|
// re-sync covers disconnect windows / approval-path inserts that
|
|
// don't yet emit. SCH3DUL3S also re-fetches reminders (same tab).
|
|
if (target === 'schedules') { refreshSchedules(); refreshReminders(); }
|
|
// Permissions tables: SSE covers worker-applied changes
|
|
// (capabilities_changed / tool_groups_changed); re-fetch on
|
|
// activation as a safety net for any gap between SSE events and
|
|
// the cold-load snapshot.
|
|
if (target === 'permissions') {
|
|
initPermissions();
|
|
fetchAndRenderCapabilities();
|
|
fetchAndRenderToolGroups();
|
|
}
|
|
if (target === 'call') { refreshOperatorInbox(); }
|
|
}
|
|
|
|
// ST4TS (hive-wide turn-stats rollup) moved to its own page,
|
|
// `/stats.html` — the render JS + the window selector live in
|
|
// stats.js now. The dashboard no longer fetches
|
|
// /api/stats-hive.
|
|
|
|
// Wire the shared tab strip now that activateTab + the lazy-load fns it
|
|
// calls are defined. The strip resolves the active tab from the hash
|
|
// (default SW4RM), toggles the active tab/pane + aria-selected, and
|
|
// fires activateTab for the per-tab side-effects on every change.
|
|
createTabStrip($('tabbar'), { defaultId: 'swarm', onShow: activateTab });
|
|
|
|
// Register the Y3R C4LL domain's count callback (call.js) — its live
|
|
// mutations (inbox stream append, mark-read) trigger a tab-count refresh
|
|
// through this instead of reaching back into the coordinator directly.
|
|
initCall({ onCountsChanged: refreshTabCounts, onContainersDirty: renderContainersFromState });
|
|
|
|
|
|
// Tab count pills — pure derived data from the existing state
|
|
// stores so SSE-driven updates flow through without extra plumbing.
|
|
// Set `hidden` when the count is zero so the pill doesn't draw
|
|
// attention to an empty room.
|
|
// The operator inbox (unread agent->operator messages) now lives in
|
|
// call.js — `refreshOperatorInbox`, `operatorInboxAppendFromEvent`, and
|
|
// `operatorInboxCount` are imported above. It calls back through the
|
|
// `onCountsChanged` callback registered via `initCall` at boot.
|
|
|
|
function setTabCount(tab, n) {
|
|
const el_ = $('tab-count-' + tab);
|
|
if (!el_) return;
|
|
el_.textContent = String(n);
|
|
el_.hidden = n <= 0;
|
|
}
|
|
/** Recompute every tab's count from the current state. Called on
|
|
* every renderXxx that's tab-relevant. */
|
|
function refreshTabCounts() {
|
|
// SW4RM — flag any container that's stale (needs_update). Empty
|
|
// when everyone's current. Container-row pulse signals state
|
|
// transitions; the pill catches "deploy-pending" specifically.
|
|
let swarm = 0;
|
|
for (const c of containersState.values()) {
|
|
if (c.needs_update) swarm++;
|
|
}
|
|
setTabCount('swarm', swarm);
|
|
// Y3R C4LL — pending approvals + operator-targeted questions +
|
|
// unread agent->operator messages.
|
|
const callCount =
|
|
activeApprovalCount() +
|
|
activeQuestionCount() +
|
|
operatorInboxCount();
|
|
setTabCount('call', callCount);
|
|
// Browser tab title prefix — lets the operator see the pending
|
|
// call count without switching to the window. Strips any existing
|
|
// `(N) ` prefix before re-applying so identity-title updates
|
|
// (which run once on state load, not every tick) compose cleanly.
|
|
const rawTitle = document.title.replace(/^\(\d+\) /, '');
|
|
document.title = callCount > 0 ? `(${callCount}) ${rawTitle}` : rawTitle;
|
|
// SCH3DUL3S — count of schedules with at least one still-active
|
|
// target (whole-schedule cancellation or all-targets-cancelled
|
|
// means "not waiting on the worker"; those don't pull attention).
|
|
setTabCount('schedules', activeScheduleCount());
|
|
}
|
|
// Poll the state stores on a 1s tick to keep the pill counts in
|
|
// sync. The state stores are mutated synchronously by every SSE
|
|
// event + refreshState call, so polling them is correct and cheap
|
|
// — no per-renderer hookup needed.
|
|
refreshTabCounts();
|
|
setInterval(refreshTabCounts, 1000);
|
|
|
|
// Flow-specific code (broker terminal init + the @-mention composer)
|
|
// lives in ./flow.js — loaded only by /flow.html.
|
|
})();
|