openvibers/openvibe.shared action-center.js

action-center.js

main 30.7 KB · 387 lines · javascript

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 'use strict';
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
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