openvibers/openvibe.shared Commits
4.31.0: the Action Center: the ✦ beside the bell opens every agent task in one place (Running, Waiting for you with Approve and Decline, Scheduled, Done, Failed), from Network's /api/v1/me/actions; phones reach it from the bell's Agents tab (#164)
Changes
@@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).## Unreleased++## 4.31.0 — 2026-10-28++++- **The Action Center** (Notifications 2.0, plan 2026-10-28 §3.10): the ✦ beside the bell opens every agent task in one++ place, on every site.++ - Tabs: Running (re-read every 15 s while open), Waiting for you (Approve and Decline right there), Scheduled, Done++ (cost, steps and the page) and Failed (why).++ - A service that did not answer is named.++ - A 420 px popover on desktop; on phones the bell's Agents tab opens it as a full-height sheet.++ - It reads Network's `GET /api/v1/me/actions` (Network#396, contracts 0.229.0).++ - `action-center.js` loads on the first click (8 KB brotli budget); `notification-ui.js` adds only the button.++ - `init({ actions: false })` leaves the ✦ out; `openActions(anchor)` opens it from a site's own control.++## 4.30.0 — 2026-10-28- **The bell center** (Notifications 2.0, plan 2026-10-28 §3.10):@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.30.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.31.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -513,6 +513,14 @@ a × and a scrim; the page underneath does not scroll).- **Touch:** a row swipes right to mark it read and left to clear it (Network's dismiss); the buttons on the time linedo the same with a mouse or keyboard.++**The Action Center (4.31.0).** The ✦ beside the bell (desktop; on phones, the bell's Agents tab links to it) opens++every agent task in one place: Running (re-read every 15 s while open), Waiting for you (Approve and Decline right++there), Scheduled, Done (cost, steps, the page) and Failed (why). It reads Network's `GET /api/v1/me/actions`++(`network.me-actions-result@1`, contracts 0.229.0) and acts through `POST /api/v1/me/actions/:service/:id/approve|decline`.++`action-center.js` loads from beside `notification-ui.js` on the first click (serve it there; it is in `files.js`).++`OpenVibeNotifications.init({ actions: false })` leaves the ✦ out; `OpenVibeNotifications.openActions(anchor)` opens it++from a site's own control.++**Quiet hours (4.30.0).** During the person's window (`GET /api/notifications/quiet-hours`, read at start and every tenminutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.30.0",++ "version": "4.31.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",nnew file mode 100644@@@ -0,0 +1,326 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — the Action Center (✦), every agent task in one place (plan 2026-10-28 §3.10; Shared 4.31.0)++//++// notification-ui.js draws the ✦ beside the bell and loads this file, from beside itself, on the first click.++// OpenVibeActionCenter.toggle({ anchor, token: () => jwt, apiBase }) · open(opts) · close() · isOpen()++//++// Reads GET <apiBase>/api/v1/me/actions network.me-actions-result@1 (Bearer, or the cookie on++// openvibe.network itself)++// Writes POST <apiBase>/api/v1/me/actions/:service/:id/approve|decline++//++// Five tabs: Running (live: re-read every 15 s while open and something runs), Waiting for you (approve or decline++// here), Scheduled, Done (a receipt: cost, steps, the page), Failed (why, and open it to try again). A service that did++// not answer is named under the tabs; the rest still show. Desktop: a 420 px popover under the bar. Phones: a full-height++// sheet with a handle (drag down, ×, or a tap outside closes it). Opaque, on the theme's raised surface, and while it is++// open toasts wait (aria-modal: notification-ui.js holds them).++// ═══════════════════════════════════════════════════════════════++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeActionCenter) return;++++ const BUCKETS = [['running', 'Running'], ['waiting', 'Waiting for you'], ['scheduled', 'Scheduled'], ['done', 'Done'], ['failed', 'Failed']];++ const SHORT = { running: 'Running', waiting: 'Waiting', scheduled: 'Scheduled', done: 'Done', failed: 'Failed' };++ const EMPTY = {++ running: ['Nothing running', 'When an agent is working for you, its steps show here as they happen.'],++ waiting: ['Nothing needs you', 'When an agent asks before it spends, sends or deletes, you approve it here.'],++ scheduled: ['Nothing scheduled', 'Agents you set to run later, like a morning brief, show here.'],++ done: ['Nothing finished yet', 'Finished work shows here with what it cost and where it is.'],++ failed: ['Nothing failed', 'If a task fails, it shows here with why, so you can try again.'],++ };++ const SERVICE = { actor: 'Actor', codes: 'Codes', ai: 'AI', network: 'Network', run: 'Run' };++ const REFRESH_MS = 15000;++ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;++ const G = {++ spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',++ code: '<path d="M8.5 7.5L4 12l4.5 4.5M15.5 7.5L20 12l-4.5 4.5M13.5 5.5l-3 13"/>',++ ai: '<rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/><path d="M9.5 3.5v3M14.5 3.5v3M9.5 17.5v3M14.5 17.5v3M3.5 9.5h3M3.5 14.5h3M17.5 9.5h3M17.5 14.5h3"/>',++ key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',++ desk: '<rect x="3.5" y="4.5" width="17" height="11" rx="2"/><path d="M9 19.5h6M12 15.5v4"/>',++ refresh: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4"/>',++ close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',++ open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',++ };++ const KIND_GLYPH = { agent_task: 'spark', code_task: 'code', ai_run: 'ai', approval: 'key', desk_session: 'desk' };++ const TONE = { running: 'var(--accent, #3b82f6)', waiting: 'var(--warning, #f5a524)', scheduled: 'var(--text-secondary, #a0a0ab)', done: 'var(--success, #30a46c)', failed: 'var(--live-red, #e5484d)' };++++ let el = null, opts = null, data = null, tab = null, timer = null, loading = false, seq = 0;++++ function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }++ const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };++ const apiBase = () => String((opts && opts.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');++ const crossOrigin = () => { try { return new URL(apiBase()).origin !== root.location.origin; } catch { return true; } };++ const tokenOf = () => { const t = opts && opts.token; return typeof t === 'function' ? t() : t; };++ function api(path, init = {}) {++ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };++ const t = tokenOf();++ if (t) headers.authorization = `Bearer ${t}`;++ return fetch(apiBase() + path, { ...init, headers, credentials: crossOrigin() ? 'omit' : 'include' }).then(async (r) => {++ const body = await r.json().catch(() => ({}));++ if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }++ return body;++ });++ }++ function ago(iso) {++ const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';++ const s = (Date.now() - t) / 1000;++ if (s < 0) { const m = -s / 60; return m < 60 ? `in ${Math.max(1, Math.round(m))} min` : m < 1440 ? `in ${Math.round(m / 60)} h` : `in ${Math.round(m / 1440)} d`; }++ if (s < 45) return 'just now';++ if (s < 3600) return `${Math.max(1, Math.floor(s / 60))} min ago`;++ if (s < 86400) return `${Math.floor(s / 3600)} h ago`;++ return `${Math.floor(s / 86400)} d ago`;++ }++ const money = (v) => (typeof v === 'number' && v > 0 ? (v < 0.01 ? `$${v.toFixed(4).replace(/0+$/, '')}` : `$${v.toFixed(2)}`) : null);++++ function styles() {++ if (document.getElementById('ovac-styles')) return;++ const st = document.createElement('style');++ st.id = 'ovac-styles';++ st.textContent = `++ @keyframes ovac-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }++ @keyframes ovac-up { from { transform: translateY(48px); opacity: .5; } to { transform: none; opacity: 1; } }++ @keyframes ovac-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }++ .ovac { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden; top: var(--ovac-top, 62px); right: max(8px, env(safe-area-inset-right, 0px));++ width: min(420px, calc(100vw - 16px)); max-height: calc(100vh - var(--ovac-top, 62px) - 12px); max-height: calc(100dvh - var(--ovac-top, 62px) - 12px);++ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));++ border-radius: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); font-family: inherit; animation: ovac-in .2s cubic-bezier(.2,.8,.2,1); }++ .ovac.open { display: flex; }++ .ovac svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++ .ovac-head { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 10px 8px 16px; }++ .ovac-head h3 { margin: 0; display: flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 750; letter-spacing: -.01em; }++ .ovac-head h3 svg { width: 19px; height: 19px; color: var(--accent-light, var(--accent, #60a5fa)); }++ .ovac-acts { display: flex; gap: 2px; }++ .ovac-acts button { width: 36px; height: 36px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }++ .ovac-acts button:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }++ .ovac-acts button svg { width: 18px; height: 18px; }++ .ovac-acts button.spin svg { animation: ovac-spin .8s linear infinite; }++ @keyframes ovac-spin { to { transform: rotate(360deg); } }++ .ovac-acts [data-act="close"] { display: none; }++ .ovac-tabs { display: flex; overflow-x: auto; scrollbar-width: none; padding: 0 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }++ .ovac-tabs::-webkit-scrollbar { display: none; }++ .ovac-tabs button { flex: none; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 10px; border: 0; background: transparent;++ color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }++ .ovac-tabs button:hover, .ovac-tabs button[aria-selected="true"] { color: var(--text-primary, #ececf1); }++ .ovac-tabs button[aria-selected="true"]::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: 0; height: 2.5px; border-radius: 2px 2px 0 0; background: var(--accent, #3b82f6); }++ .ovac-tabs button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -3px; border-radius: 10px; }++ .ovac-tabs .n { min-width: 18px; padding: 2px 5px; box-sizing: border-box; border-radius: 999px; font-size: 10.5px; font-weight: 750; line-height: 1.2; text-align: center; font-variant-numeric: tabular-nums;++ color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); }++ .ovac-tabs [data-tab="waiting"] .n { background: color-mix(in srgb, var(--warning, #f5a524) 30%, transparent); }++ .ovac-tabs .n:empty { display: none; }++ .ovac-note { margin: 8px 12px 0; padding: 8px 12px; border-radius: 12px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }++ .ovac-list { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 6px 6px 10px; }++ .ovac-item { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; padding: 10px 10px 10px 8px; border-radius: 14px; }++ .ovac-item:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 4%, transparent); }++ .ovac-glyph { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; color: color-mix(in srgb, var(--ovac-tone) 68%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovac-tone) 15%, transparent); }++ .ovac-glyph svg { width: 20px; height: 20px; }++ .ovac-body { min-width: 0; }++ .ovac-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 13.5px; font-weight: 650; line-height: 1.32; color: inherit; text-decoration: none; }++ .ovac-title:hover { text-decoration: underline; text-underline-offset: 2px; }++ .ovac-sum { margin: 2px 0 0; font-size: 12.5px; line-height: 1.42; color: var(--text-secondary, #b6b6c0); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }++ .ovac-item[data-bucket="failed"] .ovac-sum { color: color-mix(in srgb, var(--live-red, #e5484d) 55%, var(--text-primary, #ececf1)); }++ .ovac-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 6px; font-size: 11.5px; color: var(--text-muted, #8b8b96); }++ .ovac-state { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; color: color-mix(in srgb, var(--ovac-tone) 55%, var(--text-primary, #ececf1)); }++ .ovac-state::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--ovac-tone); }++ .ovac-item[data-bucket="running"] .ovac-state::before { animation: ovac-pulse 1.4s ease-in-out infinite; }++ .ovac-cost { font-variant-numeric: tabular-nums; font-weight: 650; color: var(--text-secondary, #b6b6c0); }++ .ovac-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }++ .ovac-btns button, .ovac-btns a { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 14px; border-radius: 999px; font: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer; text-decoration: none;++ border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); }++ .ovac-btns .primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }++ .ovac-btns button:disabled { opacity: .55; cursor: default; }++ .ovac-btns svg { width: 14px; height: 14px; }++ .ovac-err { margin: 6px 0 0; font-size: 12px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }++ .ovac-empty { text-align: center; padding: 44px 24px 52px; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }++ .ovac-empty svg { width: 46px; height: 46px; display: block; margin: 0 auto 12px; color: var(--accent, #3b82f6); opacity: .75; stroke-width: 1.4; }++ .ovac-empty b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }++ .ovac-wait { display: flex; justify-content: center; padding: 28px; }++ .ovac-wait span { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); animation: ovac-spin .8s linear infinite; }++ .ovac-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #8b8b96); }++ .ovac-foot a { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; text-decoration: none; }++ @media (max-width: 640px) {++ .ovac { top: calc(env(safe-area-inset-top, 0px) + 10px); left: 0; right: 0; bottom: 0; width: auto; max-height: none; border-radius: 22px 22px 0 0; border-bottom: 0;++ padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: 0 0 0 100vmax rgba(0,0,0,.5), 0 -12px 40px rgba(0,0,0,.35); animation-name: ovac-up; animation-duration: .28s; }++ .ovac-head { padding-top: 20px; touch-action: none; }++ .ovac-head::before { content: ''; position: absolute; top: 8px; left: 50%; width: 40px; height: 4px; margin-left: -20px; border-radius: 2px; background: var(--border-light, var(--border, rgba(255,255,255,.22))); }++ .ovac-acts [data-act="close"] { display: grid; }++ .ovac-acts button { width: 40px; height: 40px; }++ }++ html.ovac-sheet-open, html.ovac-sheet-open body { overflow: hidden; }++ @media (prefers-reduced-motion: reduce) { .ovac { animation: none; } .ovac-item[data-bucket="running"] .ovac-state::before { animation: none; } }++ `;++ document.head.appendChild(st);++ }++++ function build() {++ styles();++ el = document.createElement('div');++ el.className = 'ovac';++ el.setAttribute('role', 'dialog');++ el.setAttribute('aria-label', 'Agents');++ el.hidden = true;++ el.innerHTML = `++ <div class="ovac-head"><h3>${svg(G.spark)}Agents</h3>++ <div class="ovac-acts"><button type="button" data-act="refresh" title="Refresh" aria-label="Refresh">${svg(G.refresh)}</button><button type="button" data-act="close" title="Close" aria-label="Close">${svg(G.close)}</button></div></div>++ <div class="ovac-tabs" role="tablist" aria-label="Agents">${BUCKETS.map(([id]) => `<button type="button" role="tab" data-tab="${id}" aria-selected="false" tabindex="-1">${SHORT[id]}<span class="n" data-n="${id}"></span></button>`).join('')}</div>++ <div class="ovac-note" hidden></div>++ <div class="ovac-list" role="tabpanel" aria-live="polite"></div>++ <div class="ovac-foot"><span data-role="when"></span><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;++ document.body.appendChild(el);++ const tabs = [...el.querySelectorAll('[role="tab"]')];++ const pick = (b, focus) => { setTab(b.dataset.tab); if (focus) b.focus(); };++ tabs.forEach((b) => b.addEventListener('click', () => pick(b)));++ el.querySelector('.ovac-tabs').addEventListener('keydown', (e) => {++ const i = tabs.indexOf(document.activeElement); if (i < 0) return;++ const j = e.key === 'ArrowRight' ? (i + 1) % tabs.length : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : -1;++ if (j >= 0) { e.preventDefault(); pick(tabs[j], true); }++ });++ el.querySelector('[data-act="refresh"]').addEventListener('click', () => load());++ el.querySelector('[data-act="close"]').addEventListener('click', () => close());++ el.querySelector('.ovac-list').addEventListener('click', (e) => {++ const b = e.target.closest('button[data-do]'); if (!b) return;++ decide(b.closest('.ovac-item'), b.dataset.do, b);++ });++ document.addEventListener('click', (e) => { if (isOpen() && !el.contains(e.target) && !(opts && opts.anchor && opts.anchor.contains(e.target))) close(); });++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isOpen()) close(); });++ // Phones: the sheet's head drags down to close.++ const head = el.querySelector('.ovac-head');++ let y0 = null, dy = 0;++ head.addEventListener('pointerdown', (e) => { if (!isPhone() || e.pointerType === 'mouse' || e.target.closest('button')) return; y0 = e.clientY; dy = 0; el.style.transition = 'none'; });++ head.addEventListener('pointermove', (e) => { if (y0 == null) return; dy = Math.max(0, e.clientY - y0); el.style.transform = `translateY(${dy}px)`; });++ const end = () => { if (y0 == null) return; y0 = null; el.style.transition = 'transform .2s ease'; if (dy > 110) close(); else el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); };++ head.addEventListener('pointerup', end); head.addEventListener('pointercancel', end);++ if (root.OpenVibePanels) { try { root.OpenVibePanels.register({ el, openClass: 'open', id: 'action-center', close: () => close() }); } catch { /* */ } }++ }++++ function setTab(id) {++ tab = id;++ el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === id; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });++ el.querySelector('.ovac-list').setAttribute('aria-label', (BUCKETS.find(([b]) => b === id) || ['', ''])[1]);++ paint();++ }++++ function itemHTML(it) {++ const tone = TONE[it.bucket] || TONE.running;++ const meta = [++ `<span class="ovac-state">${esc(it.bucket === 'waiting' ? 'Needs you' : SHORT[it.bucket] || it.state)}</span>`,++ `<span>${esc([SERVICE[it.service] || it.service, it.agent].filter(Boolean).join(' · '))}</span>`,++ money(it.cost_usd) ? `<span class="ovac-cost">${money(it.cost_usd)}</span>` : '',++ typeof it.steps === 'number' && it.steps > 0 ? `<span>${it.steps} step${it.steps === 1 ? '' : 's'}</span>` : '',++ `<span>${esc(it.bucket === 'scheduled' && it.next_run_at ? `runs ${ago(it.next_run_at)}` : ago(it.updated_at))}</span>`,++ ].join('');++ const decides = it.bucket === 'waiting' && it.actions.includes('approve');++ const btns = decides++ ? `<div class="ovac-btns"><button type="button" class="primary" data-do="approve">Approve</button><button type="button" data-do="decline">Decline</button><a href="${esc(it.href)}">${svg(G.open)}Details</a></div>`++ : it.bucket === 'failed' && it.actions.includes('retry') ? `<div class="ovac-btns"><a href="${esc(it.href)}">${svg(G.refresh)}Try again</a></div>` : '';++ const text = it.bucket === 'failed' && it.error ? it.error : it.summary;++ return `<article class="ovac-item" data-bucket="${esc(it.bucket)}" data-service="${esc(it.service)}" data-id="${esc(it.id)}" style="--ovac-tone:${tone}">++ <span class="ovac-glyph">${svg(G[KIND_GLYPH[it.kind]] || G.spark)}</span>++ <div class="ovac-body"><a class="ovac-title" href="${esc(it.href)}">${esc(it.title)}</a>${text ? `<p class="ovac-sum">${esc(text)}</p>` : ''}++ <div class="ovac-meta">${meta}</div>${btns}</div></article>`;++ }++++ function paint() {++ if (!el) return;++ const list = el.querySelector('.ovac-list');++ const note = el.querySelector('.ovac-note');++ if (!data) { list.innerHTML = '<div class="ovac-wait"><span></span></div>'; note.hidden = true; return; }++ if (data.error) {++ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${data.error.status === 401 ? 'Sign in to see your agents' : 'Could not load your agents'}</b>${data.error.status === 401 ? '' : 'Check your connection and try again.'}</div>`;++ note.hidden = true;++ return;++ }++ for (const [id] of BUCKETS) {++ const n = data.counts[id] || 0;++ const span = el.querySelector(`[data-n="${id}"]`);++ span.textContent = n ? (n > 99 ? '99+' : String(n)) : '';++ span.parentNode.setAttribute('aria-label', n ? `${SHORT[id]}, ${n}` : SHORT[id]);++ }++ const missing = Object.entries(data.sources || {}).filter(([, s]) => s === 'timeout' || s === 'unavailable').map(([k]) => SERVICE[k] || k);++ note.hidden = !missing.length;++ note.textContent = missing.length ? `${missing.join(' and ')} did not answer, so some items may be missing. Refresh to try again.` : '';++ const items = data.buckets[tab] || [];++ if (!items.length) {++ const [t, s] = EMPTY[tab] || EMPTY.running;++ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${esc(t)}</b>${esc(s)}</div>`;++ } else list.innerHTML = items.map(itemHTML).join('');++ const when = el.querySelector('[data-role="when"]');++ when.textContent = data.generated_at ? `Updated ${ago(data.generated_at)}` : '';++ }++++ async function load({ quiet = false } = {}) {++ if (loading) return;++ loading = true;++ const my = ++seq;++ const btn = el && el.querySelector('[data-act="refresh"]');++ if (btn) btn.classList.add('spin');++ if (!quiet && !data) paint();++ try {++ const doc = await api('/api/v1/me/actions');++ if (my !== seq) return;++ data = doc;++ // First open: the tab with something in it, waiting first.++ if (!tab) tab = ['waiting', 'running', 'scheduled', 'done', 'failed'].find((b) => (doc.counts[b] || 0) > 0) || 'running';++ setTab(tab);++ } catch (e) {++ if (my !== seq) return;++ data = { error: e };++ if (!tab) setTab('running'); else paint();++ } finally {++ loading = false;++ if (btn) btn.classList.remove('spin');++ schedule();++ }++ }++ function schedule() {++ clearTimeout(timer);++ if (isOpen() && data && !data.error && (data.counts.running || 0) > 0) timer = setTimeout(() => load({ quiet: true }), REFRESH_MS);++ }++++ async function decide(item, what, btn) {++ if (!item || (what !== 'approve' && what !== 'decline')) return;++ item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = true; });++ const old = item.querySelector('.ovac-err'); if (old) old.remove();++ try {++ await api(`/api/v1/me/actions/${encodeURIComponent(item.dataset.service)}/${encodeURIComponent(item.dataset.id)}/${what}`, { method: 'POST', body: '{}' });++ btn.textContent = what === 'approve' ? 'Approved' : 'Declined';++ setTimeout(() => load({ quiet: true }), 600);++ } catch (e) {++ item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = false; });++ item.querySelector('.ovac-body').insertAdjacentHTML('beforeend', `<p class="ovac-err" role="alert">${esc(e.message || 'That did not work. Try again.')}</p>`);++ }++ }++++ function isOpen() { return !!(el && el.classList.contains('open')); }++ function open(o) {++ opts = { ...(opts || {}), ...(o || {}) };++ if (!el) build();++ if (root.OpenVibePanels) { try { root.OpenVibePanels.closeAll(el); } catch { /* */ } }++ let bottom = 56;++ try {++ const bar = opts.anchor && opts.anchor.closest && opts.anchor.closest('nav, header, .navbar');++ bottom = (bar || opts.anchor || null) ? (bar || opts.anchor).getBoundingClientRect().bottom : 56;++ } catch { /* no layout: under a 56 px bar */ }++ el.style.setProperty('--ovac-top', `${Math.max(8, Math.round(bottom) + 6)}px`);++ el.hidden = false;++ el.setAttribute('aria-modal', 'true');++ el.classList.add('open');++ el.style.transform = '';++ document.documentElement.classList.toggle('ovac-sheet-open', isPhone());++ if (opts.anchor) { opts.anchor.setAttribute('aria-expanded', 'true'); opts.anchor.classList.add('open'); }++ load();++ setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]')?.focus({ preventScroll: true }); } catch { /* */ } }, 0);++ }++ function close() {++ if (!el) return;++ const inside = el.contains(document.activeElement);++ el.classList.remove('open');++ el.removeAttribute('aria-modal');++ el.hidden = true;++ el.style.transform = '';++ clearTimeout(timer);++ document.documentElement.classList.remove('ovac-sheet-open');++ if (opts && opts.anchor) { opts.anchor.setAttribute('aria-expanded', 'false'); opts.anchor.classList.remove('open'); if (inside) { try { opts.anchor.focus({ preventScroll: true }); } catch { /* */ } } }++ }++ function toggle(o) { if (isOpen()) close(); else open(o); }++++ root.OpenVibeActionCenter = { open, close, toggle, isOpen, _state: () => ({ data, tab }) };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -20,6 +20,7 @@ const BROWSER = Object.freeze(['shipped.js', // "Recently shipped" from the network changelog (openvibe.blog)'notification-ui.js', // bell + notification panel'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)++ 'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click'account-switcher.js','user-card.js','ov-mark.js', // the OV brand mark drop-in@@@ -26,6 +26,8 @@// • operator alerts (OPERATOR_ALERT, or rich_content.kind "ops_alert") read as System cards: "Browser check// failed · events · release 80b7955", a status link, and the fix command behind a copy button in the bell —// never raw shell text in the body;++// • the ✦ beside the bell (4.31.0, desktop) opens the Action Center (action-center.js, loaded from beside this file++// on the first click): every agent task in one place; on phones the bell's Agents tab links to it;// • the bell (4.30.0) is a 420 px popover on desktop and a full-height sheet on phones: tabs (All · Mentions · Live ·// Deals & alerts · System · Agents) counted by Network's unread-by-tab, an Unread filter, day headers, thumbnails,// unread dots and grouped repeats; on touch screens a row swipes right to read and left to clear.@@@ -64,6 +66,7 @@check: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',bell: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>',key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',++ spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',};const CATEGORY_GLYPH = { stream: 'live', social: 'people', chat: 'chat', game: 'game', economy: 'coin', achievement: 'trophy', moderation: 'shield', system: 'gear', service: 'grid', admin: 'bell' };const TONE = {@@@ -168,6 +171,15 @@font-variant-numeric: tabular-nums; animation: ovn-badge-pop .36s ease; }.openvibe-bell .badge.hidden { display: none; }.openvibe-bell.ringing svg { animation: ovn-bell-ring .7s ease; transform-origin: 50% 15%; }++ /* The ✦ beside the bell: the Action Center (desktop; phones reach it from the bell's Agents tab). */++ .openvibe-actions-btn { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; }++ .openvibe-actions-btn svg { width: 21px; height: 21px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: stroke .2s; }++ .openvibe-actions-btn:hover svg, .openvibe-actions-btn.open svg { stroke: var(--accent-light, var(--accent, #60a5fa)); }++ .openvibe-actions-btn:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++ @media (max-width: 640px) { .openvibe-actions-btn { display: none; } }++ .ovn-ac-link { display: flex; align-items: center; gap: 10px; width: calc(100% - 12px); margin: 6px; padding: 10px 14px; border-radius: 14px; border: 1px dashed color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #333));++ background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; text-align: left; }++ .ovn-ac-link svg { width: 18px; height: 18px; flex: none; fill: none; stroke: var(--accent-light, var(--accent, #60a5fa)); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }/* The panel (desktop popover, phone sheet) and the full inbox share one list view. */.openvibe-notif-panel { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden;@@@ -721,6 +733,37 @@setTimeout(() => { el.remove(); flushQueue(); }, 240);}++ // ── The ✦: the Action Center, loaded from beside this file on the first click ──++ let _acLoading = null;++ function actionCenterSrc() {++ if (_config.actionCenterSrc) return _config.actionCenterSrc;++ const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);++ return `${m ? m[1] : 'https://openvibe.network/shared/'}action-center.js`;++ }++ function loadActionCenter() {++ if (root.OpenVibeActionCenter) return Promise.resolve(root.OpenVibeActionCenter);++ return _acLoading || (_acLoading = new Promise((ok) => {++ const sc = document.createElement('script');++ sc.src = actionCenterSrc(); sc.async = true;++ sc.onload = () => ok(root.OpenVibeActionCenter || null);++ sc.onerror = () => { _acLoading = null; ok(null); };++ document.head.appendChild(sc);++ }));++ }++ function openActions(anchor) {++ loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => _config.token, apiBase: _config.apiBase }); });++ }++ function createActionsEl() {++ const b = document.createElement('div');++ b.className = 'openvibe-actions-btn';++ b.setAttribute('role', 'button'); b.setAttribute('aria-label', 'Agents'); b.setAttribute('aria-haspopup', 'dialog'); b.setAttribute('aria-expanded', 'false');++ b.title = 'Agents: everything your agents are doing'; b.tabIndex = 0;++ b.innerHTML = svg(GLYPH.spark);++ b.addEventListener('click', () => openActions(b));++ b.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openActions(b); } });++ return b;++ }++// ── Bell / badge ────────────────────────────────────────function createBellEl() {const bell = document.createElement('div');@@@ -831,6 +874,7 @@if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);});list.addEventListener('click', (e) => {++ if (e.target.closest('[data-act="ac"]')) { const anchor = _bellEl; closePanel(); openActions(anchor); return; }const item = e.target.closest('.openvibe-notif-item');if (!item || (item.__ovnSwipedAt && Date.now() - item.__ovnSwipedAt < 450)) return;const id = item.dataset.id;@@@ -956,12 +1000,13 @@} catch { return ''; }}).join('');v.lastDay = lastDay;-- if (!append) list.innerHTML = '';++ if (!append) list.innerHTML = v.tab === 'agents' ? `<button type="button" class="ovn-ac-link" data-act="ac">${svg(GLYPH.spark)}<span>Everything your agents are doing: running, waiting for you, done</span></button>` : '';if (!v.items.length) {const [t0, s0] = TAB_EMPTY[v.tab] || TAB_EMPTY.all;++ const acLink = v.tab === 'agents' ? `<button type="button" class="ovn-ac-link" data-act="ac">${svg(GLYPH.spark)}<span>Everything your agents are doing: running, waiting for you, done</span></button>` : '';const title = v.q ? 'Nothing matches that search' : (v.unread ? 'You\'re all caught up' : t0);const sub = v.q ? 'Try other words.' : (v.unread ? 'Nothing unread here.' : s0);-- list.innerHTML = `<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${esc(title)}</b>${esc(sub)}</div>`;++ list.innerHTML = `${acLink}<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${esc(title)}</b>${esc(sub)}</div>`;} else {const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();list.insertAdjacentHTML('beforeend', html);@@@ -1325,10 +1370,13 @@createBell(container) {injectStyles();const bell = createBellEl();++ if (container && _config.actions !== false && !container.querySelector('.openvibe-actions-btn')) container.appendChild(createActionsEl());if (container) container.appendChild(bell);pollUnread();return bell;},++ /** Open the Action Center (the ✦) from a site's own control. */++ openActions(anchor) { injectStyles(); openActions(anchor || null); },/** Full-page inbox (same list, filters, search, infinite scroll) rendered into a container. */renderInbox(container, opts = {}) {injectStyles();@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.30.0",++ "version": "4.31.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -42,6 +42,7 @@"./nav-icons": "./nav-icons.js","./navbar": "./navbar.js","./notification-live": "./notification-live.js",++ "./action-center": "./action-center.js","./notification-ui": "./notification-ui.js","./notifications": "./notifications.js","./openvibe-sw": "./openvibe-sw.js",@@@ -21,6 +21,7 @@ const linkedom = require('linkedom');const ROOT = path.join(__dirname, '..');const LIVE_SRC = fs.readFileSync(path.join(ROOT, 'notification-live.js'), 'utf8');const UI_SRC = fs.readFileSync(path.join(ROOT, 'notification-ui.js'), 'utf8');++const AC_SRC = fs.readFileSync(path.join(ROOT, 'action-center.js'), 'utf8');const ALICE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';const API = 'https://openvibe.network';@@@ -30,7 +31,7 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5const { window: lw, document } = linkedom.parseHTML('<!doctype html><html><head></head><body></body></html>');const origin = new URL(url).origin;const out = { requests: [], es: [], timers: new Map(), intervals: [], scripts: [], now: Date.parse('2026-09-26T18:00:00Z') };-- const state = { ticketStatus: 200, subject: ALICE, count: 0, tickets: 0, newest: '2026-09-26 17:00:00', fresh: [], slow: null, tabs: null, list: [] };++ const state = { ticketStatus: 200, subject: ALICE, count: 0, tickets: 0, newest: '2026-09-26 17:00:00', fresh: [], slow: null, tabs: null, list: [], actions: null, decided: [] };let hidden = false; let tid = 0; let currentSrc = null;Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });Object.defineProperty(document, 'currentScript', { configurable: true, get: () => (currentSrc ? { src: currentSrc } : null) });@@@ -76,6 +77,8 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5return json(200, { ok: true, count: state.count });}if (p.pathname === '/api/notifications/newest') return json(200, { ok: true, notification: { created_at: state.newest } });++ if (p.pathname === '/api/v1/me/actions') return state.actions ? json(200, state.actions) : json(401, { title: 'sign in' });++ if (p.pathname.startsWith('/api/v1/me/actions/') && req.method === 'POST') { state.decided.push(p.pathname); return json(200, { item: {} }); }if (p.pathname === '/api/notifications/unread-by-tab') return state.tabs ? json(200, { ok: true, tabs: state.tabs }) : json(404, {});if (p.pathname === '/api/notifications') return json(200, { ok: true, notifications: p.searchParams.get('since') ? state.fresh : state.list, has_more: false, total: state.list.length });return json(404, {});@@@ -92,6 +95,7 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5if (src) {out.scripts.push(src);setImmediate(() => {++ if (/action-center\.js$/.test(src)) { vm.runInContext(AC_SRC, ctx, { filename: 'action-center.js' }); if (node.onload) node.onload(new ctx.Event('load')); return; }if (!/notification-live\.js$/.test(src)) { if (node.onerror) node.onerror(new ctx.Event('error')); return; }vm.runInContext(LIVE_SRC, ctx, { filename: 'notification-live.js' });if (node.onload) node.onload(new ctx.Event('load'));@@@ -422,10 +426,61 @@ const created = (subject, over = {}) => ({N.destroy();}++ // ── The ✦ Action Center (4.31.0): beside the bell, loaded on the first click, five buckets, approve here ──++ {++ const p = page();++ p.run(UI_SRC, 'notification-ui.js', 'https://openvibe.live/shared/notification-ui.js');++ const N = p.ctx.OpenVibeNotifications;++ const at = '2026-09-26T18:00:00.000Z';++ const it = (o) => ({ service: 'actor', id: 'tsk_1', kind: 'agent_task', title: 'Find three cheap flights', summary: 'Read 4 pages', bucket: 'running', state: 'running', agent: 'deepseek-flash',++ cost_usd: 0.004, steps: 6, error: null, created_at: at, updated_at: at, next_run_at: null, href: 'https://openvibe.actor/tasks/tsk_1', actions: ['open', 'cancel'], ...o });++ p.state.actions = {++ buckets: { running: [it()], waiting: [it({ id: 'apr_1', kind: 'approval', title: 'Spend <b>$0.40</b> on a desk hour', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'] })], scheduled: [], done: [], failed: [] },++ counts: { running: 1, waiting: 1, scheduled: 0, done: 0, failed: 0 }, sources: { network: 'ok', actor: 'ok', codes: 'timeout', ai: 'ok' }, partial: true, generated_at: at,++ };++ N.init({ token: 'jwt-ac', apiBase: API });++ const mount = p.document.createElement('div');++ p.document.body.appendChild(mount);++ N.createBell(mount);++ const star = mount.querySelector('.openvibe-actions-btn');++ assert.ok(star && star.nextElementSibling && star.nextElementSibling.classList.contains('openvibe-bell'), 'the ✦ sits just before the bell');++ assert.ok(!p.out.scripts.some((s) => /action-center/.test(s)), 'nothing is loaded before the first click');++ star.dispatchEvent(new p.ctx.Event('click', { bubbles: true }));++ await p.settle(20);++ assert.ok(p.out.scripts.includes('https://openvibe.live/shared/action-center.js'), 'loaded from beside notification-ui.js');++ const req = p.out.requests.find((r) => r.url === `${API}/api/v1/me/actions`);++ assert.ok(req, 'it reads the person\'s actions');++ assert.deepStrictEqual([req.headers.authorization, req.credentials], ['Bearer jwt-ac', 'omit'], 'cross-site: the token, never cookies');++ const panel = p.document.querySelector('.ovac');++ assert.ok(panel && panel.classList.contains('open') && panel.getAttribute('aria-modal') === 'true', 'open, and modal so toasts wait');++ assert.strictEqual(star.getAttribute('aria-expanded'), 'true');++ assert.strictEqual(panel.querySelector('[role="tab"][aria-selected="true"]').dataset.tab, 'waiting', 'what needs the person comes first');++ assert.deepStrictEqual(['running', 'waiting', 'done'].map((b) => panel.querySelector(`[data-n="${b}"]`).textContent), ['1', '1', '']);++ assert.match(panel.querySelector('.ovac-note').textContent, /Codes did not answer/, 'a source that did not answer is named');++ const row = panel.querySelector('.ovac-item');++ assert.strictEqual(row.querySelector('.ovac-title').textContent, 'Spend <b>$0.40</b> on a desk hour', 'text, never markup');++ row.querySelector('button[data-do="approve"]').dispatchEvent(new p.ctx.Event('click', { bubbles: true }));++ await p.settle(20);++ assert.deepStrictEqual(p.state.decided, ['/api/v1/me/actions/actor/apr_1/approve'], 'approve goes to Network for the owning service');++ panel.querySelector('[data-tab="running"]').dispatchEvent(new p.ctx.Event('click', { bubbles: true }));++ const meta = panel.querySelector('.ovac-item .ovac-meta').textContent;++ assert.ok(/Actor · deepseek-flash/.test(meta) && /\$0\.004/.test(meta) && /6 steps/.test(meta), meta);++ panel.querySelector('[data-act="close"]').dispatchEvent(new p.ctx.Event('click', { bubbles: true }));++ assert.ok(!panel.classList.contains('open') && star.getAttribute('aria-expanded') === 'false', '× closes it');++ // Signed out: says so.++ p.state.actions = null;++ p.ctx.OpenVibeActionCenter.open({ anchor: star, token: () => 'jwt-ac', apiBase: API });++ await p.settle(20);++ assert.match(panel.querySelector('.ovac-empty b').textContent, /Sign in/);++ N.destroy();++ }++// ── Size: loaded on demand, after sign-in ──────────────────────────────────────────────const { brotli } = require('../scripts/size-report');const size = (f) => brotli(fs.readFileSync(path.join(ROOT, f)));assert.ok(size('notification-live.js') <= 3 * 1024, `notification-live.js is ${(size('notification-live.js') / 1024).toFixed(2)} KB brotli, budget 3 KB`);++ // The Action Center loads on the ✦'s first click only.++ assert.ok(size('action-center.js') <= 8 * 1024, `action-center.js is ${(size('action-center.js') / 1024).toFixed(2)} KB brotli, budget 8 KB`);// 13 → 20 KB on 2026-10-28 (4.27.0, Notifications 2.0: line glyphs, System cards, grouping, layering, priority, the// bell's rich list). It loads only after sign-in, when the bell mounts, never on first paint; measured 19.16 KB.// 20 → 24 KB on 2026-10-28 (4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe to read or
@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,18 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## Unreleased
+## 4.31.0 — 2026-10-28
+
+- **The Action Center** (Notifications 2.0, plan 2026-10-28 §3.10): the ✦ beside the bell opens every agent task in one
+ place, on every site.
+ - Tabs: Running (re-read every 15 s while open), Waiting for you (Approve and Decline right there), Scheduled, Done
+ (cost, steps and the page) and Failed (why).
+ - A service that did not answer is named.
+ - A 420 px popover on desktop; on phones the bell's Agents tab opens it as a full-height sheet.
+ - It reads Network's `GET /api/v1/me/actions` (Network#396, contracts 0.229.0).
+ - `action-center.js` loads on the first click (8 KB brotli budget); `notification-ui.js` adds only the button.
+ - `init({ actions: false })` leaves the ✦ out; `openActions(anchor)` opens it from a site's own control.
+
## 4.30.0 — 2026-10-28
## 4.30.0 — 2026-10-28
- **The bell center** (Notifications 2.0, plan 2026-10-28 §3.10):
- **The bell center** (Notifications 2.0, plan 2026-10-28 §3.10):
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.30.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.31.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -513,6 +513,14 @@ a × and a scrim; the page underneath does not scroll).
@@ -513,6 +513,14 @@ a × and a scrim; the page underneath does not scroll).
- **Touch:** a row swipes right to mark it read and left to clear it (Network's dismiss); the buttons on the time line
- **Touch:** a row swipes right to mark it read and left to clear it (Network's dismiss); the buttons on the time line
do the same with a mouse or keyboard.
do the same with a mouse or keyboard.
+**The Action Center (4.31.0).** The ✦ beside the bell (desktop; on phones, the bell's Agents tab links to it) opens
+every agent task in one place: Running (re-read every 15 s while open), Waiting for you (Approve and Decline right
+there), Scheduled, Done (cost, steps, the page) and Failed (why). It reads Network's `GET /api/v1/me/actions`
+(`network.me-actions-result@1`, contracts 0.229.0) and acts through `POST /api/v1/me/actions/:service/:id/approve|decline`.
+`action-center.js` loads from beside `notification-ui.js` on the first click (serve it there; it is in `files.js`).
+`OpenVibeNotifications.init({ actions: false })` leaves the ✦ out; `OpenVibeNotifications.openActions(anchor)` opens it
+from a site's own control.
+
**Quiet hours (4.30.0).** During the person's window (`GET /api/notifications/quiet-hours`, read at start and every ten
**Quiet hours (4.30.0).** During the person's window (`GET /api/notifications/quiet-hours`, read at start and every ten
minutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.
minutes), toasts and sounds wait; the bell and the badge still fill, and a critical notice still shows.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.30.0",
+ "version": "4.31.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,326 @@
@@ -0,0 +1,326 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — the Action Center (✦), every agent task in one place (plan 2026-10-28 §3.10; Shared 4.31.0)
+//
+// notification-ui.js draws the ✦ beside the bell and loads this file, from beside itself, on the first click.
+// OpenVibeActionCenter.toggle({ anchor, token: () => jwt, apiBase }) · open(opts) · close() · isOpen()
+//
+// Reads GET <apiBase>/api/v1/me/actions network.me-actions-result@1 (Bearer, or the cookie on
+// openvibe.network itself)
+// Writes POST <apiBase>/api/v1/me/actions/:service/:id/approve|decline
+//
+// Five tabs: Running (live: re-read every 15 s while open and something runs), Waiting for you (approve or decline
+// here), Scheduled, Done (a receipt: cost, steps, the page), Failed (why, and open it to try again). A service that did
+// not answer is named under the tabs; the rest still show. Desktop: a 420 px popover under the bar. Phones: a full-height
+// sheet with a handle (drag down, ×, or a tap outside closes it). Opaque, on the theme's raised surface, and while it is
+// open toasts wait (aria-modal: notification-ui.js holds them).
+// ═══════════════════════════════════════════════════════════════
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeActionCenter) return;
+
+ const BUCKETS = [['running', 'Running'], ['waiting', 'Waiting for you'], ['scheduled', 'Scheduled'], ['done', 'Done'], ['failed', 'Failed']];
+ const SHORT = { running: 'Running', waiting: 'Waiting', scheduled: 'Scheduled', done: 'Done', failed: 'Failed' };
+ const EMPTY = {
+ running: ['Nothing running', 'When an agent is working for you, its steps show here as they happen.'],
+ waiting: ['Nothing needs you', 'When an agent asks before it spends, sends or deletes, you approve it here.'],
+ scheduled: ['Nothing scheduled', 'Agents you set to run later, like a morning brief, show here.'],
+ done: ['Nothing finished yet', 'Finished work shows here with what it cost and where it is.'],
+ failed: ['Nothing failed', 'If a task fails, it shows here with why, so you can try again.'],
+ };
+ const SERVICE = { actor: 'Actor', codes: 'Codes', ai: 'AI', network: 'Network', run: 'Run' };
+ const REFRESH_MS = 15000;
+ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
+ const G = {
+ spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
+ code: '<path d="M8.5 7.5L4 12l4.5 4.5M15.5 7.5L20 12l-4.5 4.5M13.5 5.5l-3 13"/>',
+ ai: '<rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/><path d="M9.5 3.5v3M14.5 3.5v3M9.5 17.5v3M14.5 17.5v3M3.5 9.5h3M3.5 14.5h3M17.5 9.5h3M17.5 14.5h3"/>',
+ key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',
+ desk: '<rect x="3.5" y="4.5" width="17" height="11" rx="2"/><path d="M9 19.5h6M12 15.5v4"/>',
+ refresh: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4"/>',
+ close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>',
+ open: '<path d="M14 4h6v6M20 4l-9 9M19 14v5a1.5 1.5 0 0 1-1.5 1.5h-12A1.5 1.5 0 0 1 4 19V7a1.5 1.5 0 0 1 1.5-1.5H10"/>',
+ };
+ const KIND_GLYPH = { agent_task: 'spark', code_task: 'code', ai_run: 'ai', approval: 'key', desk_session: 'desk' };
+ const TONE = { running: 'var(--accent, #3b82f6)', waiting: 'var(--warning, #f5a524)', scheduled: 'var(--text-secondary, #a0a0ab)', done: 'var(--success, #30a46c)', failed: 'var(--live-red, #e5484d)' };
+
+ let el = null, opts = null, data = null, tab = null, timer = null, loading = false, seq = 0;
+
+ function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); }
+ const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } };
+ const apiBase = () => String((opts && opts.apiBase) || 'https://openvibe.network').replace(/\/+$/, '');
+ const crossOrigin = () => { try { return new URL(apiBase()).origin !== root.location.origin; } catch { return true; } };
+ const tokenOf = () => { const t = opts && opts.token; return typeof t === 'function' ? t() : t; };
+ function api(path, init = {}) {
+ const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) };
+ const t = tokenOf();
+ if (t) headers.authorization = `Bearer ${t}`;
+ return fetch(apiBase() + path, { ...init, headers, credentials: crossOrigin() ? 'omit' : 'include' }).then(async (r) => {
+ const body = await r.json().catch(() => ({}));
+ if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; }
+ return body;
+ });
+ }
+ function ago(iso) {
+ const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';
+ const s = (Date.now() - t) / 1000;
+ if (s < 0) { const m = -s / 60; return m < 60 ? `in ${Math.max(1, Math.round(m))} min` : m < 1440 ? `in ${Math.round(m / 60)} h` : `in ${Math.round(m / 1440)} d`; }
+ if (s < 45) return 'just now';
+ if (s < 3600) return `${Math.max(1, Math.floor(s / 60))} min ago`;
+ if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
+ return `${Math.floor(s / 86400)} d ago`;
+ }
+ const money = (v) => (typeof v === 'number' && v > 0 ? (v < 0.01 ? `$${v.toFixed(4).replace(/0+$/, '')}` : `$${v.toFixed(2)}`) : null);
+
+ function styles() {
+ if (document.getElementById('ovac-styles')) return;
+ const st = document.createElement('style');
+ st.id = 'ovac-styles';
+ st.textContent = `
+ @keyframes ovac-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } }
+ @keyframes ovac-up { from { transform: translateY(48px); opacity: .5; } to { transform: none; opacity: 1; } }
+ @keyframes ovac-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
+ .ovac { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden; top: var(--ovac-top, 62px); right: max(8px, env(safe-area-inset-right, 0px));
+ width: min(420px, calc(100vw - 16px)); max-height: calc(100vh - var(--ovac-top, 62px) - 12px); max-height: calc(100dvh - var(--ovac-top, 62px) - 12px);
+ background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1));
+ border-radius: 18px; box-shadow: 0 24px 60px rgba(0,0,0,.45); font-family: inherit; animation: ovac-in .2s cubic-bezier(.2,.8,.2,1); }
+ .ovac.open { display: flex; }
+ .ovac svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+ .ovac-head { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 10px 8px 16px; }
+ .ovac-head h3 { margin: 0; display: flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 750; letter-spacing: -.01em; }
+ .ovac-head h3 svg { width: 19px; height: 19px; color: var(--accent-light, var(--accent, #60a5fa)); }
+ .ovac-acts { display: flex; gap: 2px; }
+ .ovac-acts button { width: 36px; height: 36px; border: 0; border-radius: 10px; background: transparent; color: var(--text-muted, #8b8b96); cursor: pointer; display: grid; place-items: center; }
+ .ovac-acts button:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); }
+ .ovac-acts button svg { width: 18px; height: 18px; }
+ .ovac-acts button.spin svg { animation: ovac-spin .8s linear infinite; }
+ @keyframes ovac-spin { to { transform: rotate(360deg); } }
+ .ovac-acts [data-act="close"] { display: none; }
+ .ovac-tabs { display: flex; overflow-x: auto; scrollbar-width: none; padding: 0 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
+ .ovac-tabs::-webkit-scrollbar { display: none; }
+ .ovac-tabs button { flex: none; position: relative; display: inline-flex; align-items: center; gap: 6px; min-height: 40px; padding: 0 10px; border: 0; background: transparent;
+ color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; }
+ .ovac-tabs button:hover, .ovac-tabs button[aria-selected="true"] { color: var(--text-primary, #ececf1); }
+ .ovac-tabs button[aria-selected="true"]::after { content: ''; position: absolute; left: 10px; right: 10px; bottom: 0; height: 2.5px; border-radius: 2px 2px 0 0; background: var(--accent, #3b82f6); }
+ .ovac-tabs button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -3px; border-radius: 10px; }
+ .ovac-tabs .n { min-width: 18px; padding: 2px 5px; box-sizing: border-box; border-radius: 999px; font-size: 10.5px; font-weight: 750; line-height: 1.2; text-align: center; font-variant-numeric: tabular-nums;
+ color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); }
+ .ovac-tabs [data-tab="waiting"] .n { background: color-mix(in srgb, var(--warning, #f5a524) 30%, transparent); }
+ .ovac-tabs .n:empty { display: none; }
+ .ovac-note { margin: 8px 12px 0; padding: 8px 12px; border-radius: 12px; font-size: 12.5px; line-height: 1.4; color: var(--text-secondary, #b6b6c0); background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }
+ .ovac-list { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 6px 6px 10px; }
+ .ovac-item { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; padding: 10px 10px 10px 8px; border-radius: 14px; }
+ .ovac-item:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 4%, transparent); }
+ .ovac-glyph { width: 38px; height: 38px; border-radius: 12px; display: grid; place-items: center; color: color-mix(in srgb, var(--ovac-tone) 68%, var(--text-primary, #ececf1)); background: color-mix(in srgb, var(--ovac-tone) 15%, transparent); }
+ .ovac-glyph svg { width: 20px; height: 20px; }
+ .ovac-body { min-width: 0; }
+ .ovac-title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font-size: 13.5px; font-weight: 650; line-height: 1.32; color: inherit; text-decoration: none; }
+ .ovac-title:hover { text-decoration: underline; text-underline-offset: 2px; }
+ .ovac-sum { margin: 2px 0 0; font-size: 12.5px; line-height: 1.42; color: var(--text-secondary, #b6b6c0); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
+ .ovac-item[data-bucket="failed"] .ovac-sum { color: color-mix(in srgb, var(--live-red, #e5484d) 55%, var(--text-primary, #ececf1)); }
+ .ovac-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin-top: 6px; font-size: 11.5px; color: var(--text-muted, #8b8b96); }
+ .ovac-state { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; color: color-mix(in srgb, var(--ovac-tone) 55%, var(--text-primary, #ececf1)); }
+ .ovac-state::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--ovac-tone); }
+ .ovac-item[data-bucket="running"] .ovac-state::before { animation: ovac-pulse 1.4s ease-in-out infinite; }
+ .ovac-cost { font-variant-numeric: tabular-nums; font-weight: 650; color: var(--text-secondary, #b6b6c0); }
+ .ovac-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
+ .ovac-btns button, .ovac-btns a { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 14px; border-radius: 999px; font: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer; text-decoration: none;
+ border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); }
+ .ovac-btns .primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); }
+ .ovac-btns button:disabled { opacity: .55; cursor: default; }
+ .ovac-btns svg { width: 14px; height: 14px; }
+ .ovac-err { margin: 6px 0 0; font-size: 12px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); }
+ .ovac-empty { text-align: center; padding: 44px 24px 52px; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; }
+ .ovac-empty svg { width: 46px; height: 46px; display: block; margin: 0 auto 12px; color: var(--accent, #3b82f6); opacity: .75; stroke-width: 1.4; }
+ .ovac-empty b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); }
+ .ovac-wait { display: flex; justify-content: center; padding: 28px; }
+ .ovac-wait span { width: 20px; height: 20px; border-radius: 50%; border: 2px solid var(--border, rgba(255,255,255,.12)); border-top-color: var(--accent, #3b82f6); animation: ovac-spin .8s linear infinite; }
+ .ovac-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 16px; border-top: 1px solid var(--border, rgba(255,255,255,.08)); font-size: 12px; color: var(--text-muted, #8b8b96); }
+ .ovac-foot a { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; text-decoration: none; }
+ @media (max-width: 640px) {
+ .ovac { top: calc(env(safe-area-inset-top, 0px) + 10px); left: 0; right: 0; bottom: 0; width: auto; max-height: none; border-radius: 22px 22px 0 0; border-bottom: 0;
+ padding-bottom: env(safe-area-inset-bottom, 0px); box-shadow: 0 0 0 100vmax rgba(0,0,0,.5), 0 -12px 40px rgba(0,0,0,.35); animation-name: ovac-up; animation-duration: .28s; }
+ .ovac-head { padding-top: 20px; touch-action: none; }
+ .ovac-head::before { content: ''; position: absolute; top: 8px; left: 50%; width: 40px; height: 4px; margin-left: -20px; border-radius: 2px; background: var(--border-light, var(--border, rgba(255,255,255,.22))); }
+ .ovac-acts [data-act="close"] { display: grid; }
+ .ovac-acts button { width: 40px; height: 40px; }
+ }
+ html.ovac-sheet-open, html.ovac-sheet-open body { overflow: hidden; }
+ @media (prefers-reduced-motion: reduce) { .ovac { animation: none; } .ovac-item[data-bucket="running"] .ovac-state::before { animation: none; } }
+ `;
+ document.head.appendChild(st);
+ }
+
+ function build() {
+ styles();
+ el = document.createElement('div');
+ el.className = 'ovac';
+ el.setAttribute('role', 'dialog');
+ el.setAttribute('aria-label', 'Agents');
+ el.hidden = true;
+ el.innerHTML = `
+ <div class="ovac-head"><h3>${svg(G.spark)}Agents</h3>
+ <div class="ovac-acts"><button type="button" data-act="refresh" title="Refresh" aria-label="Refresh">${svg(G.refresh)}</button><button type="button" data-act="close" title="Close" aria-label="Close">${svg(G.close)}</button></div></div>
+ <div class="ovac-tabs" role="tablist" aria-label="Agents">${BUCKETS.map(([id]) => `<button type="button" role="tab" data-tab="${id}" aria-selected="false" tabindex="-1">${SHORT[id]}<span class="n" data-n="${id}"></span></button>`).join('')}</div>
+ <div class="ovac-note" hidden></div>
+ <div class="ovac-list" role="tabpanel" aria-live="polite"></div>
+ <div class="ovac-foot"><span data-role="when"></span><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
+ document.body.appendChild(el);
+ const tabs = [...el.querySelectorAll('[role="tab"]')];
+ const pick = (b, focus) => { setTab(b.dataset.tab); if (focus) b.focus(); };
+ tabs.forEach((b) => b.addEventListener('click', () => pick(b)));
+ el.querySelector('.ovac-tabs').addEventListener('keydown', (e) => {
+ const i = tabs.indexOf(document.activeElement); if (i < 0) return;
+ const j = e.key === 'ArrowRight' ? (i + 1) % tabs.length : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : -1;
+ if (j >= 0) { e.preventDefault(); pick(tabs[j], true); }
+ });
+ el.querySelector('[data-act="refresh"]').addEventListener('click', () => load());
+ el.querySelector('[data-act="close"]').addEventListener('click', () => close());
+ el.querySelector('.ovac-list').addEventListener('click', (e) => {
+ const b = e.target.closest('button[data-do]'); if (!b) return;
+ decide(b.closest('.ovac-item'), b.dataset.do, b);
+ });
+ document.addEventListener('click', (e) => { if (isOpen() && !el.contains(e.target) && !(opts && opts.anchor && opts.anchor.contains(e.target))) close(); });
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isOpen()) close(); });
+ // Phones: the sheet's head drags down to close.
+ const head = el.querySelector('.ovac-head');
+ let y0 = null, dy = 0;
+ head.addEventListener('pointerdown', (e) => { if (!isPhone() || e.pointerType === 'mouse' || e.target.closest('button')) return; y0 = e.clientY; dy = 0; el.style.transition = 'none'; });
+ head.addEventListener('pointermove', (e) => { if (y0 == null) return; dy = Math.max(0, e.clientY - y0); el.style.transform = `translateY(${dy}px)`; });
+ const end = () => { if (y0 == null) return; y0 = null; el.style.transition = 'transform .2s ease'; if (dy > 110) close(); else el.style.transform = ''; setTimeout(() => { el.style.transition = ''; }, 220); };
+ head.addEventListener('pointerup', end); head.addEventListener('pointercancel', end);
+ if (root.OpenVibePanels) { try { root.OpenVibePanels.register({ el, openClass: 'open', id: 'action-center', close: () => close() }); } catch { /* */ } }
+ }
+
+ function setTab(id) {
+ tab = id;
+ el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === id; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
+ el.querySelector('.ovac-list').setAttribute('aria-label', (BUCKETS.find(([b]) => b === id) || ['', ''])[1]);
+ paint();
+ }
+
+ function itemHTML(it) {
+ const tone = TONE[it.bucket] || TONE.running;
+ const meta = [
+ `<span class="ovac-state">${esc(it.bucket === 'waiting' ? 'Needs you' : SHORT[it.bucket] || it.state)}</span>`,
+ `<span>${esc([SERVICE[it.service] || it.service, it.agent].filter(Boolean).join(' · '))}</span>`,
+ money(it.cost_usd) ? `<span class="ovac-cost">${money(it.cost_usd)}</span>` : '',
+ typeof it.steps === 'number' && it.steps > 0 ? `<span>${it.steps} step${it.steps === 1 ? '' : 's'}</span>` : '',
+ `<span>${esc(it.bucket === 'scheduled' && it.next_run_at ? `runs ${ago(it.next_run_at)}` : ago(it.updated_at))}</span>`,
+ ].join('');
+ const decides = it.bucket === 'waiting' && it.actions.includes('approve');
+ const btns = decides
+ ? `<div class="ovac-btns"><button type="button" class="primary" data-do="approve">Approve</button><button type="button" data-do="decline">Decline</button><a href="${esc(it.href)}">${svg(G.open)}Details</a></div>`
+ : it.bucket === 'failed' && it.actions.includes('retry') ? `<div class="ovac-btns"><a href="${esc(it.href)}">${svg(G.refresh)}Try again</a></div>` : '';
+ const text = it.bucket === 'failed' && it.error ? it.error : it.summary;
+ return `<article class="ovac-item" data-bucket="${esc(it.bucket)}" data-service="${esc(it.service)}" data-id="${esc(it.id)}" style="--ovac-tone:${tone}">
+ <span class="ovac-glyph">${svg(G[KIND_GLYPH[it.kind]] || G.spark)}</span>
+ <div class="ovac-body"><a class="ovac-title" href="${esc(it.href)}">${esc(it.title)}</a>${text ? `<p class="ovac-sum">${esc(text)}</p>` : ''}
+ <div class="ovac-meta">${meta}</div>${btns}</div></article>`;
+ }
+
+ function paint() {
+ if (!el) return;
+ const list = el.querySelector('.ovac-list');
+ const note = el.querySelector('.ovac-note');
+ if (!data) { list.innerHTML = '<div class="ovac-wait"><span></span></div>'; note.hidden = true; return; }
+ if (data.error) {
+ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${data.error.status === 401 ? 'Sign in to see your agents' : 'Could not load your agents'}</b>${data.error.status === 401 ? '' : 'Check your connection and try again.'}</div>`;
+ note.hidden = true;
+ return;
+ }
+ for (const [id] of BUCKETS) {
+ const n = data.counts[id] || 0;
+ const span = el.querySelector(`[data-n="${id}"]`);
+ span.textContent = n ? (n > 99 ? '99+' : String(n)) : '';
+ span.parentNode.setAttribute('aria-label', n ? `${SHORT[id]}, ${n}` : SHORT[id]);
+ }
+ const missing = Object.entries(data.sources || {}).filter(([, s]) => s === 'timeout' || s === 'unavailable').map(([k]) => SERVICE[k] || k);
+ note.hidden = !missing.length;
+ note.textContent = missing.length ? `${missing.join(' and ')} did not answer, so some items may be missing. Refresh to try again.` : '';
+ const items = data.buckets[tab] || [];
+ if (!items.length) {
+ const [t, s] = EMPTY[tab] || EMPTY.running;
+ list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${esc(t)}</b>${esc(s)}</div>`;
+ } else list.innerHTML = items.map(itemHTML).join('');
+ const when = el.querySelector('[data-role="when"]');
+ when.textContent = data.generated_at ? `Updated ${ago(data.generated_at)}` : '';
+ }
+
+ async function load({ quiet = false } = {}) {
+ if (loading) return;
+ loading = true;
+ const my = ++seq;
+ const btn = el && el.querySelector('[data-act="refresh"]');
+ if (btn) btn.classList.add('spin');
+ if (!quiet && !data) paint();
+ try {
+ const doc = await api('/api/v1/me/actions');
+ if (my !== seq) return;
+ data = doc;
+ // First open: the tab with something in it, waiting first.
+ if (!tab) tab = ['waiting', 'running', 'scheduled', 'done', 'failed'].find((b) => (doc.counts[b] || 0) > 0) || 'running';
+ setTab(tab);
+ } catch (e) {
+ if (my !== seq) return;
+ data = { error: e };
+ if (!tab) setTab('running'); else paint();
+ } finally {
+ loading = false;
+ if (btn) btn.classList.remove('spin');
+ schedule();
+ }
+ }
+ function schedule() {
+ clearTimeout(timer);
+ if (isOpen() && data && !data.error && (data.counts.running || 0) > 0) timer = setTimeout(() => load({ quiet: true }), REFRESH_MS);
+ }
+
+ async function decide(item, what, btn) {
+ if (!item || (what !== 'approve' && what !== 'decline')) return;
+ item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = true; });
+ const old = item.querySelector('.ovac-err'); if (old) old.remove();
+ try {
+ await api(`/api/v1/me/actions/${encodeURIComponent(item.dataset.service)}/${encodeURIComponent(item.dataset.id)}/${what}`, { method: 'POST', body: '{}' });
+ btn.textContent = what === 'approve' ? 'Approved' : 'Declined';
+ setTimeout(() => load({ quiet: true }), 600);
+ } catch (e) {
+ item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = false; });
+ item.querySelector('.ovac-body').insertAdjacentHTML('beforeend', `<p class="ovac-err" role="alert">${esc(e.message || 'That did not work. Try again.')}</p>`);
+ }
+ }
+
+ function isOpen() { return !!(el && el.classList.contains('open')); }
+ function open(o) {
+ opts = { ...(opts || {}), ...(o || {}) };
+ if (!el) build();
+ if (root.OpenVibePanels) { try { root.OpenVibePanels.closeAll(el); } catch { /* */ } }
+ let bottom = 56;
+ try {
+ const bar = opts.anchor && opts.anchor.closest && opts.anchor.closest('nav, header, .navbar');
+ bottom = (bar || opts.anchor || null) ? (bar || opts.anchor).getBoundingClientRect().bottom : 56;
+ } catch { /* no layout: under a 56 px bar */ }
+ el.style.setProperty('--ovac-top', `${Math.max(8, Math.round(bottom) + 6)}px`);
+ el.hidden = false;
+ el.setAttribute('aria-modal', 'true');
+ el.classList.add('open');
+ el.style.transform = '';
+ document.documentElement.classList.toggle('ovac-sheet-open', isPhone());
+ if (opts.anchor) { opts.anchor.setAttribute('aria-expanded', 'true'); opts.anchor.classList.add('open'); }
+ load();
+ setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]')?.focus({ preventScroll: true }); } catch { /* */ } }, 0);
+ }
+ function close() {
+ if (!el) return;
+ const inside = el.contains(document.activeElement);
+ el.classList.remove('open');
+ el.removeAttribute('aria-modal');
+ el.hidden = true;
+ el.style.transform = '';
+ clearTimeout(timer);
+ document.documentElement.classList.remove('ovac-sheet-open');
+ if (opts && opts.anchor) { opts.anchor.setAttribute('aria-expanded', 'false'); opts.anchor.classList.remove('open'); if (inside) { try { opts.anchor.focus({ preventScroll: true }); } catch { /* */ } } }
+ }
+ function toggle(o) { if (isOpen()) close(); else open(o); }
+
+ root.OpenVibeActionCenter = { open, close, toggle, isOpen, _state: () => ({ data, tab }) };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -20,6 +20,7 @@ const BROWSER = Object.freeze([
@@ -20,6 +20,7 @@ const BROWSER = Object.freeze([
'shipped.js', // "Recently shipped" from the network changelog (openvibe.blog)
'shipped.js', // "Recently shipped" from the network changelog (openvibe.blog)
'notification-ui.js', // bell + notification panel
'notification-ui.js', // bell + notification panel
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
+ 'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'account-switcher.js',
'account-switcher.js',
'user-card.js',
'user-card.js',
'ov-mark.js', // the OV brand mark drop-in
'ov-mark.js', // the OV brand mark drop-in
@@ -26,6 +26,8 @@
@@ -26,6 +26,8 @@
// • operator alerts (OPERATOR_ALERT, or rich_content.kind "ops_alert") read as System cards: "Browser check
// • operator alerts (OPERATOR_ALERT, or rich_content.kind "ops_alert") read as System cards: "Browser check
// failed · events · release 80b7955", a status link, and the fix command behind a copy button in the bell —
// failed · events · release 80b7955", a status link, and the fix command behind a copy button in the bell —
// never raw shell text in the body;
// never raw shell text in the body;
+// • the ✦ beside the bell (4.31.0, desktop) opens the Action Center (action-center.js, loaded from beside this file
+// on the first click): every agent task in one place; on phones the bell's Agents tab links to it;
// • the bell (4.30.0) is a 420 px popover on desktop and a full-height sheet on phones: tabs (All · Mentions · Live ·
// • the bell (4.30.0) is a 420 px popover on desktop and a full-height sheet on phones: tabs (All · Mentions · Live ·
// Deals & alerts · System · Agents) counted by Network's unread-by-tab, an Unread filter, day headers, thumbnails,
// Deals & alerts · System · Agents) counted by Network's unread-by-tab, an Unread filter, day headers, thumbnails,
// unread dots and grouped repeats; on touch screens a row swipes right to read and left to clear.
// unread dots and grouped repeats; on touch screens a row swipes right to read and left to clear.
@@ -64,6 +66,7 @@
@@ -64,6 +66,7 @@
check: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',
check: '<path d="M5 12.6l4.6 4.6L19 7.6"/>',
bell: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>',
bell: '<path d="M6.2 16.2V11a5.8 5.8 0 0 1 11.6 0v5.2l1.6 2H4.6z"/><path d="M10 20.2a2.1 2.1 0 0 0 4 0"/>',
key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',
key: '<circle cx="8.2" cy="14.8" r="4"/><path d="M11.1 12l8.2-8.2M16.4 6.7l2.3 2.3M14.2 8.9l1.8 1.8"/>',
+ spark: '<path d="M12 3.5l1.9 5.1 5.1 1.9-5.1 1.9-1.9 5.1-1.9-5.1L5 10.5l5.1-1.9z"/><path d="M18.4 15.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z"/>',
};
};
const CATEGORY_GLYPH = { stream: 'live', social: 'people', chat: 'chat', game: 'game', economy: 'coin', achievement: 'trophy', moderation: 'shield', system: 'gear', service: 'grid', admin: 'bell' };
const CATEGORY_GLYPH = { stream: 'live', social: 'people', chat: 'chat', game: 'game', economy: 'coin', achievement: 'trophy', moderation: 'shield', system: 'gear', service: 'grid', admin: 'bell' };
const TONE = {
const TONE = {
@@ -168,6 +171,15 @@
@@ -168,6 +171,15 @@
font-variant-numeric: tabular-nums; animation: ovn-badge-pop .36s ease; }
font-variant-numeric: tabular-nums; animation: ovn-badge-pop .36s ease; }
.openvibe-bell .badge.hidden { display: none; }
.openvibe-bell .badge.hidden { display: none; }
.openvibe-bell.ringing svg { animation: ovn-bell-ring .7s ease; transform-origin: 50% 15%; }
.openvibe-bell.ringing svg { animation: ovn-bell-ring .7s ease; transform-origin: 50% 15%; }
+ /* The ✦ beside the bell: the Action Center (desktop; phones reach it from the bell's Agents tab). */
+ .openvibe-actions-btn { position: relative; cursor: pointer; padding: 6px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; }
+ .openvibe-actions-btn svg { width: 21px; height: 21px; fill: none; stroke: var(--text-secondary, #b0b0b8); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; transition: stroke .2s; }
+ .openvibe-actions-btn:hover svg, .openvibe-actions-btn.open svg { stroke: var(--accent-light, var(--accent, #60a5fa)); }
+ .openvibe-actions-btn:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+ @media (max-width: 640px) { .openvibe-actions-btn { display: none; } }
+ .ovn-ac-link { display: flex; align-items: center; gap: 10px; width: calc(100% - 12px); margin: 6px; padding: 10px 14px; border-radius: 14px; border: 1px dashed color-mix(in srgb, var(--accent, #3b82f6) 45%, var(--border, #333));
+ background: color-mix(in srgb, var(--accent, #3b82f6) 7%, transparent); color: var(--text-primary, #ececf1); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; text-align: left; }
+ .ovn-ac-link svg { width: 18px; height: 18px; flex: none; fill: none; stroke: var(--accent-light, var(--accent, #60a5fa)); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* The panel (desktop popover, phone sheet) and the full inbox share one list view. */
/* The panel (desktop popover, phone sheet) and the full inbox share one list view. */
.openvibe-notif-panel { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden;
.openvibe-notif-panel { position: fixed; z-index: 99999; display: none; flex-direction: column; overflow: hidden;
@@ -721,6 +733,37 @@
@@ -721,6 +733,37 @@
setTimeout(() => { el.remove(); flushQueue(); }, 240);
setTimeout(() => { el.remove(); flushQueue(); }, 240);
}
}
+ // ── The ✦: the Action Center, loaded from beside this file on the first click ──
+ let _acLoading = null;
+ function actionCenterSrc() {
+ if (_config.actionCenterSrc) return _config.actionCenterSrc;
+ const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);
+ return `${m ? m[1] : 'https://openvibe.network/shared/'}action-center.js`;
+ }
+ function loadActionCenter() {
+ if (root.OpenVibeActionCenter) return Promise.resolve(root.OpenVibeActionCenter);
+ return _acLoading || (_acLoading = new Promise((ok) => {
+ const sc = document.createElement('script');
+ sc.src = actionCenterSrc(); sc.async = true;
+ sc.onload = () => ok(root.OpenVibeActionCenter || null);
+ sc.onerror = () => { _acLoading = null; ok(null); };
+ document.head.appendChild(sc);
+ }));
+ }
+ function openActions(anchor) {
+ loadActionCenter().then((AC) => { if (AC) AC.toggle({ anchor, token: () => _config.token, apiBase: _config.apiBase }); });
+ }
+ function createActionsEl() {
+ const b = document.createElement('div');
+ b.className = 'openvibe-actions-btn';
+ b.setAttribute('role', 'button'); b.setAttribute('aria-label', 'Agents'); b.setAttribute('aria-haspopup', 'dialog'); b.setAttribute('aria-expanded', 'false');
+ b.title = 'Agents: everything your agents are doing'; b.tabIndex = 0;
+ b.innerHTML = svg(GLYPH.spark);
+ b.addEventListener('click', () => openActions(b));
+ b.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openActions(b); } });
+ return b;
+ }
+
// ── Bell / badge ────────────────────────────────────────
// ── Bell / badge ────────────────────────────────────────
function createBellEl() {
function createBellEl() {
const bell = document.createElement('div');
const bell = document.createElement('div');
@@ -831,6 +874,7 @@
@@ -831,6 +874,7 @@
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 120) loadMore(v);
});
});
list.addEventListener('click', (e) => {
list.addEventListener('click', (e) => {
+ if (e.target.closest('[data-act="ac"]')) { const anchor = _bellEl; closePanel(); openActions(anchor); return; }
const item = e.target.closest('.openvibe-notif-item');
const item = e.target.closest('.openvibe-notif-item');
if (!item || (item.__ovnSwipedAt && Date.now() - item.__ovnSwipedAt < 450)) return;
if (!item || (item.__ovnSwipedAt && Date.now() - item.__ovnSwipedAt < 450)) return;
const id = item.dataset.id;
const id = item.dataset.id;
@@ -956,12 +1000,13 @@
@@ -956,12 +1000,13 @@
} catch { return ''; }
} catch { return ''; }
}).join('');
}).join('');
v.lastDay = lastDay;
v.lastDay = lastDay;
- if (!append) list.innerHTML = '';
+ if (!append) list.innerHTML = v.tab === 'agents' ? `<button type="button" class="ovn-ac-link" data-act="ac">${svg(GLYPH.spark)}<span>Everything your agents are doing: running, waiting for you, done</span></button>` : '';
if (!v.items.length) {
if (!v.items.length) {
const [t0, s0] = TAB_EMPTY[v.tab] || TAB_EMPTY.all;
const [t0, s0] = TAB_EMPTY[v.tab] || TAB_EMPTY.all;
+ const acLink = v.tab === 'agents' ? `<button type="button" class="ovn-ac-link" data-act="ac">${svg(GLYPH.spark)}<span>Everything your agents are doing: running, waiting for you, done</span></button>` : '';
const title = v.q ? 'Nothing matches that search' : (v.unread ? 'You\'re all caught up' : t0);
const title = v.q ? 'Nothing matches that search' : (v.unread ? 'You\'re all caught up' : t0);
const sub = v.q ? 'Try other words.' : (v.unread ? 'Nothing unread here.' : s0);
const sub = v.q ? 'Try other words.' : (v.unread ? 'Nothing unread here.' : s0);
- list.innerHTML = `<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${esc(title)}</b>${esc(sub)}</div>`;
+ list.innerHTML = `${acLink}<div class="openvibe-nv-empty"><svg viewBox="0 0 64 64" aria-hidden="true">${ICON.empty}</svg><b>${esc(title)}</b>${esc(sub)}</div>`;
} else {
} else {
const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();
const old = list.querySelector('.openvibe-nv-more'); if (old) old.remove();
list.insertAdjacentHTML('beforeend', html);
list.insertAdjacentHTML('beforeend', html);
@@ -1325,10 +1370,13 @@
@@ -1325,10 +1370,13 @@
createBell(container) {
createBell(container) {
injectStyles();
injectStyles();
const bell = createBellEl();
const bell = createBellEl();
+ if (container && _config.actions !== false && !container.querySelector('.openvibe-actions-btn')) container.appendChild(createActionsEl());
if (container) container.appendChild(bell);
if (container) container.appendChild(bell);
pollUnread();
pollUnread();
return bell;
return bell;
},
},
+ /** Open the Action Center (the ✦) from a site's own control. */
+ openActions(anchor) { injectStyles(); openActions(anchor || null); },
/** Full-page inbox (same list, filters, search, infinite scroll) rendered into a container. */
/** Full-page inbox (same list, filters, search, infinite scroll) rendered into a container. */
renderInbox(container, opts = {}) {
renderInbox(container, opts = {}) {
injectStyles();
injectStyles();
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.30.0",
+ "version": "4.31.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -42,6 +42,7 @@
@@ -42,6 +42,7 @@
"./nav-icons": "./nav-icons.js",
"./nav-icons": "./nav-icons.js",
"./navbar": "./navbar.js",
"./navbar": "./navbar.js",
"./notification-live": "./notification-live.js",
"./notification-live": "./notification-live.js",
+ "./action-center": "./action-center.js",
"./notification-ui": "./notification-ui.js",
"./notification-ui": "./notification-ui.js",
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
"./openvibe-sw": "./openvibe-sw.js",
"./openvibe-sw": "./openvibe-sw.js",
@@ -21,6 +21,7 @@ const linkedom = require('linkedom');
@@ -21,6 +21,7 @@ const linkedom = require('linkedom');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const LIVE_SRC = fs.readFileSync(path.join(ROOT, 'notification-live.js'), 'utf8');
const LIVE_SRC = fs.readFileSync(path.join(ROOT, 'notification-live.js'), 'utf8');
const UI_SRC = fs.readFileSync(path.join(ROOT, 'notification-ui.js'), 'utf8');
const UI_SRC = fs.readFileSync(path.join(ROOT, 'notification-ui.js'), 'utf8');
+const AC_SRC = fs.readFileSync(path.join(ROOT, 'action-center.js'), 'utf8');
const ALICE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
const ALICE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
const API = 'https://openvibe.network';
const API = 'https://openvibe.network';
@@ -30,7 +31,7 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5
@@ -30,7 +31,7 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5
const { window: lw, document } = linkedom.parseHTML('<!doctype html><html><head></head><body></body></html>');
const { window: lw, document } = linkedom.parseHTML('<!doctype html><html><head></head><body></body></html>');
const origin = new URL(url).origin;
const origin = new URL(url).origin;
const out = { requests: [], es: [], timers: new Map(), intervals: [], scripts: [], now: Date.parse('2026-09-26T18:00:00Z') };
const out = { requests: [], es: [], timers: new Map(), intervals: [], scripts: [], now: Date.parse('2026-09-26T18:00:00Z') };
- const state = { ticketStatus: 200, subject: ALICE, count: 0, tickets: 0, newest: '2026-09-26 17:00:00', fresh: [], slow: null, tabs: null, list: [] };
+ const state = { ticketStatus: 200, subject: ALICE, count: 0, tickets: 0, newest: '2026-09-26 17:00:00', fresh: [], slow: null, tabs: null, list: [], actions: null, decided: [] };
let hidden = false; let tid = 0; let currentSrc = null;
let hidden = false; let tid = 0; let currentSrc = null;
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
Object.defineProperty(document, 'currentScript', { configurable: true, get: () => (currentSrc ? { src: currentSrc } : null) });
Object.defineProperty(document, 'currentScript', { configurable: true, get: () => (currentSrc ? { src: currentSrc } : null) });
@@ -76,6 +77,8 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5
@@ -76,6 +77,8 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5
return json(200, { ok: true, count: state.count });
return json(200, { ok: true, count: state.count });
}
}
if (p.pathname === '/api/notifications/newest') return json(200, { ok: true, notification: { created_at: state.newest } });
if (p.pathname === '/api/notifications/newest') return json(200, { ok: true, notification: { created_at: state.newest } });
+ if (p.pathname === '/api/v1/me/actions') return state.actions ? json(200, state.actions) : json(401, { title: 'sign in' });
+ if (p.pathname.startsWith('/api/v1/me/actions/') && req.method === 'POST') { state.decided.push(p.pathname); return json(200, { item: {} }); }
if (p.pathname === '/api/notifications/unread-by-tab') return state.tabs ? json(200, { ok: true, tabs: state.tabs }) : json(404, {});
if (p.pathname === '/api/notifications/unread-by-tab') return state.tabs ? json(200, { ok: true, tabs: state.tabs }) : json(404, {});
if (p.pathname === '/api/notifications') return json(200, { ok: true, notifications: p.searchParams.get('since') ? state.fresh : state.list, has_more: false, total: state.list.length });
if (p.pathname === '/api/notifications') return json(200, { ok: true, notifications: p.searchParams.get('since') ? state.fresh : state.list, has_more: false, total: state.list.length });
return json(404, {});
return json(404, {});
@@ -92,6 +95,7 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5
@@ -92,6 +95,7 @@ function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5
if (src) {
if (src) {
out.scripts.push(src);
out.scripts.push(src);
setImmediate(() => {
setImmediate(() => {
+ if (/action-center\.js$/.test(src)) { vm.runInContext(AC_SRC, ctx, { filename: 'action-center.js' }); if (node.onload) node.onload(new ctx.Event('load')); return; }
if (!/notification-live\.js$/.test(src)) { if (node.onerror) node.onerror(new ctx.Event('error')); return; }
if (!/notification-live\.js$/.test(src)) { if (node.onerror) node.onerror(new ctx.Event('error')); return; }
vm.runInContext(LIVE_SRC, ctx, { filename: 'notification-live.js' });
vm.runInContext(LIVE_SRC, ctx, { filename: 'notification-live.js' });
if (node.onload) node.onload(new ctx.Event('load'));
if (node.onload) node.onload(new ctx.Event('load'));
@@ -422,10 +426,61 @@ const created = (subject, over = {}) => ({
@@ -422,10 +426,61 @@ const created = (subject, over = {}) => ({
N.destroy();
N.destroy();
}
}
+ // ── The ✦ Action Center (4.31.0): beside the bell, loaded on the first click, five buckets, approve here ──
+ {
+ const p = page();
+ p.run(UI_SRC, 'notification-ui.js', 'https://openvibe.live/shared/notification-ui.js');
+ const N = p.ctx.OpenVibeNotifications;
+ const at = '2026-09-26T18:00:00.000Z';
+ const it = (o) => ({ service: 'actor', id: 'tsk_1', kind: 'agent_task', title: 'Find three cheap flights', summary: 'Read 4 pages', bucket: 'running', state: 'running', agent: 'deepseek-flash',
+ cost_usd: 0.004, steps: 6, error: null, created_at: at, updated_at: at, next_run_at: null, href: 'https://openvibe.actor/tasks/tsk_1', actions: ['open', 'cancel'], ...o });
+ p.state.actions = {
+ buckets: { running: [it()], waiting: [it({ id: 'apr_1', kind: 'approval', title: 'Spend <b>$0.40</b> on a desk hour', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'] })], scheduled: [], done: [], failed: [] },
+ counts: { running: 1, waiting: 1, scheduled: 0, done: 0, failed: 0 }, sources: { network: 'ok', actor: 'ok', codes: 'timeout', ai: 'ok' }, partial: true, generated_at: at,
+ };
+ N.init({ token: 'jwt-ac', apiBase: API });
+ const mount = p.document.createElement('div');
+ p.document.body.appendChild(mount);
+ N.createBell(mount);
+ const star = mount.querySelector('.openvibe-actions-btn');
+ assert.ok(star && star.nextElementSibling && star.nextElementSibling.classList.contains('openvibe-bell'), 'the ✦ sits just before the bell');
+ assert.ok(!p.out.scripts.some((s) => /action-center/.test(s)), 'nothing is loaded before the first click');
+ star.dispatchEvent(new p.ctx.Event('click', { bubbles: true }));
+ await p.settle(20);
+ assert.ok(p.out.scripts.includes('https://openvibe.live/shared/action-center.js'), 'loaded from beside notification-ui.js');
+ const req = p.out.requests.find((r) => r.url === `${API}/api/v1/me/actions`);
+ assert.ok(req, 'it reads the person\'s actions');
+ assert.deepStrictEqual([req.headers.authorization, req.credentials], ['Bearer jwt-ac', 'omit'], 'cross-site: the token, never cookies');
+ const panel = p.document.querySelector('.ovac');
+ assert.ok(panel && panel.classList.contains('open') && panel.getAttribute('aria-modal') === 'true', 'open, and modal so toasts wait');
+ assert.strictEqual(star.getAttribute('aria-expanded'), 'true');
+ assert.strictEqual(panel.querySelector('[role="tab"][aria-selected="true"]').dataset.tab, 'waiting', 'what needs the person comes first');
+ assert.deepStrictEqual(['running', 'waiting', 'done'].map((b) => panel.querySelector(`[data-n="${b}"]`).textContent), ['1', '1', '']);
+ assert.match(panel.querySelector('.ovac-note').textContent, /Codes did not answer/, 'a source that did not answer is named');
+ const row = panel.querySelector('.ovac-item');
+ assert.strictEqual(row.querySelector('.ovac-title').textContent, 'Spend <b>$0.40</b> on a desk hour', 'text, never markup');
+ row.querySelector('button[data-do="approve"]').dispatchEvent(new p.ctx.Event('click', { bubbles: true }));
+ await p.settle(20);
+ assert.deepStrictEqual(p.state.decided, ['/api/v1/me/actions/actor/apr_1/approve'], 'approve goes to Network for the owning service');
+ panel.querySelector('[data-tab="running"]').dispatchEvent(new p.ctx.Event('click', { bubbles: true }));
+ const meta = panel.querySelector('.ovac-item .ovac-meta').textContent;
+ assert.ok(/Actor · deepseek-flash/.test(meta) && /\$0\.004/.test(meta) && /6 steps/.test(meta), meta);
+ panel.querySelector('[data-act="close"]').dispatchEvent(new p.ctx.Event('click', { bubbles: true }));
+ assert.ok(!panel.classList.contains('open') && star.getAttribute('aria-expanded') === 'false', '× closes it');
+ // Signed out: says so.
+ p.state.actions = null;
+ p.ctx.OpenVibeActionCenter.open({ anchor: star, token: () => 'jwt-ac', apiBase: API });
+ await p.settle(20);
+ assert.match(panel.querySelector('.ovac-empty b').textContent, /Sign in/);
+ N.destroy();
+ }
+
// ── Size: loaded on demand, after sign-in ──────────────────────────────────────────────
// ── Size: loaded on demand, after sign-in ──────────────────────────────────────────────
const { brotli } = require('../scripts/size-report');
const { brotli } = require('../scripts/size-report');
const size = (f) => brotli(fs.readFileSync(path.join(ROOT, f)));
const size = (f) => brotli(fs.readFileSync(path.join(ROOT, f)));
assert.ok(size('notification-live.js') <= 3 * 1024, `notification-live.js is ${(size('notification-live.js') / 1024).toFixed(2)} KB brotli, budget 3 KB`);
assert.ok(size('notification-live.js') <= 3 * 1024, `notification-live.js is ${(size('notification-live.js') / 1024).toFixed(2)} KB brotli, budget 3 KB`);
+ // The Action Center loads on the ✦'s first click only.
+ assert.ok(size('action-center.js') <= 8 * 1024, `action-center.js is ${(size('action-center.js') / 1024).toFixed(2)} KB brotli, budget 8 KB`);
// 13 → 20 KB on 2026-10-28 (4.27.0, Notifications 2.0: line glyphs, System cards, grouping, layering, priority, the
// 13 → 20 KB on 2026-10-28 (4.27.0, Notifications 2.0: line glyphs, System cards, grouping, layering, priority, the
// bell's rich list). It loads only after sign-in, when the bell mounts, never on first paint; measured 19.16 KB.
// bell's rich list). It loads only after sign-in, when the bell mounts, never on first paint; measured 19.16 KB.
// 20 → 24 KB on 2026-10-28 (4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe to read or
// 20 → 24 KB on 2026-10-28 (4.30.0, the bell center: tabs with counts, the Unread filter, thumbnails, swipe to read or