From e056e82d6d2e20bb92b7792514d9ce2d5fb98a5b Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 30 May 2026 21:40:04 +0200 Subject: [PATCH] =?UTF-8?q?agent/terminal:=20inline=20answer=20form=20unde?= =?UTF-8?q?r=20ask=20=E2=86=92=20operator=20rows=20(#666)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent calls mcp__hyperhive__ask with to==operator (the default), the rich tool-use renderer now mounts an empty ask-answer-inline-slot inside the expanded ask row and enqueues a loose-ends refresh. A new reconcileAskBinds() walks waiting slots on every loose-ends update, matches each against pending operator-bound questions by question text, and injects the same inline answer form the side panel uses (buildAnswerForm → POST to the host dashboard answer-question endpoint). When a question resolves, the form gets replaced by a struck-through [answered ✓] tag so the scrollback reflects the closed state. Lets the operator respond to agent questions inline in the live terminal without context-switching to the loose-ends side panel or the dashboard Y3R C4LL tab. Refreshes loose-ends both on tool_use render (best-effort, may miss the question before the broker persists it) and on the matching tool_result (the right moment — MCP has just returned the assigned id). Slots are pruned on /clear and defensively filtered for isConnected on each reconcile. closes #666 --- docs/terminal-rendering.md | 21 +++++ frontend/packages/agent/src/agent.css | 61 ++++++++++++++ frontend/packages/agent/src/app.js | 113 +++++++++++++++++++++++++- 3 files changed, 194 insertions(+), 1 deletion(-) diff --git a/docs/terminal-rendering.md b/docs/terminal-rendering.md index fce8d21a..a3d44441 100644 --- a/docs/terminal-rendering.md +++ b/docs/terminal-rendering.md @@ -43,6 +43,7 @@ parent's negative pull. | `.tool-use` (flat) | `→ Name args…` | cyan | tool_use w/o rich renderer | stream-json | | `.tool-use` `
` | `Write/Edit · +N` (no `→`) | cyan, body is +/- diff | `renderRichToolUse` Write/Edit | stream-json | | `.tool-use` `
` | `send → to · NL`, `ask → to`, `answer #id` | cyan, body is markdown | rich renderer for send / ask / answer | stream-json | +| `.tool-use .ask-answer-inline-slot` | (sub-block under `ask → operator`) | inherits row | inline answer form bound by `reconcileAskBinds` to the loose-end | #666 | | `.tool-result` (flat) | `← ` | muted | short `tool_result` (≤120c, non-recv) | stream-json | | `.tool-result-block` `
` | `Nl · headline` | muted, body is text | long generic `tool_result` | stream-json | | `.tool-result-block` `
` | `recv ← ` | muted, body is markdown | `tool_result` correlated to a prior `recv` tool_use via id | stream-json | @@ -104,6 +105,26 @@ isn't in the built-in `fmtToolUse` switch: This keeps `mcp__matrix__send_message` and similar from dumping raw JSON. +## Inline ask-operator answer (#666) + +When an agent calls `mcp__hyperhive__ask` with `to == "operator"` +(default), the rich tool-use renderer mounts an empty +`
` inside the row's expanded +body, then enqueues a loose-ends refresh. `reconcileAskBinds()` +runs on every loose-ends refresh, matches each waiting slot +against pending operator-bound questions by question text, and +injects an inline `.answer-form` (textarea + send button bound to +`/answer-question/` on the host dashboard) into the matching +slot. When a question subsequently resolves (operator answered via +the inline form, the side-panel form, or the dashboard Y3R C4LL +tab), the same reconciler replaces the form with a struck-through +`[answered ✓]` tag so the scrollback reflects the closed state. + +Lets the operator answer mid-flow without context-switching to the +loose-ends side panel or the dashboard tab. Side panel + dashboard +forms remain — they're the same `buildAnswerForm` factory, three +mount points for the same POST. + ## Dashboard side (not covered here) The main dashboard's message-flow pane is a different diff --git a/frontend/packages/agent/src/agent.css b/frontend/packages/agent/src/agent.css index 4243acf7..486c685b 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -552,6 +552,67 @@ pre.diff { .agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; } .agent-inbox .answer-status { color: var(--muted); align-self: center; } +/* #666: inline answer slot mounted under each `ask → operator` row + in the live terminal stream. Mirrors the side-panel `.answer-form` + look-and-feel — same textarea, same send button — so the operator + doesn't have to context-switch between "answering in the panel" and + "answering inline". Empty slot collapses to nothing (no margin) so + pre-bind rows stay tidy; populated slot gets a thin top divider to + separate the question body from the form. Resolved tag (the + struck-through `[answered ✓]`) replaces the form once the + question's been answered. */ +.live .ask-answer-inline-slot:empty { display: none; } +.live .ask-answer-inline-slot { + margin-top: 0.5em; + padding-top: 0.5em; + border-top: 1px dashed var(--border); +} +.live .ask-answer-inline-slot .answer-form { + display: flex; + gap: 0.4em; + align-items: flex-start; +} +.live .ask-answer-inline-slot .answer-form textarea { + flex: 1; + font-family: inherit; + font-size: inherit; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0.3em; + resize: vertical; +} +.live .ask-answer-inline-slot .answer-form button { + font-family: inherit; + font-size: inherit; + background: var(--bg-elev); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0.3em 0.7em; + cursor: pointer; + white-space: nowrap; +} +.live .ask-answer-inline-slot .answer-form button:hover:not(:disabled) { + border-color: var(--purple); + color: var(--purple); +} +.live .ask-answer-inline-slot .answer-form button:disabled { + opacity: 0.5; + cursor: default; +} +.live .ask-answer-inline-slot .answer-status { + color: var(--muted); + align-self: center; +} +.live .ask-answer-inline-slot .ask-answered-tag { + color: var(--muted); + font-style: italic; + text-decoration: line-through; + text-decoration-color: var(--muted); +} + /* #559: "mark all read" header row sits above the recent-messages list in the inbox side-panel flyout. Same look as the answer-form button (mauve hover, bg-elev background) so they read as part of diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index edb3281e..cb61e2ca 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -488,6 +488,10 @@ window.marked = marked; return true; case '/clear': termAPI.clear(); + // #666: detached `ask → operator` rows no longer have a live + // mount-point in the DOM — drop their slots so subsequent + // loose-ends reconciliation doesn't walk dead references. + pendingAskBinds.length = 0; termAPI.row('note', '· terminal cleared (local view only — server history kept)'); return true; case '/cancel': @@ -681,6 +685,19 @@ 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. */ + const pendingAskBinds = []; + function buildLooseEndsList(threads) { // Returns the
the side panel renders. The structural shape // mirrors the legacy
-collapsible block — same CSS rules @@ -750,11 +767,69 @@ 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. + 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. */ + 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. + 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. + const stillPending = pending.some((q) => q.id === slot._boundId); + if (!stillPending && !slot._resolvedShown) { + slot.innerHTML = ''; + slot.appendChild(el('span', { class: 'ask-answered-tag' }, '[answered ✓]')); + slot._resolvedShown = true; + } + 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); + slot._boundId = q.id; + slot.innerHTML = ''; + slot.appendChild(buildAnswerForm(q.id)); + } } // 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. + // Used by both the loose-ends side panel (`buildLooseEndsList`) and + // the in-stream ask-row binder (`reconcileAskBinds`, #666). function buildAnswerForm(id) { const wrap = el('div', { class: 'answer-form' }); const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' }); @@ -1259,9 +1334,36 @@ window.marked = marked; const to = input.to || 'operator'; const q = String(input.question || ''); const lines = q.split('\n').length; - return detailsOpenMd(api, 'tool-use', + const d = detailsOpenMd(api, 'tool-use', 'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''), q); + // #666: when the ask targets the operator, mount an inline + // answer slot in the live terminal so the operator doesn't + // need to open the loose-ends side panel (or jump to Y3R C4LL + // on the dashboard) to respond. The slot starts empty and + // gets populated by `reconcileAskBinds()` once the + // loose-ends fetch identifies a matching pending question + // (by asker == this agent + question text). Resolved + // questions render as a struck-through [answered ✓] tag + // instead of a form. + if (to === 'operator') { + const slot = el('div', { class: 'ask-answer-inline-slot' }); + // Stash the question text on the slot so the reconciler + // can match against `lastLooseEnds` entries without + // walking the row's text content. Options/multi can be + // surfaced later when the harness emits them on the + // tool_result; phase A is pure text-match. + slot._askQuestion = q; + d.appendChild(slot); + pendingAskBinds.push(slot); + // Mid-turn refresh — the standard `turn_end` refresh + // won't fire until the agent's turn finishes; we want + // the form to show up as soon as the ask lands. Cheap + // best-effort (silent on failure). + if (!api.fromHistory) refreshLooseEnds(); + else reconcileAskBinds(); + } + return d; } if (name === 'mcp__hyperhive__answer') { const id = input.id != null ? String(input.id) : '?'; @@ -1285,6 +1387,15 @@ window.marked = marked; : (c.content || ''); const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null; const isMessageBearing = sourceName === 'mcp__hyperhive__recv'; + // #666: when an ask's tool_result lands the broker has just + // persisted the question with its assigned id. Refresh loose + // ends so `reconcileAskBinds` finds the new entry and mounts + // the inline answer form under the rendered ask row. Skipped + // during history replay (the question's likely long-resolved; + // turn_end refresh on cold-load already covers reconciliation). + if (sourceName === 'mcp__hyperhive__ask' && !api.fromHistory) { + refreshLooseEnds(); + } const trimmed = txt.replace(/\s+/g, ' ').trim(); const summaryBody = (() => { if (!trimmed) return '(empty)';