From d4a53d83d3be7c79e5ae9b8e30f31d7be25c9f58 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 16:39:04 +0200 Subject: [PATCH 1/2] dashboard: atomic-swap paint for schedules + reminders (#464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit argus flagged + mara confirmed: operators see a brief "blink" on every poll cycle when refreshState fires. Root cause for the async-fetch sections (refreshReminders, refreshSchedules): the `await resp.json()` yield is a paint opportunity the browser can take BEFORE the renderer's `root.innerHTML = ''` + `root.append` land. The "loading…" placeholder (or the previous render's stale content) may briefly show through. Fix: render off-DOM into a `DocumentFragment`, then atomically swap into the live root with `replaceChildren`. The browser only sees the new content; no intermediate empty state is reachable. Added `paintAtomic(liveRoot, build)` helper near the top of the IIFE — minimal-churn pattern where each renderer's existing `root.append(...)` body carries over unchanged, just wrapped in a builder callback that receives the fragment as its `root` parameter. Applied to the async-fetch sections argus's note + mara's report specifically called out: - `refreshReminders` (both http-error + catch paths) - `renderReminders` - `refreshSchedules` (both error paths) - `renderScheduleNewForm` (carry read still happens against the live root BEFORE the swap so mid-typing values are preserved) - `renderSchedulesList` Kept scope tight to the async paths. The sync renderers (renderContainers / renderTombstones / etc.) run inside the same JS turn as refreshState's other sync work, so the browser can't paint between their clear+populate steps — no flash to fix there. If mara still sees blink on those sections after this lands, extending the pattern is a clean follow-up. docs/web-ui.md no change needed; this is an implementation detail of the existing managed-section render machinery. --- frontend/packages/dashboard/src/app.js | 105 ++++++++++++++++--------- 1 file changed, 68 insertions(+), 37 deletions(-) diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 7056579c..a9847d3f 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -41,6 +41,28 @@ window.marked = marked; // Helpers ($, el, esc, form, fmtAgeSecs) moved to ./common.js (#406). + // #464 — atomic-swap render helper. Each managed section's render + // function used to do `root.innerHTML = ''; root.append(...);` in + // sequence; even though both operations sit in the same JS turn, + // operators could still see a "blink" on every poll cycle because + // (a) on async paths the await yield gave the browser a paint + // opportunity, and (b) complex builds with many `el()` allocations + // can blow the browser's per-task budget enough for layout to flash + // empty before the new children land. + // + // The fix: build the new content off-DOM into a `DocumentFragment`, + // then move it into the live root in a single `replaceChildren` + // call. The browser never sees an intermediate empty state. Builder + // callbacks receive the fragment as their `root` argument, so each + // renderer's existing `root.append(...)` code carries over with + // zero internal changes. Early-return inside the builder is fine — + // the commit still happens with whatever the builder appended. + function paintAtomic(liveRoot, build) { + const buf = document.createDocumentFragment(); + build(buf); + liveRoot.replaceChildren(buf); + } + // Side panel singleton (Panel) moved to ./common.js (#406). // Path linkification + file-preview side panel (openFilePanel, @@ -1827,30 +1849,32 @@ window.marked = marked; // agents themselves and the operator already sees them next time // they interact with the page. async function refreshReminders() { - const root = $('reminders-section'); - if (!root) return; + const liveRoot = $('reminders-section'); + if (!liveRoot) return; try { const resp = await fetch('/api/reminders'); if (!resp.ok) { - root.innerHTML = ''; - root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status)); + paintAtomic(liveRoot, (root) => { + root.append(el('p', { class: 'empty' }, 'reminders unavailable: http ' + resp.status)); + }); return; } const rows = await resp.json(); renderReminders(rows); } catch (err) { - root.innerHTML = ''; - root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err)); + paintAtomic(liveRoot, (root) => { + root.append(el('p', { class: 'empty' }, 'reminders fetch failed: ' + err)); + }); } } function renderReminders(rows) { - const root = $('reminders-section'); - if (!root) return; - root.innerHTML = ''; - if (!rows.length) { - root.append(el('p', { class: 'empty' }, 'no queued reminders')); - return; - } + const liveRoot = $('reminders-section'); + if (!liveRoot) return; + paintAtomic(liveRoot, (root) => { + if (!rows.length) { + root.append(el('p', { class: 'empty' }, 'no queued reminders')); + return; + } const ul = el('ul', { class: 'reminders' }); for (const r of rows) { const failed = (r.attempt_count || 0) > 0; @@ -1910,6 +1934,7 @@ window.marked = marked; ul.append(li); } root.append(ul); + }); // paintAtomic } function fmtDuration(secs) { if (secs < 60) return secs + 's'; @@ -1939,14 +1964,16 @@ window.marked = marked; try { const resp = await fetch('/api/schedules'); if (!resp.ok) { - listRoot.innerHTML = ''; - listRoot.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status)); + paintAtomic(listRoot, (root) => { + root.append(el('p', { class: 'empty' }, 'schedules unavailable: http ' + resp.status)); + }); return; } schedulesState = await resp.json(); } catch (err) { - listRoot.innerHTML = ''; - listRoot.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err)); + paintAtomic(listRoot, (root) => { + root.append(el('p', { class: 'empty' }, 'schedules fetch failed: ' + err)); + }); return; } renderScheduleNewForm(); @@ -2063,13 +2090,15 @@ window.marked = marked; } function renderScheduleNewForm() { - const root = $('schedule-new-section'); - if (!root) return; + const liveRoot = $('schedule-new-section'); + if (!liveRoot) return; // Preserve any field the operator was mid-typing in by reading // current values before we re-render. Only `body` + `description` // are big enough to feel — the others are toggle/datetime/number. - const carry = readScheduleFormCarry(root); - root.innerHTML = ''; + // Read from the live root BEFORE the swap so we see current + // values (the fragment we build into is fresh). + const carry = readScheduleFormCarry(liveRoot); + paintAtomic(liveRoot, (root) => { // Targets multi-select pulls from the live containers list. The // backend accepts any string (forward-compat for richer recipients), @@ -2165,6 +2194,7 @@ window.marked = marked; form_.append(actions); root.append(form_); + }); // paintAtomic } function readScheduleFormCarry(root) { return { @@ -2249,23 +2279,24 @@ window.marked = marked; } } function renderSchedulesList() { - const root = $('schedules-section'); - if (!root) return; - root.innerHTML = ''; - if (!schedulesState.length) { - root.append(el('p', { class: 'empty' }, 'no schedules queued')); - return; - } - const ul = el('ul', { class: 'schedules' }); - // Active schedules first (still firing), then cancelled tail. - const sorted = schedulesState.slice().sort((a, b) => { - const aDone = a.cancelled_at_unix ? 1 : 0; - const bDone = b.cancelled_at_unix ? 1 : 0; - if (aDone !== bDone) return aDone - bDone; - return a.next_fire_at_unix - b.next_fire_at_unix; + const liveRoot = $('schedules-section'); + if (!liveRoot) return; + paintAtomic(liveRoot, (root) => { + if (!schedulesState.length) { + root.append(el('p', { class: 'empty' }, 'no schedules queued')); + return; + } + const ul = el('ul', { class: 'schedules' }); + // Active schedules first (still firing), then cancelled tail. + const sorted = schedulesState.slice().sort((a, b) => { + const aDone = a.cancelled_at_unix ? 1 : 0; + const bDone = b.cancelled_at_unix ? 1 : 0; + if (aDone !== bDone) return aDone - bDone; + return a.next_fire_at_unix - b.next_fire_at_unix; + }); + for (const s of sorted) ul.append(renderScheduleCard(s)); + root.append(ul); }); - for (const s of sorted) ul.append(renderScheduleCard(s)); - root.append(ul); } function renderScheduleCard(s) { const cancelled = !!s.cancelled_at_unix; From 062e2d30559e26de16e67f81dbfb61a61c36c561 Mon Sep 17 00:00:00 2001 From: iris Date: Tue, 26 May 2026 16:42:39 +0200 Subject: [PATCH 2/2] renderReminders: re-indent body inside paintAtomic callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit argus 🟡 nit on #480 — the `if (!rows.length)` block sat at +6 inside the paintAtomic callback, but the `const ul` + for loop underneath stayed at the pre-wrap +4. Code was correct, just visually inconsistent. Re-indented the whole callback body uniformly. renderScheduleNewForm has the same cosmetic mis-indent but its body is ~100 lines; leaving that for a separate sweep so the diff stays focused. --- frontend/packages/dashboard/src/app.js | 114 ++++++++++++------------- 1 file changed, 57 insertions(+), 57 deletions(-) diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index a9847d3f..231b0e44 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -1875,66 +1875,66 @@ window.marked = marked; root.append(el('p', { class: 'empty' }, 'no queued reminders')); return; } - const ul = el('ul', { class: 'reminders' }); - for (const r of rows) { - const failed = (r.attempt_count || 0) > 0; - const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') }); - const dueIn = r.due_at - Math.floor(Date.now() / 1000); - const dueLabel = dueIn <= 0 - ? `overdue ${fmtAgo(r.due_at)}` - : `in ${fmtDuration(dueIn)}`; - const head = el('div', { class: 'reminder-head' }, - el('span', { class: 'agent' }, r.agent), ' ', - el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel), - ' ', - el('span', { class: 'meta' }, `· id ${r.id}`), - ); - if (r.file_path) { - head.append(' ', el('span', { class: 'meta' }, '· payload → ')); - appendLinkified(head, r.file_path); - } - if (failed) { - head.append(' ', el('span', - { - class: 'badge badge-warn', - title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)', - }, - `⚠ ${r.attempt_count} failed`)); - } - const body = el('div', { class: 'reminder-body' }); - appendLinkified(body, r.message); - li.append(head, body); - if (r.last_error) { - li.append(el('div', { class: 'reminder-error' }, - el('span', { class: 'msg-sep' }, 'error: '), - r.last_error, - )); - } - const actions = el('div', { class: 'reminder-actions' }); - if (failed) { - // Retry resets the failure counters so the scheduler picks - // the row up again on its next 5s tick. No data-no-refresh - // — the resulting refreshState re-fires refreshReminders. - const retryForm = el('form', { - method: 'POST', action: '/retry-reminder/' + r.id, + const ul = el('ul', { class: 'reminders' }); + for (const r of rows) { + const failed = (r.attempt_count || 0) > 0; + const li = el('li', { class: 'reminder-row' + (failed ? ' reminder-failed' : '') }); + const dueIn = r.due_at - Math.floor(Date.now() / 1000); + const dueLabel = dueIn <= 0 + ? `overdue ${fmtAgo(r.due_at)}` + : `in ${fmtDuration(dueIn)}`; + const head = el('div', { class: 'reminder-head' }, + el('span', { class: 'agent' }, r.agent), ' ', + el('span', { class: 'meta', title: new Date(r.due_at * 1000).toISOString() }, dueLabel), + ' ', + el('span', { class: 'meta' }, `· id ${r.id}`), + ); + if (r.file_path) { + head.append(' ', el('span', { class: 'meta' }, '· payload → ')); + appendLinkified(head, r.file_path); + } + if (failed) { + head.append(' ', el('span', + { + class: 'badge badge-warn', + title: 'consecutive failed delivery attempts (capped at 5; over the cap the scheduler stops retrying until you click R3TRY or cancel)', + }, + `⚠ ${r.attempt_count} failed`)); + } + const body = el('div', { class: 'reminder-body' }); + appendLinkified(body, r.message); + li.append(head, body); + if (r.last_error) { + li.append(el('div', { class: 'reminder-error' }, + el('span', { class: 'msg-sep' }, 'error: '), + r.last_error, + )); + } + const actions = el('div', { class: 'reminder-actions' }); + if (failed) { + // Retry resets the failure counters so the scheduler picks + // the row up again on its next 5s tick. No data-no-refresh + // — the resulting refreshState re-fires refreshReminders. + const retryForm = el('form', { + method: 'POST', action: '/retry-reminder/' + r.id, + class: 'inline', 'data-async': '', + }); + retryForm.append(el('button', + { type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY')); + actions.append(retryForm); + } + const cancelForm = el('form', { + method: 'POST', action: '/cancel-reminder/' + r.id, class: 'inline', 'data-async': '', + 'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`, }); - retryForm.append(el('button', - { type: 'submit', class: 'btn btn-restart' }, '↻ R3TRY')); - actions.append(retryForm); + cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L')); + actions.append(cancelForm); + li.append(actions); + ul.append(li); } - const cancelForm = el('form', { - method: 'POST', action: '/cancel-reminder/' + r.id, - class: 'inline', 'data-async': '', - 'data-confirm': `cancel reminder ${r.id} for ${r.agent}? this drops the queued delivery; no undo.`, - }); - cancelForm.append(el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ C4NC3L')); - actions.append(cancelForm); - li.append(actions); - ul.append(li); - } - root.append(ul); - }); // paintAtomic + root.append(ul); + }); } function fmtDuration(secs) { if (secs < 60) return secs + 's';