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 An `mcp__hyperhive__ask(to: "operator", ...)` row has no inline
answer form in this terminal — it renders like any other tool call. answer form in this terminal — it renders like any other tool call.
The operator answers a pending question from the main dashboard's The dashboard's own question-surfacing UI that used to let the
own question surfacing (`dashboard/src/swarm.js` + `call.js`, the operator answer from the Y3R C4LL tab is gone (hyperhive#3721); there
Y3R C4LL tab), not from the per-agent page. is currently no UI path to answer an operator-targeted `ask()` at
all.
## Dashboard side (not covered here) ## 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 - **SW4RM** — every agent, live. This is the default tab and the one
you'll check most. you'll check most.
- **Y3R C4LL** — anything waiting on *you*: pending approvals and - **Y3R C4LL** — anything waiting on *you*: pending approvals. If an
agent questions. If an agent needs a decision from you, it's here. agent needs a decision from you, it's here.
- **P3RM1SS10NS** — what tools and system-level access each agent has. - **P3RM1SS10NS** — what tools and system-level access each agent has.
- **SCH3DUL3S** — scheduled prompts and agent self-reminders. - **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 and quick links (stats, screen, forge profile). Click the name to open
its terminal and watch it work in real time. 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 worth checking regularly — it's everything that needs *you*: approvals
for config changes, and questions an agent has asked and is blocked on. for config changes. The tab's count pill tells you at a glance if
The tab's count pill tells you at a glance if anything's pending. anything's pending.
**Approve or reject a config change.** Agent config changes (new **Approve or reject a config change.** Agent config changes (new
packages, env vars, MCP servers) go through an approval queue rather 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 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 "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 `ask` tool calls in this terminal: an `ask` renders like any other
tool call, and the operator answers a pending question from the tool call (see
dashboard's Y3R C4LL tab instead (see
[`terminal-rendering.md`](../terminal-rendering.md#inline-ask-operator-answer)). [`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 ## Live view

View file

@ -20,7 +20,7 @@ from the dashboard tab strip.
standalone **C0R3** page (`/core.html`), and FL0W / L0GS / ST4TS / standalone **C0R3** page (`/core.html`), and FL0W / L0GS / ST4TS /
S3TT1NGS / M4TR1X live on their own pages too, all reachable from the S3TT1NGS / M4TR1X live on their own pages too, all reachable from the
H0M3 hub (not the tab strip). Count pills on SW4RM 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 operator messages), and SCH3DUL3S (active schedules); S3TT1NGS has no
count. count.
- **Banner-thin** (`░▒▓█▓▒░ HYPERHIVE / HIVE-C0RE / WE ARE THE WIRED ░▒▓█▓▒░`) - **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. snapshot. Hidden when there are no warnings.
- **Browser tab title**`hyperhive // h1ve-c0re` by default; updated - **Browser tab title**`hyperhive // h1ve-c0re` by default; updated
to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name` to `<swarm> / <hive> // h1ve-c0re` once `hive_name` / `swarm_name`
arrive in the state snapshot. When there are pending approvals or arrive in the state snapshot. When there are pending approvals, a
unanswered questions, a `(N)` prefix is prepended — `(3) pr1ma // `(N)` prefix is prepended — `(3) pr1ma // h1ve-c0re` — so the
h1ve-c0re` — so the operator can see the call count in an unfocused operator can see the call count in an unfocused browser tab without
browser tab without opening the dashboard. The prefix is set on the opening the dashboard. The prefix is set on the initial `/api/state`
initial `/api/state` cold-load and updated live by cold-load and updated live by `approval_added` / `approval_resolved`
`approval_added` / `approval_resolved` / `question_added` / SSE events; it's preserved when `hive_name` / `swarm_name` later
`question_resolved` SSE events; it's preserved when replace the raw title.
`hive_name` / `swarm_name` later replace the raw title.
The FL0W and L0GS pages use a slim header (a `← home` back-link + the 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 page title) rather than the dashboard tab strip — they're standalone
@ -82,9 +81,7 @@ agent is stale.
## Y3R C4LL tab ## Y3R C4LL tab
Things blocked on operator decision — approvals and questions Things blocked on operator decision — the approval queue.
share a tab because they're the same concept ("something is
waiting on you").
**P3NDING APPR0VALS** — the queue (see "Approval card" below). **P3NDING APPR0VALS** — the queue (see "Approval card" below).
The R3QU3ST SP4WN form lives at the top of this section. A 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 queue and the last 30 resolved approvals (see "Approval card" for
the history row shape). 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 **0PER4T0R 1NB0X** — messages agents have sent to `to="operator"` but
the operator hasn't read yet. Cold-loaded from `/api/operator-inbox` on the operator hasn't read yet. Cold-loaded from `/api/operator-inbox` on
tab activation + page load; appended live from the broker `sent` stream 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 Unix timestamp attribute; a shared 1s ticker rewrites it
in-place showing `fmtDuration` while in the future and in-place showing `fmtDuration` while in the future and
`overdue X ago` once the fire time has passed — same `overdue X ago` once the fire time has passed — same
zero-re-render pattern as the reminder due-at labels and the zero-re-render pattern as the reminder due-at labels.
question TTL chip.
The table's last row is a permanent inline creation row: The table's last row is a permanent inline creation row:
inputs live directly in table cells (targets as checkboxes, 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 ## 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 approval (per id, delta on `/api/state`)
- new pending operator question (per id)
- new broker message sent `to: "operator"` (live via SSE) - new broker message sent `to: "operator"` (live via SSE)
The toggle controls live in the `S3TT1NGS` tab (`#settings`); see 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 First `/api/state` after page load seeds "seen" sets without
firing — only items that arrive while the page is open count. 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 `hyperhive:msg:<at>:<rand>`) so distinct events stack in the OS
notification center instead of overwriting each other. notification center instead of overwriting each other.
`console.debug` logs at every block point (unsupported, `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. `destroy` accepts `purge=on` to also wipe state dirs.
- `POST /api/purge-tombstone/{name}` — wipe a tombstone's state dirs. - `POST /api/purge-tombstone/{name}` — wipe a tombstone's state dirs.
- `POST /api/answer-question/{id}` — answer a pending operator question. - `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 - `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/request-spawn` — queue a Spawn approval.
- `POST /api/update-all` — rebuild every stale container. - `POST /api/update-all` — rebuild every stale container.
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry. - `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry.
@ -1411,14 +1385,10 @@ payload):
re-renders only the approvals section. re-renders only the approvals section.
- `question_added` (id, asker, question, options, multi, - `question_added` (id, asker, question, options, multi,
asked_at, deadline_at, target) / `question_resolved` (id, asked_at, deadline_at, target) / `question_resolved` (id,
answer, answerer, answered_at, cancelled, target) — both answer, answerer, answered_at, cancelled, target) — still
operator-targeted and peer (agent-to-agent) threads fire fired by the backend `ask`/`answer` mechanism, but the
these. The dashboard's questions pane surfaces both, with dashboard no longer subscribes to either kind: the questions
filter chips (all / @operator / @peer / per-participant) and pane that used to surface them is gone (hyperhive#3721).
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.
- `transient_set` (name, transient_kind, since_unix) / - `transient_set` (name, transient_kind, since_unix) /
`transient_cleared` (name) — lifecycle action spinners. The `transient_cleared` (name) — lifecycle action spinners. The
client ticks the elapsed-seconds badge off `since_unix` 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 `/api/state` is **only fetched on cold-load and on the few
forms that mutate non-event-derived state** (PURG3 + forms that mutate non-event-derived state** (PURG3 +
meta-update, since tombstones + meta_inputs aren't event- 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 — transients, containers, operator inbox, message flow —
derives from `/api/dashboard/stream` after the initial snapshot, derives from `/api/dashboard/stream` after the initial snapshot,
maintaining its own client-side store and applying events on maintaining its own client-side store and applying events on

View file

@ -51,8 +51,7 @@
dashboard stream carries broker `Sent` / `Delivered` (mirrored dashboard stream carries broker `Sent` / `Delivered` (mirrored
by a forwarder task from the broker's intra-process channel) by a forwarder task from the broker's intra-process channel)
plus mutation events (`approval_added` / `approval_resolved`, plus mutation events (`approval_added` / `approval_resolved`,
`question_added` / `question_resolved`, `transient_set` / `transient_set` / `transient_cleared`). Each frame carries a `seq`. The
`transient_cleared`). Each frame carries a `seq`. The
matching backfill endpoint is `GET /api/dashboard/history` (last matching backfill endpoint is `GET /api/dashboard/history` (last
~200 broker messages wrapped in `{ seq, events }`) on the ~200 broker messages wrapped in `{ seq, events }`) on the
dashboard and `GET /events/history` (last 2000 `LiveEvent`s 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 On each render: cache-hit rows are reused verbatim (preserving
textarea draft, checkbox state, and event listeners); only cache-miss textarea draft, checkbox state, and event listeners); only cache-miss
rows are rebuilt and inserted. Used for: containers (`containerRowCache`), rows are rebuilt and inserted. Used for: containers (`containerRowCache`),
rebuild-queue entries (`rebuildQueueRowCache`), and question rows rebuild-queue entries (`rebuildQueueRowCache`). The spawn-form input+focus and meta-input
(`questionRowCache`). The spawn-form input+focus and meta-input
checkboxes use a lighter snapshot-then-restore pattern (snapshot checkboxes use a lighter snapshot-then-restore pattern (snapshot
before `replaceChildren`, restore after) since they are single before `replaceChildren`, restore after) since they are single
values rather than per-row caches. 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 refresh. `snapshotOpenDetails()` walks managed sections before
render, `restoreOpenDetails()` re-applies after. Long-content render, `restoreOpenDetails()` re-applies after. Long-content
drill-ins (file previews, diffs, journald logs) now open in the drill-ins (file previews, diffs, journald logs) now open in the
**side panel** (see below) rather than expanding inline, so the **side panel** (see below) rather than expanding inline. The
only restore-keyed `<details>` left is the answered-questions dashboard's own answered-questions history list was the only
history list. `<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 **Side panel (dashboard):** long content opens in a drawer that
swipes in from the right — a singleton `#side-panel` with a 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 // Y3R C4LL domain — the dashboard pane for things blocked on an operator
// decision: approvals, the operator's question queue, and the operator // decision: approvals and the operator inbox (messages agents sent to
// inbox (messages agents sent to `to="operator"`). Lifted out of the // `to="operator"`). Lifted out of the dashboard entry (tabs.js) into its
// dashboard entry (tabs.js) into its own module so the entry stays a thin // own module so the entry stays a thin coordinator; mirrors the
// coordinator; mirrors the permissions.js / schedules.js extractions. // 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.
// //
// Tab-count wiring: like schedules.js this module does not call the // Tab-count wiring: like schedules.js this module does not call the
// coordinator's `refreshTabCounts` directly (that would be a circular // coordinator's `refreshTabCounts` directly (that would be a circular
@ -16,24 +12,14 @@
import { $, form, appendLinkified } from './common.js'; import { $, form, appendLinkified } from './common.js';
import { el } from '@hive/shared/dom.js'; import { el } from '@hive/shared/dom.js';
import { themedToast } from '@hive/shared/modal.js';
import { epochSec, fmtAgo, fmtDuration } from './util.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 // Registered by the dashboard entry at boot; defaults to a no-op so the
// module is safe to call before wiring. // module is safe to call before wiring.
let onCountsChanged = () => {}; 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 = {}) { export function initCall(opts = {}) {
if (typeof opts.onCountsChanged === 'function') onCountsChanged = opts.onCountsChanged; if (typeof opts.onCountsChanged === 'function') onCountsChanged = opts.onCountsChanged;
if (typeof opts.onContainersDirty === 'function') onContainersDirty = opts.onContainersDirty;
} }
// ─── operator inbox — unread agent→operator messages ──────────── // ─── operator inbox — unread agent→operator messages ────────────
@ -378,395 +364,3 @@ function renderApprovalHistory(root, history) {
} }
root.append(ul); 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); document.body.append(sidePanel);
// ─── path linkification ───────────────────────────────────────────────── // ─── path linkification ─────────────────────────────────────────────────
// Agents constantly drop pointer strings into messages + question // Agents constantly drop pointer strings into messages (it's the 1
// bodies (it's the 1 KiB-cap escape hatch). Anything matching the // KiB-cap escape hatch). Anything matching the
// PATH_RE patterns becomes a clickable anchor; clicking expands an // PATH_RE patterns becomes a clickable anchor; clicking expands an
// inline <details> with the file's contents, fetched lazily from // inline <details> with the file's contents, fetched lazily from
// /api/state-file. The legacy in-container `/state/...` prefix is // /api/state-file. The legacy in-container `/state/...` prefix is
@ -471,9 +471,8 @@ export function appendLinkified(parent, text, refs) {
} }
// ─── browser notifications ────────────────────────────────────────────── // ─── 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 approval landed in the queue
// - new operator question queued (ask, target IS NULL)
// - broker message sent `to: "operator"` // - broker message sent `to: "operator"`
// Permission grant is per-browser; a localStorage "muted" toggle lets // Permission grant is per-browser; a localStorage "muted" toggle lets
// the operator silence without revoking. Secure-context only (HTTPS / // the operator silence without revoking. Secure-context only (HTTPS /

View file

@ -476,10 +476,6 @@ hive-agent-menu {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.7; } 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 /* Pending approval: a card with three stacked sections identity
header, what-changed body, decision actions. */ header, what-changed body, decision actions. */
@ -650,110 +646,6 @@ summary:hover { color: var(--purple); }
.diff .diff-hunk { color: var(--cyan); } .diff .diff-hunk { color: var(--cyan); }
.diff .diff-file { color: var(--purple); font-weight: bold; } .diff .diff-file { color: var(--purple); font-weight: bold; }
.diff .diff-ctx { color: var(--fg); } .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 { footer {
margin-top: 4em; margin-top: 4em;

View file

@ -109,10 +109,9 @@
</div> </div>
</section> </section>
<!-- Y3R C4LL: things blocked on operator decision. Approvals + <!-- Y3R C4LL: things blocked on operator decision — the approval
questions read as the same concept ("something is waiting on queue; surfaces full bodies inline so the operator can decide
you"); both surface their full bodies inline so the operator without leaving the pane. -->
can decide without leaving the pane. -->
<section class="tab-pane" id="tab-pane-call" data-tab-pane="call" hidden <section class="tab-pane" id="tab-pane-call" data-tab-pane="call" hidden
role="tabpanel" aria-labelledby="tab-call"> role="tabpanel" aria-labelledby="tab-call">
<!-- 1NB0X: unread agent→operator messages. Fetched on <!-- 1NB0X: unread agent→operator messages. Fetched on
@ -130,12 +129,6 @@
<div id="approvals-section"> <div id="approvals-section">
<p class="meta">loading…</p> <p class="meta">loading…</p>
</div> </div>
<h2>◆ M1ND H4S QU3STI0NS ◆</h2>
<div class="divider">══════════════════════════════════════════════════════════════</div>
<div id="questions-section">
<p class="meta">loading…</p>
</div>
</section> </section>
<!-- SYST3M pane moved to /core.html (the standalone "C0R3" page): <!-- 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> <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> <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"> <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-enable" class="btn btn-notif" hidden>🔔 enable notifications</button>
<button type="button" id="notif-mute" class="btn btn-notif" hidden>🔕 mute</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 // Extracted from the dashboard S3TT1NGS tab. The browser-notification
// toggle: NOTIF.bind() wires the enable / mute / unmute buttons and // toggle: NOTIF.bind() wires the enable / mute / unmute buttons and
// persists state to localStorage. The dashboard's NOTIF.show() calls // 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 // browser-level permission, so firing still works from the dashboard
// even though the toggle UI now lives on its own page. // even though the toggle UI now lives on its own page.
// //

View file

@ -23,13 +23,3 @@ export function syncContainersFromSnapshot(s) {
containersState.clear(); containersState.clear();
for (const c of s.containers || []) containersState.set(c.name, c); 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 { h, render } from 'preact';
import { JobqRollup } from '@hive/shared/jobq-rollup.js'; import { JobqRollup } from '@hive/shared/jobq-rollup.js';
import { import {
containersState, questionsState, containersState,
} from './state.js'; } from './state.js';
import { closeAllMenus } from '@hive/shared/hive-menu.js'; import { closeAllMenus } from '@hive/shared/hive-menu.js';
import './agent-menu/hive-agent-menu.js'; // registers <hive-agent-menu> — side-effect import 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 // strip, ctx badge, status text) is intentionally excluded: it
// populates in-place and is preserved when a row is reused. // populates in-place and is preserved when a row is reused.
function containerRowFingerprint(c, node, pending, opRunning, selected, function containerRowFingerprint(c, node, pending, opRunning, selected,
askerCount, targetCount, gatewayLinks, hostname) { gatewayLinks, hostname) {
return JSON.stringify({ return JSON.stringify({
running: c.running, running: c.running,
failed: c.failed, failed: c.failed,
@ -317,8 +317,6 @@ function containerRowFingerprint(c, node, pending, opRunning, selected,
pending, pending,
opRunning, opRunning,
selected, selected,
askerCount,
targetCount,
depth: node.depth, depth: node.depth,
isLast: node.isLast, isLast: node.isLast,
ancestorIsLast: node.ancestorIsLast, ancestorIsLast: node.ancestorIsLast,
@ -334,7 +332,6 @@ function containerRowFingerprint(c, node, pending, opRunning, selected,
function buildContainerLi(c, node, opts) { function buildContainerLi(c, node, opts) {
const { const {
pending, opRunning, selected, pending, opRunning, selected,
askerCount, targetCount, agentQCount,
url, containerBase, forgeBase, s, url, containerBase, forgeBase, s,
} = opts; } = opts;
// A single `pending-running` class now covers the whole "has at // A single `pending-running` class now covers the whole "has at
@ -559,21 +556,6 @@ function buildContainerLi(c, node, opts) {
c.active_model)); 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); body.append(head);
// Per-card action buttons (R3ST4RT / ST0P / ST4RT / R3BU1LD / // 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 // Belt-and-suspenders for any future page that adds tabs.js
// without a #containers-section — matches the // without a #containers-section — matches the
// no-op-when-target-absent convention the other renderers // no-op-when-target-absent convention the other renderers
// (renderQuestions, renderApprovals, etc.) follow. // (renderApprovals, etc.) follow.
if (!root) return; if (!root) return;
// Containers come from the derived map (event-driven) rather than // Containers come from the derived map (event-driven) rather than
@ -690,18 +672,9 @@ export function renderContainers(s) {
? Array.from(transientKindsMap.keys()).sort() : []; ? Array.from(transientKindsMap.keys()).sort() : [];
const opRunning = pending.length > 0; const opRunning = pending.length > 0;
const selected = selectionState.has(c.name); 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, const fp = containerRowFingerprint(c, node, pending, opRunning, selected,
askerCount, targetCount, gatewayLinks, hostname); gatewayLinks, hostname);
const cached = containerRowCache.get(c.name); const cached = containerRowCache.get(c.name);
let li; let li;
@ -713,7 +686,6 @@ export function renderContainers(s) {
} else { } else {
li = buildContainerLi(c, node, { li = buildContainerLi(c, node, {
pending, opRunning, selected, pending, opRunning, selected,
askerCount, targetCount, agentQCount,
url, containerBase, forgeBase, s, url, containerBase, forgeBase, s,
}); });
containerRowCache.set(c.name, { el: li, fingerprint: fp }); containerRowCache.set(c.name, { el: li, fingerprint: fp });

View file

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

View file

@ -1,7 +1,7 @@
// Shared dashboard render + format helpers. // Shared dashboard render + format helpers.
// //
// Small pure utilities used across most dashboard tabs (the container // 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 // an atomic-swap render helper and a handful of relative-time / duration
// formatters. They live here — a dashboard-internal module — rather than // formatters. They live here — a dashboard-internal module — rather than
// in the cross-page `common.js`, because they're specific to the // 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 // Seq-dedupe only events of a kind that actually appeared in
// the history replay — those are the only ones that could // the history replay — those are the only ones that could
// double (once via history, once via the live buffer). // 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 // carried by the history endpoint; deduping them against the
// broker-history seq would wrongly drop ones that fired // broker-history seq would wrongly drop ones that fired
// between a consumer's own snapshot read and this history // between a consumer's own snapshot read and this history