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
|
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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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 /
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -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: [] };
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue