web-ui: remove dead ask->operator inline-answer binding from the per-agent terminal

hyperhive#2922: the inline-answer slot (pendingAskBinds/reconcileAskBinds/
buildAnswerForm) depended on a since-removed /api/loose-ends endpoint
and had been silently non-functional the whole time — nothing ever
reassigned lastLooseEnds, so the reconciler always no-op'd.

Per mara's call on the issue (ask/answer is deprecated per #2850, only
the main dashboard UI needs to work, remove the broken per-agent inline
view): ripped out the dead JS (binding + form + CSS) rather than
restoring the endpoint. An ask tool call now renders like any other
tool call, no inline answer affordance; the operator answers via the
dashboard's own question surfacing.

Also fixed adjacent doc staleness this same removal made newly
contradictory (docs/web-ui/agent.md, docs/terminal-rendering.md):
the doc already described the loose-ends/bash-tasks flyouts and the
ask-binding as recently dead, but the endpoint reference table and a
header-pills bullet list still described them as live.
This commit is contained in:
iris 2026-08-11 17:59:27 +02:00 committed by mara
commit ecf9ff4d80
4 changed files with 37 additions and 288 deletions

View file

@ -59,7 +59,6 @@ parent's negative pull.
| `.tool-use` (flat) | `<icon> Name args…` | cyan | tool_use w/o rich renderer; `<icon>` from `toolIcon(name)`: 📤 send · 📥 recv · ❓ ask · ⏰ remind · 🏷️ set_status · 🪢 loose-ends · ✂️ cancel_loose_end · get_agent_meta · ✅ ack_until · 📜 get_logs/get_host_journal · ↻ restart · ⏹️ kill · ▶️ start · 🔄 update · 📋 list_containers/list_rooms/list_room_members/list_invites · 📖 read_room/Read · 👁️ mark_read · 🛑 bash kill · 🖥️ bash other · 💬 matrix send/reply/dm · 📦 request_* · ⏱️ schedule · 🔧 default | 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 | rich renderer |
| `.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 |
@ -211,28 +210,17 @@ This keeps less-frequent tools that don't have a specific
`fmtToolUse` case from dumping raw JSON. Common matrix and
hyperhive tools have their own cases and skip this path.
## Inline ask-operator answer
## Inline ask-operator answer (removed)
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 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.
An `mcp__hyperhive__ask(to: "operator", ...)` row used to mount an
inline answer form (`.ask-answer-inline-slot`, `reconcileAskBinds()`)
directly in the terminal scrollback. It depended on a since-removed
`/api/loose-ends` endpoint and had been silently dead since that
removal (hyperhive#2922) — ripped out rather than rebuilt (mara: only
the main dashboard's own question surfacing needs to work). An `ask`
tool call now renders like any other tool call, with no inline answer
affordance; the operator answers via the dashboard
(`dashboard/src/swarm.js` + `call.js`).
## Dashboard side (not covered here)

View file

@ -62,15 +62,9 @@ through. Three flex columns:
- **Right cluster** (`.agent-header-pills`): flyout pills + overflow.
- **Inbox pill** (`📬 inbox · N`): hidden when empty; click opens
the inbox flyout in the side panel.
- **Loose-ends pill** (`🪢 loose ends · N`): hidden when empty;
click opens the loose-ends flyout.
- **Tasks pill** (`⚙ tasks · N`): hidden when empty; click opens the
running-bash-tasks flyout (`GET /api/bash-tasks` — the in-flight
Pending/Running tasks from the in-container `bash-tasks/` dir). Unlike
loose-ends (refreshed on turn_end), tasks start + finish asynchronously
between turns and `/api/state` isn't polled while online, so the pill
polls the endpoint on a light interval (≈4s). Snapshot only (no SSE push
yet).
- **Todos pill** (`📋 todos · N`): hidden when empty; click opens
the todos flyout — see the todos-flyout paragraph below
(`GET /api/todos`, refreshed on cold load + every `turn_end`).
- **Overflow button** (`⋯`): always visible. Opens a frosted popover
(`#overflow-menu`, positioned outside the header to escape any
stacking context) with four management rows followed by a model
@ -176,39 +170,17 @@ Older per-agent flyouts this doc used to describe (a "loose-ends"
list of questions/approvals/reminders backed by a since-removed
`GET /api/loose-ends`, and a read-only "tasks" list of in-flight bash
tasks backed by a since-removed `GET /api/bash-tasks`) no longer
exist — todos superseded both. One open gap from that migration:
the "Ask → operator inline-answer binding" described below relies on
that removed `/api/loose-ends` endpoint and is currently non-functional
(hyperhive#2922) — the operator can still answer a pending question via
the main dashboard's own question surfacing, just not inline in this
agent's terminal.
**Ask → operator inline-answer binding** (currently non-functional,
see the todos-flyout paragraph above + hyperhive#2922 — described here
for the intended design, which is what a fix would restore). When the
agent emits `mcp__hyperhive__ask(to: "operator", ...)`, the tool_use
renderer mounts an empty slot (`<div class="ask-answer-inline-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 `POST /api/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.
exist — todos superseded both. A third casualty of that migration —
an "ask → operator" inline-answer form that used to mount under an
`mcp__hyperhive__ask(to: "operator", ...)` row in the terminal
scrollback — depended on the same removed `/api/loose-ends` endpoint
and was found dead (hyperhive#2922: the binding never fired, so the
slot never mounted a form). Per mara's call on that issue, it was
removed rather than rebuilt — the main dashboard's own question
surfacing (`dashboard/src/swarm.js` + `call.js`) is the one supported
path for answering a pending question as the operator; this agent's
own terminal just shows the `ask` tool call like any other tool call,
with no inline answer affordance.
## Live view
@ -302,8 +274,8 @@ Successful POSTs return 200 (no 303 redirects). Error responses
use semantic status codes: **400** for missing/invalid input
(`body` required, unknown model name, invalid effort level),
**409** for retryable state conflicts (turn in flight when
`/compact` is called, hive-c0re busy on `GET /api/loose-ends`),
**500** only for genuine server/transport failures. The matching
`/compact` is called), **500** only for genuine server/transport
failures. The matching
mutations fire `LiveEvent` variants on the per-agent bus, so the
client doesn't refetch `/api/state` on submit — the SSE stream
delivers the new state faster anyway. Only the login flow still
@ -371,13 +343,10 @@ shaped).
rate_limited, links }`. Only called when the container is running;
skipped (muted badges) when stopped. Also accessible via the gateway
at `/agent/<name>/api/dashboard-state`.
- `GET /api/loose-ends` — loose-ends snapshot consumed by the inbox
flyout (`renderLooseEnds`). Returns pending questions the agent asked
or owes, plus pending reminders. Also calls `reconcileAskBinds()` to
wire inline answer forms to open `question_asked` events.
- `GET /api/bash-tasks` — in-flight bash-task snapshot; returns only
`Pending` and `Running` tasks (completed/killed tasks are excluded).
Consumed by the running-bash-tasks flyout.
- `GET /api/todos` — harness-local todos snapshot (loose-ends v2)
consumed by the todos flyout (`refreshTodos` / `buildTodosList`).
See the todos-flyout paragraph above for the producer/subsystem
model and the bulk mark-done row.
- `GET /api/stats?window=24h|7d|30d|all` — time-bucketed turn analytics
`Snapshot` consumed by the `/stats` page. `all` ranges from the
earliest recorded turn with an adaptive bucket width.

View file

@ -560,112 +560,11 @@ pre.diff {
}
.agent-inbox .inbox-reply-tag { color: var(--muted); font-size: 0.85em; }
.agent-inbox .answer-form {
/* Block-level under the new layout `grid-column: 1 / -1` was
for the legacy grid; under block layout the form naturally
starts on its own row. */
display: flex;
gap: 0.4em;
align-items: flex-start;
margin-top: 0.5em;
padding-left: 0.8em;
}
.agent-inbox .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;
}
.agent-inbox .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;
}
.agent-inbox .answer-form button:hover:not(:disabled) {
border-color: var(--purple);
color: var(--purple);
}
.agent-inbox .answer-form button:disabled { opacity: 0.5; cursor: default; }
.agent-inbox .answer-status { color: var(--muted); align-self: center; }
/* 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. A
`[resolved]` tag (neutral, covers answered / cancelled /
TTL-expired uniformly) replaces the form once the question
leaves the pending list. */
.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);
}
/* Bulk-action header row: "mark all read" above the recent-messages
list in the inbox flyout, and "select all / select none / mark
done" above the list in the todos flyout same classes, shared
look (mauve hover, bg-elev background) so both read as part of
the same affordance family as the answer-form button. */
the same affordance family. */
.agent-inbox .inbox-mark-all-row {
display: flex;
gap: 0.6em;

View file

@ -54,7 +54,7 @@ window.marked = marked;
});
})();
// ─── side panel (singleton drawer for inbox + loose-ends flyouts) ──────
// ─── side panel (singleton drawer for inbox + todos flyouts) ───────────
// The shared `<hive-side-panel>` element (see @hive/shared/side-panel.js
// for the chrome/behavior it owns), created once, eagerly, when this
// module's IIFE runs (ES modules execute after the document is
@ -69,7 +69,7 @@ window.marked = marked;
// Wire the header pills to open the side panel. Pre-built (vs
// re-building per-click) so the freshest snapshot already lives
// in `lastInbox` / `lastLooseEnds` when the pill is clicked — even
// in `lastInbox` / `lastTodos` when the pill is clicked — even
// if it fires during a turn the render is the same.
(function bindHeaderPills() {
const inboxPill = $('inbox-pill');
@ -140,7 +140,7 @@ window.marked = marked;
// ↑ dashboard — host dashboard back-link. The dashboard SPA lives at
// `dashboard.html` (the `/` root now serves the H0M3 menu hub), so the
// link targets that file; `dashUrl` itself stays the API origin used
// for the rebuild / answer-question / mark-all-read POSTs below.
// for the rebuild / mark-all-read POSTs below.
menu.append(el('a', {
class: 'overflow-item overflow-item-dashboard',
href: dashUrl + 'dashboard.html',
@ -582,10 +582,6 @@ window.marked = marked;
return true;
case '/clear':
termAPI.clear();
// 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':
@ -805,17 +801,8 @@ window.marked = marked;
/** Latest snapshot kept in module state so the pill click handler
* has fresh data to render into the panel without re-fetching. */
let lastTodos = [];
let lastLooseEnds = [];
let lastInbox = [];
/** 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
* currently non-functional (its data source was the old loose-ends
* endpoint, since removed tracked on the forge), kept for when
* that's resolved rather than ripped out speculatively. */
const pendingAskBinds = [];
/** Bulk "mark done" row for the todos flyout: select all / select none
* + a mark-done button, disabled until at least one row is checked.
* POSTs the checked ids (comma-joined into one field, same shape as
@ -922,83 +909,8 @@ window.marked = marked;
buildTodosList(todos));
}
/** 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;
const pending = lastLooseEnds.filter(
(t) => t.kind === 'question' && (!t.target || t.target === 'operator'),
);
// 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);
}
const claimed = new Set();
for (const slot of pendingAskBinds) {
if (slot._boundId) claimed.add(slot._boundId);
}
for (const slot of pendingAskBinds) {
if (slot._boundId) {
const stillPending = pending.some((q) => q.id === slot._boundId);
if (!stillPending && !slot._resolvedShown) {
slot.replaceChildren();
slot.appendChild(el('span', { class: 'ask-resolved-tag' }, '[resolved]'));
slot._resolvedShown = true;
}
continue;
}
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.replaceChildren();
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.
// Only live call site today is the in-stream ask-row binder
// (`reconcileAskBinds`) — see its doc comment above for why that path
// is currently non-functional.
function buildAnswerForm(id) {
const wrap = el('div', { class: 'answer-form' });
const ta = el('textarea', { rows: '2', placeholder: 'answer as operator…' });
const btn = el('button', { type: 'button' }, 'send answer');
const status = el('span', { class: 'answer-status' });
btn.addEventListener('click', () => {
const answer = ta.value.trim();
if (!answer) { status.textContent = 'answer required'; return; }
if (!dashboardBase) { status.textContent = 'dashboard url unknown'; return; }
status.textContent = 'sending…';
asyncBtn(btn, async () => {
try {
const resp = await fetch(dashboardBase + 'api/answer-question/' + id, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'answer=' + encodeURIComponent(answer),
});
if (resp.ok) {
status.textContent = 'answered ✓';
} else {
status.textContent = 'failed: ' + (await resp.text());
}
} catch (err) {
status.textContent = 'failed: ' + err;
}
});
});
wrap.append(ta, btn, status);
return wrap;
}
/** "mark all read" affordance for the agent's inbox flyout
* see docs/web-ui.md::Per-agent page (Loose-ends flyout) for the
* see docs/web-ui.md::Per-agent page for the inbox flyout's
* cross-origin POST + count rendering. Returns a DOM row
* containing a button + an inline status pill; re-runs
* `onCleared` on success so the caller can refresh its own state. */
@ -1388,7 +1300,6 @@ window.marked = marked;
// mcp__hyperhive__send: collapsed <details>, full body text inside.
function renderRichToolUse(c, api) {
const name = c.name || '';
const input = c.input || {};
const icon = c._icon || '🔧';
// Message-bearing tools render default-open with a markdown body so
// the operator sees the content without an extra click. send / ask
@ -1402,25 +1313,7 @@ window.marked = marked;
return api.detailsDiff('tool-use', summary, c._body, icon);
}
if (c._body_type === 'markdown') {
const d = detailsOpenMd(api, 'tool-use', summary, c._body, icon);
// For ask → operator: mount an inline answer slot in the live terminal
// — see docs/web-ui.md::Per-agent page (Ask → operator inline-answer
// binding) for the slot registry + reconciler + [resolved] semantics.
// Body text and recipient are both available from backend fields.
if (name === 'mcp__hyperhive__ask') {
const to = (input.to || 'operator');
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.
slot._askQuestion = c._body;
d.appendChild(slot);
pendingAskBinds.push(slot);
if (api.fromHistory) reconcileAskBinds();
}
}
return d;
return detailsOpenMd(api, 'tool-use', summary, c._body, icon);
}
return api.details('tool-use', summary, c._body, icon);
}