dashboard: surface peer questions + operator override

questions pane now shows both operator-targeted threads
(target IS NULL) and agent-to-agent threads (target = some
agent). filter chips above the list: all / @operator / @peer /
per-participant. peer rows get a mauve left rule + a 0V3RR1D3
button that POSTs the same /answer-question endpoint
(OperatorQuestions::answer already permits the operator as
answerer on any target).

wire changes: OperatorQuestions gains pending_all +
recent_answered_all; QuestionAdded + QuestionResolved events
carry target: Option<String>; emit sites drop their
target.is_none() guard. answered-history rows show the
answerer prefix so override answers are auditable at a glance.
This commit is contained in:
müde 2026-05-17 22:06:53 +02:00
parent e7ce35c503
commit a15fafb5de
9 changed files with 187 additions and 71 deletions

View file

@ -149,7 +149,9 @@
for (const q of questions) {
if (seenQuestions.has(q.id)) continue;
seenQuestions.add(q.id);
NOTIF.show('◆ manager asks', q.question.slice(0, 120),
const targetLabel = q.target || 'operator';
NOTIF.show(`${q.asker}${targetLabel} asks`,
q.question.slice(0, 120),
'hyperhive:question:' + q.id);
}
}
@ -610,6 +612,7 @@
multi: !!ev.multi,
asked_at: ev.asked_at,
deadline_at: ev.deadline_at ?? null,
target: ev.target || null,
});
renderQuestions();
}
@ -627,26 +630,84 @@
answered_at: ev.answered_at,
answer: ev.answer,
answerer: ev.answerer,
target: existing?.target ?? ev.target ?? null,
});
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
questionsState.history.length = QUESTION_HISTORY_LIMIT;
}
renderQuestions();
}
// 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;
}
function renderQuestions() {
const root = $('questions-section');
root.innerHTML = '';
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const pending = questionsState.pending;
const allPending = questionsState.pending;
const activeFilter = getQuestionsFilter();
const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter));
// 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);
}
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' }, 'no pending questions'));
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 li = el('li', { class: 'question' });
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) {
@ -701,9 +762,19 @@
}, 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' },
isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'),
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),
@ -712,10 +783,11 @@
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? manager will see '
'data-confirm': `cancel this question? ${cancelTargetLabel} will see `
+ '"[cancelled]" as the answer.',
});
cancelForm.append(
@ -733,17 +805,20 @@
details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')'));
const hul = el('ul', { class: 'questions questions-answered' });
for (const q of hist) {
const li = el('li', { class: 'question question-answered' });
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:'),
);
li.append(
head,
el('div', { class: 'q-body' }, q.question),
el('div', { class: 'q-answer' },
el('span', { class: 'msg-sep' }, 'answer: '),
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
el('span', { class: 'q-answer-text' }, q.answer || '(none)'),
),
);