dashboard: remove the Y3R C4LL questions pane (ask/answer removal, frontend)
This commit is contained in:
parent
e1aae29068
commit
ff0a6660aa
16 changed files with 74 additions and 680 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 /
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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: [] };
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue