openvibers/openvibe.shared Commits
A6: the Dock's Agent panel (Now, Needs you, the one timeline) — 4.127.0 (#313)
Changes
@@@ -1,5 +1,13 @@# Changelog++## 4.127.0 — 2026-11-07++++- **The Dock's Agent panel** (design 2026-11-07, one agent behind the Dock): Now says what the agent is doing this moment (the mode, the step in plain words, how long, and Stop/Resume), Needs you lists every approval and question from any mode with its buttons in place, and the timeline below shows one entry per step — the mode, the title, where it ran and why, the outcome, the time, the cost and a link to the step's own page. A mode change is a line of its own.++- **Folding and filters:** today's entries are open, earlier days are counted folds ("Yesterday (14)"), and a long run of one mode folds into "12 more Code steps". A chip for each mode and for Needs you sits in the URL of the panel, and a filter never hides without a count.++- **Only me by default:** the panel shows the person's own entries only; a step's title, outcome and link, never a prompt or a diff.++- **It wears the site's theme:** the panel uses the site's own tokens and font (the navy of a dark site, the paper of a light one), with a 12 px gutter. Approve is the one filled button on a card and Stop is red. A running step's pill breathes; nothing else moves. Needs-you cards are tinted and labelled with their mode.++- **The Dock's tabs always fit:** in a narrow Dock the open tab shows its icon and name and the others their icon and count, their names kept for screen readers. "Activity" was cut off at the strip's edge.++## 4.126.0 — 2026-11-07- **Audio description in the player (X1)**: a video with a `<track kind="descriptions">` gets an **AD** button beside@@@ -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.126.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.127.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.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.126.0",++ "version": "4.127.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,362 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — the Dock's Agent panel (design 2026-11-07: one agent, one timeline behind the Dock), Shared 4.126.0++//++// OpenVibeDockTimeline.mount(container, { token, base, active }) → { refresh(), destroy() }++//++// `base` is OpenVibe.Actor's API: GET /api/v1/agent/timeline (the person's own steps, newest first, with the cursor++// and the mode and needs-you filters), POST /api/v1/agent/approvals/:product/:id/decide (an approval is answered in++// the mode that owns it) and POST /api/v1/agent/kill-switch (stop what is open and pause the agent).++//++// The panel is what the person reads:++// Now one card for what the agent is doing this moment: the mode's word, the step in plain words, how long,++// and the mode's own controls (Stop, and Resume when it is paused). Nothing running: "Your agent is idle."++// Needs you every approval or question from any mode, oldest first, with its buttons in place.++// Timeline one entry per step, newest first: the mode, the title, where it ran and why, the outcome, the time++// and the cost, and a link to the step's own page. A mode change is a line of its own.++// Folding today's entries open; earlier days are counted folds ("Yesterday (14)"); a long run of one mode folds++// into "12 more Code steps".++// Filters a chip for each mode and for "Needs you", in the URL of the panel; a filter never hides without a++// count.++//++// Everything is set as text, never as markup: a title, a reason or a link label never becomes HTML.++// ═══════════════════════════════════════════════════════════════++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeDockTimeline) return;++++ const POLL_MS = 15000;++ const MODES = [++ { id: 'talk', word: 'Talk' }, { id: 'act', word: 'Act' }, { id: 'run', word: 'Run' },++ { id: 'code', word: 'Code' }, { id: 'browse', word: 'Browse' }, { id: 'watch', word: 'Watch' },++ { id: 'stream', word: 'Stream' },++ ];++ const word = (mode) => (MODES.find((m) => m.id === mode) || { word: 'Agent' }).word;++ const OUTCOME = { done: 'Done', failed: 'Failed', stopped: 'Stopped by you', running: 'Running', waiting: 'Waiting', needs_you: 'Needs you' };++++ function el(doc, tag, cls, text) {++ const n = doc.createElement(tag);++ if (cls) n.className = cls;++ if (text !== undefined && text !== null) n.textContent = String(text);++ return n;++ }++++ function since(startedAt) {++ const t = Date.parse(String(startedAt || ''));++ if (!Number.isFinite(t)) return '';++ const s = Math.max(0, Math.round((Date.now() - t) / 1000));++ if (s < 60) return s + 's';++ if (s < 3600) return Math.round(s / 60) + ' min';++ return Math.round(s / 3600) + ' h';++ }++++ function dayKey(iso) {++ const d = new Date(String(iso || ''));++ if (Number.isNaN(d.getTime())) return '';++ return d.toISOString().slice(0, 10);++ }++++ function styles(doc) {++ if (!doc || doc.getElementById('ovdt-styles')) return;++ const st = doc.createElement('style');++ st.id = 'ovdt-styles';++ st.textContent = [++ // 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++ // of a light one, never a grey card of its own. The site's font, a gutter that clears the panel's edge, one++ // primary button per card, and a breathing dot only on what is really running.++ '.ovdt{font:inherit;font-size:14px;line-height:1.45;color:var(--text-primary,#ececf1);padding:4px 12px 20px}',++ '.ovdt h3{margin:14px 2px 8px;font-size:11px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:var(--text-muted,#93a0b4)}',++ '.ovdt h3:first-child{margin-top:6px}',++ '.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)}',++ '.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)}',++ '.ovdt-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',++ '.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))}',++ '.ovdt-mode{font-weight:750}',++ '.ovdt-title{flex:1 1 100%;min-width:0;overflow-wrap:anywhere;font-size:14.5px;font-weight:550}',++ '.ovdt-meta{color:var(--text-muted,#93a0b4);font-size:12.5px;overflow-wrap:anywhere}',++ '.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)}',++ '.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)}',++ '.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}',++ '.ovdt-outcome[data-s="failed"]{color:var(--danger-light,#f87171);background:color-mix(in srgb,var(--danger,#ef4444) 14%,transparent)}',++ '@keyframes ovdt-breathe{50%{opacity:.35;transform:scale(.8)}}',++ '.ovdt-buttons{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}',++ '.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}',++ '.ovdt-btn.is-primary{border-color:transparent;background:var(--accent,#3b82f6);color:var(--on-accent,#fff)}',++ '.ovdt-btn.is-stop{border-color:color-mix(in srgb,var(--danger,#ef4444) 45%,transparent);color:var(--danger-light,#f87171)}',++ '.ovdt-btn:disabled{opacity:.55;cursor:default}',++ '.ovdt-btn:focus-visible,.ovdt-chip:focus-visible,.ovdt-fold:focus-visible{outline:2px solid var(--accent-light,var(--accent,#3b82f6));outline-offset:1px}',++ '@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)}}',++ '.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}',++ '.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)}',++ '.ovdt-chips{display:flex;gap:8px;overflow-x:auto;margin:4px -12px 0;padding:6px 12px 10px;scrollbar-width:none;-webkit-overflow-scrolling:touch}',++ '.ovdt-chips::-webkit-scrollbar{display:none}',++ '.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}',++ '.ovdt-empty{color:var(--text-muted,#93a0b4);padding:4px 2px 6px}',++ '.ovdt-card.ovdt-empty{padding:12px 14px}',++ '.ovdt-link{color:color-mix(in srgb,var(--accent,#3b82f6) 72%,var(--text-primary,#ececf1));font-weight:650;text-decoration:none}',++ '@media (max-width:640px){.ovdt-link{display:inline-flex;align-items:center;min-height:44px;padding:0 4px}}',++ '@media (prefers-reduced-motion:reduce){.ovdt-outcome[data-s="running"]::before{animation:none}}',++ ].join('');++ (doc.head || doc.documentElement).appendChild(st);++ }++++ function mount(container, opts = {}) {++ const doc = container.ownerDocument || document;++ styles(doc);++ const base = String(opts.base || 'https://openvibe.actor').replace(/\/+$/, '');++ const token = opts.token;++ const active = typeof opts.active === 'function' ? opts.active : () => true;++ const filters = new Set();++ let timer = null;++ let destroyed = false;++++ const wrap = el(doc, 'div', 'ovdt');++ const nowHead = el(doc, 'h3', null, 'Now');++ const nowBox = el(doc, 'div', 'ovdt-now');++ const needsHead = el(doc, 'h3', null, 'Needs you');++ const needsBox = el(doc, 'div', 'ovdt-needs');++ const chips = el(doc, 'div', 'ovdt-chips');++ const list = el(doc, 'div', 'ovdt-list');++ wrap.append(nowHead, nowBox, needsHead, needsBox, chips, list);++ container.appendChild(wrap);++++ async function api(path, options = {}) {++ const res = await fetch(base + path, Object.assign({ headers: { Accept: 'application/json', Authorization: 'Bearer ' + token } }, options));++ const body = await res.json().catch(() => ({}));++ if (!res.ok) throw new Error((body && (body.detail || body.title)) || ('HTTP ' + res.status));++ return body;++ }++++ function chip(id, label, count) {++ const b = el(doc, 'button', 'ovdt-chip', label + (count === undefined || count === null ? '' : ' (' + count + ')'));++ b.type = 'button';++ b.setAttribute('aria-pressed', filters.has(id) ? 'true' : 'false');++ b.addEventListener('click', () => {++ if (filters.has(id)) filters.delete(id); else filters.add(id);++ refresh();++ });++ return b;++ }++++ /** The step's state as a small pill; data-s lets a running step breathe and a failed one show red. */++ function outcome(status) {++ const o = el(doc, 'span', 'ovdt-outcome', OUTCOME[status] || status);++ o.setAttribute('data-s', String(status || '').toLowerCase());++ return o;++ }++++ function renderNow(now) {++ nowBox.textContent = '';++ if (!now) {++ nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'Your agent is idle. Ask it something in Talk.'));++ return;++ }++ const card = el(doc, 'div', 'ovdt-card');++ const row = el(doc, 'div', 'ovdt-row');++ row.appendChild(el(doc, 'span', 'ovdt-mode', word(now.mode)));++ row.appendChild(outcome(now.status));++ row.appendChild(el(doc, 'span', 'ovdt-title', now.title));++ row.appendChild(el(doc, 'span', 'ovdt-meta', since(now.started_at)));++ card.appendChild(row);++ if (now.placement && (now.placement.where || now.placement.why)) {++ card.appendChild(el(doc, 'div', 'ovdt-meta', [now.placement.where, now.placement.why].filter(Boolean).join(': ')));++ }++ const buttons = el(doc, 'div', 'ovdt-buttons');++ const stop = el(doc, 'button', 'ovdt-btn is-stop', 'Stop');++ stop.type = 'button';++ stop.addEventListener('click', async () => {++ stop.disabled = true;++ try { await api('/api/v1/agent/kill-switch', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ action: 'stop' }) }); } catch (err) { /* the panel refreshes either way */ }++ refresh();++ });++ buttons.appendChild(stop);++ card.appendChild(buttons);++ nowBox.appendChild(card);++ }++++ function renderNeeds(needs) {++ needsBox.textContent = '';++ if (!needs.length) {++ needsBox.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing needs you.'));++ return;++ }++ for (const item of needs) {++ const card = el(doc, 'div', 'ovdt-card');++ card.appendChild(el(doc, 'div', 'ovdt-mode', word(item.mode) + ' needs you'));++ card.appendChild(el(doc, 'div', 'ovdt-title', item.title));++ if (item.reason) card.appendChild(el(doc, 'div', 'ovdt-meta', item.reason));++ const buttons = el(doc, 'div', 'ovdt-buttons');++ for (const [label, decision] of [['Approve', 'approve'], ['Decline', 'decline']]) {++ const b = el(doc, 'button', decision === 'approve' ? 'ovdt-btn is-primary' : 'ovdt-btn', label);++ b.type = 'button';++ b.addEventListener('click', async () => {++ b.disabled = true;++ const approval = item.approval || {};++ try {++ await api('/api/v1/agent/approvals/' + encodeURIComponent(approval.product || item.product) + '/' + encodeURIComponent(approval.id) + '/decide', {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },++ body: JSON.stringify({ decision }),++ });++ } catch (err) { /* the panel refreshes either way */ }++ refresh();++ });++ buttons.appendChild(b);++ }++ card.appendChild(buttons);++ needsBox.appendChild(card);++ }++ }++++ function entryNode(entry) {++ const card = el(doc, 'div', 'ovdt-card');++ const row = el(doc, 'div', 'ovdt-row');++ row.appendChild(el(doc, 'span', 'ovdt-mode', word(entry.mode)));++ row.appendChild(outcome(entry.status));++ row.appendChild(el(doc, 'span', 'ovdt-title', entry.title));++ const when = new Date(entry.started_at || 0);++ row.appendChild(el(doc, 'span', 'ovdt-meta', Number.isNaN(when.getTime()) ? '' : when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })));++ card.appendChild(row);++ if (entry.transition) card.appendChild(el(doc, 'div', 'ovdt-meta', entry.transition));++ else if (entry.placement && (entry.placement.where || entry.placement.why)) {++ card.appendChild(el(doc, 'div', 'ovdt-meta', [entry.placement.where, entry.placement.why].filter(Boolean).join(': ')));++ }++ if (entry.reason) card.appendChild(el(doc, 'div', 'ovdt-meta', entry.reason));++ if (entry.cost_usd !== null && entry.cost_usd !== undefined) card.appendChild(el(doc, 'div', 'ovdt-meta', '$' + Number(entry.cost_usd).toFixed(2)));++ if (entry.link) {++ const a = el(doc, 'a', 'ovdt-link', 'Open');++ a.href = entry.link;++ a.rel = 'noopener noreferrer';++ a.target = '_blank';++ card.appendChild(a);++ }++ return card;++ }++++ function renderList(entries) {++ list.textContent = '';++ if (!entries.length) {++ list.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing yet. The agent\u2019s steps show up here as it works.'));++ return;++ }++ // Today's entries are open; earlier days are counted folds.++ const today = new Date().toISOString().slice(0, 10);++ const groups = new Map();++ for (const entry of entries) {++ const key = dayKey(entry.started_at);++ if (!groups.has(key)) groups.set(key, []);++ groups.get(key).push(entry);++ }++ for (const [key, group] of groups) {++ if (key === today) {++ for (const entry of group) list.appendChild(entryNode(entry));++ continue;++ }++ const fold = el(doc, 'button', 'ovdt-fold', labelOf(key, group.length));++ fold.type = 'button';++ fold.setAttribute('aria-expanded', 'false');++ fold.addEventListener('click', () => {++ const open = fold.getAttribute('aria-expanded') === 'true';++ fold.setAttribute('aria-expanded', open ? 'false' : 'true');++ const shown = fold.nextElementSibling;++ if (shown && shown.classList.contains('ovdt-fold-body')) shown.remove();++ if (!open) {++ const box = el(doc, 'div', 'ovdt-fold-body');++ for (const entry of group) box.appendChild(entryNode(entry));++ fold.after(box);++ }++ });++ list.appendChild(fold);++ }++ // A long run of one mode folds into "12 more Code steps".++ let run = 0;++ let runMode = null;++ const runs = [];++ for (const entry of entries) {++ if (entry.mode === runMode) { run += 1; continue; }++ if (run >= 3) runs.push({ mode: runMode, count: run });++ runMode = entry.mode;++ run = 1;++ }++ if (run >= 3) runs.push({ mode: runMode, count: run });++ for (const r of runs) {++ const more = el(doc, 'div', 'ovdt-meta', r.count + ' more ' + word(r.mode) + ' steps');++ list.appendChild(more);++ }++ }++++ function labelOf(day, count) {++ const today = new Date();++ const d = new Date(day + 'T00:00:00Z');++ const diff = Math.round((Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) - d.getTime()) / 86400000);++ if (diff === 1) return 'Yesterday (' + count + ')';++ if (diff < 7) return 'This week (' + count + ')';++ return day + ' (' + count + ')';++ }++++ function renderChips(counts) {++ chips.textContent = '';++ for (const m of MODES) {++ const n = counts && counts[m.id];++ if (!n) continue;++ chips.appendChild(chip(m.id, m.word, n));++ }++ if (counts && counts.needs_you) chips.appendChild(chip('needs_you', 'Needs you', counts.needs_you));++ }++++ async function refresh() {++ if (destroyed || !active()) return;++ const mode = [...filters].find((f) => f !== 'needs_you');++ const query = [];++ if (mode) query.push('mode=' + encodeURIComponent(mode));++ if (filters.has('needs_you')) query.push('needs_you=1');++ const path = '/api/v1/agent/timeline' + (query.length ? '?' + query.join('&') : '');++ let data;++ try { data = await api(path); } catch (err) {++ if (destroyed) return;++ nowBox.textContent = '';++ nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'The agent is not reachable right now.'));++ return;++ }++ if (destroyed) return;++ renderNow(data.now);++ renderNeeds(data.needs_you || []);++ renderChips(data.counts || {});++ renderList(data.entries || []);++ writeUrl();++ }++++ function writeUrl() {++ try {++ const url = new URL(doc.defaultView.location.href);++ url.searchParams.delete('mode');++ url.searchParams.delete('needs_you');++ for (const f of filters) {++ if (f === 'needs_you') url.searchParams.set('needs_you', '1');++ else url.searchParams.set('mode', f);++ }++ doc.defaultView.history.replaceState(null, '', url.toString());++ } catch (err) { /* the panel keeps its filters in memory */ }++ }++++ function start() {++ if (timer) return;++ timer = setInterval(refresh, POLL_MS);++ if (timer.unref) timer.unref();++ }++++ refresh();++ start();++++ return {++ refresh,++ destroy() {++ destroyed = true;++ if (timer) clearInterval(timer);++ timer = null;++ wrap.remove();++ },++ };++ }++++ root.OpenVibeDockTimeline = { mount };++}(typeof window !== 'undefined' ? window : globalThis));@@@ -65,6 +65,8 @@const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);// K-D3: the agent stream comes from OpenVibe.Codes' API (it mints the view ticket with the person's own token).const agentBase = () => String(o.agentBase || 'https://openvibe.codes').replace(/\/+$/, '');++ // The one timeline behind the Dock (design 2026-11-07): OpenVibe.Actor keeps it.++ const actorBase = () => String(o.actorBase || 'https://openvibe.actor').replace(/\/+$/, '');async function call(path, init = {}) {const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');@@@ -195,7 +197,13 @@.ovdk[data-corner="top-left"] .ovdk-size::after { top: auto; bottom: 9px; left: auto; right: 9px; border: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-right: 2px solid var(--text-muted, #8b8b96); border-radius: 0 0 3px 0; }@media (pointer: coarse) { .ovdk-acts button { width: 44px; height: 44px; } .ovdk-tabs button { height: 40px; } }.ovdk { container-type: inline-size; }-- @container (max-width: 440px) { .ovdk-tabs button { padding: 0 8px; gap: 6px; font-size: 13px; } .ovdk-tabs button svg { display: none; } .ovdk-acts { gap: 0; } .ovdk-acts button { width: 34px; } }++ @container (max-width: 440px) { .ovdk-tabs button { padding: 0 8px; gap: 6px; font-size: 13px; } .ovdk-acts { gap: 0; } .ovdk-acts button { width: 34px; } }++ /* A narrow Dock (Opus, 11-07: "Activity" was cut off at the strip's edge): the open tab keeps its icon and name,++ the others show their icon and count only, their names kept for screen readers. All four always fit. */++ @container (max-width: 480px) {++ .ovdk-tabs button:not([aria-selected="true"]) { padding: 0 10px; gap: 4px; }++ .ovdk-tabs button:not([aria-selected="true"]) span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++ }.ovdk-muted { color: var(--text-muted, #8b8b96); }.ovdk-i { display: inline-grid; place-items: center; }.ovdk-i svg { width: 18px; height: 18px; }@@@ -244,6 +252,10 @@.ovdk-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))); }.ovdk-size { display: none; }.ovdk-tabs { flex: 1; } .ovdk-tabs button { flex: 1; justify-content: center; }++ /* A thumb, not a cursor: every control on the sheet is 44 px (design 2026-11-07 §1, Phone). */++ .ovdk-tabs button { height: 44px; }++ .ovdk-acts button { width: 44px; height: 44px; }++ .ovdk-foot a, .ovdk-foot button { min-height: 44px; }}html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }@media (prefers-reduced-motion: reduce) { .ovdk.is-in, .ovdk-wait span { animation: none; } }@@@ -449,7 +461,12 @@const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));if (!ok) return failed(panel, id);// K-D3: the person's own agent stream above the list; it hides itself when there is none.-- panel.innerHTML = `<div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;++ panel.innerHTML = `<div class="ovdk-timeline"></div><div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;++ // The Agent panel: Now, Needs you and the one timeline (design 2026-11-07). It is first in the tab, so on a++ // phone Now and Needs you are on screen before anything else.++ load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {++ if (on && !mounts[id + 'Timeline'] && panel.isConnected) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: actorBase(), active: () => isOpen() && tab === 'agent' });++ });load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.firstChild, { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });});@@@ -25,6 +25,7 @@ const BROWSER = Object.freeze(['dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens++ 'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel'account-switcher.js','site-switcher.js', // the OpenVibe mark's site switcher (4.48.0); navbar.js loads it on the first tap. Missing from this list until 4.106.1, so every site answered 404 for it and the mark did nothing'user-card.js',@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.126.0",++ "version": "4.127.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -50,6 +50,7 @@"./dock-vibe": "./dock-vibe.js","./dock-timer": "./dock-timer.js","./dock-agent": "./dock-agent.js",++ "./dock-timeline": "./dock-timeline.js","./notification-ui": "./notification-ui.js","./notifications": "./notifications.js","./openvibe-sw": "./openvibe-sw.js",nnew file mode 100644@@@ -0,0 +1,53 @@++'use strict';++/**++ * The Dock's Agent panel (design 2026-11-07): Now, Needs you and the one timeline, with the folds and the filters.++ * The panel is set as text, never as markup, and it reads only the person's own timeline.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const panel = fs.readFileSync(path.join(__dirname, '..', 'dock-timeline.js'), 'utf8');++const dock = fs.readFileSync(path.join(__dirname, '..', 'dock.js'), 'utf8');++++// The panel is what the person reads.++assert.ok(panel.includes('OpenVibeDockTimeline'), 'the panel is named');++assert.ok(/root\.OpenVibeDockTimeline = \{ mount \}/.test(panel), 'it mounts');++assert.ok(panel.includes("'Now'"), 'Now is first');++assert.ok(panel.includes("'Needs you'"), 'Needs you is under it');++assert.ok(panel.indexOf("el(doc, 'h3', null, 'Now')") < panel.indexOf("el(doc, 'h3', null, 'Needs you')"), 'Now comes before Needs you');++assert.ok(panel.includes('Your agent is idle'), 'nothing running says so');++++// The three parts of an entry.++for (const part of ['ovdt-mode', 'ovdt-title', 'ovdt-outcome', 'ovdt-meta']) {++ assert.ok(panel.includes(part), 'the entry shows ' + part);++}++assert.ok(panel.includes('placement'), 'the entry says where it ran and why');++assert.ok(panel.includes('transition'), 'a mode change is a line of its own');++assert.ok(panel.includes('cost_usd'), 'the cost is shown when there was one');++assert.ok(panel.includes('entry.link'), 'the entry links to the step\'s own page');++++// The folds and the filters.++assert.ok(panel.includes('Yesterday ('), 'earlier days fold with a count');++assert.ok(panel.includes('This week ('), 'and so does the week');++assert.ok(panel.includes("' more '"), 'a long run of one mode folds into "N more Code steps"');++assert.ok(panel.includes('aria-pressed'), 'a filter chip says whether it is on');++assert.ok(panel.includes('url.searchParams.set'), 'the filters are in the URL of the panel');++assert.ok(panel.includes('counts'), 'a filter never hides without a count');++++// The person's own timeline, and only that.++assert.ok(panel.includes('/api/v1/agent/timeline'), 'the panel reads the timeline');++assert.ok(panel.includes('/api/v1/agent/approvals/'), 'an approval is answered in the mode that owns it');++assert.ok(panel.includes('/api/v1/agent/kill-switch'), 'the kill switch is one button on Now');++assert.ok(!panel.includes('innerHTML'), 'the panel never writes markup');++assert.ok(!/entry\.prompt|entry\.diff|\.prompt\b|\.diff\b/.test(panel), 'the panel reads no prompt and no diff');++++// Phone: every control is 44 px, and the panel is the first thing in the Agents tab.++assert.ok(/min-height:44px/.test(panel), 'every control is 44 px');++assert.ok(dock.includes("load('dock-timeline.js'"), 'the Dock loads the panel');++assert.ok(dock.includes("panel.querySelector('.ovdk-timeline')"), 'the panel has its own place in the tab');++assert.ok(dock.indexOf('ovdk-timeline') < dock.indexOf('ovdk-agent'), 'the panel comes before the desk view');++assert.ok(dock.includes('https://openvibe.actor'), 'the panel reads OpenVibe.Actor');++++console.log('dock timeline: now, needs you, the timeline, the folds and the filters');++console.log('all passed');
@@ -1,5 +1,13 @@
@@ -1,5 +1,13 @@
# Changelog
# Changelog
+## 4.127.0 — 2026-11-07
+
+- **The Dock's Agent panel** (design 2026-11-07, one agent behind the Dock): Now says what the agent is doing this moment (the mode, the step in plain words, how long, and Stop/Resume), Needs you lists every approval and question from any mode with its buttons in place, and the timeline below shows one entry per step — the mode, the title, where it ran and why, the outcome, the time, the cost and a link to the step's own page. A mode change is a line of its own.
+- **Folding and filters:** today's entries are open, earlier days are counted folds ("Yesterday (14)"), and a long run of one mode folds into "12 more Code steps". A chip for each mode and for Needs you sits in the URL of the panel, and a filter never hides without a count.
+- **Only me by default:** the panel shows the person's own entries only; a step's title, outcome and link, never a prompt or a diff.
+- **It wears the site's theme:** the panel uses the site's own tokens and font (the navy of a dark site, the paper of a light one), with a 12 px gutter. Approve is the one filled button on a card and Stop is red. A running step's pill breathes; nothing else moves. Needs-you cards are tinted and labelled with their mode.
+- **The Dock's tabs always fit:** in a narrow Dock the open tab shows its icon and name and the others their icon and count, their names kept for screen readers. "Activity" was cut off at the strip's edge.
+
## 4.126.0 — 2026-11-07
## 4.126.0 — 2026-11-07
- **Audio description in the player (X1)**: a video with a `<track kind="descriptions">` gets an **AD** button beside
- **Audio description in the player (X1)**: a video with a `<track kind="descriptions">` gets an **AD** button beside
@@ -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.126.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.127.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.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.126.0",
+ "version": "4.127.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,362 @@
@@ -0,0 +1,362 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — the Dock's Agent panel (design 2026-11-07: one agent, one timeline behind the Dock), Shared 4.126.0
+//
+// OpenVibeDockTimeline.mount(container, { token, base, active }) → { refresh(), destroy() }
+//
+// `base` is OpenVibe.Actor's API: GET /api/v1/agent/timeline (the person's own steps, newest first, with the cursor
+// and the mode and needs-you filters), POST /api/v1/agent/approvals/:product/:id/decide (an approval is answered in
+// the mode that owns it) and POST /api/v1/agent/kill-switch (stop what is open and pause the agent).
+//
+// The panel is what the person reads:
+// Now one card for what the agent is doing this moment: the mode's word, the step in plain words, how long,
+// and the mode's own controls (Stop, and Resume when it is paused). Nothing running: "Your agent is idle."
+// Needs you every approval or question from any mode, oldest first, with its buttons in place.
+// Timeline one entry per step, newest first: the mode, the title, where it ran and why, the outcome, the time
+// and the cost, and a link to the step's own page. A mode change is a line of its own.
+// Folding today's entries open; earlier days are counted folds ("Yesterday (14)"); a long run of one mode folds
+// into "12 more Code steps".
+// Filters a chip for each mode and for "Needs you", in the URL of the panel; a filter never hides without a
+// count.
+//
+// Everything is set as text, never as markup: a title, a reason or a link label never becomes HTML.
+// ═══════════════════════════════════════════════════════════════
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeDockTimeline) return;
+
+ const POLL_MS = 15000;
+ const MODES = [
+ { id: 'talk', word: 'Talk' }, { id: 'act', word: 'Act' }, { id: 'run', word: 'Run' },
+ { id: 'code', word: 'Code' }, { id: 'browse', word: 'Browse' }, { id: 'watch', word: 'Watch' },
+ { id: 'stream', word: 'Stream' },
+ ];
+ const word = (mode) => (MODES.find((m) => m.id === mode) || { word: 'Agent' }).word;
+ const OUTCOME = { done: 'Done', failed: 'Failed', stopped: 'Stopped by you', running: 'Running', waiting: 'Waiting', needs_you: 'Needs you' };
+
+ function el(doc, tag, cls, text) {
+ const n = doc.createElement(tag);
+ if (cls) n.className = cls;
+ if (text !== undefined && text !== null) n.textContent = String(text);
+ return n;
+ }
+
+ function since(startedAt) {
+ const t = Date.parse(String(startedAt || ''));
+ if (!Number.isFinite(t)) return '';
+ const s = Math.max(0, Math.round((Date.now() - t) / 1000));
+ if (s < 60) return s + 's';
+ if (s < 3600) return Math.round(s / 60) + ' min';
+ return Math.round(s / 3600) + ' h';
+ }
+
+ function dayKey(iso) {
+ const d = new Date(String(iso || ''));
+ if (Number.isNaN(d.getTime())) return '';
+ return d.toISOString().slice(0, 10);
+ }
+
+ function styles(doc) {
+ if (!doc || doc.getElementById('ovdt-styles')) return;
+ const st = doc.createElement('style');
+ st.id = 'ovdt-styles';
+ st.textContent = [
+ // 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
+ // of a light one, never a grey card of its own. The site's font, a gutter that clears the panel's edge, one
+ // primary button per card, and a breathing dot only on what is really running.
+ '.ovdt{font:inherit;font-size:14px;line-height:1.45;color:var(--text-primary,#ececf1);padding:4px 12px 20px}',
+ '.ovdt h3{margin:14px 2px 8px;font-size:11px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;color:var(--text-muted,#93a0b4)}',
+ '.ovdt h3:first-child{margin-top:6px}',
+ '.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)}',
+ '.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)}',
+ '.ovdt-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}',
+ '.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))}',
+ '.ovdt-mode{font-weight:750}',
+ '.ovdt-title{flex:1 1 100%;min-width:0;overflow-wrap:anywhere;font-size:14.5px;font-weight:550}',
+ '.ovdt-meta{color:var(--text-muted,#93a0b4);font-size:12.5px;overflow-wrap:anywhere}',
+ '.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)}',
+ '.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)}',
+ '.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}',
+ '.ovdt-outcome[data-s="failed"]{color:var(--danger-light,#f87171);background:color-mix(in srgb,var(--danger,#ef4444) 14%,transparent)}',
+ '@keyframes ovdt-breathe{50%{opacity:.35;transform:scale(.8)}}',
+ '.ovdt-buttons{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}',
+ '.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}',
+ '.ovdt-btn.is-primary{border-color:transparent;background:var(--accent,#3b82f6);color:var(--on-accent,#fff)}',
+ '.ovdt-btn.is-stop{border-color:color-mix(in srgb,var(--danger,#ef4444) 45%,transparent);color:var(--danger-light,#f87171)}',
+ '.ovdt-btn:disabled{opacity:.55;cursor:default}',
+ '.ovdt-btn:focus-visible,.ovdt-chip:focus-visible,.ovdt-fold:focus-visible{outline:2px solid var(--accent-light,var(--accent,#3b82f6));outline-offset:1px}',
+ '@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)}}',
+ '.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}',
+ '.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)}',
+ '.ovdt-chips{display:flex;gap:8px;overflow-x:auto;margin:4px -12px 0;padding:6px 12px 10px;scrollbar-width:none;-webkit-overflow-scrolling:touch}',
+ '.ovdt-chips::-webkit-scrollbar{display:none}',
+ '.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}',
+ '.ovdt-empty{color:var(--text-muted,#93a0b4);padding:4px 2px 6px}',
+ '.ovdt-card.ovdt-empty{padding:12px 14px}',
+ '.ovdt-link{color:color-mix(in srgb,var(--accent,#3b82f6) 72%,var(--text-primary,#ececf1));font-weight:650;text-decoration:none}',
+ '@media (max-width:640px){.ovdt-link{display:inline-flex;align-items:center;min-height:44px;padding:0 4px}}',
+ '@media (prefers-reduced-motion:reduce){.ovdt-outcome[data-s="running"]::before{animation:none}}',
+ ].join('');
+ (doc.head || doc.documentElement).appendChild(st);
+ }
+
+ function mount(container, opts = {}) {
+ const doc = container.ownerDocument || document;
+ styles(doc);
+ const base = String(opts.base || 'https://openvibe.actor').replace(/\/+$/, '');
+ const token = opts.token;
+ const active = typeof opts.active === 'function' ? opts.active : () => true;
+ const filters = new Set();
+ let timer = null;
+ let destroyed = false;
+
+ const wrap = el(doc, 'div', 'ovdt');
+ const nowHead = el(doc, 'h3', null, 'Now');
+ const nowBox = el(doc, 'div', 'ovdt-now');
+ const needsHead = el(doc, 'h3', null, 'Needs you');
+ const needsBox = el(doc, 'div', 'ovdt-needs');
+ const chips = el(doc, 'div', 'ovdt-chips');
+ const list = el(doc, 'div', 'ovdt-list');
+ wrap.append(nowHead, nowBox, needsHead, needsBox, chips, list);
+ container.appendChild(wrap);
+
+ async function api(path, options = {}) {
+ const res = await fetch(base + path, Object.assign({ headers: { Accept: 'application/json', Authorization: 'Bearer ' + token } }, options));
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error((body && (body.detail || body.title)) || ('HTTP ' + res.status));
+ return body;
+ }
+
+ function chip(id, label, count) {
+ const b = el(doc, 'button', 'ovdt-chip', label + (count === undefined || count === null ? '' : ' (' + count + ')'));
+ b.type = 'button';
+ b.setAttribute('aria-pressed', filters.has(id) ? 'true' : 'false');
+ b.addEventListener('click', () => {
+ if (filters.has(id)) filters.delete(id); else filters.add(id);
+ refresh();
+ });
+ return b;
+ }
+
+ /** The step's state as a small pill; data-s lets a running step breathe and a failed one show red. */
+ function outcome(status) {
+ const o = el(doc, 'span', 'ovdt-outcome', OUTCOME[status] || status);
+ o.setAttribute('data-s', String(status || '').toLowerCase());
+ return o;
+ }
+
+ function renderNow(now) {
+ nowBox.textContent = '';
+ if (!now) {
+ nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'Your agent is idle. Ask it something in Talk.'));
+ return;
+ }
+ const card = el(doc, 'div', 'ovdt-card');
+ const row = el(doc, 'div', 'ovdt-row');
+ row.appendChild(el(doc, 'span', 'ovdt-mode', word(now.mode)));
+ row.appendChild(outcome(now.status));
+ row.appendChild(el(doc, 'span', 'ovdt-title', now.title));
+ row.appendChild(el(doc, 'span', 'ovdt-meta', since(now.started_at)));
+ card.appendChild(row);
+ if (now.placement && (now.placement.where || now.placement.why)) {
+ card.appendChild(el(doc, 'div', 'ovdt-meta', [now.placement.where, now.placement.why].filter(Boolean).join(': ')));
+ }
+ const buttons = el(doc, 'div', 'ovdt-buttons');
+ const stop = el(doc, 'button', 'ovdt-btn is-stop', 'Stop');
+ stop.type = 'button';
+ stop.addEventListener('click', async () => {
+ stop.disabled = true;
+ try { await api('/api/v1/agent/kill-switch', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token }, body: JSON.stringify({ action: 'stop' }) }); } catch (err) { /* the panel refreshes either way */ }
+ refresh();
+ });
+ buttons.appendChild(stop);
+ card.appendChild(buttons);
+ nowBox.appendChild(card);
+ }
+
+ function renderNeeds(needs) {
+ needsBox.textContent = '';
+ if (!needs.length) {
+ needsBox.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing needs you.'));
+ return;
+ }
+ for (const item of needs) {
+ const card = el(doc, 'div', 'ovdt-card');
+ card.appendChild(el(doc, 'div', 'ovdt-mode', word(item.mode) + ' needs you'));
+ card.appendChild(el(doc, 'div', 'ovdt-title', item.title));
+ if (item.reason) card.appendChild(el(doc, 'div', 'ovdt-meta', item.reason));
+ const buttons = el(doc, 'div', 'ovdt-buttons');
+ for (const [label, decision] of [['Approve', 'approve'], ['Decline', 'decline']]) {
+ const b = el(doc, 'button', decision === 'approve' ? 'ovdt-btn is-primary' : 'ovdt-btn', label);
+ b.type = 'button';
+ b.addEventListener('click', async () => {
+ b.disabled = true;
+ const approval = item.approval || {};
+ try {
+ await api('/api/v1/agent/approvals/' + encodeURIComponent(approval.product || item.product) + '/' + encodeURIComponent(approval.id) + '/decide', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + token },
+ body: JSON.stringify({ decision }),
+ });
+ } catch (err) { /* the panel refreshes either way */ }
+ refresh();
+ });
+ buttons.appendChild(b);
+ }
+ card.appendChild(buttons);
+ needsBox.appendChild(card);
+ }
+ }
+
+ function entryNode(entry) {
+ const card = el(doc, 'div', 'ovdt-card');
+ const row = el(doc, 'div', 'ovdt-row');
+ row.appendChild(el(doc, 'span', 'ovdt-mode', word(entry.mode)));
+ row.appendChild(outcome(entry.status));
+ row.appendChild(el(doc, 'span', 'ovdt-title', entry.title));
+ const when = new Date(entry.started_at || 0);
+ row.appendChild(el(doc, 'span', 'ovdt-meta', Number.isNaN(when.getTime()) ? '' : when.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })));
+ card.appendChild(row);
+ if (entry.transition) card.appendChild(el(doc, 'div', 'ovdt-meta', entry.transition));
+ else if (entry.placement && (entry.placement.where || entry.placement.why)) {
+ card.appendChild(el(doc, 'div', 'ovdt-meta', [entry.placement.where, entry.placement.why].filter(Boolean).join(': ')));
+ }
+ if (entry.reason) card.appendChild(el(doc, 'div', 'ovdt-meta', entry.reason));
+ if (entry.cost_usd !== null && entry.cost_usd !== undefined) card.appendChild(el(doc, 'div', 'ovdt-meta', '$' + Number(entry.cost_usd).toFixed(2)));
+ if (entry.link) {
+ const a = el(doc, 'a', 'ovdt-link', 'Open');
+ a.href = entry.link;
+ a.rel = 'noopener noreferrer';
+ a.target = '_blank';
+ card.appendChild(a);
+ }
+ return card;
+ }
+
+ function renderList(entries) {
+ list.textContent = '';
+ if (!entries.length) {
+ list.appendChild(el(doc, 'div', 'ovdt-empty', 'Nothing yet. The agent\u2019s steps show up here as it works.'));
+ return;
+ }
+ // Today's entries are open; earlier days are counted folds.
+ const today = new Date().toISOString().slice(0, 10);
+ const groups = new Map();
+ for (const entry of entries) {
+ const key = dayKey(entry.started_at);
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(entry);
+ }
+ for (const [key, group] of groups) {
+ if (key === today) {
+ for (const entry of group) list.appendChild(entryNode(entry));
+ continue;
+ }
+ const fold = el(doc, 'button', 'ovdt-fold', labelOf(key, group.length));
+ fold.type = 'button';
+ fold.setAttribute('aria-expanded', 'false');
+ fold.addEventListener('click', () => {
+ const open = fold.getAttribute('aria-expanded') === 'true';
+ fold.setAttribute('aria-expanded', open ? 'false' : 'true');
+ const shown = fold.nextElementSibling;
+ if (shown && shown.classList.contains('ovdt-fold-body')) shown.remove();
+ if (!open) {
+ const box = el(doc, 'div', 'ovdt-fold-body');
+ for (const entry of group) box.appendChild(entryNode(entry));
+ fold.after(box);
+ }
+ });
+ list.appendChild(fold);
+ }
+ // A long run of one mode folds into "12 more Code steps".
+ let run = 0;
+ let runMode = null;
+ const runs = [];
+ for (const entry of entries) {
+ if (entry.mode === runMode) { run += 1; continue; }
+ if (run >= 3) runs.push({ mode: runMode, count: run });
+ runMode = entry.mode;
+ run = 1;
+ }
+ if (run >= 3) runs.push({ mode: runMode, count: run });
+ for (const r of runs) {
+ const more = el(doc, 'div', 'ovdt-meta', r.count + ' more ' + word(r.mode) + ' steps');
+ list.appendChild(more);
+ }
+ }
+
+ function labelOf(day, count) {
+ const today = new Date();
+ const d = new Date(day + 'T00:00:00Z');
+ const diff = Math.round((Date.UTC(today.getFullYear(), today.getMonth(), today.getDate()) - d.getTime()) / 86400000);
+ if (diff === 1) return 'Yesterday (' + count + ')';
+ if (diff < 7) return 'This week (' + count + ')';
+ return day + ' (' + count + ')';
+ }
+
+ function renderChips(counts) {
+ chips.textContent = '';
+ for (const m of MODES) {
+ const n = counts && counts[m.id];
+ if (!n) continue;
+ chips.appendChild(chip(m.id, m.word, n));
+ }
+ if (counts && counts.needs_you) chips.appendChild(chip('needs_you', 'Needs you', counts.needs_you));
+ }
+
+ async function refresh() {
+ if (destroyed || !active()) return;
+ const mode = [...filters].find((f) => f !== 'needs_you');
+ const query = [];
+ if (mode) query.push('mode=' + encodeURIComponent(mode));
+ if (filters.has('needs_you')) query.push('needs_you=1');
+ const path = '/api/v1/agent/timeline' + (query.length ? '?' + query.join('&') : '');
+ let data;
+ try { data = await api(path); } catch (err) {
+ if (destroyed) return;
+ nowBox.textContent = '';
+ nowBox.appendChild(el(doc, 'div', 'ovdt-card ovdt-empty', 'The agent is not reachable right now.'));
+ return;
+ }
+ if (destroyed) return;
+ renderNow(data.now);
+ renderNeeds(data.needs_you || []);
+ renderChips(data.counts || {});
+ renderList(data.entries || []);
+ writeUrl();
+ }
+
+ function writeUrl() {
+ try {
+ const url = new URL(doc.defaultView.location.href);
+ url.searchParams.delete('mode');
+ url.searchParams.delete('needs_you');
+ for (const f of filters) {
+ if (f === 'needs_you') url.searchParams.set('needs_you', '1');
+ else url.searchParams.set('mode', f);
+ }
+ doc.defaultView.history.replaceState(null, '', url.toString());
+ } catch (err) { /* the panel keeps its filters in memory */ }
+ }
+
+ function start() {
+ if (timer) return;
+ timer = setInterval(refresh, POLL_MS);
+ if (timer.unref) timer.unref();
+ }
+
+ refresh();
+ start();
+
+ return {
+ refresh,
+ destroy() {
+ destroyed = true;
+ if (timer) clearInterval(timer);
+ timer = null;
+ wrap.remove();
+ },
+ };
+ }
+
+ root.OpenVibeDockTimeline = { mount };
+}(typeof window !== 'undefined' ? window : globalThis));
@@ -65,6 +65,8 @@
@@ -65,6 +65,8 @@
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
// K-D3: the agent stream comes from OpenVibe.Codes' API (it mints the view ticket with the person's own token).
// K-D3: the agent stream comes from OpenVibe.Codes' API (it mints the view ticket with the person's own token).
const agentBase = () => String(o.agentBase || 'https://openvibe.codes').replace(/\/+$/, '');
const agentBase = () => String(o.agentBase || 'https://openvibe.codes').replace(/\/+$/, '');
+ // The one timeline behind the Dock (design 2026-11-07): OpenVibe.Actor keeps it.
+ const actorBase = () => String(o.actorBase || 'https://openvibe.actor').replace(/\/+$/, '');
async function call(path, init = {}) {
async function call(path, init = {}) {
const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');
const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');
@@ -195,7 +197,13 @@
@@ -195,7 +197,13 @@
.ovdk[data-corner="top-left"] .ovdk-size::after { top: auto; bottom: 9px; left: auto; right: 9px; border: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-right: 2px solid var(--text-muted, #8b8b96); border-radius: 0 0 3px 0; }
.ovdk[data-corner="top-left"] .ovdk-size::after { top: auto; bottom: 9px; left: auto; right: 9px; border: 0; border-bottom: 2px solid var(--text-muted, #8b8b96); border-right: 2px solid var(--text-muted, #8b8b96); border-radius: 0 0 3px 0; }
@media (pointer: coarse) { .ovdk-acts button { width: 44px; height: 44px; } .ovdk-tabs button { height: 40px; } }
@media (pointer: coarse) { .ovdk-acts button { width: 44px; height: 44px; } .ovdk-tabs button { height: 40px; } }
.ovdk { container-type: inline-size; }
.ovdk { container-type: inline-size; }
- @container (max-width: 440px) { .ovdk-tabs button { padding: 0 8px; gap: 6px; font-size: 13px; } .ovdk-tabs button svg { display: none; } .ovdk-acts { gap: 0; } .ovdk-acts button { width: 34px; } }
+ @container (max-width: 440px) { .ovdk-tabs button { padding: 0 8px; gap: 6px; font-size: 13px; } .ovdk-acts { gap: 0; } .ovdk-acts button { width: 34px; } }
+ /* A narrow Dock (Opus, 11-07: "Activity" was cut off at the strip's edge): the open tab keeps its icon and name,
+ the others show their icon and count only, their names kept for screen readers. All four always fit. */
+ @container (max-width: 480px) {
+ .ovdk-tabs button:not([aria-selected="true"]) { padding: 0 10px; gap: 4px; }
+ .ovdk-tabs button:not([aria-selected="true"]) span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+ }
.ovdk-muted { color: var(--text-muted, #8b8b96); }
.ovdk-muted { color: var(--text-muted, #8b8b96); }
.ovdk-i { display: inline-grid; place-items: center; }
.ovdk-i { display: inline-grid; place-items: center; }
.ovdk-i svg { width: 18px; height: 18px; }
.ovdk-i svg { width: 18px; height: 18px; }
@@ -244,6 +252,10 @@
@@ -244,6 +252,10 @@
.ovdk-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))); }
.ovdk-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))); }
.ovdk-size { display: none; }
.ovdk-size { display: none; }
.ovdk-tabs { flex: 1; } .ovdk-tabs button { flex: 1; justify-content: center; }
.ovdk-tabs { flex: 1; } .ovdk-tabs button { flex: 1; justify-content: center; }
+ /* A thumb, not a cursor: every control on the sheet is 44 px (design 2026-11-07 §1, Phone). */
+ .ovdk-tabs button { height: 44px; }
+ .ovdk-acts button { width: 44px; height: 44px; }
+ .ovdk-foot a, .ovdk-foot button { min-height: 44px; }
}
}
html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }
html.ovdk-sheet-open, html.ovdk-sheet-open body { overflow: hidden; }
@media (prefers-reduced-motion: reduce) { .ovdk.is-in, .ovdk-wait span { animation: none; } }
@media (prefers-reduced-motion: reduce) { .ovdk.is-in, .ovdk-wait span { animation: none; } }
@@ -449,7 +461,12 @@
@@ -449,7 +461,12 @@
const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));
const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));
if (!ok) return failed(panel, id);
if (!ok) return failed(panel, id);
// K-D3: the person's own agent stream above the list; it hides itself when there is none.
// K-D3: the person's own agent stream above the list; it hides itself when there is none.
- panel.innerHTML = `<div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
+ panel.innerHTML = `<div class="ovdk-timeline"></div><div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-foot"><button type="button" data-act="all">${svg(G.open)}Scheduled, done and failed</button><a href="https://openvibe.actor/">Give an agent a task →</a></div>`;
+ // The Agent panel: Now, Needs you and the one timeline (design 2026-11-07). It is first in the tab, so on a
+ // phone Now and Needs you are on screen before anything else.
+ load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
+ if (on && !mounts[id + 'Timeline'] && panel.isConnected) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: actorBase(), active: () => isOpen() && tab === 'agent' });
+ });
load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {
load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {
if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.firstChild, { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });
if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.firstChild, { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });
});
});
@@ -25,6 +25,7 @@ const BROWSER = Object.freeze([
@@ -25,6 +25,7 @@ const BROWSER = Object.freeze([
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-vibe.js', // the Dock's Vibe tab (L48 V2); dock.js loads it the first time the tab opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-timer.js', // the Dock's timer (L48 R2); dock.js loads it while a timer runs or its menu opens
'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens
'dock-agent.js', // the Dock's agent view (K-D3); dock.js loads it the first time the Agents tab opens
+ 'dock-timeline.js', // the Dock's one timeline (A6); dock.js loads it with the Agent panel
'account-switcher.js',
'account-switcher.js',
'site-switcher.js', // the OpenVibe mark's site switcher (4.48.0); navbar.js loads it on the first tap. Missing from this list until 4.106.1, so every site answered 404 for it and the mark did nothing
'site-switcher.js', // the OpenVibe mark's site switcher (4.48.0); navbar.js loads it on the first tap. Missing from this list until 4.106.1, so every site answered 404 for it and the mark did nothing
'user-card.js',
'user-card.js',
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.126.0",
+ "version": "4.127.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": {
@@ -50,6 +50,7 @@
@@ -50,6 +50,7 @@
"./dock-vibe": "./dock-vibe.js",
"./dock-vibe": "./dock-vibe.js",
"./dock-timer": "./dock-timer.js",
"./dock-timer": "./dock-timer.js",
"./dock-agent": "./dock-agent.js",
"./dock-agent": "./dock-agent.js",
+ "./dock-timeline": "./dock-timeline.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",
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+'use strict';
+/**
+ * The Dock's Agent panel (design 2026-11-07): Now, Needs you and the one timeline, with the folds and the filters.
+ * The panel is set as text, never as markup, and it reads only the person's own timeline.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const panel = fs.readFileSync(path.join(__dirname, '..', 'dock-timeline.js'), 'utf8');
+const dock = fs.readFileSync(path.join(__dirname, '..', 'dock.js'), 'utf8');
+
+// The panel is what the person reads.
+assert.ok(panel.includes('OpenVibeDockTimeline'), 'the panel is named');
+assert.ok(/root\.OpenVibeDockTimeline = \{ mount \}/.test(panel), 'it mounts');
+assert.ok(panel.includes("'Now'"), 'Now is first');
+assert.ok(panel.includes("'Needs you'"), 'Needs you is under it');
+assert.ok(panel.indexOf("el(doc, 'h3', null, 'Now')") < panel.indexOf("el(doc, 'h3', null, 'Needs you')"), 'Now comes before Needs you');
+assert.ok(panel.includes('Your agent is idle'), 'nothing running says so');
+
+// The three parts of an entry.
+for (const part of ['ovdt-mode', 'ovdt-title', 'ovdt-outcome', 'ovdt-meta']) {
+ assert.ok(panel.includes(part), 'the entry shows ' + part);
+}
+assert.ok(panel.includes('placement'), 'the entry says where it ran and why');
+assert.ok(panel.includes('transition'), 'a mode change is a line of its own');
+assert.ok(panel.includes('cost_usd'), 'the cost is shown when there was one');
+assert.ok(panel.includes('entry.link'), 'the entry links to the step\'s own page');
+
+// The folds and the filters.
+assert.ok(panel.includes('Yesterday ('), 'earlier days fold with a count');
+assert.ok(panel.includes('This week ('), 'and so does the week');
+assert.ok(panel.includes("' more '"), 'a long run of one mode folds into "N more Code steps"');
+assert.ok(panel.includes('aria-pressed'), 'a filter chip says whether it is on');
+assert.ok(panel.includes('url.searchParams.set'), 'the filters are in the URL of the panel');
+assert.ok(panel.includes('counts'), 'a filter never hides without a count');
+
+// The person's own timeline, and only that.
+assert.ok(panel.includes('/api/v1/agent/timeline'), 'the panel reads the timeline');
+assert.ok(panel.includes('/api/v1/agent/approvals/'), 'an approval is answered in the mode that owns it');
+assert.ok(panel.includes('/api/v1/agent/kill-switch'), 'the kill switch is one button on Now');
+assert.ok(!panel.includes('innerHTML'), 'the panel never writes markup');
+assert.ok(!/entry\.prompt|entry\.diff|\.prompt\b|\.diff\b/.test(panel), 'the panel reads no prompt and no diff');
+
+// Phone: every control is 44 px, and the panel is the first thing in the Agents tab.
+assert.ok(/min-height:44px/.test(panel), 'every control is 44 px');
+assert.ok(dock.includes("load('dock-timeline.js'"), 'the Dock loads the panel');
+assert.ok(dock.includes("panel.querySelector('.ovdk-timeline')"), 'the panel has its own place in the tab');
+assert.ok(dock.indexOf('ovdk-timeline') < dock.indexOf('ovdk-agent'), 'the panel comes before the desk view');
+assert.ok(dock.includes('https://openvibe.actor'), 'the panel reads OpenVibe.Actor');
+
+console.log('dock timeline: now, needs you, the timeline, the folds and the filters');
+console.log('all passed');