openvibers/openvibe.shared Commits

The Dock's agent view: Pause, Curtain, Mute and End (#309)

OP OpenVibers committed 6 days ago · 0009073262 · 6 files, +51 −15

* The Dock's agent view: Pause, Curtain, Mute and End

* Merge remote-tracking branch 'origin/main' into feat/agent-streams-s5-copilot

* 4.124.0: the Dock's agent view gets Pause, Curtain, Mute and End (Agent Streams S5)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b53e73e..eb2187d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,9 @@
# Changelog
++## 4.124.0 — 2026-11-07
++
++- **The Dock's agent view gets the four controls** (Agent Streams S5, Co-pilot): Pause and Resume, Curtain now, Mute and Unmute, and End, each sent through Codes to Live. The owner's own view may always resume; Live still refuses a moderator resuming the owner's pause. The line says "Paused by @name" when someone else paused the stream.
++
## 4.123.0 — 2026-11-06
- **The "not on the list" gate from markup**: `<div data-ov-share-gate data-kind data-id data-signed-in-as data-sign-in-url>` is started by `ov-share.js` when it loads (and by `OVShare.startGates()` for markup added later), once per element. Sites no longer write an inline script for it: an inline script needs a CSP nonce on most sites, and a value written into one with an HTML-escaping template turns its quotes into `&quot;` and breaks the script, so the gate never drew. The values are plain attributes, escaped like any other.
ddiff --git a/README.md b/README.md
iindex dc66209..391f2d4 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.123.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.124.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 5182871..947c8c8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.123.0",
++ "version": "4.124.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
iindex 33e85ca..5dac3ad 100644
---- a/dock-agent.js
++++ b/dock-agent.js
@@@ -5,7 +5,9 @@
// 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).
++// `base` is OpenVibe.Codes' API (GET /api/v1/me/agent-view, the same POST for pause, Curtain now, mute and End, and
++// the frame). Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the
++// Dock is the owner's own view, so it may always resume.
// 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.
@@@ -23,6 +25,10 @@
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"/>',
++ pause: '<rect x="7" y="5" width="3.5" height="14" rx="1"/><rect x="13.5" y="5" width="3.5" height="14" rx="1"/>',
++ play: '<path d="M8 5.5v13l10-6.5z"/>',
++ mute: '<path d="M4 9.5h4L13 5v14l-5-4.5H4z"/><path d="M17 9l4 4m0-4l-4 4"/>',
++ sound: '<path d="M4 9.5h4L13 5v14l-5-4.5H4z"/><path d="M17 9.5a4 4 0 0 1 0 5"/>',
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"/>',
};
@@@ -89,7 +95,7 @@
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 r = await fetch(base + path, { ...init, headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : 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;
@@@ -156,12 +162,21 @@
meta.textContent = '';
const bits = [live ? 'Live' : ended ? 'Ended' : 'Waiting for the desk'];
if (live && agent.viewers > 0) bits.push(`${agent.viewers} watching`);
++ if (agent.paused) bits.push(agent.paused_by ? `Paused by @${agent.paused_by}` : 'Paused');
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.
++ // S5: the four controls, for the owner (the Dock is their own view, so resume is always theirs).
acts.innerHTML = '';
++ if (!ended) {
++ const pause = h('button', null);
++ pause.type = 'button';
++ pause.dataset.act = 'pause';
++ pause.innerHTML = `${svg(agent.paused ? G.play : G.pause)}${agent.paused ? 'Resume' : 'Pause'}`;
++ pause.title = agent.paused && agent.paused_by ? `Paused by @${agent.paused_by}` : 'Pause the agent at the next safe point';
++ acts.append(pause);
++ }
if (live && !(agent.curtain && agent.curtain.up)) {
const b = h('button', null);
b.type = 'button';
@@@ -170,6 +185,14 @@
b.title = 'Hide the screen now; your desk lifts it when it is clear';
acts.append(b);
}
++ if (!ended) {
++ const mute = h('button', null);
++ mute.type = 'button';
++ mute.dataset.act = 'mute';
++ mute.innerHTML = `${svg(agent.voice_muted ? G.sound : G.mute)}${agent.voice_muted ? 'Unmute' : 'Mute'}`;
++ mute.title = agent.voice_muted ? 'Let the agent speak again' : 'Mute the agent\'s voice';
++ acts.append(mute);
++ }
if (!ended) {
const b = h('button', null);
b.type = 'button';
@@@ -212,11 +235,11 @@
if (!gone && agent && agent.state !== 'ended') timer = setTimeout(refresh, POLL_MS);
}
-- async function control(what, btn) {
++ async function control(what, btn, body = {}) {
if (!btn || btn.disabled) return;
btn.disabled = true;
try {
-- const doc = await api(`/api/v1/me/agent-view/${what}`, { method: 'POST', json: true });
++ const doc = await api(`/api/v1/me/agent-view/${what}`, { method: 'POST', json: true, body });
agent = doc && doc.agent ? doc.agent : agent;
if (what === 'end') frameUrl = '';
paint();
@@@ -231,7 +254,9 @@
acts.addEventListener('click', (e) => {
const b = e.target.closest('button[data-act]');
if (!b) return;
-- if (b.dataset.act === 'curtain') control('curtain', b);
++ if (b.dataset.act === 'pause') control('pause', b, { paused: !(agent && agent.paused) });
++ else if (b.dataset.act === 'curtain') control('curtain', b);
++ else if (b.dataset.act === 'mute') control('mute', b, { muted: !(agent && agent.voice_muted) });
else if (b.dataset.act === 'end') control('end', b);
});
ddiff --git a/package.json b/package.json
iindex 678460f..b51ddbc 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.123.0",
++ "version": "4.124.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex e5e80a4..18b5021 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -95,13 +95,15 @@ const server = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'image/jpeg' });
return res.end(JPEG);
}
-- const ctl = /^\/api\/v1\/me\/agent-view\/(curtain|end)$/.exec(u.pathname);
++ const ctl = /^\/api\/v1\/me\/agent-view\/(pause|curtain|mute|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' };
++ const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
++ agentView = ctl[1] === 'pause' ? { ...agentView, paused: !!body.paused, paused_by: body.paused ? 'ada' : null }
++ : ctl[1] === 'curtain' ? { ...agentView, curtain: { up: true } }
++ : ctl[1] === 'mute' ? { ...agentView, voice_muted: !!body.muted }
++ : { ...agentView, state: 'ended', paused: false, paused_by: null, 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);
@@@ -309,18 +311,23 @@ async function drag(p, from, to) {
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.deepStrictEqual(await p.evaluate("[...document.querySelectorAll('.ovda-acts button')].map((b) => b.dataset.act)"), ['pause', 'curtain', 'mute', 'end'], 'the owner\'s four controls (S5)');
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');
++ // S5: Pause and Mute reach Codes, which puts them to Live; the line says whose pause it is.
++ await p.evaluate("document.querySelector('.ovda-acts button[data-act=pause]').click()");
++ await until(p, "document.querySelector('.ovda-acts button[data-act=pause]').textContent.includes('Resume')", 'the pause in the button');
++ await p.evaluate("document.querySelector('.ovda-acts button[data-act=mute]').click()");
++ await until(p, "document.querySelector('.ovda-acts button[data-act=mute]').textContent.includes('Unmute')", 'the mute in the button');
// 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.deepStrictEqual(controls, ['curtain', 'pause', 'mute', 'end'], 'Pause, Mute and End reached Codes, which puts them 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');