diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index 5f46df80..ff51a5d9 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -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 (`
`) +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 (`
`) 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 diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index b53ecaeb..33e0af8c 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -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
the side panel renders. The structural shape - // mirrors the legacy
-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 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…' });