openvibers/openvibe.extensions Commits

0.8.1: the side panel opens on the page's Dock state, and follows it (#13)

OP OpenVibers committed 7 days ago · 5124cbd90b · 6 files, +74 −18

The panel already read the Network's Dock state only to check the sign-in, then let the Dock open on its default tab; its own first read raced the state back. It now hands that state to the Dock as it opens (the same tab, chat, agent and unread counts as the page) and re-reads it every 30 s and when the browser shows the panel again, so a change on the page reaches the open side panel. The panel test covers the seeded state and the follow-up read.

Changes

ddiff --git a/README.md b/README.md
iindex 04db140..f8ad924 100644
---- a/README.md
++++ b/README.md
@@@ -110,7 +110,8 @@ The **OpenVibe Dock** — the pill, the card with its Agents, Chats and Activity
browser's side panel (Chrome's `side_panel`, Firefox's `sidebar_action`), on every site. It is the
released OpenVibe.Shared Dock, bundled under `browser/shared/` because the extension's CSP allows no
remote script, and it reads and writes the same state on the Network as the Dock on a site, so it
--opens where the person left off.
++opens where the person left off. It hands that state to the Dock as it opens (the page's tab, chat, agent and
++unread counts) and re-reads it every 30 s, so a change on the page reaches the open side panel.
It needs the person's own OpenVibe sign-in. Press **Sign in** in the panel or the settings page: the
Network's account chooser opens, the extension is a public OAuth client (`companion`, PKCE S256, no
ddiff --git a/STATUS.json b/STATUS.json
iindex 732b60f..e01ea90 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,11 +5,11 @@
"wave": "W18",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"adr": "OpenVibe.Contracts ADR-023 (first-party client surfaces)",
-- "version": "0.8.0",
++ "version": "0.8.1",
"surfaces": {
"browser": {
"name": "OpenVibe Companion",
-- "version": "0.8.0",
++ "version": "0.8.1",
"manifest": 3,
"targets": [
"chromium >= 114",
@@@ -43,7 +43,7 @@
"storeListing": "store/ (listing, privacy and permission answers, screenshot plan); support URL https://openvibe.coupons/companion",
"features": [
"OpenVibe Dock one-click sign-in: public OAuth client companion (PKCE S256, no secret), read-only scopes dock/activity/chat.read, access token in storage.session, rotating refresh token in storage.local, Sign out revokes the family, the account page lists it under Signed-in apps",
-- "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity); approvals/sends are hidden with the read-only scopes"
++ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity), and re-reading it every 30 s so the page, the pop-out and the panel agree; approvals/sends are hidden with the read-only scopes"
],
"pending": [
"owner: create the Chrome Web Store and AMO developer listings and upload the store zips with store/'s answers and screenshots; the Chrome id is fixed by browser/manifest.json key",
@@@ -64,10 +64,10 @@
"watches a page on OpenVibe.Watch, and compares delivery apps on OpenVibe.Food",
"saves a review site's stated rating to OpenVibe.Reviews (read only when pressed, only on those sites)",
"an optional cpx_ install token, never an OpenVibe session; and the Dock one-click OAuth sign-in (scopes dock, activity, chat.read; access in storage.session, rotating refresh in storage.local; the 0.7.0 pasted token is still a fallback until a later release)",
-- "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity); approvals/sends are hidden with the read-only scopes",
++ "the OpenVibe Dock in the side panel (Chrome side_panel, Firefox sidebar_action): the released OpenVibe.Shared Dock (v4.57.0) bundled under browser/shared/, reading and writing the same Network state as the Dock on a site, with the same tabs (Agents, Chats, Activity), and re-reading it every 30 s so the page, the pop-out and the panel agree; approvals/sends are hidden with the read-only scopes",
"store-ready: reproducible Chrome and Firefox store zips (npm run pack:stores), listing and privacy answers in store/, Mozilla web-ext lint in CI",
"kiosk companion (Firefox new-tab page and history bridge for Live's /kiosk), moved from OpenVibe.Live",
"hardware/: the Raspberry Pi streamer and controller scripts, moved from OpenVibe.Live"
],
-- "updated": "2026-10-30"
++ "updated": "2026-11-06"
}
ddiff --git a/browser/manifest.json b/browser/manifest.json
iindex 672f664..dd0918c 100644
---- a/browser/manifest.json
++++ b/browser/manifest.json
@@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "OpenVibe Companion",
"short_name": "OpenVibe",
-- "version": "0.8.0",
++ "version": "0.8.1",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB",
"description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; a review site's rating saved to OpenVibe.Reviews; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. The OpenVibe Dock sits in the side panel, on every site. Reads the tab only when you open it.",
"homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
ddiff --git a/browser/panel.js b/browser/panel.js
iindex 06d8e28..0367466 100644
---- a/browser/panel.js
++++ b/browser/panel.js
@@@ -24,6 +24,7 @@
const gateTitle = doc.getElementById('gate-title');
const gateText = doc.getElementById('gate-text');
const gateButton = doc.getElementById('gate-button');
++ let current = null, mounted = false;
function showGate(title, text, button) {
gateTitle.textContent = title;
@@@ -54,7 +55,7 @@
return { mounted: false, reason: 'no-token' };
}
try {
-- await opts.net.dock();
++ current = await opts.net.dock();
} catch (err) {
showGate('OpenVibe.Network said no',
'That sign-in was not accepted. Sign in to OpenVibe.Network again.',
@@@ -71,11 +72,25 @@
chatBase: opts.config.CHAT_ORIGIN,
sibling: opts.sibling,
});
-- opts.dock.open();
++ // Hand the state we just read to the Dock as it opens, so its own first read cannot race it back to the
++ // default tab. The page and the side panel then open on the same tab, chat and agent.
++ opts.dock.open({ doc: current });
++ mounted = true;
gate.hidden = true;
return { mounted: true, reason: null, mode: status.mode };
}
++ /** Re-read the Network's Dock state and hand it to the open Dock in place (the panel follows the page). */
++ async function sync() {
++ if (!mounted) return null;
++ try {
++ const next = await opts.net.dock();
++ current = next;
++ if (opts.dock.update) opts.dock.update(next);
++ return next;
++ } catch { return null; }
++ }
++
async function signIn() {
if (!opts.auth) return;
gateButton.disabled = true;
@@@ -95,7 +110,7 @@
if (gateButton && typeof gateButton.addEventListener === 'function') {
gateButton.addEventListener('click', function () { signIn(); });
}
-- return { start: start, signIn: signIn, showGate: showGate, messageFor: messageFor };
++ return { start: start, sync: sync, signIn: signIn, showGate: showGate, messageFor: messageFor };
}
/** The page wiring: the real browser APIs and the real Dock. */
@@@ -121,6 +136,13 @@
openSettings: function () { ext.runtime.openOptionsPage(); },
});
panel.start();
++ // The side panel stays open while the person moves between tabs and sites: re-read the Network's Dock state
++ // again every 30 s, and when the browser shows the panel again, so the page, the pop-out and the panel agree.
++ const syncT = setInterval(function () { panel.sync(); }, 30000);
++ if (syncT && typeof syncT.unref === 'function') syncT.unref();
++ root.document.addEventListener('visibilitychange', function () {
++ if (!root.document.hidden) panel.sync();
++ });
return panel;
}
ddiff --git a/package.json b/package.json
iindex e644984..bc80b67 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-extensions",
-- "version": "0.8.0",
++ "version": "0.8.1",
"private": true,
"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"scripts": {
ddiff --git a/test/panel.test.js b/test/panel.test.js
iindex 3b82f74..2d21259 100644
---- a/test/panel.test.js
++++ b/test/panel.test.js
@@@ -21,24 +21,31 @@ function fakeDoc() {
return { nodes, getElementById: (id) => nodes[id] || null };
}
--/** A stand-in for the Network's GET /api/v1/me/dock. */
--async function startNetwork({ accept = TOKEN } = {}) {
++/** A stand-in for the Network's GET /api/v1/me/dock. `setState` changes what the next GET answers. */
++async function startNetwork({ accept = TOKEN, state, counts = { running: 1, waiting: 0, unread: 0 } } = {}) {
const requests = [];
++ let answer = {
++ state: state || { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: new Date().toISOString() },
++ counts,
++ };
const server = http.createServer((req, res) => {
requests.push({ url: req.url, auth: req.headers.authorization || null });
const ok = req.headers.authorization === 'Bearer ' + accept;
res.writeHead(ok ? 200 : 401, { 'content-type': 'application/json' });
-- res.end(JSON.stringify(ok
-- ? { state: { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } }, updated_at: new Date().toISOString() }, counts: { running: 1, waiting: 0, unread: 0 } }
-- : { error: 'unauthorized' }));
++ res.end(JSON.stringify(ok ? answer : { error: 'unauthorized' }));
});
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
const origin = 'http://127.0.0.1:' + server.address().port;
-- return { origin, requests, close: () => server.close() };
++ return { origin, requests, state: () => answer, setState: (next) => { answer = next; }, close: () => server.close() };
}
function fakeDock() {
-- const dock = { mounted: null, opened: 0, mount(opts) { dock.mounted = opts; }, open() { dock.opened++; } };
++ const dock = {
++ mounted: null, opened: 0, openedWith: null, updated: [],
++ mount(opts) { dock.mounted = opts; },
++ open(opts) { dock.opened++; dock.openedWith = opts || null; },
++ update(next) { dock.updated.push(next); },
++ };
return dock;
}
@@@ -74,6 +81,32 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
assert.strictEqual(dock.mounted.sibling('dock.js'), 'chrome-extension://abc/shared/dock.js', 'the bundled Dock, from this extension');
assert.strictEqual(doc.nodes.gate.hidden, true, 'the gate steps aside');
assert.ok(net1.requests.some((r) => r.url === '/api/v1/me/dock' && r.auth === 'Bearer ' + TOKEN), 'the Network is asked with that sign-in');
++ assert.deepStrictEqual(dock.openedWith.doc, net1.state(), 'the Dock opens on the state the Network keeps, before its own read could race it');
++ assert.ok(!('tab' in dock.openedWith), 'the Dock itself chooses the tab from that state and what needs the person');
++ });
++
++ await check('the panel follows the state the Network keeps', async () => {
++ const first = {
++ state: { open: true, tab: 'chats', pinned: true, agent: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ', chat: '11', place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'top-left', width: 500, height: 600 } }, updated_at: new Date().toISOString() },
++ counts: { running: 0, waiting: 0, unread: 3 },
++ };
++ const net2 = await startNetwork({ state: first.state, counts: first.counts });
++ try {
++ const doc = fakeDoc();
++ const dock = fakeDock();
++ const net = createNetworkClient({ origin: net2.origin, fetch: (...a) => fetch(...a), getToken: async () => TOKEN });
++ const panel = createPanel({ doc, config: { NETWORK_ORIGIN: 'https://openvibe.network', CHAT_ORIGIN: 'https://openvibe.chat' }, platform: { storage: { getNetworkToken: async () => null } }, auth: fakeAuth(), net, dock });
++ await panel.start();
++ assert.strictEqual(dock.openedWith.doc.state.tab, 'chats', 'the side panel opens on the page\'s tab');
++ assert.deepStrictEqual(dock.openedWith.doc.counts, first.counts, 'and the unread counts the page sees');
++ // The page changes the tab and an agent: the next read carries it to the open side panel.
++ const next = { ...first, state: { ...first.state, tab: 'agent' }, counts: { running: 1, waiting: 0, unread: 3 } };
++ net2.setState(next);
++ assert.deepStrictEqual(await panel.sync(), next, 'the panel reads the same Network state again');
++ assert.deepStrictEqual(dock.updated.at(-1), next, 'and hands it to the Dock in place, without a reload');
++ } finally {
++ net2.close();
++ }
});
await check('without a sign-in the panel says what is missing', async () => {