agent ui: answer questions inline from the per-agent page

loose-ends question rows get a textarea + send button; the operator
answers as operator by POSTing to the core dashboard's
/answer-question route, not the per-agent socket — keeps the
operator-authority path off the agent's own socket. cross-origin POST
needs a CORS shim on that route for now; drops out once the gateway
makes the page same-origin.

also splits deployment/ops/boundaries/gateway work into TODO-ops.md.
This commit is contained in:
müde 2026-05-20 10:01:12 +02:00
parent f8795dc029
commit 56e7eb6e73
5 changed files with 221 additions and 8 deletions

View file

@ -22,6 +22,12 @@
return e;
};
// Base URL of the host dashboard (core backend). Set once the first
// /api/state lands. Operator-authority actions (answering a question
// as the operator) POST here rather than to this agent's own socket —
// see TODO-ops.md for why the boundary lives on the core side.
let dashboardBase = '';
// ─── async-form submit (shared with dashboard) ──────────────────────────
document.addEventListener('submit', async (e) => {
const f = e.target;
@ -68,6 +74,7 @@
// ↑ DASHB04RD — back-link to the host dashboard. Opens in a new
// tab to keep the agent page anchored where the operator is.
const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`;
dashboardBase = dashUrl;
title.append(
el('a', {
href: dashUrl, target: '_blank', rel: 'noopener',
@ -454,6 +461,7 @@
el('span', { class: 'inbox-sep' }, t.asker + ' → ' + target), ' ',
el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'),
el('div', { class: 'inbox-body' }, t.question || ''),
buildAnswerForm(t.id),
);
} else if (t.kind === 'reminder') {
// due_at is an absolute unix-seconds value; show time-until-fire
@ -474,6 +482,42 @@
}
}
// Inline "answer as operator" form for a question loose-end. POSTs to
// the host dashboard (core backend), never this agent's socket — the
// core is the only place that can stamp `operator` as the answerer.
function buildAnswerForm(id) {
const wrap = el('div', { class: 'answer-form' });
const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' });
const btn = el('button', { type: 'button' }, 'send answer');
const status = el('span', { class: 'answer-status' });
btn.addEventListener('click', async () => {
const answer = ta.value.trim();
if (!answer) { status.textContent = 'answer required'; return; }
if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; }
btn.disabled = true;
status.textContent = 'sending…';
try {
const resp = await fetch(dashboardBase + 'answer-question/' + id, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'answer=' + encodeURIComponent(answer),
});
if (resp.ok) {
status.textContent = 'answered ✓';
refreshLooseEnds();
} else {
status.textContent = 'failed: ' + (await resp.text());
btn.disabled = false;
}
} catch (err) {
status.textContent = 'failed: ' + err;
btn.disabled = false;
}
});
wrap.append(ta, btn, status);
return wrap;
}
function renderInbox(rows) {
const root = $('inbox-section');
const list = $('inbox-list');