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` `<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 .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-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 |
|
||||
|
|
@ -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
|
||||
`<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)
|
||||
|
||||
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-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
|
||||
|
|
|
|||
|
|
@ -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 `<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) {
|
||||
// Returns the <div> the side panel renders. The structural shape
|
||||
// mirrors the legacy <details>-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)';
|
||||
|
|
|
|||
Loading…
Reference in a new issue