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.
|
// live by `question_added` / `question_resolved` dashboard events.
|
||||||
const QUESTION_HISTORY_LIMIT = 20;
|
const QUESTION_HISTORY_LIMIT = 20;
|
||||||
const questionsState = { pending: [], history: [] };
|
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) {
|
function syncQuestionsFromSnapshot(s) {
|
||||||
questionsState.pending = (s.questions || []).slice();
|
questionsState.pending = (s.questions || []).slice();
|
||||||
questionsState.history = (s.question_history || []).slice();
|
questionsState.history = (s.question_history || []).slice();
|
||||||
|
|
@ -1685,75 +1690,24 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
function renderQuestions() {
|
// Serialise the fields that determine a pending-question <li>'s DOM
|
||||||
const root = $('questions-section');
|
// structure. Used by questionRowCache to skip rebuilds when nothing
|
||||||
// #questions-section only lives on /index.html (Y3R C4LL tab);
|
// visible has changed. deadline_at controls whether the TTL chip node
|
||||||
// no-op when the section is missing. `question_added` /
|
// exists at all (its text is kept current by the global 1s ticker).
|
||||||
// `question_resolved` SSE events route through here.
|
function questionRowFingerprint(q) {
|
||||||
if (!root) return;
|
return JSON.stringify({
|
||||||
// Snapshot open <details> state before wiping the DOM so SSE-triggered
|
asker: q.asker, target: q.target, asked_at: q.asked_at,
|
||||||
// calls (applyQuestionAdded / applyQuestionResolved) preserve it just
|
deadline_at: q.deadline_at, question: q.question,
|
||||||
// as well as the full refreshState path. The double-restore that happens
|
question_refs: q.question_refs, options: q.options, multi: q.multi,
|
||||||
// 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.
|
|
||||||
const openDetails = snapshotOpenDetails();
|
// Build a single pending-question <li>. Extracted from renderQuestions so
|
||||||
root.replaceChildren();
|
// 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 fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
const allPending = questionsState.pending;
|
|
||||||
|
|
||||||
// Filter chips. Always include `all` / `operator` / `peer`; add
|
|
||||||
// per-agent chips for any agent that appears as asker or target
|
|
||||||
// in the pending list so the operator can isolate a single
|
|
||||||
// thread without typing.
|
|
||||||
const participants = new Set();
|
|
||||||
for (const q of allPending) {
|
|
||||||
participants.add(q.asker);
|
|
||||||
if (q.target) participants.add(q.target);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-reset a stale per-agent filter: if the operator had `agent:foo`
|
|
||||||
// selected and all of foo's questions resolved, foo's chip disappears
|
|
||||||
// from the row. Without a reset the section would show "no questions
|
|
||||||
// match this filter" with no active chip visible — confusing. Fall back
|
|
||||||
// to `all` whenever the stored value is no longer a valid chip value.
|
|
||||||
let activeFilter = getQuestionsFilter();
|
|
||||||
const validFilters = new Set(['all', 'operator', 'peer',
|
|
||||||
...Array.from(participants).map((n) => 'agent:' + n)]);
|
|
||||||
if (!validFilters.has(activeFilter)) {
|
|
||||||
activeFilter = 'all';
|
|
||||||
// Write directly to localStorage to avoid the re-render that
|
|
||||||
// setQuestionsFilter() triggers (we're already mid-render).
|
|
||||||
localStorage.setItem(QUESTIONS_FILTER_KEY, 'all');
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
|
|
||||||
|
|
||||||
const filterRow = el('div', { class: 'questions-filters' });
|
|
||||||
const mkChip = (value, label) => {
|
|
||||||
const b = el('button', {
|
|
||||||
type: 'button',
|
|
||||||
class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''),
|
|
||||||
}, label);
|
|
||||||
b.addEventListener('click', () => setQuestionsFilter(value));
|
|
||||||
return b;
|
|
||||||
};
|
|
||||||
filterRow.append(
|
|
||||||
mkChip('all', `all · ${allPending.length}`),
|
|
||||||
mkChip('operator', '@operator'),
|
|
||||||
mkChip('peer', '@peer'),
|
|
||||||
);
|
|
||||||
for (const name of Array.from(participants).sort()) {
|
|
||||||
filterRow.append(mkChip('agent:' + name, '@' + name));
|
|
||||||
}
|
|
||||||
root.append(filterRow);
|
|
||||||
|
|
||||||
if (!pending.length) {
|
|
||||||
root.append(el('p', { class: 'empty' },
|
|
||||||
activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter'));
|
|
||||||
}
|
|
||||||
const ul = el('ul', { class: 'questions' });
|
|
||||||
for (const q of pending) {
|
|
||||||
const targetLabel = q.target || 'operator';
|
const targetLabel = q.target || 'operator';
|
||||||
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
|
const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') });
|
||||||
const head = el('div', { class: 'q-head' },
|
const head = el('div', { class: 'q-head' },
|
||||||
|
|
@ -1765,8 +1719,7 @@ window.marked = marked;
|
||||||
);
|
);
|
||||||
if (q.deadline_at) {
|
if (q.deadline_at) {
|
||||||
// Tag the chip with its deadline so the global 1s ticker
|
// Tag the chip with its deadline so the global 1s ticker
|
||||||
// (set up just below this function) can refresh the text
|
// can refresh the text without re-rendering the questions section.
|
||||||
// without re-rendering the whole questions section.
|
|
||||||
const ttlEl = el('span', {
|
const ttlEl = el('span', {
|
||||||
class: 'q-ttl', 'data-deadline': String(q.deadline_at),
|
class: 'q-ttl', 'data-deadline': String(q.deadline_at),
|
||||||
});
|
});
|
||||||
|
|
@ -1857,11 +1810,101 @@ window.marked = marked;
|
||||||
el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'),
|
el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'),
|
||||||
);
|
);
|
||||||
li.append(cancelForm);
|
li.append(cancelForm);
|
||||||
ul.append(li);
|
return li;
|
||||||
}
|
}
|
||||||
if (pending.length) root.append(ul);
|
|
||||||
|
|
||||||
// Answered question history
|
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 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();
|
||||||
|
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
const allPending = questionsState.pending;
|
||||||
|
|
||||||
|
// Filter chips. Always include `all` / `operator` / `peer`; add
|
||||||
|
// per-agent chips for any agent that appears as asker or target
|
||||||
|
// in the pending list so the operator can isolate a single
|
||||||
|
// thread without typing.
|
||||||
|
const participants = new Set();
|
||||||
|
for (const q of allPending) {
|
||||||
|
participants.add(q.asker);
|
||||||
|
if (q.target) participants.add(q.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-reset a stale per-agent filter: if the operator had `agent:foo`
|
||||||
|
// selected and all of foo's questions resolved, foo's chip disappears
|
||||||
|
// from the row. Without a reset the section would show "no questions
|
||||||
|
// match this filter" with no active chip visible — confusing. Fall back
|
||||||
|
// to `all` whenever the stored value is no longer a valid chip value.
|
||||||
|
let activeFilter = getQuestionsFilter();
|
||||||
|
const validFilters = new Set(['all', 'operator', 'peer',
|
||||||
|
...Array.from(participants).map((n) => 'agent:' + n)]);
|
||||||
|
if (!validFilters.has(activeFilter)) {
|
||||||
|
activeFilter = 'all';
|
||||||
|
// Write directly to localStorage to avoid the re-render that
|
||||||
|
// setQuestionsFilter() triggers (we're already mid-render).
|
||||||
|
localStorage.setItem(QUESTIONS_FILTER_KEY, 'all');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
|
||||||
|
|
||||||
|
const filterRow = el('div', { class: 'questions-filters' });
|
||||||
|
const mkChip = (value, label) => {
|
||||||
|
const b = el('button', {
|
||||||
|
type: 'button',
|
||||||
|
class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''),
|
||||||
|
}, label);
|
||||||
|
b.addEventListener('click', () => setQuestionsFilter(value));
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
filterRow.append(
|
||||||
|
mkChip('all', `all · ${allPending.length}`),
|
||||||
|
mkChip('operator', '@operator'),
|
||||||
|
mkChip('peer', '@peer'),
|
||||||
|
);
|
||||||
|
for (const name of Array.from(participants).sort()) {
|
||||||
|
filterRow.append(mkChip('agent:' + name, '@' + name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answered question history (read-only, no inputs — built fresh each render).
|
||||||
const hist = questionsState.history;
|
const hist = questionsState.history;
|
||||||
if (hist.length) {
|
if (hist.length) {
|
||||||
const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
|
const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
|
||||||
|
|
@ -1889,7 +1932,14 @@ window.marked = marked;
|
||||||
hul.append(li);
|
hul.append(li);
|
||||||
}
|
}
|
||||||
details.append(hul);
|
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);
|
restoreOpenDetails(openDetails);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue