openvibers/openvibe.shared Commits
4.198.0: on a phone the menu is one sheet; Messages and AI open inside it (#432)
Changes
@@@ -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@@@ -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 deployedconsumer 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` namesthe tag it pins.@@@ -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)",@@@ -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);@@@ -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');nnew file mode 100644@@@ -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);@@@ -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);@@@ -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; },@@@ -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);@@@ -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@@@ -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">.@@@ -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);@@@ -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",@@@ -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",@@@ -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(', ')} };`;@@@ -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.nnew file mode 100644@@@ -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); });@@@ -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 ──────────────────────────────────────────────{@@@ -55,6 +55,34 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).maawait 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 },`));
@@ -1,5 +1,26 @@
@@ -1,5 +1,26 @@
# Changelog
# 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
## 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
**The footer says "3 online", and the tooltip part works anywhere.** From the owner's notes of 2026-11-10: "Make it like
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**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
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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.197.0",
+ "version": "4.198.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"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)",
"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)",
@@ -347,8 +347,8 @@
@@ -347,8 +347,8 @@
async function refresh() {
async function refresh() {
if (gone) return;
if (gone) return;
if (o.active && !o.active()) 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();
refresh();
timer = setInterval(refresh, POLL_MS);
timer = setInterval(refresh, POLL_MS);
@@ -66,6 +66,12 @@
@@ -66,6 +66,12 @@
.ovda-acts button:disabled { opacity: .5; cursor: default; }
.ovda-acts button:disabled { opacity: .5; cursor: default; }
.ovda-acts svg { width: 14px; height: 14px; }
.ovda-acts svg { width: 14px; height: 14px; }
.ovda-acts button[data-act="end"] { color: color-mix(in srgb, var(--live-red, #e5484d) 65%, var(--text-primary, #ececf1)); }
.ovda-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; }
.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; } }
@keyframes ovda-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
`;
`;
@@ -93,7 +99,7 @@
@@ -93,7 +99,7 @@
const line = h('div', 'ovda-line');
const line = h('div', 'ovda-line');
const meta = h('div', 'ovda-meta');
const meta = h('div', 'ovda-meta');
const acts = h('div', 'ovda-acts');
const acts = h('div', 'ovda-acts');
- const head = h('div');
+ const head = h('div', 'ovda-head');
head.append(line, meta, acts);
head.append(line, meta, acts);
const body = h('div');
const body = h('div');
body.append(stage, head);
body.append(stage, head);
@@ -141,6 +147,7 @@
@@ -141,6 +147,7 @@
const live = agent.state === 'live';
const live = agent.state === 'live';
const ended = agent.state === 'ended';
const ended = agent.state === 'ended';
+ container.classList.toggle('is-ended', ended);
// The picture: the live embed when there is one, else the still.
// 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 embed = typeof agent.embed_url === 'string' && EMBED.test(agent.embed_url) ? agent.embed_url : null;
const shown = stage.querySelector('iframe');
const shown = stage.querySelector('iframe');
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -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);
@@ -53,7 +53,7 @@
@@ -53,7 +53,7 @@
const st = o.styles && o.styles(); if (st && !st.textContent.includes('.ovdk-overview{')) st.textContent += CSS;
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.
// 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');
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 request = async (path, init) => o.call(path, init);
const all = () => doc ? ['waiting','running','scheduled','done','failed'].flatMap((b) => doc.buckets[b] || []) : [];
const all = () => doc ? ['waiting','running','scheduled','done','failed'].flatMap((b) => doc.buckets[b] || []) : [];
function row(it) {
function row(it) {
@@ -96,7 +96,7 @@
@@ -96,7 +96,7 @@
// Through Network (every site's connect-src reaches openvibe.network and refuses openvibe.actor).
// Through Network (every site's connect-src reaches openvibe.network and refuses openvibe.actor).
const data = await request('/api/v1/me/actions/skills');
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);
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'));
box.append(h('h3', '', 'Things agents can do for you'));
const grid = h('div', 'ovdk-skills');
const grid = h('div', 'ovdk-skills');
available.forEach((s) => {
available.forEach((s) => {
@@ -123,13 +123,13 @@
@@ -123,13 +123,13 @@
tile.append(h('small', '', label), n, sub); tiles.append(tile); }); box.append(tiles);
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); }
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; }
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());
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.')); }
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); }
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 };
root.OpenVibeDockRuns = { mount };
})(typeof window !== 'undefined' ? window : globalThis);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -66,7 +66,7 @@
@@ -66,7 +66,7 @@
// browser without it (Firefox) records instead, up to 30 s, and Chat's /api/vibe/listen turns that into text
// 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).
// (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.
// 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 SR = root.SpeechRecognition || root.webkitSpeechRecognition || null;
const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null;
const MR = !SR && root.MediaRecorder && root.navigator && root.navigator.mediaDevices && root.navigator.mediaDevices.getUserMedia ? root.MediaRecorder : null;
const TALK = Boolean(SR || MR);
const TALK = Boolean(SR || MR);
@@ -129,6 +129,7 @@
@@ -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`; };
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() {
async function openList() {
+ const generation = ++vibe.generation;
clearTimeout(vibe.timer); vibe.view = 'list'; vibe.chat = null;
clearTimeout(vibe.timer); vibe.view = 'list'; vibe.chat = null;
const panel = vibe.panel; if (!panel) return;
const panel = vibe.panel; if (!panel) return;
panel.textContent = '';
panel.textContent = '';
@@ -139,7 +140,7 @@
@@ -139,7 +140,7 @@
const first = h('li'); first.append(newer); list.append(first); scroll.append(list); panel.append(head, scroll);
const first = h('li'); first.append(newer); list.append(first); scroll.append(list); panel.append(head, scroll);
try {
try {
const out = await ctx.chatCall('/api/vibe/conversations');
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 : [];
const conversations = Array.isArray(out.conversations) ? out.conversations : [];
if (!conversations.length) list.append(h('li', 'ovdk-none', 'Your Vibe conversations will appear here.'));
if (!conversations.length) list.append(h('li', 'ovdk-none', 'Your Vibe conversations will appear here.'));
conversations.forEach((c) => {
conversations.forEach((c) => {
@@ -156,9 +157,10 @@
@@ -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(); });
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);
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() {
async function openVibe() {
+ const generation = ++vibe.generation;
clearTimeout(vibe.timer);
clearTimeout(vibe.timer);
vibe.view = 'conversation';
vibe.view = 'conversation';
const panel = vibe.panel; if (!panel) return;
const panel = vibe.panel; if (!panel) return;
@@ -219,6 +221,7 @@
@@ -219,6 +221,7 @@
try {
try {
let timezone = null; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { /* */ }
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 }) });
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() } }); }
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();
box.value = ''; fit();
const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove();
const empty = list.querySelector('.ovdk-vibe-start'); if (empty) empty.remove();
@@ -238,10 +241,10 @@
@@ -238,10 +241,10 @@
try {
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}` : ''}` : ''}`);
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) {
} catch (e) {
- if (first) { list.textContent = ''; showFailure(e, () => pull(true)); }
+ if (first && generation === vibe.generation) { list.textContent = ''; showFailure(e, () => pull(true)); }
return;
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 (got.chat) { vibe.chat = got.chat; away.href = got.chat.url; title.textContent = got.chat.name || 'Vibe'; }
if (first) list.textContent = '';
if (first) list.textContent = '';
const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
@@ -362,7 +365,7 @@
@@ -362,7 +365,7 @@
return {
return {
refresh: () => { vibe.panel = panel; const list = ctx.wantList ? ctx.wantList() : false; if (list) return openList(); return vibe.view === 'list' ? openList() : openVibe(); },
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(); },
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(); },
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.
// 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; },
ask: (text) => { if (vibe.view === 'list') { vibe.pendingAsk = text; vibe.fresh = true; openVibe(); } else if (vibe.fill) vibe.fill(text); else vibe.pendingAsk = text; },
@@ -1,30 +1,5 @@
@@ -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) {
(function (root) {
'use strict';
'use strict';
if (typeof document === 'undefined' || root.OpenVibeDock) return;
if (typeof document === 'undefined' || root.OpenVibeDock) return;
@@ -59,7 +34,7 @@
@@ -59,7 +34,7 @@
const SAVED_TABS = ['vibe', 'agent', 'chats', 'activity'];
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 } } };
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.
// 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;
let pipWin = null, popped = false, pipClosing = false, popOk = true;
const mounts = {};
const mounts = {};
@@ -113,7 +88,7 @@
@@ -113,7 +88,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// 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) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -376,6 +351,7 @@
@@ -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>
<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>`;
<span class="ovdk-size" aria-hidden="true"></span>`;
document.body.appendChild(el);
document.body.appendChild(el);
+ bodyEl = el.querySelector('.ovdk-body');
const tabs = [...el.querySelectorAll('[role="tab"]')];
const tabs = [...el.querySelectorAll('[role="tab"]')];
tabs.forEach((b) => b.addEventListener('click', () => setTab(b.dataset.tab, true)));
tabs.forEach((b) => b.addEventListener('click', () => setTab(b.dataset.tab, true)));
@@ -542,24 +518,39 @@
@@ -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";
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) {
function setTab(id, remember) {
+ if (id === 'agents') id = 'agent';
+ if (inline && tab !== id) disposePanel();
tab = TABS.some(([t]) => t === id) ? id : 'activity';
tab = TABS.some(([t]) => t === id) ? id : 'activity';
if (tab !== 'chats') clearTimeout(chats.timer);
if (tab !== 'chats') clearTimeout(chats.timer);
if (tab !== 'vibe' && vibe.api) vibe.api.pause();
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('[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 });
if (remember && state().tab !== tab && SAVED_TABS.includes(tab)) save({ tab });
fill(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) {
async function fill(id) {
- const panel = el.querySelector(`[data-panel="${id}"]`);
+ const epoch = panelEpoch;
+ const panel = bodyEl.querySelector(`[data-panel="${id}"]`);
if (id === 'vibe') {
if (id === 'vibe') {
vibe.panel = panel;
vibe.panel = panel;
if (vibe.api) return vibe.api.refresh();
if (vibe.api) return vibe.api.refresh();
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
const ok = await load('dock-vibe.js', () => !!(root.OpenVibeDockVibe && root.OpenVibeDockVibe.mount));
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; }
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;
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(); }
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 @@
@@ -569,23 +560,24 @@
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
if (id === 'agent') {
if (id === 'agent') {
const ok = await load('dock-runs.js', () => !!(root.OpenVibeDockRuns && root.OpenVibeDockRuns.mount));
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);
if (!ok) return failed(panel, id);
// K-D3: the person's own agent stream above the list; it hides itself when there is none.
// 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>';
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
// 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.
// phone Now and Needs you are on screen before anything else.
load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
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.
// 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) => {
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) => {
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'), {
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'),
styles: () => (el && el.ownerDocument.getElementById('ovdk-styles')) || document.getElementById('ovdk-styles'),
foot: panel.querySelector('.ovdk-runs-foot'),
foot: panel.querySelector('.ovdk-runs-foot'),
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
onCounts: (c) => { doc = { ...(doc || {}), state: state(), counts: { ...counts(), running: c.running || 0, waiting: c.waiting || 0 } }; badges(); notify(); },
@@ -594,6 +586,7 @@
@@ -594,6 +586,7 @@
return;
return;
}
}
const ok = await load('notification-ui.js', () => !!(root.OpenVibeNotifications && root.OpenVibeNotifications.renderDock));
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);
if (!ok) return failed(panel, id);
const N = root.OpenVibeNotifications;
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 { /* */ } }
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 @@
@@ -630,7 +623,7 @@
function readUnread() {
function readUnread() {
clearTimeout(chats.unreadT);
clearTimeout(chats.unreadT);
chatCall('/api/dm/unread').then((b) => { chats.unread = Number(b && b.unread) || 0; badges(); }).catch(() => { /* Chat away: no count */ });
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 when = (iso) => {
const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';
const t = Date.parse(iso || ''); if (!Number.isFinite(t)) return '';
@@ -671,9 +664,9 @@
@@ -671,9 +664,9 @@
const [dm, ai] = await Promise.all([chatCall('/api/dm/conversations'), chatCall('/api/vibe/conversations').catch(() => null)]);
const [dm, ai] = await Promise.all([chatCall('/api/dm/conversations'), chatCall('/api/vibe/conversations').catch(() => null)]);
chats.list = dm.conversations || [];
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;
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 (sharing) await sharing;
- if (chats.open || tab !== 'chats') return;
+ if (panel !== chats.panel || !panelActive('chats') || chats.open) return;
panel.textContent = '';
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); } }));
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');
const scroll = h('div', 'ovdk-scroll');
@@ -781,10 +774,10 @@
@@ -781,10 +774,10 @@
const id = chats.open;
const id = chats.open;
let got = [];
let got = [];
try { got = (await chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/messages?limit=50${chats.last ? `&after=${chats.last}` : ''}`)).messages || []; } catch (e) {
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;
return;
}
}
- if (id !== chats.open) return;
+ if (id !== chats.open || !panelActive('chats')) return;
if (first) list.textContent = '';
if (first) list.textContent = '';
const atEnd = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 40;
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); });
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 @@
@@ -792,7 +785,7 @@
if (first || atEnd) scroll.scrollTop = scroll.scrollHeight;
if (first || atEnd) scroll.scrollTop = scroll.scrollHeight;
if (got.length || first) chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/read`, { method: 'POST', body: '{}' }).then(readUnread).catch(() => { /* */ });
if (got.length || first) chatCall(`/api/dm/conversations/${encodeURIComponent(id)}/read`, { method: 'POST', body: '{}' }).then(readUnread).catch(() => { /* */ });
clearTimeout(chats.timer);
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
// ── 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 @@
@@ -863,7 +856,7 @@
const next = r.ok ? await r.json() : null;
const next = r.ok ? await r.json() : null;
if (!next || !next.state || !pill) return;
if (!next || !next.state || !pill) return;
update(next);
update(next);
- if (!isOpen() && next.state.open && !phone()) open();
+ if (!isOpen() && !inline && next.state.open && !phone()) open();
const c = next.counts || {};
const c = next.counts || {};
if (c.running || c.waiting || next.state.timer) readT = setTimeout(read, 30000);
if (c.running || c.waiting || next.state.timer) readT = setTimeout(read, 30000);
} catch { /* offline: the copy stands */ }
} catch { /* offline: the copy stands */ }
@@ -871,6 +864,7 @@
@@ -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). */
/** The pill for a signed-in person (navbar.js), or none (null: signed out, or the site turned the Dock off). */
function mount(opts) {
function mount(opts) {
if (!opts) {
if (!opts) {
+ if (mountHandle) mountHandle.destroy();
clearTimeout(readT);
clearTimeout(readT);
if (pill) { pill.remove(); pill = null; }
if (pill) { pill.remove(); pill = null; }
if (isOpen()) close();
if (isOpen()) close();
@@ -1016,6 +1010,7 @@
@@ -1016,6 +1010,7 @@
open({ tab: 'chats' });
open({ tab: 'chats' });
}
}
function open(opts = {}) {
function open(opts = {}) {
+ if (mountHandle) mountHandle.destroy();
openedAt = typeof performance !== 'undefined' && performance.now ? performance.now() : 0;
openedAt = typeof performance !== 'undefined' && performance.now ? performance.now() : 0;
o = { ...o, ...opts };
o = { ...o, ...opts };
if (opts.doc) doc = opts.doc;
if (opts.doc) doc = opts.doc;
@@ -1053,7 +1048,7 @@
@@ -1053,7 +1048,7 @@
}
}
shut();
shut();
}
}
- function shut() {
+ function shut(keepState = false) {
if (!isOpen()) return;
if (!isOpen()) return;
unlinkOrb();
unlinkOrb();
const wasPopped = popped;
const wasPopped = popped;
@@ -1066,7 +1061,7 @@
@@ -1066,7 +1061,7 @@
document.documentElement.classList.remove('ovdk-sheet-open');
document.documentElement.classList.remove('ovdk-sheet-open');
clear(0);
clear(0);
clearTimeout(chats.timer); clearTimeout(chats.unreadT);
clearTimeout(chats.timer); clearTimeout(chats.unreadT);
- save({ open: false });
+ if (!keepState) save({ open: false });
if (inside) {
if (inside) {
const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
const back = pill && !pill.hidden ? pill : (lastFocus && lastFocus.isConnected ? lastFocus : null);
try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }
try { if (back) back.focus({ preventScroll: true }); } catch { /* */ }
@@ -1085,5 +1080,27 @@
@@ -1085,5 +1080,27 @@
if (isOpen()) layout();
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);
})(typeof window !== 'undefined' ? window : globalThis);
@@ -23,6 +23,7 @@ const BROWSER = Object.freeze([
@@ -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)
'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
'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.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
'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
'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
'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
@@ -517,11 +517,7 @@
@@ -517,11 +517,7 @@
.openvibe-navbar .openvibe-network-badge { display: none; }
.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; }
.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"]):not(:disabled):active { scale: .96; }
:where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
:where(.openvibe-navbar, .openvibe-navbar-dropdown, .ovnav-drawer) :where(a, button, [role="button"], [tabindex]:not([tabindex="-1"])):focus-visible {
@@ -566,7 +562,7 @@
@@ -566,7 +562,7 @@
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '148a0eda9ca8'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// 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
// 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
// (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">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -983,6 +983,7 @@
@@ -983,6 +983,7 @@
}, true);
}, true);
attachRowSwipe(v, list);
attachRowSwipe(v, list);
refreshPushButton(v);
refreshPushButton(v);
+ v.destroy = () => { clearTimeout(qTimer); v.seq++; };
_views.set(root, v);
_views.set(root, v);
paintTabCounts();
paintTabCounts();
return v;
return v;
@@ -1483,6 +1484,13 @@
@@ -1483,6 +1484,13 @@
reload(v);
reload(v);
return 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. */
/** Programmatically show a toast (real-time events). Marks nothing on the server. */
push(notification) {
push(notification) {
showToast(notification);
showToast(notification);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.197.0",
+ "version": "4.198.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.197.0",
+ "version": "4.198.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -48,6 +48,7 @@
@@ -48,6 +48,7 @@
"./notification-live": "./notification-live.js",
"./notification-live": "./notification-live.js",
"./action-center": "./action-center.js",
"./action-center": "./action-center.js",
"./dock": "./dock.js",
"./dock": "./dock.js",
+ "./dock-mount": "./dock-mount.js",
"./presence": "./presence.js",
"./presence": "./presence.js",
"./purse": "./purse.js",
"./purse": "./purse.js",
"./drop": "./ov-drop.js",
"./drop": "./ov-drop.js",
@@ -23,7 +23,7 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
@@ -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).
// 4.193.0: the one tooltip (Sign in, the menu button, short labels).
'ov-tip.js'];
'ov-tip.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
// 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'] },
'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'] } };
'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(', ')} };`;
const table = (name, files) => ` const ${name} = { ${files.map((f) => `'${f}': '${hashAsset(fs.readFileSync(path.join(ROOT, f)))}'`).join(', ')} };`;
@@ -77,6 +77,8 @@
@@ -77,6 +77,8 @@
const RTABS = [['all', 'All'], ['openvibe', 'OpenVibe'], ['web', 'Web'], ['video', 'Video'], ['news', 'News'], ['products', 'Products'], ['mine', 'Yours']];
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' };
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;
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; } };
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) {
function siteOf(url) {
@@ -269,6 +271,18 @@
@@ -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; }
.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); } }
@keyframes ovs-rise { from { opacity: 0; transform: translateY(6px); } }
.ovs-list { list-style: none; margin: 0; padding: 6px; overflow-y: auto; overscroll-behavior: contain; }
.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 { 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-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; }
.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 @@
@@ -444,6 +458,13 @@
input = el.querySelector('.ovs-input');
input = el.querySelector('.ovs-input');
ghost = el.querySelector('.ovs-ghost');
ghost = el.querySelector('.ovs-ghost');
listEl = el.querySelector('.ovs-list');
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');
tabsEl = el.querySelector('.ovs-tabs');
rtabsEl = el.querySelector('.ovs-rtabs');
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(); } });
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 @@
@@ -456,6 +477,7 @@
el.appendChild(orbEl);
el.appendChild(orbEl);
tabsEl.addEventListener('click', (e) => {
tabsEl.addEventListener('click', (e) => {
const b = e.target.closest && e.target.closest('[data-tab]'); if (!b) return;
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;
tab = b.getAttribute('data-tab'); if (tab === 'feed') markFeedSeen(); render(''); listEl.scrollTop = 0;
let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
let touch = false; try { touch = root.matchMedia('(pointer: coarse)').matches; } catch { /* */ }
if (touch) b.focus(); else input.focus();
if (touch) b.focus(); else input.focus();
@@ -1007,6 +1029,36 @@
@@ -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
* 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.
* 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() {
function paintTabs() {
tabsEl.hidden = false;
tabsEl.hidden = false;
tabsEl.setAttribute('role', 'tablist');
tabsEl.setAttribute('role', 'tablist');
@@ -1022,20 +1074,22 @@
@@ -1022,20 +1074,22 @@
const doors = typeof ctx.dock === 'function' ? [['chats', 'Messages'], ['vibe', 'AI']] : [];
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]];
const row = [TABS[0], TABS[1], ...doors.map(([id, label]) => [id, label, true]), TABS[2]];
tabsEl.classList.toggle('has-doors', doors.length > 0);
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');
const b = document.createElement('button');
b.type = 'button'; b.className = 'ovs-tab'; b.setAttribute('role', 'tab');
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 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);
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);
tabsEl.appendChild(b);
continue;
continue;
}
}
b.setAttribute('data-tab', id);
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';
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); }
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)) {
else if (id === 'you' && me && me.avatar_url && /^https:\/\//.test(me.avatar_url)) {
@@ -1297,6 +1351,7 @@
@@ -1297,6 +1351,7 @@
function render(q) {
function render(q) {
q = String(q || '').trim();
q = String(q || '').trim();
el.classList.toggle('ovs-typing', !!q);
el.classList.toggle('ovs-typing', !!q);
+ if (q) leaveDoor();
tabsEl.hidden = !!q;
tabsEl.hidden = !!q;
if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); paintTabs(); renderTab(); return; }
if (!q) { if (inflight) { inflight.abort(); inflight = null; } el.classList.remove('ovs-busy'); paintTabs(); renderTab(); return; }
const n = q.toLowerCase();
const n = q.toLowerCase();
@@ -1517,6 +1572,7 @@
@@ -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; } });
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 } = {}) {
function close({ navigating = false } = {}) {
if (view === 'results' && rtabsEl) leaveResults();
if (view === 'results' && rtabsEl) leaveResults();
+ leaveDoor();
if (!el || closing) return;
if (!el || closing) return;
if (navigating) pushed = false;
if (navigating) pushed = false;
// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
// Closed by the person (Close, Escape, a tap outside): take back the entry Back would have used.
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -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); });
@@ -22,7 +22,7 @@ const DOCK_PATH = path.join(ROOT, 'dock.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
// 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.
// 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 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 ──────────────────────────────────────────────
// ── the budget ──────────────────────────────────────────────
{
{
@@ -55,6 +55,34 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -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()`);
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`);
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);
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.
// Opened from a button: the sheet grows out of that button's centre, wherever the sheet itself was placed.
if (width > 900) {
if (width > 900) {
await page.evaluate(OPEN(true).replace("frame: {},", `frame: {}, anchor: { top: 52, left: ${width - 150}, cx: ${width - 130}, cy: 26 },`));
await page.evaluate(OPEN(true).replace("frame: {},", `frame: {}, anchor: { top: 52, left: ${width - 150}, cx: ${width - 130}, cy: 26 },`));