// Y3R C4LL domain — the dashboard pane for things blocked on an operator // decision: 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 // import). Instead it exposes count getters the coordinator pulls, and the // live-mutation paths call an injected `onCountsChanged` callback the entry // registers once via `initCall`. import { $, appendLinkified } from "./common.js"; import { el } from "@hive/shared/dom.js"; // Registered by the dashboard entry at boot; defaults to a no-op so the // module is safe to call before wiring. let onCountsChanged = () => {}; export function initCall(opts = {}) { if (typeof opts.onCountsChanged === "function") onCountsChanged = opts.onCountsChanged; } // ─── operator inbox — unread agent→operator messages ──────────── // The Y3R C4LL tab surfaces messages agents `send(to: "operator")` so // the operator stops missing them. Unread = broker rows to "operator" // with `acked_at IS NULL`; cold-loaded from `/api/operator-inbox`, // appended live from the broker `sent` stream, and cleared via the // existing per-recipient ack (`POST /api/agent/operator/mark-all-read`). // Count folds into the Y3R C4LL pill + browser-title prefix. let operatorInbox = []; // [{ id, from, body, at, file_refs }], newest-first export function operatorInboxCount() { return operatorInbox.length; } export async function refreshOperatorInbox() { try { const r = await fetch("/api/operator-inbox"); if (r.ok) { const data = await r.json(); operatorInbox = Array.isArray(data.messages) ? data.messages : []; } } catch { /* keep prior list on transient failure */ } renderOperatorInbox(); onCountsChanged(); } function renderOperatorInbox() { const root = $("operator-inbox-section"); if (!root) return; root.replaceChildren(); if (!operatorInbox.length) { root.append(el("p", { class: "meta" }, "no unread messages")); return; } const mark = el( "button", { type: "button", class: "btn", id: "op-inbox-mark-read" }, `✓ mark all read (${operatorInbox.length})`, ); mark.addEventListener("click", markOperatorInboxRead); root.append(el("div", { class: "inbox-toolbar" }, mark)); const fmt = (ts) => new Date(ts).toISOString().replace("T", " ").slice(0, 19); const ul = el("ul", { class: "inbox" }); for (const m of operatorInbox) { const body = el("span", { class: "msg-body" }); appendLinkified(body, m.body, m.file_refs); ul.append( el( "li", {}, el("span", { class: "msg-ts" }, fmt(m.at)), " ", el("span", { class: "msg-from" }, m.from), " ", el("span", { class: "msg-sep" }, "→ "), body, ), ); } root.append(ul); } async function markOperatorInboxRead() { try { await fetch("/api/agent/operator/mark-all-read", { method: "POST" }); } catch { /* best-effort; the next refresh reconciles */ } operatorInbox = []; renderOperatorInbox(); onCountsChanged(); } // Live append from the broker stream — a `sent` frame addressed to // "operator". De-dupes on broker row id so a history/live overlap or // a refresh racing the stream doesn't double-list. export function operatorInboxAppendFromEvent(ev) { if (ev.id != null && operatorInbox.some((m) => m.id === ev.id)) return; operatorInbox.unshift({ id: ev.id, from: ev.from, body: ev.body, at: ev.at, file_refs: ev.file_refs || [], }); if (operatorInbox.length > 100) operatorInbox.length = 100; renderOperatorInbox(); onCountsChanged(); }