diff --git a/docs/terminal-rendering.md b/docs/terminal-rendering.md index eed6948c..fce8d21a 100644 --- a/docs/terminal-rendering.md +++ b/docs/terminal-rendering.md @@ -43,7 +43,6 @@ 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 | @@ -105,29 +104,6 @@ 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 leaves the pending list (answered, -cancelled by asker, or TTL-expired), the same reconciler -replaces the form with a struck-through `[resolved]` tag so the -scrollback reflects the closed state. The label is neutral -because `/api/loose-ends` only carries pending state — full -resolution detail is visible via the question's history in the -side panel. - -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 5c51f484..4243acf7 100644 --- a/frontend/packages/agent/src/agent.css +++ b/frontend/packages/agent/src/agent.css @@ -552,67 +552,6 @@ 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-resolved-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 f490f649..edb3281e 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -488,10 +488,6 @@ 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': @@ -685,19 +681,6 @@ 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 @@ -767,76 +750,11 @@ 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. 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 = ''; - slot.appendChild(el('span', { class: 'ask-resolved-tag' }, '[resolved]')); - 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…' }); @@ -1341,36 +1259,9 @@ window.marked = marked; const to = input.to || 'operator'; const q = String(input.question || ''); const lines = q.split('\n').length; - const d = detailsOpenMd(api, 'tool-use', + return 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) : '?'; @@ -1394,15 +1285,6 @@ 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)';