docs/agent.md: fix stale 3-flyout description; drop dead buildLooseEndsList

docs/web-ui/agent.md still described a "loose-ends" and "tasks" flyout
that were superseded by the todos flyout when loose-ends-v2 landed —
GET /api/loose-ends and GET /api/bash-tasks are both gone server-side.
Replaced with an accurate description of the todos flyout (including
the mark-done bulk action from #2919), and noted that the ask->operator
inline-answer binding this doc also describes is currently
non-functional (its data source was the same removed endpoint) —
tracked separately as #2922, not fixed here.

buildLooseEndsList in app.js rendered the old loose-ends flyout and had
zero call sites left; removed it. buildAnswerForm stays — reconcileAskBinds
still calls it, even though that path is currently dead per #2922.

Fixes #2920
This commit is contained in:
iris 2026-08-01 21:08:08 +02:00 committed by mara
commit 94fb42f2b6
2 changed files with 42 additions and 96 deletions

View file

@ -147,34 +147,42 @@ newlines, Tab-completes slash commands (see "Terminal-embedded
prompt" below).
**Side panel** (slide-in from right): singleton shared with the
dashboard's side panel shape. Carries inbox and loose-ends flyouts
(opened via the header pills) as well as long content (file previews,
diffs, journald logs). Inbox flyout: unread messages addressed to
this agent (`acked_at IS NULL`, newest-first, up to 30); reply
messages indented with `↳ reply ·` in amber. A `✓ mark all read`
button appears in the flyout header when the inbox is non-empty;
clicking it confirms then POSTs cross-origin to the core
dashboard's `POST /api/agent/{name}/mark-all-read` — all pending
messages for this agent are acked, the harness won't receive
wake-prompts for them. A `{ marked: N }` pill surfaces the count.
After the drain the inbox list empties on reload (the filter is
`acked_at IS NULL`, so drained messages disappear). Loose-ends flyout: questions,
approvals, reminders, pending inbox messages, and unread matrix
notifications pending against this agent (`GET /api/loose-ends`);
question rows carry an inline answer form that POSTs cross-origin to
the core dashboard's `POST /api/answer-question/{id}` so the operator answers
*as operator* (see `docs/boundary.md`). Pending inbox messages (`✉ inbox
— N pending message(s)`) and unread matrix rooms appear informational
only (not cancellable from the flyout — drain with `recv` / `mark_read`
in-turn). Tasks flyout: in-flight bash
tasks (`GET /api/bash-tasks`); each row shows status (`▶ running` /
`◷ queued`), the task id, elapsed time, and a truncated one-line
command preview. Read-only — kill/inspect lives in the harness, not
the page.
dashboard's side panel shape. Carries inbox and todos flyouts (opened
via the header pills) as well as long content (file previews, diffs,
journald logs). Inbox flyout: unread messages addressed to this agent
(`acked_at IS NULL`, newest-first, up to 30); reply messages indented
with `↳ reply ·` in amber. A `✓ mark all read` button appears in the
flyout header when the inbox is non-empty; clicking it confirms then
POSTs cross-origin to the core dashboard's `POST
/api/agent/{name}/mark-all-read` — all pending messages for this agent
are acked, the harness won't receive wake-prompts for them. A `{
marked: N }` pill surfaces the count. After the drain the inbox list
empties on reload (the filter is `acked_at IS NULL`, so drained
messages disappear). Todos flyout ("loose-ends v2"): the harness-local
todos other subsystems (matrix, forge, bash) push at this agent
(`GET /api/todos`) — each row shows the producing subsystem, an
optional source label, a summary, and age. A checkbox per row plus a
select-all / select-none / `✓ mark done` bulk row above the list POSTs
the checked ids to `POST /api/todos/mark-done`, which dismisses them
from the harness-local store (same effect as `cancel_loose_end(kind:
"todo")`, just from the web UI instead of the agent's own tool calls).
**Ask → operator inline-answer binding.** When the agent emits
`mcp__hyperhive__ask(to: "operator", ...)`, the tool_use renderer
mounts an empty slot (`<div class="ask-answer-inline-slot">`)
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

View file

@ -792,75 +792,12 @@ window.marked = marked;
/** 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. */
* 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 = [];
function buildLooseEndsList(threads) {
// Returns the <div> the side panel renders. The structural shape
// mirrors the legacy <details>-collapsible block — same CSS rules
// apply via `hive-side-panel .agent-inbox`.
const wrap = el('div', { class: 'agent-inbox' });
if (!threads.length) {
wrap.append(el('p', { class: 'side-panel-empty' },
'no loose ends — every question, approval and reminder is resolved.'));
return wrap;
}
const list = el('ul');
const fmtAge = (s) => {
if (s < 60) return s + 's';
if (s < 3600) return Math.floor(s / 60) + 'm';
if (s < 86400) return Math.floor(s / 3600) + 'h';
return Math.floor(s / 86400) + 'd';
};
for (const t of threads) {
const li = el('li');
if (t.kind === 'approval') {
li.append(
el('span', { class: 'inbox-from' }, '◇ approval #' + t.id), ' ',
el('span', { class: 'inbox-sep' }, t.agent + ' @ ' + (t.commit_ref || '').slice(0, 12)), ' ',
el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'),
);
if (t.description) {
li.append(el('div', { class: 'inbox-body' }, t.description));
}
} else if (t.kind === 'question') {
const target = t.target || 'operator';
li.append(
el('span', { class: 'inbox-from' }, '? #' + t.id), ' ',
el('span', { class: 'inbox-sep' }, t.asker + ' → ' + target), ' ',
el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'),
el('div', { class: 'inbox-body' }, t.question || ''),
buildAnswerForm(t.id),
);
} else if (t.kind === 'reminder') {
// due_at arrives as an ISO 8601 string (DateTime<Utc> on the wire).
// Parse it to unix seconds before arithmetic — doing `t.due_at - now`
// directly yields NaN because a string minus a number is NaN in JS.
const now = Math.floor(Date.now() / 1000);
const dueAtSec = t.due_at ? Math.floor(new Date(t.due_at).getTime() / 1000) : 0;
const dueIn = dueAtSec - now;
const dueLabel = dueIn >= 0 ? 'in ' + fmtAge(dueIn) : fmtAge(-dueIn) + ' overdue';
li.append(
el('span', { class: 'inbox-from' }, '⏰ reminder #' + t.id), ' ',
el('span', { class: 'inbox-sep' }, t.owner + ' · due ' + dueLabel), ' ',
el('span', { class: 'inbox-ts' }, 'scheduled ' + fmtAge(t.age_seconds || 0) + ' ago'),
el('div', { class: 'inbox-body' }, t.message || ''),
);
} else if (t.kind === 'pending_messages') {
li.append(
el('span', { class: 'inbox-from' }, '✉ inbox'), ' ',
el('span', { class: 'inbox-sep' }, (t.count || 0) + ' pending message(s)'), ' ',
el('span', { class: 'inbox-ts' }, 'drain with recv'),
);
} else {
li.append(el('span', { class: 'inbox-body' }, JSON.stringify(t)));
}
list.append(li);
}
wrap.append(list);
return wrap;
}
/** 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
@ -1008,8 +945,9 @@ window.marked = marked;
// 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`).
// 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…' });