ask_operator tool: non-blocking; operator answer arrives as helper event

new mcp tool on the manager surface that queues a question on the
dashboard and returns the question id immediately. operator submits an
answer via /answer-question/<id>; the dashboard fires
HelperEvent::OperatorAnswered { id, question, answer } into the manager
inbox so the next turn picks it up.

also: fix async-form button stuck on spinner after successful submit
(refreshState skipped re-rendering, so the button was never re-enabled).
This commit is contained in:
müde 2026-05-15 18:44:42 +02:00
parent abfd2cce4b
commit 2770630f33
17 changed files with 426 additions and 79 deletions

View file

@ -58,6 +58,12 @@
if (btn) { btn.disabled = false; btn.innerHTML = original; }
return;
}
// Re-enable the button — refreshState() rebuilds most lists but
// skips forms that didn't change (e.g. the spawn form), so without
// this the spinner sticks and the button can't be clicked again.
if (btn) { btn.disabled = false; btn.innerHTML = original; }
// Clear text inputs whose value was just submitted.
f.querySelectorAll('input[type="text"], input:not([type]), textarea').forEach((i) => { i.value = ''; });
refreshState();
} catch (err) {
alert('action failed: ' + err);
@ -170,6 +176,49 @@
root.append(ul);
}
function renderQuestions(s) {
const root = $('questions-section');
root.innerHTML = '';
if (!s.questions || !s.questions.length) {
root.append(el('p', { class: 'empty' }, '▓ no pending questions ▓'));
return;
}
const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(0, 19);
const ul = el('ul', { class: 'questions' });
for (const q of s.questions) {
const li = el('li', { class: 'question' });
li.append(
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' }, 'asks:'),
),
el('div', { class: 'q-body' }, q.question),
);
const f = el('form', {
method: 'POST', action: '/answer-question/' + q.id,
class: 'qform', 'data-async': '',
});
let input;
if (q.options && q.options.length) {
input = el('select', { name: 'answer', required: '' });
input.append(el('option', { value: '', disabled: '', selected: '' }, 'choose…'));
for (const opt of q.options) {
input.append(el('option', { value: opt }, opt));
}
} else {
input = el('input', {
name: 'answer', type: 'text', required: '',
placeholder: 'your answer', autocomplete: 'off',
});
}
f.append(input, el('button', { type: 'submit', class: 'btn btn-approve' }, '▸ ANSW3R'));
li.append(f);
ul.append(li);
}
root.append(ul);
}
function renderInbox(s) {
const root = $('inbox-section');
root.innerHTML = '';
@ -250,6 +299,7 @@
if (!resp.ok) throw new Error('http ' + resp.status);
const s = await resp.json();
renderContainers(s);
renderQuestions(s);
renderInbox(s);
renderApprovals(s);
// Auto-refresh while a spawn is in flight; otherwise back off.

View file

@ -193,6 +193,36 @@ summary:hover { color: var(--purple); }
.diff .diff-hunk { color: var(--cyan); }
.diff .diff-file { color: var(--purple); font-weight: bold; }
.diff .diff-ctx { color: var(--fg); }
.questions {
background: var(--bg-elev);
border: 1px solid var(--amber);
box-shadow: 0 0 12px -4px var(--amber);
padding: 0.6em 0.9em;
}
.questions li.question {
padding: 0.4em 0;
border-bottom: 1px solid var(--border);
}
.questions li.question:last-child { border-bottom: 0; }
.questions .q-head { font-size: 0.9em; }
.questions .q-body {
color: var(--fg);
margin: 0.3em 0;
white-space: pre-wrap;
word-break: break-word;
}
.qform { display: flex; gap: 0.6em; align-items: stretch; margin-top: 0.3em; }
.qform input, .qform select {
font-family: inherit;
font-size: 1em;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
padding: 0.4em 0.6em;
flex: 1;
}
.qform input::placeholder { color: var(--muted); }
.qform input:focus, .qform select:focus { outline: 1px solid var(--amber); }
.inbox {
background: var(--bg-elev);
border: 1px solid var(--border);

View file

@ -16,6 +16,12 @@
<p class="meta">loading…</p>
</div>
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="questions-section">
<p class="meta">loading…</p>
</div>
<h2>◆ 0PER4T0R 1NB0X ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="inbox-section">