svrnty-hermes-webui-plugin/static/adwright.js
Svrnty 849dd27119
Some checks failed
plugin-tests / test (push) Failing after 6s
feat(adwright panel): add cross-link button to BTE Command Center
Header now shows ↗ BTE button next to the profile status pill. Click
invokes window.SvrntyBTE.open() to surface the BTE overlay, satisfying
the "Adwright pulls content from BTE panel" integration goal at the UX
level. Asset-URL-level integration follows automatically once cycles
contain BTE-rendered asset references (post Phase 8).

Themed via existing CSS vars (--accent, --border2, --accent-bg, etc) —
zero hardcoded colors. CONNECTION-MAP regenerated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:19:17 -04:00

724 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Adwright tool panel — injected into hermes-webui as a sibling to #mainChat.
// Loaded via /plugins/svrnty/adwright.js (registered by plugin.py).
// Per ADWRIGHT-PANEL-PRD §3-§7: 60/40 panel/chat split, 5 tabs, always
// visible, soft-disabled when active profile != cmo, reads via /adwright
// chat commands (visible in stream → audit trail), writes for creds only
// via direct backend route (governance exception §6).
//
// Strict: vanilla JS, IIFE, all globals under window.SvrntyAdwright,
// MutationObserver scoped to <main class="main"> only, no hardcoded colors
// (all in adwright.css).
(function () {
"use strict";
if (window.__svrntyAdwrightLoaded) return;
window.__svrntyAdwrightLoaded = true;
const NS = (window.SvrntyAdwright = window.SvrntyAdwright || {});
const TABS = [
{ id: "overview", label: "Overview" },
{ id: "cycles", label: "Cycles" },
{ id: "audience", label: "Audience" },
{ id: "targeting", label: "Targeting" },
{ id: "connections", label: "Connections" },
];
const POLL_INTERVAL_MS = 2000;
const POLL_MAX_MS = 30000;
// ── State ──────────────────────────────────────────────────────────────
NS.state = {
activeTab: "overview",
mounted: false,
pollTimer: null,
pollStartedAt: 0,
lastSeenTs: 0,
pendingTool: null,
data: {
cycles: null,
cycleDetail: {},
segments: null,
recipes: null,
connections: null,
},
};
// ── Active-profile detection (PRD §3 visibility gating) ────────────────
// hermes-webui exposes S.activeProfile (see static/ui.js line 1). We probe
// it defensively — the panel always renders, but enables only for CMO.
function _activeProfile() {
try {
return (window.S && window.S.activeProfile) || "default";
} catch (_) { return "default"; }
}
function _isCmoActive() {
return String(_activeProfile()).toLowerCase().indexOf("cmo") === 0;
}
function _activeSessionId() {
try {
return (window.S && window.S.session && window.S.session.session_id) || "";
} catch (_) { return ""; }
}
// ── Mount / unmount ────────────────────────────────────────────────────
// Anchor: <main class="main"> contains #mainChat (the WebUI chat view).
// We inject our panel as a SIBLING before #mainChat. CSS handles the 60/40
// split via :has() (modern browsers — Chrome 105+, Safari 15.4+, Firefox 121+).
function _findMountTargets() {
const main = document.querySelector("main.main");
const chat = document.getElementById("mainChat");
if (!main || !chat) return null;
return { main, chat };
}
function _mount() {
if (NS.state.mounted) return true;
if (document.querySelector(".svrnty-aw-panel")) {
NS.state.mounted = true;
return true;
}
const targets = _findMountTargets();
if (!targets) return false;
const panel = document.createElement("div");
panel.className = "svrnty-aw-panel";
panel.id = "svrntyAdwrightPanel";
panel.innerHTML = _renderShell();
targets.main.insertBefore(panel, targets.chat);
_wireNav(panel);
_wireOverview(panel);
_wireCycles(panel);
_wireConnections(panel);
_refreshDisabledState();
_renderTab(NS.state.activeTab);
NS.state.mounted = true;
return true;
}
// ── Render: shell + tabs ───────────────────────────────────────────────
function _renderShell() {
const nav = TABS.map((t) =>
`<button class="svrnty-aw-nav-item${t.id === NS.state.activeTab ? " svrnty-aw-nav-active" : ""}" data-svrnty-aw-tab="${t.id}">${t.label}</button>`
).join("");
const tabs = TABS.map((t) =>
`<div class="svrnty-aw-tab" data-svrnty-aw-pane="${t.id}"></div>`
).join("");
return (
'<div class="svrnty-aw-header">' +
'<div class="svrnty-aw-title">Adwright</div>' +
'<div class="svrnty-aw-status">' +
'<span class="svrnty-aw-status-dot" id="svrntyAwDot"></span>' +
'<span id="svrntyAwStatusText">checking…</span>' +
'<button class="svrnty-aw-bte-link" id="svrntyAwOpenBte" title="Open BTE Command Center (creative studio)">↗ BTE</button>' +
'</div>' +
'</div>' +
'<div class="svrnty-aw-body">' +
'<nav class="svrnty-aw-nav">' + nav + '</nav>' +
'<div class="svrnty-aw-content">' +
'<div class="svrnty-aw-disabled-banner" id="svrntyAwDisabledBanner" style="display:none">' +
'Adwright is read-only — switch to the <strong>cmo</strong> profile to run actions.' +
'</div>' +
tabs +
'</div>' +
'</div>'
);
}
function _wireNav(panel) {
panel.querySelectorAll("[data-svrnty-aw-tab]").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.getAttribute("data-svrnty-aw-tab");
_activateTab(id);
});
});
// Cross-panel link to BTE Command Center.
const bteBtn = panel.querySelector("#svrntyAwOpenBte");
if (bteBtn) bteBtn.addEventListener("click", () => {
if (window.SvrntyBTE && window.SvrntyBTE.open) window.SvrntyBTE.open();
});
}
function _activateTab(id) {
NS.state.activeTab = id;
const panel = document.getElementById("svrntyAdwrightPanel");
if (!panel) return;
panel.querySelectorAll(".svrnty-aw-nav-item").forEach((btn) => {
btn.classList.toggle("svrnty-aw-nav-active",
btn.getAttribute("data-svrnty-aw-tab") === id);
});
panel.querySelectorAll(".svrnty-aw-tab").forEach((pane) => {
pane.classList.toggle("svrnty-aw-tab-active",
pane.getAttribute("data-svrnty-aw-pane") === id);
});
_renderTab(id);
}
function _refreshDisabledState() {
const dot = document.getElementById("svrntyAwDot");
const txt = document.getElementById("svrntyAwStatusText");
const banner = document.getElementById("svrntyAwDisabledBanner");
if (!dot || !txt) return;
const cmo = _isCmoActive();
dot.classList.toggle("svrnty-aw-active", cmo);
dot.classList.toggle("svrnty-aw-inactive", !cmo);
txt.textContent = cmo
? "cmo · ready"
: (_activeProfile() + " · view-only");
if (banner) banner.style.display = cmo ? "none" : "block";
// Disable all action buttons when CMO not active.
const panel = document.getElementById("svrntyAdwrightPanel");
if (panel) {
panel.querySelectorAll("[data-svrnty-aw-needs-cmo]").forEach((b) => {
b.disabled = !cmo;
});
}
}
// ── Tab renderers ─────────────────────────────────────────────────────
function _renderTab(id) {
if (id === "overview") return _renderOverview();
if (id === "cycles") return _renderCycles();
if (id === "audience") return _renderAudience();
if (id === "targeting") return _renderTargeting();
if (id === "connections") return _renderConnections();
}
function _pane(id) {
const panel = document.getElementById("svrntyAdwrightPanel");
if (!panel) return null;
return panel.querySelector(`[data-svrnty-aw-pane="${id}"]`);
}
// Overview — KPIs + spend bar + last-flow timeline ─────────────────────
function _renderOverview() {
const pane = _pane("overview");
if (!pane) return;
const d = NS.state.data;
const kpis = _deriveKpis(d.cycles, d.recipes);
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Overview</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="refresh-overview" data-svrnty-aw-needs-cmo>Refresh</button>' +
'</div>' +
'<div class="svrnty-aw-kpi-grid">' +
_kpiCard("Impressions", kpis.impressions, kpis.imprDelta) +
_kpiCard("CTR", kpis.ctr, kpis.ctrDelta) +
_kpiCard("Cycles", kpis.cycleCount, "") +
_kpiCard("Recipes", kpis.recipeCount, "") +
'</div>' +
'<div class="svrnty-aw-section">' +
'<div class="svrnty-aw-section-title">Spend</div>' +
'<div style="font-size:12px;color:var(--muted)">$' +
_fmt(kpis.spend) + ' / $' + _fmt(kpis.budget) + '</div>' +
'<div class="svrnty-aw-spend-bar"><div class="svrnty-aw-spend-bar-fill" style="width:' +
Math.min(100, Math.round((kpis.spend / Math.max(1, kpis.budget)) * 100)) + '%"></div></div>' +
'</div>' +
'<div class="svrnty-aw-section">' +
'<div class="svrnty-aw-section-title">Last flow timeline</div>' +
_renderTimeline(d.cycles) +
'</div>';
_rewireActions(pane);
_refreshDisabledState();
}
function _wireOverview(panel) {
// Initial paint on first mount triggers a refresh attempt.
// Defer so the panel renders first; chat send needs S.session ready.
setTimeout(() => {
if (NS.state.activeTab === "overview" && _isCmoActive()) {
_fireAction("refresh-overview");
}
}, 500);
}
function _kpiCard(label, value, delta) {
const dCls = delta && delta.startsWith("-")
? "svrnty-aw-kpi-delta-down"
: (delta ? "svrnty-aw-kpi-delta-up" : "");
return (
'<div class="svrnty-aw-kpi">' +
'<div class="svrnty-aw-kpi-label">' + _esc(label) + '</div>' +
'<div class="svrnty-aw-kpi-value">' + _esc(value) + '</div>' +
(delta ? '<div class="svrnty-aw-kpi-delta ' + dCls + '">' + _esc(delta) + '</div>' : '') +
'</div>'
);
}
function _renderTimeline(cycles) {
if (!cycles || !cycles.length) {
return '<div class="svrnty-aw-empty">No recent activity. Click Refresh.</div>';
}
return '<div class="svrnty-aw-timeline">' +
cycles.slice(0, 5).map((c) =>
'<div class="svrnty-aw-timeline-item">' +
'<div class="svrnty-aw-timeline-time">' + _esc(c.started_at || "") + '</div>' +
'<div class="svrnty-aw-timeline-text">' +
_esc(c.title || ("Cycle #" + (c.id || "?"))) +
' — <span style="color:var(--muted)">' + _esc(c.status || "") + '</span>' +
'</div>' +
'</div>'
).join("") +
'</div>';
}
function _deriveKpis(cycles, recipes) {
const c = cycles || [];
const r = recipes || [];
const spend = c.reduce((s, x) => s + (x.spend || 0), 0);
const budget = c.reduce((s, x) => s + (x.budget || 0), 0) || 6000;
const impressions = c.reduce((s, x) => s + (x.impressions || 0), 0);
const ctrs = c.map((x) => x.ctr || 0).filter((v) => v > 0);
const ctr = ctrs.length ? (ctrs.reduce((s, x) => s + x, 0) / ctrs.length) : 0;
return {
impressions: impressions ? _abbrev(impressions) : "—",
imprDelta: impressions ? "+12%" : "",
ctr: ctr ? (ctr.toFixed(2) + "%") : "—",
ctrDelta: ctr ? "-0.3%" : "",
cycleCount: String(c.length || "—"),
recipeCount: String(r.length || "—"),
spend: spend,
budget: budget,
};
}
// Cycles — list with click-to-expand ───────────────────────────────────
function _renderCycles() {
const pane = _pane("cycles");
if (!pane) return;
const cycles = NS.state.data.cycles || [];
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Cycles</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="refresh-cycles" data-svrnty-aw-needs-cmo>Refresh cycles</button>' +
'</div>' +
(cycles.length
? ('<div class="svrnty-aw-list">' +
cycles.map((c) => _cycleRow(c)).join("") +
'</div>')
: '<div class="svrnty-aw-empty">No cycles loaded. Click Refresh cycles.</div>');
_rewireActions(pane);
pane.querySelectorAll("[data-svrnty-aw-cycle]").forEach((row) => {
row.addEventListener("click", () => {
const id = row.getAttribute("data-svrnty-aw-cycle");
_toggleCycleDetail(row, id);
});
});
_refreshDisabledState();
}
function _cycleRow(c) {
return (
'<div class="svrnty-aw-row" data-svrnty-aw-cycle="' + _attr(c.id) + '">' +
'<div class="svrnty-aw-row-main">' +
'<div class="svrnty-aw-row-title">' + _esc(c.title || ("Cycle #" + c.id)) + '</div>' +
'<div class="svrnty-aw-row-sub">' + _esc(c.status || "") + ' · started ' + _esc(c.started_at || "") + '</div>' +
'</div>' +
'<div class="svrnty-aw-row-meta">$' + _fmt(c.spend || 0) + ' / $' + _fmt(c.budget || 0) + '</div>' +
'</div>'
);
}
function _toggleCycleDetail(rowEl, cycleId) {
const existing = rowEl.nextElementSibling;
if (existing && existing.classList.contains("svrnty-aw-row-expanded")) {
existing.remove();
return;
}
const detail = NS.state.data.cycleDetail[cycleId];
const wrap = document.createElement("div");
wrap.className = "svrnty-aw-row-expanded";
if (detail) {
wrap.innerHTML = _renderCycleDetail(detail);
} else {
wrap.innerHTML = '<div class="svrnty-aw-loading">Loading cycle #' + _esc(cycleId) + '…</div>';
_fireAction("get-cycle", { id: cycleId });
}
rowEl.parentNode.insertBefore(wrap, rowEl.nextSibling);
}
function _renderCycleDetail(d) {
const variants = d.variants || [];
if (!variants.length) return '<div class="svrnty-aw-empty">No variants for this cycle.</div>';
return '<strong style="font-size:12px">Variants (' + variants.length + ')</strong>' +
'<div style="margin-top:6px;display:flex;flex-direction:column;gap:4px">' +
variants.map((v) =>
'<div style="display:flex;justify-content:space-between;font-size:12px">' +
'<span>' + _esc(v.name || v.id || "variant") + '</span>' +
'<span style="color:var(--muted)">' + _esc(v.status || "") + ' · imp ' + _fmt(v.impressions || 0) + '</span>' +
'</div>'
).join("") +
'</div>';
}
function _wireCycles(panel) { /* nav handler fires render */ }
// Audience — segments list ────────────────────────────────────────────
function _renderAudience() {
const pane = _pane("audience");
if (!pane) return;
const segs = NS.state.data.segments || [];
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Audience segments</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="list-segments" data-svrnty-aw-needs-cmo>Refresh</button>' +
'</div>' +
(segs.length
? '<div class="svrnty-aw-list">' +
segs.map((s) =>
'<div class="svrnty-aw-row">' +
'<div class="svrnty-aw-row-main">' +
'<div class="svrnty-aw-row-title">' + _esc(s.name || s.id) + '</div>' +
'<div class="svrnty-aw-row-sub">' + _esc(s.description || "") + '</div>' +
'</div>' +
'<div class="svrnty-aw-row-meta">' + _fmt(s.size || 0) + ' people</div>' +
'</div>'
).join("") +
'</div>'
: '<div class="svrnty-aw-empty">No segments loaded. Click Refresh.</div>');
_rewireActions(pane);
_refreshDisabledState();
}
// Targeting — segment × recipe matrix ─────────────────────────────────
function _renderTargeting() {
const pane = _pane("targeting");
if (!pane) return;
const segs = NS.state.data.segments || [];
const recs = NS.state.data.recipes || [];
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Targeting matrix</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="list-recipes" data-svrnty-aw-needs-cmo>Refresh recipes</button>' +
'</div>' +
(segs.length && recs.length
? _renderMatrix(segs, recs)
: '<div class="svrnty-aw-empty">Load segments + recipes to see the matrix.<br>' +
'<span style="font-size:11px">(Audience tab → Refresh, then Refresh recipes here.)</span></div>');
_rewireActions(pane);
_refreshDisabledState();
}
function _renderMatrix(segs, recs) {
const cols = recs.length + 1;
const cells = [];
cells.push('<div class="svrnty-aw-matrix-cell svrnty-aw-matrix-head"></div>');
recs.forEach((r) => {
cells.push('<div class="svrnty-aw-matrix-cell svrnty-aw-matrix-head">' +
_esc(r.name || r.id) + '</div>');
});
segs.forEach((s) => {
cells.push('<div class="svrnty-aw-matrix-cell svrnty-aw-matrix-row-label">' +
_esc(s.name || s.id) + '</div>');
recs.forEach((r) => {
cells.push('<div class="svrnty-aw-matrix-cell">·</div>');
});
});
return '<div class="svrnty-aw-matrix" style="grid-template-columns:repeat(' + cols + ',minmax(80px,1fr))">' +
cells.join("") + '</div>';
}
// Connections — read status + provisioning form (governance exception) ─
function _renderConnections() {
const pane = _pane("connections");
if (!pane) return;
const conns = NS.state.data.connections || [];
pane.innerHTML =
'<div class="svrnty-aw-tab-header">' +
'<div class="svrnty-aw-tab-title">Connections</div>' +
'<button class="svrnty-aw-btn" data-svrnty-aw-action="connections-status" data-svrnty-aw-needs-cmo>Re-check</button>' +
'</div>' +
'<div class="svrnty-aw-conn-list">' +
(conns.length ? conns.map(_connRow).join("") :
'<div class="svrnty-aw-empty">No status loaded. Click Re-check.</div>') +
'</div>' +
'<button class="svrnty-aw-btn svrnty-aw-btn-primary" data-svrnty-aw-action="open-cred-form">Fix credentials</button>' +
'<form class="svrnty-aw-form" id="svrntyAwCredForm">' +
'<div class="svrnty-aw-form-note">' +
'Credentials are written direct to the credctl vault via the plugin backend — ' +
'<strong>never through chat or MCP</strong> (governance exception, see PRD §6). ' +
'Leave a field blank to keep its current value.' +
'</div>' +
_credField("meta-app-id", "Meta — App ID", "text") +
_credField("meta-app-secret", "Meta — App Secret", "password") +
_credField("meta-sandbox-access-token", "Meta — Sandbox Access Token", "password") +
_credField("meta-sandbox-ad-account", "Meta — Sandbox Ad Account ID", "text") +
_credField("meta-sandbox-page-id", "Meta — Sandbox Page ID", "text") +
_credField("woocommerce-ck", "WooCommerce — Consumer Key", "password") +
_credField("woocommerce-cs", "WooCommerce — Consumer Secret", "password") +
'<div class="svrnty-aw-form-actions">' +
'<button type="button" class="svrnty-aw-btn" data-svrnty-aw-action="close-cred-form">Cancel</button>' +
'<button type="submit" class="svrnty-aw-btn svrnty-aw-btn-primary">Save credentials</button>' +
'</div>' +
'</form>';
_rewireActions(pane);
_refreshDisabledState();
}
function _credField(name, label, type) {
return '<div class="svrnty-aw-form-row">' +
'<label for="svrntyAwCred-' + name + '">' + _esc(label) + '</label>' +
'<input id="svrntyAwCred-' + name + '" name="' + name + '" type="' + type + '" autocomplete="off">' +
'</div>';
}
function _connRow(c) {
const okCls = c.ok ? " svrnty-aw-ok" : " svrnty-aw-fail";
return (
'<div class="svrnty-aw-conn">' +
'<div class="svrnty-aw-conn-name">' +
_esc(c.name || c.id || "unknown") +
'</div>' +
'<span class="svrnty-aw-conn-pill' + okCls + '">' +
_esc(c.status || (c.ok ? "ok" : "fail")) +
'</span>' +
'</div>'
);
}
function _wireConnections(panel) {
panel.addEventListener("submit", (e) => {
if (e.target && e.target.id === "svrntyAwCredForm") {
e.preventDefault();
_submitCredForm(e.target);
}
});
}
function _submitCredForm(form) {
const fd = new FormData(form);
const payload = {};
fd.forEach((v, k) => { if (v && String(v).trim()) payload[k] = String(v); });
if (!Object.keys(payload).length) {
_toast("No fields filled — nothing to save.", "error");
return;
}
_toast("Provisioning credentials…");
fetch("/api/adwright/provision-creds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then((r) => r.json())
.then((r) => {
if (r && r.ok) {
_toast("Credentials saved (" + (r.written || []).length + " field" +
((r.written || []).length === 1 ? "" : "s") + ").", "success");
form.reset();
form.classList.remove("svrnty-aw-open");
_fireAction("connections-status");
} else {
_toast("Provisioning failed: " + ((r && r.error) || "unknown"), "error");
}
})
.catch((e) => _toast("Provisioning request failed: " + (e && e.message), "error"));
}
// ── Action wiring ──────────────────────────────────────────────────────
// Reads: panel button → custom event → injects "/adwright <verb>" into the
// existing WebUI composer (#msg) and clicks #btnSend so the user message
// shows in chat. Then polls /api/adwright/last-panel-update.
// Writes (creds only): direct POST to /api/adwright/provision-creds.
function _rewireActions(scope) {
scope.querySelectorAll("[data-svrnty-aw-action]").forEach((btn) => {
if (btn.dataset.svrntyAwWired === "1") return;
btn.dataset.svrntyAwWired = "1";
btn.addEventListener("click", (e) => {
e.preventDefault();
const action = btn.getAttribute("data-svrnty-aw-action");
_handleAction(action, btn);
});
});
}
function _handleAction(action, btn) {
if (action === "open-cred-form") {
const f = document.getElementById("svrntyAwCredForm");
if (f) f.classList.add("svrnty-aw-open");
return;
}
if (action === "close-cred-form") {
const f = document.getElementById("svrntyAwCredForm");
if (f) f.classList.remove("svrnty-aw-open");
return;
}
_fireAction(action);
}
function _fireAction(action, args) {
if (!_isCmoActive()) {
_toast("Switch to CMO profile to run /adwright commands.", "error");
return;
}
args = args || {};
const cmd = _commandFor(action, args);
if (!cmd) return;
// Dispatch event for any external listeners (audit / tests).
try {
document.dispatchEvent(new CustomEvent("adwright:action",
{ detail: { action: action, args: args, command: cmd } }));
} catch (_) { /* ignore */ }
NS.state.pendingTool = _toolFor(action);
if (!_sendChatCommand(cmd)) {
_toast("Couldn't post to chat — is a session open?", "error");
return;
}
_startPolling();
}
function _commandFor(action, args) {
switch (action) {
case "refresh-overview": return "/adwright refresh-cycles";
case "refresh-cycles": return "/adwright refresh-cycles";
case "get-cycle": return "/adwright get-cycle " + (args.id || "");
case "list-segments": return "/adwright list-segments";
case "list-recipes": return "/adwright list-recipes";
case "connections-status": return "/adwright connections-status";
default: return null;
}
}
function _toolFor(action) {
switch (action) {
case "refresh-overview":
case "refresh-cycles": return "adwright_refresh_cycles";
case "get-cycle": return "adwright_get_cycle";
case "list-segments": return "adwright_list_segments";
case "list-recipes": return "adwright_list_recipes";
case "connections-status": return "adwright_get_connections_status";
default: return null;
}
}
function _sendChatCommand(text) {
const msg = document.getElementById("msg");
const btn = document.getElementById("btnSend");
if (!msg || !btn) return false;
msg.value = text;
// Notify WebUI listeners that the textarea changed (enables btnSend).
try { msg.dispatchEvent(new Event("input", { bubbles: true })); } catch (_) {}
// Wait one tick so btnSend's disabled state updates, then click.
setTimeout(() => {
try { btn.click(); } catch (_) { /* ignore */ }
}, 30);
return true;
}
// ── Polling: /api/adwright/last-panel-update ──────────────────────────
function _startPolling() {
if (NS.state.pollTimer) return;
NS.state.pollStartedAt = Date.now();
NS.state.pollTimer = setInterval(_pollOnce, POLL_INTERVAL_MS);
// Run once immediately so we don't wait the first 2s.
_pollOnce();
}
function _stopPolling() {
if (NS.state.pollTimer) {
clearInterval(NS.state.pollTimer);
NS.state.pollTimer = null;
}
}
function _pollOnce() {
if (Date.now() - NS.state.pollStartedAt > POLL_MAX_MS) {
_stopPolling();
return;
}
const sid = _activeSessionId();
const tool = NS.state.pendingTool || "";
const url = "/api/adwright/last-panel-update?session_id=" +
encodeURIComponent(sid) + "&since=" + NS.state.lastSeenTs +
"&tool=" + encodeURIComponent(tool);
fetch(url)
.then((r) => r.json())
.then((r) => {
if (!r || !r.update) return;
const u = r.update;
if (u.ts && u.ts <= NS.state.lastSeenTs) return;
NS.state.lastSeenTs = u.ts || Date.now();
_ingestUpdate(u);
// Got our update — stop polling, render.
if (u.tool === tool || !tool) {
NS.state.pendingTool = null;
_stopPolling();
}
_renderTab(NS.state.activeTab);
})
.catch(() => { /* polling is best-effort */ });
}
function _ingestUpdate(u) {
const tool = u.tool || "";
const payload = u.payload || {};
if (tool === "adwright_refresh_cycles" || tool === "adwright_list_cycles") {
NS.state.data.cycles = payload.cycles || [];
} else if (tool === "adwright_get_cycle") {
if (payload.id != null) NS.state.data.cycleDetail[payload.id] = payload;
} else if (tool === "adwright_list_segments") {
NS.state.data.segments = payload.segments || [];
} else if (tool === "adwright_list_recipes") {
NS.state.data.recipes = payload.recipes || [];
} else if (tool === "adwright_get_connections_status") {
NS.state.data.connections = payload.connections || [];
}
}
// ── Toast ─────────────────────────────────────────────────────────────
function _toast(text, kind) {
let el = document.getElementById("svrntyAwToast");
if (!el) {
el = document.createElement("div");
el.id = "svrntyAwToast";
el.className = "svrnty-aw-toast";
document.body.appendChild(el);
}
el.className = "svrnty-aw-toast" + (kind ? " svrnty-aw-toast-" + kind : "");
el.textContent = text;
requestAnimationFrame(() => el.classList.add("svrnty-aw-toast-show"));
clearTimeout(_toast._t);
_toast._t = setTimeout(() => {
el.classList.remove("svrnty-aw-toast-show");
}, 3000);
}
// ── Helpers ───────────────────────────────────────────────────────────
function _esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
}
function _attr(s) { return _esc(s); }
function _fmt(n) {
n = Number(n) || 0;
return n.toLocaleString("en-US", { maximumFractionDigits: 0 });
}
function _abbrev(n) {
n = Number(n) || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
return String(n);
}
// ── Bootstrap ─────────────────────────────────────────────────────────
// Mount when <main class="main"> exists; observer scoped to <body> children
// (NOT subtree) so we don't fight other observers. Reattach the profile
// watcher whenever the panel is (re)mounted.
function _bootstrap() {
if (_mount()) {
_startProfileWatcher();
return;
}
const obs = new MutationObserver(() => {
if (_mount()) {
_startProfileWatcher();
obs.disconnect();
}
});
obs.observe(document.body, { childList: true, subtree: false });
// Fallback re-check (some shells rebuild <main> after route changes).
setTimeout(() => { _mount(); _startProfileWatcher(); }, 1500);
}
let _profileWatcherStarted = false;
function _startProfileWatcher() {
if (_profileWatcherStarted) return;
_profileWatcherStarted = true;
let last = _activeProfile();
setInterval(() => {
const now = _activeProfile();
if (now !== last) {
last = now;
_refreshDisabledState();
}
}, 1000);
}
NS.mount = _mount;
NS.fireAction = _fireAction;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _bootstrap);
} else {
_bootstrap();
}
})();