diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js
index a48f51b8..54069c86 100644
--- a/frontend/packages/dashboard/src/tabs.js
+++ b/frontend/packages/dashboard/src/tabs.js
@@ -2442,6 +2442,15 @@ window.marked = marked;
}
// ─── 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
+ // changed rather than replacing the entire list on every snapshot event.
+ // The elapsed-time ticker (data-rqe-elapsed + 1s setInterval below) already
+ // updates running-entry timestamps in-place, so started_at doesn't need to
+ // invalidate — including it in the fingerprint only matters for the initial
+ // render of a newly-running entry.
+ const rebuildQueueRowCache = new Map();
+
// Glyph + verb per QueueKind. Mirrors the labels used in
// hive-c0re::rebuild_queue::QueueKind::as_str.
const QUEUE_KIND_GLYPH = {
@@ -2461,15 +2470,38 @@ window.marked = marked;
cancelled: '⊘',
};
+ // Fingerprint for a single rebuild-queue row. Everything visible in the
+ // row except the ticking elapsed seconds (handled by data-rqe-elapsed
+ // ticker, not by re-rendering).
+ function rebuildQueueEntryFingerprint(entry, isChild) {
+ return JSON.stringify({
+ state: entry.state,
+ step: entry.step,
+ kind: entry.kind,
+ agent: entry.agent,
+ source: entry.source,
+ started_at: entry.started_at,
+ enqueued_at: entry.enqueued_at,
+ finished_at: entry.finished_at,
+ reason: entry.reason,
+ error: entry.error,
+ build_log_id: entry.build_log_id,
+ isChild,
+ });
+ }
+
function renderRebuildQueue(s) {
const root = $('rebuild-queue-section');
if (!root) return;
- root.replaceChildren();
const queue = s.rebuild_queue || [];
+
if (!queue.length) {
- root.append(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
+ // Queue drained — show placeholder and purge cache.
+ rebuildQueueRowCache.clear();
+ root.replaceChildren(el('p', { class: 'empty' }, 'queue is empty — nothing pending or in flight.'));
return;
}
+
// Index by id for parent lookup.
const byId = new Map(queue.map((e) => [e.id, e]));
// Top-level entries first; children render nested under their parent.
@@ -2481,21 +2513,58 @@ window.marked = marked;
childrenOf.get(e.parent_id).push(e);
}
}
- const ul = el('ul', { class: 'rebuild-queue' });
- for (const top of tops) {
- ul.append(renderQueueEntry(top, byId));
- for (const child of childrenOf.get(top.id) || []) {
- ul.append(renderQueueEntry(child, byId, true));
- }
- }
// Children whose parent isn't in the snapshot (history-evicted) still render flat.
const orphans = queue.filter(
(e) => e.parent_id != null && !byId.has(e.parent_id),
);
- for (const o of orphans) {
- ul.append(renderQueueEntry(o, byId, true));
+
+ // Build ordered list of , reusing cached nodes for unchanged entries.
+ const orderedLis = [];
+ function addEntry(entry, isChild) {
+ const fp = rebuildQueueEntryFingerprint(entry, isChild);
+ const cached = rebuildQueueRowCache.get(entry.id);
+ let li;
+ if (cached && cached.fingerprint === fp) {
+ li = cached.el;
+ } else {
+ li = renderQueueEntry(entry, byId, isChild);
+ rebuildQueueRowCache.set(entry.id, { el: li, fingerprint: fp });
+ }
+ orderedLis.push(li);
}
- root.append(ul);
+ for (const top of tops) {
+ addEntry(top, false);
+ for (const child of childrenOf.get(top.id) || []) {
+ addEntry(child, true);
+ }
+ }
+ for (const o of orphans) {
+ addEntry(o, true);
+ }
+
+ // Drop cache entries for IDs no longer in the snapshot.
+ const liveIds = new Set(queue.map((e) => e.id));
+ for (const [id, entry] of rebuildQueueRowCache) {
+ if (!liveIds.has(id)) {
+ entry.el.remove();
+ rebuildQueueRowCache.delete(id);
+ }
+ }
+
+ // Get or create the ; remove any "empty" placeholder if present.
+ let ul = root.querySelector('ul.rebuild-queue');
+ if (!ul) {
+ ul = el('ul', { class: 'rebuild-queue' });
+ root.replaceChildren(ul);
+ }
+
+ // Reconcile DOM order without a wipe.
+ for (let i = 0; i < orderedLis.length; i++) {
+ if (ul.children[i] !== orderedLis[i]) {
+ ul.insertBefore(orderedLis[i], ul.children[i] ?? null);
+ }
+ }
+ while (ul.children.length > orderedLis.length) ul.lastChild.remove();
}
function renderQueueEntry(entry, _byId, isChild) {