openvibers/openvibe.shared Commits

4.198.0: on a phone the menu is one sheet; Messages and AI open inside it (#432)

OP OpenVibers committed 2 days ago · c83eb3bde9 · 19 files, +360 −80

* Mount Dock panels inside a host sheet

* Merge main (4.197.0) into the menu's one sheet

* The agent view: an ended session's one button sits beside its two lines

* 4.198.0: on a phone the menu is one sheet; Messages and AI open inside it

Messages and AI in the row of places open the Dock's panels inside the
menu, under its own head (OpenVibeDock.mountInto), and AI carries a
Vibe · Agents switch. Feed, typing or closing hand the panel back. On a
computer they still open the floating Dock.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex d20b656..178cf6d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,26 @@
# Changelog
++## 4.198.0 — 2026-11-10
++
++**On a phone the menu is one sheet: Messages and AI open inside it.** From the owner's notes of 2026-11-10, with the menu
++and the Dock side by side: "combine and consolidate these two menus into one unified menu … easier to search between",
++and a phone picture of the Dock's own head over the menu ("Menu", "…", close, then four tabs: two rows of chrome).
++
++- **The menu (`search.js`).** On a phone, Messages and AI in the row of places no longer close the menu to open the
++ Dock's second sheet. They open inside the menu, under its own head, and become the chosen place like Feed or You. AI
++ carries a small two-part switch at its top: Vibe · Agents (your Actor is the top of Agents). Feed, Here or You, typing
++ in the field, or closing the menu hand the Dock's panel back with its state. On a computer the two places still open
++ the floating Dock, which stays beside the work.
++- **The Dock (`dock.js`, new `dock-mount.js`).** `OpenVibeDock.mountInto(host, { tab })` → `{ show(tab), destroy() }`
++ shows one of the Dock's panels (chats, vibe, agents, activity) inside another sheet, with no head of its own: the same
++ panel body, state, counts, token and loaders as the floating Dock, which closes while a panel is lent. The layout
++ loads on first use (`dock-mount.js`), so `dock.js` stays inside its budget. Six mount and destroy laps leave no nodes or
++ listeners behind (`test/dock-mount-chrome.test.js`).
++- **The agent view (`dock-agent.js`).** An ended session is two lines of words with its one button beside them (it sat
++ alone on a third row), and the agent view's buttons are 44 px under a finger.
++- The bar's touch-feedback comment, which ships inside its styles, is one line now: the bar is back under its size
++ budget with room for the next hashes.
++
## 4.197.0 — 2026-11-10
**The footer says "3 online", and the tooltip part works anywhere.** From the owner's notes of 2026-11-10: "Make it like
ddiff --git a/README.md b/README.md
iindex b051dc6..3dcc69d 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.197.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.198.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 4aa2bc1..0d3b839 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.197.0",
++ "version": "4.198.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-actor.js b/dock-actor.js
iindex 0de1ada..3df031a 100644
---- a/dock-actor.js
++++ b/dock-actor.js
@@@ -347,8 +347,8 @@
async function refresh() {
if (gone) return;
if (o.active && !o.active()) return;
-- try { paint(await api('')); }
-- catch (err) { if (err.status === 401 || err.status === 403 || err.status === 404) paint(null); }
++ try { const data = await api(''); if (!gone) paint(data); }
++ catch (err) { if (!gone && (err.status === 401 || err.status === 403 || err.status === 404)) paint(null); }
}
refresh();
timer = setInterval(refresh, POLL_MS);
ddiff --git a/dock-agent.js b/dock-agent.js
iindex 377e5e0..ca8ad5e 100644
---- a/dock-agent.js
++++ b/dock-agent.js
@@@ -66,6 +66,12 @@
.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)); }
++ /* A session that ended is two lines of words with its one way out beside them, not a button on a row of its own. */
++ .ovda.is-ended .ovda-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 8px; align-items: center; padding: 2px 4px 6px; }
++ .ovda.is-ended .ovda-line, .ovda.is-ended .ovda-meta { grid-column: 1; }
++ .ovda.is-ended .ovda-acts { grid-column: 2; grid-row: 1 / span 2; margin: 0; }
++ .ovda.is-ended .ovda-acts:empty { display: none; }
++ @media (pointer: coarse) { .ovda-acts button, .ovda-acts a { min-height: 44px; min-width: 44px; justify-content: center; } }
.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; } }
`;
@@@ -93,7 +99,7 @@
const line = h('div', 'ovda-line');
const meta = h('div', 'ovda-meta');
const acts = h('div', 'ovda-acts');
-- const head = h('div');
++ const head = h('div', 'ovda-head');
head.append(line, meta, acts);
const body = h('div');
body.append(stage, head);
@@@ -141,6 +147,7 @@
const live = agent.state === 'live';
const ended = agent.state === 'ended';
++ container.classList.toggle('is-ended', 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');
ddiff --git a/dock-mount.js b/dock-mount.js
nnew file mode 100644
iindex 0000000..f2a4dda
---- /dev/null
++++ b/dock-mount.js
@@@ -0,0 +1,27 @@
++// The Dock body inside a host sheet. Loaded only when mountInto is first used.
++(function (root) {
++ 'use strict';
++ function mount(host, first, bridge) {
++ if (!document.getElementById('ovdk-mount-styles')) {
++ const style = document.createElement('style');
++ style.id = 'ovdk-mount-styles';
++ style.textContent = `
++ .ovdk.ovdk-inline, .ovdk.ovdk-inline[data-corner] { position: relative !important; inset: auto !important; z-index: auto !important; width: 100% !important; height: 100% !important; max-width: 100% !important; max-height: none !important; min-height: 0; padding: 0 !important; border: 0; border-radius: 0; box-shadow: none !important; transform: none !important; animation: none !important; }
++ .ovdk-inline .ovdk-body { width: 100%; min-width: 0; border: 0; overflow: hidden; }
++ .ovdk-inline .ovdk-panel { min-width: 0; overflow-x: hidden; }
++ @media (max-width: 640px) { .ovdk-inline button, .ovdk-inline a, .ovdk-inline summary, .ovdk-inline input, .ovdk-inline textarea { min-height: 44px !important; max-height: none !important; box-sizing: border-box; } }
++ `;
++ document.head.append(style);
++ }
++ const wrap = document.createElement('section');
++ wrap.className = 'ovdk ovdk-inline';
++ wrap.setAttribute('aria-label', 'Dock panel');
++ host.replaceChildren(wrap);
++ bridge.start(wrap);
++ let gone = false;
++ const show = (tab) => { if (!gone) bridge.show(tab); };
++ show(first);
++ return { show, destroy() { if (gone) return; gone = true; bridge.end(); host.replaceChildren(); } };
++ }
++ root.OpenVibeDockMount = { mount };
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/dock-runs.js b/dock-runs.js
iindex dd0c46b..8ec6ec2 100644
---- a/dock-runs.js
++++ b/dock-runs.js
@@@ -53,7 +53,7 @@
const st = o.styles && o.styles(); if (st && !st.textContent.includes('.ovdk-overview{')) st.textContent += CSS;
// A site's Dock passes no scopes (full behaviour); the extension passes its read-only set.
const canDecide = !Array.isArray(o.scopes) || o.scopes.includes('actions.decide');
-- let doc = null, busy = false, timer = 0, filter = o.state().agent_filter || 'all';
++ let doc = null, busy = false, timer = 0, gone = false, filter = o.state().agent_filter || 'all';
const request = async (path, init) => o.call(path, init);
const all = () => doc ? ['waiting','running','scheduled','done','failed'].flatMap((b) => doc.buckets[b] || []) : [];
function row(it) {
@@@ -96,7 +96,7 @@
// Through Network (every site's connect-src reaches openvibe.network and refuses openvibe.actor).
const data = await request('/api/v1/me/actions/skills');
const available = (data.listings || []).filter((x) => x && x.skill && x.scan && x.scan.state === 'passed' && x.curator && x.curator.state === 'active').slice(0, 6);
-- if (!available.length) return;
++ if (gone || !available.length) return;
box.append(h('h3', '', 'Things agents can do for you'));
const grid = h('div', 'ovdk-skills');
available.forEach((s) => {
@@@ -123,13 +123,13 @@
tile.append(h('small', '', label), n, sub); tiles.append(tile); }); box.append(tiles);
const recent = [...(doc.buckets.done || []), ...(doc.buckets.failed || [])].sort((a,b) => String(b.updated_at).localeCompare(String(a.updated_at))).slice(0,3); if (recent.length) { box.append(h('h3', '', 'Last results')); rows(recent, box); }
skills(box); return box; }
-- function paint() { if (!doc) return; panel.textContent = ''; const items = all(); const filters = h('div', 'ovdk-run-filters'); for (const [id,label] of [['all','All'],['running','Running'],['waiting','Waiting'],['scheduled','Scheduled'],['done','Done'],['failed','Failed']]) { const b = h('button', '', label); b.type='button'; b.setAttribute('aria-pressed', String(filter===id)); b.addEventListener('click', () => { filter=id; o.save({agent_filter:id}); paint(); }); filters.append(b); } panel.append(filters);
++ function paint() { if (gone || !doc) return; panel.textContent = ''; const items = all(); const filters = h('div', 'ovdk-run-filters'); for (const [id,label] of [['all','All'],['running','Running'],['waiting','Waiting'],['scheduled','Scheduled'],['done','Done'],['failed','Failed']]) { const b = h('button', '', label); b.type='button'; b.setAttribute('aria-pressed', String(filter===id)); b.addEventListener('click', () => { filter=id; o.save({agent_filter:id}); paint(); }); filters.append(b); } panel.append(filters);
if (filter==='all' && !(doc.counts.running || doc.counts.waiting)) panel.append(overview());
else { const shown = filter==='all' ? items : (doc.buckets[filter] || []); if (shown.length) rows(shown,panel); else panel.append(h('p','ovdk-none','No runs in this view.')); }
const foot = o.foot || h('div','ovdk-foot'); foot.textContent=''; const allLink = h('a','','All runs'); allLink.href='https://openvibe.actor/tasks'; const give = h('a','','Give an agent a task'); give.href='https://openvibe.actor/skills'; foot.append(allLink,give); if (!o.foot) panel.append(foot); }
-- async function refresh() { if (busy || !o.active()) return; busy=true; clearTimeout(timer); try { doc = await request('/api/v1/me/actions'); if (doc && doc.buckets) { paint(); if (o.onCounts) o.onCounts(doc.counts || {}); } } catch { panel.textContent=''; panel.append(h('div','ovdk-none','Your runs did not load. Open Agents again to retry.')); } finally { busy=false; if (o.active() && doc && doc.counts && doc.counts.running) timer=setTimeout(refresh,15000); } }
-- refresh(); if (o.loadMarks) o.loadMarks().then((ok) => { if (ok && doc) paint(); });
-- return { refresh, destroy: () => clearTimeout(timer) };
++ async function refresh() { if (gone || busy || !o.active()) return; busy=true; clearTimeout(timer); try { doc = await request('/api/v1/me/actions'); if (gone) return; if (doc && doc.buckets) { paint(); if (o.onCounts) o.onCounts(doc.counts || {}); } } catch { if (!gone) { panel.textContent=''; panel.append(h('div','ovdk-none','Your runs did not load. Open Agents again to retry.')); } } finally { busy=false; if (!gone && o.active() && doc && doc.counts && doc.counts.running) timer=setTimeout(refresh,15000); } }
++ refresh(); if (o.loadMarks) o.loadMarks().then((ok) => { if (!gone && ok && doc) paint(); });
++ return { refresh, destroy: () => { gone = true; clearTimeout(timer); } };
}
root.OpenVibeDockRuns = { mount };
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex 2bde957..cbe5aa2 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -66,7 +66,7 @@
// browser without it (Firefox) records instead, up to 30 s, and Chat's /api/vibe/listen turns that into text
// (OpenVibe.AI's speech-to-text on this network's own machines; the recording is never kept).
// Everything is set as text; a link is a link only when it is https.
-- const vibe = { panel: null, chat: null, view: 'list', fresh: false, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null };
++ const vibe = { panel: null, chat: null, view: 'list', fresh: false, msgs: [], last: 0, timer: 0, busy: false, waitUntil: 0, rec: null, talk: null, generation: 0 };
const SR = root.SpeechRecognition || root.webkitSpeechRecognition || null;
const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null;
const TALK = Boolean(SR || MR);
@@@ -129,6 +129,7 @@
}
const age = (iso) => { const n = Date.parse(iso || ''); if (!Number.isFinite(n)) return ''; const m = Math.max(0, Math.floor((Date.now() - n) / 60000)); return m < 1 ? 'now' : m < 60 ? `${m} min ago` : m < 1440 ? `${Math.floor(m / 60)} h ago` : `${Math.floor(m / 1440)} d ago`; };
async function openList() {
++ const generation = ++vibe.generation;
clearTimeout(vibe.timer); vibe.view = 'list'; vibe.chat = null;
const panel = vibe.panel; if (!panel) return;
panel.textContent = '';
@@@ -139,7 +140,7 @@
const first = h('li'); first.append(newer); list.append(first); scroll.append(list); panel.append(head, scroll);
try {
const out = await ctx.chatCall('/api/vibe/conversations');
-- if (vibe.view !== 'list' || vibe.panel !== panel) return;
++ if (generation !== vibe.generation || vibe.view !== 'list' || vibe.panel !== panel) return;
const conversations = Array.isArray(out.conversations) ? out.conversations : [];
if (!conversations.length) list.append(h('li', 'ovdk-none', 'Your Vibe conversations will appear here.'));
conversations.forEach((c) => {
@@@ -156,9 +157,10 @@
b.addEventListener('click', () => { vibe.fresh = false; vibe.chat = { slug: c.slug }; ctx.save({ vibe: { slug: c.slug, at: new Date().toISOString() } }); openVibe(); });
li.append(b); list.append(li);
});
-- } catch (e) { list.append(h('li', 'ovdk-none', e && e.status === 401 ? 'Sign in to see your conversations.' : 'Conversations did not load. Open Vibe again to retry.')); }
++ } catch (e) { if (generation === vibe.generation) list.append(h('li', 'ovdk-none', e && e.status === 401 ? 'Sign in to see your conversations.' : 'Conversations did not load. Open Vibe again to retry.')); }
}
async function openVibe() {
++ const generation = ++vibe.generation;
clearTimeout(vibe.timer);
vibe.view = 'conversation';
const panel = vibe.panel; if (!panel) return;
@@@ -219,6 +221,7 @@
try {
let timezone = null; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { /* */ }
const out = await ctx.chatCall('/api/vibe/ask', { method: 'POST', body: JSON.stringify({ message: text, slug: vibe.chat ? vibe.chat.slug : undefined, fresh: vibe.fresh, timezone }) });
++ if (generation !== vibe.generation) return;
if (out.chat) { vibe.chat = out.chat; vibe.fresh = false; away.href = out.chat.url; ctx.save({ vibe: { slug: out.chat.slug, at: new Date().toISOString() } }); }
box.value = ''; fit();
const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove();
@@@ -238,10 +241,10 @@
try {
got = vibe.fresh && !vibe.chat ? { chat: null, messages: [] } : await ctx.chatCall(`/api/vibe/chat${vibe.chat ? `?slug=${encodeURIComponent(vibe.chat.slug)}${vibe.last ? `&after=${vibe.last}` : ''}` : ''}`);
} catch (e) {
-- if (first) { list.textContent = ''; showFailure(e, () => pull(true)); }
++ if (first && generation === vibe.generation) { list.textContent = ''; showFailure(e, () => pull(true)); }
return;
}
-- if (!ctx.active() || vibe.panel !== panel) return;
++ if (generation !== vibe.generation || !ctx.active() || vibe.panel !== panel) return;
if (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; title.textContent = got.chat.name || 'Vibe'; }
if (first) list.textContent = '';
const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
@@@ -362,7 +365,7 @@
return {
refresh: () => { vibe.panel = panel; const list = ctx.wantList ? ctx.wantList() : false; if (list) return openList(); return vibe.view === 'list' ? openList() : openVibe(); },
list: () => { vibe.panel = panel; return openList(); },
-- pause: () => { clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },
++ pause: () => { vibe.generation++; clearTimeout(vibe.timer); if (vibe.rec) { try { vibe.rec.abort(); } catch { /* */ } } },
talk: () => { if (vibe.talk) vibe.talk(); },
// A page's "Ask your agent" (4.135.0): the question waits in the box, with its context, for the person to send.
ask: (text) => { if (vibe.view === 'list') { vibe.pendingAsk = text; vibe.fresh = true; openVibe(); } else if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },
ddiff --git a/dock.js b/dock.js
iindex 629fffe..2a8dbab 100644
---- a/dock.js
++++ b/dock.js
@@@ -1,30 +1,5 @@
--// ═══════════════════════════════════════════════════════════════
--// OpenVibe — the Dock (Dock plan 2026-10-30, K-D1): your agents and your activity at the edge of every OpenVibe page
--//
--// navbar.js loads this file from beside itself for a signed-in person, after the bell, and mounts it:
--// OpenVibeDock.mount({ token: () => jwt, apiBase, sibling }) the pill; mount(null) takes it away (signed out)
--// OpenVibeDock.open({ tab }) · close() · isOpen()
--// OpenVibeDock.share({ text, title }) · canSend() Send to… (4.144.0, dock-share.js): the text waits in a box, unsent
--//
--// The pill (the mark, a word and a dot) shows only while an agent works, one needs you, or the person pinned it, in the
--// corner the Dock sits in. It paints at once from this site's copy (localStorage ov-dock), then from the Network, and
--// re-reads every 30 s while something runs and when the page comes back. A 404 (Network without the route yet) keeps
--// { off } for a day, and navbar.js does not load this file until then.
--//
--// 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; 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.
--// Pop out (K-D4): on desktop Chromium the panel moves into a window of its own (Document Picture-in-Picture) — the same
--// DOM, the same tabs, the page's own styles copied in; the page keeps the pill, which says where the Dock went.
--// ═══════════════════════════════════════════════════════════════
++// The Dock: navbar.js calls mount({ token, apiBase, sibling }) for the pill. open({ tab }) opens the floating card;
++// mountInto(host, { tab }) places its panel body inside another sheet. Both use the same state, counts and loaders.
(function (root) {
'use strict';
if (typeof document === 'undefined' || root.OpenVibeDock) return;
@@@ -59,7 +34,7 @@
const SAVED_TABS = ['vibe', 'agent', 'chats', 'activity'];
const DEFAULT_STATE = { open: false, tab: 'activity', pinned: false, agent: null, chat: null, place: { phone: { corner: 'bottom-right' }, desktop: { corner: 'bottom-right', width: null, height: null } } };
-- let el = null, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null, dockSurface = null;
++ let el = null, bodyEl = null, inline = null, panelEpoch = 0, o = {}, doc = null, tab = 'activity', saveT = 0, pending = {}, lastFocus = null, dockSurface = null;
// Pop out (K-D4): the window the panel lives in while it is popped out, and whether it is in one.
let pipWin = null, popped = false, pipClosing = false, popOk = true;
const mounts = {};
@@@ -113,7 +88,7 @@
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
-- const DOCK_REV = { 'dock-vibe.js': '41d5083aeeb8', 'dock-runs.js': 'b734e5e7ba11', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'a88ef9799001', 'dock-agent.js': '2875da28ff94', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
++ const DOCK_REV = { 'dock-vibe.js': 'eb2b2a51ed49', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': '8ed09c416a48', 'dock-agent.js': 'c1281ef69ea8', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
function load(src, test) {
return new Promise((ok) => {
if (test()) return ok(true);
@@@ -376,6 +351,7 @@
<div class="ovdk-body">${TABS.map(([id]) => `<div class="ovdk-panel" role="tabpanel" id="ovdk-panel-${id}" aria-labelledby="ovdk-tab-${id}" data-panel="${id}" hidden></div>`).join('')}</div>
<span class="ovdk-size" aria-hidden="true"></span>`;
document.body.appendChild(el);
++ bodyEl = el.querySelector('.ovdk-body');
const tabs = [...el.querySelectorAll('[role="tab"]')];
tabs.forEach((b) => b.addEventListener('click', () => setTab(b.dataset.tab, true)));
@@@ -542,24 +518,39 @@
b.title = on ? "On: the Dock's button stays on every page" : "Off: the Dock's button shows only while something works or needs you";
}
function setTab(id, remember) {
++ if (id === 'agents') id = 'agent';
++ if (inline && tab !== id) disposePanel();
tab = TABS.some(([t]) => t === id) ? id : 'activity';
if (tab !== 'chats') clearTimeout(chats.timer);
if (tab !== 'vibe' && vibe.api) vibe.api.pause();
el.querySelectorAll('[role="tab"]').forEach((b) => { const on = b.dataset.tab === tab; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
-- el.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
++ bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.hidden = p.dataset.panel !== tab; });
if (remember && state().tab !== tab && SAVED_TABS.includes(tab)) save({ tab });
fill(tab);
++ if (inline && tab === 'chats') readUnread();
++ }
++ const panelActive = (id) => tab === id && (isOpen() || !!inline);
++ function disposePanel() {
++ panelEpoch++;
++ clearTimeout(chats.timer); clearTimeout(chats.unreadT);
++ if (vibe.api) { vibe.api.pause(); vibe.api = null; }
++ for (const k of Object.keys(mounts)) { if (mounts[k] && mounts[k].destroy) mounts[k].destroy(); delete mounts[k]; }
++ if (root.OpenVibeNotifications && root.OpenVibeNotifications.unmountDock && bodyEl) root.OpenVibeNotifications.unmountDock(bodyEl.querySelector('[data-panel="activity"]'));
++ chats.panel = null; chats.open = null; vibe.panel = null;
++ if (bodyEl) bodyEl.querySelectorAll('[data-panel]').forEach((p) => { p.replaceChildren(); p.hidden = true; });
}
async function fill(id) {
-- const panel = el.querySelector(`[data-panel="${id}"]`);
++ const epoch = panelEpoch;
++ const panel = bodyEl.querySelector(`[data-panel="${id}"]`);
if (id === 'vibe') {
vibe.panel = panel;
if (vibe.api) return vibe.api.refresh();
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));
++ if (epoch !== panelEpoch || !panelActive(id)) return;
if (!ok) { panel.innerHTML = '<div class="ovdk-none">Vibe did not load. Check your connection, then open it again.</div>'; return; }
-- vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => isOpen() && tab === 'vibe', state, save, wantList: () => { const v = vibeOnList; vibeOnList = false; return v; }, styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
++ vibe.api = root.OpenVibeDockVibe.mount(panel, { chatCall, active: () => panelActive('vibe'), state, save, wantList: () => { const v = vibeOnList; vibeOnList = false; return v; }, styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'), icons: { vibe: G.vibe, send: G.send, clock: G.clock, bell: G.bell }, refresh: () => read() });
return;
}
if (id === 'chats') { chats.panel = panel; mounts.chats = mounts.chats || { refresh: null }; if (chats.draft) return chatList(); return state().chat && chats.open !== state().chat ? openChat(state().chat, false) : chats.open ? openChat(chats.open, false) : chatList(); }
@@@ -569,23 +560,24 @@
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
if (id === 'agent') {
const ok = await load('dock-runs.js', () => !!(root.OpenVibeDockRuns && root.OpenVibeDockRuns.mount));
++ if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; }
if (!ok) return failed(panel, id);
// K-D3: the person's own agent stream above the list; it hides itself when there is none.
panel.innerHTML = '<div class="ovdk-actor"></div><div class="ovdk-timeline"></div><div class="ovdk-agent"></div><div class="ovdk-scroll"></div><div class="ovdk-runs-foot ovdk-foot"></div>';
// The Agent panel: Now, Needs you and the one timeline (design 2026-11-07). It is first in the tab, so on a
// phone Now and Needs you are on screen before anything else.
load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
-- if (on && !mounts[id + 'Timeline'] && panel.isConnected) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent', focus: () => o.task || null });
++ if (on && epoch === panelEpoch && !mounts[id + 'Timeline'] && panel.isConnected && panelActive(id)) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => panelActive('agent'), focus: () => o.task || null });
});
// Actor v3 (A3): your Actor first, on every site; it shows nothing for a person without one.
load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => {
-- if (on && !mounts[id + 'Actor'] && panel.isConnected) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), networkBase: o.apiBase || 'https://openvibe.network', active: () => isOpen() && tab === 'agent' });
++ if (on && epoch === panelEpoch && !mounts[id + 'Actor'] && panel.isConnected && panelActive(id)) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), networkBase: o.apiBase || 'https://openvibe.network', active: () => panelActive('agent') });
});
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.querySelector('.ovdk-agent'), { token: o.token, base: directBase(o.agentBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent' });
++ if (on && epoch === panelEpoch && !mounts[id + 'View'] && panel.isConnected && panelActive(id)) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: directBase(o.agentBase), frameBase: frameBase(), active: () => panelActive('agent') });
});
const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {
-- call, state, save, scopes: o.scopes, active: () => isOpen() && tab === 'agent', actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),
++ call, state, save, scopes: o.scopes, active: () => panelActive('agent'), actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),
styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'),
foot: panel.querySelector('.ovdk-runs-foot'),
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
@@@ -594,6 +586,7 @@
return;
}
const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
++ if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; }
if (!ok) return failed(panel, id);
const N = root.OpenVibeNotifications;
if (!N.__ovNavInit) { try { N.init({ token: o.token, apiBase: o.apiBase || 'https://openvibe.network', scopes: o.scopes }); N.__ovNavInit = true; } catch { /* */ } }
@@@ -630,7 +623,7 @@
function readUnread() {
clearTimeout(chats.unreadT);
chatCall('/api/dm/unread').then((b) => { chats.unread = Number(b && b.unread) || 0; badges(); }).catch(() => { /* Chat away: no count */ });
-- if (isOpen()) chats.unreadT = setTimeout(readUnread, 30000);
++ if (panelActive('chats')) chats.unreadT = setTimeout(readUnread, 30000);
}
const when = (iso) => {
const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';
@@@ -671,9 +664,9 @@
const [dm, ai] = await Promise.all([chatCall('/api/dm/conversations'), chatCall('/api/vibe/conversations').catch(() => null)]);
chats.list = dm.conversations || [];
chats.vibeSummary = ai && ai.count ? { kind: 'vibe', count: ai.count, latest: ai.conversations && ai.conversations[0], last_message_at: ai.conversations && ai.conversations[0] && ai.conversations[0].last_message_at } : null;
-- } catch (e) { panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; }
++ } catch (e) { if (panel !== chats.panel || !panelActive('chats')) return; panel.textContent = ''; panel.append(h('div', 'ovdk-none', e.status === 401 ? 'Sign in to Chat to see your conversations.' : 'Your chats did not load. Check your connection, then open them again.')); chatFoot(panel); return; }
if (sharing) await sharing;
-- if (chats.open || tab !== 'chats') return;
++ if (panel !== chats.panel || !panelActive('chats') || chats.open) return;
panel.textContent = '';
if (chats.draft && canWriteChat() && root.OpenVibeDockShare) panel.append(root.OpenVibeDockShare.bar({ draft: chats.draft, h, chatCall, host: el, cancel: () => { chats.draft = null; chatList(); }, open: (id) => { chats.list = null; return openChat(String(id), true); } }));
const scroll = h('div', 'ovdk-scroll');
@@@ -781,10 +774,10 @@
const id = chats.open;
let got = [];
try { got = (await chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/messages?limit=50${chats.last ? `&after=${chats.last}` : ''}`)).messages || []; } catch (e) {
-- if (first) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 403 ? 'This conversation is not yours.' : 'The messages did not load. Try again.')); }
++ if (first && panelActive('chats')) { list.textContent = ''; list.append(h('li', 'ovdk-none', e.status === 403 ? 'This conversation is not yours.' : 'The messages did not load. Try again.')); }
return;
}
-- if (id !== chats.open) return;
++ if (id !== chats.open || !panelActive('chats')) return;
if (first) list.textContent = '';
const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
got.forEach((m) => { if (chats.msgs.some((x) => x.id === m.id)) return; list.append(bubble(m, chats.msgs[chats.msgs.length - 1])); chats.msgs.push(m); chats.last = Math.max(chats.last, Number(m.id) || 0); });
@@@ -792,7 +785,7 @@
if (first || atEnd) scroll.scrollTop = scroll.scrollHeight;
if (got.length || first) chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/read`, { method: 'POST', body: '{}' }).then(readUnread).catch(() => { /* */ });
clearTimeout(chats.timer);
-- if (isOpen() && tab === 'chats' && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);
++ if (panelActive('chats') && chats.open === id) chats.timer = setTimeout(() => pull(list, scroll), 5000);
}
// ── Vibe (plan L48 V2): the person's Vibe, in dock-vibe.js, loaded the first time the tab opens (the Dock is on
@@@ -863,7 +856,7 @@
const next = r.ok ? await r.json() : null;
if (!next || !next.state || !pill) return;
update(next);
-- if (!isOpen() && next.state.open && !phone()) open();
++ if (!isOpen() && !inline && next.state.open && !phone()) open();
const c = next.counts || {};
if (c.running || c.waiting || next.state.timer) readT = setTimeout(read, 30000);
} catch { /* offline: the copy stands */ }
@@@ -871,6 +864,7 @@
/** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */
function mount(opts) {
if (!opts) {
++ if (mountHandle) mountHandle.destroy();
clearTimeout(readT);
if (pill) { pill.remove(); pill = null; }
if (isOpen()) close();
@@@ -1016,6 +1010,7 @@
open({ tab: 'chats' });
}
function open(opts = {}) {
++ if (mountHandle) mountHandle.destroy();
openedAt = typeof performance !== 'undefined' && performance.now ? performance.now() : 0;
o = { ...o, ...opts };
if (opts.doc) doc = opts.doc;
@@@ -1053,7 +1048,7 @@
}
shut();
}
-- function shut() {
++ function shut(keepState = false) {
if (!isOpen()) return;
unlinkOrb();
const wasPopped = popped;
@@@ -1066,7 +1061,7 @@
document.documentElement.classList.remove('ovdk-sheet-open');
clear(0);
clearTimeout(chats.timer); clearTimeout(chats.unreadT);
-- save({ open: false });
++ if (!keepState) save({ open: false });
if (inside) {
const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }
@@@ -1085,5 +1080,27 @@
if (isOpen()) layout();
}
-- root.OpenVibeDock = { mount, open, close, isOpen, update, ask, share, canSend: () => canWriteChat(), _state: () => ({ doc, tab, pending }) };
++ let mountHandle = null;
++ /** Place the same panel body under another sheet's head. The helper and its layout load on first use. */
++ function mountInto(host, { tab: first = 'activity' } = {}) {
++ if (!(host instanceof Element)) throw new TypeError('mountInto needs a host element');
++ if (mountHandle) mountHandle.destroy();
++ if (isOpen()) { clearTimeout(closing); closing = 0; shut(true); }
++ let api = null, gone = false, wanted = first;
++ const handle = {
++ show(id) { if (gone) return; wanted = id; if (api) api.show(id); },
++ destroy() { if (gone) return; gone = true; if (api) api.destroy(); if (mountHandle === handle) mountHandle = null; },
++ };
++ mountHandle = handle;
++ load('dock-mount.js', () => !!root.OpenVibeDockMount).then((ok) => {
++ if (ok && !gone) api = root.OpenVibeDockMount.mount(host, wanted, {
++ start(wrap) { if (!el) build(); inline = wrap; wrap.append(bodyEl); },
++ show(id) { setTab(id, false); badges(); },
++ end() { disposePanel(); inline = null; if (el) el.insertBefore(bodyEl, el.querySelector('.ovdk-size')); paintPill(); },
++ });
++ });
++ return handle;
++ }
++
++ root.OpenVibeDock = { mount, mountInto, open, close, isOpen, update, ask, share, canSend: () => canWriteChat(), _state: () => ({ doc, tab, pending }) };
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/files.js b/files.js
iindex 8dbf8fd..2295128 100644
---- a/files.js
++++ b/files.js
@@@ -23,6 +23,7 @@ const BROWSER = Object.freeze([
'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'action-center.js', // the ✦ Action Center; notification-ui loads it on the first click
'dock.js', // the Dock and its pill; navbar.js loads it for a signed-in person
++ 'dock-mount.js', // the Dock body inside another sheet; dock.js loads it on first mountInto
'dock-runs.js', // the Dock's content-aware Agents rows and overview, loaded with that tab
'ai-marks-client.js', // maker marks on a run receipt, loaded only in Agents
'presence.js', 'purse.js', 'machines.js', 'ov-drop.js', 'mini.js', 'ov-tip.js', 'overlay.js', 'nav-slots.js', // your online status: the dot, the heartbeat, the picker; navbar.js loads it for a signed-in person
ddiff --git a/navbar.js b/navbar.js
iindex a1abf4a..976ec63 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -517,11 +517,7 @@
.openvibe-navbar .openvibe-network-badge { display: none; }
}
-- /* Touch and keyboard feedback. Phones flash a square, theme-blind blue box over anything
-- tapped (-webkit-tap-highlight-color); the Frame turns it off and shows a slight press
-- instead, and keyboard focus gets one ring in the theme accent. :where() keeps it at zero
-- specificity, so the Frame's own :focus-visible rules and a host page's rules still win.
-- \`scale\` is its own property, so it composes with any transform already in use. */
++ /* No square tap flash: a slight press (scale composes with transforms) and one accent focus ring, at zero specificity. */
.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer { -webkit-tap-highlight-color: transparent; }
:where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"]):not(:disabled):active { scale: .96; }
:where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
@@@ -566,7 +562,7 @@
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
-- const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '810a6665e79c', 'dock.js': 'de8b3805e36c', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'b64ea0c39d34', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '5fdb2b84221d', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0', 'ov-tip.js': '1d918e7de69d' };
++ const SIBLING_REV = { 'ov-mark.js': '01cf79e36af5', 'ov-glyphs.js': '97224763226b', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '7ef00feeb07c', 'ov-fx.js': 'bb5afd571c72', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '496bc3134c08', 'dock.js': 'c606aca212ab', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': '0bd4b26af88a', 'history.js': 'f9fb9e096c65', 'appbar.js': 'dfc8e4da0869', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8e5047755456', 'sso-client.js': '52439265b980', 'overlay.js': '336b30eae325', 'ov-home.js': '5fdb2b84221d', 'nav-slots.js': '1db19edaeac4', 'ov-wallet.js': 'b750eacee14b', 'ov-wallet.css': 'da51db3269db', 'purse.js': '749120bfd24b', 'ov-drop.js': '0132897495f0', 'ov-tip.js': '1d918e7de69d' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
ddiff --git a/notification-ui.js b/notification-ui.js
iindex a1e9c24..2ca78ac 100644
---- a/notification-ui.js
++++ b/notification-ui.js
@@@ -983,6 +983,7 @@
}, true);
attachRowSwipe(v, list);
refreshPushButton(v);
++ v.destroy = () => { clearTimeout(qTimer); v.seq++; };
_views.set(root, v);
paintTabCounts();
return v;
@@@ -1483,6 +1484,13 @@
reload(v);
return v;
},
++ unmountDock(container) {
++ const v = _views.get(container);
++ if (!v || v.mode !== 'dock') return;
++ if (v.destroy) v.destroy();
++ _views.delete(container);
++ container.classList.remove('openvibe-notif-dock');
++ },
/** Programmatically show a toast (real-time events). Marks nothing on the server. */
push(notification) {
showToast(notification);
ddiff --git a/package-lock.json b/package-lock.json
iindex 34da7ea..5ade31a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.197.0",
++ "version": "4.198.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.197.0",
++ "version": "4.198.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 117675d..205bdf4 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.197.0",
++ "version": "4.198.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -48,6 +48,7 @@
"./notification-live": "./notification-live.js",
"./action-center": "./action-center.js",
"./dock": "./dock.js",
++ "./dock-mount": "./dock-mount.js",
"./presence": "./presence.js",
"./purse": "./purse.js",
"./drop": "./ov-drop.js",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex f2a3507..fd905f9 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -23,7 +23,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
// 4.193.0: the one tooltip (Sign in, the menu button, short labels).
'ov-tip.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
--const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
++const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
'footer.js': { name: 'FOOTER_REV', files: ['shipped.js'] }, 'boost.js': { name: 'BOOST_REV', files: ['route-transition.js'] } };
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
ddiff --git a/search.js b/search.js
iindex e309a9e..113336c 100644
---- a/search.js
++++ b/search.js
@@@ -77,6 +77,8 @@
const RTABS = [['all', 'All'], ['openvibe', 'OpenVibe'], ['web', 'Web'], ['video', 'Video'], ['news', 'News'], ['products', 'Products'], ['mine', 'Yours']];
const PROVIDER = { open_index: 'OpenVibe\'s index', deepseek: 'DeepSeek', wikipedia: 'Wikipedia', brave: 'Brave', exa: 'Exa', tavily: 'Tavily' };
let view = 'suggest', rtabsEl = null, rq = '', rtab = 'all', rflight = null, moved = false;
++ // A place of the Dock shown inside the menu (a phone, 312 step 2): which one, its mount, and AI's part.
++ let door = null, doorEl = null, doorHost = null, doorSw = null, doorMount = null, aiPart = 'vibe';
const safeUrl = (u) => { try { const x = new URL(u, location.href); return x.protocol === 'https:' || x.origin === location.origin ? x.href : null; } catch { return null; } };
function siteOf(url) {
@@@ -269,6 +271,18 @@
.ovs-list > li:nth-child(5) { animation-delay: 100ms; } .ovs-list > li:nth-child(6) { animation-delay: 125ms; } .ovs-list > li:nth-child(n+7) { animation-delay: 150ms; }
@keyframes ovs-rise { from { opacity: 0; transform: translateY(6px); } }
.ovs-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; overscroll-behavior: contain; }
++/* A place of the Dock inside the menu (312 step 2, a phone): it fills the sheet under the head and scrolls itself. */
++.ovs-door { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
++.ovs-door[hidden] { display: none; }
++.ovs-door-host { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
++.ovs-door-host > .ovdk { flex: 1 1 auto; min-height: 0; }
++.ovs-door-sw { flex: none; display: grid; grid-template-columns: 1fr 1fr; gap: 3px; margin: 8px 12px 4px; padding: 3px; border-radius: 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
++.ovs-door-sw[hidden] { display: none; }
++.ovs-door-sw button { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 44px; border-radius: 11px; cursor: pointer; font-size: 14px; font-weight: 650; color: var(--text-secondary, #a8b3c4); transition: background-color .15s, color .15s; }
++.ovs-door-sw button[aria-selected="true"] { color: var(--text-primary, #e6edf7); background: var(--bg-secondary, #121826); box-shadow: 0 1px 2px rgba(0,0,0,.3), inset 0 0 0 1px color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
++.ovs-door-sw button[aria-selected="true"] .ovs-tab-ic { color: var(--accent-light, var(--accent, #60a5fa)); }
++.ovs-door-sw button:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ovs-in-door .ovs-foot { display: none; }
.ovs-group { display: flex; align-items: center; gap: 8px; padding: 10px 10px 4px; font-size: 11px; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #93a0b4); }
.ovs-group button { margin-left: auto; border: 0; background: none; color: var(--accent-light, #8ab4ff); font: inherit; font-size: 11.5px; font-weight: 650; text-transform: none; letter-spacing: 0; cursor: pointer; }
.ovs-item a { display: flex; align-items: center; gap: 12px; min-height: 46px; padding: 5px 10px; border-radius: 12px; color: inherit; text-decoration: none; }
@@@ -444,6 +458,13 @@
input = el.querySelector('.ovs-input');
ghost = el.querySelector('.ovs-ghost');
listEl = el.querySelector('.ovs-list');
++ doorEl = document.createElement('div'); doorEl.className = 'ovs-door'; doorEl.hidden = true;
++ doorSw = document.createElement('div'); doorSw.className = 'ovs-door-sw'; doorSw.setAttribute('role', 'tablist'); doorSw.setAttribute('aria-label', 'AI');
++ doorSw.innerHTML = [['vibe', 'Vibe'], ['agent', 'Agents']].map(([id, label]) => `<button type="button" role="tab" data-part="${id}" aria-selected="false"><span class="ovs-tab-ic">${BRIDGE_SVG[id]}</span><span>${label}</span></button>`).join('');
++ doorSw.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-part]'); if (b && door === 'vibe') { aiPart = b.getAttribute('data-part'); openDoor('vibe'); } });
++ doorHost = document.createElement('div'); doorHost.className = 'ovs-door-host';
++ doorEl.append(doorSw, doorHost);
++ listEl.after(doorEl);
tabsEl = el.querySelector('.ovs-tabs');
rtabsEl = el.querySelector('.ovs-rtabs');
rtabsEl.addEventListener('click', (e) => { const b = e.target.closest && e.target.closest('[data-rtab]'); if (b) { showResults(rq, b.getAttribute('data-rtab')); input.focus(); } });
@@@ -456,6 +477,7 @@
el.appendChild(orbEl);
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
++ leaveDoor();
tab = b.getAttribute('data-tab'); if (tab === 'feed') markFeedSeen(); render(''); listEl.scrollTop = 0;
let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
if (touch) b.focus(); else input.focus();
@@@ -1007,6 +1029,36 @@
* the feed content aware… each site its own icon"): this site's own mark and name, Feed with a red dot while anyone is
* live or a count of what is new since you last looked, and You with your picture and an online dot.
*/
++ /** A phone's menu with the Dock to hand: its places open inside the menu, under the menu's own head. */
++ function inSheet() {
++ let phone = false;
++ try { phone = root.matchMedia('(max-width: 640px)').matches; } catch { /* */ }
++ return phone && !!(root.OpenVibeDock && typeof root.OpenVibeDock.mountInto === 'function');
++ }
++ /** Show a place of the Dock in the menu: Messages, or AI (Vibe or your agents, by the two-part switch). */
++ function openDoor(id) {
++ if (!doorEl || !inSheet()) return;
++ door = id;
++ const want = id === 'chats' ? 'chats' : aiPart;
++ doorSw.hidden = id !== 'vibe';
++ doorSw.querySelectorAll('[data-part]').forEach((b) => b.setAttribute('aria-selected', b.getAttribute('data-part') === aiPart ? 'true' : 'false'));
++ listEl.hidden = true; doorEl.hidden = false; el.classList.add('ovs-in-door');
++ try { if (!doorMount) doorMount = root.OpenVibeDock.mountInto(doorHost, { tab: want }); else doorMount.show(want); }
++ catch { leaveDoor(); try { ctx.dock(id); } catch { /* the Dock is optional */ } return; }
++ input.placeholder = id === 'chats' ? 'Search your messages' : `Search ${here()}, or ask Vibe`;
++ paintTabs();
++ }
++ /** Back to the menu's own list (a tab, typing, or closing). The Dock's panels go home with their state. */
++ function leaveDoor() {
++ if (!door) return;
++ door = null;
++ if (doorMount) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }
++ if (doorEl) doorEl.hidden = true;
++ if (listEl) listEl.hidden = false;
++ if (el) el.classList.remove('ovs-in-door');
++ if (input) input.placeholder = `Search ${here()}, or ask Vibe`;
++ }
++
function paintTabs() {
tabsEl.hidden = false;
tabsEl.setAttribute('role', 'tablist');
@@@ -1022,20 +1074,22 @@
const doors = typeof ctx.dock === 'function' ? [['chats', 'Messages'], ['vibe', 'AI']] : [];
const row = [TABS[0], TABS[1], ...doors.map(([id, label]) => [id, label, true]), TABS[2]];
tabsEl.classList.toggle('has-doors', doors.length > 0);
-- for (const [id, label, door] of row) {
++ for (const [id, label, isDoor] of row) {
const b = document.createElement('button');
b.type = 'button'; b.className = 'ovs-tab'; b.setAttribute('role', 'tab');
-- if (door) {
-- // A place that opens in the Dock: never the chosen tab here; it says it opens something.
-- b.setAttribute('data-dock', id); b.setAttribute('aria-selected', 'false'); b.setAttribute('aria-haspopup', 'dialog');
++ if (isDoor) {
++ // A place of the Dock. On a phone it opens here, inside the menu, and is then the chosen tab; on a computer
++ // it opens the floating Dock beside the page (it is kept beside the work there).
++ b.setAttribute('data-dock', id); b.setAttribute('aria-selected', door === id ? 'true' : 'false');
++ if (!inSheet()) b.setAttribute('aria-haspopup', 'dialog');
const ic = document.createElement('span'); ic.className = 'ovs-tab-ic'; ic.innerHTML = BRIDGE_SVG[id]; b.appendChild(ic);
const t = document.createElement('span'); t.textContent = label; b.appendChild(t);
-- b.addEventListener('click', (e) => { e.stopPropagation(); try { ctx.dock(id); } catch { /* the Dock is optional */ } });
++ b.addEventListener('click', (e) => { e.stopPropagation(); if (inSheet()) { openDoor(id); return; } try { ctx.dock(id); } catch { /* the Dock is optional */ } });
tabsEl.appendChild(b);
continue;
}
b.setAttribute('data-tab', id);
-- b.setAttribute('aria-selected', tab === id ? 'true' : 'false');
++ b.setAttribute('aria-selected', !door && tab === id ? 'true' : 'false');
const ic = document.createElement('span'); ic.className = 'ovs-tab-ic';
if (id === 'here' && siteId) { const g = document.createElement('span'); g.className = 'ov-icon'; g.setAttribute('data-icon', siteId); g.setAttribute('data-size', '20'); g.setAttribute('data-fx', 'none'); ic.appendChild(g); }
else if (id === 'you' && me && me.avatar_url && /^https:\/\//.test(me.avatar_url)) {
@@@ -1297,6 +1351,7 @@
function render(q) {
q = String(q || '').trim();
el.classList.toggle('ovs-typing', !!q);
++ if (q) leaveDoor();
tabsEl.hidden = !!q;
if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); paintTabs(); renderTab(); return; }
const n = q.toLowerCase();
@@@ -1517,6 +1572,7 @@
if (typeof window !== 'undefined') window.addEventListener('popstate', () => { if (el && el.classList.contains('is-open')) { popping = true; pushed = false; close(); popping = false; } });
function close({ navigating = false } = {}) {
if (view === 'results' && rtabsEl) leaveResults();
++ leaveDoor();
if (!el || closing) return;
if (navigating) pushed = false;
// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
ddiff --git a/test/dock-mount-chrome.test.js b/test/dock-mount-chrome.test.js
nnew file mode 100644
iindex 0000000..fdc53b7
---- /dev/null
++++ b/test/dock-mount-chrome.test.js
@@@ -0,0 +1,115 @@
++'use strict';
++// mountInto uses the Dock's actual panel siblings against local service stand-ins. Also checks six cleanup laps.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('dock-mount-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const state = { open: false, tab: 'activity', pinned: true, place: { phone: {}, desktop: {} } };
++const page = `<!doctype html><html><head><meta name="viewport" content="width=device-width,initial-scale=1"><style>
++ html,body{margin:0;height:100%;font:14px system-ui;background:#161820;color:#eee}
++ #host{width:100%;height:600px;overflow:hidden}main{height:100px}
++ </style><script src="/dock.js"></script></head><body><main>Local Dock mount test</main><div id="host"></div>
++ <script>OpenVibeDock.mount({token:()=> 'local-token',apiBase:location.origin,chatBase:location.origin,actorBase:location.origin,agentBase:location.origin,sibling:(name)=>'/'+name});</script></body></html>`;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://local');
++ const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(page); }
++ const file = /^\/([a-z0-9-]+\.js)$/.exec(u.pathname);
++ if (file && fs.existsSync(path.join(ROOT, file[1]))) { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, file[1]))); }
++ if (u.pathname === '/api/v1/me/dock') return json(200, { state, counts: { unread: 2, running: 0, waiting: 0 } });
++ if (u.pathname === '/api/v1/me/actions') return json(200, { counts: { running: 0, waiting: 0 }, buckets: { waiting: [], running: [], scheduled: [], done: [], failed: [] } });
++ if (u.pathname === '/api/v1/me/actions/skills') return json(200, { listings: [] });
++ if (u.pathname === '/api/v1/me/actor' || u.pathname === '/api/v1/me/agent-view') return json(404, {});
++ if (u.pathname === '/api/dm/unread') return json(200, { unread: 3 });
++ if (u.pathname === '/api/dm/conversations') return json(200, { conversations: [{ id: 1, participants: [{ username: 'local', display_name: 'Local' }, { username: 'ada', display_name: 'Ada' }], last_sender_id: 1, last_message: 'Hello', unread_count: 3, last_message_at: new Date().toISOString() }] });
++ if (u.pathname === '/api/vibe/conversations') return json(200, { conversations: [], count: 0 });
++ if (u.pathname === '/api/notifications') return json(200, { notifications: [], has_more: false, total: 0 });
++ if (u.pathname === '/api/notifications/count' || u.pathname === '/api/notifications/unread') return json(200, { unread: 2, count: 2 });
++ if (u.pathname === '/api/notifications/tabs') return json(200, { tabs: {} });
++ return json(404, {});
++});
++
++const pause = (ms) => new Promise((ok) => setTimeout(ok, ms));
++async function until(p, expression, ms = 6000) {
++ const end = Date.now() + ms;
++ while (Date.now() < end) { if (await p.evaluate(expression)) return; await pause(50); }
++ throw new Error(`Timed out: ${expression}`);
++}
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-dock-mount-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ for (const width of [390, 1280]) {
++ const context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width, height: 800, probe: true });
++ await p.goto(base, { maxMs: 5000 });
++ await until(p, '!!window.OpenVibeDock && !!document.getElementById("ovdk-pill")');
++ await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"chats"})');
++ await until(p, '!!document.querySelector("#host .ovdk-chat")');
++ await until(p, 'document.querySelector("#ovdk-tab-chats .n").textContent === "3"');
++ await p.evaluate('OpenVibeDock.update({state: OpenVibeDock._state().doc.state, counts: {unread: 2, running: 0, waiting: 1}})');
++ assert.strictEqual(await p.evaluate('document.querySelector("#ovdk-tab-agent .n").textContent'), '1', `${width}px: mounted panel shares unread counts`);
++ assert.strictEqual(await p.evaluate('document.querySelector("#ovdk-pill .ovdk-t").textContent'), 'Needs you', `${width}px: pill uses the same counts`);
++ for (const [tab, marker] of [['chats', '.ovdk-chat'], ['vibe', '.ovdk-vibe-new'], ['agent', '.ovdk-runs-foot'], ['activity', '.openvibe-nv-head']]) {
++ await p.evaluate(`mount.show('${tab}')`);
++ await until(p, `!!document.querySelector('#host [data-panel="${tab}"]:not([hidden]) ${marker}')`);
++ const result = await p.evaluate(`(() => { const host=document.getElementById('host'); const panel=host.querySelector('[data-panel="${tab}"]');
++ const controls=[...panel.querySelectorAll('button,a,input,textarea,summary')].filter(n=>n.getClientRects().length && getComputedStyle(n).visibility!=='hidden');
++ return {heads:host.querySelectorAll('.ovdk-head,.ovdk-tabs,.ovdk-more,[data-act="close"]').length,
++ visible:host.querySelectorAll('[data-panel]:not([hidden])').length,
++ small:controls.filter(n=>n.getBoundingClientRect().height < 43.5).map(n=>n.getAttribute('aria-label')||n.textContent.trim().slice(0,30)),
++ overflow:host.scrollWidth-host.clientWidth,documentOverflow:document.documentElement.scrollWidth-innerWidth}; })()`);
++ assert.strictEqual(result.heads, 0, `${width}px ${tab}: no Dock chrome`);
++ assert.strictEqual(result.visible, 1, `${width}px ${tab}: one panel`);
++ assert.ok(result.overflow <= 1 && result.documentOverflow <= 1, `${width}px ${tab}: no sideways scroll ${JSON.stringify(result)}`);
++ if (width === 390) assert.deepStrictEqual(result.small, [], `${tab}: every phone control is 44px`);
++ }
++ await p.evaluate('mount.show("agents")');
++ await until(p, '!!document.querySelector("#host [data-panel=agent]:not([hidden]) .ovdk-runs-foot")');
++ await p.evaluate('mount.destroy()');
++ assert.strictEqual(await p.evaluate('document.getElementById("host").childElementCount'), 0, `${width}px: destroy empties host`);
++ await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"vibe"})');
++ await until(p, '!!document.querySelector("#host [data-panel=vibe]")');
++ await p.evaluate('OpenVibeDock.open({tab:"activity"})');
++ assert.strictEqual(await p.evaluate('document.getElementById("host").childElementCount'), 0, `${width}px: floating Dock takes ownership`);
++ assert.strictEqual(await p.evaluate('OpenVibeDock.isOpen()'), true, `${width}px: floating Dock opens`);
++ await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"chats"})');
++ await until(p, '!!document.querySelector("#host [data-panel=chats]")');
++ assert.strictEqual(await p.evaluate('OpenVibeDock.isOpen()'), false, `${width}px: mount closes floating Dock`);
++ await p.evaluate('mount.destroy()');
++ if (width === 390) {
++ // The probe and forced GC use the same measures as boost-navbar-chrome's lifecycle check.
++ const samples = [];
++ for (let lap = 0; lap < 6; lap++) {
++ await p.evaluate('window.mount = OpenVibeDock.mountInto(document.getElementById("host"), {tab:"chats"})');
++ await until(p, '!!document.querySelector("#host .ovdk-chat")');
++ for (const tab of ['vibe', 'agent', 'activity', 'chats']) {
++ await p.evaluate(`mount.show('${tab}')`);
++ await until(p, `!!document.querySelector('#host [data-panel="${tab}"]:not([hidden])')`);
++ }
++ await p.evaluate('mount.destroy()');
++ await p.send('HeapProfiler.collectGarbage'); await pause(100);
++ await p.send('HeapProfiler.collectGarbage');
++ const m = await p.metrics(), probe = await p.evaluate('__ovProbe()');
++ samples.push({ nodes: m.Nodes, listeners: m.JSEventListeners, doc: probe.documentListeners, win: probe.windowListeners, host: await p.evaluate('document.getElementById("host").childElementCount') });
++ }
++ const a = samples[1], b = samples[5];
++ assert.ok(samples.every(s => s.host === 0), `host empties each lap: ${JSON.stringify(samples)}`);
++ assert.ok(b.nodes-a.nodes <= 25 && b.listeners-a.listeners <= 3 && b.doc-a.doc <= 0 && b.win-a.win <= 0, `no node or listener growth: ${JSON.stringify(samples)}`);
++ }
++ }
++ console.log('dock-mount-chrome: four panels, both widths, exclusive ownership, six leak laps passed');
++ } finally { await browser.close().catch(() => {}); server.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/dock-size.test.js b/test/dock-size.test.js
iindex e9f7076..32e226e 100644
---- a/test/dock-size.test.js
++++ b/test/dock-size.test.js
@@@ -22,7 +22,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.js');
// 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 = 20787; // 20.30 KB with a robot for Agents, so the spark means one thing (Vibe) in the Dock as in the menu's row of places (4.194.0, 2026-11-10); 20.25 KB: 20.04 KB with a phone's sheet opening and closing as the menu's does (a circle from the orb, the menu's close button in the menu's place, a ring as it opens; 4.186.0, 2026-11-10); 20.00 KB: 19.80 KB with a phone's sheet opening out of the navbar's orb (4.185.0, 2026-11-10: the hand-off's styles and its two functions, about 0.95 KB; it runs as the sheet opens, so it cannot be a sibling loaded later); 19.00 KB: 18.63 KB with Dock v2's AI chats row, the conversation list, the group faces and the maker's marks (2026-11-09); 18.50 KB: 18.1 KB with the Actor tab, the sheets' shared surface and Dock chats from a site's session (4.162.0, 2026-11-09); 18.34 KB measured on 2026-11-09 with Dock v2's Chats summary and its lazy Agents rows; 18.00 KB: 17.9 KB with the orb's × as Close, dots drawn on the centre line and the pop from the pill (4.161.0, 2026-11-09); 17.50 KB: 17.28 KB with the full-screen phone Dock, the one-row header and its More menu (2026-11-09, owner: the pin unclear, pop-overs that did not close) + headroom; 16.80 KB: 16.55 KB with the phone header's two rows (4.150.0, 2026-11-08: the open tab read "Ac") + headroom; 16.40 KB: 16.30 KB with the link cards' loader (4.147.0, 2026-11-08; the card itself is dock-card.js, loaded the first time one shows) + headroom; 16.25 KB: 16.07 KB with OpenVibe Chat's hooks (share(), canSend(), the draft; 4.144.0, 2026-11-08), Send to… itself is dock-share.js (1.9 KB, loaded on first use); 16.0 KB: 15.74 KB with its siblings' ?v= content hashes (4.137.0, 2026-11-07) + headroom; 15.70 KB: 15.55 KB with Ask your agent (4.135.0, 2026-11-07) + headroom; was 15.50 KB (15435 after 4.120.0)
--const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-runs.js', 'ai-marks-client.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
++const SIBLINGS = ['action-center.js', 'notification-ui.js', 'notification-live.js', 'ov-mark.js', 'dock-vibe.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'dock-timer.js', 'dock-agent.js', 'dock-share.js', 'dock-card.js'];
// ── the budget ──────────────────────────────────────────────
{
ddiff --git a/test/menu-places-chrome.test.js b/test/menu-places-chrome.test.js
iindex 0cdf4a8..921c102 100644
---- a/test/menu-places-chrome.test.js
++++ b/test/menu-places-chrome.test.js
@@@ -55,6 +55,34 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`);
assert.ok(await until(`(document.querySelector('.ovs-starttabs [aria-selected="true"]') || {}).textContent.trim().startsWith('Feed')`), `${name}: Feed is chosen in place`);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
++ // On a phone, with the Dock able to lend its panels (OpenVibeDock.mountInto, 312 step 2): Messages and AI open
++ // INSIDE the menu, under its own head, and become the chosen place; AI has a Vibe · Agents switch; Feed and
++ // closing hand the panel back. A computer keeps the floating Dock.
++ await page.evaluate(`window.went = null; window.log = []; window.OpenVibeDock = { mountInto(host, o) { window.log.push('mount:' + o.tab); host.innerHTML = '<div class="stub"></div>';
++ return { show(t) { window.log.push('show:' + t); }, destroy() { window.log.push('destroy'); host.innerHTML = ''; } }; } }`);
++ await page.evaluate(OPEN(true)); await wait(350);
++ const inside = async () => page.evaluate(`({ door: !!document.querySelector('.ovs.ovs-in-door'), list: !document.querySelector('.ovs-list').hidden, stub: !!document.querySelector('.ovs-door-host .stub'),
++ sw: document.querySelector('.ovs-door-sw') && !document.querySelector('.ovs-door-sw').hidden ? Array.from(document.querySelectorAll('.ovs-door-sw [data-part]')).map((b) => b.textContent + (b.getAttribute('aria-selected') === 'true' ? '*' : '')).join(' ') : '',
++ chosen: Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab[aria-selected="true"]')).map((t) => t.textContent.trim()).join(' ') })`);
++ await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(120);
++ if (width <= 640) {
++ assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Vibe* Agents', chosen: 'AI' }, `${name}: AI opens inside the menu, on Vibe`);
++ assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`);
++ await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);
++ assert.equal((await inside()).sw, 'Vibe Agents*', `${name}: the switch goes to Agents`);
++ await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`); await wait(80);
++ assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`);
++ await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`); await wait(80);
++ assert.deepEqual(await inside(), { door: false, list: true, stub: false, sw: '', chosen: 'Feed' }, `${name}: Feed hands the panel back`);
++ await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(80);
++ await page.evaluate('OpenVibeSearch.close()'); await wait(300);
++ assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`);
++ } else {
++ assert.equal(await page.evaluate('window.went'), 'vibe', `${name}: AI still opens the floating Dock`);
++ assert.deepEqual(await page.evaluate('window.log'), [], `${name}: nothing mounted in the menu`);
++ await page.evaluate('OpenVibeSearch.close()'); await wait(300);
++ }
++ await page.evaluate('delete window.OpenVibeDock');
// Opened from a button: the sheet grows out of that button's centre, wherever the sheet itself was placed.
if (width > 900) {
await page.evaluate(OPEN(true).replace("frame: {},", `frame: {}, anchor: { top: 52, left: ${width - 150}, cx: ${width - 130}, cy: 26 },`));