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). prompt" below).
**Side panel** (slide-in from right): singleton shared with the **Side panel** (slide-in from right): singleton shared with the
dashboard's side panel shape. Carries inbox and loose-ends flyouts dashboard's side panel shape. Carries inbox and todos flyouts (opened
(opened via the header pills) as well as long content (file previews, via the header pills) as well as long content (file previews, diffs,
diffs, journald logs). Inbox flyout: unread messages addressed to journald logs). Inbox flyout: unread messages addressed to this agent
this agent (`acked_at IS NULL`, newest-first, up to 30); reply (`acked_at IS NULL`, newest-first, up to 30); reply messages indented
messages indented with `↳ reply ·` in amber. A `✓ mark all read` with `↳ reply ·` in amber. A `✓ mark all read` button appears in the
button appears in the flyout header when the inbox is non-empty; flyout header when the inbox is non-empty; clicking it confirms then
clicking it confirms then POSTs cross-origin to the core POSTs cross-origin to the core dashboard's `POST
dashboard's `POST /api/agent/{name}/mark-all-read` — all pending /api/agent/{name}/mark-all-read` — all pending messages for this agent
messages for this agent are acked, the harness won't receive are acked, the harness won't receive wake-prompts for them. A `{
wake-prompts for them. A `{ marked: N }` pill surfaces the count. marked: N }` pill surfaces the count. After the drain the inbox list
After the drain the inbox list empties on reload (the filter is empties on reload (the filter is `acked_at IS NULL`, so drained
`acked_at IS NULL`, so drained messages disappear). Loose-ends flyout: questions, messages disappear). Todos flyout ("loose-ends v2"): the harness-local
approvals, reminders, pending inbox messages, and unread matrix todos other subsystems (matrix, forge, bash) push at this agent
notifications pending against this agent (`GET /api/loose-ends`); (`GET /api/todos`) — each row shows the producing subsystem, an
question rows carry an inline answer form that POSTs cross-origin to optional source label, a summary, and age. A checkbox per row plus a
the core dashboard's `POST /api/answer-question/{id}` so the operator answers select-all / select-none / `✓ mark done` bulk row above the list POSTs
*as operator* (see `docs/boundary.md`). Pending inbox messages (`✉ inbox the checked ids to `POST /api/todos/mark-done`, which dismisses them
— N pending message(s)`) and unread matrix rooms appear informational from the harness-local store (same effect as `cancel_loose_end(kind:
only (not cancellable from the flyout — drain with `recv` / `mark_read` "todo")`, just from the web UI instead of the agent's own tool calls).
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.
**Ask → operator inline-answer binding.** When the agent emits Older per-agent flyouts this doc used to describe (a "loose-ends"
`mcp__hyperhive__ask(to: "operator", ...)`, the tool_use renderer list of questions/approvals/reminders backed by a since-removed
mounts an empty slot (`<div class="ask-answer-inline-slot">`) `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 right under the `↳ ask → operator` row in the terminal scrollback
and pushes a reference into `pendingAskBinds`. The broker assigns and pushes a reference into `pendingAskBinds`. The broker assigns
the question id asynchronously, so the slot waits — and the next 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 /** ask operator inline-answer slot registry. See
* docs/web-ui.md::Per-agent page (Ask operator inline-answer * 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 = []; 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 /** Bulk "mark done" row for the todos flyout: select all / select none
* + a mark-done button, disabled until at least one row is checked. * + a mark-done button, disabled until at least one row is checked.
* POSTs the checked ids (comma-joined into one field, same shape as * 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 // 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 // Only live call site today is the in-stream ask-row binder
// the in-stream ask-row binder (`reconcileAskBinds`). // (`reconcileAskBinds`) — see its doc comment above for why that path
// is currently non-functional.
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…' });