openvibers/openvibe.shared dock-timeline.js

dock-timeline.js

main 24.7 KB · 424 lines · javascript

1// ═══════════════════════════════════════════════════════════════
2// OpenVibe — the Dock's Agent panel (design 2026-11-07: one agent, one timeline behind the Dock), Shared 4.126.0
3//
4// OpenVibeDockTimeline.mount(container, { token, base, active, focus }) → { refresh(), destroy() }
5//
6// `token` is the person's own Network token: a string, or the function the navbar hands over (which
7// resolves it when it is needed). Nothing is asked without it: a signed-out page can never read the timeline.
8// OpenVibe.Actor's API is GET /api/v1/agent/timeline (the person's own steps, newest first, with the cursor and the
9// mode and needs-you filters), POST /api/v1/agent/approvals/:product/:id/decide (an approval is answered in the mode
10// that owns it) and POST /api/v1/agent/kill-switch (stop what is open and pause the agent). On a site every call goes
11// through Network's same-origin relay (/api/frame/agent…, /api/frame/tasks…) because every site's connect-src allows
12// openvibe.network and refuses openvibe.actor; `base` reaches Actor itself in a test, and `frameBase` names the relay.
13// `focus` is the id of the step to point at (the task id a site opened the Dock with): the entry that
14// carries it is marked and brought into view.
15//
16// The panel is what the person reads:
17// Now one card for what the agent is doing this moment: the mode's word, the step in plain words, how long,
18// and the mode's own controls (Stop, and Resume when it is paused). Nothing running: "Your agent is idle."
19// Needs you every approval or question from any mode, oldest first, with its buttons in place.
20// Timeline one entry per step, newest first: the mode, the title, where it ran and why, the outcome, the time
21// and the cost, and a link to the step's own page. A mode change is a line of its own.
22// Folding today's entries open; earlier days are counted folds ("Yesterday (14)"); a long run of one mode folds
23// into "12 more Code steps".
24// Filters a chip for each mode and for "Needs you", in the URL of the panel; a filter never hides without a
25// count.
26//
27// Everything is set as text, never as markup: a title, a reason or a link label never becomes HTML.
28// ═══════════════════════════════════════════════════════════════
29(function (root) {
30 'use strict';
31 if (typeof document === 'undefined' || root.OpenVibeDockTimeline) return;
32
33 const POLL_MS = 15000;
34 const MODES = [
35 { id: 'talk', word: 'Talk' }, { id: 'act', word: 'Act' }, { id: 'run', word: 'Run' },
36 { id: 'code', word: 'Code' }, { id: 'browse', word: 'Browse' }, { id: 'watch', word: 'Watch' },
37 { id: 'stream', word: 'Stream' },
38 ];
39 const word = (mode) => (MODES.find((m) => m.id === mode) || { word: 'Agent' }).word;
40 const OUTCOME = { done: 'Done', failed: 'Failed', stopped: 'Stopped by you', running: 'Running', waiting: 'Waiting', needs_you: 'Needs you' };
41
42 function el(doc, tag, cls, text) {
43 const n = doc.createElement(tag);
44 if (cls) n.className = cls;
45 if (text !== undefined && text !== null) n.textContent = String(text);
46 return n;
47 }
48
49 function since(startedAt) {
50 const t = Date.parse(String(startedAt || ''));
51 if (!Number.isFinite(t)) return '';
52 const s = Math.max(0, Math.round((Date.now() - t) / 1000));
53 if (s < 60) return s + 's';
54 if (s < 3600) return Math.round(s / 60) + ' min';
55 return Math.round(s / 3600) + ' h';
56 }
57
58 function dayKey(iso) {
59 const d = new Date(String(iso || ''));
60 if (Number.isNaN(d.getTime())) return '';
61 return d.toISOString().slice(0, 10);
62 }
63
64 function styles(doc) {
65 if (!doc || doc.getElementById('ovdt-styles')) return;
66 const st = doc.createElement('style');
67 st.id = 'ovdt-styles';
68 st.textContent = [
69 // The site's own theme (Opus, 11-07): the Dock's tokens, so the panel is the navy of a dark site and the paper
70 // of a light one, never a grey card of its own. The site's font, a gutter that clears the panel's edge, one
71 // primary button per card, and a breathing dot only on what is really running.
72 '.ovdt{font:inherit;font-size:14px;line-height:1.45;color:var(--text-primary,#ececf1);padding:4px 12px 20px}',
73 '.ovdt h3{margin:14px 2px 8px;font-size:11px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:var(--text-muted,#93a0b4)}',
74 '.ovdt h3:first-child{margin-top:6px}',
75 '.ovdt-card{border:1px solid var(--border,rgba(255,255,255,.1));border-radius:14px;padding:12px 14px;margin:0 0 8px;background:color-mix(in srgb,var(--text-primary,#ececf1) 4%,transparent)}',
76 '.ovdt-needs .ovdt-card{border-color:color-mix(in srgb,var(--warning,#f5a524) 38%,var(--border,rgba(255,255,255,.1)));background:color-mix(in srgb,var(--warning,#f5a524) 7%,transparent)}',
77 '.ovdt-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
78 '.ovdt-needs .ovdt-card>.ovdt-mode{margin-bottom:2px;font-size:11.5px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;color:color-mix(in srgb,var(--warning,#f5a524) 70%,var(--text-primary,#ececf1))}',
79 '.ovdt-mode{font-weight:750}',
80 '.ovdt-title{flex:1 1 100%;min-width:0;overflow-wrap:anywhere;font-size:14.5px;font-weight:550}',
81 '.ovdt-meta{color:var(--text-muted,#93a0b4);font-size:12.5px;overflow-wrap:anywhere}',
82 '.ovdt-outcome{display:inline-flex;align-items:center;gap:6px;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:750;letter-spacing:.04em;text-transform:uppercase;color:var(--text-secondary,#a8b3c4);background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent)}',
83 '.ovdt-outcome[data-s="running"]{color:color-mix(in srgb,var(--accent,#3b82f6) 72%,var(--text-primary,#ececf1));background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent)}',
84 '.ovdt-outcome[data-s="running"]::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;animation:ovdt-breathe 1.8s ease-in-out infinite}',
85 '.ovdt-outcome[data-s="failed"]{color:var(--danger-light,#f87171);background:color-mix(in srgb,var(--danger,#ef4444) 14%,transparent)}',
86 '@keyframes ovdt-breathe{50%{opacity:.35;transform:scale(.8)}}',
87 '.ovdt-buttons{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}',
88 '.ovdt-btn{min-height:44px;min-width:44px;padding:0 16px;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.14));background:transparent;color:inherit;font:inherit;font-weight:650;cursor:pointer}',
89 '.ovdt-btn.is-primary{border-color:transparent;background:var(--accent,#3b82f6);color:var(--on-accent,#fff)}',
90 '.ovdt-btn.is-stop{border-color:color-mix(in srgb,var(--danger,#ef4444) 45%,transparent);color:var(--danger-light,#f87171)}',
91 '.ovdt-btn:disabled{opacity:.55;cursor:default}',
92 '.ovdt-btn:focus-visible,.ovdt-chip:focus-visible,.ovdt-fold:focus-visible{outline:2px solid var(--accent-light,var(--accent,#3b82f6));outline-offset:1px}',
93 '@media (hover:hover){.ovdt-btn:not(.is-primary):hover,.ovdt-chip:hover{background:color-mix(in srgb,var(--text-primary,#ececf1) 7%,transparent)}.ovdt-btn.is-primary:hover{filter:brightness(1.08)}}',
94 '.ovdt-chip{min-height:44px;padding:0 14px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.12));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-size:13.5px;font-weight:650;cursor:pointer;white-space:nowrap;flex:none}',
95 '.ovdt-chip[aria-pressed="true"]{color:color-mix(in srgb,var(--accent,#3b82f6) 72%,var(--text-primary,#ececf1));border-color:color-mix(in srgb,var(--accent,#3b82f6) 55%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent)}',
96 '.ovdt-chips{display:flex;gap:8px;overflow-x:auto;margin:4px -12px 0;padding:6px 12px 10px;scrollbar-width:none;-webkit-overflow-scrolling:touch}',
97 '.ovdt-chips::-webkit-scrollbar{display:none}',
98 '.ovdt-fold{width:100%;text-align:left;min-height:44px;margin:0 0 8px;padding:0 14px;border-radius:12px;border:1px dashed var(--border,rgba(255,255,255,.16));background:transparent;color:var(--text-secondary,#a8b3c4);font:inherit;font-weight:650;cursor:pointer}',
99 '.ovdt-card.ovdt-focus{border-color:color-mix(in srgb,var(--accent,#3b82f6) 55%,transparent)}',
100 '.ovdt-empty{color:var(--text-muted,#93a0b4);padding:4px 2px 6px}',
101 '.ovdt-card.ovdt-empty{padding:12px 14px}',
102 '.ovdt-link{color:color-mix(in srgb,var(--accent,#3b82f6) 72%,var(--text-primary,#ececf1));font-weight:650;text-decoration:none}',
103 '@media (max-width:640px){.ovdt-link{display:inline-flex;align-items:center;min-height:44px;padding:0 4px}}',
104 '',
105 ].join('');
106 (doc.head || doc.documentElement).appendChild(st);
107 }
108
109 function mount(container, opts = {}) {
110 const doc = container.ownerDocument || document;
111 styles(doc);
112 // Network's frame relay on a site; an explicit base reaches Actor itself (a test).
113 const direct = opts.base ? String(opts.base).replace(/\/+$/, '') : '';
114 const frameBase = String(opts.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
115 const endpoint = (path) => (direct ? direct + path : frameBase + path.replace(/^\/api\/v1/, ''));
116 const rawToken = opts.token;
117 const active = typeof opts.active === 'function' ? opts.active : () => true;
118 const focus = typeof opts.focus === 'function' ? opts.focus : () => opts.focus || null;
119 const filters = new Set();
120 let timer = null;
121 let destroyed = false;
122
123 // The navbar hands the Dock a function (the token may change when the person signs in again),
124 // so it is resolved when a call is made, never read once at mount.
125 async function bearer() {
126 const t = typeof rawToken === 'function' ? await rawToken() : rawToken;
127 return t ? 'Bearer ' + t : null;
128 }
129
130 const wrap = el(doc, 'div', 'ovdt');
131 const nowHead = el(doc, 'h3', null, 'Now');
132 const nowBox = el(doc, 'div', 'ovdt-now');
133 const needsHead = el(doc, 'h3', null, 'Needs you');
134 const needsBox = el(doc, 'div', 'ovdt-needs');
135 const chips = el(doc, 'div', 'ovdt-chips');
136 const list = el(doc, 'div', 'ovdt-list');
137 wrap.append(nowHead, nowBox, needsHead, needsBox, chips, list);
138 container.appendChild(wrap);
139
140 async function api(path, options = {}) {
141 const auth = await bearer();
142 if (!auth) throw new Error('Sign in to see your agent');
143 const res = await fetch(endpoint(path), Object.assign({ headers: { Accept: 'application/json', Authorization: auth } }, options));
144 const body = await res.json().catch(() => ({}));
145 if (!res.ok) throw new Error((body && (body.detail || body.title)) || ('HTTP ' + res.status));
146 return body;
147 }
148
149 function chip(id, label, count) {
150 const b = el(doc, 'button', 'ovdt-chip', label + (count === undefined || count === null ? '' : ' (' + count + ')'));
151 b.type = 'button';
152 b.setAttribute('aria-pressed', filters.has(id) ? 'true' : 'false');
153 b.addEventListener('click', () => {
154 if (filters.has(id)) filters.delete(id); else filters.add(id);
155 refresh();
156 });
157 return b;
158 }
159
160 /** The step's state as a small pill; data-s lets a running step breathe and a failed one show red. */
161 function outcome(status) {
162 const o = el(doc, 'span', 'ovdt-outcome', OUTCOME[status] || status);
163 o.setAttribute('data-s', String(status || '').toLowerCase());
164 return o;
165 }
166
167 function renderNow(now) {
168 nowBox.textContent = '';
169 if (!now) {
170 nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'Your agent is idle. Ask it something in Talk.'));
171 return;
172 }
173 const card = el(doc, 'div', 'ovdt-card');
174 const row = el(doc, 'div', 'ovdt-row');
175 row.appendChild(el(doc, 'span', 'ovdt-mode', word(now.mode)));
176 row.appendChild(outcome(now.status));
177 row.appendChild(el(doc, 'span', 'ovdt-title', now.title));
178 row.appendChild(el(doc, 'span', 'ovdt-meta', since(now.started_at)));
179 card.appendChild(row);
180 if (now.placement && (now.placement.where || now.placement.why)) {
181 card.appendChild(el(doc, 'div', 'ovdt-meta', [now.placement.where, now.placement.why].filter(Boolean).join(': ')));
182 }
183 // The Computer this step works on (plan 2026-11-07): the same machine and cost the task record carries.
184 if (now.computer) card.appendChild(el(doc, 'div', 'ovdt-meta', [now.computer.machine, now.computer.cost_hint].filter(Boolean).join(' · ')));
185 const buttons = el(doc, 'div', 'ovdt-buttons');
186 // Take over / Give it back: one API on the task at Actor, the same hand-off the agent's own
187 // hand_to_person tool uses. Only a step that names its task (ref) and its Computer offers it.
188 if (now.computer && now.ref) {
189 const driving = now.computer.driving === true || /^You took over/.test(String(now.title || ''));
190 const take = el(doc, 'button', 'ovdt-btn is-primary', driving ? 'Give it back' : 'Take over');
191 take.type = 'button';
192 take.addEventListener('click', async () => {
193 take.disabled = true;
194 const auth = await bearer();
195 try {
196 await api('/api/v1/tasks/' + encodeURIComponent(now.ref) + (driving ? '/giveback' : '/takeover'), {
197 method: 'POST', headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) }, body: '{}',
198 });
199 } catch (err) { /* the panel refreshes either way */ }
200 refresh();
201 });
202 buttons.appendChild(take);
203 }
204 const stop = el(doc, 'button', 'ovdt-btn is-stop', 'Stop');
205 stop.type = 'button';
206 stop.addEventListener('click', async () => {
207 stop.disabled = true;
208 const auth = await bearer();
209 try { await api('/api/v1/agent/kill-switch', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) }, body: JSON.stringify({ action: 'stop' }) }); } catch (err) { /* the panel refreshes either way */ }
210 refresh();
211 });
212 buttons.appendChild(stop);
213 card.appendChild(buttons);
214 nowBox.appendChild(card);
215 }
216
217 function renderNeeds(needs) {
218 needsBox.textContent = '';
219 if (!needs.length) {
220 needsBox.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing needs you.'));
221 return;
222 }
223 for (const item of needs) {
224 const card = el(doc, 'div', 'ovdt-card');
225 card.appendChild(el(doc, 'div', 'ovdt-mode', word(item.mode) + ' needs you'));
226 card.appendChild(el(doc, 'div', 'ovdt-title', item.title));
227 if (item.reason) card.appendChild(el(doc, 'div', 'ovdt-meta', item.reason));
228 const buttons = el(doc, 'div', 'ovdt-buttons');
229 for (const [label, decision] of [['Approve', 'approve'], ['Decline', 'decline']]) {
230 const b = el(doc, 'button', decision === 'approve' ? 'ovdt-btn is-primary' : 'ovdt-btn', label);
231 b.type = 'button';
232 b.addEventListener('click', async () => {
233 b.disabled = true;
234 const approval = item.approval || {};
235 const auth = await bearer();
236 try {
237 await api('/api/v1/agent/approvals/' + encodeURIComponent(approval.product || item.product) + '/' + encodeURIComponent(approval.id) + '/decide', {
238 method: 'POST',
239 headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) },
240 body: JSON.stringify({ decision }),
241 });
242 } catch (err) { /* the panel refreshes either way */ }
243 refresh();
244 });
245 buttons.appendChild(b);
246 }
247 card.appendChild(buttons);
248 needsBox.appendChild(card);
249 }
250 }
251
252 function entryNode(entry) {
253 const card = el(doc, 'div', 'ovdt-card');
254 const row = el(doc, 'div', 'ovdt-row');
255 row.appendChild(el(doc, 'span', 'ovdt-mode', word(entry.mode)));
256 row.appendChild(outcome(entry.status));
257 row.appendChild(el(doc, 'span', 'ovdt-title', entry.title));
258 const when = new Date(entry.started_at || 0);
259 row.appendChild(el(doc, 'span', 'ovdt-meta', Number.isNaN(when.getTime()) ? '' : when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })));
260 card.appendChild(row);
261 if (entry.transition) card.appendChild(el(doc, 'div', 'ovdt-meta', entry.transition));
262 else if (entry.placement && (entry.placement.where || entry.placement.why)) {
263 card.appendChild(el(doc, 'div', 'ovdt-meta', [entry.placement.where, entry.placement.why].filter(Boolean).join(': ')));
264 }
265 if (entry.computer) card.appendChild(el(doc, 'div', 'ovdt-meta', [entry.computer.machine, entry.computer.cost_hint].filter(Boolean).join(' · ')));
266 if (entry.reason) card.appendChild(el(doc, 'div', 'ovdt-meta', entry.reason));
267 if (entry.cost_usd !== null && entry.cost_usd !== undefined) card.appendChild(el(doc, 'div', 'ovdt-meta', '$' + Number(entry.cost_usd).toFixed(2)));
268 if (entry.link) {
269 const a = el(doc, 'a', 'ovdt-link', 'Open');
270 a.href = entry.link;
271 a.rel = 'noopener noreferrer';
272 a.target = '_blank';
273 card.appendChild(a);
274 }
275 // The step a site opened the Dock at (its task id): it is marked, so the person sees where they landed.
276 const wanted = focusId();
277 if (wanted && (entry.id === wanted || entry.ref === wanted)) card.classList.add('ovdt-focus');
278 return card;
279 }
280
281 /** The step to point at: the id (a task id) a site opened the Dock with, if it is still there. */
282 function focusId() {
283 try { return String(focus() || '').trim() || null; } catch (err) { return null; }
284 }
285
286 /** Bring the focused step into view (the Dock's panel is short; the step may be below the fold). */
287 function scrollToFocus() {
288 const wanted = focusId();
289 if (!wanted) return;
290 const node = list.querySelector('.ovdt-focus');
291 if (!node || !node.scrollIntoView) return;
292 try { node.scrollIntoView({ block: 'nearest' }); } catch (err) { node.scrollIntoView(); }
293 }
294
295 function renderList(entries) {
296 list.textContent = '';
297 if (!entries.length) {
298 list.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing yet. The agent\u2019s steps show up here as it works.'));
299 return;
300 }
301 // Today's entries are open; earlier days are counted folds.
302 const today = new Date().toISOString().slice(0, 10);
303 const groups = new Map();
304 for (const entry of entries) {
305 const key = dayKey(entry.started_at);
306 if (!groups.has(key)) groups.set(key, []);
307 groups.get(key).push(entry);
308 }
309 for (const [key, group] of groups) {
310 if (key === today) {
311 for (const entry of group) list.appendChild(entryNode(entry));
312 continue;
313 }
314 const fold = el(doc, 'button', 'ovdt-fold', labelOf(key, group.length));
315 fold.type = 'button';
316 fold.setAttribute('aria-expanded', 'false');
317 fold.addEventListener('click', () => {
318 const open = fold.getAttribute('aria-expanded') === 'true';
319 fold.setAttribute('aria-expanded', open ? 'false' : 'true');
320 const shown = fold.nextElementSibling;
321 if (shown && shown.classList.contains('ovdt-fold-body')) shown.remove();
322 if (!open) {
323 const box = el(doc, 'div', 'ovdt-fold-body');
324 for (const entry of group) box.appendChild(entryNode(entry));
325 fold.after(box);
326 }
327 });
328 list.appendChild(fold);
329 }
330 // A long run of one mode folds into "12 more Code steps".
331 let run = 0;
332 let runMode = null;
333 const runs = [];
334 for (const entry of entries) {
335 if (entry.mode === runMode) { run += 1; continue; }
336 if (run >= 3) runs.push({ mode: runMode, count: run });
337 runMode = entry.mode;
338 run = 1;
339 }
340 if (run >= 3) runs.push({ mode: runMode, count: run });
341 for (const r of runs) {
342 const more = el(doc, 'div', 'ovdt-meta', r.count + ' more ' + word(r.mode) + ' steps');
343 list.appendChild(more);
344 }
345 }
346
347 function labelOf(day, count) {
348 const today = new Date();
349 const d = new Date(day + 'T00:00:00Z');
350 const diff = Math.round((Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) - d.getTime()) / 86400000);
351 if (diff === 1) return 'Yesterday (' + count + ')';
352 if (diff < 7) return 'This week (' + count + ')';
353 return day + ' (' + count + ')';
354 }
355
356 function renderChips(counts) {
357 chips.textContent = '';
358 for (const m of MODES) {
359 const n = counts && counts[m.id];
360 if (!n) continue;
361 chips.appendChild(chip(m.id, m.word, n));
362 }
363 if (counts && counts.needs_you) chips.appendChild(chip('needs_you', 'Needs you', counts.needs_you));
364 }
365
366 async function refresh() {
367 if (destroyed || !active()) return;
368 const mode = [...filters].find((f) => f !== 'needs_you');
369 const query = [];
370 if (mode) query.push('mode=' + encodeURIComponent(mode));
371 if (filters.has('needs_you')) query.push('needs_you=1');
372 const path = '/api/v1/agent/timeline' + (query.length ? '?' + query.join('&') : '');
373 let data;
374 try { data = await api(path); } catch (err) {
375 if (destroyed) return;
376 container.hidden = true;
377 return;
378 }
379 if (destroyed) return;
380 container.hidden = !data.now && !(data.needs_you || []).length && !(data.entries || []).length;
381 renderNow(data.now);
382 renderNeeds(data.needs_you || []);
383 renderChips(data.counts || {});
384 renderList(data.entries || []);
385 scrollToFocus();
386 writeUrl();
387 }
388
389 function writeUrl() {
390 try {
391 const url = new URL(doc.defaultView.location.href);
392 url.searchParams.delete('mode');
393 url.searchParams.delete('needs_you');
394 for (const f of filters) {
395 if (f === 'needs_you') url.searchParams.set('needs_you', '1');
396 else url.searchParams.set('mode', f);
397 }
398 doc.defaultView.history.replaceState(null, '', url.toString());
399 } catch (err) { /* the panel keeps its filters in memory */ }
400 }
401
402 function start() {
403 if (timer) return;
404 timer = setInterval(refresh, POLL_MS);
405 if (timer.unref) timer.unref();
406 }
407
408 refresh();
409 start();
410
411 return {
412 refresh,
413 destroy() {
414 destroyed = true;
415 if (timer) clearInterval(timer);
416 timer = null;
417 wrap.remove();
418 },
419 };
420 }
421
422 root.OpenVibeDockTimeline = { mount };
423}(typeof window !== 'undefined' ? window : globalThis));
424