dashboard: extract questions domain into call.js — Y3R C4LL split complete
Final domain: move the operator question queue (questionRowCache, the sync/apply/filter/fingerprint/buildQuestionLi/renderQuestions fns, formatTtl, and the .q-ttl countdown ticker) from tabs.js into call.js. tabs.js imports syncQuestionsFromSnapshot, applyQuestionAdded, applyQuestionResolved, renderQuestions, and the activeQuestionCount getter; refreshTabCounts now sums activeApprovalCount()+activeQuestionCount()+operatorInboxCount(). The .status-age (SW4RM) and .approval-ts/.reminder-due/.sched-due (approvals+schedules) tickers stay in tabs.js — only the questions-specific .q-ttl ticker moved. questionsState stays in state.js (shared with the SW4RM badges). Behaviour-preserving; no visual change. Net: tabs.js 2323 to 1603 lines; the Y3R C4LL pane now lives in call.js, alongside permissions.js / schedules.js as a per-domain module.
This commit is contained in:
parent
d0ec0d2896
commit
a0fc7e1a9f
2 changed files with 381 additions and 371 deletions
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
import { $, el, form, Panel, appendLinkified } from './common.js';
|
import { $, el, form, Panel, appendLinkified } from './common.js';
|
||||||
import { fmtAgo } from './util.js';
|
import { fmtAgo } from './util.js';
|
||||||
|
import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js';
|
||||||
|
|
||||||
// Registered by the dashboard entry at boot; defaults to a no-op so the
|
// Registered by the dashboard entry at boot; defaults to a no-op so the
|
||||||
// module is safe to call before wiring.
|
// module is safe to call before wiring.
|
||||||
|
|
@ -408,3 +409,374 @@ export function operatorInboxAppendFromEvent(ev) {
|
||||||
}
|
}
|
||||||
root.append(ul);
|
root.append(ul);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── questions — the operator question queue (Y3R C4LL) ───────────────────
|
||||||
|
// questionsState + QUESTION_HISTORY_LIMIT are imported from state.js (shared
|
||||||
|
// with the SW4RM per-agent question-count badges).
|
||||||
|
// 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();
|
||||||
|
export function activeQuestionCount() { return questionsState.pending.length; }
|
||||||
|
export function syncQuestionsFromSnapshot(s) {
|
||||||
|
questionsState.pending = (s.questions || []).slice();
|
||||||
|
questionsState.history = (s.question_history || []).slice();
|
||||||
|
}
|
||||||
|
export function applyQuestionAdded(ev) {
|
||||||
|
if (questionsState.pending.some((q) => q.id === ev.id)) return;
|
||||||
|
questionsState.pending.push({
|
||||||
|
id: ev.id,
|
||||||
|
asker: ev.asker,
|
||||||
|
question: ev.question,
|
||||||
|
options: ev.options || [],
|
||||||
|
multi: !!ev.multi,
|
||||||
|
asked_at: ev.asked_at,
|
||||||
|
deadline_at: ev.deadline_at ?? null,
|
||||||
|
target: ev.target || null,
|
||||||
|
question_refs: ev.question_refs || [],
|
||||||
|
});
|
||||||
|
renderQuestions();
|
||||||
|
renderContainersFromState();
|
||||||
|
}
|
||||||
|
export function applyQuestionResolved(ev) {
|
||||||
|
const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
|
||||||
|
const existing = idx >= 0 ? questionsState.pending[idx] : null;
|
||||||
|
if (idx >= 0) questionsState.pending.splice(idx, 1);
|
||||||
|
// Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can
|
||||||
|
// carry this same answered row in `question_history` while a live
|
||||||
|
// event also delivers it — guard the unshift so history can't
|
||||||
|
// double a row.
|
||||||
|
if (!questionsState.history.some((h) => h.id === ev.id)) {
|
||||||
|
questionsState.history.unshift({
|
||||||
|
id: ev.id,
|
||||||
|
asker: existing?.asker || '?',
|
||||||
|
question: existing?.question || '',
|
||||||
|
options: existing?.options || [],
|
||||||
|
multi: existing?.multi || false,
|
||||||
|
asked_at: existing?.asked_at || ev.answered_at,
|
||||||
|
answered_at: ev.answered_at,
|
||||||
|
answer: ev.answer,
|
||||||
|
answerer: ev.answerer,
|
||||||
|
target: existing?.target ?? ev.target ?? null,
|
||||||
|
question_refs: existing?.question_refs || [],
|
||||||
|
answer_refs: ev.answer_refs || [],
|
||||||
|
});
|
||||||
|
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
|
||||||
|
questionsState.history.length = QUESTION_HISTORY_LIMIT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderQuestions();
|
||||||
|
renderContainersFromState();
|
||||||
|
}
|
||||||
|
// Filter selection for the questions section. Persisted so the
|
||||||
|
// operator's preferred view (all / operator-targeted / peer)
|
||||||
|
// survives a reload.
|
||||||
|
const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter';
|
||||||
|
function getQuestionsFilter() {
|
||||||
|
return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all';
|
||||||
|
}
|
||||||
|
function setQuestionsFilter(v) {
|
||||||
|
localStorage.setItem(QUESTIONS_FILTER_KEY, v);
|
||||||
|
renderQuestions();
|
||||||
|
}
|
||||||
|
function questionMatchesFilter(q, filter) {
|
||||||
|
if (filter === 'all') return true;
|
||||||
|
if (filter === 'operator') return !q.target;
|
||||||
|
if (filter === 'peer') return !!q.target;
|
||||||
|
// `agent:<name>` matches when the agent appears as asker OR target.
|
||||||
|
if (filter.startsWith('agent:')) {
|
||||||
|
const name = filter.slice('agent:'.length);
|
||||||
|
return q.asker === name || q.target === name;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderQuestions() {
|
||||||
|
const root = $('questions-section');
|
||||||
|
// #questions-section only lives on /dashboard.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;
|
||||||
|
};
|
||||||
|
// Count pending questions per filter value so each chip shows its
|
||||||
|
// own hit count — the operator can see "operator · 2 / peer · 3"
|
||||||
|
// at a glance without clicking through each tab.
|
||||||
|
const operatorCount = allPending.filter((q) => !q.target).length;
|
||||||
|
const peerCount = allPending.filter((q) => !!q.target).length;
|
||||||
|
const agentCount = (name) => allPending.filter(
|
||||||
|
(q) => q.asker === name || q.target === name).length;
|
||||||
|
filterRow.append(
|
||||||
|
mkChip('all', `all · ${allPending.length}`),
|
||||||
|
mkChip('operator', `@operator · ${operatorCount}`),
|
||||||
|
mkChip('peer', `@peer · ${peerCount}`),
|
||||||
|
);
|
||||||
|
for (const name of Array.from(participants).sort()) {
|
||||||
|
filterRow.append(mkChip('agent:' + name, `@${name} · ${agentCount(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;
|
||||||
|
if (hist.length) {
|
||||||
|
const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
|
||||||
|
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
|
||||||
|
const hul = el('ul', { class: 'questions questions-answered' });
|
||||||
|
for (const q of hist) {
|
||||||
|
const targetLabel = q.target || 'operator';
|
||||||
|
const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') });
|
||||||
|
const head = el('div', { class: 'q-head' },
|
||||||
|
el('span', { class: 'msg-ts' }, fmt(q.answered_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' }, 'asked:'),
|
||||||
|
);
|
||||||
|
const histBody = el('div', { class: 'q-body' });
|
||||||
|
appendLinkified(histBody, q.question, q.question_refs);
|
||||||
|
const ansText = el('span', { class: 'q-answer-text' });
|
||||||
|
appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
|
||||||
|
const ansLine = el('div', { class: 'q-answer' },
|
||||||
|
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
||||||
|
ansText,
|
||||||
|
);
|
||||||
|
li.append(head, histBody, ansLine);
|
||||||
|
hul.append(li);
|
||||||
|
}
|
||||||
|
details.append(hul);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format a remaining-seconds count as the `⏳ …` TTL chip text on a
|
||||||
|
// question card. Bucketed at minutes / hours so a long deadline stays
|
||||||
|
// readable; "expiring…" once the deadline has passed (the host-side
|
||||||
|
// ttl-watchdog will fire shortly).
|
||||||
|
function formatTtl(remaining) {
|
||||||
|
if (remaining <= 0) return 'expiring…';
|
||||||
|
if (remaining < 60) return '⏳ ' + remaining + 's';
|
||||||
|
if (remaining < 3600) {
|
||||||
|
return '⏳ ' + Math.floor(remaining / 60) + 'm '
|
||||||
|
+ (remaining % 60) + 's';
|
||||||
|
}
|
||||||
|
return '⏳ ' + Math.floor(remaining / 3600) + 'h '
|
||||||
|
+ Math.floor((remaining % 3600) / 60) + 'm';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single page-wide ticker that refreshes every TTL chip in place
|
||||||
|
// each second. Renderers stamp `data-deadline` on the
|
||||||
|
// chip; this just updates `textContent`, no re-render of the
|
||||||
|
// questions section. No-op when no chips are on screen, so the
|
||||||
|
// cost is negligible.
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => {
|
||||||
|
const deadline = Number(node.getAttribute('data-deadline'));
|
||||||
|
if (!Number.isFinite(deadline)) return;
|
||||||
|
node.textContent = formatTtl(deadline - now);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||||
import {
|
import {
|
||||||
containersState, syncContainersFromSnapshot,
|
containersState, syncContainersFromSnapshot,
|
||||||
questionsState, QUESTION_HISTORY_LIMIT,
|
questionsState,
|
||||||
} from './state.js';
|
} from './state.js';
|
||||||
import { fmtAgo, truncate, fmtElapsed, fmtDuration } from './util.js';
|
import { fmtAgo, truncate, fmtElapsed, fmtDuration } from './util.js';
|
||||||
import {
|
import {
|
||||||
|
|
@ -37,6 +37,8 @@ import {
|
||||||
refreshOperatorInbox, operatorInboxAppendFromEvent, operatorInboxCount,
|
refreshOperatorInbox, operatorInboxAppendFromEvent, operatorInboxCount,
|
||||||
syncApprovalsFromSnapshot, applyApprovalAdded, applyApprovalResolved,
|
syncApprovalsFromSnapshot, applyApprovalAdded, applyApprovalResolved,
|
||||||
renderApprovals, activeApprovalCount,
|
renderApprovals, activeApprovalCount,
|
||||||
|
syncQuestionsFromSnapshot, applyQuestionAdded, applyQuestionResolved,
|
||||||
|
renderQuestions, activeQuestionCount,
|
||||||
} from './call.js';
|
} from './call.js';
|
||||||
|
|
||||||
// mdNode (in common.js) reads `window.marked` for the markdown side
|
// mdNode (in common.js) reads `window.marked` for the markdown side
|
||||||
|
|
@ -1190,375 +1192,11 @@ window.marked = marked;
|
||||||
parent.append(btn);
|
parent.append(btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
// `questionsState` + `QUESTION_HISTORY_LIMIT` now live in state.js (imported
|
// The Y3R C4LL questions domain (sync/apply/render/filter/TTL) now lives in
|
||||||
// above): the SW4RM container rows read `questionsState.pending` for per-agent
|
// call.js — `syncQuestionsFromSnapshot`, `applyQuestionAdded`,
|
||||||
// question-count badges, so it's cross-domain shared state, not Y3R-C4LL-local.
|
// `applyQuestionResolved`, `renderQuestions`, and `activeQuestionCount` are
|
||||||
// Keyed row cache: question id → {el, fingerprint}. Allows renderQuestions
|
// imported above. `questionsState` itself lives in state.js (the SW4RM
|
||||||
// to reuse <li> elements whose state hasn't changed. The main benefit is
|
// container rows read `questionsState.pending` for per-agent count badges).
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
function applyQuestionAdded(ev) {
|
|
||||||
if (questionsState.pending.some((q) => q.id === ev.id)) return;
|
|
||||||
questionsState.pending.push({
|
|
||||||
id: ev.id,
|
|
||||||
asker: ev.asker,
|
|
||||||
question: ev.question,
|
|
||||||
options: ev.options || [],
|
|
||||||
multi: !!ev.multi,
|
|
||||||
asked_at: ev.asked_at,
|
|
||||||
deadline_at: ev.deadline_at ?? null,
|
|
||||||
target: ev.target || null,
|
|
||||||
question_refs: ev.question_refs || [],
|
|
||||||
});
|
|
||||||
renderQuestions();
|
|
||||||
renderContainersFromState();
|
|
||||||
}
|
|
||||||
function applyQuestionResolved(ev) {
|
|
||||||
const idx = questionsState.pending.findIndex((q) => q.id === ev.id);
|
|
||||||
const existing = idx >= 0 ? questionsState.pending[idx] : null;
|
|
||||||
if (idx >= 0) questionsState.pending.splice(idx, 1);
|
|
||||||
// Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can
|
|
||||||
// carry this same answered row in `question_history` while a live
|
|
||||||
// event also delivers it — guard the unshift so history can't
|
|
||||||
// double a row.
|
|
||||||
if (!questionsState.history.some((h) => h.id === ev.id)) {
|
|
||||||
questionsState.history.unshift({
|
|
||||||
id: ev.id,
|
|
||||||
asker: existing?.asker || '?',
|
|
||||||
question: existing?.question || '',
|
|
||||||
options: existing?.options || [],
|
|
||||||
multi: existing?.multi || false,
|
|
||||||
asked_at: existing?.asked_at || ev.answered_at,
|
|
||||||
answered_at: ev.answered_at,
|
|
||||||
answer: ev.answer,
|
|
||||||
answerer: ev.answerer,
|
|
||||||
target: existing?.target ?? ev.target ?? null,
|
|
||||||
question_refs: existing?.question_refs || [],
|
|
||||||
answer_refs: ev.answer_refs || [],
|
|
||||||
});
|
|
||||||
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
|
|
||||||
questionsState.history.length = QUESTION_HISTORY_LIMIT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
renderQuestions();
|
|
||||||
renderContainersFromState();
|
|
||||||
}
|
|
||||||
// Filter selection for the questions section. Persisted so the
|
|
||||||
// operator's preferred view (all / operator-targeted / peer)
|
|
||||||
// survives a reload.
|
|
||||||
const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter';
|
|
||||||
function getQuestionsFilter() {
|
|
||||||
return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all';
|
|
||||||
}
|
|
||||||
function setQuestionsFilter(v) {
|
|
||||||
localStorage.setItem(QUESTIONS_FILTER_KEY, v);
|
|
||||||
renderQuestions();
|
|
||||||
}
|
|
||||||
function questionMatchesFilter(q, filter) {
|
|
||||||
if (filter === 'all') return true;
|
|
||||||
if (filter === 'operator') return !q.target;
|
|
||||||
if (filter === 'peer') return !!q.target;
|
|
||||||
// `agent:<name>` matches when the agent appears as asker OR target.
|
|
||||||
if (filter.startsWith('agent:')) {
|
|
||||||
const name = filter.slice('agent:'.length);
|
|
||||||
return q.asker === name || q.target === name;
|
|
||||||
}
|
|
||||||
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 /dashboard.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;
|
|
||||||
};
|
|
||||||
// Count pending questions per filter value so each chip shows its
|
|
||||||
// own hit count — the operator can see "operator · 2 / peer · 3"
|
|
||||||
// at a glance without clicking through each tab.
|
|
||||||
const operatorCount = allPending.filter((q) => !q.target).length;
|
|
||||||
const peerCount = allPending.filter((q) => !!q.target).length;
|
|
||||||
const agentCount = (name) => allPending.filter(
|
|
||||||
(q) => q.asker === name || q.target === name).length;
|
|
||||||
filterRow.append(
|
|
||||||
mkChip('all', `all · ${allPending.length}`),
|
|
||||||
mkChip('operator', `@operator · ${operatorCount}`),
|
|
||||||
mkChip('peer', `@peer · ${peerCount}`),
|
|
||||||
);
|
|
||||||
for (const name of Array.from(participants).sort()) {
|
|
||||||
filterRow.append(mkChip('agent:' + name, `@${name} · ${agentCount(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;
|
|
||||||
if (hist.length) {
|
|
||||||
const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' });
|
|
||||||
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
|
|
||||||
const hul = el('ul', { class: 'questions questions-answered' });
|
|
||||||
for (const q of hist) {
|
|
||||||
const targetLabel = q.target || 'operator';
|
|
||||||
const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') });
|
|
||||||
const head = el('div', { class: 'q-head' },
|
|
||||||
el('span', { class: 'msg-ts' }, fmt(q.answered_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' }, 'asked:'),
|
|
||||||
);
|
|
||||||
const histBody = el('div', { class: 'q-body' });
|
|
||||||
appendLinkified(histBody, q.question, q.question_refs);
|
|
||||||
const ansText = el('span', { class: 'q-answer-text' });
|
|
||||||
appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
|
|
||||||
const ansLine = el('div', { class: 'q-answer' },
|
|
||||||
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
|
||||||
ansText,
|
|
||||||
);
|
|
||||||
li.append(head, histBody, ansLine);
|
|
||||||
hul.append(li);
|
|
||||||
}
|
|
||||||
details.append(hul);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format a remaining-seconds count as the `⏳ …` TTL chip text on a
|
|
||||||
// question card. Bucketed at minutes / hours so a long deadline stays
|
|
||||||
// readable; "expiring…" once the deadline has passed (the host-side
|
|
||||||
// ttl-watchdog will fire shortly).
|
|
||||||
function formatTtl(remaining) {
|
|
||||||
if (remaining <= 0) return 'expiring…';
|
|
||||||
if (remaining < 60) return '⏳ ' + remaining + 's';
|
|
||||||
if (remaining < 3600) {
|
|
||||||
return '⏳ ' + Math.floor(remaining / 60) + 'm '
|
|
||||||
+ (remaining % 60) + 's';
|
|
||||||
}
|
|
||||||
return '⏳ ' + Math.floor(remaining / 3600) + 'h '
|
|
||||||
+ Math.floor((remaining % 3600) / 60) + 'm';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single page-wide ticker that refreshes every TTL chip in place
|
|
||||||
// each second. Renderers stamp `data-deadline` on the
|
|
||||||
// chip; this just updates `textContent`, no re-render of the
|
|
||||||
// questions section. No-op when no chips are on screen, so the
|
|
||||||
// cost is negligible.
|
|
||||||
setInterval(() => {
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => {
|
|
||||||
const deadline = Number(node.getAttribute('data-deadline'));
|
|
||||||
if (!Number.isFinite(deadline)) return;
|
|
||||||
node.textContent = formatTtl(deadline - now);
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// 30s ticker for agent status-age chips. Renderers stamp `data-set-at`
|
// 30s ticker for agent status-age chips. Renderers stamp `data-set-at`
|
||||||
// (unix seconds) on the `.status-age` span. Keyed container rows persist
|
// (unix seconds) on the `.status-age` span. Keyed container rows persist
|
||||||
|
|
@ -1939,7 +1577,7 @@ window.marked = marked;
|
||||||
// unread agent→operator messages.
|
// unread agent→operator messages.
|
||||||
const callCount =
|
const callCount =
|
||||||
activeApprovalCount() +
|
activeApprovalCount() +
|
||||||
(questionsState?.pending?.length ?? 0) +
|
activeQuestionCount() +
|
||||||
operatorInboxCount();
|
operatorInboxCount();
|
||||||
setTabCount('call', callCount);
|
setTabCount('call', callCount);
|
||||||
// Browser tab title prefix — lets the operator see the pending
|
// Browser tab title prefix — lets the operator see the pending
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue