openvibers/openvibe.shared action-center.js
action-center.js
| 1 | // ═══════════════════════════════════════════════════════════════ |
| 2 | // OpenVibe — the Action Center (✦), every agent task in one place (plan 2026-10-28 §3.10; Shared 4.31.0) |
| 3 | // |
| 4 | // notification-ui.js draws the ✦ beside the bell and loads this file, from beside itself, on the first click. |
| 5 | // OpenVibeActionCenter.toggle({ anchor, token: () => jwt, apiBase }) · open(opts) · close() · isOpen() |
| 6 | // OpenVibeActionCenter.mount(container, { token, apiBase, buckets, onCounts }) → { refresh(), destroy() } |
| 7 | // the Dock's Agent tab (dock.js): the items of some buckets in a container of the Dock's, waiting first, the |
| 8 | // same rows and the same approve and decline, re-read every 15 s while something runs. |
| 9 | // |
| 10 | // Reads GET <apiBase>/api/v1/me/actions network.me-actions-result@1 (Bearer, or the cookie on |
| 11 | // openvibe.network itself) |
| 12 | // Writes POST <apiBase>/api/v1/me/actions/:service/:id/approve|decline |
| 13 | // |
| 14 | // Five tabs: Running (live: re-read every 15 s while open and something runs), Waiting for you (approve or decline |
| 15 | // here), Scheduled, Done (a receipt: cost, steps, the page), Failed (why, and open it to try again). A service that did |
| 16 | // not answer is named under the tabs; the rest still show. Desktop: a 420 px popover under the bar. Phones: a full-height |
| 17 | // sheet with a handle (drag down, ×, or a tap outside closes it). Opaque, on the theme's raised surface, and while it is |
| 18 | // open toasts wait (aria-modal: notification-ui.js holds them). |
| 19 | // ═══════════════════════════════════════════════════════════════ |
| 20 | (function (root) { |
| 21 | ; |
| 22 | if (typeof document === 'undefined' || root.OpenVibeActionCenter) return; |
| 23 | |
| 24 | const BUCKETS = [['running', 'Running'], ['waiting', 'Waiting for you'], ['scheduled', 'Scheduled'], ['done', 'Done'], ['failed', 'Failed']]; |
| 25 | const SHORT = { running: 'Running', waiting: 'Waiting', scheduled: 'Scheduled', done: 'Done', failed: 'Failed' }; |
| 26 | const EMPTY = { |
| 27 | running: ['Nothing running', 'When an agent is working for you, its steps show here as they happen.'], |
| 28 | waiting: ['Nothing needs you', 'When an agent asks before it spends, sends or deletes, you approve it here.'], |
| 29 | scheduled: ['Nothing scheduled', 'Agents you set to run later, like a morning brief, show here.'], |
| 30 | done: ['Nothing finished yet', 'Finished work shows here with what it cost and where it is.'], |
| 31 | failed: ['Nothing failed', 'If a task fails, it shows here with why, so you can try again.'], |
| 32 | }; |
| 33 | const SERVICE = { actor: 'Actor', codes: 'Codes', ai: 'AI', network: 'Network', run: 'Run' }; |
| 34 | const REFRESH_MS = 15000; |
| 35 | const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`; |
| 36 | const G = { |
| 37 | 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"/>', |
| 38 | code: '<path d="M8.5 7.5L4 12l4.5 4.5M15.5 7.5L20 12l-4.5 4.5M13.5 5.5l-3 13"/>', |
| 39 | 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"/>', |
| 40 | 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"/>', |
| 41 | desk: '<rect x="3.5" y="4.5" width="17" height="11" rx="2"/><path d="M9 19.5h6M12 15.5v4"/>', |
| 42 | refresh: '<path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3M19.5 4.5v4h-4"/>', |
| 43 | close: '<path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/>', |
| 44 | 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"/>', |
| 45 | }; |
| 46 | const KIND_GLYPH = { agent_task: 'spark', code_task: 'code', ai_run: 'ai', approval: 'key', desk_session: 'desk' }; |
| 47 | const TONE = { running: 'var(--accent, #3b82f6)', waiting: 'var(--warning, #f5a524)', scheduled: 'var(--text-secondary, #a0a0ab)', done: 'var(--success, #30a46c)', failed: 'var(--live-red, #e5484d)' }; |
| 48 | |
| 49 | let el = null, opts = null, data = null, tab = null, timer = null, loading = false, seq = 0; |
| 50 | /** A site's Dock passes no scopes (full behaviour); the extension passes its read-only set. */ |
| 51 | const canDecide = (o = opts) => !Array.isArray(o && o.scopes) || o.scopes.includes('actions.decide'); |
| 52 | |
| 53 | function esc(v) { return String(v == null ? '' : v).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); } |
| 54 | const isPhone = () => { try { return root.matchMedia('(max-width: 640px)').matches; } catch { return false; } }; |
| 55 | async function apiWith(o, path, init = {}) { |
| 56 | const base = String((o && o.apiBase) || 'https://openvibe.network').replace(/\/+$/, ''); |
| 57 | let cross = true; try { cross = new URL(base).origin !== root.location.origin; } catch { /* */ } |
| 58 | const headers = { accept: 'application/json', ...(init.body ? { 'content-type': 'application/json' } : {}) }; |
| 59 | const t = o && (typeof o.token === 'function' ? await o.token() : o.token); |
| 60 | if (t) headers.authorization = `Bearer ${t}`; |
| 61 | const r = await fetch(base + path, { ...init, headers, credentials: cross ? 'omit' : 'include' }); |
| 62 | const body = await r.json().catch(() => ({})); |
| 63 | if (!r.ok) { const e = new Error(body.detail || body.title || `HTTP ${r.status}`); e.status = r.status; throw e; } |
| 64 | return body; |
| 65 | } |
| 66 | const api = (path, init) => apiWith(opts, path, init); |
| 67 | function ago(iso) { |
| 68 | const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return ''; |
| 69 | const s = (Date.now() - t) / 1000; |
| 70 | 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`; } |
| 71 | if (s < 45) return 'just now'; |
| 72 | if (s < 3600) return `${Math.max(1, Math.floor(s / 60))} min ago`; |
| 73 | if (s < 86400) return `${Math.floor(s / 3600)} h ago`; |
| 74 | return `${Math.floor(s / 86400)} d ago`; |
| 75 | } |
| 76 | const money = (v) => (typeof v === 'number' && v > 0 ? (v < 0.01 ? `$${v.toFixed(4).replace(/0+$/, '')}` : `$${v.toFixed(2)}`) : null); |
| 77 | |
| 78 | function styles() { |
| 79 | if (document.getElementById('ovac-styles')) return; |
| 80 | const st = document.createElement('style'); |
| 81 | st.id = 'ovac-styles'; |
| 82 | st.textContent = ` |
| 83 | @keyframes ovac-in { from { transform: translateY(-8px); opacity: 0; } to { transform: none; opacity: 1; } } |
| 84 | @keyframes ovac-up { from { transform: translateY(48px); opacity: .5; } to { transform: none; opacity: 1; } } |
| 85 | @keyframes ovac-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } } |
| 86 | .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)); |
| 87 | width: min(420px, calc(100vw - 16px)); max-height: calc(100vh - var(--ovac-top, 62px) - 12px); max-height: calc(100dvh - var(--ovac-top, 62px) - 12px); |
| 88 | background: var(--bg-elevated, var(--bg-secondary, #1d1f27)); color: var(--text-primary, #ececf1); border: 1px solid var(--border, rgba(255,255,255,.1)); |
| 89 | 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); } |
| 90 | .ovac.open { display: flex; } |
| 91 | .ovac svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } |
| 92 | .ovac-head { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 14px 10px 8px 16px; } |
| 93 | .ovac-head h3 { margin: 0; display: flex; align-items: center; gap: 8px; font-size: 17px; font-weight: 750; letter-spacing: -.01em; } |
| 94 | .ovac-head h3 svg { width: 19px; height: 19px; color: var(--accent-light, var(--accent, #60a5fa)); } |
| 95 | .ovac-acts { display: flex; gap: 2px; } |
| 96 | .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; } |
| 97 | .ovac-acts button:hover { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-primary, #ececf1); } |
| 98 | .ovac-acts button svg { width: 18px; height: 18px; } |
| 99 | .ovac-acts button.spin svg { animation: ovac-pulse 1s ease-in-out infinite; } |
| 100 | @keyframes ovac-pulse { 0%, 100% { opacity: .35; } 50% { opacity: 1; } } |
| 101 | .ovac-acts [data-act="close"] { display: none; } |
| 102 | .ovac-tabs { display: flex; overflow-x: auto; scrollbar-width: none; padding: 0 6px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); } |
| 103 | .ovac-tabs::-webkit-scrollbar { display: none; } |
| 104 | .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; |
| 105 | color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; white-space: nowrap; } |
| 106 | .ovac-tabs button:hover, .ovac-tabs button[aria-selected="true"] { color: var(--text-primary, #ececf1); } |
| 107 | .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); } |
| 108 | .ovac-tabs button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: -3px; border-radius: 10px; } |
| 109 | .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; |
| 110 | color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--accent, #3b82f6) 24%, transparent); } |
| 111 | .ovac-tabs [data-tab="waiting"] .n { background: color-mix(in srgb, var(--warning, #f5a524) 30%, transparent); } |
| 112 | .ovac-tabs .n:empty { display: none; } |
| 113 | .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); } |
| 114 | .ovac-list { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 6px 6px 10px; } |
| 115 | .ovac-item { display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; padding: 10px 10px 10px 8px; border-radius: 14px; } |
| 116 | .ovac-item:hover { background: color-mix(in srgb, var(--text-primary, #ececf1) 4%, transparent); } |
| 117 | .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); } |
| 118 | .ovac-glyph svg { width: 20px; height: 20px; } |
| 119 | .ovac-body { min-width: 0; } |
| 120 | .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; } |
| 121 | .ovac-title:hover { text-decoration: underline; text-underline-offset: 2px; } |
| 122 | .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; } |
| 123 | .ovac-item[data-bucket="failed"] .ovac-sum { color: color-mix(in srgb, var(--live-red, #e5484d) 55%, var(--text-primary, #ececf1)); } |
| 124 | .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); } |
| 125 | .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)); } |
| 126 | .ovac-state::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--ovac-tone); } |
| 127 | .ovac-item[data-bucket="running"] .ovac-state::before { animation: ovac-pulse 1.4s ease-in-out infinite; } |
| 128 | .ovac-cost { font-variant-numeric: tabular-nums; font-weight: 650; color: var(--text-secondary, #b6b6c0); } |
| 129 | .ovac-btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } |
| 130 | .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; |
| 131 | border: 1px solid var(--border, rgba(255,255,255,.12)); background: transparent; color: var(--text-primary, #ececf1); } |
| 132 | .ovac-btns .primary { background: var(--accent-strong, var(--accent, #3472d8)); border-color: transparent; color: var(--on-accent-strong, var(--on-accent, #fff)); } |
| 133 | .ovac-btns button:disabled { opacity: .55; cursor: default; } |
| 134 | .ovac-btns svg { width: 14px; height: 14px; } |
| 135 | .ovac-err { margin: 6px 0 0; font-size: 12px; color: color-mix(in srgb, var(--live-red, #e5484d) 60%, var(--text-primary, #ececf1)); } |
| 136 | .ovac-empty { text-align: center; padding: 44px 24px 52px; color: var(--text-muted, #8b8b96); font-size: 13.5px; line-height: 1.45; } |
| 137 | .ovac-empty svg { width: 46px; height: 46px; display: block; margin: 0 auto 12px; color: var(--accent, #3b82f6); opacity: .75; stroke-width: 1.4; } |
| 138 | .ovac-empty b { display: block; margin-bottom: 4px; font-size: 15px; color: var(--text-primary, #ececf1); } |
| 139 | .ovac-wait { display: flex; justify-content: center; padding: 28px; } |
| 140 | .ovac-wait span { position: relative; display: inline-block; width: 6px; height: 6px; margin: 0 10px; border: 0; border-radius: 50%; background: var(--accent, #3b82f6); animation: ovac-hop 1s ease-in-out .15s infinite; } |
| 141 | .ovac-wait span::before, .ovac-wait span::after { content: ''; position: absolute; top: 0; width: 6px; height: 6px; border-radius: 50%; background: inherit; animation: ovac-hop 1s ease-in-out infinite; } |
| 142 | .ovac-wait span::before { left: -10px; } .ovac-wait span::after { left: 10px; animation-delay: .3s; } |
| 143 | @keyframes ovac-hop { 0%, 80%, 100% { transform: translateY(0); opacity: .45; } 40% { transform: translateY(-4px); opacity: 1; } } |
| 144 | .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); } |
| 145 | .ovac-foot a { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; text-decoration: none; } |
| 146 | @media (max-width: 640px) { |
| 147 | .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; |
| 148 | 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; } |
| 149 | .ovac-head { padding-top: 20px; touch-action: none; } |
| 150 | .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))); } |
| 151 | .ovac-acts [data-act="close"] { display: grid; } |
| 152 | .ovac-acts button { width: 40px; height: 40px; } |
| 153 | } |
| 154 | html.ovac-sheet-open, html.ovac-sheet-open body { overflow: hidden; } |
| 155 | .ovac-mounted { display: flex; flex-direction: column; min-height: 0; color: var(--text-primary, #ececf1); } |
| 156 | .ovac-mounted .ovac-list { padding: 4px 6px 10px; } |
| 157 | .ovac-mounted svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; } |
| 158 | .ovac-group { display: flex; align-items: center; gap: 8px; margin: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #8b8b96); } |
| 159 | .ovac-group span { font-variant-numeric: tabular-nums; letter-spacing: 0; color: var(--text-secondary, #b6b6c0); } |
| 160 | .ovac-group[data-bucket="waiting"] { color: color-mix(in srgb, var(--warning, #f5a524) 70%, var(--text-primary, #ececf1)); } |
| 161 | `; |
| 162 | document.head.appendChild(st); |
| 163 | } |
| 164 | |
| 165 | function build() { |
| 166 | styles(); |
| 167 | el = document.createElement('div'); |
| 168 | el.className = 'ovac'; |
| 169 | el.setAttribute('role', 'dialog'); |
| 170 | el.setAttribute('aria-label', 'Agents'); |
| 171 | el.hidden = true; |
| 172 | el.innerHTML = ` |
| 173 | <div class="ovac-head"><h3>${svg(G.spark)}Agents</h3> |
| 174 | <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> |
| 175 | <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> |
| 176 | <div class="ovac-note" hidden></div> |
| 177 | <div class="ovac-list" role="tabpanel" aria-live="polite"></div> |
| 178 | <div class="ovac-foot"><span data-role="when"></span><a href="https://openvibe.actor/">Give an agent a task →</a></div>`; |
| 179 | document.body.appendChild(el); |
| 180 | const tabs = [...el.querySelectorAll('[role="tab"]')]; |
| 181 | const pick = (b, focus) => { setTab(b.dataset.tab); if (focus) b.focus(); }; |
| 182 | tabs.forEach((b) => b.addEventListener('click', () => pick(b))); |
| 183 | el.querySelector('.ovac-tabs').addEventListener('keydown', (e) => { |
| 184 | const i = tabs.indexOf(document.activeElement); if (i < 0) return; |
| 185 | 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; |
| 186 | if (j >= 0) { e.preventDefault(); pick(tabs[j], true); } |
| 187 | }); |
| 188 | el.querySelector('[data-act="refresh"]').addEventListener('click', () => load()); |
| 189 | el.querySelector('[data-act="close"]').addEventListener('click', () => close()); |
| 190 | el.querySelector('.ovac-list').addEventListener('click', (e) => { |
| 191 | const b = e.target.closest('button[data-do]'); if (!b) return; |
| 192 | decide(b.closest('.ovac-item'), b.dataset.do, b); |
| 193 | }); |
| 194 | document.addEventListener('click', (e) => { if (isOpen() && !el.contains(e.target) && !(opts && opts.anchor && opts.anchor.contains(e.target))) close(); }); |
| 195 | document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isOpen()) close(); }); |
| 196 | // Phones: the sheet's head drags down to close. |
| 197 | const head = el.querySelector('.ovac-head'); |
| 198 | let y0 = null, dy = 0; |
| 199 | head.addEventListener('pointerdown', (e) => { if (!isPhone() || e.pointerType === 'mouse' || e.target.closest('button')) return; y0 = e.clientY; dy = 0; el.style.transition = 'none'; }); |
| 200 | head.addEventListener('pointermove', (e) => { if (y0 == null) return; dy = Math.max(0, e.clientY - y0); el.style.transform = `translateY(${dy}px)`; }); |
| 201 | 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); }; |
| 202 | head.addEventListener('pointerup', end); head.addEventListener('pointercancel', end); |
| 203 | if (root.OpenVibePanels) { try { root.OpenVibePanels.register({ el, openClass: 'open', id: 'action-center', close: () => close() }); } catch { /* */ } } |
| 204 | } |
| 205 | |
| 206 | function setTab(id) { |
| 207 | tab = id; |
| 208 | el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === id; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; }); |
| 209 | el.querySelector('.ovac-list').setAttribute('aria-label', (BUCKETS.find(([b]) => b === id) || ['', ''])[1]); |
| 210 | paint(); |
| 211 | } |
| 212 | |
| 213 | function itemHTML(it, o = opts) { |
| 214 | const tone = TONE[it.bucket] || TONE.running; |
| 215 | const meta = [ |
| 216 | `<span class="ovac-state">${esc(it.bucket === 'waiting' ? 'Needs you' : SHORT[it.bucket] || it.state)}</span>`, |
| 217 | `<span>${esc([SERVICE[it.service] || it.service, it.agent].filter(Boolean).join(' · '))}</span>`, |
| 218 | money(it.cost_usd) ? `<span class="ovac-cost">${money(it.cost_usd)}</span>` : '', |
| 219 | typeof it.steps === 'number' && it.steps > 0 ? `<span>${it.steps} step${it.steps === 1 ? '' : 's'}</span>` : '', |
| 220 | `<span>${esc(it.bucket === 'scheduled' && it.next_run_at ? `runs ${ago(it.next_run_at)}` : ago(it.updated_at))}</span>`, |
| 221 | ].join(''); |
| 222 | const decides = canDecide(o) && it.bucket === 'waiting' && it.actions.includes('approve'); |
| 223 | const btns = decides |
| 224 | ? `<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>` |
| 225 | : it.bucket === 'failed' && it.actions.includes('retry') ? `<div class="ovac-btns"><a href="${esc(it.href)}">${svg(G.refresh)}Try again</a></div>` : ''; |
| 226 | const text = it.bucket === 'failed' && it.error ? it.error : it.summary; |
| 227 | return `<article class="ovac-item" data-bucket="${esc(it.bucket)}" data-service="${esc(it.service)}" data-id="${esc(it.id)}" style="--ovac-tone:${tone}"> |
| 228 | <span class="ovac-glyph">${svg(G[KIND_GLYPH[it.kind]] || G.spark)}</span> |
| 229 | <div class="ovac-body"><a class="ovac-title" href="${esc(it.href)}">${esc(it.title)}</a>${text ? `<p class="ovac-sum">${esc(text)}</p>` : ''} |
| 230 | <div class="ovac-meta">${meta}</div>${btns}</div></article>`; |
| 231 | } |
| 232 | |
| 233 | function paint() { |
| 234 | if (!el) return; |
| 235 | const list = el.querySelector('.ovac-list'); |
| 236 | const note = el.querySelector('.ovac-note'); |
| 237 | if (!data) { list.innerHTML = '<div class="ovac-wait"><span></span></div>'; note.hidden = true; return; } |
| 238 | if (data.error) { |
| 239 | 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>`; |
| 240 | note.hidden = true; |
| 241 | return; |
| 242 | } |
| 243 | for (const [id] of BUCKETS) { |
| 244 | const n = data.counts[id] || 0; |
| 245 | const span = el.querySelector(`[data-n="${id}"]`); |
| 246 | span.textContent = n ? (n > 99 ? '99+' : String(n)) : ''; |
| 247 | span.parentNode.setAttribute('aria-label', n ? `${SHORT[id]}, ${n}` : SHORT[id]); |
| 248 | } |
| 249 | const missing = Object.entries(data.sources || {}).filter(([, s]) => s === 'timeout' || s === 'unavailable').map(([k]) => SERVICE[k] || k); |
| 250 | note.hidden = !missing.length; |
| 251 | note.textContent = missing.length ? `${missing.join(' and ')} did not answer, so some items may be missing. Refresh to try again.` : ''; |
| 252 | const items = data.buckets[tab] || []; |
| 253 | if (!items.length) { |
| 254 | const [t, s] = EMPTY[tab] || EMPTY.running; |
| 255 | list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${esc(t)}</b>${esc(s)}</div>`; |
| 256 | } else list.innerHTML = items.map(itemHTML).join(''); |
| 257 | const when = el.querySelector('[data-role="when"]'); |
| 258 | when.textContent = data.generated_at ? `Updated ${ago(data.generated_at)}` : ''; |
| 259 | } |
| 260 | |
| 261 | async function load({ quiet = false } = {}) { |
| 262 | if (loading) return; |
| 263 | loading = true; |
| 264 | const my = ++seq; |
| 265 | const btn = el && el.querySelector('[data-act="refresh"]'); |
| 266 | if (btn) btn.classList.add('spin'); |
| 267 | if (!quiet && !data) paint(); |
| 268 | try { |
| 269 | const doc = await api('/api/v1/me/actions'); |
| 270 | if (my !== seq) return; |
| 271 | data = doc; |
| 272 | // First open: the tab with something in it, waiting first. |
| 273 | if (!tab) tab = ['waiting', 'running', 'scheduled', 'done', 'failed'].find((b) => (doc.counts[b] || 0) > 0) || 'running'; |
| 274 | setTab(tab); |
| 275 | } catch (e) { |
| 276 | if (my !== seq) return; |
| 277 | data = { error: e }; |
| 278 | if (!tab) setTab('running'); else paint(); |
| 279 | } finally { |
| 280 | loading = false; |
| 281 | if (btn) btn.classList.remove('spin'); |
| 282 | schedule(); |
| 283 | } |
| 284 | } |
| 285 | function schedule() { |
| 286 | clearTimeout(timer); |
| 287 | if (isOpen() && data && !data.error && (data.counts.running || 0) > 0) timer = setTimeout(() => load({ quiet: true }), REFRESH_MS); |
| 288 | } |
| 289 | |
| 290 | async function decide(item, what, btn, call = api, reload = () => load({ quiet: true })) { |
| 291 | if (!item || (what !== 'approve' && what !== 'decline')) return; |
| 292 | item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = true; }); |
| 293 | const old = item.querySelector('.ovac-err'); if (old) old.remove(); |
| 294 | try { |
| 295 | await call(`/api/v1/me/actions/${encodeURIComponent(item.dataset.service)}/${encodeURIComponent(item.dataset.id)}/${what}`, { method: 'POST', body: '{}' }); |
| 296 | btn.textContent = what === 'approve' ? 'Approved' : 'Declined'; |
| 297 | setTimeout(reload, 600); |
| 298 | } catch (e) { |
| 299 | item.querySelectorAll('.ovac-btns button').forEach((b) => { b.disabled = false; }); |
| 300 | item.querySelector('.ovac-body').insertAdjacentHTML('beforeend', `<p class="ovac-err" role="alert">${esc(e.message || 'That did not work. Try again.')}</p>`); |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | function isOpen() { return !!(el && el.classList.contains('open')); } |
| 305 | function open(o) { |
| 306 | opts = { ...(opts || {}), ...(o || {}) }; |
| 307 | if (!el) build(); |
| 308 | if (root.OpenVibePanels) { try { root.OpenVibePanels.closeAll(el); } catch { /* */ } } |
| 309 | let bottom = 56; |
| 310 | try { |
| 311 | const bar = opts.anchor && opts.anchor.closest && opts.anchor.closest('nav, header, .navbar'); |
| 312 | bottom = (bar || opts.anchor || null) ? (bar || opts.anchor).getBoundingClientRect().bottom : 56; |
| 313 | } catch { /* no layout: under a 56 px bar */ } |
| 314 | el.style.setProperty('--ovac-top', `${Math.max(8, Math.round(bottom) + 6)}px`); |
| 315 | el.hidden = false; |
| 316 | el.setAttribute('aria-modal', 'true'); |
| 317 | el.classList.add('open'); |
| 318 | el.style.transform = ''; |
| 319 | document.documentElement.classList.toggle('ovac-sheet-open', isPhone()); |
| 320 | if (opts.anchor) { opts.anchor.setAttribute('aria-expanded', 'true'); opts.anchor.classList.add('open'); } |
| 321 | load(); |
| 322 | setTimeout(() => { try { el.querySelector('[role="tab"][aria-selected="true"]')?.focus({ preventScroll: true }); } catch { /* */ } }, 0); |
| 323 | } |
| 324 | function close() { |
| 325 | if (!el) return; |
| 326 | const inside = el.contains(document.activeElement); |
| 327 | el.classList.remove('open'); |
| 328 | el.removeAttribute('aria-modal'); |
| 329 | el.hidden = true; |
| 330 | el.style.transform = ''; |
| 331 | clearTimeout(timer); |
| 332 | document.documentElement.classList.remove('ovac-sheet-open'); |
| 333 | if (opts && opts.anchor) { opts.anchor.setAttribute('aria-expanded', 'false'); opts.anchor.classList.remove('open'); if (inside) { try { opts.anchor.focus({ preventScroll: true }); } catch { /* */ } } } |
| 334 | } |
| 335 | function toggle(o) { if (isOpen()) close(); else open(o); } |
| 336 | |
| 337 | /** |
| 338 | * A list of some buckets in someone else's container (the Dock's Agent tab): waiting first, then running, each |
| 339 | * bucket under a small heading when there is more than one. Its own data, timer and errors; the popover's are |
| 340 | * untouched. onCounts(counts) hears every answer, so the Dock's tab and pill can follow it. |
| 341 | */ |
| 342 | function mount(container, o = {}) { |
| 343 | styles(); |
| 344 | const mo = { ...o }; |
| 345 | const want = Array.isArray(mo.buckets) && mo.buckets.length ? mo.buckets.filter((b) => SHORT[b]) : ['waiting', 'running']; |
| 346 | const call = (path, init) => apiWith(mo, path, init); |
| 347 | let doc = null, t = 0, gone = false, busy = false; |
| 348 | container.classList.add('ovac-mounted'); |
| 349 | container.innerHTML = '<div class="ovac-list" aria-live="polite"><div class="ovac-wait"><span></span></div></div>'; |
| 350 | const list = container.firstChild; |
| 351 | const draw = () => { |
| 352 | if (gone) return; |
| 353 | if (doc && doc.error) { |
| 354 | list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${doc.error.status === 401 ? 'Sign in to see your agents' : 'Could not load your agents'}</b>${doc.error.status === 401 ? '' : 'Check your connection and try again.'}</div>`; |
| 355 | return; |
| 356 | } |
| 357 | if (!doc) return; |
| 358 | const groups = want.map((b) => [b, (doc.buckets && doc.buckets[b]) || []]).filter(([, items]) => items.length); |
| 359 | if (!groups.length) { |
| 360 | const [h, p] = mo.empty || ['Nothing is running', 'When an agent works for you, it shows here with its steps, and anything it asks you shows first.']; |
| 361 | list.innerHTML = `<div class="ovac-empty">${svg(G.spark)}<b>${esc(h)}</b>${esc(p)}</div>`; |
| 362 | return; |
| 363 | } |
| 364 | list.innerHTML = groups.map(([b, items]) => (groups.length > 1 ? `<h4 class="ovac-group" data-bucket="${b}">${esc(b === 'waiting' ? 'Needs you' : SHORT[b])}<span>${items.length}</span></h4>` : '') |
| 365 | + items.map((it) => itemHTML(it, mo)).join('')).join(''); |
| 366 | }; |
| 367 | const refresh = async () => { |
| 368 | if (gone || busy) return; |
| 369 | busy = true; clearTimeout(t); |
| 370 | try { doc = await call('/api/v1/me/actions'); } catch (e) { doc = { error: e }; } |
| 371 | busy = false; |
| 372 | if (gone) return; |
| 373 | draw(); |
| 374 | if (doc && !doc.error && typeof mo.onCounts === 'function') { try { mo.onCounts(doc.counts || {}); } catch { /* */ } } |
| 375 | if (doc && !doc.error && (doc.counts.running || 0) > 0) t = setTimeout(refresh, REFRESH_MS); |
| 376 | }; |
| 377 | list.addEventListener('click', (e) => { |
| 378 | const b = e.target.closest('button[data-do]'); if (!b) return; |
| 379 | decide(b.closest('.ovac-item'), b.dataset.do, b, call, refresh); |
| 380 | }); |
| 381 | refresh(); |
| 382 | return { refresh, destroy() { gone = true; clearTimeout(t); container.classList.remove('ovac-mounted'); container.innerHTML = ''; } }; |
| 383 | } |
| 384 | |
| 385 | root.OpenVibeActionCenter = { open, close, toggle, isOpen, mount, _state: () => ({ data, tab }) }; |
| 386 | })(typeof window !== 'undefined' ? window : globalThis); |
| 387 |