perf(dashboard): keyed question row cache — preserve textarea + checkbox state across re-renders
renderQuestions() was calling root.replaceChildren() on every
question_added / question_resolved SSE event, wiping all <li>
elements including any textarea the operator was typing into.
Add questionRowCache (Map<id, {el, fingerprint}>):
- questionRowFingerprint encodes the static fields that determine
the <li> DOM structure (asker, target, asked_at, deadline_at,
question text, options, multi)
- buildQuestionLi extracts the <li>-building code so it can be
called only when the fingerprint changes
- renderQuestions reuses cached <li> 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 <li> is reused — the textarea value, radio
selection, and checkbox state are all preserved.
The history <details> 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.
This commit is contained in:
parent
221de2204d
commit
b15d7a4b5d
1 changed files with 169 additions and 119 deletions
|
|
@ -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 <li> 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 <li>'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 <li>. 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 <details> 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 <details> is idempotent.
|
||||
// Snapshot open <details> state so SSE-triggered re-renders restore
|
||||
// any expanded sections. The keyed-cache approach reuses question
|
||||
// <li> 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 <li> 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 <details> 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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue