From ff0a6660aa5b55ac08b3b7c22af201b46a15bdbd Mon Sep 17 00:00:00 2001 From: damocles Date: Sat, 29 Aug 2026 20:32:34 +0200 Subject: [PATCH] dashboard: remove the Y3R C4LL questions pane (ask/answer removal, frontend) --- docs/terminal-rendering.md | 7 +- docs/web-ui/README.md | 10 +- docs/web-ui/agent.md | 7 +- docs/web-ui/dashboard.md | 74 +--- docs/web-ui/shape.md | 15 +- frontend/packages/dashboard/src/call.js | 414 +----------------- frontend/packages/dashboard/src/common.js | 7 +- frontend/packages/dashboard/src/dashboard.css | 108 ----- .../packages/dashboard/src/dashboard.html | 13 +- frontend/packages/dashboard/src/settings.html | 2 +- frontend/packages/dashboard/src/settings.js | 2 +- frontend/packages/dashboard/src/state.js | 10 - frontend/packages/dashboard/src/swarm.js | 36 +- frontend/packages/dashboard/src/tabs.js | 45 +- frontend/packages/dashboard/src/util.js | 2 +- .../packages/shared/src/terminal/terminal.js | 2 +- 16 files changed, 74 insertions(+), 680 deletions(-) diff --git a/docs/terminal-rendering.md b/docs/terminal-rendering.md index 873e27b4..f1da4c63 100644 --- a/docs/terminal-rendering.md +++ b/docs/terminal-rendering.md @@ -246,9 +246,10 @@ hyperhive tools have their own cases and skip this path. An `mcp__hyperhive__ask(to: "operator", ...)` row has no inline answer form in this terminal — it renders like any other tool call. -The operator answers a pending question from the main dashboard's -own question surfacing (`dashboard/src/swarm.js` + `call.js`, the -Y3R C4LL tab), not from the per-agent page. +The dashboard's own question-surfacing UI that used to let the +operator answer from the Y3R C4LL tab is gone (hyperhive#3721); there +is currently no UI path to answer an operator-targeted `ask()` at +all. ## Dashboard side (not covered here) diff --git a/docs/web-ui/README.md b/docs/web-ui/README.md index 98aac13f..12201218 100644 --- a/docs/web-ui/README.md +++ b/docs/web-ui/README.md @@ -19,8 +19,8 @@ of your time. It's a single page with exactly four tabs: - **SW4RM** — every agent, live. This is the default tab and the one you'll check most. -- **Y3R C4LL** — anything waiting on *you*: pending approvals and - agent questions. If an agent needs a decision from you, it's here. +- **Y3R C4LL** — anything waiting on *you*: pending approvals. If an + agent needs a decision from you, it's here. - **P3RM1SS10NS** — what tools and system-level access each agent has. - **SCH3DUL3S** — scheduled prompts and agent self-reminders. @@ -46,10 +46,10 @@ whether it's running, what it's currently doing (a live status pill — and quick links (stats, screen, forge profile). Click the name to open its terminal and watch it work in real time. -**Answer something an agent is waiting on.** Y3R C4LL is the one tab +**Approve something an agent is waiting on.** Y3R C4LL is the one tab worth checking regularly — it's everything that needs *you*: approvals -for config changes, and questions an agent has asked and is blocked on. -The tab's count pill tells you at a glance if anything's pending. +for config changes. The tab's count pill tells you at a glance if +anything's pending. **Approve or reject a config change.** Agent config changes (new packages, env vars, MCP servers) go through an approval queue rather diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index af065280..c7654775 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -172,9 +172,12 @@ from the harness-local store (same effect as `cancel_loose_end(kind: The todos flyout is the only per-agent flyout — there is no separate "loose-ends" or "tasks" list. There is also no inline answer form for `ask` tool calls in this terminal: an `ask` renders like any other -tool call, and the operator answers a pending question from the -dashboard's Y3R C4LL tab instead (see +tool call (see [`terminal-rendering.md`](../terminal-rendering.md#inline-ask-operator-answer)). +The dashboard's own Y3R C4LL questions pane that used to let the +operator answer from there is gone (hyperhive#3721) — an +operator-targeted `ask()` currently has no UI path to an answer at +all until the removal finishes or a replacement lands. ## Live view diff --git a/docs/web-ui/dashboard.md b/docs/web-ui/dashboard.md index d23fc9c7..835c9853 100644 --- a/docs/web-ui/dashboard.md +++ b/docs/web-ui/dashboard.md @@ -20,7 +20,7 @@ from the dashboard tab strip. standalone **C0R3** page (`/core.html`), and FL0W / L0GS / ST4TS / S3TT1NGS / M4TR1X live on their own pages too, all reachable from the H0M3 hub (not the tab strip). Count pills on SW4RM - (container count), Y3R C4LL (pending approvals + questions + unread + (container count), Y3R C4LL (pending approvals + unread operator messages), and SCH3DUL3S (active schedules); S3TT1NGS has no count. - **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`) @@ -43,14 +43,13 @@ from the dashboard tab strip. snapshot. Hidden when there are no warnings. - **Browser tab title** — `hyperhive // h1ve-c0re` by default; updated to ` / // h1ve-c0re` once `hive_name` / `swarm_name` - arrive in the state snapshot. When there are pending approvals or - unanswered questions, a `(N)` prefix is prepended — `(3) pr1ma // - h1ve-c0re` — so the operator can see the call count in an unfocused - browser tab without opening the dashboard. The prefix is set on the - initial `/api/state` cold-load and updated live by - `approval_added` / `approval_resolved` / `question_added` / - `question_resolved` SSE events; it's preserved when - `hive_name` / `swarm_name` later replace the raw title. + arrive in the state snapshot. When there are pending approvals, a + `(N)` prefix is prepended — `(3) pr1ma // h1ve-c0re` — so the + operator can see the call count in an unfocused browser tab without + opening the dashboard. The prefix is set on the initial `/api/state` + cold-load and updated live by `approval_added` / `approval_resolved` + SSE events; it's preserved when `hive_name` / `swarm_name` later + replace the raw title. The FL0W and L0GS pages use a slim header (a `← home` back-link + the page title) rather than the dashboard tab strip — they're standalone @@ -82,9 +81,7 @@ agent is stale. ## Y3R C4LL tab -Things blocked on operator decision — approvals and questions -share a tab because they're the same concept ("something is -waiting on you"). +Things blocked on operator decision — the approval queue. **P3NDING APPR0VALS** — the queue (see "Approval card" below). The R3QU3ST SP4WN form lives at the top of this section. A @@ -92,31 +89,6 @@ The R3QU3ST SP4WN form lives at the top of this section. A queue and the last 30 resolved approvals (see "Approval card" for the history row shape). -**M1ND H4S QU3STI0NS** — pending `ask` calls waiting on the -operator, with amber pulsing border. Anatomy of each card: - -- **Filter chips** — `all · N`, `@operator · N`, `@peer · N`, - plus one chip per participant name (`@asker · N` / `@target · N`). - Every chip shows its own count so the operator can see the - distribution at a glance. Clicking a chip narrows the visible - list; selection persists in localStorage so a tab switch doesn't - lose the filter. -- **Question card** — timestamp · asker → target · body text - (with file-path links). Operator-targeted questions (`target = - null`) show `▸ ANSW3R`; peer-targeted questions (`target = - agent`) show `⤿ 0V3RR1D3` so the operator can unblock an - agent-to-agent exchange. Questions with a `ttl_seconds` show a - `⏳ MM:SS` live countdown chip; the host-side watchdog resolves - with `answerer = "ttl-watchdog"` on expiry. -- **Answer form** — free-text textarea (Enter = submit, - Shift+Enter = newline) + optional option list (radio for - single-select, checkboxes for `multi=true`). Submit merges - selected options + free text comma-joined into a single - `answer` field. `✗ CANC3L` is a separate form so the submit - merge handler doesn't interfere. -- **◆ answ3red (N)** — collapsible `
` below the pending - list; shows the last 20 resolved questions with their answers. - **0PER4T0R 1NB0X** — messages agents have sent to `to="operator"` but the operator hasn't read yet. Cold-loaded from `/api/operator-inbox` on tab activation + page load; appended live from the broker `sent` stream @@ -570,8 +542,7 @@ The `next` column cell (`.sched-due`) carries a `data-due-at` Unix timestamp attribute; a shared 1s ticker rewrites it in-place showing `fmtDuration` while in the future and `overdue X ago` once the fire time has passed — same -zero-re-render pattern as the reminder due-at labels and the -question TTL chip. +zero-re-render pattern as the reminder due-at labels. The table's last row is a permanent inline creation row: inputs live directly in table cells (targets as checkboxes, @@ -1156,10 +1127,9 @@ between the live queue and the last 30 resolved approvals. ## Browser notifications -Pure frontend (`Notification` API). Three signals trigger them: +Pure frontend (`Notification` API). Two signals trigger them: - new pending approval (per id, delta on `/api/state`) -- new pending operator question (per id) - new broker message sent `to: "operator"` (live via SSE) The toggle controls live in the `S3TT1NGS` tab (`#settings`); see @@ -1168,7 +1138,7 @@ in `common.js::NOTIF`. First `/api/state` after page load seeds "seen" sets without firing — only items that arrive while the page is open count. -Per-event tags (`hyperhive:approval:`, `hyperhive:question:`, +Per-event tags (`hyperhive:approval:`, `hyperhive:msg::`) so distinct events stack in the OS notification center instead of overwriting each other. `console.debug` logs at every block point (unsupported, @@ -1203,8 +1173,12 @@ below — some endpoints aren't in it yet. `destroy` accepts `purge=on` to also wipe state dirs. - `POST /api/purge-tombstone/{name}` — wipe a tombstone's state dirs. - `POST /api/answer-question/{id}` — answer a pending operator question. + No dashboard UI calls this any more (the questions pane is gone — + see hyperhive#3721); kept as long as the backend `ask`/`answer` + mechanism itself still exists. - `POST /api/cancel-question/{id}` — cancel a pending question with - the sentinel `[cancelled]`. Same code path as a real answer. + the sentinel `[cancelled]`. Same code path as a real answer. Same + no-UI-consumer note as above. - `POST /api/request-spawn` — queue a Spawn approval. - `POST /api/update-all` — rebuild every stale container. - `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry. @@ -1411,14 +1385,10 @@ payload): re-renders only the approvals section. - `question_added` (id, asker, question, options, multi, asked_at, deadline_at, target) / `question_resolved` (id, - answer, answerer, answered_at, cancelled, target) — both - operator-targeted and peer (agent-to-agent) threads fire - these. The dashboard's questions pane surfaces both, with - filter chips (all / @operator / @peer / per-participant) and - an `0V3RR1D3` button on peer rows so the operator can - answer when an agent is stuck. The ttl watchdog fires - `question_resolved` with `answerer = "ttl-watchdog"` on - expiry. + answer, answerer, answered_at, cancelled, target) — still + fired by the backend `ask`/`answer` mechanism, but the + dashboard no longer subscribes to either kind: the questions + pane that used to surface them is gone (hyperhive#3721). - `transient_set` (name, transient_kind, since_unix) / `transient_cleared` (name) — lifecycle action spinners. The client ticks the elapsed-seconds badge off `since_unix` @@ -1488,7 +1458,7 @@ payload): `/api/state` is **only fetched on cold-load and on the few forms that mutate non-event-derived state** (PURG3 + meta-update, since tombstones + meta_inputs aren't event- -shaped yet). Every other section — approvals, questions, +shaped yet). Every other section — approvals, transients, containers, operator inbox, message flow — derives from `/api/dashboard/stream` after the initial snapshot, maintaining its own client-side store and applying events on diff --git a/docs/web-ui/shape.md b/docs/web-ui/shape.md index 33372e44..3a12b496 100644 --- a/docs/web-ui/shape.md +++ b/docs/web-ui/shape.md @@ -51,8 +51,7 @@ dashboard stream carries broker `Sent` / `Delivered` (mirrored by a forwarder task from the broker's intra-process channel) plus mutation events (`approval_added` / `approval_resolved`, - `question_added` / `question_resolved`, `transient_set` / - `transient_cleared`). Each frame carries a `seq`. The + `transient_set` / `transient_cleared`). Each frame carries a `seq`. The matching backfill endpoint is `GET /api/dashboard/history` (last ~200 broker messages wrapped in `{ seq, events }`) on the dashboard and `GET /events/history` (last 2000 `LiveEvent`s @@ -233,8 +232,7 @@ where the fingerprint is `JSON.stringify({...visible fields...})`. On each render: cache-hit rows are reused verbatim (preserving textarea draft, checkbox state, and event listeners); only cache-miss rows are rebuilt and inserted. Used for: containers (`containerRowCache`), -rebuild-queue entries (`rebuildQueueRowCache`), and question rows -(`questionRowCache`). The spawn-form input+focus and meta-input +rebuild-queue entries (`rebuildQueueRowCache`). The spawn-form input+focus and meta-input checkboxes use a lighter snapshot-then-restore pattern (snapshot before `replaceChildren`, restore after) since they are single values rather than per-row caches. @@ -244,9 +242,12 @@ tagged with `data-restore-key=""` survives the refresh. `snapshotOpenDetails()` walks managed sections before render, `restoreOpenDetails()` re-applies after. Long-content drill-ins (file previews, diffs, journald logs) now open in the -**side panel** (see below) rather than expanding inline, so the -only restore-keyed `
` left is the answered-questions -history list. +**side panel** (see below) rather than expanding inline. The +dashboard's own answered-questions history list was the only +`
` consumer and is gone (hyperhive#3721) +— `snapshotOpenDetails()` / `restoreOpenDetails()` currently have +nothing to restore, kept in place for the next collapsible section +that needs it rather than ripped out along with their one caller. **Side panel (dashboard):** long content opens in a drawer that swipes in from the right — a singleton `#side-panel` with a diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js index 3ad1f7c1..c0de1cec 100644 --- a/frontend/packages/dashboard/src/call.js +++ b/frontend/packages/dashboard/src/call.js @@ -1,12 +1,8 @@ // Y3R C4LL domain — the dashboard pane for things blocked on an operator -// decision: approvals, the operator's question queue, and the operator -// inbox (messages agents sent to `to="operator"`). Lifted out of the -// dashboard entry (tabs.js) into its own module so the entry stays a thin -// coordinator; mirrors the permissions.js / schedules.js extractions. -// -// State the tab owns lives here (module-local); the one genuinely -// cross-domain store, `questionsState`, lives in state.js because the -// SW4RM container rows read it too. +// decision: approvals and the operator inbox (messages agents sent to +// `to="operator"`). Lifted out of the dashboard entry (tabs.js) into its +// own module so the entry stays a thin coordinator; mirrors the +// permissions.js / schedules.js extractions. // // Tab-count wiring: like schedules.js this module does not call the // coordinator's `refreshTabCounts` directly (that would be a circular @@ -16,24 +12,14 @@ import { $, form, appendLinkified } from './common.js'; import { el } from '@hive/shared/dom.js'; -import { themedToast } from '@hive/shared/modal.js'; import { epochSec, fmtAgo, fmtDuration } from './util.js'; -import { questionsState, QUESTION_HISTORY_LIMIT } from './state.js'; // Registered by the dashboard entry at boot; defaults to a no-op so the // module is safe to call before wiring. let onCountsChanged = () => {}; -// Re-render the SW4RM container rows (their per-agent question-count badges -// read questionsState). `renderContainersFromState` is a tabs.js closure-local -// — not in this module's scope — so the entry injects it here via `initCall`, -// same pattern as `onCountsChanged`. Referencing it directly threw -// `ReferenceError: renderContainersFromState is not defined` and aborted the -// `question_added` / `question_resolved` SSE handlers. -let onContainersDirty = () => {}; export function initCall(opts = {}) { if (typeof opts.onCountsChanged === 'function') onCountsChanged = opts.onCountsChanged; - if (typeof opts.onContainersDirty === 'function') onContainersDirty = opts.onContainersDirty; } // ─── operator inbox — unread agent→operator messages ──────────── @@ -378,395 +364,3 @@ function renderApprovalHistory(root, history) { } root.append(ul); } - -// ─── questions — the operator question queue (Y3R C4LL) ─────────────────── -// questionsState + QUESTION_HISTORY_LIMIT are imported from state.js (shared -// with the SW4RM per-agent question-count badges). -// Keyed row cache: question id → {el, fingerprint}. Allows renderQuestions -// to reuse
  • elements whose state hasn't changed. The main benefit is -// preserving textarea draft text and radio/checkbox selections when an -// unrelated question arrives while the operator is composing a reply. -const questionRowCache = new Map(); -export function activeQuestionCount() { return questionsState.pending.length; } -export function syncQuestionsFromSnapshot(s) { - questionsState.pending = (s.questions || []).slice(); - questionsState.history = (s.question_history || []).slice(); -} -export function applyQuestionAdded(ev) { - if (questionsState.pending.some((q) => q.id === ev.id)) return; - questionsState.pending.push({ - id: ev.id, - asker: ev.asker, - question: ev.question, - options: ev.options || [], - multi: !!ev.multi, - asked_at: ev.asked_at, - deadline_at: ev.deadline_at ?? null, - target: ev.target || null, - question_refs: ev.question_refs || [], - }); - renderQuestions(); - onContainersDirty(); -} -export function applyQuestionResolved(ev) { - const idx = questionsState.pending.findIndex((q) => q.id === ev.id); - const existing = idx >= 0 ? questionsState.pending[idx] : null; - if (idx >= 0) questionsState.pending.splice(idx, 1); - // Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can - // carry this same answered row in `question_history` while a live - // event also delivers it — guard the unshift so history can't - // double a row. - if (!questionsState.history.some((h) => h.id === ev.id)) { - questionsState.history.unshift({ - id: ev.id, - asker: existing?.asker || '?', - question: existing?.question || '', - options: existing?.options || [], - multi: existing?.multi || false, - asked_at: existing?.asked_at || ev.answered_at, - answered_at: ev.answered_at, - answer: ev.answer, - answerer: ev.answerer, - target: existing?.target ?? ev.target ?? null, - question_refs: existing?.question_refs || [], - answer_refs: ev.answer_refs || [], - }); - if (questionsState.history.length > QUESTION_HISTORY_LIMIT) { - questionsState.history.length = QUESTION_HISTORY_LIMIT; - } - } - renderQuestions(); - onContainersDirty(); -} -// Filter selection for the questions section. Persisted so the -// operator's preferred view (all / operator-targeted / peer) -// survives a reload. -const QUESTIONS_FILTER_KEY = 'hyperhive:questions:filter'; -function getQuestionsFilter() { - return localStorage.getItem(QUESTIONS_FILTER_KEY) || 'all'; -} -function setQuestionsFilter(v) { - localStorage.setItem(QUESTIONS_FILTER_KEY, v); - renderQuestions(); -} -function questionMatchesFilter(q, filter) { - if (filter === 'all') return true; - if (filter === 'operator') return !q.target; - if (filter === 'peer') return !!q.target; - // `agent:` matches when the agent appears as asker OR target. - if (filter.startsWith('agent:')) { - const name = filter.slice('agent:'.length); - return q.asker === name || q.target === name; - } - return true; -} -// Serialise the fields that determine a pending-question
  • 's DOM -// structure. Used by questionRowCache to skip rebuilds when nothing -// visible has changed. deadline_at controls whether the TTL chip node -// exists at all (its text is kept current by the global 1s ticker). -function questionRowFingerprint(q) { - return JSON.stringify({ - asker: q.asker, target: q.target, asked_at: q.asked_at, - deadline_at: q.deadline_at, question: q.question, - question_refs: q.question_refs, options: q.options, multi: q.multi, - }); -} - -// Build a single pending-question
  • . Extracted from renderQuestions so -// questionRowCache can reuse unchanged nodes without re-running this body. -// Event listeners attached here (keydown on textarea, submit on form) are -// preserved in the reused node — no re-attachment needed. -function buildQuestionLi(q) { - const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19); - const targetLabel = q.target || 'operator'; - const li = el('li', { class: 'question' + (q.target ? ' question-peer' : '') }); - const head = el('div', { class: 'q-head' }, - el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', - el('span', { class: 'msg-from' }, q.asker), ' ', - el('span', { class: 'msg-sep' }, '→'), ' ', - el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', - el('span', { class: 'msg-sep' }, 'asks:'), - ); - if (q.deadline_at) { - // Tag the chip with its deadline so the global 1s ticker - // can refresh the text without re-rendering the questions section. - const ttlEl = el('span', { - class: 'q-ttl', 'data-deadline': String(epochSec(q.deadline_at)), - }); - ttlEl.textContent = formatTtl( - epochSec(q.deadline_at) - Math.floor(Date.now() / 1000), - ); - head.append(' ', ttlEl); - } - const qBody = el('div', { class: 'q-body' }); - appendLinkified(qBody, q.question, q.question_refs); - li.append(head, qBody); - const f = el('form', { - method: 'POST', action: '/api/answer-question/' + q.id, - class: 'qform', 'data-async': '', 'data-no-refresh': '', - }); - const hasOptions = q.options && q.options.length; - const isMulti = !!q.multi && hasOptions; - const freeText = el('textarea', { - name: 'answer-free', rows: '2', autocomplete: 'off', - placeholder: (hasOptions ? 'or type your own…' : 'your answer') - + ' (shift+enter for newline)', - }); - // Enter submits; shift+enter inserts a newline (textarea default). - freeText.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - f.requestSubmit(); - } - }); - const optionGroup = el('div', { class: 'q-options' }); - if (hasOptions) { - for (const opt of q.options) { - const inputType = isMulti ? 'checkbox' : 'radio'; - const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); - const input = el('input', { type: inputType, name: 'choice', value: opt, id }); - const label = el('label', { for: id }, ' ' + opt); - optionGroup.append(el('div', { class: 'q-option' }, input, label)); - } - } - // On submit, build the final `answer` field from selected - // options + free-text, joined by ', '. This lets the operator - // pick options AND add free text in the same form. - f.addEventListener('submit', (ev) => { - const parts = []; - for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { - parts.push(cb.value); - } - const ft = (freeText.value || '').trim(); - if (ft) parts.push(ft); - const merged = parts.join(', '); - // Replace the existing hidden `answer` (if any) with the merged value. - const existing = f.querySelector('input[name="answer"]'); - if (existing) existing.remove(); - f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); - if (!merged) { ev.preventDefault(); themedToast('pick an option or type an answer', { type: 'error' }); } - }, true); - if (hasOptions) f.append(optionGroup); - const buttons = el('div', { class: 'q-buttons' }); - // On peer threads the operator's answer is an override — - // mark the button so it's clear what the click does (the - // backend permits it via OperatorQuestions::answer's - // answerer-auth rule). - const answerLabel = q.target - ? (isMulti ? '⤿ 0V3RR1D3 · ' + q.options.length + ' opts' : '⤿ 0V3RR1D3') - : (isMulti ? '▸ ANSW3R · ' + q.options.length + ' opts' : '▸ ANSW3R'); - buttons.append( - el('button', { - type: 'submit', - class: 'btn btn-approve' + (q.target ? ' btn-override' : ''), - title: q.target ? `override-answer on behalf of operator (target was ${q.target})` : '', - }, answerLabel), - ); - f.append( - el('div', { class: 'q-free' }, freeText), - buttons, - ); - li.append(f); - // Separate form so the cancel button doesn't get the answer - // merge-on-submit handler attached to the main form. - const cancelTargetLabel = q.target ? q.target : 'asker'; - const cancelForm = el('form', { - method: 'POST', action: '/api/cancel-question/' + q.id, - class: 'qform-cancel', 'data-async': '', 'data-no-refresh': '', - 'data-confirm': `cancel this question? ${cancelTargetLabel} will see ` - + '"[cancelled]" as the answer.', - }); - cancelForm.append( - el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'), - ); - li.append(cancelForm); - return li; -} - -// Snapshot / restore open `
    ` state across a re-render, scoped to -// one section root. renderQuestions only manages `#questions-section`, so it -// keeps its own section-local pair rather than reaching into tabs.js's -// `MANAGED_SECTION_IDS`-based helpers (which aren't in this module's scope — -// referencing them threw `ReferenceError: snapshotOpenDetails is not defined` -// and aborted refreshState). Sections that should survive a refresh carry a -// stable `data-restore-key`. -function snapshotOpenDetails(root) { - const open = new Set(); - for (const d of root.querySelectorAll('details[data-restore-key]')) { - if (d.open) open.add(d.dataset.restoreKey); - } - return open; -} -function restoreOpenDetails(root, open) { - if (!open.size) return; - for (const d of root.querySelectorAll('details[data-restore-key]')) { - if (open.has(d.dataset.restoreKey)) d.open = true; - } -} - -export function renderQuestions() { - const root = $('questions-section'); - // #questions-section only lives on /dashboard.html (Y3R C4LL tab); - // no-op when the section is missing. `question_added` / - // `question_resolved` SSE events route through here. - if (!root) return; - // Snapshot open
    state so SSE-triggered re-renders restore - // any expanded sections. The keyed-cache approach reuses question - //
  • nodes (preserving textarea/checkbox state) and only rebuilds - // cache-miss rows, so we no longer wipe the DOM at the start. - const openDetails = snapshotOpenDetails(root); - const fmt = (ts) => new Date(ts).toISOString().replace('T', ' ').slice(0, 19); - const allPending = questionsState.pending; - - // Filter chips. Always include `all` / `operator` / `peer`; add - // per-agent chips for any agent that appears as asker or target - // in the pending list so the operator can isolate a single - // thread without typing. - const participants = new Set(); - for (const q of allPending) { - participants.add(q.asker); - if (q.target) participants.add(q.target); - } - - // Auto-reset a stale per-agent filter: if the operator had `agent:foo` - // selected and all of foo's questions resolved, foo's chip disappears - // from the row. Without a reset the section would show "no questions - // match this filter" with no active chip visible — confusing. Fall back - // to `all` whenever the stored value is no longer a valid chip value. - let activeFilter = getQuestionsFilter(); - const validFilters = new Set(['all', 'operator', 'peer', - ...Array.from(participants).map((n) => 'agent:' + n)]); - if (!validFilters.has(activeFilter)) { - activeFilter = 'all'; - // Write directly to localStorage to avoid the re-render that - // setQuestionsFilter() triggers (we're already mid-render). - localStorage.setItem(QUESTIONS_FILTER_KEY, 'all'); - } - - const pending = allPending.filter((q) => questionMatchesFilter(q, activeFilter)); - - const filterRow = el('div', { class: 'questions-filters' }); - const mkChip = (value, label) => { - const b = el('button', { - type: 'button', - class: 'q-filter-chip' + (activeFilter === value ? ' active' : ''), - }, label); - b.addEventListener('click', () => setQuestionsFilter(value)); - return b; - }; - // Count pending questions per filter value so each chip shows its - // own hit count — the operator can see "operator · 2 / peer · 3" - // at a glance without clicking through each tab. - const operatorCount = allPending.filter((q) => !q.target).length; - const peerCount = allPending.filter((q) => !!q.target).length; - const agentCount = (name) => allPending.filter( - (q) => q.asker === name || q.target === name).length; - filterRow.append( - mkChip('all', `all · ${allPending.length}`), - mkChip('operator', `@operator · ${operatorCount}`), - mkChip('peer', `@peer · ${peerCount}`), - ); - for (const name of Array.from(participants).sort()) { - filterRow.append(mkChip('agent:' + name, `@${name} · ${agentCount(name)}`)); - } - - // Evict resolved/cancelled questions from the row cache. - const allPendingIds = new Set(allPending.map((q) => q.id)); - for (const id of questionRowCache.keys()) { - if (!allPendingIds.has(id)) questionRowCache.delete(id); - } - - // Build the ordered list of
  • elements, reusing cached nodes whose - // serialised state hasn't changed. This is what preserves textarea - // draft text and radio/checkbox selections across re-renders. - const orderedLis = pending.map((q) => { - const fp = questionRowFingerprint(q); - const cached = questionRowCache.get(q.id); - if (cached && cached.fingerprint === fp) return cached.el; - const li = buildQuestionLi(q); - questionRowCache.set(q.id, { el: li, fingerprint: fp }); - return li; - }); - - // Save the history
    open state before the DOM swap so it - // isn't collapsed every time a question arrives while it's open. - const historyWasOpen = root.querySelector('.q-history')?.open ?? false; - - const children = [filterRow]; - if (!pending.length) { - children.push(el('p', { class: 'empty' }, - activeFilter === 'all' ? 'no pending questions' : 'no questions match this filter')); - } else { - const ul = el('ul', { class: 'questions' }); - for (const li of orderedLis) ul.append(li); - children.push(ul); - } - - // Answered question history (read-only, no inputs — built fresh each render). - const hist = questionsState.history; - if (hist.length) { - const details = el('details', { class: 'q-history', 'data-restore-key': 'q-history' }); - details.append(el('summary', {}, '◆ answ3red (' + hist.length + ')')); - const hul = el('ul', { class: 'questions questions-answered' }); - for (const q of hist) { - const targetLabel = q.target || 'operator'; - const li = el('li', { class: 'question question-answered' + (q.target ? ' question-peer' : '') }); - const head = el('div', { class: 'q-head' }, - el('span', { class: 'msg-ts' }, fmt(q.answered_at)), ' ', - el('span', { class: 'msg-from' }, q.asker), ' ', - el('span', { class: 'msg-sep' }, '→'), ' ', - el('span', { class: q.target ? 'msg-to msg-to-peer' : 'msg-to' }, targetLabel), ' ', - el('span', { class: 'msg-sep' }, 'asked:'), - ); - const histBody = el('div', { class: 'q-body' }); - appendLinkified(histBody, q.question, q.question_refs); - const ansText = el('span', { class: 'q-answer-text' }); - appendLinkified(ansText, q.answer || '(none)', q.answer_refs); - const ansLine = el('div', { class: 'q-answer' }, - el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `), - ansText, - ); - li.append(head, histBody, ansLine); - hul.append(li); - } - details.append(hul); - children.push(details); - } - - root.replaceChildren(...children); - // Restore history open state after the DOM swap. - if (historyWasOpen) { - const histEl = root.querySelector('.q-history'); - if (histEl) histEl.open = true; - } - restoreOpenDetails(root, openDetails); -} - -// Format a remaining-seconds count as the `⏳ …` TTL chip text on a -// question card. Bucketed at minutes / hours so a long deadline stays -// readable; "expiring…" once the deadline has passed (the host-side -// ttl-watchdog will fire shortly). -function formatTtl(remaining) { - if (remaining <= 0) return 'expiring…'; - if (remaining < 60) return '⏳ ' + remaining + 's'; - if (remaining < 3600) { - return '⏳ ' + Math.floor(remaining / 60) + 'm ' - + (remaining % 60) + 's'; - } - return '⏳ ' + Math.floor(remaining / 3600) + 'h ' - + Math.floor((remaining % 3600) / 60) + 'm'; -} - -// Single page-wide ticker that refreshes every TTL chip in place -// each second. Renderers stamp `data-deadline` on the -// chip; this just updates `textContent`, no re-render of the -// questions section. No-op when no chips are on screen, so the -// cost is negligible. -setInterval(() => { - const now = Math.floor(Date.now() / 1000); - document.querySelectorAll('.q-ttl[data-deadline]').forEach((node) => { - const deadline = Number(node.getAttribute('data-deadline')); - if (!Number.isFinite(deadline)) return; - node.textContent = formatTtl(deadline - now); - }); -}, 1000); diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js index 7637d161..52b7643f 100644 --- a/frontend/packages/dashboard/src/common.js +++ b/frontend/packages/dashboard/src/common.js @@ -299,8 +299,8 @@ export const sidePanel = document.createElement('hive-side-panel'); document.body.append(sidePanel); // ─── path linkification ───────────────────────────────────────────────── -// Agents constantly drop pointer strings into messages + question -// bodies (it's the 1 KiB-cap escape hatch). Anything matching the +// Agents constantly drop pointer strings into messages (it's the 1 +// KiB-cap escape hatch). Anything matching the // PATH_RE patterns becomes a clickable anchor; clicking expands an // inline
    with the file's contents, fetched lazily from // /api/state-file. The legacy in-container `/state/...` prefix is @@ -471,9 +471,8 @@ export function appendLinkified(parent, text, refs) { } // ─── browser notifications ────────────────────────────────────────────── -// Fires OS notifications on three operator-bound signals: +// Fires OS notifications on two operator-bound signals: // - new approval landed in the queue -// - new operator question queued (ask, target IS NULL) // - broker message sent `to: "operator"` // Permission grant is per-browser; a localStorage "muted" toggle lets // the operator silence without revoking. Secure-context only (HTTPS / diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css index 44899b9e..476663dd 100644 --- a/frontend/packages/dashboard/src/dashboard.css +++ b/frontend/packages/dashboard/src/dashboard.css @@ -476,10 +476,6 @@ hive-agent-menu { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } -@keyframes questions-pulse { - 0%, 100% { box-shadow: 0 0 12px -4px color-mix(in srgb, var(--amber) 55%, transparent); } - 50% { box-shadow: 0 0 22px -2px color-mix(in srgb, var(--amber) 95%, transparent); } -} /* Pending approval: a card with three stacked sections — identity header, what-changed body, decision actions. */ @@ -650,110 +646,6 @@ summary:hover { color: var(--purple); } .diff .diff-hunk { color: var(--cyan); } .diff .diff-file { color: var(--purple); font-weight: bold; } .diff .diff-ctx { color: var(--fg); } -.questions { - background: var(--bg-elev); - border: 1px solid var(--amber); - box-shadow: 0 0 12px -4px var(--amber); - padding: 0.6em 0.9em; - animation: questions-pulse 2.4s ease-in-out infinite; -} - -.questions-filters { - display: flex; - flex-wrap: wrap; - gap: 0.3em; - margin-bottom: 0.5em; -} -.q-filter-chip { - background: var(--bg); - color: var(--muted); - border: 1px solid var(--border); - border-radius: 999px; - padding: 0.15em 0.7em; - font: inherit; - font-size: 0.85em; - cursor: pointer; -} -.q-filter-chip:hover { color: var(--fg); } -.q-filter-chip.active { - color: var(--amber); - border-color: var(--amber); -} -.questions li.question-peer { - border-left: 2px solid var(--purple); - padding-left: 0.6em; -} -.questions .msg-to-peer { color: var(--purple); } -.btn-override { background: var(--purple) !important; color: var(--bg) !important; } -.questions li.question { - padding: 0.4em 0; - border-bottom: 1px solid var(--border); -} -.questions li.question:last-child { border-bottom: 0; } -.questions .q-head { font-size: 0.9em; } -.questions .q-ttl { - color: var(--amber); - margin-left: 0.4em; - font-size: 0.95em; - letter-spacing: 0.05em; -} -.questions .q-body { - color: var(--fg); - margin: 0.3em 0; - white-space: pre-wrap; - word-break: break-word; -} -.qform { - display: flex; - flex-direction: column; - gap: 0.5em; - margin-top: 0.4em; -} -.qform .q-options { - display: flex; - flex-direction: column; - gap: 0.25em; - background: var(--bg); - border: 1px solid var(--border); - border-radius: 4px; - padding: 0.4em 0.6em; -} -.qform .q-option label { cursor: pointer; user-select: none; } -.qform .q-option input { margin-right: 0.4em; accent-color: var(--amber); } -.qform .q-free { display: flex; } -.qform .q-free textarea { - flex: 1; - font-family: inherit; - font-size: 1em; - background: var(--bg); - color: var(--fg); - border: 1px solid var(--border); - padding: 0.4em 0.6em; - resize: vertical; - line-height: 1.4; -} -.qform .q-free textarea::placeholder { color: var(--muted); } -.qform .q-free textarea:focus { outline: 1px solid var(--amber); } -.qform button { align-self: flex-start; } -.qform-cancel { margin-top: 0.3em; } -.q-history { - margin-top: 0.8em; - border: 1px solid var(--border); - border-radius: 4px; - padding: 0.4em 0.7em; -} -.q-history summary { cursor: pointer; color: var(--muted); font-size: 0.9em; user-select: none; } -.questions-answered { - border: none; - box-shadow: none; - animation: none; - padding: 0; - margin-top: 0.5em; -} -.question-answered { opacity: 0.7; } -.question-answered .q-body { color: var(--muted); margin-bottom: 0.15em; } -.q-answer { font-size: 0.9em; color: var(--green); padding: 0.1em 0 0.4em 0; } -.q-answer-text { font-style: italic; } footer { margin-top: 4em; diff --git a/frontend/packages/dashboard/src/dashboard.html b/frontend/packages/dashboard/src/dashboard.html index b1baf418..d8a6112f 100644 --- a/frontend/packages/dashboard/src/dashboard.html +++ b/frontend/packages/dashboard/src/dashboard.html @@ -109,10 +109,9 @@ - +