diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js
index cd60f399..8a05f6ee 100644
--- a/frontend/packages/dashboard/src/common.js
+++ b/frontend/packages/dashboard/src/common.js
@@ -46,10 +46,11 @@ export const form = (action, btnClass, btnLabel, confirmMsg, extra = {}, opts =
return f;
};
-// `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.
+// `truncate`, `fmtAgo`, `fmtElapsed`, `fmtDuration` + the `paintAtomic`
+// render helper live in the dashboard-internal `./util.js`, not here —
+// their phrasing ("X running", "X ago") is dashboard-specific, so they
+// stay out of the cross-page `common.js` but are shared across the
+// dashboard's own tab modules.
// ─── shared-worker SSE pipe ─────────────────────────────────────────────
// Returns an EventSource-shaped facade backed by a SharedWorker that
diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js
index e896184e..7345755b 100644
--- a/frontend/packages/dashboard/src/tabs.js
+++ b/frontend/packages/dashboard/src/tabs.js
@@ -20,6 +20,7 @@ import {
} from './common.js';
import { createTabStrip } from '@hive/shared/tabs.js';
import { containersState, syncContainersFromSnapshot } from './state.js';
+import { paintAtomic, fmtAgo, truncate, fmtElapsed, fmtDuration } from './util.js';
import {
applyCapabilitiesChanged, applyToolGroupsChanged,
fetchAndRenderCapabilities, fetchAndRenderToolGroups,
@@ -43,16 +44,6 @@ window.marked = marked;
const CTX_WARN_TOKENS = 150_000; // fallback red threshold (≈ 75% of 200k)
const CTX_CAUTION_TOKENS = 100_000; // fallback yellow threshold (≈ 50% of 200k)
- // Atomic-swap render helper: build into a DocumentFragment off-DOM,
- // commit with one `replaceChildren`. See docs/web-ui.md::Atomic
- // section repaint for why (no intermediate empty-state flash on
- // poll cycles even when the builder allocates a lot of `el()`).
- function paintAtomic(liveRoot, build) {
- const buf = document.createDocumentFragment();
- build(buf);
- liveRoot.replaceChildren(buf);
- }
-
// 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.
@@ -2110,17 +2101,6 @@ window.marked = marked;
root.append(ul);
}
- // Relative time, anchored to now. resolved_at is unix seconds (server-
- // authored), so we don't have to worry about client/server clock skew
- // for sub-minute precision.
- function fmtAgo(unixSecs) {
- const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
- if (ageSec < 60) return ageSec + 's ago';
- if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
- if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
- return Math.floor(ageSec / 86400) + 'd ago';
- }
-
function renderMetaInputs(s) {
const root = $('meta-inputs-section');
if (!root) return;
@@ -2230,10 +2210,6 @@ window.marked = marked;
root.append(form);
}
- function truncate(s, n) {
- return s.length <= n ? s : s.slice(0, n - 1) + '…';
- }
-
// ─── rebuild queue ──────────────────────────────────────────────────────
// Keyed row cache for the rebuild-queue list. Maps entry.id → { el, fingerprint }.
// Same pattern as containerRowCache: reuse
nodes whose state hasn't
@@ -2461,12 +2437,6 @@ window.marked = marked;
return li;
}
- function fmtElapsed(secs) {
- if (secs < 60) return secs + 's running';
- if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running';
- return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running';
- }
-
// Tick once per second to refresh "running Xs" badges in place
// (mirrors the question-TTL ticker pattern above).
// Tick rebuild-queue elapsed-time badges once per second.
@@ -2605,13 +2575,6 @@ window.marked = marked;
root.append(ul);
});
}
- function fmtDuration(secs) {
- if (secs < 60) return secs + 's';
- if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
- if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
- return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
- }
-
// ─── scheduled prompts ─────────────────────────────────────────────────
// Backend exposes `/api/schedules` (snapshot), `/api/schedules`
// (POST, operator-direct submit), `/api/schedules/{id}/cancel`
diff --git a/frontend/packages/dashboard/src/util.js b/frontend/packages/dashboard/src/util.js
new file mode 100644
index 00000000..5ebb1e57
--- /dev/null
+++ b/frontend/packages/dashboard/src/util.js
@@ -0,0 +1,49 @@
+// Shared dashboard render + format helpers.
+//
+// Small pure utilities used across most dashboard tabs (the container
+// tree, the rebuild queue, questions / approvals, schedules + reminders):
+// an atomic-swap render helper and a handful of relative-time / duration
+// formatters. They live here — a dashboard-internal module — rather than
+// in the cross-page `common.js`, because they're specific to the
+// dashboard's render style and not needed by the stand-alone pages.
+//
+// All pure: no DOM/module state captured, so any tab module can import
+// them freely without ordering concerns.
+
+// Atomic-swap render: build into an off-DOM DocumentFragment, then commit
+// with a single `replaceChildren`. Avoids an intermediate empty-state
+// flash on poll cycles even when the builder allocates a lot of nodes.
+export function paintAtomic(liveRoot, build) {
+ const buf = document.createDocumentFragment();
+ build(buf);
+ liveRoot.replaceChildren(buf);
+}
+
+// Relative age of a unix timestamp, coarsened to one unit ("5m ago").
+export function fmtAgo(unixSecs) {
+ const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSecs));
+ if (ageSec < 60) return ageSec + 's ago';
+ if (ageSec < 3600) return Math.floor(ageSec / 60) + 'm ago';
+ if (ageSec < 86400) return Math.floor(ageSec / 3600) + 'h ago';
+ return Math.floor(ageSec / 86400) + 'd ago';
+}
+
+// Truncate a string to `n` chars, appending an ellipsis when clipped.
+export function truncate(s, n) {
+ return s.length <= n ? s : s.slice(0, n - 1) + '…';
+}
+
+// Running-duration label for in-flight items ("3m 12s running").
+export function fmtElapsed(secs) {
+ if (secs < 60) return secs + 's running';
+ if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's running';
+ return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm running';
+}
+
+// Compact duration label, two units deep ("1h 5m", "2d 3h").
+export function fmtDuration(secs) {
+ if (secs < 60) return secs + 's';
+ if (secs < 3600) return Math.floor(secs / 60) + 'm ' + (secs % 60) + 's';
+ if (secs < 86400) return Math.floor(secs / 3600) + 'h ' + Math.floor((secs % 3600) / 60) + 'm';
+ return Math.floor(secs / 86400) + 'd ' + Math.floor((secs % 86400) / 3600) + 'h';
+}