diff --git a/frontend/packages/dashboard/src/call.js b/frontend/packages/dashboard/src/call.js
index 01f82015..41a34300 100644
--- a/frontend/packages/dashboard/src/call.js
+++ b/frontend/packages/dashboard/src/call.js
@@ -1,5 +1,5 @@
// Y3R C4LL domain — the dashboard pane for things blocked on an operator
-// decision: approvals and the operator inbox (messages agents sent to
+// 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.
@@ -10,9 +10,8 @@
// live-mutation paths call an injected `onCountsChanged` callback the entry
// registers once via `initCall`.
-import { $, form, appendLinkified } from "./common.js";
+import { $, appendLinkified } from "./common.js";
import { el } from "@hive/shared/dom.js";
-import { epochSec, fmtAgo } from "./util.js";
// Registered by the dashboard entry at boot; defaults to a no-op so the
// module is safe to call before wiring.
@@ -113,240 +112,3 @@ export function operatorInboxAppendFromEvent(ev) {
renderOperatorInbox();
onCountsChanged();
}
-
-// ─── approvals — the operator config-change approval queue ────────
-const APPROVAL_TAB_KEY = "hyperhive:approvals:tab";
-// Derived approval state — cold-loaded from /api/state, then mutated
-// live by `approval_resolved` dashboard events.
-// `pending` is the open queue (newest-first); `history` is the last
-// 30 resolved rows.
-const APPROVAL_HISTORY_LIMIT = 30;
-const approvalsState = { pending: [], history: [] };
-export function activeApprovalCount() {
- return approvalsState.pending.length;
-}
-export function syncApprovalsFromSnapshot(s) {
- approvalsState.pending = (s.approvals || []).slice();
- approvalsState.history = (s.approval_history || []).slice();
-}
-export function applyApprovalResolved(ev) {
- // Drop from pending; prepend to history (newest-first), cap at 30.
- approvalsState.pending = approvalsState.pending.filter((a) => a.id !== ev.id);
- // Idempotent: a snapshot re-sync (post-disconnect SSE catchup) can
- // carry this same resolved row in `approval_history` while a live
- // event also delivers it — guard the unshift so history can't
- // double a row.
- if (!approvalsState.history.some((h) => h.id === ev.id)) {
- approvalsState.history.unshift({
- id: ev.id,
- agent: ev.agent,
- kind: ev.approval_kind,
- status: ev.status,
- resolved_at: ev.resolved_at,
- note: ev.note || null,
- description: ev.description || null,
- });
- if (approvalsState.history.length > APPROVAL_HISTORY_LIMIT) {
- approvalsState.history.length = APPROVAL_HISTORY_LIMIT;
- }
- }
- renderApprovals();
-}
-export function renderApprovals() {
- const root = $("approvals-section");
- // #approvals-section only lives on /dashboard.html (Y3R C4LL tab);
- // no-op elsewhere — `approval_resolved` SSE
- // events route through here on every page that loads the bundle.
- if (!root) return;
- root.replaceChildren();
-
- const pending = approvalsState.pending;
- const history = approvalsState.history;
- const active = localStorage.getItem(APPROVAL_TAB_KEY) || "pending";
- const tabs = el("div", { class: "approval-tabs" });
- const pendingTab = el(
- "button",
- {
- type: "button",
- class: "approval-tab" + (active === "pending" ? " active" : ""),
- },
- `pending · ${pending.length}`,
- );
- const historyTab = el(
- "button",
- {
- type: "button",
- class: "approval-tab" + (active === "history" ? " active" : ""),
- },
- `history · ${history.length}`,
- );
- pendingTab.addEventListener("click", () => {
- localStorage.setItem(APPROVAL_TAB_KEY, "pending");
- renderApprovals();
- });
- historyTab.addEventListener("click", () => {
- localStorage.setItem(APPROVAL_TAB_KEY, "history");
- renderApprovals();
- });
- tabs.append(pendingTab, historyTab);
- root.append(tabs);
-
- if (active === "history") {
- renderApprovalHistory(root, history);
- return;
- }
-
- if (!pending.length) {
- root.append(el("p", { class: "empty" }, "queue empty"));
- return;
- }
- const ul = el("ul", { class: "approvals" });
- for (const a of pending) {
- const isUpdateMeta = a.kind === "update_meta_inputs";
- const li = el("li", { class: "approval-card" });
-
- // ── identity header ──────────────────────────────────────────
- const head = el(
- "div",
- { class: "approval-head" },
- el("span", { class: "glyph" }, "↻"),
- el("span", { class: "id" }, "#" + a.id),
- el("span", { class: "agent" }, a.agent),
- el("span", { class: "kind" }, isUpdateMeta ? "meta-update" : a.kind),
- );
- // When the approval was requested — relative time, right-aligned.
- // Goes amber once it's been pending an hour so a stale request is
- // obvious at a glance (see docs/web-ui/dashboard.md::Approval card).
- if (a.requested_at != null) {
- const requestedSec = epochSec(a.requested_at);
- const ageSec = Math.max(0, Math.floor(Date.now() / 1000 - requestedSec));
- head.append(
- el(
- "span",
- {
- class: "approval-ts" + (ageSec >= 3600 ? " stale" : ""),
- title: "requested " + new Date(a.requested_at).toLocaleString(),
- "data-requested-at": String(requestedSec),
- },
- "requested " + fmtAgo(a.requested_at),
- ),
- );
- }
- li.append(head);
-
- // ── what-changed body ────────────────────────────────────────
- const body = el("div", { class: "approval-body" });
- if (a.description) {
- body.append(el("div", { class: "approval-description" }, a.description));
- }
- if (isUpdateMeta) {
- let inputs;
- try {
- inputs = JSON.parse(a.commit_ref || "[]");
- } catch (_) {
- inputs = [];
- }
- body.append(
- el(
- "span",
- { class: "meta" },
- inputs.length
- ? "bump flake inputs: " + inputs.join(", ")
- : "bump all flake inputs",
- ),
- );
- } else {
- body.append(
- el(
- "span",
- { class: "meta" },
- isInit
- ? "scaffold proposed config repo — submitting agent customises agent.nix before spawn"
- : "new sub-agent — container will be created on approve",
- ),
- );
- }
- li.append(body);
-
- // ── decision actions ─────────────────────────────────────────
- // Deny prompts the operator for an optional reason; the submit
- // handler stashes it into a hidden `note` input that rides along
- // on the POST and is surfaced to the submitting agent via
- // HelperEvent::ApprovalResolved { note }.
- const denyForm = el("form", {
- method: "POST",
- action: "/api/deny/" + a.id,
- class: "inline",
- "data-async": "",
- "data-no-refresh": "",
- "data-prompt": "reason for denying (optional, sent to submitter):",
- });
- denyForm.append(
- el("button", { type: "submit", class: "btn btn-deny" }, "DENY"),
- );
- li.append(
- el(
- "div",
- { class: "approval-actions" },
- form(
- "/api/approve/" + a.id,
- "btn-approve",
- "◆ APPR0VE",
- null,
- {},
- { noRefresh: true },
- ),
- denyForm,
- ),
- );
-
- ul.append(li);
- }
- root.append(ul);
-}
-
-function renderApprovalHistory(root, history) {
- if (!history.length) {
- root.append(el("p", { class: "empty" }, "no resolved approvals yet"));
- return;
- }
- const ul = el("ul", { class: "approvals approvals-history" });
- for (const a of history) {
- const li = el("li");
- const row = el("div", { class: "row" });
- const glyph =
- a.status === "approved"
- ? "✓"
- : a.status === "denied"
- ? "✗"
- : a.status === "cancelled"
- ? "⊘"
- : "⚠";
- row.append(
- el("span", { class: "glyph glyph-" + a.status }, glyph),
- " ",
- el("span", { class: "id" }, "#" + a.id),
- " ",
- el("span", { class: "agent" }, a.agent),
- " ",
- el(
- "span",
- { class: "kind" },
- a.kind === "update_meta_inputs" ? "meta-update" : a.kind,
- ),
- " ",
- );
- if (a.sha_short) row.append(el("code", {}, a.sha_short), " ");
- row.append(
- el("span", { class: "status status-" + a.status }, a.status),
- " ",
- el("span", { class: "msg-ts" }, fmtAgo(a.resolved_at)),
- );
- li.append(row);
- if (a.note) {
- li.append(el("div", { class: "history-note" }, a.note));
- }
- ul.append(li);
- }
- root.append(ul);
-}
diff --git a/frontend/packages/dashboard/src/common.css b/frontend/packages/dashboard/src/common.css
index feb188c1..20840a9d 100644
--- a/frontend/packages/dashboard/src/common.css
+++ b/frontend/packages/dashboard/src/common.css
@@ -194,10 +194,6 @@ code {
color: var(--green);
border-color: var(--green);
}
-.btn-deny {
- color: var(--red);
- border-color: var(--red);
-}
.btn-destroy {
color: var(--red);
border-color: var(--red);
diff --git a/frontend/packages/dashboard/src/common.js b/frontend/packages/dashboard/src/common.js
index 956207b5..5b592d91 100644
--- a/frontend/packages/dashboard/src/common.js
+++ b/frontend/packages/dashboard/src/common.js
@@ -348,7 +348,7 @@ export function openBuildLogStream(id, pre, { onDone, onError } = {}) {
// ─── side panel ─────────────────────────────────────────────────────────
// Singleton drawer that swipes in from the right. Long content
-// (file previews, approval diffs, journald logs, applied config)
+// (file previews, journald logs, applied config)
// opens here via `sidePanel.open(title, node)` instead of expanding
// inline. Body is swapped on each open; closing just slides out so
// the content stays visible through the transition.
@@ -566,9 +566,7 @@ export function appendLinkified(parent, text, refs) {
}
// ─── browser notifications ──────────────────────────────────────────────
-// Fires OS notifications on two operator-bound signals:
-// - new approval landed in the queue
-// - broker message sent `to: "operator"`
+// Fires OS notifications on broker messages sent `to: "operator"`.
// Permission grant is per-browser; a localStorage "muted" toggle lets
// the operator silence without revoking. Secure-context only (HTTPS /
// localhost) — on other origins the API is unavailable and we hide
diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css
index 6fd695a5..fa84305e 100644
--- a/frontend/packages/dashboard/src/dashboard.css
+++ b/frontend/packages/dashboard/src/dashboard.css
@@ -496,55 +496,6 @@ hive-agent-menu {
}
}
-/* Pending approval: a card with three stacked sections — identity
- header, what-changed body, decision actions. */
-.approvals {
- list-style: none;
- padding: 0;
- margin: 0.4em 0 0;
-}
-.approval-card {
- background: var(--bg-elev);
- border: 1px solid var(--border);
- border-left: 3px solid var(--purple);
- border-radius: 4px;
- padding: 0.6em 0.8em;
- margin-bottom: 0.6em;
-}
-.approval-head {
- display: flex;
- align-items: baseline;
- flex-wrap: wrap;
- gap: 0.3em;
-}
-.approval-ts {
- margin-left: auto;
- color: var(--muted);
- font-size: 0.85em;
-}
-.approval-ts.stale {
- color: var(--amber);
- text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 50%, transparent);
-}
-.approval-body {
- margin: 0.45em 0;
- padding-left: 1.3em;
-}
-.approval-description {
- font-size: 0.9em;
- color: var(--fg);
- white-space: pre-wrap;
- margin-bottom: 0.35em;
-}
-.approval-actions {
- display: flex;
- gap: 0.5em;
- padding-top: 0.45em;
- border-top: 1px solid var(--border);
-}
-.approval-actions form.inline {
- display: inline;
-}
/* Inline drill-in triggers (logs / config repo / view diff). */
.drill-ins {
display: flex;
@@ -556,91 +507,6 @@ hive-agent-menu {
margin-top: 0;
}
-.approval-tabs {
- display: flex;
- gap: 0.4em;
- margin: 0.6em 0 0.4em;
-}
-.approval-tab {
- background: transparent;
- border: 1px solid var(--border);
- color: var(--muted);
- font: inherit;
- font-size: 0.85em;
- letter-spacing: 0.08em;
- padding: 0.25em 0.9em;
- cursor: pointer;
- transition:
- color 0.15s ease,
- border-color 0.15s ease,
- background 0.15s ease;
-}
-.approval-tab:hover {
- color: var(--fg);
-}
-.approval-tab.active {
- color: var(--purple);
- border-color: var(--purple);
- background: color-mix(in srgb, var(--purple) 8%, transparent);
- text-shadow: 0 0 4px currentColor;
-}
-/* History items: lighter card treatment so they're visually coherent with
- the pending .approval-card items above them. Left accent colour reflects
- the outcome via :has() on the per-status glyph span. */
-.approvals-history li {
- background: color-mix(in srgb, var(--bg-elev) 40%, transparent);
- border: 1px solid var(--border);
- border-left: 3px solid var(--subtext0);
- border-radius: 3px;
- padding: 0.35em 0.8em;
- margin-bottom: 0.4em;
-}
-.approvals-history li:has(.glyph-approved) {
- border-left-color: var(--green);
-}
-.approvals-history li:has(.glyph-denied) {
- border-left-color: var(--red);
-}
-.approvals-history li:has(.glyph-failed) {
- border-left-color: var(--amber);
-}
-.approvals-history .status {
- font-size: 0.85em;
- padding: 0 0.5em;
-}
-.status-approved {
- color: var(--green);
-}
-.status-denied {
- color: var(--red);
-}
-.status-failed {
- color: var(--amber);
-}
-.status-cancelled {
- color: var(--muted);
-}
-.glyph-approved {
- color: var(--green);
-}
-.glyph-denied {
- color: var(--red);
-}
-.glyph-failed {
- color: var(--amber);
-}
-.glyph-cancelled {
- color: var(--muted);
-}
-
-.history-note {
- margin-left: 1.8em;
- margin-top: 0.2em;
- color: var(--muted);
- font-size: 0.85em;
- white-space: pre-wrap;
- word-break: break-word;
-}
ul form.inline {
display: inline-block;
}
diff --git a/frontend/packages/dashboard/src/dashboard.html b/frontend/packages/dashboard/src/dashboard.html
index 062015fc..34d42ef9 100644
--- a/frontend/packages/dashboard/src/dashboard.html
+++ b/frontend/packages/dashboard/src/dashboard.html
@@ -134,8 +134,8 @@
-