From b15d7a4b5d9ac4add1e25cf26db8bdc48a775d75 Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 5 Jun 2026 12:31:16 +0200 Subject: [PATCH] =?UTF-8?q?perf(dashboard):=20keyed=20question=20row=20cac?= =?UTF-8?q?he=20=E2=80=94=20preserve=20textarea=20+=20checkbox=20state=20a?= =?UTF-8?q?cross=20re-renders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderQuestions() was calling root.replaceChildren() on every question_added / question_resolved SSE event, wiping all
  • elements including any textarea the operator was typing into. Add questionRowCache (Map): - questionRowFingerprint encodes the static fields that determine the
  • DOM structure (asker, target, asked_at, deadline_at, question text, options, multi) - buildQuestionLi extracts the
  • -building code so it can be called only when the fingerprint changes - renderQuestions reuses cached
  • nodes for unchanged questions and evicts entries for resolved/cancelled questions Effect: when a new question arrives while the operator is typing a reply, the existing
  • is reused — the textarea value, radio selection, and checkbox state are all preserved. The history
    open state is also saved before and restored after the replaceChildren call, so the answered-history panel does not collapse when a live question event fires. --- frontend/packages/dashboard/src/tabs.js | 288 ++++++++++++++---------- 1 file changed, 169 insertions(+), 119 deletions(-) diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js index 5d6c8e01..a48f51b8 100644 --- a/frontend/packages/dashboard/src/tabs.js +++ b/frontend/packages/dashboard/src/tabs.js @@ -1613,6 +1613,11 @@ window.marked = marked; // live by `question_added` / `question_resolved` dashboard events. const QUESTION_HISTORY_LIMIT = 20; const questionsState = { pending: [], history: [] }; + // Keyed row cache: question id → {el, fingerprint}. Allows renderQuestions + // to reuse
  • elements whose state hasn't changed. The main benefit is + // preserving textarea draft text and radio/checkbox selections when an + // unrelated question arrives while the operator is composing a reply. + const questionRowCache = new Map(); function syncQuestionsFromSnapshot(s) { questionsState.pending = (s.questions || []).slice(); questionsState.history = (s.question_history || []).slice(); @@ -1685,20 +1690,140 @@ window.marked = marked; } return true; } + // Serialise the fields that determine a pending-question
  • 's DOM + // structure. Used by questionRowCache to skip rebuilds when nothing + // visible has changed. deadline_at controls whether the TTL chip node + // exists at all (its text is kept current by the global 1s ticker). + function questionRowFingerprint(q) { + return JSON.stringify({ + asker: q.asker, target: q.target, asked_at: q.asked_at, + deadline_at: q.deadline_at, question: q.question, + question_refs: q.question_refs, options: q.options, multi: q.multi, + }); + } + + // Build a single pending-question
  • . Extracted from renderQuestions so + // questionRowCache can reuse unchanged nodes without re-running this body. + // Event listeners attached here (keydown on textarea, submit on form) are + // preserved in the reused node — no re-attachment needed. + function buildQuestionLi(q) { + const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); + const targetLabel = q.target || 'operator'; + const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') }); + const head = el('div', { class: 'q-head' }, + el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', + el('span', { class: 'msg-from' }, q.asker), ' ', + el('span', { class: 'msg-sep' }, '→'), ' ', + el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', + el('span', { class: 'msg-sep' }, 'asks:'), + ); + if (q.deadline_at) { + // Tag the chip with its deadline so the global 1s ticker + // can refresh the text without re-rendering the questions section. + const ttlEl = el('span', { + class: 'q-ttl', 'data-deadline': String(q.deadline_at), + }); + ttlEl.textContent = formatTtl( + q.deadline_at - Math.floor(Date.now() / 1000), + ); + head.append(' ', ttlEl); + } + const qBody = el('div', { class: 'q-body' }); + appendLinkified(qBody, q.question, q.question_refs); + li.append(head, qBody); + const f = el('form', { + method: 'POST', action: '/answer-question/' + q.id, + class: 'qform', 'data-async': '', 'data-no-refresh': '', + }); + const hasOptions = q.options && q.options.length; + const isMulti = !!q.multi && hasOptions; + const freeText = el('textarea', { + name: 'answer-free', rows: '2', autocomplete: 'off', + placeholder: (hasOptions ? 'or type your own…' : 'your answer') + + ' (shift+enter for newline)', + }); + // Enter submits; shift+enter inserts a newline (textarea default). + freeText.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + f.requestSubmit(); + } + }); + const optionGroup = el('div', { class: 'q-options' }); + if (hasOptions) { + for (const opt of q.options) { + const inputType = isMulti ? 'checkbox' : 'radio'; + const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); + const input = el('input', { type: inputType, name: 'choice', value: opt, id }); + const label = el('label', { for: id }, ' ' + opt); + optionGroup.append(el('div', { class: 'q-option' }, input, label)); + } + } + // On submit, build the final `answer` field from selected + // options + free-text, joined by ', '. This lets the operator + // pick options AND add free text in the same form. + f.addEventListener('submit', (ev) => { + const parts = []; + for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { + parts.push(cb.value); + } + const ft = (freeText.value || '').trim(); + if (ft) parts.push(ft); + const merged = parts.join(', '); + // Replace the existing hidden `answer` (if any) with the merged value. + const existing = f.querySelector('input[name="answer"]'); + if (existing) existing.remove(); + f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); + if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } + }, true); + if (hasOptions) f.append(optionGroup); + const buttons = el('div', { class: 'q-buttons' }); + // On peer threads the operator's answer is an override — + // mark the button so it's clear what the click does (the + // backend permits it via OperatorQuestions::answer's + // answerer-auth rule). + const answerLabel = q.target + ? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3') + : (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R'); + buttons.append( + el('button', { + type: 'submit', + class: 'btn btn-approve' + (q.target ? ' btn-override' : ''), + title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '', + }, answerLabel), + ); + f.append( + el('div', { class: 'q-free' }, freeText), + buttons, + ); + li.append(f); + // Separate form so the cancel button doesn't get the answer + // merge-on-submit handler attached to the main form. + const cancelTargetLabel = q.target ? q.target : 'asker'; + const cancelForm = el('form', { + method: 'POST', action: '/cancel-question/' + q.id, + class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '', + 'data-confirm': `cancel this question? ${cancelTargetLabel} will see ` + + '"[cancelled]" as the answer.', + }); + cancelForm.append( + el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'), + ); + li.append(cancelForm); + return li; + } + function renderQuestions() { const root = $('questions-section'); // #questions-section only lives on /index.html (Y3R C4LL tab); // no-op when the section is missing. `question_added` / // `question_resolved` SSE events route through here. if (!root) return; - // Snapshot open
    state before wiping the DOM so SSE-triggered - // calls (applyQuestionAdded / applyQuestionResolved) preserve it just - // as well as the full refreshState path. The double-restore that happens - // when renderQuestions is called from inside refreshState is harmless — - // restoreOpenDetails is a no-op when the set is empty, and re-opening an - // already-open
    is idempotent. + // Snapshot open
    state so SSE-triggered re-renders restore + // any expanded sections. The keyed-cache approach reuses question + //
  • nodes (preserving textarea/checkbox state) and only rebuilds + // cache-miss rows, so we no longer wipe the DOM at the start. const openDetails = snapshotOpenDetails(); - root.replaceChildren(); const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19); const allPending = questionsState.pending; @@ -1746,122 +1871,40 @@ window.marked = marked; for (const name of Array.from(participants).sort()) { filterRow.append(mkChip('agent:' + name, '@' + name)); } - root.append(filterRow); + // Evict resolved/cancelled questions from the row cache. + const allPendingIds = new Set(allPending.map((q) => q.id)); + for (const id of questionRowCache.keys()) { + if (!allPendingIds.has(id)) questionRowCache.delete(id); + } + + // Build the ordered list of
  • elements, reusing cached nodes whose + // serialised state hasn't changed. This is what preserves textarea + // draft text and radio/checkbox selections across re-renders. + const orderedLis = pending.map((q) => { + const fp = questionRowFingerprint(q); + const cached = questionRowCache.get(q.id); + if (cached && cached.fingerprint === fp) return cached.el; + const li = buildQuestionLi(q); + questionRowCache.set(q.id, { el: li, fingerprint: fp }); + return li; + }); + + // Save the history
    open state before the DOM swap so it + // isn't collapsed every time a question arrives while it's open. + const historyWasOpen = root.querySelector('.q-history')?.open ?? false; + + const children = [filterRow]; if (!pending.length) { - root.append(el('p', { class: 'empty' }, + children.push(el('p', { class: 'empty' }, activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter')); + } else { + const ul = el('ul', { class: 'questions' }); + for (const li of orderedLis) ul.append(li); + children.push(ul); } - const ul = el('ul', { class: 'questions' }); - for (const q of pending) { - const targetLabel = q.target || 'operator'; - const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') }); - const head = el('div', { class: 'q-head' }, - el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', - el('span', { class: 'msg-from' }, q.asker), ' ', - el('span', { class: 'msg-sep' }, '→'), ' ', - el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', - el('span', { class: 'msg-sep' }, 'asks:'), - ); - if (q.deadline_at) { - // Tag the chip with its deadline so the global 1s ticker - // (set up just below this function) can refresh the text - // without re-rendering the whole questions section. - const ttlEl = el('span', { - class: 'q-ttl', 'data-deadline': String(q.deadline_at), - }); - ttlEl.textContent = formatTtl( - q.deadline_at - Math.floor(Date.now() / 1000), - ); - head.append(' ', ttlEl); - } - const qBody = el('div', { class: 'q-body' }); - appendLinkified(qBody, q.question, q.question_refs); - li.append(head, qBody); - const f = el('form', { - method: 'POST', action: '/answer-question/' + q.id, - class: 'qform', 'data-async': '', 'data-no-refresh': '', - }); - const hasOptions = q.options && q.options.length; - const isMulti = !!q.multi && hasOptions; - const freeText = el('textarea', { - name: 'answer-free', rows: '2', autocomplete: 'off', - placeholder: (hasOptions ? 'or type your own…' : 'your answer') - + ' (shift+enter for newline)', - }); - // Enter submits; shift+enter inserts a newline (textarea default). - freeText.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - f.requestSubmit(); - } - }); - const optionGroup = el('div', { class: 'q-options' }); - if (hasOptions) { - for (const opt of q.options) { - const inputType = isMulti ? 'checkbox' : 'radio'; - const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); - const input = el('input', { type: inputType, name: 'choice', value: opt, id }); - const label = el('label', { for: id }, ' ' + opt); - optionGroup.append(el('div', { class: 'q-option' }, input, label)); - } - } - // On submit, build the final `answer` field from selected - // options + free-text, joined by ', '. This lets the operator - // pick options AND add free text in the same form. - f.addEventListener('submit', (ev) => { - const parts = []; - for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { - parts.push(cb.value); - } - const ft = (freeText.value || '').trim(); - if (ft) parts.push(ft); - const merged = parts.join(', '); - // Replace the existing hidden `answer` (if any) with the merged value. - const existing = f.querySelector('input[name="answer"]'); - if (existing) existing.remove(); - f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); - if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } - }, true); - if (hasOptions) f.append(optionGroup); - const buttons = el('div', { class: 'q-buttons' }); - // On peer threads the operator's answer is an override — - // mark the button so it's clear what the click does (the - // backend permits it via OperatorQuestions::answer's - // answerer-auth rule). - const answerLabel = q.target - ? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3') - : (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R'); - buttons.append( - el('button', { - type: 'submit', - class: 'btn btn-approve' + (q.target ? ' btn-override' : ''), - title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '', - }, answerLabel), - ); - f.append( - el('div', { class: 'q-free' }, freeText), - buttons, - ); - li.append(f); - // Separate form so the cancel button doesn't get the answer - // merge-on-submit handler attached to the main form. - const cancelTargetLabel = q.target ? q.target : 'asker'; - const cancelForm = el('form', { - method: 'POST', action: '/cancel-question/' + q.id, - class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '', - 'data-confirm': `cancel this question? ${cancelTargetLabel} will see ` - + '"[cancelled]" as the answer.', - }); - cancelForm.append( - el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'), - ); - li.append(cancelForm); - ul.append(li); - } - if (pending.length) root.append(ul); - // Answered question history + // Answered question history (read-only, no inputs — built fresh each render). const hist = questionsState.history; if (hist.length) { const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' }); @@ -1889,7 +1932,14 @@ window.marked = marked; hul.append(li); } details.append(hul); - root.append(details); + children.push(details); + } + + root.replaceChildren(...children); + // Restore history open state after the DOM swap. + if (historyWasOpen) { + const histEl = root.querySelector('.q-history'); + if (histEl) histEl.open = true; } restoreOpenDetails(openDetails); }