agent/terminal: inline answer form under ask → operator rows (#666)
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
This commit is contained in:
parent
4d64f253c8
commit
e056e82d6d
3 changed files with 194 additions and 1 deletions
|
|
@ -43,6 +43,7 @@ parent's negative pull.
|
||||||
| `.tool-use` (flat) | `→ Name args…` | cyan | tool_use w/o rich renderer | stream-json |
|
| `.tool-use` (flat) | `→ Name args…` | cyan | tool_use w/o rich renderer | stream-json |
|
||||||
| `.tool-use` `<details>` | `Write/Edit <path> · +N` (no `→`) | cyan, body is +/- diff | `renderRichToolUse` Write/Edit | stream-json |
|
| `.tool-use` `<details>` | `Write/Edit <path> · +N` (no `→`) | cyan, body is +/- diff | `renderRichToolUse` Write/Edit | stream-json |
|
||||||
| `.tool-use` `<details open>` | `send → to · NL`, `ask → to`, `answer #id` | cyan, body is markdown | rich renderer for send / ask / answer | stream-json |
|
| `.tool-use` `<details open>` | `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) | `← <txt>` | muted | short `tool_result` (≤120c, non-recv) | stream-json |
|
| `.tool-result` (flat) | `← <txt>` | muted | short `tool_result` (≤120c, non-recv) | stream-json |
|
||||||
| `.tool-result-block` `<details>` | `Nl · headline` | muted, body is text | long generic `tool_result` | stream-json |
|
| `.tool-result-block` `<details>` | `Nl · headline` | muted, body is text | long generic `tool_result` | stream-json |
|
||||||
| `.tool-result-block` `<details open>` | `recv ← <txt>` | muted, body is markdown | `tool_result` correlated to a prior `recv` tool_use via id | stream-json |
|
| `.tool-result-block` `<details open>` | `recv ← <txt>` | 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
|
This keeps `mcp__matrix__send_message` and similar from
|
||||||
dumping raw JSON.
|
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
|
||||||
|
`<div class="ask-answer-inline-slot">` 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/<id>` 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)
|
## Dashboard side (not covered here)
|
||||||
|
|
||||||
The main dashboard's message-flow pane is a different
|
The main dashboard's message-flow pane is a different
|
||||||
|
|
|
||||||
|
|
@ -552,6 +552,67 @@ pre.diff {
|
||||||
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
|
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
|
||||||
.agent-inbox .answer-status { color: var(--muted); align-self: center; }
|
.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
|
/* #559: "mark all read" header row sits above the recent-messages
|
||||||
list in the inbox side-panel flyout. Same look as the answer-form
|
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
|
button (mauve hover, bg-elev background) so they read as part of
|
||||||
|
|
|
||||||
|
|
@ -488,6 +488,10 @@ window.marked = marked;
|
||||||
return true;
|
return true;
|
||||||
case '/clear':
|
case '/clear':
|
||||||
termAPI.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)');
|
termAPI.row('note', '· terminal cleared (local view only — server history kept)');
|
||||||
return true;
|
return true;
|
||||||
case '/cancel':
|
case '/cancel':
|
||||||
|
|
@ -681,6 +685,19 @@ window.marked = marked;
|
||||||
let lastLooseEnds = [];
|
let lastLooseEnds = [];
|
||||||
let lastInbox = [];
|
let lastInbox = [];
|
||||||
|
|
||||||
|
/** #666: ask-operator inline-answer slots awaiting a question id.
|
||||||
|
* Each entry is the empty `<div class="ask-answer-inline-slot">`
|
||||||
|
* 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) {
|
function buildLooseEndsList(threads) {
|
||||||
// Returns the <div> the side panel renders. The structural shape
|
// Returns the <div> the side panel renders. The structural shape
|
||||||
// mirrors the legacy <details>-collapsible block — same CSS rules
|
// mirrors the legacy <details>-collapsible block — same CSS rules
|
||||||
|
|
@ -750,11 +767,69 @@ window.marked = marked;
|
||||||
if (pill) pill.hidden = threads.length === 0;
|
if (pill) pill.hidden = threads.length === 0;
|
||||||
Panel.refresh('loose-ends', 'loose ends · ' + threads.length,
|
Panel.refresh('loose-ends', 'loose ends · ' + threads.length,
|
||||||
buildLooseEndsList(threads));
|
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
|
// Inline "answer as operator" form for a question loose-end. POSTs to
|
||||||
// the host dashboard (core backend), never this agent's socket — the
|
// the host dashboard (core backend), never this agent's socket — the
|
||||||
// core is the only place that can stamp `operator` as the answerer.
|
// 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) {
|
function buildAnswerForm(id) {
|
||||||
const wrap = el('div', { class: 'answer-form' });
|
const wrap = el('div', { class: 'answer-form' });
|
||||||
const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' });
|
const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' });
|
||||||
|
|
@ -1259,9 +1334,36 @@ window.marked = marked;
|
||||||
const to = input.to || 'operator';
|
const to = input.to || 'operator';
|
||||||
const q = String(input.question || '');
|
const q = String(input.question || '');
|
||||||
const lines = q.split('\n').length;
|
const lines = q.split('\n').length;
|
||||||
return detailsOpenMd(api, 'tool-use',
|
const d = detailsOpenMd(api, 'tool-use',
|
||||||
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
||||||
q);
|
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') {
|
if (name === 'mcp__hyperhive__answer') {
|
||||||
const id = input.id != null ? String(input.id) : '?';
|
const id = input.id != null ? String(input.id) : '?';
|
||||||
|
|
@ -1285,6 +1387,15 @@ window.marked = marked;
|
||||||
: (c.content || '');
|
: (c.content || '');
|
||||||
const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null;
|
const sourceName = c.tool_use_id ? toolNameById.get(c.tool_use_id) : null;
|
||||||
const isMessageBearing = sourceName === 'mcp__hyperhive__recv';
|
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 trimmed = txt.replace(/\s+/g, ' ').trim();
|
||||||
const summaryBody = (() => {
|
const summaryBody = (() => {
|
||||||
if (!trimmed) return '(empty)';
|
if (!trimmed) return '(empty)';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue