openvibers/openvibe.shared Commits

4.120.0: the Dock's agent view — the person's desk stream, Curtain now and End (K-D3) (#305)

OP OpenVibers committed 6 days ago · a44cd5e3f1 · 11 files, +354 −14

* 4.120.0: the Dock's agent view — the person's desk stream, Curtain now and End (K-D3)

The Agents tab opens on the person's own agent stream: Live's embed with the ticket in its address (the AI
agent label, the curtain state and the viewer count are the embed's own), one line for the task and the time
it has worked, and the owner's Curtain now and End. It hides itself when there is no stream, so the tab is
what it was.

The 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. The embed renews its own ticket while it plays, so a poll never
re-mounts it. Nothing live: the last frame, read with the token and shown as a local object URL, with the
task and the worked time. No step line yet — steps are Agent Streams S2.

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). The browser
test covers the embed and its address, the line, Curtain now, End, the still and the empty case.

* Curtain now shows an eye with a slash: it hides the screen

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 40b6f81..38810bb 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/README.md b/README.md
iindex 2c34723..48fc1a3 100644
---- a/README.md
++++ b/README.md
@@@ -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 deployed
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
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex b54f325..ecdc51b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/dock-agent.js b/dock-agent.js
nnew file mode 100644
iindex 0000000..f53e684
---- /dev/null
++++ b/dock-agent.js
@@@ -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);
ddiff --git a/dock.js b/dock.js
iindex 1a162b5..942a124 100644
---- a/dock.js
++++ b/dock.js
@@@ -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(); },
});
ddiff --git a/files.js b/files.js
iindex dc24734..e7fa567 100644
---- a/files.js
++++ b/files.js
@@@ -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',
ddiff --git a/navbar.js b/navbar.js
iindex 00fa04b..53efe35 100644
---- a/navbar.js
++++ b/navbar.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();
ddiff --git a/package-lock.json b/package-lock.json
iindex 6f2bfc7..e9bebe4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex a0bcd64..1117277 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex c394ff4..9a4fcda 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.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 } };
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex b226f8b..b0665d4 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.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
// 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 ──────────────────────────────────────────────
{