openvibers/openvibe.shared Commits
4.120.0: the Dock's agent view — the person's desk stream, Curtain now and End (K-D3) (#305)
Changes
@@@ -1,5 +1,20 @@# Changelog++## 4.120.0 — 2026-11-06++++- **The Dock's agent view** (Dock K-D3; `dock-agent.js`, `dock.js`; Agent Streams S1). The Agents tab now opens on the++ person's own desk stream: Live's embed (`/embed/agent/<ags_id>?vt=wvt_…`, the `AI agent` label, the curtain state and++ the viewer count on the picture), one line for the task with the time it has worked, and the owner's **Curtain++ now** and **End**. Above the list, and it hides itself when there is no stream, so the tab is what it was.++ - The view ticket is minted server-side by OpenVibe.Codes with the person's own token (`GET++ /api/v1/me/agent-view`) — a third-party iframe has no Live cookie — and the embed renews its own ticket while it++ plays, so nothing is re-mounted on a poll.++ - Nothing live: the last frame, read with the token and shown as a local object URL (an `<img>` cannot carry a++ bearer), with the task and the worked time.++ - No step line yet: steps are Agent Streams S2, and the view shows none until Live serves them.++ - `dock-agent.js` is a sibling loaded the first time the tab opens (3.9 KB brotli); `dock.js` grows 222 bytes and its++ budget goes to 15.50 KB. `navbar.js` passes `dockAgentBase` (default `https://openvibe.codes`).++## 4.119.0 — 2026-11-06- A featured link keeps a place on the phone's bar: where the links row folds into the menu, the featured link (4.117.0) also shows as one tinted icon button beside Sign In or the avatar (38 px, 34 px under 400 px, a 44 px hit area on the element), named by its label for screen readers. The Node on the Network is one tap away on a phone. Only a featured link with an icon gets one; no animation.@@@ -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.119.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.120.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.119.0",++ "version": "4.120.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,240 @@++// ═══════════════════════════════════════════════════════════════++// OpenVibe — the Dock's agent view (Dock plan 2026-10-30, K-D3; Agent Streams S1), Shared 4.120.0++//++// The person's own agent desk stream at the top of the Dock's Agents tab. dock.js loads this file the first time that++// tab opens:++// OpenVibeDockAgent.mount(container, { token, base, active }) → { refresh(), destroy() }++//++// `base` is OpenVibe.Codes' API (GET /api/v1/me/agent-view, the same POST for Curtain now and End, and the frame).++// Codes mints the view ticket server-side with the person's own token — an iframe on another site has no Live cookie —++// so the frame it answers is already Live's /embed/agent/<ags_id>?vt=wvt_…: the picture, the "AI agent" label, the++// curtain state and the viewer count. The embed renews its own ticket while it plays.++//++// Nothing live: the last frame through the same API (an <img> cannot carry a token), with the task and the worked time.++// There is no step line yet: steps are Agent Streams S2, and this view shows none until Live serves them.++// Everything is set as text, never markup; the frame's address is the one Codes answered, as an https Live address.++// ═══════════════════════════════════════════════════════════════++(function (root) {++ 'use strict';++ if (typeof document === 'undefined' || root.OpenVibeDockAgent) return;++++ const POLL_MS = 20000;++ const EMBED = /^https:\/\/openvibe\.live\/embed\/agent\/ags_[0-9A-HJKMNP-TV-Z]{26}\?vt=wvt_[A-Za-z0-9_-]+$/;++ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;++ const G = {++ eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/><path d="M4 4l16 16"/>',++ stop: '<rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/>',++ 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"/>',++ };++++ function styles() {++ if (document.getElementById('ovda-styles')) return;++ const st = document.createElement('style');++ st.id = 'ovda-styles';++ st.textContent = `++ .ovda { display: none; flex: none; padding: 8px 8px 2px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }++ .ovda.on { display: block; }++ .ovda-stage { position: relative; overflow: hidden; aspect-ratio: 16 / 9; border-radius: 14px; background: #0a0c11; border: 1px solid var(--border, rgba(255,255,255,.1)); }++ .ovda-frame, .ovda-still { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; object-fit: contain; background: #0a0c11; }++ .ovda-still { object-fit: cover; opacity: .92; }++ .ovda-wait { position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-muted, #8b8b96); font-size: 12.5px; text-align: center; padding: 12px; }++ .ovda-line { display: flex; align-items: center; gap: 6px; min-height: 34px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }++ .ovda-line b { color: var(--text-primary, #ececf1); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .ovda-line b a { color: inherit; text-decoration: none; }++ .ovda-line b a:hover { text-decoration: underline; }++ .ovda-line .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); flex: none; }++ .ovda-line .dot.live { background: var(--live-red, #e5484d); animation: ovda-pulse 1.6s ease-in-out infinite; }++ .ovda-line .dot.off { background: var(--text-muted, #8b8b96); }++ .ovda-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11.5px; color: var(--text-muted, #8b8b96); }++ .ovda-acts { display: flex; gap: 6px; margin-left: auto; }++ .ovda-acts button, .ovda-acts a { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12));++ background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; text-decoration: none; }++ .ovda-acts button:hover, .ovda-acts a:hover { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }++ .ovda-acts button:disabled { opacity: .5; cursor: default; }++ .ovda-acts svg { width: 14px; height: 14px; }++ .ovda-acts button[data-act="end"] { color: color-mix(in srgb, var(--live-red, #e5484d) 65%, var(--text-primary, #ececf1)); }++ .ovda svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }++ @keyframes ovda-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }++ @media (prefers-reduced-motion: reduce) { .ovda-line .dot.live { animation: none; } }++ `;++ document.head.appendChild(st);++ }++++ const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };++ const mins = (s) => {++ const m = Math.max(0, Math.round((Number(s) || 0) / 60));++ return m < 1 ? 'under a minute' : m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;++ };++++ function mount(container, opts = {}) {++ styles();++ const o = { ...opts };++ let gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';++ container.innerHTML = '';++ container.classList.add('ovda');++ const stage = h('div', 'ovda-stage');++ const line = h('div', 'ovda-line');++ const meta = h('div', 'ovda-meta');++ const acts = h('div', 'ovda-acts');++ const head = h('div');++ head.append(line, meta, acts);++ const body = h('div');++ body.append(stage, head);++ container.append(body);++++ async function api(path, init = {}) {++ const base = String(o.base || 'https://openvibe.codes').replace(/\/+$/, '');++ const t = typeof o.token === 'function' ? await o.token() : o.token;++ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };++ const r = await fetch(base + path, { ...init, headers, credentials: 'omit', body: init.json ? '{}' : undefined });++ const b = await r.json().catch(() => ({}));++ if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });++ return b;++ }++++ /** The still is behind the person's token, so it is read here and shown as a local object URL. */++ function still(url) {++ if (!url || frameUrl === url) return;++ frameUrl = url;++ Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => fetch(url, { headers: t ? { authorization: `Bearer ${t}` } : {}, credentials: 'omit' }))++ .then((r) => (r.ok ? r.blob() : null))++ .then((blob) => {++ if (!blob || gone || frameUrl !== url) return;++ const next = URL.createObjectURL(blob);++ const img = h('img', 'ovda-still');++ img.alt = 'The last frame of your agent';++ img.src = next;++ img.decoding = 'async';++ const old = stage.querySelector('img.ovda-still');++ if (old) { stage.removeChild(old); }++ stage.append(img);++ if (lastFrame) URL.revokeObjectURL(lastFrame);++ lastFrame = next;++ })++ .catch(() => { /* no still: the line says enough */ });++ }++++ function paint() {++ if (gone) return;++ const has = !!agent;++ container.classList.toggle('on', has);++ if (!has) { stage.innerHTML = ''; line.innerHTML = ''; meta.textContent = ''; acts.innerHTML = ''; return; }++++ const live = agent.state === 'live';++ const ended = agent.state === 'ended';++ // The picture: the live embed when there is one, else the still.++ const embed = typeof agent.embed_url === 'string' && EMBED.test(agent.embed_url) ? agent.embed_url : null;++ const shown = stage.querySelector('iframe');++ if (embed && (!shown || shown.getAttribute('src') !== embed)) {++ stage.innerHTML = '';++ const f = h('iframe', 'ovda-frame');++ f.src = embed;++ f.title = 'Your agent\'s screen';++ f.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture');++ f.setAttribute('referrerpolicy', 'no-referrer');++ stage.append(f);++ } else if (!embed) {++ if (shown) stage.innerHTML = '';++ if (agent.last_frame_url) still(agent.last_frame_url);++ else if (!stage.querySelector('img')) stage.append(h('div', 'ovda-wait', ended ? 'Nothing to show' : 'Waiting for the desk'));++ }++++ // The line: what it is doing, one row.++ line.innerHTML = '';++ line.append(h('span', `dot ${live ? 'live' : 'off'}`));++ const title = h('b');++ const url = agent.task && /^https:\/\//.test(String(agent.task.url || '')) ? agent.task.url : (/^https:\/\//.test(String(agent.page_url || '')) ? agent.page_url : null);++ const text = (agent.task && agent.task.title) || agent.title || 'Your agent';++ if (url) { const a = h('a', null, text); a.href = url; a.rel = 'noopener'; a.target = '_blank'; title.append(a); }++ else title.textContent = text;++ line.append(title);++++ // The meta: the state, the viewers, the curtain, the worked time.++ meta.textContent = '';++ const bits = [live ? 'Live' : ended ? 'Ended' : 'Waiting for the desk'];++ if (live && agent.viewers > 0) bits.push(`${agent.viewers} watching`);++ if (live && agent.curtain && agent.curtain.up) bits.push('Private screen');++ bits.push(`worked ${mins(agent.worked_seconds)}`);++ meta.textContent = bits.join(' · ');++++ // The owner's buttons.++ acts.innerHTML = '';++ if (live && !(agent.curtain && agent.curtain.up)) {++ const b = h('button', null);++ b.type = 'button';++ b.dataset.act = 'curtain';++ b.innerHTML = `${svg(G.eye)}Curtain now`;++ b.title = 'Hide the screen now; your desk lifts it when it is clear';++ acts.append(b);++ }++ if (!ended) {++ const b = h('button', null);++ b.type = 'button';++ b.dataset.act = 'end';++ b.innerHTML = `${svg(G.stop)}End`;++ acts.append(b);++ }++ if (agent.page_url && /^https:\/\/openvibe\.live\//.test(agent.page_url)) {++ const a = h('a');++ a.href = agent.page_url;++ a.target = '_blank';++ a.rel = 'noopener';++ a.setAttribute('aria-label', 'Open the stream on OpenVibe.Live');++ a.title = 'Open the stream on OpenVibe.Live';++ a.innerHTML = svg(G.open);++ acts.append(a);++ }++ }++++ async function refresh() {++ if (gone || busy) return;++ if (typeof o.active === 'function' && !o.active()) { schedule(); return; }++ busy = true;++ clearTimeout(timer);++ try {++ const doc = await api('/api/v1/me/agent-view');++ if (gone) return;++ agent = doc && doc.agent && typeof doc.agent === 'object' ? doc.agent : null;++ paint();++ } catch (e) {++ // Codes away, or Live away: no stream, and the rest of the tab is untouched.++ if (!gone) { agent = null; paint(); }++ } finally {++ busy = false;++ schedule();++ }++ }++ function schedule() {++ clearTimeout(timer);++ if (!gone && agent && agent.state !== 'ended') timer = setTimeout(refresh, POLL_MS);++ }++++ async function control(what, btn) {++ if (!btn || btn.disabled) return;++ btn.disabled = true;++ try {++ const doc = await api(`/api/v1/me/agent-view/${what}`, { method: 'POST', json: true });++ agent = doc && doc.agent ? doc.agent : agent;++ if (what === 'end') frameUrl = '';++ paint();++ } catch (e) {++ btn.disabled = false;++ btn.title = e.message || 'That did not work. Try again.';++ return;++ }++ if (agent && agent.state !== 'ended') schedule();++ }++++ acts.addEventListener('click', (e) => {++ const b = e.target.closest('button[data-act]');++ if (!b) return;++ if (b.dataset.act === 'curtain') control('curtain', b);++ else if (b.dataset.act === 'end') control('end', b);++ });++++ refresh();++ return { refresh, destroy() { gone = true; clearTimeout(timer); if (lastFrame) URL.revokeObjectURL(lastFrame); container.innerHTML = ''; container.classList.remove('ovda'); } };++ }++++ root.OpenVibeDockAgent = { mount };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -13,9 +13,11 @@// State: GET/PUT <apiBase>/api/v1/me/dock (network.dock-result@1, network.dock-state@1): open, tab, pinned and where it// sits for each device class, so it follows the person from site to site. A change shows at once (onChange paints the// pill and the site's copy) and reaches the Network a beat later; before Network has the route the copy stands.--// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place), Chats (K-D2: the--// person's conversations and DMs from OpenVibe.Chat's own API, GET /api/dm/conversations, opened in place, read and--// answered there) and Activity (notification-ui.js renderDock(): the bell's list). The agent's live screen is K-D3.++// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place; K-D3: above it,++// the person's own desk stream — dock-agent.js loads the first time the tab opens — with Curtain now, End and the still++// when nothing is live), Chats (K-D2: the person's conversations and DMs from OpenVibe.Chat's own API,++// GET /api/dm/conversations, opened in place, read and answered there) and Activity (notification-ui.js renderDock():++// the bell's list).// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the// free corner to resize (320-720 × 360-900 px). It is not modal: the page stays usable. Phones: a sheet from the// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.@@@ -61,6 +63,8 @@const kind = () => (phone() ? 'phone' : 'desktop');const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });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(/\/+$/, '');async function call(path, init = {}) {const base = String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '');@@@ -444,8 +448,12 @@if (id === 'agent') {const ok = await load('action-center.js', () => !!(root.OpenVibeActionCenter && root.OpenVibeActionCenter.mount));if (!ok) return failed(panel, id);-- panel.innerHTML = `<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>`;-- const m = root.OpenVibeActionCenter.mount(panel.firstChild, {++ // 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>`;++ 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' });++ });++ const m = root.OpenVibeActionCenter.mount(panel.querySelector('.ovdk-scroll'), {token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },});@@@ -24,6 +24,7 @@ const BROWSER = Object.freeze(['dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person'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'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',@@@ -1191,7 +1191,7 @@// for a signed-in person it loads after the bell, from beside this file. A Network without the route yet (dock.js// keeps { off } for a day) is not asked again. dockApiBase and dockChatBase override the Network and Chat (tests, staging).const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');-- const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, me: _config.user && _config.user.username, sibling });++ const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, agentBase: _config.dockAgentBase, me: _config.user && _config.user.username, sibling });const DOCK_GLYPH = '<svg class="ovnav-ic" viewBox="0 0 512 512" style="width:1em" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M80 64h352a48 48 0 0 1 48 48v288a48 48 0 0 1-48 48H80a48 48 0 0 1-48-48V112a48 48 0 0 1 48-48Zm8 56v272h336V120ZM264 312h112a28 28 0 0 1 0 56H264a28 28 0 0 1 0-56Z"/></svg>';function loadDock(then) {if (root.OpenVibeDock) return then();@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.119.0",++ "version": "4.120.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.119.0",++ "version": "4.120.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.119.0",++ "version": "4.120.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -49,6 +49,7 @@"./dock": "./dock.js","./dock-vibe": "./dock-vibe.js","./dock-timer": "./dock-timer.js",++ "./dock-agent": "./dock-agent.js","./notification-ui": "./notification-ui.js","./notifications": "./notifications.js","./openvibe-sw": "./openvibe-sw.js",@@@ -54,6 +54,11 @@ const NOTES = [// The stand-in's state for the scenario under way.let scenario = {};const puts = [], decided = [], said = [], reads = [];++// K-D3: the agent view Codes' API answers (dock-agent.js) and the controls the Dock pressed.++let agentView = null;++const controls = [];++const AGENT_ID = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9]);// Chat's Vibe API for the Dock (OpenVibe.Chat server/rooms/vibe-api.js): one chat, Vibe answering a moment later.let vibeChat = null;const vibeMsgs = [], asked = [], listened = [], undone = [];@@@ -63,7 +68,7 @@ const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}</style></head><body><div id="navbar-mount"></div><main>${'<p>Fix the flaky upload test in the Media client and add a regression case.</p>'.repeat(30)}</main><script src="/navbar.js"></script>--<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin, dockChatBase: location.origin,++<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin, dockChatBase: location.origin, dockAgentBase: location.origin,${o.signedIn === false ? 'user: null,' : "token: 'tok_user', user: { id: 7, username: 'ada', display_name: 'Ada', avatar_url: null },"}links: [{ label: 'Tasks', href: '/', icon: 'fa-list' }] });</script></body></html>`;@@@ -81,6 +86,24 @@ const server = http.createServer((req, res) => {return json(200, { state: scenario.state || defaultState(), counts: scenario.counts || { running: 0, waiting: 0, unread: 0 }, sources_partial: false });}if (u.pathname === '/api/v1/me/actions') return json(200, ACTIONS);++ // Codes' agent view (K-D3): the stream, its two controls, and the still.++ if (u.pathname === '/api/v1/me/agent-view' && req.method === 'GET') {++ assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'the agent view is read with the person\'s own token');++ return json(200, { agent: agentView });++ }++ if (u.pathname === '/api/v1/me/agent-view/frame') {++ res.writeHead(200, { 'content-type': 'image/jpeg' });++ return res.end(JPEG);++ }++ const ctl = /^\/api\/v1\/me\/agent-view\/(curtain|end)$/.exec(u.pathname);++ if (ctl && req.method === 'POST') {++ assert.strictEqual(req.headers.authorization, 'Bearer tok_user', `${ctl[1]} is pressed with the person's own token`);++ controls.push(ctl[1]);++ agentView = ctl[1] === 'curtain'++ ? { ...agentView, curtain: { up: true } }++ : { ...agentView, state: 'ended', embed_url: null, last_frame_url: '/api/v1/me/agent-view/frame' };++ return json(200, { agent: agentView });++ }const m = /^\/api\/v1\/me\/actions\/(\w+)\/(\w+)\/(approve|decline)$/.exec(u.pathname);if (m && req.method === 'POST') { decided.push(m.slice(1).join(':')); return json(200, { item: ACTIONS.buckets.waiting[0] }); }// Chat's DM API, as OpenVibe.Chat answers it (server/chat/dm-routes.js).@@@ -261,6 +284,56 @@ async function drag(p, from, to) {await p.close();}++ // ── The agent view (K-D3): the person's own desk stream, its curtain, End, and the still when it ends ──++ {++ scenario = { counts: { running: 1, waiting: 0, unread: 0 } };++ controls.length = 0;++ const view = () => ({++ id: AGENT_ID, state: 'live', title: 'Fix the flaky upload test',++ task: { source: 'codes', ref: 'tsk_1', title: 'Fix the flaky upload test', url: 'https://openvibe.codes/run/tasks/tsk_1' },++ curtain: { up: false }, viewers: 2, worked_seconds: 720, visibility: 'private',++ page_url: `https://openvibe.live/@ada/agents/${AGENT_ID}`,++ embed_url: `https://openvibe.live/embed/agent/${AGENT_ID}?vt=wvt_test`, last_frame_url: null,++ });++ agentView = view();++ // The embed is Live's own page; the harness blocks its address so the test never reaches it.++ const p = await h.openPage(browser, await browser.newContext(), { width: 390, height: 780, block: ['https://openvibe.live/*'] });++ await p.goto(`${base}/page`, { maxMs: 8000 });++ await until(p, PILL, 'the pill');++ await p.evaluate("document.getElementById('ovdk-pill').click()");++ await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab');++ // The stream: Live's embed, the task, the numbers.++ const src = await until(p, "document.querySelector('.ovda iframe') && document.querySelector('.ovda iframe').getAttribute('src')", 'the embed');++ assert.strictEqual(src, view().embed_url, 'the embed Codes answered, with the ticket in its address');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovda-line').textContent"), 'Fix the flaky upload test', 'the task, one line');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Live · 2 watching · worked 12 min');++ assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ovda-acts button')].map((b) => b.dataset.act)"), ['curtain', 'end'], 'the owner\'s two buttons');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), true, 'the view is shown above the list');++ // Curtain now: Live is asked, and the line says so.++ await p.evaluate("document.querySelector('.ovda-acts button[data-act=curtain]').click()");++ await until(p, "document.querySelector('.ovda-meta').textContent.includes('Private screen')", 'the curtain in the line');++ assert.deepStrictEqual(controls, ['curtain'], 'Curtain now reached Codes, which puts it to Live');++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda-acts button[data-act=curtain]')"), false, 'no Curtain now while the curtain is up');++ // End: the stream becomes the last frame, and the list is still there under it.++ await p.evaluate("document.querySelector('.ovda-acts button[data-act=end]').click()");++ const stillSrc = await until(p, "document.querySelector('.ovda img') && document.querySelector('.ovda img').getAttribute('src')", 'the still after End');++ assert.ok(stillSrc.startsWith('blob:'), 'the still is read with the token, never a bare <img> URL');++ assert.deepStrictEqual(controls, ['curtain', 'end'], 'End reached Codes, which puts it to Live');++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda iframe')"), false, 'no player once it has ended');++ assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Ended · worked 12 min');++ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the agents list is untouched under the stream');++ // No stream at all: the view hides itself and the tab is just the list.++ agentView = null;++ await p.goto(`${base}/page`, { maxMs: 8000 });++ await until(p, PILL, 'the pill again');++ await p.evaluate("document.getElementById('ovdk-pill').click()");++ await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab again');++ await wait(400);++ assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it');++ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone');++ await p.close();++ }++// ── 320 px: nothing runs off the screen, closed or open. ──{scenario = { counts: { running: 1, waiting: 0, unread: 0 } };@@@ -15,10 +15,12 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');// 4.74.0 (2026-11-01, L48 V2) adds the Vibe tab's entry, its loader and Alt+V: +568 bytes (14757). The conversation// and the mic are dock-vibe.js (4.1 KB brotli), loaded the first time the tab opens, not on every page.// 4.76.0 (2026-11-01, L48 R2) adds the timer's button, its loader and the pill's countdown hook: +456 bytes (15213).++// 4.120.0 (2026-11-06, K-D3) adds the agent view's container and its lazy loader: +222 bytes (15435). The view itself++// (the embed, Curtain now, End, the still) is dock-agent.js, 3.9 KB brotli, loaded the first time the Agents tab opens.// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer// runs or its menu opens; the header's tab rail and narrow-width rules came with it.--const DOCK_BROTLI_BUDGET = 15360; // 15.00 KB (15213 after 4.76.0 + 147 bytes of headroom)--const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js'];++const DOCK_BROTLI_BUDGET = 15872; // 15.50 KB (15435 after 4.120.0 + 437 bytes of headroom)++const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js'];// ── the budget ──────────────────────────────────────────────{
@@ -1,5 +1,20 @@
@@ -1,5 +1,20 @@
# Changelog
# Changelog
+## 4.120.0 — 2026-11-06
+
+- **The Dock's agent view** (Dock K-D3; `dock-agent.js`, `dock.js`; Agent Streams S1). The Agents tab now opens on the
+ person's own desk stream: Live's embed (`/embed/agent/<ags_id>?vt=wvt_…`, the `AI agent` label, the curtain state and
+ the viewer count on the picture), one line for the task with the time it has worked, and the owner's **Curtain
+ now** and **End**. Above the list, and it hides itself when there is no stream, so the tab is what it was.
+ - The view ticket is minted server-side by OpenVibe.Codes with the person's own token (`GET
+ /api/v1/me/agent-view`) — a third-party iframe has no Live cookie — and the embed renews its own ticket while it
+ plays, so nothing is re-mounted on a poll.
+ - Nothing live: the last frame, read with the token and shown as a local object URL (an `<img>` cannot carry a
+ bearer), with the task and the worked time.
+ - No step line yet: steps are Agent Streams S2, and the view shows none until Live serves them.
+ - `dock-agent.js` is a sibling loaded the first time the tab opens (3.9 KB brotli); `dock.js` grows 222 bytes and its
+ budget goes to 15.50 KB. `navbar.js` passes `dockAgentBase` (default `https://openvibe.codes`).
+
## 4.119.0 — 2026-11-06
## 4.119.0 — 2026-11-06
- A featured link keeps a place on the phone's bar: where the links row folds into the menu, the featured link (4.117.0) also shows as one tinted icon button beside Sign In or the avatar (38 px, 34 px under 400 px, a 44 px hit area on the element), named by its label for screen readers. The Node on the Network is one tap away on a phone. Only a featured link with an icon gets one; no animation.
- A featured link keeps a place on the phone's bar: where the links row folds into the menu, the featured link (4.117.0) also shows as one tinted icon button beside Sign In or the avatar (38 px, 34 px under 400 px, a 44 px hit area on the element), named by its label for screen readers. The Node on the Network is one tap away on a phone. Only a featured link with an icon gets one; no animation.
@@ -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.119.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.120.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.119.0",
+ "version": "4.120.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,240 @@
@@ -0,0 +1,240 @@
+// ═══════════════════════════════════════════════════════════════
+// OpenVibe — the Dock's agent view (Dock plan 2026-10-30, K-D3; Agent Streams S1), Shared 4.120.0
+//
+// The person's own agent desk stream at the top of the Dock's Agents tab. dock.js loads this file the first time that
+// tab opens:
+// OpenVibeDockAgent.mount(container, { token, base, active }) → { refresh(), destroy() }
+//
+// `base` is OpenVibe.Codes' API (GET /api/v1/me/agent-view, the same POST for Curtain now and End, and the frame).
+// Codes mints the view ticket server-side with the person's own token — an iframe on another site has no Live cookie —
+// so the frame it answers is already Live's /embed/agent/<ags_id>?vt=wvt_…: the picture, the "AI agent" label, the
+// curtain state and the viewer count. The embed renews its own ticket while it plays.
+//
+// Nothing live: the last frame through the same API (an <img> cannot carry a token), with the task and the worked time.
+// There is no step line yet: steps are Agent Streams S2, and this view shows none until Live serves them.
+// Everything is set as text, never markup; the frame's address is the one Codes answered, as an https Live address.
+// ═══════════════════════════════════════════════════════════════
+(function (root) {
+ 'use strict';
+ if (typeof document === 'undefined' || root.OpenVibeDockAgent) return;
+
+ const POLL_MS = 20000;
+ const EMBED = /^https:\/\/openvibe\.live\/embed\/agent\/ags_[0-9A-HJKMNP-TV-Z]{26}\?vt=wvt_[A-Za-z0-9_-]+$/;
+ const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
+ const G = {
+ eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/><path d="M4 4l16 16"/>',
+ stop: '<rect x="6.5" y="6.5" width="11" height="11" rx="2.5"/>',
+ 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"/>',
+ };
+
+ function styles() {
+ if (document.getElementById('ovda-styles')) return;
+ const st = document.createElement('style');
+ st.id = 'ovda-styles';
+ st.textContent = `
+ .ovda { display: none; flex: none; padding: 8px 8px 2px; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); }
+ .ovda.on { display: block; }
+ .ovda-stage { position: relative; overflow: hidden; aspect-ratio: 16 / 9; border-radius: 14px; background: #0a0c11; border: 1px solid var(--border, rgba(255,255,255,.1)); }
+ .ovda-frame, .ovda-still { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; object-fit: contain; background: #0a0c11; }
+ .ovda-still { object-fit: cover; opacity: .92; }
+ .ovda-wait { position: absolute; inset: 0; display: grid; place-items: center; color: var(--text-muted, #8b8b96); font-size: 12.5px; text-align: center; padding: 12px; }
+ .ovda-line { display: flex; align-items: center; gap: 6px; min-height: 34px; font-size: 12.5px; color: var(--text-secondary, #b6b6c0); }
+ .ovda-line b { color: var(--text-primary, #ececf1); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .ovda-line b a { color: inherit; text-decoration: none; }
+ .ovda-line b a:hover { text-decoration: underline; }
+ .ovda-line .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #3b82f6); flex: none; }
+ .ovda-line .dot.live { background: var(--live-red, #e5484d); animation: ovda-pulse 1.6s ease-in-out infinite; }
+ .ovda-line .dot.off { background: var(--text-muted, #8b8b96); }
+ .ovda-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11.5px; color: var(--text-muted, #8b8b96); }
+ .ovda-acts { display: flex; gap: 6px; margin-left: auto; }
+ .ovda-acts button, .ovda-acts a { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12));
+ background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; text-decoration: none; }
+ .ovda-acts button:hover, .ovda-acts a:hover { color: var(--text-primary, #ececf1); background: color-mix(in srgb, var(--text-primary, #ececf1) 7%, transparent); }
+ .ovda-acts button:disabled { opacity: .5; cursor: default; }
+ .ovda-acts svg { width: 14px; height: 14px; }
+ .ovda-acts button[data-act="end"] { color: color-mix(in srgb, var(--live-red, #e5484d) 65%, var(--text-primary, #ececf1)); }
+ .ovda svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
+ @keyframes ovda-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
+ @media (prefers-reduced-motion: reduce) { .ovda-line .dot.live { animation: none; } }
+ `;
+ document.head.appendChild(st);
+ }
+
+ const h = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
+ const mins = (s) => {
+ const m = Math.max(0, Math.round((Number(s) || 0) / 60));
+ return m < 1 ? 'under a minute' : m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
+ };
+
+ function mount(container, opts = {}) {
+ styles();
+ const o = { ...opts };
+ let gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';
+ container.innerHTML = '';
+ container.classList.add('ovda');
+ const stage = h('div', 'ovda-stage');
+ const line = h('div', 'ovda-line');
+ const meta = h('div', 'ovda-meta');
+ const acts = h('div', 'ovda-acts');
+ const head = h('div');
+ head.append(line, meta, acts);
+ const body = h('div');
+ body.append(stage, head);
+ container.append(body);
+
+ async function api(path, init = {}) {
+ const base = String(o.base || 'https://openvibe.codes').replace(/\/+$/, '');
+ const t = typeof o.token === 'function' ? await o.token() : o.token;
+ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
+ const r = await fetch(base + path, { ...init, headers, credentials: 'omit', body: init.json ? '{}' : undefined });
+ const b = await r.json().catch(() => ({}));
+ if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
+ return b;
+ }
+
+ /** The still is behind the person's token, so it is read here and shown as a local object URL. */
+ function still(url) {
+ if (!url || frameUrl === url) return;
+ frameUrl = url;
+ Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => fetch(url, { headers: t ? { authorization: `Bearer ${t}` } : {}, credentials: 'omit' }))
+ .then((r) => (r.ok ? r.blob() : null))
+ .then((blob) => {
+ if (!blob || gone || frameUrl !== url) return;
+ const next = URL.createObjectURL(blob);
+ const img = h('img', 'ovda-still');
+ img.alt = 'The last frame of your agent';
+ img.src = next;
+ img.decoding = 'async';
+ const old = stage.querySelector('img.ovda-still');
+ if (old) { stage.removeChild(old); }
+ stage.append(img);
+ if (lastFrame) URL.revokeObjectURL(lastFrame);
+ lastFrame = next;
+ })
+ .catch(() => { /* no still: the line says enough */ });
+ }
+
+ function paint() {
+ if (gone) return;
+ const has = !!agent;
+ container.classList.toggle('on', has);
+ if (!has) { stage.innerHTML = ''; line.innerHTML = ''; meta.textContent = ''; acts.innerHTML = ''; return; }
+
+ const live = agent.state === 'live';
+ const ended = agent.state === 'ended';
+ // The picture: the live embed when there is one, else the still.
+ const embed = typeof agent.embed_url === 'string' && EMBED.test(agent.embed_url) ? agent.embed_url : null;
+ const shown = stage.querySelector('iframe');
+ if (embed && (!shown || shown.getAttribute('src') !== embed)) {
+ stage.innerHTML = '';
+ const f = h('iframe', 'ovda-frame');
+ f.src = embed;
+ f.title = 'Your agent\'s screen';
+ f.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture');
+ f.setAttribute('referrerpolicy', 'no-referrer');
+ stage.append(f);
+ } else if (!embed) {
+ if (shown) stage.innerHTML = '';
+ if (agent.last_frame_url) still(agent.last_frame_url);
+ else if (!stage.querySelector('img')) stage.append(h('div', 'ovda-wait', ended ? 'Nothing to show' : 'Waiting for the desk'));
+ }
+
+ // The line: what it is doing, one row.
+ line.innerHTML = '';
+ line.append(h('span', `dot ${live ? 'live' : 'off'}`));
+ const title = h('b');
+ const url = agent.task && /^https:\/\//.test(String(agent.task.url || '')) ? agent.task.url : (/^https:\/\//.test(String(agent.page_url || '')) ? agent.page_url : null);
+ const text = (agent.task && agent.task.title) || agent.title || 'Your agent';
+ if (url) { const a = h('a', null, text); a.href = url; a.rel = 'noopener'; a.target = '_blank'; title.append(a); }
+ else title.textContent = text;
+ line.append(title);
+
+ // The meta: the state, the viewers, the curtain, the worked time.
+ meta.textContent = '';
+ const bits = [live ? 'Live' : ended ? 'Ended' : 'Waiting for the desk'];
+ if (live && agent.viewers > 0) bits.push(`${agent.viewers} watching`);
+ if (live && agent.curtain && agent.curtain.up) bits.push('Private screen');
+ bits.push(`worked ${mins(agent.worked_seconds)}`);
+ meta.textContent = bits.join(' · ');
+
+ // The owner's buttons.
+ acts.innerHTML = '';
+ if (live && !(agent.curtain && agent.curtain.up)) {
+ const b = h('button', null);
+ b.type = 'button';
+ b.dataset.act = 'curtain';
+ b.innerHTML = `${svg(G.eye)}Curtain now`;
+ b.title = 'Hide the screen now; your desk lifts it when it is clear';
+ acts.append(b);
+ }
+ if (!ended) {
+ const b = h('button', null);
+ b.type = 'button';
+ b.dataset.act = 'end';
+ b.innerHTML = `${svg(G.stop)}End`;
+ acts.append(b);
+ }
+ if (agent.page_url && /^https:\/\/openvibe\.live\//.test(agent.page_url)) {
+ const a = h('a');
+ a.href = agent.page_url;
+ a.target = '_blank';
+ a.rel = 'noopener';
+ a.setAttribute('aria-label', 'Open the stream on OpenVibe.Live');
+ a.title = 'Open the stream on OpenVibe.Live';
+ a.innerHTML = svg(G.open);
+ acts.append(a);
+ }
+ }
+
+ async function refresh() {
+ if (gone || busy) return;
+ if (typeof o.active === 'function' && !o.active()) { schedule(); return; }
+ busy = true;
+ clearTimeout(timer);
+ try {
+ const doc = await api('/api/v1/me/agent-view');
+ if (gone) return;
+ agent = doc && doc.agent && typeof doc.agent === 'object' ? doc.agent : null;
+ paint();
+ } catch (e) {
+ // Codes away, or Live away: no stream, and the rest of the tab is untouched.
+ if (!gone) { agent = null; paint(); }
+ } finally {
+ busy = false;
+ schedule();
+ }
+ }
+ function schedule() {
+ clearTimeout(timer);
+ if (!gone && agent && agent.state !== 'ended') timer = setTimeout(refresh, POLL_MS);
+ }
+
+ async function control(what, btn) {
+ if (!btn || btn.disabled) return;
+ btn.disabled = true;
+ try {
+ const doc = await api(`/api/v1/me/agent-view/${what}`, { method: 'POST', json: true });
+ agent = doc && doc.agent ? doc.agent : agent;
+ if (what === 'end') frameUrl = '';
+ paint();
+ } catch (e) {
+ btn.disabled = false;
+ btn.title = e.message || 'That did not work. Try again.';
+ return;
+ }
+ if (agent && agent.state !== 'ended') schedule();
+ }
+
+ acts.addEventListener('click', (e) => {
+ const b = e.target.closest('button[data-act]');
+ if (!b) return;
+ if (b.dataset.act === 'curtain') control('curtain', b);
+ else if (b.dataset.act === 'end') control('end', b);
+ });
+
+ refresh();
+ return { refresh, destroy() { gone = true; clearTimeout(timer); if (lastFrame) URL.revokeObjectURL(lastFrame); container.innerHTML = ''; container.classList.remove('ovda'); } };
+ }
+
+ root.OpenVibeDockAgent = { mount };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -13,9 +13,11 @@
@@ -13,9 +13,11 @@
// State: GET/PUT <apiBase>/api/v1/me/dock (network.dock-result@1, network.dock-state@1): open, tab, pinned and where it
// State: GET/PUT <apiBase>/api/v1/me/dock (network.dock-result@1, network.dock-state@1): open, tab, pinned and where it
// sits for each device class, so it follows the person from site to site. A change shows at once (onChange paints the
// sits for each device class, so it follows the person from site to site. A change shows at once (onChange paints the
// pill and the site's copy) and reaches the Network a beat later; before Network has the route the copy stands.
// pill and the site's copy) and reaches the Network a beat later; before Network has the route the copy stands.
-// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place), Chats (K-D2: the
-// person's conversations and DMs from OpenVibe.Chat's own API, GET /api/dm/conversations, opened in place, read and
-// answered there) and Activity (notification-ui.js renderDock(): the bell's list). The agent's live screen is K-D3.
+// Tabs: Agents (action-center.js mount(): what needs you, then what runs, approve or decline in place; K-D3: above it,
+// the person's own desk stream — dock-agent.js loads the first time the tab opens — with Curtain now, End and the still
+// when nothing is live), Chats (K-D2: the person's conversations and DMs from OpenVibe.Chat's own API,
+// GET /api/dm/conversations, opened in place, read and answered there) and Activity (notification-ui.js renderDock():
+// the bell's list).
// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the
// Desktop: a floating card at the pill's corner. Drag its head to move it (it snaps to the nearest corner), drag the
// free corner to resize (320-720 × 360-900 px). It is not modal: the page stays usable. Phones: a sheet from the
// free corner to resize (320-720 × 360-900 px). It is not modal: the page stays usable. Phones: a sheet from the
// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.
// bottom over a scrim; drag the head down, ×, Escape or a tap outside closes it. Motion is transform and opacity only.
@@ -61,6 +63,8 @@
@@ -61,6 +63,8 @@
const kind = () => (phone() ? 'phone' : 'desktop');
const kind = () => (phone() ? 'phone' : 'desktop');
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
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).
+ const agentBase = () => String(o.agentBase || 'https://openvibe.codes').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(/\/+$/, '');
@@ -444,8 +448,12 @@
@@ -444,8 +448,12 @@
if (id === 'agent') {
if (id === 'agent') {
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);
- panel.innerHTML = `<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>`;
- const m = root.OpenVibeActionCenter.mount(panel.firstChild, {
+ // 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>`;
+ 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' });
+ });
+ const m = root.OpenVibeActionCenter.mount(panel.querySelector('.ovdk-scroll'), {
token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],
token: o.token, apiBase: o.apiBase, scopes: o.scopes, buckets: ['waiting', 'running'],
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
});
});
@@ -24,6 +24,7 @@ const BROWSER = Object.freeze([
@@ -24,6 +24,7 @@ const BROWSER = Object.freeze([
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
'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
'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',
@@ -1191,7 +1191,7 @@
@@ -1191,7 +1191,7 @@
// for a signed-in person it loads after the bell, from beside this file. A Network without the route yet (dock.js
// for a signed-in person it loads after the bell, from beside this file. A Network without the route yet (dock.js
// keeps { off } for a day) is not asked again. dockApiBase and dockChatBase override the Network and Chat (tests, staging).
// keeps { off } for a day) is not asked again. dockApiBase and dockChatBase override the Network and Chat (tests, staging).
const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');
const dockBase = () => String(_config.dockApiBase || 'https://openvibe.network').replace(/\/+$/, '');
- const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, me: _config.user && _config.user.username, sibling });
+ const dockOpts = () => ({ token: () => _config.token, apiBase: dockBase(), chatBase: _config.dockChatBase, agentBase: _config.dockAgentBase, me: _config.user && _config.user.username, sibling });
const DOCK_GLYPH = '<svg class="ovnav-ic" viewBox="0 0 512 512" style="width:1em" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M80 64h352a48 48 0 0 1 48 48v288a48 48 0 0 1-48 48H80a48 48 0 0 1-48-48V112a48 48 0 0 1 48-48Zm8 56v272h336V120ZM264 312h112a28 28 0 0 1 0 56H264a28 28 0 0 1 0-56Z"/></svg>';
const DOCK_GLYPH = '<svg class="ovnav-ic" viewBox="0 0 512 512" style="width:1em" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M80 64h352a48 48 0 0 1 48 48v288a48 48 0 0 1-48 48H80a48 48 0 0 1-48-48V112a48 48 0 0 1 48-48Zm8 56v272h336V120ZM264 312h112a28 28 0 0 1 0 56H264a28 28 0 0 1 0-56Z"/></svg>';
function loadDock(then) {
function loadDock(then) {
if (root.OpenVibeDock) return then();
if (root.OpenVibeDock) return then();
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.119.0",
+ "version": "4.120.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.119.0",
+ "version": "4.120.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.119.0",
+ "version": "4.120.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": {
@@ -49,6 +49,7 @@
@@ -49,6 +49,7 @@
"./dock": "./dock.js",
"./dock": "./dock.js",
"./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",
"./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",
@@ -54,6 +54,11 @@ const NOTES = [
@@ -54,6 +54,11 @@ const NOTES = [
// The stand-in's state for the scenario under way.
// The stand-in's state for the scenario under way.
let scenario = {};
let scenario = {};
const puts = [], decided = [], said = [], reads = [];
const puts = [], decided = [], said = [], reads = [];
+// K-D3: the agent view Codes' API answers (dock-agent.js) and the controls the Dock pressed.
+let agentView = null;
+const controls = [];
+const AGENT_ID = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9]);
// Chat's Vibe API for the Dock (OpenVibe.Chat server/rooms/vibe-api.js): one chat, Vibe answering a moment later.
// Chat's Vibe API for the Dock (OpenVibe.Chat server/rooms/vibe-api.js): one chat, Vibe answering a moment later.
let vibeChat = null;
let vibeChat = null;
const vibeMsgs = [], asked = [], listened = [], undone = [];
const vibeMsgs = [], asked = [], listened = [], undone = [];
@@ -63,7 +68,7 @@ const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8">
@@ -63,7 +68,7 @@ const PAGE = (o) => `<!doctype html><html lang="en"><head><meta charset="utf-8">
<style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}</style></head>
<style>body{margin:0;font:15px system-ui}main{padding:16px}p{margin:0 0 12px}</style></head>
<body><div id="navbar-mount"></div><main>${'<p>Fix the flaky upload test in the Media client and add a regression case.</p>'.repeat(30)}</main>
<body><div id="navbar-mount"></div><main>${'<p>Fix the flaky upload test in the Media client and add a regression case.</p>'.repeat(30)}</main>
<script src="/navbar.js"></script>
<script src="/navbar.js"></script>
-<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin, dockChatBase: location.origin,
+<script>OpenVibeNavbar.init({ service: 'codes', brandName: 'OpenVibe.Codes', notifications: false, dockApiBase: location.origin, dockChatBase: location.origin, dockAgentBase: location.origin,
${o.signedIn === false ? 'user: null,' : "token: 'tok_user', user: { id: 7, username: 'ada', display_name: 'Ada', avatar_url: null },"}
${o.signedIn === false ? 'user: null,' : "token: 'tok_user', user: { id: 7, username: 'ada', display_name: 'Ada', avatar_url: null },"}
links: [{ label: 'Tasks', href: '/', icon: 'fa-list' }] });</script></body></html>`;
links: [{ label: 'Tasks', href: '/', icon: 'fa-list' }] });</script></body></html>`;
@@ -81,6 +86,24 @@ const server = http.createServer((req, res) => {
@@ -81,6 +86,24 @@ const server = http.createServer((req, res) => {
return json(200, { state: scenario.state || defaultState(), counts: scenario.counts || { running: 0, waiting: 0, unread: 0 }, sources_partial: false });
return json(200, { state: scenario.state || defaultState(), counts: scenario.counts || { running: 0, waiting: 0, unread: 0 }, sources_partial: false });
}
}
if (u.pathname === '/api/v1/me/actions') return json(200, ACTIONS);
if (u.pathname === '/api/v1/me/actions') return json(200, ACTIONS);
+ // Codes' agent view (K-D3): the stream, its two controls, and the still.
+ if (u.pathname === '/api/v1/me/agent-view' && req.method === 'GET') {
+ assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'the agent view is read with the person\'s own token');
+ return json(200, { agent: agentView });
+ }
+ if (u.pathname === '/api/v1/me/agent-view/frame') {
+ res.writeHead(200, { 'content-type': 'image/jpeg' });
+ return res.end(JPEG);
+ }
+ const ctl = /^\/api\/v1\/me\/agent-view\/(curtain|end)$/.exec(u.pathname);
+ if (ctl && req.method === 'POST') {
+ assert.strictEqual(req.headers.authorization, 'Bearer tok_user', `${ctl[1]} is pressed with the person's own token`);
+ controls.push(ctl[1]);
+ agentView = ctl[1] === 'curtain'
+ ? { ...agentView, curtain: { up: true } }
+ : { ...agentView, state: 'ended', embed_url: null, last_frame_url: '/api/v1/me/agent-view/frame' };
+ return json(200, { agent: agentView });
+ }
const m = /^\/api\/v1\/me\/actions\/(\w+)\/(\w+)\/(approve|decline)$/.exec(u.pathname);
const m = /^\/api\/v1\/me\/actions\/(\w+)\/(\w+)\/(approve|decline)$/.exec(u.pathname);
if (m && req.method === 'POST') { decided.push(m.slice(1).join(':')); return json(200, { item: ACTIONS.buckets.waiting[0] }); }
if (m && req.method === 'POST') { decided.push(m.slice(1).join(':')); return json(200, { item: ACTIONS.buckets.waiting[0] }); }
// Chat's DM API, as OpenVibe.Chat answers it (server/chat/dm-routes.js).
// Chat's DM API, as OpenVibe.Chat answers it (server/chat/dm-routes.js).
@@ -261,6 +284,56 @@ async function drag(p, from, to) {
@@ -261,6 +284,56 @@ async function drag(p, from, to) {
await p.close();
await p.close();
}
}
+ // ── The agent view (K-D3): the person's own desk stream, its curtain, End, and the still when it ends ──
+ {
+ scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
+ controls.length = 0;
+ const view = () => ({
+ id: AGENT_ID, state: 'live', title: 'Fix the flaky upload test',
+ task: { source: 'codes', ref: 'tsk_1', title: 'Fix the flaky upload test', url: 'https://openvibe.codes/run/tasks/tsk_1' },
+ curtain: { up: false }, viewers: 2, worked_seconds: 720, visibility: 'private',
+ page_url: `https://openvibe.live/@ada/agents/${AGENT_ID}`,
+ embed_url: `https://openvibe.live/embed/agent/${AGENT_ID}?vt=wvt_test`, last_frame_url: null,
+ });
+ agentView = view();
+ // The embed is Live's own page; the harness blocks its address so the test never reaches it.
+ const p = await h.openPage(browser, await browser.newContext(), { width: 390, height: 780, block: ['https://openvibe.live/*'] });
+ await p.goto(`${base}/page`, { maxMs: 8000 });
+ await until(p, PILL, 'the pill');
+ await p.evaluate("document.getElementById('ovdk-pill').click()");
+ await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab');
+ // The stream: Live's embed, the task, the numbers.
+ const src = await until(p, "document.querySelector('.ovda iframe') && document.querySelector('.ovda iframe').getAttribute('src')", 'the embed');
+ assert.strictEqual(src, view().embed_url, 'the embed Codes answered, with the ticket in its address');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovda-line').textContent"), 'Fix the flaky upload test', 'the task, one line');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Live · 2 watching · worked 12 min');
+ assert.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ovda-acts button')].map((b) => b.dataset.act)"), ['curtain', 'end'], 'the owner\'s two buttons');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), true, 'the view is shown above the list');
+ // Curtain now: Live is asked, and the line says so.
+ await p.evaluate("document.querySelector('.ovda-acts button[data-act=curtain]').click()");
+ await until(p, "document.querySelector('.ovda-meta').textContent.includes('Private screen')", 'the curtain in the line');
+ assert.deepStrictEqual(controls, ['curtain'], 'Curtain now reached Codes, which puts it to Live');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda-acts button[data-act=curtain]')"), false, 'no Curtain now while the curtain is up');
+ // End: the stream becomes the last frame, and the list is still there under it.
+ await p.evaluate("document.querySelector('.ovda-acts button[data-act=end]').click()");
+ const stillSrc = await until(p, "document.querySelector('.ovda img') && document.querySelector('.ovda img').getAttribute('src')", 'the still after End');
+ assert.ok(stillSrc.startsWith('blob:'), 'the still is read with the token, never a bare <img> URL');
+ assert.deepStrictEqual(controls, ['curtain', 'end'], 'End reached Codes, which puts it to Live');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda iframe')"), false, 'no player once it has ended');
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Ended · worked 12 min');
+ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the agents list is untouched under the stream');
+ // No stream at all: the view hides itself and the tab is just the list.
+ agentView = null;
+ await p.goto(`${base}/page`, { maxMs: 8000 });
+ await until(p, PILL, 'the pill again');
+ await p.evaluate("document.getElementById('ovdk-pill').click()");
+ await until(p, `(() => { const c = ${CARD}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents tab again');
+ await wait(400);
+ assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it');
+ assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone');
+ await p.close();
+ }
+
// ── 320 px: nothing runs off the screen, closed or open. ──
// ── 320 px: nothing runs off the screen, closed or open. ──
{
{
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
@@ -15,10 +15,12 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
@@ -15,10 +15,12 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// 4.74.0 (2026-11-01, L48 V2) adds the Vibe tab's entry, its loader and Alt+V: +568 bytes (14757). The conversation
// 4.74.0 (2026-11-01, L48 V2) adds the Vibe tab's entry, its loader and Alt+V: +568 bytes (14757). The conversation
// and the mic are dock-vibe.js (4.1 KB brotli), loaded the first time the tab opens, not on every page.
// and the mic are dock-vibe.js (4.1 KB brotli), loaded the first time the tab opens, not on every page.
// 4.76.0 (2026-11-01, L48 R2) adds the timer's button, its loader and the pill's countdown hook: +456 bytes (15213).
// 4.76.0 (2026-11-01, L48 R2) adds the timer's button, its loader and the pill's countdown hook: +456 bytes (15213).
+// 4.120.0 (2026-11-06, K-D3) adds the agent view's container and its lazy loader: +222 bytes (15435). The view itself
+// (the embed, Curtain now, End, the still) is dock-agent.js, 3.9 KB brotli, loaded the first time the Agents tab opens.
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// The timer itself (menu, countdown, chime, focus rounds) is dock-timer.js (3.7 KB brotli), loaded only while a timer
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
// runs or its menu opens; the header's tab rail and narrow-width rules came with it.
-const DOCK_BROTLI_BUDGET = 15360; // 15.00 KB (15213 after 4.76.0 + 147 bytes of headroom)
-const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js'];
+const DOCK_BROTLI_BUDGET = 15872; // 15.50 KB (15435 after 4.120.0 + 437 bytes of headroom)
+const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-timer.js', 'dock-agent.js'];
// ── the budget ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
{
{