dashboard: remove the Y3R C4LL questions pane (ask/answer removal, frontend)

This commit is contained in:
damocles 2026-08-29 20:32:34 +02:00 committed by mara
commit ff0a6660aa
16 changed files with 74 additions and 680 deletions

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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 `<swarm> / <hive> // 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 `<details>` 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:<id>`, `hyperhive:question:<id>`,
Per-event tags (`hyperhive:approval:<id>`,
`hyperhive:msg:<at>:<rand>`) 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

View file

@ -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="<stable-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 `<details>` 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
`<details data-restore-key>` 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

View file

@ -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 <li> 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:<name>` 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 <li>'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 <li>. 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 `<details>` 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 <details> state so SSE-triggered re-renders restore
// any expanded sections. The keyed-cache approach reuses question
// <li> 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 <li> 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 <details> 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);

View file

@ -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 <details> 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 /

View file

@ -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;

View file

@ -109,10 +109,9 @@
</div>
</section>
<!-- Y3R C4LL: things blocked on operator decision. Approvals +
questions read as the same concept ("something is waiting on
you"); both surface their full bodies inline so the operator
can decide without leaving the pane. -->
<!-- Y3R C4LL: things blocked on operator decision — the approval
queue; surfaces full bodies inline so the operator can decide
without leaving the pane. -->
<section class="tab-pane" id="tab-pane-call" data-tab-pane="call" hidden
role="tabpanel" aria-labelledby="tab-call">
<!-- 1NB0X: unread agent→operator messages. Fetched on
@ -130,12 +129,6 @@
<div id="approvals-section">
<p class="meta">loading…</p>
</div>
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="questions-section">
<p class="meta">loading…</p>
</div>
</section>
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page):

View file

@ -24,7 +24,7 @@
<p class="meta">operator-local preferences. these live in the browser's localStorage — they do not sync between devices and do not survive a profile wipe.</p>
<h3>◇ browser notifications</h3>
<p class="meta">desktop notifications for new approvals, new operator questions, and broker messages addressed to you. requires a secure context (https or localhost). mute silences the notifications without revoking the OS-level permission.</p>
<p class="meta">desktop notifications for new approvals and broker messages addressed to you. requires a secure context (https or localhost). mute silences the notifications without revoking the OS-level permission.</p>
<div id="notif-row" class="notif-row">
<button type="button" id="notif-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</button>

View file

@ -3,7 +3,7 @@
// Extracted from the dashboard S3TT1NGS tab. The browser-notification
// toggle: NOTIF.bind() wires the enable / mute / unmute buttons and
// persists state to localStorage. The dashboard's NOTIF.show() calls
// (approvals / questions) read that same localStorage state + the
// (approvals) read that same localStorage state + the
// browser-level permission, so firing still works from the dashboard
// even though the toggle UI now lives on its own page.
//

View file

@ -23,13 +23,3 @@ export function syncContainersFromSnapshot(s) {
containersState.clear();
for (const c of s.containers || []) containersState.set(c.name, c);
}
// Derived question state — the other genuinely cross-domain store. Owned by
// the Y3R C4LL questions domain (`call.js`: cold-loaded from /api/state, then
// mutated live by `question_added` / `question_resolved` events), but also
// read by the SW4RM container rows, which render per-agent asker/target
// question-count badges off `questionsState.pending`. It lives here for the
// same reason as `containersState`: a single source of truth both domains
// import by reference rather than threading through call signatures.
export const QUESTION_HISTORY_LIMIT = 20;
export const questionsState = { pending: [], history: [] };

View file

@ -12,7 +12,7 @@ import { themedConfirm, themedToast } from '@hive/shared/modal.js';
import { h, render } from 'preact';
import { JobqRollup } from '@hive/shared/jobq-rollup.js';
import {
containersState, questionsState,
containersState,
} from './state.js';
import { closeAllMenus } from '@hive/shared/hive-menu.js';
import './agent-menu/hive-agent-menu.js'; // registers <hive-agent-menu> — side-effect import
@ -305,7 +305,7 @@ function treePrefixDom({ depth, ancestorIsLast, isLast }) {
// strip, ctx badge, status text) is intentionally excluded: it
// populates in-place and is preserved when a row is reused.
function containerRowFingerprint(c, node, pending, opRunning, selected,
askerCount, targetCount, gatewayLinks, hostname) {
gatewayLinks, hostname) {
return JSON.stringify({
running: c.running,
failed: c.failed,
@ -317,8 +317,6 @@ function containerRowFingerprint(c, node, pending, opRunning, selected,
pending,
opRunning,
selected,
askerCount,
targetCount,
depth: node.depth,
isLast: node.isLast,
ancestorIsLast: node.ancestorIsLast,
@ -334,7 +332,6 @@ function containerRowFingerprint(c, node, pending, opRunning, selected,
function buildContainerLi(c, node, opts) {
const {
pending, opRunning, selected,
askerCount, targetCount, agentQCount,
url, containerBase, forgeBase, s,
} = opts;
// A single `pending-running` class now covers the whole "has at
@ -559,21 +556,6 @@ function buildContainerLi(c, node, opts) {
c.active_model));
}
// Pending questions where this agent is the asker (awaiting an
// answer) or the target (owes a reply). Derived live from
// questionsState so the badge updates instantly on QuestionAdded /
// QuestionResolved without a separate backend field.
if (agentQCount > 0) {
const parts = [];
if (askerCount > 0) parts.push(`${askerCount} asked`);
if (targetCount > 0) parts.push(`${targetCount} to answer`);
head.append(el('span',
{
class: 'hive-pill-sm badge-loose-ends',
title: `pending questions: ${parts.join(', ')} — see the Q33R1ES tab`,
},
`${agentQCount}`));
}
body.append(head);
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD /
@ -595,7 +577,7 @@ export function renderContainers(s) {
// Belt-and-suspenders for any future page that adds tabs.js
// without a #containers-section — matches the
// no-op-when-target-absent convention the other renderers
// (renderQuestions, renderApprovals, etc.) follow.
// (renderApprovals, etc.) follow.
if (!root) return;
// Containers come from the derived map (event-driven) rather than
@ -690,18 +672,9 @@ export function renderContainers(s) {
? Array.from(transientKindsMap.keys()).sort() : [];
const opRunning = pending.length > 0;
const selected = selectionState.has(c.name);
// Pending questions where this agent is the asker (awaiting an
// answer) or the target (owes a reply). Derived live from
// questionsState so the badge updates instantly on QuestionAdded /
// QuestionResolved without a separate backend field.
const askerCount = questionsState.pending.filter((q) => q.asker === c.name).length;
const targetCount = questionsState.pending.filter((q) => q.target === c.name).length;
const agentQCount = questionsState.pending.filter(
(q) => q.asker === c.name || q.target === c.name,
).length;
const fp = containerRowFingerprint(c, node, pending, opRunning, selected,
askerCount, targetCount, gatewayLinks, hostname);
gatewayLinks, hostname);
const cached = containerRowCache.get(c.name);
let li;
@ -713,7 +686,6 @@ export function renderContainers(s) {
} else {
li = buildContainerLi(c, node, {
pending, opRunning, selected,
askerCount, targetCount, agentQCount,
url, containerBase, forgeBase, s,
});
containerRowCache.set(c.name, { el: li, fingerprint: fp });

View file

@ -38,14 +38,12 @@ import {
refreshOperatorInbox, operatorInboxAppendFromEvent, operatorInboxCount,
syncApprovalsFromSnapshot, applyApprovalAdded, applyApprovalResolved,
renderApprovals, activeApprovalCount,
syncQuestionsFromSnapshot, applyQuestionAdded, applyQuestionResolved,
renderQuestions, activeQuestionCount,
} from './call.js';
import {
initJobqRollup, syncTransientsFromSnapshot,
applyRebuildQueueChanged, applyContainerStateChanged, applyContainerRemoved,
applyTransientSet, applyTransientCleared,
renderContainers, renderContainersFromState,
renderContainers,
renderSelectionBar,
} from './swarm.js';
@ -59,21 +57,18 @@ window.marked = marked;
// doesn't re-fire for the same row. Keyed by stable ids; reset only
// when the page reloads.
const seenApprovals = new Set();
const seenQuestions = new Set();
let seededNotify = false;
function notifyDeltas(s) {
const approvals = s.approvals || [];
const questions = s.questions || [];
if (!seededNotify) {
// First render after page load — fill the "seen" sets without
// First render after page load — fill the "seen" set without
// firing notifications. We only want to notify on NEW items
// that arrived while the page is open. The inbox no longer
// needs seeding here: it's derived from the broker stream which
// does its own per-event notification on live arrival, and
// history-replayed events are silent by virtue of `fromHistory`.
for (const a of approvals) seenApprovals.add(a.id);
for (const q of questions) seenQuestions.add(q.id);
seededNotify = true;
return;
}
@ -86,14 +81,6 @@ window.marked = marked;
NOTIF.show('◆ approval #' + a.id, `${verb} for ${a.agent}`,
'hyperhive:approval:' + a.id);
}
for (const q of questions) {
if (seenQuestions.has(q.id)) continue;
seenQuestions.add(q.id);
const targetLabel = q.target || 'operator';
NOTIF.show(`${q.asker}${targetLabel} asks`,
q.question.slice(0, 120),
'hyperhive:question:' + q.id);
}
}
// ─── async forms ────────────────────────────────────────────────────────
@ -137,7 +124,6 @@ window.marked = marked;
// tick (or a manual action) will pick it up after they blur.
const MANAGED_SECTION_IDS = [
'containers-section',
'questions-section',
'inbox-section',
'approvals-section',
'schedules-section',
@ -239,12 +225,9 @@ window.marked = marked;
// mount's own effect handles the actual fetch.
initJobqRollup();
renderContainers(s);
// Sync the derived approvals + questions stores from the
// snapshot, then render. Live `*_added` / `*_resolved` events
// mutate the stores directly and re-render without a snapshot
// refetch.
syncQuestionsFromSnapshot(s);
renderQuestions();
// Sync the derived approvals store from the snapshot, then
// render. Live `*_added` / `*_resolved` events mutate the store
// directly and re-render without a snapshot refetch.
// (renderInbox now lives in ./flow.js — dashboard has no
// #inbox-section element to render into.)
syncApprovalsFromSnapshot(s);
@ -255,7 +238,7 @@ window.marked = marked;
// No periodic refresh timer. Phase 6 covers every container
// mutation with `ContainerStateChanged` / `ContainerRemoved`
// (lifecycle ops, destroy, rebuild, crash_watch's 10s poll);
// approvals + questions + transients have their own events;
// approvals + transients have their own events;
// broker traffic flows through the SSE channel. The only
// /api/state fetches are the initial cold load and the
// post-submit refetch on forms without `data-no-refresh`
@ -275,8 +258,8 @@ window.marked = marked;
// operator opens the tab. Live updates arrive via the broker stream.
refreshOperatorInbox();
// NOTIF.bind() (the enable/mute/unmute toggle UI) moved to /settings.html
// with the S3TT1NGS page. The dashboard keeps NOTIF.show() for approval/
// question notifications — it reads the browser permission + localStorage
// with the S3TT1NGS page. The dashboard keeps NOTIF.show() for approval
// notifications — it reads the browser permission + localStorage
// mute flag the settings page sets, so no bind() is needed here. The
// side panel (<hive-side-panel>) wires its own internals in
// connectedCallback — no bind() step needed either.
@ -300,8 +283,6 @@ window.marked = marked;
const MUTATION_HANDLERS = {
approval_added: applyApprovalAdded,
approval_resolved: applyApprovalResolved,
question_added: applyQuestionAdded,
question_resolved: applyQuestionResolved,
transient_set: applyTransientSet,
transient_cleared: applyTransientCleared,
container_state_changed: applyContainerStateChanged,
@ -327,13 +308,13 @@ window.marked = marked;
//
// kinds= matches MUTATION_HANDLERS below verbatim, plus `sent`
// (checked separately, just above, for the operator inbox) —
// narrows this from all 17 wire kinds down to the 13 this page
// narrows this from all 17 wire kinds down to the 11 this page
// actually acts on. This page was one of 4 unfiltered
// `/api/dashboard/stream` subscribers before this (subscription
// discipline, part 1 of the dashboard-event-stream-split issue).
const es = openStream(
'/api/dashboard/stream?kinds=sent,approval_added,approval_resolved,' +
'question_added,question_resolved,transient_set,transient_cleared,' +
'transient_set,transient_cleared,' +
'container_state_changed,container_removed,rebuild_queue_changed,' +
'schedules_changed,capabilities_changed,tool_groups_changed',
);
@ -414,7 +395,7 @@ window.marked = marked;
// Register the Y3R C4LL domain's count callback (call.js) — its live
// mutations (inbox stream append, mark-read) trigger a tab-count refresh
// through this instead of reaching back into the coordinator directly.
initCall({ onCountsChanged: refreshTabCounts, onContainersDirty: renderContainersFromState });
initCall({ onCountsChanged: refreshTabCounts });
// Tab count pills — pure derived data from the existing state
@ -443,11 +424,9 @@ window.marked = marked;
if (c.needs_update) swarm++;
}
setTabCount('swarm', swarm);
// Y3R C4LL — pending approvals + operator-targeted questions +
// unread agent->operator messages.
// Y3R C4LL — pending approvals + unread agent->operator messages.
const callCount =
activeApprovalCount() +
activeQuestionCount() +
operatorInboxCount();
setTabCount('call', callCount);
// Browser tab title prefix — lets the operator see the pending

View file

@ -1,7 +1,7 @@
// Shared dashboard render + format helpers.
//
// Small pure utilities used across most dashboard tabs (the container
// tree, the rebuild queue, questions / approvals, schedules + reminders):
// tree, the rebuild queue, approvals, schedules + reminders):
// an atomic-swap render helper and a handful of relative-time / duration
// formatters. They live here — a dashboard-internal module — rather than
// in the cross-page `common.js`, because they're specific to the

View file

@ -451,7 +451,7 @@ export function create(opts) {
// Seq-dedupe only events of a kind that actually appeared in
// the history replay — those are the only ones that could
// double (once via history, once via the live buffer).
// Mutation events (approval/question/container/…) are never
// Mutation events (approval/container/…) are never
// carried by the history endpoint; deduping them against the
// broker-history seq would wrongly drop ones that fired
// between a consumer's own snapshot read and this history