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);
}