From 3f84b6492afd7e40626e169df909bf9d4d050f89 Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 31 May 2026 14:32:11 +0200 Subject: [PATCH] =?UTF-8?q?agent/app.js=20+=20docs:=20migrate=20ask?= =?UTF-8?q?=E2=86=92operator=20inline-answer=20binding=20prose=20(#713=20b?= =?UTF-8?q?atch=201=20cont.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the #666 ask→operator inline-answer wiring rationale from app.js into a new docs/web-ui.md::Per-agent page sub-paragraph **Ask → operator inline-answer binding**. Substantial block — the slot-registry / reconcileAskBinds / buildAnswerForm trio has real design rationale (async question id, text-match pairing, resolved-vs-cancelled-vs-expired neutrality, defensive prune, no-regression-fallback to side-panel answer) that belongs in docs, not three JSDoc blocks scattered through app.js. Moved to docs (~30 lines of substantive prose): - pendingAskBinds slot-registry rationale - reconcileAskBinds matching algorithm (text-match, first-unbound to first-unclaimed pairing stability) - defensive prune for disconnected slots - [resolved] vs ✓ rationale (answered / cancelled / TTL-expired ambiguity — neutral label) - fallback to side-panel answer when slot stays unbound Collapsed in app.js: 3 JSDoc blocks + inline comments → ~10-line total pointer. Drops #666 ×3, #559, #668 cookies en passant (substance now lives in docs). app.js: 24 → 19 refs in this commit; 27 → 19 across batch 1 (30% reduction so far). Net ~57 lines of substantive prose migrated from app.js to docs/web-ui.md across both commits in this PR. --- docs/web-ui.md | 27 +++++++++++++- frontend/packages/agent/src/app.js | 56 +++++++----------------------- 2 files changed, 39 insertions(+), 44 deletions(-) diff --git a/docs/web-ui.md b/docs/web-ui.md index ff86d9e2..6c88f02a 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -910,7 +910,7 @@ dashboard's side panel shape. Carries inbox and loose-ends flyouts diffs, journald logs). Inbox flyout: last 30 messages addressed to this agent (`AgentRequest::Recent { limit: 30 }`); reply messages indented with `↳ reply ·` in amber. A `✓ mark all read` button -appears in the flyout header when the inbox is non-empty (#559); +appears in the flyout header when the inbox is non-empty; clicking it confirms then POSTs cross-origin to the core dashboard's `POST /api/agent/{name}/mark-all-read` — all pending messages for this agent are acked, the harness won't receive @@ -923,6 +923,31 @@ question rows carry an inline answer form that POSTs cross-origin to the core dashboard's `/answer-question/{id}` so the operator answers *as operator* (see `docs/boundary.md`). +**Ask → operator inline-answer binding.** When the agent emits +`mcp__hyperhive__ask(to: "operator", ...)`, the tool_use renderer +mounts an empty slot (`
`) +right under the `↳ ask → operator` row in the terminal scrollback +and pushes a reference into `pendingAskBinds`. The broker assigns +the question id asynchronously, so the slot waits — and the next +`/api/loose-ends` refresh runs `reconcileAskBinds()`, which walks +the slot list and pairs each unbound slot with the first unclaimed +pending operator-bound question whose `question` text matches the +slot's stashed `_askQuestion`. On match the slot mounts the +`buildAnswerForm` (same form shape as the loose-ends flyout — +POSTs to the core's `/answer-question/{id}` cross-origin). Slots +stay in the array after binding so the reconciler can flip them +to a neutral `[resolved]` tag when the question later disappears +from the pending list. Disappearance can mean answered, cancelled +by the asker, or TTL-expired — the neutral label avoids +mis-asserting "✓" on the cancel / expire paths; full resolution +state is visible via the side-panel history. A defensive prune +walks the slot list each tick and drops any whose DOM node has +been removed (e.g. via a future "clear single row" affordance), +so stale references don't accumulate. Slots whose question never +arrives (e.g. the agent cancelled the ask, or the question is +older than the loose-ends retention window) stay empty — the +operator can still answer via the side panel, no regression. + ### Live view Each agent runs an `events::Bus`: a `tokio::sync::broadcast` diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 49f7a22c..b181d248 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -682,17 +682,9 @@ window.marked = marked; let lastLooseEnds = []; let lastInbox = []; - /** #666: ask-operator inline-answer slots awaiting a question id. - * Each entry is the empty `
` - * element appended to an `ask → operator` row by - * `renderRichToolUse`. `reconcileAskBinds()` walks the list on - * every loose-ends refresh, looking up the matching pending - * question by `kind=question` + `target == 'operator'` (or - * missing — broker omits target for operator-bound asks) + - * question text match against the slot's stashed `_askQuestion`. - * Slots stay in the array even after binding so the reconciler - * can flip them back to "answered" if the question later - * resolves. */ + /** ask → operator inline-answer slot registry. See + * docs/web-ui.md::Per-agent page (Ask → operator inline-answer + * binding) for the matching algorithm + resolution semantics. */ const pendingAskBinds = []; function buildLooseEndsList(threads) { @@ -764,53 +756,32 @@ window.marked = marked; if (pill) pill.hidden = threads.length === 0; Panel.refresh('loose-ends', 'loose ends · ' + threads.length, buildLooseEndsList(threads)); - // #666: wire inline answer forms into any `ask → operator` rows - // that have been waiting for their broker-assigned question id. + // Wire inline answer forms into any `ask → operator` rows + // waiting on a broker-assigned question id. reconcileAskBinds(); } - /** #666: for each `ask → operator` row mounted by renderRichToolUse, - * look up the matching pending question in `lastLooseEnds` (by - * target=operator + question text) and inject an inline answer - * form. If a previously-bound slot's question has been resolved - * (no longer in the pending list), replace the form with a small - * `[answered ✓]` tag so the scrollback reflects the closed state. - * Slots whose question never appears (e.g. agent cancelled the - * ask, or the question is older than the loose-ends fetch - * retention) stay empty — the operator can still answer via the - * side panel, no regression. */ + /** Walk `pendingAskBinds` against the latest `lastLooseEnds` + * snapshot, pair unbound slots with the first unclaimed pending + * operator-bound question whose text matches, and flip already- + * bound slots to `[resolved]` when their question disappears. + * See docs/web-ui.md::Per-agent page (Ask → operator inline-answer + * binding) for the full design + edge cases. */ function reconcileAskBinds() { if (!pendingAskBinds.length) return; - // Build an index of pending operator-bound questions by text. - // Note: an agent could in theory ask the same question text - // twice; the loose-ends list is small and the broker assigns - // distinct ids — we match the first unbound slot to the first - // unmatched pending question to keep the pairing stable. const pending = lastLooseEnds.filter( (t) => t.kind === 'question' && (!t.target || t.target === 'operator'), ); - // Defensive prune: drop slots whose DOM node has been removed - // (e.g. via a future "clear single row" affordance) so we don't - // process detached references on every refresh. + // Defensive prune: drop slots whose DOM node has been removed. for (let i = pendingAskBinds.length - 1; i >= 0; i--) { if (!pendingAskBinds[i].isConnected) pendingAskBinds.splice(i, 1); } - // Track which pending question ids have already been claimed by - // an existing bound slot so we don't double-bind on refresh. const claimed = new Set(); for (const slot of pendingAskBinds) { if (slot._boundId) claimed.add(slot._boundId); } for (const slot of pendingAskBinds) { if (slot._boundId) { - // Already bound — check if the question got resolved. The - // loose-ends endpoint only returns pending questions, so - // disappearance can mean "answered", "cancelled by asker", - // or "TTL expired" (argus #668 review). Use the neutral - // `[resolved]` label rather than a check-mark that would - // misrepresent the cancel / expire paths; full resolution - // state is visible via the question's history in the - // side panel. const stillPending = pending.some((q) => q.id === slot._boundId); if (!stillPending && !slot._resolvedShown) { slot.innerHTML = ''; @@ -819,7 +790,6 @@ window.marked = marked; } continue; } - // Find the first matching pending question we haven't claimed yet. const q = pending.find((p) => p.question === slot._askQuestion && !claimed.has(p.id)); if (!q) continue; claimed.add(q.id); @@ -833,7 +803,7 @@ window.marked = marked; // the host dashboard (core backend), never this agent's socket — the // core is the only place that can stamp `operator` as the answerer. // Used by both the loose-ends side panel (`buildLooseEndsList`) and - // the in-stream ask-row binder (`reconcileAskBinds`, #666). + // the in-stream ask-row binder (`reconcileAskBinds`). function buildAnswerForm(id) { const wrap = el('div', { class: 'answer-form' }); const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' });