openvibers/openvibe.chat Commits

The agent's canvas beside a Vibe chat (HO9 step 2) (#406)

OP OpenVibers committed 6 days ago · d00b0a3a0e · 6 files, +262 −5

Changes

ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex f5d498f..98c013c 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -206,3 +206,24 @@
.vbk .ovt-answer.vb-streaming { opacity: .92; }
/* Memory: a plain page in the frame; on a wide screen its head held only New chat, which the chats column has. */
@media (min-width: 761px) { .vbk .ovt.is-page .ovt-head { display: none; } }
++
++/* ── The canvas (plan HO9, actor.canvas@1) ───────────────────────────────────────────────────────────────────────
++ The agent's data panel beside a Vibe chat: the thread's third column from 1180 px (thread.css's own sticky panel),
++ and a closed, counted fold directly above the composer below that. Both hold the same document, drawn read-only by
++ openvibe-shared's ov-canvas.js; the one link opens the Actor thread where the person can tick or press. */
++.vbk .vbc { min-width: 0; }
++.vbk .vbc-count { flex: none; font-size: 12.5px; color: var(--text-muted, #7386a3); white-space: nowrap; }
++.vbk .vbc-body, .vbk .vbc-host { min-width: 0; }
++.vbk .vbc-body { display: grid; gap: 8px; }
++.vbk .vbc-link { justify-self: start; font-size: 12.5px; font-weight: 650; color: var(--accent-light, var(--ovt-accent, #60a5fa)); text-decoration: none; }
++.vbk .vbc-link:hover, .vbk .vbc-link:focus-visible { text-decoration: underline; }
++.vbk .vbc-phone { max-width: var(--vb-col); margin: 0 auto 8px; border: 1px solid var(--ovt-line); border-radius: 14px; background: var(--bg-card, #131c2e); overflow: hidden; contain: layout paint; }
++.vbk .vbc-phone > summary { display: flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 14px; cursor: pointer; font-size: 14px; font-weight: 650; list-style: none; color: var(--text-primary, #e6edf7); }
++.vbk .vbc-phone > summary::-webkit-details-marker { display: none; }
++.vbk .vbc-phone > summary::marker { content: ''; }
++.vbk .vbc-phone .vbc-updated { flex: none; font-size: 12.5px; font-weight: 650; color: var(--ovt-accent, #60a5fa); }
++.vbk .vbc-phone .vbc-chev { margin-left: auto; color: var(--text-muted, #7386a3); transition: transform .2s; }
++.vbk .vbc-phone[open] .vbc-chev { transform: rotate(180deg); }
++.vbk .vbc-phone .vbc-body { max-height: min(45vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 0 10px 10px; }
++@media (min-width: 1180px) { .vbk .vbc-phone { display: none !important; } }
++@media (max-width: 1179px) { .vbk .vbc-wide { display: none !important; } }
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex 6bb085c..4e3a250 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -341,6 +341,141 @@
if (stick) toBottom();
}
++ // ── The canvas (plan HO9): the agent's data panel beside the chat ─────────────────────────────────────────────
++ // Only the newest task in the chat, and only when OpenVibe.Actor holds it (the task's actor_task is the tsk_…
++ // thread). The document is read in the browser with the person's own Network token — the Actor API allows that one
++ // read from openvibe.chat and refuses cookies — and drawn by openvibe-shared's ov-canvas.js. It is read-only here:
++ // checklist boxes disabled, buttons not drawn, and one link to the Actor thread where the person can tick or press.
++ // The wide third column and the closed fold above the composer share the same document, as on Actor.
++ var ACTOR = 'https://openvibe.actor';
++ var CANVAS_THREAD = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
++ var canvas = cfg.canvas && CANVAS_THREAD.test(String(cfg.canvas.thread || '')) ? cfg.canvas : null;
++ var canvasVersion = 0, canvasTimer = 0, canvasBusy = false, canvasUpdatedTimer = 0;
++ function canvasRoot() { return app.querySelector('.ovt[data-ovt]'); }
++ function canvasIsOpen() { return !!(canvas && (canvas.status === 'queued' || canvas.status === 'running')); }
++ function canvasItems(n) { return n + (n === 1 ? ' item' : ' items'); }
++ function canvasCountSpan() { var span = el('span', 'vbc-count'); span.setAttribute('data-vb-canvas-count', ''); return span; }
++ function canvasLink() {
++ var a = el('a', 'vbc-link', 'Open in Actor to tick or press');
++ a.href = ACTOR + '/chat/' + encodeURIComponent(canvas.thread);
++ a.target = '_blank'; a.rel = 'noopener';
++ return a;
++ }
++ function canvasBody() {
++ var body = el('div', 'vbc-body');
++ var host = el('div', 'vbc-host'); host.setAttribute('data-vb-canvas-host', '');
++ body.appendChild(host); body.appendChild(canvasLink());
++ return body;
++ }
++ function canvasWide() {
++ var root = canvasRoot(); if (!root || !canvas) return null;
++ var box = root.querySelector('.vbc-wide');
++ if (box) return box;
++ box = el('aside', 'ovt-panel vbc vbc-wide');
++ box.setAttribute('aria-label', 'Canvas'); box.setAttribute('data-vb-canvas', ''); box.setAttribute('data-thread', canvas.thread);
++ var head = el('div', 'ovt-panel-head');
++ head.appendChild(el('h2', null, 'Canvas')); head.appendChild(canvasCountSpan());
++ box.appendChild(head); box.appendChild(canvasBody());
++ root.appendChild(box); root.classList.add('has-panel');
++ return box;
++ }
++ function canvasPhone() {
++ var root = canvasRoot(); if (!root || !canvas) return null;
++ var box = root.querySelector('.vbc-phone');
++ if (box) return box;
++ var dock = root.querySelector('.ovt-dock'); if (!dock) return null;
++ box = el('details', 'vbc vbc-phone');
++ box.setAttribute('data-vb-canvas', ''); box.setAttribute('data-thread', canvas.thread);
++ var sum = el('summary');
++ sum.appendChild(el('span', 'vbc-label', 'Canvas'));
++ var sep = el('span', 'vbc-sep', '·'); sep.setAttribute('aria-hidden', 'true'); sum.appendChild(sep);
++ sum.appendChild(canvasCountSpan());
++ var up = el('span', 'vbc-updated', 'updated');
++ up.setAttribute('data-vb-canvas-updated', ''); up.setAttribute('aria-live', 'polite'); up.hidden = true; sum.appendChild(up);
++ var chev = el('span', 'vbc-chev', '▾'); chev.setAttribute('aria-hidden', 'true'); sum.appendChild(chev);
++ box.appendChild(sum); box.appendChild(canvasBody());
++ dock.insertBefore(box, dock.firstChild);
++ // Closed first; the person's choice lasts for this visit, as on Actor.
++ var key = 'ov-canvas-open:' + canvas.thread;
++ try { if (window.sessionStorage && sessionStorage.getItem(key) === '1') box.open = true; } catch (e) { /* private mode */ }
++ box.addEventListener('toggle', function () {
++ try { if (window.sessionStorage) sessionStorage.setItem(key, box.open ? '1' : '0'); } catch (e) { /* private mode */ }
++ });
++ return box;
++ }
++ function canvasPaint(doc) {
++ if (!canvas || !doc || !window.OVCanvas) return;
++ // A canvas never acts here: the buttons live on the Actor thread the link opens. The checklist draws disabled
++ // because no onCheck is given, which is the renderer's own read-only mode.
++ var shown = {
++ canvas: doc.canvas, version: doc.version, title: doc.title, updated_at: doc.updated_at,
++ blocks: (Array.isArray(doc.blocks) ? doc.blocks : []).filter(function (b) { return b && b.type !== 'button'; }),
++ };
++ if (!window.OVCanvas.valid(shown)) return;
++ [canvasWide(), canvasPhone()].forEach(function (box) {
++ var host = box && box.querySelector('[data-vb-canvas-host]');
++ if (host) window.OVCanvas.render(host, shown, {});
++ });
++ [].forEach.call(app.querySelectorAll('[data-vb-canvas-count]'), function (node) { node.textContent = canvasItems(shown.blocks.length); });
++ canvasVersion = Number(doc.version) || canvasVersion;
++ }
++ function canvasClear() {
++ [].forEach.call(app.querySelectorAll('[data-vb-canvas]'), function (box) { box.remove(); });
++ var root = canvasRoot(); if (root) root.classList.remove('has-panel');
++ canvasVersion = 0;
++ }
++ function canvasUpdated() {
++ var node = app.querySelector('.vbc-phone [data-vb-canvas-updated]');
++ if (!node) return;
++ node.hidden = false;
++ if (canvasUpdatedTimer) clearTimeout(canvasUpdatedTimer);
++ canvasUpdatedTimer = setTimeout(function () { node.hidden = true; canvasUpdatedTimer = 0; }, 4000);
++ }
++ function canvasSchedule() {
++ if (canvasTimer) { clearTimeout(canvasTimer); canvasTimer = 0; }
++ if (!canvasIsOpen()) return;
++ canvasTimer = setTimeout(function () { canvasTimer = 0; canvasFetch(); }, 30000);
++ }
++ function canvasFetch() {
++ if (!canvas || !cfg.signedIn || canvasBusy || !CANVAS_THREAD.test(String(canvas.thread || ''))) return;
++ var tok = token(); if (!tok) return;
++ canvasBusy = true;
++ fetch(ACTOR + '/api/v1/me/canvases?thread=' + encodeURIComponent(canvas.thread), {
++ // The person's own Bearer, no credentials mode: Actor refuses a cookie from another site.
++ credentials: 'omit', headers: { authorization: 'Bearer ' + tok, accept: 'application/json' },
++ }).then(function (r) {
++ if (r.status === 401 || r.status === 403) return { stop: true };
++ return r.ok ? r.json() : null;
++ }).then(function (j) {
++ if (j && j.stop) { canvas = null; canvasClear(); return; }
++ var doc = j && Array.isArray(j.canvases) ? j.canvases[0] : null;
++ if (!doc) { canvasClear(); return; }
++ if (canvasVersion && Number(doc.version) > canvasVersion) canvasUpdated();
++ canvasPaint(doc);
++ }).catch(function () { /* offline: the panel stays as it was, and asks again while the task runs */ })
++ .then(function () { canvasBusy = false; canvasSchedule(); });
++ }
++ function canvasRefreshNow() {
++ if (canvasTimer) { clearTimeout(canvasTimer); canvasTimer = 0; }
++ canvasFetch();
++ }
++ function canvasWatch(task) {
++ var thread = task && task.actor_task ? String(task.actor_task) : '';
++ if (!CANVAS_THREAD.test(thread)) return false;
++ canvas = { thread: thread, task: task.id, status: task.status };
++ canvasClear();
++ canvasRefreshNow();
++ return true;
++ }
++ function canvasTaskStatus(task) {
++ if (!canvas || !task || Number(task.id) !== Number(canvas.task)) return;
++ var status = String(task.status || canvas.status);
++ if (status === canvas.status) return;
++ canvas.status = status;
++ canvasRefreshNow();
++ }
++ function canvasInit() { if (canvas) canvasFetch(); }
++
// ── Live ──
function catchUp() {
if (!room) return Promise.resolve();
@@@ -365,6 +500,7 @@
else if (m.type === 'room_ai_typing' && m.room === room) { showWait(); if (nearBottom()) toBottom(); }
else if (m.type === 'room_ai_delta' && m.room === room) streamText(m.text);
else if (m.type === 'room_message_deleted' && m.room === room) removeMessage(m.id);
++ else if (m.type === 'room_task' && m.room === room) canvasTaskStatus(m.task);
else if (m.type === 'auth_revoked') notice('You were signed out. Sign in again to keep chatting.');
};
ws.onclose = function () { ws = null; if (room) { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); } };
@@@ -461,6 +597,8 @@
.then(function (x) {
if (!x.ok) { putBack((x.d && x.d.error) || 'That did not send. Try again.'); return; }
if (x.d.message && x.d.message.id) { mine.setAttribute('data-me', String(x.d.message.id)); latest = Math.max(latest, Number(x.d.message.id)); }
++ // A Research, Agent or Code task just became the chat's newest: its canvas is the one to draw from now on.
++ if (x.d && x.d.task && !canvasWatch(x.d.task)) { canvas = null; canvasClear(); }
if (!room && x.d.room) {
room = x.d.room.slug;
history.replaceState(null, '', '/vibe/' + room);
@@@ -491,6 +629,7 @@
.catch(function () { notice('You seem to be offline.'); });
});
if (cfg.waiting) showWait();
++ canvasInit();
connect();
window.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });
})();
ddiff --git a/server/rooms/workspace.js b/server/rooms/workspace.js
iindex 1440889..2f5f625 100644
---- a/server/rooms/workspace.js
++++ b/server/rooms/workspace.js
@@@ -30,6 +30,7 @@ const LIMITS = { title: 200, details: 6000, result: 20000, error: 500, url: 2000
const PER_PERSON_HOUR = 20;
const PER_ROOM_DAY = 300;
const POLL_MS = 10_000;
++const ACTOR_TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
class WorkError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
const fail = (status, code, message) => { throw new WorkError(status, code, message); };
@@@ -44,7 +45,7 @@ function shape(t, room) {
try { sources = JSON.parse(t.sources || '[]'); } catch { sources = []; }
return {
id: Number(t.id), title: t.title, details: t.details || '', kind: t.kind, status: t.status, via: t.via,
-- quick: QUICK.has(t.kind),
++ quick: QUICK.has(t.kind), actor_task: t.actor_task || null,
requested_by: { id: Number(t.requested_by), username: t.requester_username || null, name: t.requester_name || t.requester_username || 'someone' },
runner: t.runner || null, result: t.result || null, result_url: t.result_url || null, sources: Array.isArray(sources) ? sources : [],
error: t.error || null, cost_usd: t.cost_usd == null ? null : Number(t.cost_usd), how: t.how || null,
@@@ -83,6 +84,18 @@ async function get(room, user, id) {
return shape(t, room);
}
++/**
++ * The newest task in a chat when OpenVibe.Actor holds it: its thread is the conversation whose canvas Chat draws
++ * beside a Vibe chat (plan HO9). A newer task a worker took, or one Actor refused, has no thread, so there is nothing
++ * to draw beside it even when an older Actor task had a canvas.
++ */
++async function newestActor(room, user) {
++ await mustRead(room, user);
++ const t = await db.get(`${TASK_SELECT} WHERE t.room_id = ? ORDER BY t.id DESC LIMIT 1`, [room.id]);
++ if (!t || !t.actor_task || !ACTOR_TASK.test(String(t.actor_task))) return null;
++ return { id: Number(t.id), thread: String(t.actor_task), status: t.status };
++}
++
/** The agent's step for this task (design 2026-11-07): a title, an outcome and a link, never the person's words. */
async function announceStep(room, task, { topic = null, status = null, ...rest } = {}) {
if (!task) return null;
@@@ -369,4 +382,4 @@ function stop() { if (timer) clearInterval(timer); timer = null; }
function _setTokens(t) { tokens = t; }
--module.exports = { KINDS, QUICK, WorkError, list, get, add, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };
++module.exports = { KINDS, QUICK, WorkError, list, get, newestActor, add, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 738fe9c..70b2e9b 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -89,7 +89,9 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {
"font-src 'self' data:",
// https://openvibe.chat: on Chat's other host (openvibe.party) the shared navbar's Messages count and the Dock
// still call Chat's DM API there (openvibe.party is on the credentialed CORS list, server/app.js).
-- `connect-src 'self' ${wsOrigin} https://openvibe.chat https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com`,
++ // https://openvibe.actor: a Vibe chat draws the agent's canvas beside it (plan HO9), only the two canvas
++ // GETs and only with the person's own Bearer (the Actor API's CORS route).
++ `connect-src 'self' ${wsOrigin} https://openvibe.chat https://openvibe.actor https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com`,
// A workspace room's shared screen: a screen share page from OpenVibe.Bot (rooms.screen_url).
"frame-src 'self' https://openvibe.bot",
"frame-ancestors 'self'",
@@@ -106,7 +108,8 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {
* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call
* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:
* vibe.css + vibe-md.js + vibe.js, and no footer), kit (the network's conversation UI, openvibe-shared/thread: kit.css,
-- * thread.css, kit-web.js and ov-thread.js; Vibe's pages and the support inbox), appScript and appStyle (the app's own
++ * thread.css, kit-web.js and ov-thread.js; Vibe's pages and the support inbox), canvas (openvibe-shared's ov-canvas.js
++ * on Vibe's chat pages: the agent's panel beside the chat, plan HO9), appScript and appStyle (the app's own
* script and stylesheet instead of vibe.js and vibe.css: inbox.js and inbox.css), feedView
* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of
* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;
@@@ -168,6 +171,7 @@ ${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
${o.kit ? `<script src="${ovServe.url('kit-web.js')}" defer></script>\n<script src="${ovServe.url('ov-thread.js')}" defer></script>` : ''}
++${o.canvas ? `<script src="${ovServe.url('ov-canvas.js')}" defer></script>` : ''}
${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${asset(o.appScript || 'vibe.js')}" defer></script>` : ''}
</head>
<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex 45e0b7c..a90b41d 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -20,6 +20,7 @@ const md = require('../../public/web/vibe-md.js');
const pictures = require('../rooms/pictures');
const rooms = require('../rooms/rooms');
const vibe = require('../rooms/vibe');
++const workspace = require('../rooms/workspace');
const how = require('../rooms/how');
const memory = require('../rooms/vibe-memory');
const { esc } = require('./layout');
@@@ -254,6 +255,11 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,
const allowance = user ? await allowanceOf(user) : null;
const messages = room ? await rooms.history(room, { limit: 200 }) : [];
const hows = room ? await how.forRoom(room) : new Map(); // "How Vibe did this" under agent tasks' answers
++ // The agent's canvas beside the chat (plan HO9): only when the newest task in the chat is Actor's, because
++ // that task's thread is the conversation the panel belongs to. The browser reads the document itself with
++ // the person's own token (OpenVibe.Actor allows that one read from openvibe.chat), so a signed-out or refused
++ // read simply draws nothing.
++ const canvas = room && user ? await workspace.newestActor(room, user) : null;
const enabled = vibe.enabled();
const title = room ? room.name : 'Vibe';
const waiting = room && messages.length && !messages[messages.length - 1].ai_member_id;
@@@ -283,7 +289,7 @@ ${MARK}
center: !room, label: room ? 'Your chat with Vibe' : 'A new chat with Vibe',
})}</div>`;
await page(req, res, status, {
-- actor, app: true, kit: true, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',
++ actor, app: true, kit: true, canvas: !!room, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',
title: room ? `${room.name} · Vibe` : 'Vibe, OpenVibe\'s assistant',
fullTitle: room ? undefined : 'Vibe: chat with OpenVibe\'s assistant · OpenVibe.Chat',
description: 'Chat with Vibe, OpenVibe\'s free assistant: it searches news, deals, jobs, rentals and videos across OpenVibe, reads pages you link and helps you think.',
@@@ -292,6 +298,7 @@ ${MARK}
page: {
view: 'vibe', room: room ? room.slug : null, me: user ? user.id : null, latest: messages.length ? messages[messages.length - 1].id : 0,
waiting: !!waiting, enabled, signedIn: !!user,
++ canvas: canvas ? { thread: canvas.thread, task: canvas.id, status: canvas.status } : null,
},
});
}
ddiff --git a/test/vibe-canvas.test.js b/test/vibe-canvas.test.js
nnew file mode 100644
iindex 0000000..b4c89e2
---- /dev/null
++++ b/test/vibe-canvas.test.js
@@@ -0,0 +1,73 @@
++'use strict';
++/**
++ * The agent's canvas beside a Vibe chat (plan HO9): the page names the newest Actor task's thread and loads
++ * openvibe-shared's ov-canvas.js only on Vibe's chat pages; the browser reads the document with the person's own
++ * token, and a newer non-Actor task (or no task) means there is nothing to draw. The client itself is read-only:
++ * it asks OVCanvas for a drawn canvas without onCheck/onAsk, and filters buttons out before drawing.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('vibe canvas');
++const TASK = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AB';
++let h, ann, workspace, room, slug, task;
++
++const pageJson = (html) => JSON.parse((/window\.__OV_PAGE = (\{.*?\});\n/.exec(html) || [])[1].replace(/\\u003c/g, '<')).chat;
++
++t('boot: a Vibe chat whose newest task is Actor\'s', async () => {
++ h = await boot();
++ workspace = require('../server/rooms/workspace');
++ workspace._setTokens({ getToken: async () => 'chat-actor-token' });
++ ann = h.addUser('mira', { subject: ids.newId('user') });
++ await h.ctx.upsertUser(h.live.users.get(ann.id));
++ const realFetch = globalThis.fetch;
++ globalThis.fetch = async (url, o = {}) => {
++ if (!String(url).startsWith('http://127.0.0.1:4950')) return realFetch(url, o);
++ return { ok: true, status: (o.method || 'GET') === 'POST' ? 201 : 200, json: async () => ({ id: TASK, state: 'running' }) };
++ };
++ const r = await h.http('POST', '/api/chat/vibe', { token: ann.token, body: { message: 'Plan three days in Lisbon for two', mode: 'research' } });
++ assert.strictEqual(r.status, 201, r.text);
++ slug = r.body.room.slug;
++ task = r.body.task;
++ assert.strictEqual(task.actor_task, TASK);
++ room = await require('../server/rooms/rooms').bySlug(slug);
++});
++
++t('the Vibe page names the Actor thread, loads ov-canvas.js, and allows the Actor origin', async () => {
++ const p = await h.http('GET', `/vibe/${slug}`, { headers: { cookie: `ov_token=${ann.token}` } });
++ assert.strictEqual(p.status, 200, p.text.slice(0, 300));
++ const cfg = pageJson(p.text);
++ assert.deepStrictEqual(cfg.canvas, { thread: TASK, task: task.id, status: 'running' });
++ assert.match(p.text, /shared\/ov-canvas\.js/, 'the shared renderer is on the page');
++ assert.match(String(p.headers.get('content-security-policy') || ''), /connect-src[^;]*https:\/\/openvibe\.actor/, 'the browser may read the canvas from Actor');
++
++ const home = await h.http('GET', '/', { headers: { cookie: `ov_token=${ann.token}` } });
++ assert.strictEqual(home.status, 200);
++ assert.ok(!/shared\/ov-canvas\.js/.test(home.text), 'ov-canvas.js is only on Vibe pages');
++});
++
++t('a newer task that is not Actor\'s leaves nothing to draw', async () => {
++ const user = h.live.users.get(ann.id);
++ await h.db.getDb().prepare(`INSERT INTO room_tasks (room_id, title, details, kind, requested_by, requested_subject, via, status)
++ VALUES (?, ?, '', 'code', ?, ?, 'person', 'queued')`).run(room.id, 'A task a person takes', ann.id, user.subject_id);
++ const newest = await workspace.newestActor(room, user);
++ assert.strictEqual(newest, null, 'the newest task has no Actor thread');
++ const p = await h.http('GET', `/vibe/${slug}`, { headers: { cookie: `ov_token=${ann.token}` } });
++ assert.strictEqual(p.status, 200);
++ assert.strictEqual(pageJson(p.text).canvas, null);
++ assert.match(p.text, /shared\/ov-canvas\.js/, 'the Vibe chat page still has the renderer for the next task');
++});
++
++t('the client draws it read-only and refreshes only while the task runs', async () => {
++ const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'web', 'vibe.js'), 'utf8');
++ assert.match(src, /Open in Actor to tick or press/);
++ assert.match(src, /ACTOR \+ '\/chat\/' \+ encodeURIComponent\(canvas\.thread\)/);
++ assert.match(src, /filter\(function \(b\) \{ return b && b\.type !== 'button'; \}\)/, 'buttons are not drawn');
++ assert.match(src, /window\.OVCanvas\.render\(host, shown, \{\}\)/, 'no onCheck or onAsk: the canvas only shows');
++ assert.match(src, /credentials: 'omit'/, 'the person\'s Bearer, no cookies');
++ assert.match(src, /\}, 30000\);/, 'at most every 30 s while the task runs');
++ assert.match(src, /m\.type === 'room_task'[\s\S]{0,120}canvasTaskStatus/, 'a task status change refreshes it');
++});
++
++t.run();