openvibers/openvibe.shared Commits
openvibe-shared 4.225.0: Vibe is one place; the menu scrolls on a phone; GitHub in the +; one app icon (#504)
Changes
@@@ -1,5 +1,65 @@# Changelog++## 4.225.0 — 2026-11-12++++**The menu's Vibe is one place: All · Chats · Tasks · Code, no switch** (the owner, 11-12, crossing out the Chat · Code ·++Agents switch: "one new unified agent … a shared UI").++++- **Vibe opens on All:** every run from the Frame (Vibe chats, tasks, coding runs) in one list (`ov-run.js`, 4.224.0).++ - The Action Center's waiting and running work from every product (Network `/api/v1/me/actions`) joins it, unless a++ run already shows it. What needs you comes first, approved or declined in place; the rest open their own pages.++ - Above the list: the Dock's Now and timeline, your Actor and your agent's live view (lent with++ `OpenVibeDock.mountInto(el, { tab: 'agent', part: 'top' })`).++ - Vibe's six starters sit above the box; a press starts that run. Enter in the menu's field starts a run too.++- **Chats, Tasks and Code are the Dock's own panels, lent whole** (Vibe's conversations with voice, undo and the timer;++ Agents with its history, skills and filters; Code with its repos). They move into the one view when it does all they++ do, so nothing is lost on the way.++- The Dock's places open their filter: `OpenVibeDock.open({ tab: 'vibe' })` opens Chats, `'agent'` Tasks, `'code'` Code.++ The status on the menu button still opens what needs you. `OpenVibeNavbar.openMenu()` also takes `'code'` and++ `'tasks'`.++- **`ov-run.js` gains:**++ - `o.starters`;++ - `o.actions: { base }`, the Action Center's work in the list;++ - `o.top(el)`, the host's slot above the list on All;++ - `o.panes: { chat, task, code }`, filters the host draws itself;++ - `o.onPlace`;++ - `view.prefill(text)`, words in the box, unsent.++- A system light theme darkens the agents' glyphs.++- **The menu scrolls on a phone wherever the swipe begins** (the owner, 11-12: "scrolling is very buggy … on mobile …++ especially in notifications").++ - Inside the menu, the notifications list was a scroll box of its own that held its overscroll. It never overflowed++ there, so a swipe that began on a notice moved nothing.++ - Inside the menu it is now part of the menu's one scroller (one scroller per layer).++ - Measured with real touches at 390 px, and checked on every tab: nothing else nests a scroll box like it.++ - New test `notifications-menu-scroll-chrome` (it fails without the fix).++ - `notification-ui.js`'s brotli budget grows from 25.9 to 26.1 KB for it.++- **The composer's + has GitHub.**++ - It searches repositories through OpenVibe Exchange's code search (the Frame's `/api/frame/code/search`, Network++ #878) a quarter second after the last key.++ - Each row shows the owner's picture (GitHub's avatar host only), the name, the description, the language, stars++ and the licence, all as text. Enter or a press puts the repository's address in the box, where every site draws++ it as a card.++ - A box may point it at another Network with `data-ovc-frame`. `composer-v2-chrome` checks it.++- **One app icon for every site: `.ov-appicon` and `OpenVibeIcons.app(site, { size, label })`** (design rule 142).++ - It is the menu's and Home's own icon: the app's steady hue on a rounded square, with its glyph drawn over it.++ - Use it from markup (`<span class="ov-appicon" data-icon="live" data-size="64">`) or from script. Markup added later,++ or an app that changes, is drawn again.++ - With a label it is an image with a name; otherwise it is decoration. `OpenVibeIcons.hue(id)` is the shared hue.++ `ov-appicon-chrome` checks it.++- **Home on a phone: a long press then a move keeps the app under the finger.**++ - Entering Edit repaints the grid, so the tile under the finger left the page, and the grid's touch guard no longer++ heard that touch: the first move scrolled the menu and cancelled the press.++ - The guard now rides on the pressed tile itself until the finger lifts.++ - `menu-home-screen-chrome` drags with a real touch (it fails without the fix).++- The menu reads runs and the Action Center from the Dock's Network base (`runsBase`).++- **Tests:**++ - `dock-chrome` gains the All view at 390 px: the four filters on All; a run and the Action Center's work in one++ order; the Dock's top lent without its list; the starters; Tasks as the Agents panel, remembered by the Network;++ approve in place from All. Every Dock check before it still passes unchanged.++ - `run-thread-chrome` gains the starters, the Action Center's rows (nothing twice), the host's slot, the place and++ prefill.++ - `menu-places-chrome` and `menu-home-screen-chrome` follow the filters in place of the switch.++## 4.224.0 — 2026-11-12**One thread view for every run: chats, tasks and code** (the owner, 11-12, crossing out the menu's Chat · Code · Agents@@@ -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.224.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.225.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.224.0",++ "version": "4.225.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)",@@@ -114,7 +114,7 @@// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';-- const VIBE_REV = { 'ov-composer.js': '2d3782ec0b52' };++ const VIBE_REV = { 'ov-composer.js': '293808b32ad7' };function joinComposer(form) {if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain boxform.setAttribute('data-ovc-no-files', '');@@@ -6,6 +6,9 @@** OpenVibeDock.mount(opts) a signed-in person's state and counts (navbar.js; null when signed out)* OpenVibeDock.mountInto(host, { tab }) the panel body in the menu ('vibe' | 'code' | 'agent' | 'chats')++ * OpenVibeDock.mountInto(host, { tab: 'agent', part: 'top' }) 4.225.0: only the top of Agents (Now and the++ * timeline, your Actor, the live agent view), for the menu's Vibe, which++ * draws the runs and the Action Center's work itself (ov-run.js)* OpenVibeDock.open({ tab }) / ask({ text }) / share({ text, title }) open the menu there (opts.openMenu)* What the old pill said (an agent needs you, agents working, a timer's minutes) goes to opts.status, which navbar.js* paints on the bar's menu button.@@@ -35,6 +38,7 @@const DEFAULT_STATE = { tab: 'vibe', agent: null, chat: null, vibe: null };let bodyEl = null, inline = null, panelEpoch = 0, o = {}, doc = null, tab = 'vibe', saveT = 0, pending = {};++ let part = null; // 'top': only the top of the Agents panel is lent (mountInto … part: 'top', 4.225.0)const mounts = {};const state = () => (doc && doc.state) || DEFAULT_STATE;@@@ -80,7 +84,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': 'e34bcde16fb8', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };++ const DOCK_REV = { 'dock-vibe.js': 'a918b45c0dae', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -267,6 +271,13 @@if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); if (mounts[id + 'Timeline']) mounts[id + 'Timeline'].refresh(); return; }mounts[id] = { refresh: null };panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';++ if (id === 'agent' && part === 'top') {++ // 4.225.0: only the top is lent (the menu's Vibe draws the runs and the Action Center's work itself).++ panel.innerHTML = '<div class="ovdk-actor"></div><div class="ovdk-timeline"></div><div class="ovdk-agent"></div>';++ agentTop(id, panel, epoch);++ mounts[id] = { refresh() { ['Timeline', 'Actor', 'View'].forEach((k) => { const m = mounts[id + k]; if (m && m.refresh) m.refresh(); }); } };++ return;++ }if (id === 'code') {const ok = await load('dock-code.js', () => !!(root.OpenVibeDockCode && root.OpenVibeDockCode.mount));if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; }@@@ -280,18 +291,7 @@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 && 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 && 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 && 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') });-- });++ agentTop(id, panel, epoch);const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {call, state, save, scopes: o.scopes, active: () => panelActive('agent'), actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),styles: () => document.getElementById('ovdk-styles'),@@@ -303,6 +303,20 @@}delete mounts[id];}++ /** The top of the Agents panel: Now and the one timeline (design 2026-11-07, first, so on a phone Now and Needs you++ * are on screen before anything else), your Actor (Actor v3 A3; nothing for a person without one) and your own++ * agent's live view (K-D3; it hides itself when there is none). Each loads on its own. */++ function agentTop(id, panel, epoch) {++ load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {++ 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 });++ });++ load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => {++ 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 && 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') });++ });++ }function failed(panel, id) {mounts[id] = null;panel.innerHTML = `<div class="ovdk-none">That part did not load. Check your connection, then open it again.</div>`;@@@ -674,7 +688,8 @@// ── Open: the menu, on one of its places ──// Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.-- function ask({ text = '' } = {}, n = 0) { if (!n && !panelActive('vibe')) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask && panelActive('vibe')) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }++ function ask({ text = '' } = {}, n = 0) {++ if (!n && !panelActive('vibe')) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask && panelActive('vibe')) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }function isOpen() { return !!inline; }function share({ text = '', title = '' } = {}) {const t = String(text || '').trim().slice(0, 4000);@@@ -694,7 +709,7 @@let id = want || (c.waiting || c.running ? 'agent' : state().tab || 'vibe');if (id === 'agents') id = 'agent';if (!TABS.some(([t]) => t === id)) id = 'vibe';-- if (inline && id === tab) return;++ if (inline && id === tab && part !== 'top') return;if (typeof o.openMenu === 'function') o.openMenu(id);}function close() { if (typeof o.closeMenu === 'function') o.closeMenu(); }@@@ -709,7 +724,7 @@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 = 'vibe' } = {}) {++ function mountInto(host, { tab: first = 'vibe', part: lend = null } = {}) {if (!(host instanceof Element)) throw new TypeError('mountInto needs a host element');if (mountHandle) mountHandle.destroy();let api = null, gone = false, wanted = first;@@@ -720,9 +735,10 @@mountHandle = handle;load('dock-mount.js', () => !!root.OpenVibeDockMount).then((ok) => {if (ok && !gone) api = root.OpenVibeDockMount.mount(host, wanted, {-- start(wrap) { if (!bodyEl) build(); inline = wrap; wrap.append(bodyEl); },-- show(id) { setTab(id, true); },-- end() { disposePanel(); inline = null; if (bodyEl) bodyEl.remove(); paintStatus(); },++ start(wrap) { if (!bodyEl) build(); part = lend === 'top' ? 'top' : null; wrap.classList.toggle('ovdk-part-top', part === 'top'); inline = wrap; wrap.append(bodyEl); },++ // A lent top is not a place the person chose: the menu's Vibe remembers its own (remember()).++ show(id) { setTab(id, part !== 'top'); },++ end() { disposePanel(); inline = null; part = null; if (bodyEl) bodyEl.remove(); paintStatus(); },});});return handle;@@@ -604,7 +604,7 @@* serves its own pinned copy at /shared/ gets its own), else the network's copy.*/// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'b5f1f966bae3', 'dock.js': '1fb16839cb0e', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4086b813ffda', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '6215643437c0', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'c8da956d1e28', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'f5de8ef6193b', 'ov-composer.js': '2d3782ec0b52' };++ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '146282739c06', 'dock.js': '1ae182836461', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '0cef1f895965', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '437395e64eda', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': 'a7636d0bcc02', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'c8da956d1e28', 'soft.js': '98ab9b8080b1', 'ov-run.js': '91be6059adcf', 'ov-composer.js': '293808b32ad7' };const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;// ov-tip.js loads at the first hover over a tooltip's owner.@@@ -2384,7 +2384,7 @@if (!root.OpenVibeSearch) return;if (_navEl) _navEl.classList.remove('ovnav-hide');root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: fromEl || null, section, door,-- frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,++ frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, runsBase: dockBase(), user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },look: bindLook,@@@ -2784,7 +2784,7 @@/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },/** The menu at a place (4.223.0). */-- openMenu(p) { if (/^(vibe|chats|agent)$/.test(p)) return openMenuOn(p); const y = /^(notifications|recent|apps|you)$/.test(p); openSearch('', y ? 'you' : p === 'feed' ? 'feed' : 'here', null, y && p !== 'you' ? p : null); },++ openMenu(p) { if (/^(vibe|chats|agent|code|tasks)$/.test(p)) return openMenuOn(p); const y = /^(notifications|recent|apps|you)$/.test(p); openSearch('', y ? 'you' : p === 'feed' ? 'feed' : 'here', null, y && p !== 'you' ? p : null); },pop: popFrom,unpop,askAgent(text) { askAgent(text); },@@@ -203,6 +203,11 @@.openvibe-notif-panel, .openvibe-notif-inbox, .openvibe-notif-dock { --ovn-bg: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }.openvibe-notif-dock { display: flex; flex-direction: column; min-height: 0; flex: 1; color: var(--text-primary, #ececf1); }.openvibe-notif-dock .openvibe-nv-head { display: none; }++ /* 4.225.0 (the owner, 11-12: "scrolling is very buggy … on mobile … especially in notifications"): inside the menu++ the list is not a scroll box of its own. It never overflowed there, but as a scroll box that holds its++ overscroll it took every swipe that began on a notice and passed none of it to the menu, so nothing moved.++ One scroller per layer: the menu's own list. */++ .openvibe-notif-dock .openvibe-nv-list { flex: none; overflow: visible; overscroll-behavior: auto; }.openvibe-notif-dock .openvibe-nv-tools { padding-top: 10px; }.openvibe-notif-dock.is-preview .openvibe-nv-tools, .openvibe-notif-dock.is-preview .openvibe-nv-foot, .openvibe-notif-dock.is-preview .openvibe-nv-head, .openvibe-notif-dock.is-preview .openvibe-nv-more { display: none; }.openvibe-notif-dock .openvibe-nv-foot { gap: 12px; }@@@ -279,6 +279,23 @@ html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }/* Emoji: a small panel above the box. */.ovc-emoji { position: absolute; z-index: 41; left: 8px; bottom: calc(100% + 8px); display: grid; grid-template-columns: repeat(8, 38px); gap: 2px; max-width: calc(100% - 16px); max-height: 232px; overflow: auto; padding: 8px; border-radius: 16px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .18)); background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }.ovc-emoji[hidden] { display: none; }++.ovc-repo { position: absolute; z-index: 41; left: 8px; right: 8px; bottom: calc(100% + 8px); display: grid; gap: 6px; max-width: 520px; max-height: min(420px, 60vh); padding: 10px; border-radius: 16px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .18)); background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }++.ovc-repo[hidden] { display: none; }++.ovc-repo-q { width: 100%; box-sizing: border-box; min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .22)); background: var(--bg-primary, rgba(0, 0, 0, .2)); color: inherit; font: inherit; font-size: 15px; outline: 0; }++.ovc-repo-q:focus-visible { border-color: var(--ovt-accent, var(--accent, #3b82f6)); }++.ovc-repo-list { display: grid; gap: 2px; overflow-y: auto; overscroll-behavior: contain; min-height: 0; }++.ovc-repo-list:empty { display: none; }++.ovc-repo-row { display: flex; align-items: flex-start; gap: 10px; width: 100%; min-height: 52px; padding: 8px; border: 0; border-radius: 12px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }++.ovc-repo-row:hover, .ovc-repo-row:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); outline: 0; }++.ovc-repo-av { flex: none; width: 32px; height: 32px; border-radius: 9px; object-fit: cover; display: grid; place-items: center; background: color-mix(in srgb, currentColor 8%, transparent); }++.ovc-repo-av svg { width: 18px; height: 18px; }++.ovc-repo-t { min-width: 0; display: grid; gap: 1px; }++.ovc-repo-t b { font-size: 14px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }++.ovc-repo-t small { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++.ovc-repo-t i { font-style: normal; font-size: 11.5px; color: var(--text-muted, #93a0b4); }++.ovc-repo-note { margin: 2px 4px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }++.ovc-repo-note[hidden] { display: none; }++html[data-ov-motion=off] .ovc-repo { animation: none; }.ovc-emoji button { width: 38px; height: 38px; border: 0; border-radius: 10px; background: none; font-size: 22px; line-height: 1; cursor: pointer; }.ovc-emoji button:hover, .ovc-emoji button:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }@media (max-width: 480px) { .ovc-v2.ovt-composer { padding: 12px 12px 8px; } .ovc-emoji { grid-template-columns: repeat(7, 38px); } .ovc-v2 .ovt-send { padding: 0 16px; } }@@@ -370,6 +387,9 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { animic: 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',plus: 'M12 5v14M5 12h14',timer: 'M12 4.5a8.2 8.2 0 1 1 0 16.4 8.2 8.2 0 0 1 0-16.4zM12 8.6v4.2l2.7 1.7M9.6 2.6h4.8',++ // A repository: a branch from one commit to two (the registry's git-branch; no company's mark).++ repo: 'M6.5 4.5v15M17.5 10a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2zM6.5 21.8a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2zM17.5 10c0 4.5-4 6.2-11 6.6',++ star: 'M12 4.2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4-3.9-3.8 5.4-.8z',up: 'M12 19V5.5M6.2 11.2L12 5.4l5.8 5.8',x: 'M7 7l10 10M17 7L7 17',file: 'M7 3h7l5 5v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM14 3v5h5',@@@ -938,6 +958,9 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { anipanel.querySelector('button').focus();},},++ // 4.225.0: a repository from GitHub, found through OpenVibe Exchange's code search (the Frame's++ // /api/frame/code/search): its address goes into the box, where every site draws it as a card.++ github: { label: 'GitHub', path: IC.repo, extra: true, run: (ctx, button, form) => openRepoSearch(ctx, button, form) },voice: { label: 'Speak', builtin: true }, // enhance() draws the microphone where the browser can listencamera: { label: 'Camera', path: IC.camera, run: (ctx, button) => openCamera(ctx, button) },};@@@ -1006,6 +1029,70 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { aniif (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');start();}++ /** The GitHub tool's panel: a field, the repositories as you type (a quarter second after the last key), one press++ * puts the address in the box. Everything is text; only https pictures from GitHub's avatar host are drawn. */++ function openRepoSearch(ctx, button, form) {++ let panel = form.querySelector('.ovc-repo');++ if (panel) { panel.hidden = !panel.hidden; if (!panel.hidden) panel.querySelector('input').focus(); return; }++ styles();++ panel = el('div', 'ovc-repo'); panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'A repository from GitHub');++ const field = el('input', 'ovc-repo-q'); field.type = 'search'; field.placeholder = 'Search GitHub'; field.setAttribute('aria-label', 'Search GitHub repositories'); field.autocomplete = 'off'; field.spellcheck = false;++ const list = el('div', 'ovc-repo-list'); list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', 'Repositories');++ const note = el('p', 'ovc-repo-note'); note.textContent = 'Type what the project does: “goal bar overlay”, “markdown editor”.';++ panel.append(field, list, note);++ const frame = String(form.getAttribute('data-ovc-frame') || 'https://openvibe.network').replace(/\/+$/, '');++ let t = 0, gen = 0;++ const close = () => { panel.hidden = true; button.focus(); };++ const pick = (r) => { ctx.insert(`${r.url} `); panel.hidden = true; };++ const draw = (items) => {++ list.textContent = '';++ items.forEach((r) => {++ const b = el('button', 'ovc-repo-row'); b.type = 'button'; b.setAttribute('role', 'option');++ let av = null;++ try { const u = new URL(String(r.owner_avatar_url || '')); if (u.protocol === 'https:' && u.hostname === 'avatars.githubusercontent.com') { av = el('img', 'ovc-repo-av'); av.src = u.href; av.alt = ''; av.loading = 'lazy'; } } catch { /* no picture */ }++ if (!av) { av = el('span', 'ovc-repo-av'); av.innerHTML = svg(IC.repo); }++ const t1 = el('span', 'ovc-repo-t');++ const name = el('b'); name.textContent = String(r.full_name || '');++ const sub = el('small'); sub.textContent = String(r.description || '');++ const meta = el('i'); meta.textContent = [r.language, Number.isFinite(r.stars) ? `★ ${r.stars >= 1000 ? `${(r.stars / 1000).toFixed(1)}k` : r.stars}` : '', r.license].filter(Boolean).join(' · ');++ t1.append(name, sub, meta);++ b.append(av, t1);++ b.addEventListener('click', () => pick(r));++ list.appendChild(b);++ });++ };++ const search = async () => {++ const q = field.value.trim();++ const my = ++gen;++ if (q.length < 2) { list.textContent = ''; note.hidden = false; note.textContent = 'Type what the project does: “goal bar overlay”, “markdown editor”.'; return; }++ note.hidden = false; note.textContent = 'Searching…';++ try {++ const r = await fetch(`${frame}/api/frame/code/search?q=${encodeURIComponent(q)}&limit=8`, { credentials: 'omit', headers: { accept: 'application/json' } });++ const j = await r.json().catch(() => ({}));++ if (my !== gen) return;++ if (!r.ok) throw new Error(j.error || 'Search is unavailable right now');++ const items = Array.isArray(j.items) ? j.items : [];++ draw(items);++ note.hidden = items.length > 0;++ note.textContent = items.length ? '' : 'Nothing found. Try other words.';++ } catch (err) { if (my === gen) { list.textContent = ''; note.hidden = false; note.textContent = (err && err.message) || 'Search is unavailable right now'; } }++ };++ field.addEventListener('input', () => { clearTimeout(t); t = setTimeout(search, 250); });++ field.addEventListener('keydown', (e) => {++ if (e.key === 'Escape') { e.preventDefault(); close(); }++ else if (e.key === 'Enter') { e.preventDefault(); const first = list.querySelector('.ovc-repo-row'); if (first) first.click(); else { clearTimeout(t); search(); } }++ else if (e.key === 'ArrowDown') { const first = list.querySelector('.ovc-repo-row'); if (first) { e.preventDefault(); first.focus(); } }++ });++ list.addEventListener('keydown', (e) => {++ const rows = [...list.querySelectorAll('.ovc-repo-row')]; const k = rows.indexOf(document.activeElement);++ if (e.key === 'ArrowDown' && k < rows.length - 1) { e.preventDefault(); rows[k + 1].focus(); }++ else if (e.key === 'ArrowUp') { e.preventDefault(); (k > 0 ? rows[k - 1] : field).focus(); }++ else if (e.key === 'Escape') { e.preventDefault(); close(); }++ });++ document.addEventListener('pointerdown', (e) => { if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) panel.hidden = true; });++ form.appendChild(panel);++ field.focus();++ }function tool(id, spec) {if (!/^[\w-]{1,30}$/.test(id || '') || !spec) return;TOOL[id] = Object.assign({}, TOOL[id] || {}, spec);@@@ -955,6 +955,17 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;t.addEventListener('pointerdown', (e) => {if (e.button !== 0) return;const start = { x: e.clientX, y: e.clientY, id: e.pointerId };++ // 4.225.0, a phone (the owner: a long press then a move lost the icon): entering Edit repaints the grid, so++ // the tile under the finger leaves the page and the grid's own touchmove guard no longer hears this touch;++ // the browser scrolled the menu and cancelled the press. The guard rides on the pressed tile itself, which++ // keeps getting the touch's events after it is replaced, until the finger lifts.++ if (e.pointerType === 'touch') {++ const hold = (ev) => { if ((state.edit || state.drag) && ev.cancelable) ev.preventDefault(); };++ const done = () => { t.removeEventListener('touchmove', hold); t.removeEventListener('touchend', done); t.removeEventListener('touchcancel', done); };++ t.addEventListener('touchmove', hold, { passive: false });++ t.addEventListener('touchend', done);++ t.addEventListener('touchcancel', done);++ }let timer = 0;const cancel = () => { clearTimeout(timer); window.removeEventListener('pointermove', early); window.removeEventListener('pointerup', cancel); window.removeEventListener('pointercancel', cancel); };const early = (m) => { if (m.pointerId === start.id && Math.hypot(m.clientX - start.x, m.clientY - start.y) > 8) { cancel(); if (state.edit) beginDrag(id, m); } };@@@ -152,6 +152,11 @@for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };const CSS = `++.ov-appicon{--ova-h:215;--ova-size:56px;position:relative;display:inline-block;flex:none;width:var(--ova-size);height:var(--ova-size);border-radius:30%;overflow:hidden;vertical-align:middle;background:linear-gradient(150deg,hsl(var(--ova-h) 72% 58% / .36),hsl(var(--ova-h) 70% 38% / .2));box-shadow:inset 0 0 0 1px hsl(var(--ova-h) 70% 64% / .3)}++.ov-appicon>.ov-icon{position:absolute;inset:0;width:100%;height:100%;display:block;--ovi-glyph:hsl(var(--ova-h) 92% 84%);color:hsl(var(--ova-h) 92% 84%)}++.ov-appicon>.ov-icon svg{position:absolute;left:-13%;top:-13%;width:126%;height:126%;margin:0;transform:none!important}++.ov-appicon .ovi-ring,.ov-appicon .ovi-bg,.ov-appicon .ovi-comet,.ov-appicon .ovi-prog{display:none}++:root[data-theme="light"] .ov-appicon>.ov-icon{--ovi-glyph:hsl(var(--ova-h) 70% 30%);color:hsl(var(--ova-h) 70% 30%)}.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}@@@ -348,7 +353,36 @@ html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`;applyProgress(el);}-- function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); }++ function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-appicon[data-icon]').forEach(fillApp); (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); }++++ /** A site's (or an app's) steady hue: the same on every page and device (the menu's and Home's own). */++ function hue(id) { let x = 7; const t = String(id || 'ov'); let i = 0; while (i < t.length) { x = (x * 31 + t.charCodeAt(i)) % 360; i++; } return x; }++ /** 4.225.0 (design rule 142): an app icon, the same as the menu's and Home's: the app's own hue on a rounded square,++ * its glyph at 58% of it. In markup, <span class="ov-appicon" data-icon="live" data-size="64"></span>; or++ * OpenVibeIcons.app('live', { size: 64, label: 'Live' }). Without a label it is decoration (aria-hidden). */++ function fillApp(el) {++ const name = String(el.getAttribute('data-icon') || 'ov');++ if (el.getAttribute('data-ova') === name) return;++ el.setAttribute('data-ova', name);++ el.style.setProperty('--ova-h', String(el.hasAttribute('data-hue') ? Number(el.getAttribute('data-hue')) % 360 : hue(name)));++ const size = parseInt(el.getAttribute('data-size'), 10);++ if (size) el.style.setProperty('--ova-size', `${size}px`);++ const label = el.getAttribute('data-label');++ if (label) { el.setAttribute('role', 'img'); el.setAttribute('aria-label', label); } else el.setAttribute('aria-hidden', 'true');++ el.textContent = '';++ const g = document.createElement('span'); g.className = 'ov-icon'; g.setAttribute('data-icon', name); g.setAttribute('data-fx', 'none');++ el.appendChild(g);++ mountOne(g);++ }++ function app(site, { size = 56, label = '', hue: h = null } = {}) {++ const el = document.createElement('span'); el.className = 'ov-appicon';++ el.setAttribute('data-icon', String(site || 'ov'));++ if (size) el.setAttribute('data-size', String(size));++ if (label) el.setAttribute('data-label', String(label));++ if (h != null) el.setAttribute('data-hue', String(h));++ fillApp(el);++ return el;++ }function set(el, patch) {if (!el || !patch) return;@@@ -364,7 +398,7 @@ html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`;if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });}-- const api = { names: () => Object.keys(registry), register, mount, set, svg, sprite, use, resolve, CSS };++ const api = { names: () => Object.keys(registry), register, mount, set, svg, sprite, use, resolve, app, hue, CSS };root.OpenVibeIcons = api;if (typeof module !== 'undefined' && module.exports) module.exports = api;if (typeof document === 'undefined') return;@@@ -375,8 +409,17 @@ html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`;try {new MutationObserver((muts) => {for (const m of muts) {-- if (m.type === 'attributes') { if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target); continue; }-- m.addedNodes.forEach((n) => { if (n.nodeType !== 1) return; if (n.classList.contains('ov-icon')) mountOne(n); else if (n.querySelector) n.querySelectorAll('.ov-icon').forEach(mountOne); });++ if (m.type === 'attributes') {++ if (m.target.classList && m.target.classList.contains('ov-appicon')) { m.target.removeAttribute('data-ova'); fillApp(m.target); continue; }++ if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target);++ continue;++ }++ m.addedNodes.forEach((n) => {++ if (n.nodeType !== 1) return;++ if (n.classList.contains('ov-appicon') && n.hasAttribute('data-icon')) { fillApp(n); return; }++ if (n.classList.contains('ov-icon')) { mountOne(n); return; }++ if (n.querySelector) { n.querySelectorAll('.ov-appicon[data-icon]').forEach(fillApp); n.querySelectorAll('.ov-icon').forEach(mountOne); }++ });}}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-icon', 'data-progress'] });} catch { /* */ }@@@ -13,9 +13,23 @@* examples: ['A goal bar for my stream', …], // what people make, shown in turn in the empty box* start: { kind, target, context }, // what a new run starts with (the box's defaults)* });-- * view.list('code'); view.open('chat:c_8f3k2m'); view.start('A goal bar for my stream'); view.destroy();++ * view.list('code'); view.open('chat:c_8f3k2m'); view.start('A goal bar for my stream'); view.prefill('Words, unsent');++ * view.destroy();* (o.filter: the list's first filter, all | chat | task | code; o.open: a run's ref to open first)*++ * 4.225.0, what the menu's Vibe needs to be the one place (the Dock's Vibe and Agents panels did all of it):++ * o.starters: [[glyph, words], …] things to ask, as chips above the box (All and Chats); a press starts that run++ * o.actions: { base } the Action Center's waiting and running work from every product (Network++ * GET /api/v1/me/actions), merged into the list (All and Tasks) when a run does not++ * already show it; approved or declined in place, and opened on its own page++ * o.top(el) → { destroy } something of the host's above the list (All and Tasks): the menu puts the Dock's++ * Now, your Actor and the live agent view there++ * o.panes: { chat, task, code } a filter the host still draws itself: pane(el) → { destroy }. The menu's Vibe++ * lends the Dock's Vibe, Agents and Code panels here until this view does all++ * they do; the pane replaces the rows and the box under the filters++ * o.onPlace({ filter } | { ref }) where the person is, for the host to remember++ * view.prefill(text) the words in the list's box, unsent (Ask your agent)++ ** OpenVibeRun.card(type, (data, step, ctx) => Node) a rich card for a step's `ui.type` (a site or a mod adds its* own); an unknown type shows the step's words.*@@@ -57,9 +71,13 @@compass: '<circle cx="12" cy="12" r="8.5"/><path d="m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1Z"/>',page: '<path d="M6.5 3h7.5l4 4v14h-11.5Z"/><path d="M14 3v4h4M9.5 12h5M9.5 15.5h5"/>',timer: '<path d="M12 4.5a8.2 8.2 0 1 1 0 16.4 8.2 8.2 0 0 1 0-16.4zM12 8.6v4.2l2.7 1.7M9.6 2.6h4.8"/>',++ bell: '<path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 1.5h-15ZM10 20.5a2.2 2.2 0 0 0 4 0"/>',++ calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M4 10h16M8.5 3.5v4M15.5 3.5v4"/>',++ phone: '<path d="M6.5 3.8h3l1.5 4-2 1.3a10.5 10.5 0 0 0 5.9 5.9l1.3-2 4 1.5v3a1.8 1.8 0 0 1-2 1.8A15.5 15.5 0 0 1 4.7 5.8a1.8 1.8 0 0 1 1.8-2Z"/>',};const KIND_GLYPH = { chat: 'chat', ask: 'search', task: 'robot', code: 'code', machine: 'tool', goal: 'spark' };const KIND_WORD = { chat: 'Chat', ask: 'Answer', task: 'Task', code: 'Code', machine: 'Machine', goal: 'Goal' };++ const SERVICE_NAME = { actor: 'Actor', codes: 'Codes', ai: 'AI', network: 'OpenVibe', run: 'Run', domains: 'Domains' };const svg = (name, cls = '') => {const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); if (cls) s.setAttribute('class', cls);@@@ -217,6 +235,38 @@};const box = el('div', 'ovr');host.textContent = ''; host.append(box);++ const actionsBase = o.actions ? String(o.actions.base || 'https://openvibe.network').replace(/\/+$/, '') : '';++ const place = (p) => { try { if (typeof o.onPlace === 'function') o.onPlace(p); } catch { /* the host's */ } };++ let topHandle = null, paneHandle = null, paneId = null;++ const dropTop = () => { if (topHandle && typeof topHandle.destroy === 'function') { try { topHandle.destroy(); } catch { /* */ } } topHandle = null; };++ const dropPane = () => { if (paneHandle && typeof paneHandle.destroy === 'function') { try { paneHandle.destroy(); } catch { /* */ } } paneHandle = null; paneId = null; };++ const paneFor = (f) => (o.panes && typeof o.panes[f] === 'function' ? o.panes[f] : null);++ /** The Action Center's items (common.action-item@1) that need you or are working, as rows beside the runs. */++ async function actionRows(runs) {++ if (!actionsBase || !['all', 'task'].includes(state.filter)) return [];++ try {++ const r = await fetch(`${actionsBase}/api/v1/me/actions`, { headers: { Accept: 'application/json', ...headers() }, credentials: 'omit' });++ if (!r.ok) return [];++ const doc = await r.json();++ const shown = new Set(runs.map((x) => x.ref));++ runs.forEach((x) => { if (x.waiting && x.waiting.approval) shown.add(`approval:${x.waiting.approval}`); });++ const out = [];++ for (const bucket of ['waiting', 'running']) {++ for (const it of (doc && doc.buckets && Array.isArray(doc.buckets[bucket]) ? doc.buckets[bucket] : []).slice(0, 30)) {++ if (!it || typeof it.id !== 'string' || shown.has(`${it.service}:${it.id}`) || (it.kind === 'approval' && shown.has(`approval:${it.id}`))) continue;++ out.push({++ action: it, id: it.id, kind: it.kind === 'code_task' ? 'code' : 'task', title: it.title, url: it.href,++ status: bucket === 'waiting' ? 'waiting' : 'running', updated_at: it.updated_at,++ waiting: bucket === 'waiting' ? { for: 'approval', what: it.summary || it.title } : null,++ doing: bucket === 'running' ? { text: it.step || it.summary || 'Working', glyph: it.kind === 'code_task' ? 'code' : 'robot' } : null,++ preview: it.summary ? { text: it.summary } : null,++ agent: { id: `${it.service}`, name: it.agent || SERVICE_NAME[it.service] || 'OpenVibe', glyph: it.kind === 'code_task' ? 'code' : it.kind === 'ai_run' ? 'spark' : 'robot' },++ });++ }++ }++ return out;++ } catch { return []; }++ }const state = { view: 'list', filter: FILTER_IDS.includes(o.filter) ? o.filter : 'all', runs: [], run: null, steps: new Map(), stopStream: null, abort: null, gen: 0 };function clearStream() { if (state.stopStream) state.stopStream(); state.stopStream = null; if (state.abort) state.abort.abort(); state.abort = null; }@@@ -289,10 +339,40 @@head.append(tabs);const rows = el('ul', 'ovr-rows'); rows.setAttribute('aria-label', 'Your runs');const foot = el('div', 'ovr-list-note');-- box.append(head, rows, foot);++ const top = el('div', 'ovr-top');++ const pane = el('div', 'ovr-pane'); pane.hidden = true;++ box.append(head, top, rows, foot, pane);++ dropTop(); dropPane();++ if (Array.isArray(o.starters) && o.starters.length) {++ const st = el('div', 'ovr-starters'); st.setAttribute('role', 'group'); st.setAttribute('aria-label', 'Things to ask');++ o.starters.slice(0, 8).forEach((x) => {++ const [ic, words] = Array.isArray(x) ? x : ['spark', String(x)];++ const b = el('button', 'ovr-starter'); b.type = 'button'; b.append(svg(G[ic] ? ic : 'spark'), el('span', '', words));++ b.addEventListener('click', async () => { b.disabled = true; try { await start(words); } catch (err) { b.disabled = false; foot.textContent = ''; foot.append(el('p', 'ovr-note is-err', err.message)); } });++ st.append(b);++ });++ box.append(st);++ }composer(box, { placeholder: o.placeholder || 'Ask, or say what to make', onSend: (v) => start(v.text) });}++ place({ filter: state.filter });box.querySelectorAll('.ovr-filter').forEach((b) => { const on = b.dataset.filter === state.filter; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });++ // A filter the host draws itself (a pane): it takes the place of the rows, the starters and the box. The host's++ // pieces may share one owner (the Dock lends one panel at a time), so the old one goes before the new one comes.++ const pf = paneFor(state.filter);++ const topEl = box.querySelector('.ovr-top'), paneEl = box.querySelector('.ovr-pane');++ const wantTop = !pf && ['all', 'task'].includes(state.filter) && typeof o.top === 'function';++ if (!wantTop) { dropTop(); topEl.textContent = ''; }++ if (paneId !== (pf ? state.filter : null)) { dropPane(); paneEl.textContent = ''; }++ box.classList.toggle('has-pane', !!pf);++ paneEl.hidden = !pf; topEl.hidden = !wantTop;++ { const st = box.querySelector('.ovr-starters'); if (st) st.hidden = !!pf || !['all', 'chat'].includes(state.filter); }++ if (pf) {++ if (!paneHandle) { paneId = state.filter; try { paneHandle = pf(paneEl) || {}; } catch { paneHandle = {}; } }++ const rows0 = box.querySelector('.ovr-rows'); rows0.textContent = ''; rows0.removeAttribute('aria-busy'); box.querySelector('.ovr-list-note').textContent = '';++ return;++ }++ if (wantTop && !topHandle) { try { topHandle = o.top(topEl) || null; } catch { topHandle = null; } }const rows = box.querySelector('.ovr-rows'); const foot = box.querySelector('.ovr-list-note');rows.textContent = ''; foot.textContent = ''; rows.setAttribute('aria-busy', 'true');for (let i = 0; i < 3; i++) rows.append(el('li', 'ovr-row is-ghost'));@@@ -306,13 +386,23 @@}if (gen !== state.gen) return;state.runs = Array.isArray(out.runs) ? out.runs : [];++ const extra = await actionRows(state.runs);++ if (gen !== state.gen) return;++ if (extra.length) {++ // What needs you first, then newest: the Action Center's rows join in that order.++ const needs = (r) => (needsPerson(r) ? 1 : 0);++ state.runs = state.runs.concat(extra).sort((x, y) => needs(y) - needs(x) || String(y.updated_at || '').localeCompare(String(x.updated_at || '')));++ }rows.textContent = ''; rows.removeAttribute('aria-busy');if (!state.runs.length) foot.append(empty('Nothing here yet', state.filter === 'code' ? 'Say what to build below: a page, a fix, controls for your stream.' : 'Ask anything below. Chats, tasks and code you start show up here.'));state.runs.forEach((r, i) => { const li = el('li'); li.append(row(r, i)); const acts = answerRow(r); if (acts) { li.classList.add('has-acts'); li.append(acts); } rows.append(li); });if (Array.isArray(out.partial) && out.partial.length) foot.append(el('p', 'ovr-note', `Some of your runs did not load (${out.partial.join(', ')}).`));}function row(r, i) {-- const b = el('button', `ovr-row${needsPerson(r) ? ' is-needs' : ''}`); b.type = 'button'; b.style.setProperty('--i', String(Math.min(i, 12)));++ const away = r.action ? httpsUrl(r.url) : '';++ const b = away ? el('a', `ovr-row${needsPerson(r) ? ' is-needs' : ''}`) : el('button', `ovr-row${needsPerson(r) ? ' is-needs' : ''}`);++ if (away) { b.href = away; b.target = '_blank'; b.rel = 'noopener'; } else b.type = 'button';++ b.style.setProperty('--i', String(Math.min(i, 12)));const t = el('span', 'ovr-row-t');const top = el('span', 'ovr-row-top'); top.append(el('b', '', r.title || (r.agent && r.agent.name) || KIND_WORD[r.kind] || 'Run'), el('time', '', ago(r.updated_at)));const line = el('span', 'ovr-row-line', needsPerson(r) || r.status === 'running' ? statusLine(r) : (r.preview && r.preview.text) || statusLine(r));@@@ -322,13 +412,15 @@else if (r.status === 'failed') meta.append(el('i', 'ovr-pill is-failed', 'Did not finish'));t.append(top, line, meta);b.append(face(r), t);-- b.addEventListener('click', () => open(r.ref || r.id, r));++ if (!away) b.addEventListener('click', () => open(r.ref || r.id, r));return b;}/** A run that asks before it acts is answered from the list (the Action Center's approve in place). */function answerRow(r) {const w = r && r.waiting;-- if (!needsPerson(r) || w.for !== 'approval' || !w.approval || !/^[A-Za-z0-9._~-]{1,160}$/.test(String(w.approval))) return null;++ const it = r && r.action;++ if (it && !(needsPerson(r) && Array.isArray(it.actions) && it.actions.includes('approve'))) return null;++ if (!it && (!needsPerson(r) || w.for !== 'approval' || !w.approval || !/^[A-Za-z0-9._~-]{1,160}$/.test(String(w.approval)))) return null;const box2 = el('div', 'ovr-row-acts');const say = (text, err) => { box2.textContent = ''; box2.append(el('p', `ovr-note${err ? ' is-err' : ''}`, text)); };[['approve', 'Approve', true], ['decline', 'Not now', false]].forEach(([id, label, primary]) => {@@@ -336,7 +428,13 @@b.setAttribute('aria-label', `${label}: ${r.title || 'this run'}`);b.addEventListener('click', async () => {[...box2.querySelectorAll('button')].forEach((x) => { x.disabled = true; });-- try { await api(`/${encodeURIComponent(r.ref || r.id)}/approvals/${encodeURIComponent(w.approval)}`, { method: 'POST', body: JSON.stringify({ answer: id }) }); say(id === 'approve' ? 'Approved' : 'Declined'); }++ try {++ if (it) {++ const res = await fetch(`${actionsBase}/api/v1/me/actions/${encodeURIComponent(it.service)}/${encodeURIComponent(it.id)}/${id}`, { method: 'POST', credentials: 'omit', headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...headers() }, body: '{}' });++ if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.detail || j.error || j.title || `That did not go through (${res.status})`); }++ } else await api(`/${encodeURIComponent(r.ref || r.id)}/approvals/${encodeURIComponent(w.approval)}`, { method: 'POST', body: JSON.stringify({ answer: id }) });++ say(id === 'approve' ? 'Approved' : 'Declined');++ }catch (err) { [...box2.querySelectorAll('button')].forEach((x) => { x.disabled = false; }); box2.append(el('p', 'ovr-note is-err', err.message)); }});box2.append(b);@@@ -348,8 +446,10 @@// ── the thread ──async function open(ref, known) {clearStream();++ dropTop(); dropPane();const gen = ++state.gen;state.view = 'thread'; state.run = known || null; state.steps = new Map();++ place({ ref });box.textContent = ''; box.className = 'ovr is-thread';const head = el('div', 'ovr-thead');const back = el('button', 'ovr-ib'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your runs'); back.title = 'Your runs'; back.append(svg('back'));@@@ -472,7 +572,19 @@n.append(row);}-- const view = { list, open, start, destroy() { clearStream(); state.gen++; host.textContent = ''; }, get state() { return { view: state.view, run: state.run, filter: state.filter }; } };++ /** Words in the list's box, unsent, with the focus there (a page's "Ask your agent"). */++ function prefill(text) {++ if (state.view !== 'list' || paneFor(state.filter)) list(paneFor(state.filter) ? 'all' : undefined);++ const put = (n = 0) => {++ const ta = box.querySelector('.ovr-compose textarea');++ if (!ta) { if (n < 40) setTimeout(() => put(n + 1), 50); return; }++ ta.value = String(text || '').slice(0, 16000);++ ta.dispatchEvent(new Event('input', { bubbles: true }));++ try { ta.focus({ preventScroll: true }); ta.setSelectionRange(ta.value.length, ta.value.length); } catch { /* */ }++ };++ put();++ }++ const view = { list, open, start, prefill, destroy() { clearStream(); dropTop(); dropPane(); state.gen++; host.textContent = ''; }, get state() { return { view: state.view, run: state.run, filter: state.filter }; } };if (o.open) open(o.open); else list();return view;}@@@ -501,9 +613,10 @@.ovr-row-acts .ovr-note { margin: 0; }@media (max-width: 380px) { .ovr-row-acts { padding-left: 12px; } }.ovr-rows:empty { display: none; } .ovr-list-note:empty { display: none; } .ovr-list-note { padding: 0 4px; }--.ovr.is-list > .ovr-compose { margin-top: auto; }++.ovr.is-list > .ovr-compose, .ovr.is-list > .ovr-starters:not([hidden]) { margin-top: auto; }++.ovr.is-list > .ovr-starters:not([hidden]) + .ovr-compose { margin-top: 0; }/* On a wide screen the list, the thread and the box keep a reading column. */--.ovr-head, .ovr-rows, .ovr-list-note, .ovr-compose, .ovr-thead { width: 100%; max-width: 780px; margin-inline: auto; box-sizing: border-box; }++.ovr-head, .ovr-top, .ovr-rows, .ovr-list-note, .ovr-starters, .ovr-compose, .ovr-thead { width: 100%; max-width: 780px; margin-inline: auto; box-sizing: border-box; }.ovr-thread { padding-inline: max(4px, calc((100% - 772px) / 2)); }.ovr-row { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 12px; min-height: 72px; padding: 10px 12px; border-radius: 16px; cursor: pointer; background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.07)); transition: background-color .15s, transform .15s; animation: ovr-rise .3s cubic-bezier(.2,.8,.2,1) both; animation-delay: calc(var(--i, 0) * 18ms); }.ovr-row.is-needs { box-shadow: inset 0 0 0 1px color-mix(in srgb, #f59e0b 50%, transparent); background: color-mix(in srgb, #f59e0b 7%, transparent); }@@@ -523,6 +636,7 @@.ovr-pill.is-failed { color: #f87171; background: color-mix(in srgb, #ef4444 14%, transparent); }.ovr-face { position: relative; flex: none; display: grid; place-items: center; border-radius: 30%; color: hsl(var(--ovr-h, 215) 92% 84%); background: linear-gradient(150deg, hsl(var(--ovr-h, 215) 72% 58% / .4), hsl(var(--ovr-h, 215) 70% 38% / .22)); box-shadow: inset 0 0 0 1px hsl(var(--ovr-h, 215) 70% 64% / .3); }:root[data-theme="light"] .ovr-face { color: hsl(var(--ovr-h, 215) 70% 30%); }++@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .ovr-face { color: hsl(var(--ovr-h, 215) 70% 30%); } }.ovr-face.is-m { width: 48px; height: 48px; } .ovr-face.is-m > svg { width: 26px; height: 26px; }.ovr-face.is-s { width: 38px; height: 38px; } .ovr-face.is-s > svg { width: 21px; height: 21px; }.ovr-face img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }@@@ -535,6 +649,16 @@.ovr-empty > div { display: grid; gap: 2px; } .ovr-empty b { font-size: 14.5px; } .ovr-empty span { font-size: 13px; color: var(--text-muted, #93a0b4); }.ovr-note { margin: 6px 4px; font-size: 13px; color: var(--text-muted, #93a0b4); } .ovr-note.is-err { color: #f87171; }.ovr-compose { flex: none; padding: 8px 4px 4px; }++.ovr-top { flex: none; }++.ovr-pane { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } .ovr-pane[hidden] { display: none; }++.ovr.has-pane > .ovr-rows, .ovr.has-pane > .ovr-list-note, .ovr.has-pane > .ovr-compose { display: none; } .ovr-top[hidden], .ovr-starters[hidden] { display: none; } .ovr-top:empty { display: none; }++.ovr-starters { flex: none; display: flex; gap: 6px; overflow-x: auto; padding: 8px 4px 0; scrollbar-width: none; -webkit-mask-image: linear-gradient(90deg, #000 92%, transparent); mask-image: linear-gradient(90deg, #000 92%, transparent); }++.ovr-starters::-webkit-scrollbar { display: none; }++.ovr-starter { all: unset; box-sizing: border-box; flex: none; display: inline-flex; align-items: center; gap: 7px; min-height: 38px; padding: 0 13px 0 10px; border-radius: 999px; cursor: pointer; font-size: 13.5px; white-space: nowrap; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.07)); transition: background-color .15s, color .15s; }++.ovr-starter svg { width: 16px; height: 16px; color: var(--accent-light, #60a5fa); }++.ovr-starter:disabled { opacity: .5; cursor: default; } .ovr-starter:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }++@media (hover: hover) and (pointer: fine) { .ovr-starter:hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); } }++a.ovr-row { color: inherit; text-decoration: none; }.ovr-plain { display: flex; align-items: flex-end; gap: 8px; padding: 8px; border-radius: 18px; border: 1px solid var(--border, rgba(148,163,184,.2)); background: var(--bg-primary, rgba(0,0,0,.2)); }.ovr-plain textarea { flex: 1; min-height: 40px; max-height: 160px; resize: none; border: 0; outline: 0; background: transparent; color: inherit; font: inherit; font-size: 15px; }.ovr-send { all: unset; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; color: #fff; background: var(--accent-strong, var(--accent, #2563eb)); }@@@ -24,7 +24,7 @@// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);-- const THREAD_REV = { 'ov-composer.js': '2d3782ec0b52', 'ov-combo.js': '3e426e56fc75' };++ const THREAD_REV = { 'ov-composer.js': '293808b32ad7', 'ov-combo.js': '3e426e56fc75' };var selfSrc = (document.currentScript && document.currentScript.src) || '';// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.function loadCombo() {@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.224.0",++ "version": "4.225.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.224.0",++ "version": "4.225.0","license": "MIT","dependencies": {"clean-css": "^5.3.3",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.224.0",++ "version": "4.225.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -100,7 +100,7 @@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';++ let door = null, doorEl = null, doorHost = null, doorMount = null, runView = null, runFilter = 'all';let actsEl = null, actsObs = null, actsTick = 0;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; } };@@@ -336,7 +336,7 @@ html[data-ov-motion=off] .ovs-grid.is-mini .ovs-ic { transition: none; }.ovs-group-ic svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }/* Names in the brand face (openvibe.design rule: a place's or an app's name in OV Brand 800; words you read in the UIface): the site list, the tabs, Vibe's switch. */--.ovs-grid.is-mini .ovs-tile b, .ovs-door-sw button, .ovs-starttabs .ovs-tab { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-weight: 800; letter-spacing: .005em; }++.ovs-grid.is-mini .ovs-tile b, .ovs-starttabs .ovs-tab { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-weight: 800; letter-spacing: .005em; }.ovs-grid.is-mini .ovs-tile b { font-size: 13px; color: color-mix(in srgb, var(--text-primary, #e6edf7) 92%, transparent); }.ovs-group[data-sec="all"] { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13.5px; font-weight: 800; letter-spacing: .01em; text-transform: none; color: var(--text-primary, #e6edf7); }/* A slow light drifts behind the menu (Calm slows it, only Off stops it; openvibe.design: calm reduces, only off removes). */@@@ -383,37 +383,10 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }.ovs-act:hover, .ovs-act:focus-visible { background: var(--hover-bg, rgba(127,127,127,.14)); color: var(--text-primary, #ececf1); }.ovs-act.is-new { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }.ovs-door-host > .ovdk { flex: 1 1 auto; min-height: 0; }--.ovs-door-sw { flex: none; display: grid; grid-template-columns: repeat(3, minmax(0, 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-top { flex: none; display: flex; align-items: center; gap: 6px; margin: 8px 12px 4px; }--.ovs-door-top:not(:has(> :not([hidden]))) { display: none; }--.ovs-door-top > .ovs-door-sw { flex: 1 1 auto; min-width: 0; margin: 0; }--.ovs-door-top > .ovs-ai-timer { flex: none; width: 50px; min-height: 50px; margin: 0; padding: 0; justify-content: center; border-radius: 14px; }--.ovs-door-top > .ovs-ai-timer span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }--.ovs-door-top > .ovs-ai-timer svg { width: 20px; height: 20px; }++/* 4.225.0: Vibe's runs fill the place under the menu's head (ov-run.js keeps its own filters, thread and box). */++.ovs-door-host > .ovr { flex: 1 1 auto; min-height: 0; padding: 8px 10px 10px; box-sizing: border-box; }++.ovs-door-wait { margin: 18px 16px; font-size: 14px; color: var(--text-muted, #93a0b4); }@media (min-width: 641px) { .ovs.ovs-in-door .ovs-panel { height: min(820px, 86vh); max-height: 86vh; } .ovs.ovs-in-door .ovs-foot { 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; }--/* 4.223.0: one segmented control (You's tabs have the same): the ink slides to the chosen part, names at 15 px, glyphs at-- 20 px, the chosen glyph a touch larger in the accent. */--.ovs-door-sw { position: relative; isolation: isolate; gap: 4px; padding: 4px; border-radius: 16px; }--.ovs-door-sw button { min-height: 46px; gap: 8px; font-size: 15px; font-weight: 700; border-radius: 12px; transition: color .18s, background-color .18s; -webkit-tap-highlight-color: transparent; }--.ovs-door-sw button[aria-selected="true"] { background: transparent; box-shadow: none; }--.ovs-door-sw .ovs-tab-ic, .ovs-door-sw .ovs-tab-ic > svg { width: 20px; height: 20px; }--.ovs-door-sw .ovs-tab-ic { transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }--.ovs-door-sw button[aria-selected="true"] .ovs-tab-ic { transform: scale(1.08); }--.ovs-door-sw button:active .ovs-tab-ic { transform: scale(.9); }--@media (hover: hover) and (pointer: fine) { .ovs-door-sw button:not([aria-selected="true"]):hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); } }--.ovs-door-ink { position: absolute; left: 0; top: 4px; bottom: 4px; width: 0; z-index: -1; border-radius: 12px; opacity: 0; pointer-events: none;-- 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) 35%, transparent), 0 6px 18px -8px color-mix(in srgb, var(--accent, #3b82f6) 60%, transparent);-- transition: transform .34s cubic-bezier(.3, 1.3, .4, 1), width .34s cubic-bezier(.3, 1.3, .4, 1), opacity .2s; }--.ovs-door-ink.is-on { opacity: 1; }--:root[data-theme="light"] .ovs-door-ink { background: #fff; }--html[data-ov-motion=reduced] .ovs-door-ink { transition: opacity .25s; }--html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .ovs-tab-ic { transition: none; }--@media (max-width: 380px) { .ovs-door-sw button { font-size: 14px; gap: 6px; } }.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; }@@@ -476,10 +449,6 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o.ovs-nhead-n[hidden], .ovs-nhead-more[hidden] { display: none; }.ovs-nhead-more { margin-left: auto; min-height: 32px; padding: 0 10px; border: 0; border-radius: 9px; background: none; color: var(--accent-light, var(--accent, #60a5fa)); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; }.ovs-nhead-more:hover, .ovs-nhead-more:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }--.ovs-ai-timer { flex: none; display: flex; align-items: center; gap: 10px; margin: 6px 12px 10px; min-height: 44px; padding: 0 14px; border: 0; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }--.ovs-ai-timer[hidden] { display: none; }--.ovs-ai-timer:hover, .ovs-ai-timer:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); color: var(--text-primary, #e6edf7); }--.ovs-ai-timer svg { flex: none; color: var(--accent-light, var(--accent, #60a5fa)); }.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }.ovs-feeds { display: grid; gap: 4px; padding: 4px 6px 8px; }.ovs-feed { display: grid; grid-template-columns: minmax(0, 1fr) 40px 40px; align-items: center; gap: 4px; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }@@@ -608,36 +577,10 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .oghost = 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', 'Vibe');-- // Vibe holds three things (asks 335 and 350, the owner: "chat, codes, and agents all in the same place"): your-- // chats with it, your coding tasks, and your agents.-- doorSw.innerHTML = [['vibe', 'Chat', 'talk'], ['code', 'Code', 'code'], ['agent', 'Agents', 'agent']].map(([id, label, ic]) => `<button type="button" role="tab" data-part="${id}" aria-selected="false"><span class="ovs-tab-ic">${BRIDGE_SVG[ic]}</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'); } });-- // 4.223.0: the switch's ink slides to the chosen part (the owner, 11-12: "animations, effects to the tabs"); it-- // follows aria-selected wherever that is set, and the size of the row.-- {-- const ink = document.createElement('i'); ink.className = 'ovs-door-ink'; ink.setAttribute('aria-hidden', 'true'); doorSw.prepend(ink);-- const place = () => {-- const on = doorSw.querySelector('[data-part][aria-selected="true"]');-- if (!on || !on.offsetWidth) { ink.classList.remove('is-on'); return; }-- ink.style.transform = `translateX(${on.offsetLeft}px)`; ink.style.width = `${on.offsetWidth}px`; ink.classList.add('is-on');-- };-- if (typeof MutationObserver === 'function') new MutationObserver(() => requestAnimationFrame(place)).observe(doorSw, { subtree: true, attributes: true, attributeFilter: ['aria-selected', 'hidden'] });-- if (typeof ResizeObserver === 'function') new ResizeObserver(() => { ink.style.transition = 'none'; place(); void ink.offsetWidth; ink.style.transition = ''; }).observe(doorSw);-- doorSw.addEventListener('keydown', (e) => {-- if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;-- const all = [...doorSw.querySelectorAll('[data-part]')]; const k = all.indexOf(document.activeElement); if (k < 0) return;-- const next = all[(k + (e.key === 'ArrowRight' ? 1 : all.length - 1)) % all.length];-- e.preventDefault(); next.focus(); next.click();-- });-- }++ // 4.225.0: Vibe is one place (the owner, 11-12, crossing out the Chat · Code · Agents switch: "one new unified++ // agent … a shared UI"): every chat, task and coding run in one list with its own filters, drawn by ov-run.js.doorHost = document.createElement('div'); doorHost.className = 'ovs-door-host';-- const timerButton = document.createElement('button'); timerButton.type = 'button'; timerButton.className = 'ovs-ai-timer'; timerButton.setAttribute('aria-label', 'Start a timer'); timerButton.setAttribute('data-ov-tip', 'Start a timer'); timerButton.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/></svg><span>Start a timer</span>'; timerButton.hidden = true;-- timerButton.addEventListener('click', () => { try { if (root.OpenVibeDock && root.OpenVibeDock.timer) root.OpenVibeDock.timer(timerButton); else if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* not loaded */ } });-- // Vibe's switch and its timer share one row (4.220.0): the timer is an icon at its end, never a row under the box.-- // 4.223.0: the timer is in every box's + (the owner: "the timer should be in the plus thing"), not in this row.-- const doorTop = document.createElement('div'); doorTop.className = 'ovs-door-top'; doorTop.append(doorSw); void timerButton;-- doorEl.append(doorTop, doorHost);++ doorEl.append(doorHost);listEl.after(doorEl);doorHost.addEventListener('openvibe:open-vibe', (e) => {if (door !== 'chats' || !canDoor()) return;@@@ -733,7 +676,12 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .oe.preventDefault();const q = input.value.trim();if (tab === 'feed' && homeLib() && !door) { render(q); return; }-- if (door === 'vibe' && q) { try { if (ctx.ask) ctx.ask(q); } catch { /* Dock is optional */ } return; }++ // Vibe: on Chats (the Dock's Vibe) the words go to its box as before; anywhere else they start a run.++ if (door === 'vibe' && q) {++ if (runFilter === 'chat') { try { if (ctx.ask) ctx.ask(q); } catch { /* the Dock is optional */ } }++ else if (runView) runView.start(q).then(() => { input.value = ''; paintGhost(); }, () => {});++ return;++ }if (door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }const it = items[active >= 0 ? active : 0];// Enter shows every result, unless the person picked a row or typed a place's exact name (live, tools).@@@ -1287,38 +1235,98 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o}mirrorActs();}-- /** A place by the Dock's name ('chats', or Vibe's parts 'vibe' | 'code' | 'agent'), in the open menu. */++ /** A place by the Dock's name ('chats', or Vibe's lists 'vibe' | 'code' | 'agent' | 'tasks'), in the open menu. */function showDoor(id) {if (!el || !el.classList.contains('is-open') || !canDoor() || !id) return false;-- const want = id === 'agents' ? 'agent' : id;-- if (['vibe', 'code', 'agent'].includes(want)) aiPart = want;-- openDoor(want === 'chats' ? 'chats' : 'vibe');++ // The Dock's places by name: its Vibe is Chats, its Agents are Tasks.++ const want = { all: 'all', vibe: 'chat', chat: 'chat', code: 'code', agent: 'task', agents: 'task', tasks: 'task' }[id];++ if (want) runFilter = want;++ openDoor(id === 'chats' ? 'chats' : 'vibe');return door != null;}-- /** Show a place in the menu: Messages, or AI (Vibe, Code or your agents, by the switch). */++ /** Vibe's runs (ov-run.js, loaded beside the navbar by its hash the first time Vibe opens). */++ let runLoading = null;++ function runLib() {++ if (root.OpenVibeRun) return Promise.resolve(root.OpenVibeRun);++ if (runLoading || typeof ctx.sibling !== 'function') return runLoading || Promise.reject(new Error('no sibling'));++ runLoading = new Promise((resolve, reject) => {++ const sc = document.createElement('script'); sc.src = ctx.sibling('ov-run.js'); sc.async = true;++ sc.onload = () => (root.OpenVibeRun ? resolve(root.OpenVibeRun) : reject(new Error('ov-run.js')));++ sc.onerror = () => { runLoading = null; sc.remove(); reject(new Error('ov-run.js')); };++ document.head.appendChild(sc);++ });++ return runLoading;++ }++ // What Vibe can do, to tap (they were the Dock's Vibe starters).++ const VIBE_STARTERS = [++ ['bell', 'Remind me to stretch every 2 hours'],++ ['calendar', 'What is on today?'],++ ['live', 'Who is live right now?'],++ ['clock', 'Start a 25-minute focus timer'],++ ['phone', 'Remind me tomorrow at 9 to call Sam'],++ ['search', 'What is new on OpenVibe this week?'],++ ];++ let pendingAsk = null;++ /** One of the Dock's panels in a slot of the run view (the Dock lends one at a time). */++ function lend(slot, how) {++ try { return root.OpenVibeDock && root.OpenVibeDock.mountInto ? root.OpenVibeDock.mountInto(slot, how) : null; } catch { return null; }++ }++ function mountRuns() {++ const me = ctx.token && ctx.user && !ctx.user.is_anon ? ctx.user : null;++ // Runs and the Action Center are the person's Network places, like the Dock's (navbar.js passes its base).++ const net = ctx.runsBase || ctx.apiBase || 'https://openvibe.network';++ const D = () => root.OpenVibeDock;++ const opts = {++ base: `${net}/api/frame`, token: () => ctx.token, me, sibling: ctx.sibling, filter: runFilter,++ // All: every run, with the Action Center's work from every product, the Dock's Now, your Actor and the live++ // agent view above it, and Vibe's starters. Chats, Tasks and Code are still the Dock's own panels (its Vibe,++ // Agents and Code: their history, skills, voice, undo, repos), lent whole until the one view does all they do.++ actions: { base: net }, starters: VIBE_STARTERS,++ top: (slot) => lend(slot, { tab: 'agent', part: 'top' }),++ panes: { chat: (slot) => lend(slot, { tab: 'vibe' }), task: (slot) => lend(slot, { tab: 'agent' }), code: (slot) => lend(slot, { tab: 'code' }) },++ onPlace: (p) => { if (p.filter) runFilter = p.filter; },++ };++ const go = (R) => {++ if (door !== 'vibe' || runView) return;++ doorHost.textContent = ''; runView = R.mount(doorHost, opts);++ if (pendingAsk != null) { runView.prefill(pendingAsk); pendingAsk = null; }++ };++ if (root.OpenVibeRun) { go(root.OpenVibeRun); return; }++ doorHost.textContent = '';++ const wait = document.createElement('p'); wait.className = 'ovs-door-wait'; wait.textContent = 'Opening Vibe…'; doorHost.append(wait);++ runLib().then(go, () => { if (door === 'vibe') wait.textContent = 'Vibe did not load. Check your connection and open it again.'; });++ }++ /** Show a place in the menu: Messages (the Dock's), or Vibe (your runs). */function openDoor(id) {if (!doorEl || !canDoor()) return;++ if (door && door !== id) closeDoorBody();door = id;-- const want = id === 'chats' ? 'chats' : aiPart;-- doorSw.hidden = id !== 'vibe';-- const timerButton = doorEl.querySelector('.ovs-ai-timer'); if (timerButton) timerButton.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; }++ el.classList.toggle('ovs-in-vibe', id === 'vibe');++ if (id === 'vibe') {++ if (runView) runView.list(runFilter); else mountRuns();++ } else {++ try { if (!doorMount) doorMount = root.OpenVibeDock.mountInto(doorHost, { tab: 'chats' }); else doorMount.show('chats'); }++ catch { leaveDoor(); try { ctx.dock(id); } catch { /* the Dock is optional */ } return; }++ }watchActs(true);input.placeholder = id === 'chats' ? 'Search your chats' : `Search ${here()}, or ask Vibe`;paintTabs();}++ function closeDoorBody() {++ if (doorMount) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }++ if (runView) { try { runView.destroy(); } catch { /* */ } runView = null; }++ if (doorHost) doorHost.textContent = '';++ }/** 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; }++ closeDoorBody();watchActs(false);if (doorEl) doorEl.hidden = true;if (listEl) listEl.hidden = false;-- if (el) el.classList.remove('ovs-in-door');++ if (el) el.classList.remove('ovs-in-door', 'ovs-in-vibe');if (input) input.placeholder = `Search ${here()}, or ask Vibe`;}@@@ -2062,7 +2070,14 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .odocument.addEventListener('ov:boost:load', () => { if (el && el.classList.contains('is-open') && !input.value) render(''); });-- const api = { open, close, door: (id) => showDoor(id), suggest, siteOf, registerCard, _state: () => ({ tab, door, from: ctx.from }) };++ /** Ask your agent: Vibe, with the words in its box, unsent (the Dock's ask() comes here when the bar wires it). */++ function prefill(text) {++ const t = String(text || '').slice(0, 4000);++ if (door === 'vibe' && runView) { runView.prefill(t); return true; }++ pendingAsk = t; runFilter = 'all';++ return showDoor('vibe');++ }++ const api = { open, close, door: (id) => showDoor(id), prefill, suggest, siteOf, registerCard, _state: () => ({ tab, door, from: ctx.from }) };root.OpenVibeSearch = api;if (typeof module === 'object' && module.exports) module.exports = api;})(typeof globalThis !== 'undefined' ? globalThis : this);@@@ -223,6 +223,11 @@ html{overflow-x:clip}.sc-stat dd{font-size:19px;gap:5px;letter-spacing:-.01em}.sc-stat-ic svg,.sc-stat-ic .ov-icon{width:15px;height:15px}.sc-stat dt{font-size:11.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}}++.ov-appicon{--ova-h:215;--ova-size:56px;position:relative;display:inline-block;flex:none;width:var(--ova-size);height:var(--ova-size);border-radius:30%;overflow:hidden;vertical-align:middle;background:linear-gradient(150deg,hsl(var(--ova-h) 72% 58% / .36),hsl(var(--ova-h) 70% 38% / .2));box-shadow:inset 0 0 0 1px hsl(var(--ova-h) 70% 64% / .3)}++.ov-appicon>.ov-icon{position:absolute;inset:0;width:100%;height:100%;display:block;--ovi-glyph:hsl(var(--ova-h) 92% 84%);color:hsl(var(--ova-h) 92% 84%)}++.ov-appicon>.ov-icon svg{position:absolute;left:-13%;top:-13%;width:126%;height:126%;margin:0;transform:none!important}++.ov-appicon .ovi-ring,.ov-appicon .ovi-bg,.ov-appicon .ovi-comet,.ov-appicon .ovi-prog{display:none}++:root[data-theme="light"] .ov-appicon>.ov-icon{--ovi-glyph:hsl(var(--ova-h) 70% 30%);color:hsl(var(--ova-h) 70% 30%)}.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}@@@ -12,8 +12,18 @@ const h = require('../browser-harness');assert.doesNotThrow(() => require('../ov-composer'), 'ov-composer.js loads (browser-only: Node takes the early return)');if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer-v2-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }const root = path.join(__dirname, '..');++const searched = [];const server = http.createServer((req, res) => {const u = req.url.split('?')[0];++ // 4.225.0: the Frame's code search, as Network answers it (server/frame/code-search.js).++ if (u === '/api/frame/code/search') {++ searched.push(new URL(req.url, 'http://x').searchParams.get('q'));++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end(JSON.stringify({ items: [++ { full_name: 'ada/goal-bar', url: 'https://github.com/ada/goal-bar', description: 'A goal bar <b>for</b> streams', language: 'JavaScript', stars: 1250, license: 'MIT', owner_avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' },++ { full_name: 'bo/tracker', url: 'https://github.com/bo/tracker', owner_avatar_url: 'https://evil.example/p.png', stars: 3 },++ ] }));++ }if (u === '/ov-composer.js' || u === '/ov-window.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }res.writeHead(200, { 'content-type': 'text/html' });res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">@@@ -135,6 +145,24 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'rassert.deepEqual(await page.evaluate(`window.chatSent[0]`), { text: 'half a draft', reply: { id: 7 } });assert.equal(await page.evaluate(`!!document.querySelector('#c1 .ovc-bar')`), false, 'the reply bar goes with the send');++ // 4.225.0: the + has GitHub. A repository found through the Frame's code search goes into the box as its address.++ await page.evaluate(`document.getElementById('g1').setAttribute('data-ovc-frame', location.origin), true`);++ await page.evaluate(`document.querySelector('#g1 .ovc-more').click(), true`);++ assert.ok((await page.evaluate(`[...document.querySelectorAll('#g1 .ovc-tray-t')].map((t) => t.textContent)`)).includes('GitHub'), 'GitHub is in the +');++ await page.evaluate(`[...document.querySelectorAll('#g1 .ovc-tray-i')].find((b) => b.textContent === 'GitHub').click(), true`);++ assert.equal(await page.evaluate(`document.activeElement === document.querySelector('#g1 .ovc-repo-q')`), true, 'the search field has the focus');++ await page.evaluate(`(() => { const f = document.querySelector('#g1 .ovc-repo-q'); f.value = 'goal bar'; f.dispatchEvent(new Event('input', { bubbles: true })); })()`);++ let rows = 0;++ for (let i = 0; i < 40 && rows < 2; i++) { await wait(50); rows = await page.evaluate(`document.querySelectorAll('#g1 .ovc-repo-row').length`); }++ assert.equal(rows, 2, 'the repositories as you type');++ assert.deepEqual(searched, ['goal bar'], 'asked once, a quarter second after the last key');++ const shown = await page.evaluate(`[...document.querySelectorAll('#g1 .ovc-repo-row')].map((r) => ({ name: r.querySelector('b').textContent, sub: r.querySelector('small').textContent, meta: r.querySelector('i').textContent, img: r.querySelector('img') ? r.querySelector('img').getAttribute('src') : null, bold: !!r.querySelector('small b') }))`);++ assert.deepEqual(shown[0], { name: 'ada/goal-bar', sub: 'A goal bar <b>for</b> streams', meta: 'JavaScript · ★ 1.3k · MIT', img: 'https://avatars.githubusercontent.com/u/1?v=4', bold: false }, 'everything as text');++ assert.equal(shown[1].img, null, 'a picture from anywhere but GitHub\'s avatar host is not drawn');++ await page.evaluate(`document.querySelector('#g1 .ovc-repo-q').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })), true`);++ assert.match(await page.evaluate(`document.querySelector('#g1 textarea').value`), /https:\/\/github\.com\/ada\/goal-bar $/, 'Enter puts the first one\'s address in the box');++ assert.equal(await page.evaluate(`document.querySelector('#g1 .ovc-repo').hidden`), true, 'and the panel goes');++// Nothing is wider than the phone.assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390);await page.close();@@@ -126,6 +126,11 @@ const server = http.createServer((req, res) => {return json(200, { state: scenario.state || defaultState(), counts: scenario.counts || { running: 0, waiting: 0, unread: 0 }, sources_partial: false });}if (u.pathname === '/api/v1/me/actions') return json(200, scenario.actions || ACTIONS);++ // 4.225.0: the menu's Vibe lists runs from the Frame (Network /api/frame/runs, agents.run@1).++ if (u.pathname === '/api/frame/runs' && req.method === 'GET') {++ assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'the runs are read with the person\'s own token');++ return json(200, { runs: [{ id: 'v-1', ref: 'chat:v-1', kind: 'chat', title: 'Patch notes for 4.162', status: 'waiting', waiting: { for: 'message' }, agent: { id: 'vibe', name: 'Vibe', glyph: 'spark' }, preview: { text: 'I wrote the first draft.' }, updated_at: iso(4 * 60e3) }], next: null, partial: [] });++ }// Actor's skill catalogue, as the Dock's overview reads it: through Network's actions.if (u.pathname === '/api/v1/me/actions/skills') return json(200, { listings: (scenario.skills || [1, 2, 3, 4, 5, 6]).map((n) => ({id: `skl_${n}`, skill: { slug: `skill-${n}`, name: ['Make an image', 'Make a wiki article', 'Write a blog post', 'Improve an article', 'Make a meme', 'Summarise a thread'][n - 1] },@@@ -328,6 +333,44 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...doawait p.close();}++ // ── 4.225.0: the menu's Vibe is one list (ov-run.js) under All · Chats · Tasks · Code: runs from the Frame and the++ // Action Center's work together, the Dock's Now, Actor and live view above; Chats, Tasks and Code are the Dock's++ // own panels, lent whole. No Chat · Code · Agents switch. ──++ {++ scenario = { counts: { running: 0, waiting: 0, unread: 0 } };++ agentView = null;++ const p = await fresh(390, 780);++ await p.goto(`${base}/page`, { maxMs: 5000 });++ await until(p, "!!window.OpenVibeDock", 'the panels');++ await p.evaluate(MENU_BUTTON);++ await until(p, "!!document.querySelector('.ovs.is-open .ovs-starttabs [data-dock=\"vibe\"]')", 'the Vibe place');++ await p.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);++ await until(p, "document.querySelectorAll('.ovs-door-host .ovr-rows .ovr-row:not(.is-ghost)').length === 4", 'the run and the Action Center\'s three');++ const all = await p.evaluate(`({ filters: [...document.querySelectorAll('.ovs-door-host .ovr-filter')].map((b) => b.textContent + (b.getAttribute('aria-selected') === 'true' ? '*' : '')),++ titles: [...document.querySelectorAll('.ovs-door-host .ovr-row-top b')].map((b) => b.textContent), sw: !!document.querySelector('.ovs-door-sw, [data-part]'),++ top: !!document.querySelector('.ovs-door-host .ovr-top .ovdk.ovdk-part-top'), runsList: !!document.querySelector('.ovs-door-host .ovr-top .ovdk-scroll, .ovs-door-host .ovr-top .ovac-item'),++ starters: document.querySelectorAll('.ovs-door-host .ovr-starter').length, wide: document.documentElement.scrollWidth > innerWidth })`);++ assert.deepStrictEqual(all.filters, ['All*', 'Chats', 'Tasks', 'Code'], 'All · Chats · Tasks · Code, on All');++ assert.deepStrictEqual(all.titles, ['Send the weekly digest?', 'Fix the flaky upload test', 'Compare three VPS offers', 'Patch notes for 4.162'], 'what needs you first, then newest (the work a minute ago, the chat four): a run and the Action Center\'s work in one list');++ assert.deepStrictEqual([all.sw, all.top, all.runsList, all.starters, all.wide], [false, true, false, 6, false], 'no switch; the Dock\'s top above (not its list); the six starters; nothing off the screen');++ if (process.env.OV_CAPTURE_VIBE) { await wait(500); const r = await p.send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(process.env.OV_CAPTURE_VIBE, 'vibe-all-390.png'), Buffer.from(r.data, 'base64')); }++ // Tasks: the Dock's Agents panel, whole; the Network hears the place.++ await p.evaluate(`document.querySelector('.ovs-door-host .ovr-filter[data-filter="task"]').click()`);++ const place = await until(p, `(() => { const c = ${PLACE}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'Tasks: the Agents panel');++ assert.strictEqual(place.items, 3, 'its own list, as before');++ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovs-door-host .ovr-pane .ovdk') && document.querySelector('.ovs-door-host .ovr-top').hidden"), true, 'in the pane, and the top handed back');++ if (process.env.OV_CAPTURE_VIBE) { await wait(500); const r = await p.send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(process.env.OV_CAPTURE_VIBE, 'vibe-tasks-390.png'), Buffer.from(r.data, 'base64')); }++ await eventually(async () => assert.ok(puts.some((b) => b.tab === 'agent'), 'the Network heard Tasks'));++ // Approve in place from All, through the Action Center.++ await p.evaluate(`document.querySelector('.ovs-door-host .ovr-filter[data-filter="all"]').click()`);++ await until(p, "!!document.querySelector('.ovs-door-host .ovr-rows li.has-acts .ovr-btn.is-primary')", 'All again, with its answers');++ decided.length = 0;++ await p.evaluate("document.querySelector('.ovs-door-host .ovr-rows li.has-acts .ovr-btn.is-primary').click()");++ await until(p, "(document.querySelector('.ovs-door-host .ovr-rows li.has-acts .ovr-row-acts') || {}).textContent === 'Approved'", 'approved in place from All');++ assert.deepStrictEqual(decided, ['actor:apr_1:approve'], 'through the Action Center');++ await p.close();++ }++// ── Chats (K-D2): the list, a thread in place, Enter sends, read, remembered; a phone keeps the sheet open. ──{scenario = { counts: { running: 1, waiting: 0, unread: 0 } };@@@ -181,6 +181,32 @@ const server = http.createServer((req, res) => {assert.deepStrictEqual(news.rows[0], ['Live', 'Updated 1 h ago · Big update', 'Go live from a phone: the Dock no longer covers the button'], 'each says what changed, when, and a big one says so');assert.deepStrictEqual(news.rows[1], ['Video', 'Updated 2 h ago', 'Clips play from where you left off']);await eventually(async () => assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on Feed'));++++ // 4.225.0, a phone's touch: a long press then a move keeps the app under the finger. Entering Edit repaints the++ // grid; the first move used to scroll the menu and cancel the press, so the app never moved.++ await p.evaluate(`window.OpenVibeSearch && OpenVibeSearch.close(), true`); await wait(350);++ await openTab(p, 'you');++ assert.ok(await until(p, `document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app').length >= 12`), 'Home again');++ if (await p.evaluate(`!!document.querySelector('.ovh.is-edit')`)) await p.evaluate(`document.querySelector('.ovh-edit').click(), true`);++ assert.ok(await until(p, `!document.querySelector('.ovh.is-edit')`, 3000), 'out of Edit before the touch');++ await wait(400);++ const before = await order(p);++ const f0 = await center(p, `.ovh-grid:not(.ovh-recent) .ovh-app[data-id="${before[0]}"]`);++ const f2 = await center(p, `.ovh-grid:not(.ovh-recent) .ovh-app[data-id="${before[2]}"]`);++ const scrollOf = `(() => { const l = document.querySelector('.ovs-list'); return l ? l.scrollTop : 0; })()`;++ const top0 = await p.evaluate(scrollOf);++ await p.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });++ await p.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: f0.x, y: f0.y }] });++ assert.ok(await until(p, `!!document.querySelector('.ovh.is-edit')`, 3000), 'a touch long-press starts Edit');++ await wait(120); // the finger rests a moment after Edit starts, as a person's does++ // Mostly upward first, the way a thumb that means to drag still nudges the page.++ for (let k = 1; k <= 8; k++) { await p.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: f0.x + ((f2.x - f0.x) * k) / 8, y: f0.y - (k <= 2 ? 14 * k : 28) + ((f2.y - f0.y) * k) / 8 }] }); await wait(40); }++ await p.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: f2.x, y: f2.y }] }); await wait(40);++ await p.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });++ await p.send('Emulation.setTouchEmulationEnabled', { enabled: false });++ const moved = await until(p, `[...document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app')].map((a) => a.getAttribute('data-id')).indexOf(${JSON.stringify(before[0])}) === 2`, 3000);++ assert.ok(moved, `the app moved under the finger to where it was dropped (${await order(p)})`);++ assert.strictEqual(await p.evaluate(scrollOf), top0, 'and the menu did not scroll under it');await p.close();// ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.@@@ -261,7 +287,8 @@ const server = http.createServer((req, res) => {assert.ok(await until(s, `!!document.querySelector('.ovs-door-host .ovdk')`), 'the panel is inside the menu');assert.strictEqual(await s.evaluate(`[...document.querySelectorAll('.ovdk')].every((d) => !!d.closest('.ovs-door-host')) && !document.querySelector('#ovdk-pill, .ovdk-pill')`), true, 'no floating Dock and no pill');await s.evaluate(`window.OpenVibeDock.open({ tab: 'agent' }), true`);-- assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true' && document.querySelector('.ovs-door-sw [data-part="agent"]').getAttribute('aria-selected') === 'true'`), 'open({ tab: agent }) moves the open menu to Vibe\'s Agents');++ // 4.224.0: Vibe is one list of runs (ov-run.js, loaded beside the navbar); an agent's place is its Tasks filter.++ assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true' && (document.querySelector('.ovs-door-host .ovr-filter[aria-selected="true"]') || {}).textContent === 'Tasks'`), 'open({ tab: agent }) moves the open menu to Vibe\'s Tasks');await s.evaluate(`window.OpenVibeDock.close(), true`);assert.ok(await until(s, `!document.querySelector('.ovs.is-open')`), 'close() closes the menu');await s.close();@@@ -76,41 +76,47 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).maassert.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);// With the Dock able to lend its panels (OpenVibeDock.mountInto): Messages and AI open INSIDE the menu, under its-- // own head, and become the chosen place; AI has a Chat · Code · Agents switch; Feed and closing hand the panel back.++ // own head, and become the chosen place; Feed and closing hand the panel back.// 4.220.0: on every screen (a computer opened a floating Dock; the owner, 11-12: "one unified UI").++ // 4.224.0: Vibe is one list of runs (ov-run.js) with its own filters: no Chat · Code · Agents switch.await page.evaluate(`window.went = null; window.log = []; window.OpenVibeDock = { filterChats(q) { window.filtered = q; }, 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 = ''; } }; } }`);++ return { show(t) { window.log.push('show:' + t); }, destroy() { window.log.push('destroy'); host.innerHTML = ''; } }; } };++ window.OpenVibeRun = { mount(host, o) { window.log.push('runs:' + o.filter + (o.base.endsWith('/api/frame') ? '' : ' wrong base')); host.innerHTML = '<div class="ovr runstub"></div>';++ return { list(f) { window.log.push('list:' + f); }, start(t) { window.asked = t; return Promise.resolve({}); }, destroy() { window.log.push('runs-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(' ') : '',++ runs: !!document.querySelector('.ovs-door-host .runstub'), sw: !!document.querySelector('.ovs-door-sw, .ovs-door-top, [data-part]'),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);{-- await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Code Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`));++ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: false, runs: true, sw: false, chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its runs, with no switch`));await eventually(async () => assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`));-- await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="code"]').click()`); await wait(80);-- await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat Code* Agents', chosen: 'Vibe' }, `${name}: the switch goes to Code`));-- await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);-- await eventually(async () => assert.equal((await inside()).sw, 'Chat Code Agents*', `${name}: the switch goes to Agents`));-- // 4.223.0: the timer is in every box's + (the owner: "the timer should be in the plus thing"), not in this row.-- assert.equal(await page.evaluate(`!!document.querySelector('.ovs-door-top .ovs-ai-timer')`), false, `${name}: no timer button beside the switch`);++ // 4.223.0: the timer is in every box's + (the owner: "the timer should be in the plus thing"), not in a row.++ assert.equal(await page.evaluate(`!!document.querySelector('.ovs-ai-timer')`), false, `${name}: no timer button in Vibe`);await page.evaluate(`document.querySelector('.ovs-input').value = 'find this'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(170);await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));`);-- await eventually(async () => assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: AI Enter hands its text to Vibe`));++ await eventually(async () => assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: Enter in Vibe starts a run with the words`));await page.evaluate(`document.querySelector('.ovs-input').value = ''; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`);await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`); await wait(80);-- await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`));++ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, runs: false, sw: false, chosen: 'Messages' }, `${name}: Messages inside the menu, the runs handed back`));await page.evaluate(`document.querySelector('.ovs-input').value = 'sara'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(180);await eventually(async () => assert.equal(await page.evaluate('window.filtered'), 'sara', `${name}: Messages search filters conversations in place`));await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));`);await eventually(async () => assert.equal(await page.evaluate(`!!(document.querySelector('.ovs-input').value === '' && document.querySelector('.ovs.is-open'))`), true, `${name}: Escape clears before closing`));await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`); await wait(80);-- await eventually(async () => assert.deepEqual(await inside(), { door: false, list: true, stub: false, sw: '', chosen: 'Feed' }, `${name}: Feed hands the panel back`));++ await eventually(async () => assert.deepEqual(await inside(), { door: false, list: true, stub: false, runs: false, sw: false, 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);-- await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:code', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`));++ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['runs:all', 'runs-destroy', 'mount:chats', 'destroy', 'runs:all', 'runs-destroy'], `${name}: one mount at a time, handed back on Messages, on Feed and on close`));++ // A place asked for by name (the Dock's open({ tab: 'code' }), the composer's timer): Vibe on that list.++ await page.evaluate(OPEN(true)); await wait(300);++ await page.evaluate(`window.log = []; OpenVibeSearch.door('code')`); await wait(80);++ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['runs:code'], `${name}: door('code') opens Vibe on Code`));++ await page.evaluate(`OpenVibeSearch.door('agent')`); await wait(80);++ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['runs:code', 'list:task'], `${name}: door('agent') moves the open Vibe to Tasks`));++ await page.evaluate('OpenVibeSearch.close()'); await wait(300);}-- await page.evaluate('delete window.OpenVibeDock');++ await page.evaluate('delete window.OpenVibeDock; delete window.OpenVibeRun');// 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 },`));@@@ -493,7 +493,9 @@ const created = (subject, over = {}) => ({// 25.7 → 25.75 KB on 2026-11-10 (4.201.0): the one menu's notifications (the count event, the five-row preview// with its count, per-view page sizes; 25.72 KB measured). It cannot be trimmed: every line feeds the menu.// 25.75 → 25.9 KB on 2026-11-11 (4.211.0): the You tab's three-row preview (is-preview, its rule, Show all).-- assert.ok(size('notification-ui.js') <= 25.9 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 25.9 KB`);++ // 25.9 → 26.1 KB on 2026-11-12 (4.225.0): inside the menu the list is part of the menu's one scroller (the owner: scrolling++ // in notifications did not work on a phone).++ assert.ok(size('notification-ui.js') <= 26.1 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 26.1 KB`);console.log('notification live: all checks passed');})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++// The menu's notifications scroll with the menu (4.225.0; the owner, 11-12: "scrolling is very buggy and doesn't work++// on mobile … in the menu especially in notifications tab"). The list inside the menu was a scroll box of its own that++// held its overscroll: it never overflowed there, so a swipe that began on a notice moved nothing at all. In a real++// headless Chrome with touch: a swipe on a notice scrolls the menu's list, and the list inside it is no scroll box.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const http = require('node:http');++const h = require('../browser-harness');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('notifications-menu-scroll-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }++const root = path.join(__dirname, '..');++const NOTES = Array.from({ length: 40 }, (_, i) => ({ id: i + 1, type: 'mention', category: 'social', service: 'chat', title: `kai mentioned you ${i + 1}`, message: 'look at the shots?', priority: 'normal', is_read: i > 5 ? 1 : 0, created_at: new Date(Date.now() - i * 600e3).toISOString() }));++const server = http.createServer((req, res) => {++ const u = req.url.split('?')[0];++ if (u === '/notification-ui.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'notification-ui.js'))); }++ if (u === '/api/notifications/unread-count') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"count":6}'); }++ if (u === '/api/notifications/unread-by-tab') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"tabs":{"all":6}}'); }++ if (u === '/api/notifications') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ notifications: NOTES, total: NOTES.length, has_more: false })); }++ res.writeHead(200, { 'content-type': 'text/html' });++ // The menu's shape: a fixed sheet whose list is the one scroller, the notifications drawn inside it.++ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;background:#0b1220;color:#e6edf7">++<div style="position:fixed;inset:0;display:flex;flex-direction:column"><div style="flex:none;height:96px">the menu's head</div>++<ul class="ovs-list" style="flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;margin:0;padding:6px;list-style:none"><li><div id="host"></div></li></ul></div>++<script src="/notification-ui.js"></script>++<script>OpenVibeNotifications.init({ token: 'tok_1', apiBase: location.origin }); const v = OpenVibeNotifications.renderMenuNotifications(document.getElementById('host'), () => {}); v.showAll();</script>`);++});++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notes-scroll-'));++ const browser = await h.launch({ tmpDir });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ try {++ const p = await h.openPage(browser, await browser.newContext(), { width: 390, height: 844 });++ await p.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });++ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 6000 });++ let n = 0;++ for (let i = 0; i < 80 && n < 20; i++) { await wait(50); n = await p.evaluate(`document.querySelectorAll('#host .openvibe-notif-item').length`); }++ assert.ok(n >= 20, `the notices are drawn (${n})`);++ const inner = await p.evaluate(`(() => { const l = document.querySelector('#host .openvibe-nv-list'); const cs = getComputedStyle(l); return { overflow: cs.overflowY, overscroll: cs.overscrollBehaviorY }; })()`);++ assert.deepEqual(inner, { overflow: 'visible', overscroll: 'auto' }, 'inside the menu the list is no scroll box of its own');++ const at = await p.evaluate(`(() => { const r = document.querySelectorAll('#host .openvibe-notif-item')[2].getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()`);++ await p.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [at] });++ for (let k = 1; k <= 12; k++) { await p.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: at.x, y: at.y - (400 * k) / 12 }] }); await wait(16); }++ await p.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });++ await wait(500);++ const top = await p.evaluate(`document.querySelector('.ovs-list').scrollTop`);++ assert.ok(top > 150, `a swipe that begins on a notice scrolls the menu (scrollTop ${top}; it was 0)`);++ await p.close();++ console.log('notifications-menu-scroll-chrome: a swipe on a notice scrolls the menu; the list inside it is no scroll box');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++// The one app icon (ov-icons.js .ov-appicon, OpenVibeIcons.app(), 4.225.0, design rule 142) in a real headless Chrome:++// markup and script make the same icon as the menu and Home (the app's own hue on a rounded square, its glyph drawn++// over it), at the size asked, labelled or decorative, filled when added later or when its app changes, and drawn++// dark on a light theme.++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const http = require('node:http');++const h = require('../browser-harness');++const icons = require('../ov-icons');++assert.equal(typeof icons.app, 'function', 'OpenVibeIcons.app is exported');++assert.equal(icons.hue('live'), (() => { let x = 7; for (const c of 'live') x = (x * 31 + c.charCodeAt(0)) % 360; return x; })(), 'the same hue as the menu and Home');++assert.match(icons.CSS, /\.ov-appicon\{/, 'its look ships with the icons');++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-appicon-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }++const root = path.join(__dirname, '..');++const server = http.createServer((req, res) => {++ const u = req.url.split('?')[0];++ if (u === '/ov-icons.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-icons.js'))); }++ res.writeHead(200, { 'content-type': 'text/html' });++ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;padding:16px;background:#0b1220">++<span id="a" class="ov-appicon" data-icon="live" data-size="64"></span>++<span id="b" class="ov-appicon" data-icon="tools" data-label="Tools"></span>++<script src="/ov-icons.js"></script>`);++});++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-appicon-'));++ const browser = await h.launch({ tmpDir });++ try {++ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 700 });++ await page.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));++ const look = (sel) => page.evaluate(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); const r = e.getBoundingClientRect(); const cs = getComputedStyle(e); const g = e.querySelector('.ov-icon');++ return { w: Math.round(r.width), h: Math.round(r.height), radius: cs.borderRadius, hue: e.style.getPropertyValue('--ova-h'), svg: !!e.querySelector('.ov-icon svg .ovi-glyph, .ov-icon svg .ovi-glyph--solid'),++ ring: g && g.querySelector('.ovi-ring') ? getComputedStyle(g.querySelector('.ovi-ring')).display : 'none', hidden: e.getAttribute('aria-hidden'), role: e.getAttribute('role'), label: e.getAttribute('aria-label'),++ glyph: g ? getComputedStyle(g).color : null, bg: cs.backgroundImage.startsWith('linear-gradient') }; })()`);++ await wait(100);++ const a = await look('#a');++ assert.deepEqual([a.w, a.h, a.radius, a.hue, a.svg, a.ring, a.hidden, a.bg], [64, 64, '30%', String(icons.hue('live')), true, 'none', 'true', true], 'from markup: its size, its hue, the glyph, no ring, decoration');++ const b = await look('#b');++ assert.deepEqual([b.w, b.role, b.label], [56, 'img', 'Tools'], 'the default size, and a label makes it an image with a name');++ // Script, and markup added later.++ await page.evaluate(`document.body.append(OpenVibeIcons.app('games', { size: 40, label: 'Games' })); const s = document.createElement('span'); s.id = 'late'; s.className = 'ov-appicon'; s.setAttribute('data-icon', 'chat'); document.body.append(s); true`);++ await wait(80);++ const late = await look('#late');++ assert.deepEqual([late.svg, late.hue], [true, String(icons.hue('chat'))], 'markup added later is filled');++ assert.equal(await page.evaluate(`document.querySelectorAll('.ov-appicon[data-icon="games"] .ov-icon svg').length`), 1, 'OpenVibeIcons.app() draws one');++ // Its app changes: it is redrawn in the new hue.++ await page.evaluate(`document.getElementById('late').setAttribute('data-icon', 'video'), true`);++ await wait(80);++ assert.equal((await look('#late')).hue, String(icons.hue('video')), 'a new app, a new hue');++ // A light theme draws the glyph dark.++ const darkGlyph = a.glyph;++ await page.evaluate(`document.documentElement.setAttribute('data-theme', 'light'), true`);++ await wait(50);++ assert.notEqual((await look('#a')).glyph, darkGlyph, 'the glyph changes with the theme');++ await page.close();++ console.log('ov-appicon-chrome: markup and script, size, hue, label, late markup, a changed app, the light theme');++ } finally {++ await browser.close();++ server.close();++ fs.rmSync(tmpDir, { recursive: true, force: true });++ }++})().catch((e) => { console.error(e); process.exit(1); });@@@ -27,11 +27,25 @@ const STEPS = [{ seq: 5, kind: 'message', from: 'agent', text: 'Done.', ui: { type: 'results', data: { results: [{ kind: 'timer', label: 'Timer, 10 min' }] } } },{ seq: 6, kind: 'approval', title: 'Post it?', text: 'It goes to your channel.', result: { approval: 'ap_1', options: [{ id: 'approve', label: 'Post it' }, { id: 'decline', label: 'Not now' }] } },];++// The Action Center (Network GET /api/v1/me/actions, common.action-item@1): one confirmation that needs you, one AI run++// working, and two the runs already show (the coding run, and the approval the task waits on): those two are not drawn twice.++const ACTIONS = { buckets: {++ waiting: [++ { service: 'network', id: 'cf_1', kind: 'approval', title: 'Let Clipper post for you', summary: 'A new app wants to post clips', bucket: 'waiting', state: 'waiting', created_at: T, updated_at: '2026-11-12T09:50:00Z', href: 'https://openvibe.network/confirm/cf_1', actions: ['approve', 'decline'] },++ { service: 'actor', id: 'apr_9', kind: 'approval', title: 'Post the clip', bucket: 'waiting', state: 'waiting', created_at: T, updated_at: T, href: 'https://openvibe.actor/a/apr_9', actions: ['approve', 'decline'] },++ ],++ running: [++ { service: 'ai', id: 'air_4', kind: 'ai_run', title: 'Captions for your VOD', step: 'Listening, 40%', bucket: 'running', state: 'running', created_at: T, updated_at: '2026-11-12T09:40:00Z', href: 'https://openvibe.ai/runs/air_4', actions: [] },++ { service: 'codes', id: 'k_3', kind: 'code_task', title: 'Fix the overlay', bucket: 'running', state: 'running', created_at: T, updated_at: T, href: 'https://openvibe.codes/tasks/k_3', actions: [] },++ ],++ scheduled: [], done: [], failed: [] } };const seen = { auth: new Set(), kinds: [], lastIds: [], posts: [] };const server = http.createServer((req, res) => {const u = new URL(req.url, 'http://x');if (u.pathname === '/ov-run.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-run.js'))); }if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;background:#0b1020;color:#e6edf7"><div id="host" style="height:100vh"></div><script src="/ov-run.js"></script>'); }++ if (u.pathname === '/api/v1/me/actions') { seen.auth.add(req.headers.authorization || ''); res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(ACTIONS)); }++ if (u.pathname === '/api/v1/me/actions/network/cf_1/approve' && req.method === 'POST') { seen.posts.push({ path: u.pathname, body: null }); res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"item":{}}'); }if (!u.pathname.startsWith('/api/frame/runs')) { res.writeHead(404); return res.end(); }seen.auth.add(req.headers.authorization || '');const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };@@@ -154,6 +168,44 @@ const server = http.createServer((req, res) => {assert.deepEqual([...seen.auth], ['Bearer tok_1'], `${width}: every call carries the person's token`);await page.evaluate('window.view.destroy()');assert.equal(await page.evaluate(`document.getElementById('host').children.length`), 0, `${width}: destroy empties the host`);++++ // ── 4.225.0, the menu's Vibe: starters, the Action Center's work, the host's slot above, the place, prefill ──++ await page.evaluate(`window.places = []; window.tops = 0; window.view = OpenVibeRun.mount(document.getElementById('host'), { base: '${base}/api/frame', token: 'tok_1',++ actions: { base: '${base}' }, starters: [['bell', 'Remind me to stretch'], ['live', 'Who is live right now?']],++ top(el) { window.tops++; el.innerHTML = '<div class="hosttop">Now</div>'; return { destroy() { window.tops--; } }; },++ onPlace(p) { window.places.push(p.filter || p.ref); } })`);++ assert.ok(await until(`document.querySelectorAll('.ovr-rows .ovr-row:not(.is-ghost)').length === 5`), `${width}: three runs and two from the Action Center`);++ const merged = await page.evaluate(`({ titles: [...document.querySelectorAll('.ovr-row-top b')].map((b) => b.textContent),++ links: [...document.querySelectorAll('a.ovr-row')].map((a) => a.getAttribute('href')), acts: [...document.querySelectorAll('.ovr-row-acts')].length,++ starters: [...document.querySelectorAll('.ovr-starter')].map((b) => b.textContent), top: (document.querySelector('.ovr-top .hosttop') || {}).textContent })`);++ assert.deepEqual(merged.titles, ['Let Clipper post for you', 'Post the clip', 'Goal bar ideas', 'Captions for your VOD', 'Fix the overlay'], `${width}: what needs you first, then newest; nothing twice`);++ assert.deepEqual(merged.links, ['https://openvibe.network/confirm/cf_1', 'https://openvibe.ai/runs/air_4'], `${width}: the Action Center's rows open their own pages`);++ assert.equal(merged.acts, 2, `${width}: both that need you can be answered in place`);++ assert.deepEqual(merged.starters, ['Remind me to stretch', 'Who is live right now?'], `${width}: the starters above the box`);++ assert.equal(merged.top, 'Now', `${width}: the host's slot above the list`);++ await page.evaluate(`document.querySelector('.ovr-rows li.has-acts .ovr-btn.is-primary').click()`);++ assert.ok(await until(`(document.querySelector('.ovr-rows li.has-acts .ovr-row-acts') || {}).textContent === 'Approved'`), `${width}: the Action Center's item approved in place`);++ assert.ok(seen.posts.some((x) => x.path === '/api/v1/me/actions/network/cf_1/approve'), `${width}: through the Action Center's own route`);++ // Code hides the slot and the starters; Chats shows the starters only.++ await page.evaluate(`document.querySelector('.ovr-filter[data-filter="code"]').click()`);++ assert.ok(await until(`document.querySelectorAll('.ovr-rows .ovr-row:not(.is-ghost)').length === 1`), `${width}: Code`);++ assert.deepEqual(await page.evaluate(`[document.querySelector('.ovr-top').hidden, document.querySelector('.ovr-starters').hidden]`), [true, true], `${width}: no slot and no starters on Code`);++ await page.evaluate(`document.querySelector('.ovr-filter[data-filter="chat"]').click()`);++ assert.ok(await until(`document.querySelector('.ovr-filter[aria-selected="true"]').dataset.filter === 'chat' && !document.querySelector('.ovr-rows .is-ghost')`), `${width}: Chats`);++ assert.deepEqual(await page.evaluate(`[document.querySelector('.ovr-top').hidden, document.querySelector('.ovr-starters').hidden]`), [true, false], `${width}: the starters on Chats`);++ // Ask your agent: the words in the box, unsent.++ await page.evaluate(`window.view.prefill('What is this page about?')`);++ assert.ok(await until(`document.querySelector('.ovr-compose textarea').value === 'What is this page about?' && document.activeElement === document.querySelector('.ovr-compose textarea')`), `${width}: prefill puts the words in the box, focused`);++ // A starter starts its run; the slot is handed back when the thread opens.++ await page.evaluate(`document.querySelector('.ovr-filter[data-filter="all"]').click()`);++ assert.ok(await until(`!document.querySelector('.ovr-rows .is-ghost') && !document.querySelector('.ovr-top').hidden`), `${width}: All again`);++ const before = seen.posts.length;++ await page.evaluate(`document.querySelector('.ovr-starter').click()`);++ assert.ok(await until(`!!document.querySelector('.ovr.is-thread')`), `${width}: a starter opens its new run`);++ assert.deepEqual(seen.posts.slice(before).find((x) => x.path === '').body, { text: 'Remind me to stretch' }, `${width}: started with the starter's words`);++ assert.equal(await page.evaluate('window.tops'), 0, `${width}: the host's slot is handed back in a thread`);++ assert.deepEqual(await page.evaluate('window.places'), ['all', 'code', 'chat', 'all', 'chat:c_2'], `${width}: each place is told to the host`);++ await page.evaluate('window.view.destroy()');await page.close();}console.log('run-thread-chrome: the list, filters, a thread with every kind of step, the live stream and its resume, approvals, messages and a new run');
@@ -1,5 +1,65 @@
@@ -1,5 +1,65 @@
# Changelog
# Changelog
+## 4.225.0 — 2026-11-12
+
+**The menu's Vibe is one place: All · Chats · Tasks · Code, no switch** (the owner, 11-12, crossing out the Chat · Code ·
+Agents switch: "one new unified agent … a shared UI").
+
+- **Vibe opens on All:** every run from the Frame (Vibe chats, tasks, coding runs) in one list (`ov-run.js`, 4.224.0).
+ - The Action Center's waiting and running work from every product (Network `/api/v1/me/actions`) joins it, unless a
+ run already shows it. What needs you comes first, approved or declined in place; the rest open their own pages.
+ - Above the list: the Dock's Now and timeline, your Actor and your agent's live view (lent with
+ `OpenVibeDock.mountInto(el, { tab: 'agent', part: 'top' })`).
+ - Vibe's six starters sit above the box; a press starts that run. Enter in the menu's field starts a run too.
+- **Chats, Tasks and Code are the Dock's own panels, lent whole** (Vibe's conversations with voice, undo and the timer;
+ Agents with its history, skills and filters; Code with its repos). They move into the one view when it does all they
+ do, so nothing is lost on the way.
+- The Dock's places open their filter: `OpenVibeDock.open({ tab: 'vibe' })` opens Chats, `'agent'` Tasks, `'code'` Code.
+ The status on the menu button still opens what needs you. `OpenVibeNavbar.openMenu()` also takes `'code'` and
+ `'tasks'`.
+- **`ov-run.js` gains:**
+ - `o.starters`;
+ - `o.actions: { base }`, the Action Center's work in the list;
+ - `o.top(el)`, the host's slot above the list on All;
+ - `o.panes: { chat, task, code }`, filters the host draws itself;
+ - `o.onPlace`;
+ - `view.prefill(text)`, words in the box, unsent.
+- A system light theme darkens the agents' glyphs.
+- **The menu scrolls on a phone wherever the swipe begins** (the owner, 11-12: "scrolling is very buggy … on mobile …
+ especially in notifications").
+ - Inside the menu, the notifications list was a scroll box of its own that held its overscroll. It never overflowed
+ there, so a swipe that began on a notice moved nothing.
+ - Inside the menu it is now part of the menu's one scroller (one scroller per layer).
+ - Measured with real touches at 390 px, and checked on every tab: nothing else nests a scroll box like it.
+ - New test `notifications-menu-scroll-chrome` (it fails without the fix).
+ - `notification-ui.js`'s brotli budget grows from 25.9 to 26.1 KB for it.
+- **The composer's + has GitHub.**
+ - It searches repositories through OpenVibe Exchange's code search (the Frame's `/api/frame/code/search`, Network
+ #878) a quarter second after the last key.
+ - Each row shows the owner's picture (GitHub's avatar host only), the name, the description, the language, stars
+ and the licence, all as text. Enter or a press puts the repository's address in the box, where every site draws
+ it as a card.
+ - A box may point it at another Network with `data-ovc-frame`. `composer-v2-chrome` checks it.
+- **One app icon for every site: `.ov-appicon` and `OpenVibeIcons.app(site, { size, label })`** (design rule 142).
+ - It is the menu's and Home's own icon: the app's steady hue on a rounded square, with its glyph drawn over it.
+ - Use it from markup (`<span class="ov-appicon" data-icon="live" data-size="64">`) or from script. Markup added later,
+ or an app that changes, is drawn again.
+ - With a label it is an image with a name; otherwise it is decoration. `OpenVibeIcons.hue(id)` is the shared hue.
+ `ov-appicon-chrome` checks it.
+- **Home on a phone: a long press then a move keeps the app under the finger.**
+ - Entering Edit repaints the grid, so the tile under the finger left the page, and the grid's touch guard no longer
+ heard that touch: the first move scrolled the menu and cancelled the press.
+ - The guard now rides on the pressed tile itself until the finger lifts.
+ - `menu-home-screen-chrome` drags with a real touch (it fails without the fix).
+- The menu reads runs and the Action Center from the Dock's Network base (`runsBase`).
+- **Tests:**
+ - `dock-chrome` gains the All view at 390 px: the four filters on All; a run and the Action Center's work in one
+ order; the Dock's top lent without its list; the starters; Tasks as the Agents panel, remembered by the Network;
+ approve in place from All. Every Dock check before it still passes unchanged.
+ - `run-thread-chrome` gains the starters, the Action Center's rows (nothing twice), the host's slot, the place and
+ prefill.
+ - `menu-places-chrome` and `menu-home-screen-chrome` follow the filters in place of the switch.
+
## 4.224.0 — 2026-11-12
## 4.224.0 — 2026-11-12
**One thread view for every run: chats, tasks and code** (the owner, 11-12, crossing out the menu's Chat · Code · Agents
**One thread view for every run: chats, tasks and code** (the owner, 11-12, crossing out the menu's Chat · Code · Agents
@@ -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.224.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.225.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.224.0",
+ "version": "4.225.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)",
@@ -114,7 +114,7 @@
@@ -114,7 +114,7 @@
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// 4.203.0 (ask 349, "a better composer system unified across all sites"): Vibe's box is the one composer too, for
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
// drafts, "/" commands and your own, the marks, what sends and your settings; its picture keeps its own upload.
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const selfSrc = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
- const VIBE_REV = { 'ov-composer.js': '2d3782ec0b52' };
+ const VIBE_REV = { 'ov-composer.js': '293808b32ad7' };
function joinComposer(form) {
function joinComposer(form) {
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
if (!form || form.ownerDocument !== document) return; // a popped-out Dock keeps its plain box
form.setAttribute('data-ovc-no-files', '');
form.setAttribute('data-ovc-no-files', '');
@@ -6,6 +6,9 @@
@@ -6,6 +6,9 @@
*
*
* OpenVibeDock.mount(opts) a signed-in person's state and counts (navbar.js; null when signed out)
* OpenVibeDock.mount(opts) a signed-in person's state and counts (navbar.js; null when signed out)
* OpenVibeDock.mountInto(host, { tab }) the panel body in the menu ('vibe' | 'code' | 'agent' | 'chats')
* OpenVibeDock.mountInto(host, { tab }) the panel body in the menu ('vibe' | 'code' | 'agent' | 'chats')
+ * OpenVibeDock.mountInto(host, { tab: 'agent', part: 'top' }) 4.225.0: only the top of Agents (Now and the
+ * timeline, your Actor, the live agent view), for the menu's Vibe, which
+ * draws the runs and the Action Center's work itself (ov-run.js)
* OpenVibeDock.open({ tab }) / ask({ text }) / share({ text, title }) open the menu there (opts.openMenu)
* OpenVibeDock.open({ tab }) / ask({ text }) / share({ text, title }) open the menu there (opts.openMenu)
* What the old pill said (an agent needs you, agents working, a timer's minutes) goes to opts.status, which navbar.js
* What the old pill said (an agent needs you, agents working, a timer's minutes) goes to opts.status, which navbar.js
* paints on the bar's menu button.
* paints on the bar's menu button.
@@ -35,6 +38,7 @@
@@ -35,6 +38,7 @@
const DEFAULT_STATE = { tab: 'vibe', agent: null, chat: null, vibe: null };
const DEFAULT_STATE = { tab: 'vibe', agent: null, chat: null, vibe: null };
let bodyEl = null, inline = null, panelEpoch = 0, o = {}, doc = null, tab = 'vibe', saveT = 0, pending = {};
let bodyEl = null, inline = null, panelEpoch = 0, o = {}, doc = null, tab = 'vibe', saveT = 0, pending = {};
+ let part = null; // 'top': only the top of the Agents panel is lent (mountInto … part: 'top', 4.225.0)
const mounts = {};
const mounts = {};
const state = () => (doc && doc.state) || DEFAULT_STATE;
const state = () => (doc && doc.state) || DEFAULT_STATE;
@@ -80,7 +84,7 @@
@@ -80,7 +84,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': 'e34bcde16fb8', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
+ const DOCK_REV = { 'dock-vibe.js': 'a918b45c0dae', 'dock-code.js': '7a768982eff3', 'dock-mount.js': 'fb07dccadb8c', 'dock-runs.js': '4368f039df7c', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': 'e40346ec1f16', 'dock-timeline.js': 'bffaed38fccf', 'dock-actor.js': 'f9aea671c7ad', 'dock-agent.js': '8787080a04d3', 'dock-timer.js': '460d68aa8321', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016', 'dock-chat-edits.js': '6cf8c6b6daa5' };
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);
@@ -267,6 +271,13 @@
@@ -267,6 +271,13 @@
if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); if (mounts[id + 'Timeline']) mounts[id + 'Timeline'].refresh(); return; }
if (mounts[id]) { if (mounts[id].refresh) mounts[id].refresh(); if (mounts[id + 'Timeline']) mounts[id + 'Timeline'].refresh(); return; }
mounts[id] = { refresh: null };
mounts[id] = { refresh: null };
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
panel.innerHTML = '<div class="ovdk-wait"><span></span></div>';
+ if (id === 'agent' && part === 'top') {
+ // 4.225.0: only the top is lent (the menu's Vibe draws the runs and the Action Center's work itself).
+ panel.innerHTML = '<div class="ovdk-actor"></div><div class="ovdk-timeline"></div><div class="ovdk-agent"></div>';
+ agentTop(id, panel, epoch);
+ mounts[id] = { refresh() { ['Timeline', 'Actor', 'View'].forEach((k) => { const m = mounts[id + k]; if (m && m.refresh) m.refresh(); }); } };
+ return;
+ }
if (id === 'code') {
if (id === 'code') {
const ok = await load('dock-code.js', () => !!(root.OpenVibeDockCode && root.OpenVibeDockCode.mount));
const ok = await load('dock-code.js', () => !!(root.OpenVibeDockCode && root.OpenVibeDockCode.mount));
if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; }
if (epoch !== panelEpoch || !panelActive(id)) { delete mounts[id]; return; }
@@ -280,18 +291,7 @@
@@ -280,18 +291,7 @@
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
- // phone Now and Needs you are on screen before anything else.
- load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
- 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 && 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 && 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') });
- });
+ agentTop(id, panel, epoch);
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: () => panelActive('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: () => document.getElementById('ovdk-styles'),
styles: () => document.getElementById('ovdk-styles'),
@@ -303,6 +303,20 @@
@@ -303,6 +303,20 @@
}
}
delete mounts[id];
delete mounts[id];
}
}
+ /** The top of the Agents panel: Now and the one timeline (design 2026-11-07, first, so on a phone Now and Needs you
+ * are on screen before anything else), your Actor (Actor v3 A3; nothing for a person without one) and your own
+ * agent's live view (K-D3; it hides itself when there is none). Each loads on its own. */
+ function agentTop(id, panel, epoch) {
+ load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
+ 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 });
+ });
+ load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => {
+ 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 && 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') });
+ });
+ }
function failed(panel, id) {
function failed(panel, id) {
mounts[id] = null;
mounts[id] = null;
panel.innerHTML = `<div class="ovdk-none">That part did not load. Check your connection, then open it again.</div>`;
panel.innerHTML = `<div class="ovdk-none">That part did not load. Check your connection, then open it again.</div>`;
@@ -674,7 +688,8 @@
@@ -674,7 +688,8 @@
// ── Open: the menu, on one of its places ──
// ── Open: the menu, on one of its places ──
// Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.
// Ask your agent (4.135.0): Vibe opens with the question in its box, unsent.
- function ask({ text = '' } = {}, n = 0) { if (!n && !panelActive('vibe')) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask && panelActive('vibe')) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }
+ function ask({ text = '' } = {}, n = 0) {
+ if (!n && !panelActive('vibe')) open({ tab: 'vibe' }); if (vibe.api && vibe.api.ask && panelActive('vibe')) vibe.api.ask(text); else if (n < 40) setTimeout(() => ask({ text }, n + 1), 100); }
function isOpen() { return !!inline; }
function isOpen() { return !!inline; }
function share({ text = '', title = '' } = {}) {
function share({ text = '', title = '' } = {}) {
const t = String(text || '').trim().slice(0, 4000);
const t = String(text || '').trim().slice(0, 4000);
@@ -694,7 +709,7 @@
@@ -694,7 +709,7 @@
let id = want || (c.waiting || c.running ? 'agent' : state().tab || 'vibe');
let id = want || (c.waiting || c.running ? 'agent' : state().tab || 'vibe');
if (id === 'agents') id = 'agent';
if (id === 'agents') id = 'agent';
if (!TABS.some(([t]) => t === id)) id = 'vibe';
if (!TABS.some(([t]) => t === id)) id = 'vibe';
- if (inline && id === tab) return;
+ if (inline && id === tab && part !== 'top') return;
if (typeof o.openMenu === 'function') o.openMenu(id);
if (typeof o.openMenu === 'function') o.openMenu(id);
}
}
function close() { if (typeof o.closeMenu === 'function') o.closeMenu(); }
function close() { if (typeof o.closeMenu === 'function') o.closeMenu(); }
@@ -709,7 +724,7 @@
@@ -709,7 +724,7 @@
let mountHandle = null;
let mountHandle = null;
/** Place the same panel body under another sheet's head. The helper and its layout load on first use. */
/** Place the same panel body under another sheet's head. The helper and its layout load on first use. */
- function mountInto(host, { tab: first = 'vibe' } = {}) {
+ function mountInto(host, { tab: first = 'vibe', part: lend = null } = {}) {
if (!(host instanceof Element)) throw new TypeError('mountInto needs a host element');
if (!(host instanceof Element)) throw new TypeError('mountInto needs a host element');
if (mountHandle) mountHandle.destroy();
if (mountHandle) mountHandle.destroy();
let api = null, gone = false, wanted = first;
let api = null, gone = false, wanted = first;
@@ -720,9 +735,10 @@
@@ -720,9 +735,10 @@
mountHandle = handle;
mountHandle = handle;
load('dock-mount.js', () => !!root.OpenVibeDockMount).then((ok) => {
load('dock-mount.js', () => !!root.OpenVibeDockMount).then((ok) => {
if (ok && !gone) api = root.OpenVibeDockMount.mount(host, wanted, {
if (ok && !gone) api = root.OpenVibeDockMount.mount(host, wanted, {
- start(wrap) { if (!bodyEl) build(); inline = wrap; wrap.append(bodyEl); },
- show(id) { setTab(id, true); },
- end() { disposePanel(); inline = null; if (bodyEl) bodyEl.remove(); paintStatus(); },
+ start(wrap) { if (!bodyEl) build(); part = lend === 'top' ? 'top' : null; wrap.classList.toggle('ovdk-part-top', part === 'top'); inline = wrap; wrap.append(bodyEl); },
+ // A lent top is not a place the person chose: the menu's Vibe remembers its own (remember()).
+ show(id) { setTab(id, part !== 'top'); },
+ end() { disposePanel(); inline = null; part = null; if (bodyEl) bodyEl.remove(); paintStatus(); },
});
});
});
});
return handle;
return handle;
@@ -604,7 +604,7 @@
@@ -604,7 +604,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// 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': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'b5f1f966bae3', 'dock.js': '1fb16839cb0e', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4086b813ffda', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '6215643437c0', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'c8da956d1e28', 'soft.js': '98ab9b8080b1', 'ov-run.js': 'f5de8ef6193b', 'ov-composer.js': '2d3782ec0b52' };
+ const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '146282739c06', 'dock.js': '1ae182836461', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '0cef1f895965', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '437395e64eda', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': 'a7636d0bcc02', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'c8da956d1e28', 'soft.js': '98ab9b8080b1', 'ov-run.js': '91be6059adcf', 'ov-composer.js': '293808b32ad7' };
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
@@ -2384,7 +2384,7 @@
@@ -2384,7 +2384,7 @@
if (!root.OpenVibeSearch) return;
if (!root.OpenVibeSearch) return;
if (_navEl) _navEl.classList.remove('ovnav-hide');
if (_navEl) _navEl.classList.remove('ovnav-hide');
root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: fromEl || null, section, door,
root.OpenVibeSearch.open({ service: _config.service, scope: Object.assign({ owner: _config.service }, _config.search || {}), links: flatLinks(rawLinks()), q, tab, from: fromEl || null, section, door,
- frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,
+ frame: OVFrame.get(), token: _config.token, apiBase: _config.apiBase, runsBase: dockBase(), user: _config.user, icons: (scope) => loadOvIcons(scope), anchor: anchor(), navIcon, sibling: siblingV,
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
// Display (theme, text size, motion) at the menu's foot, drawn by the same controls the drawer had.
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
display: (el) => { const box = document.createElement('div'); box.className = 'ovnav-display ovs-display'; el.appendChild(box); bindDisplayControls(box); },
look: bindLook,
look: bindLook,
@@ -2784,7 +2784,7 @@
@@ -2784,7 +2784,7 @@
/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */
/** The Dock on a tab, signed in only; opts.task points at that step (see openDock above). */
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
openDock(tab, opts) { if (_config.token && _config.dock !== false) openDock(tab, opts); },
/** The menu at a place (4.223.0). */
/** The menu at a place (4.223.0). */
- openMenu(p) { if (/^(vibe|chats|agent)$/.test(p)) return openMenuOn(p); const y = /^(notifications|recent|apps|you)$/.test(p); openSearch('', y ? 'you' : p === 'feed' ? 'feed' : 'here', null, y && p !== 'you' ? p : null); },
+ openMenu(p) { if (/^(vibe|chats|agent|code|tasks)$/.test(p)) return openMenuOn(p); const y = /^(notifications|recent|apps|you)$/.test(p); openSearch('', y ? 'you' : p === 'feed' ? 'feed' : 'here', null, y && p !== 'you' ? p : null); },
pop: popFrom,
pop: popFrom,
unpop,
unpop,
askAgent(text) { askAgent(text); },
askAgent(text) { askAgent(text); },
@@ -203,6 +203,11 @@
@@ -203,6 +203,11 @@
.openvibe-notif-panel, .openvibe-notif-inbox, .openvibe-notif-dock { --ovn-bg: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }
.openvibe-notif-panel, .openvibe-notif-inbox, .openvibe-notif-dock { --ovn-bg: var(--bg-elevated, var(--bg-secondary, #1d1f27)); }
.openvibe-notif-dock { display: flex; flex-direction: column; min-height: 0; flex: 1; color: var(--text-primary, #ececf1); }
.openvibe-notif-dock { display: flex; flex-direction: column; min-height: 0; flex: 1; color: var(--text-primary, #ececf1); }
.openvibe-notif-dock .openvibe-nv-head { display: none; }
.openvibe-notif-dock .openvibe-nv-head { display: none; }
+ /* 4.225.0 (the owner, 11-12: "scrolling is very buggy … on mobile … especially in notifications"): inside the menu
+ the list is not a scroll box of its own. It never overflowed there, but as a scroll box that holds its
+ overscroll it took every swipe that began on a notice and passed none of it to the menu, so nothing moved.
+ One scroller per layer: the menu's own list. */
+ .openvibe-notif-dock .openvibe-nv-list { flex: none; overflow: visible; overscroll-behavior: auto; }
.openvibe-notif-dock .openvibe-nv-tools { padding-top: 10px; }
.openvibe-notif-dock .openvibe-nv-tools { padding-top: 10px; }
.openvibe-notif-dock.is-preview .openvibe-nv-tools, .openvibe-notif-dock.is-preview .openvibe-nv-foot, .openvibe-notif-dock.is-preview .openvibe-nv-head, .openvibe-notif-dock.is-preview .openvibe-nv-more { display: none; }
.openvibe-notif-dock.is-preview .openvibe-nv-tools, .openvibe-notif-dock.is-preview .openvibe-nv-foot, .openvibe-notif-dock.is-preview .openvibe-nv-head, .openvibe-notif-dock.is-preview .openvibe-nv-more { display: none; }
.openvibe-notif-dock .openvibe-nv-foot { gap: 12px; }
.openvibe-notif-dock .openvibe-nv-foot { gap: 12px; }
@@ -279,6 +279,23 @@ html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }
@@ -279,6 +279,23 @@ html[data-ov-motion=off] .ovc-cam-shutter { transition: none; }
/* Emoji: a small panel above the box. */
/* Emoji: a small panel above the box. */
.ovc-emoji { position: absolute; z-index: 41; left: 8px; bottom: calc(100% + 8px); display: grid; grid-template-columns: repeat(8, 38px); gap: 2px; max-width: calc(100% - 16px); max-height: 232px; overflow: auto; padding: 8px; border-radius: 16px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .18)); background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }
.ovc-emoji { position: absolute; z-index: 41; left: 8px; bottom: calc(100% + 8px); display: grid; grid-template-columns: repeat(8, 38px); gap: 2px; max-width: calc(100% - 16px); max-height: 232px; overflow: auto; padding: 8px; border-radius: 16px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .18)); background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }
.ovc-emoji[hidden] { display: none; }
.ovc-emoji[hidden] { display: none; }
+.ovc-repo { position: absolute; z-index: 41; left: 8px; right: 8px; bottom: calc(100% + 8px); display: grid; gap: 6px; max-width: 520px; max-height: min(420px, 60vh); padding: 10px; border-radius: 16px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .18)); background: var(--bg-card, #131c2e); box-shadow: 0 18px 50px rgba(0, 0, 0, .35); animation: ovc-in .16s cubic-bezier(.2, .9, .3, 1) both; }
+.ovc-repo[hidden] { display: none; }
+.ovc-repo-q { width: 100%; box-sizing: border-box; min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--ovt-line, rgba(148, 163, 184, .22)); background: var(--bg-primary, rgba(0, 0, 0, .2)); color: inherit; font: inherit; font-size: 15px; outline: 0; }
+.ovc-repo-q:focus-visible { border-color: var(--ovt-accent, var(--accent, #3b82f6)); }
+.ovc-repo-list { display: grid; gap: 2px; overflow-y: auto; overscroll-behavior: contain; min-height: 0; }
+.ovc-repo-list:empty { display: none; }
+.ovc-repo-row { display: flex; align-items: flex-start; gap: 10px; width: 100%; min-height: 52px; padding: 8px; border: 0; border-radius: 12px; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
+.ovc-repo-row:hover, .ovc-repo-row:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 12%, transparent); outline: 0; }
+.ovc-repo-av { flex: none; width: 32px; height: 32px; border-radius: 9px; object-fit: cover; display: grid; place-items: center; background: color-mix(in srgb, currentColor 8%, transparent); }
+.ovc-repo-av svg { width: 18px; height: 18px; }
+.ovc-repo-t { min-width: 0; display: grid; gap: 1px; }
+.ovc-repo-t b { font-size: 14px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
+.ovc-repo-t small { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.ovc-repo-t i { font-style: normal; font-size: 11.5px; color: var(--text-muted, #93a0b4); }
+.ovc-repo-note { margin: 2px 4px; font-size: 12.5px; color: var(--text-muted, #93a0b4); }
+.ovc-repo-note[hidden] { display: none; }
+html[data-ov-motion=off] .ovc-repo { animation: none; }
.ovc-emoji button { width: 38px; height: 38px; border: 0; border-radius: 10px; background: none; font-size: 22px; line-height: 1; cursor: pointer; }
.ovc-emoji button { width: 38px; height: 38px; border: 0; border-radius: 10px; background: none; font-size: 22px; line-height: 1; cursor: pointer; }
.ovc-emoji button:hover, .ovc-emoji button:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
.ovc-emoji button:hover, .ovc-emoji button:focus-visible { background: color-mix(in srgb, var(--ovt-accent, var(--accent, #3b82f6)) 14%, transparent); }
@media (max-width: 480px) { .ovc-v2.ovt-composer { padding: 12px 12px 8px; } .ovc-emoji { grid-template-columns: repeat(7, 38px); } .ovc-v2 .ovt-send { padding: 0 16px; } }
@media (max-width: 480px) { .ovc-v2.ovt-composer { padding: 12px 12px 8px; } .ovc-emoji { grid-template-columns: repeat(7, 38px); } .ovc-v2 .ovt-send { padding: 0 16px; } }
@@ -370,6 +387,9 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
@@ -370,6 +387,9 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
mic: 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
mic: 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
plus: 'M12 5v14M5 12h14',
plus: 'M12 5v14M5 12h14',
timer: 'M12 4.5a8.2 8.2 0 1 1 0 16.4 8.2 8.2 0 0 1 0-16.4zM12 8.6v4.2l2.7 1.7M9.6 2.6h4.8',
timer: 'M12 4.5a8.2 8.2 0 1 1 0 16.4 8.2 8.2 0 0 1 0-16.4zM12 8.6v4.2l2.7 1.7M9.6 2.6h4.8',
+ // A repository: a branch from one commit to two (the registry's git-branch; no company's mark).
+ repo: 'M6.5 4.5v15M17.5 10a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2zM6.5 21.8a2.6 2.6 0 1 0 0-5.2 2.6 2.6 0 0 0 0 5.2zM17.5 10c0 4.5-4 6.2-11 6.6',
+ star: 'M12 4.2l2.4 4.9 5.4.8-3.9 3.8.9 5.4-4.8-2.5-4.8 2.5.9-5.4-3.9-3.8 5.4-.8z',
up: 'M12 19V5.5M6.2 11.2L12 5.4l5.8 5.8',
up: 'M12 19V5.5M6.2 11.2L12 5.4l5.8 5.8',
x: 'M7 7l10 10M17 7L7 17',
x: 'M7 7l10 10M17 7L7 17',
file: 'M7 3h7l5 5v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM14 3v5h5',
file: 'M7 3h7l5 5v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM14 3v5h5',
@@ -938,6 +958,9 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
@@ -938,6 +958,9 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
panel.querySelector('button').focus();
panel.querySelector('button').focus();
},
},
},
},
+ // 4.225.0: a repository from GitHub, found through OpenVibe Exchange's code search (the Frame's
+ // /api/frame/code/search): its address goes into the box, where every site draws it as a card.
+ github: { label: 'GitHub', path: IC.repo, extra: true, run: (ctx, button, form) => openRepoSearch(ctx, button, form) },
voice: { label: 'Speak', builtin: true }, // enhance() draws the microphone where the browser can listen
voice: { label: 'Speak', builtin: true }, // enhance() draws the microphone where the browser can listen
camera: { label: 'Camera', path: IC.camera, run: (ctx, button) => openCamera(ctx, button) },
camera: { label: 'Camera', path: IC.camera, run: (ctx, button) => openCamera(ctx, button) },
};
};
@@ -1006,6 +1029,70 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
@@ -1006,6 +1029,70 @@ html[data-ov-motion=off] .ovc-tray, html[data-ov-motion=off] .ovc-more svg { ani
if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
if (typeof dlg.showModal === 'function') dlg.showModal(); else dlg.setAttribute('open', '');
start();
start();
}
}
+ /** The GitHub tool's panel: a field, the repositories as you type (a quarter second after the last key), one press
+ * puts the address in the box. Everything is text; only https pictures from GitHub's avatar host are drawn. */
+ function openRepoSearch(ctx, button, form) {
+ let panel = form.querySelector('.ovc-repo');
+ if (panel) { panel.hidden = !panel.hidden; if (!panel.hidden) panel.querySelector('input').focus(); return; }
+ styles();
+ panel = el('div', 'ovc-repo'); panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'A repository from GitHub');
+ const field = el('input', 'ovc-repo-q'); field.type = 'search'; field.placeholder = 'Search GitHub'; field.setAttribute('aria-label', 'Search GitHub repositories'); field.autocomplete = 'off'; field.spellcheck = false;
+ const list = el('div', 'ovc-repo-list'); list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', 'Repositories');
+ const note = el('p', 'ovc-repo-note'); note.textContent = 'Type what the project does: “goal bar overlay”, “markdown editor”.';
+ panel.append(field, list, note);
+ const frame = String(form.getAttribute('data-ovc-frame') || 'https://openvibe.network').replace(/\/+$/, '');
+ let t = 0, gen = 0;
+ const close = () => { panel.hidden = true; button.focus(); };
+ const pick = (r) => { ctx.insert(`${r.url} `); panel.hidden = true; };
+ const draw = (items) => {
+ list.textContent = '';
+ items.forEach((r) => {
+ const b = el('button', 'ovc-repo-row'); b.type = 'button'; b.setAttribute('role', 'option');
+ let av = null;
+ try { const u = new URL(String(r.owner_avatar_url || '')); if (u.protocol === 'https:' && u.hostname === 'avatars.githubusercontent.com') { av = el('img', 'ovc-repo-av'); av.src = u.href; av.alt = ''; av.loading = 'lazy'; } } catch { /* no picture */ }
+ if (!av) { av = el('span', 'ovc-repo-av'); av.innerHTML = svg(IC.repo); }
+ const t1 = el('span', 'ovc-repo-t');
+ const name = el('b'); name.textContent = String(r.full_name || '');
+ const sub = el('small'); sub.textContent = String(r.description || '');
+ const meta = el('i'); meta.textContent = [r.language, Number.isFinite(r.stars) ? `★ ${r.stars >= 1000 ? `${(r.stars / 1000).toFixed(1)}k` : r.stars}` : '', r.license].filter(Boolean).join(' · ');
+ t1.append(name, sub, meta);
+ b.append(av, t1);
+ b.addEventListener('click', () => pick(r));
+ list.appendChild(b);
+ });
+ };
+ const search = async () => {
+ const q = field.value.trim();
+ const my = ++gen;
+ if (q.length < 2) { list.textContent = ''; note.hidden = false; note.textContent = 'Type what the project does: “goal bar overlay”, “markdown editor”.'; return; }
+ note.hidden = false; note.textContent = 'Searching…';
+ try {
+ const r = await fetch(`${frame}/api/frame/code/search?q=${encodeURIComponent(q)}&limit=8`, { credentials: 'omit', headers: { accept: 'application/json' } });
+ const j = await r.json().catch(() => ({}));
+ if (my !== gen) return;
+ if (!r.ok) throw new Error(j.error || 'Search is unavailable right now');
+ const items = Array.isArray(j.items) ? j.items : [];
+ draw(items);
+ note.hidden = items.length > 0;
+ note.textContent = items.length ? '' : 'Nothing found. Try other words.';
+ } catch (err) { if (my === gen) { list.textContent = ''; note.hidden = false; note.textContent = (err && err.message) || 'Search is unavailable right now'; } }
+ };
+ field.addEventListener('input', () => { clearTimeout(t); t = setTimeout(search, 250); });
+ field.addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); close(); }
+ else if (e.key === 'Enter') { e.preventDefault(); const first = list.querySelector('.ovc-repo-row'); if (first) first.click(); else { clearTimeout(t); search(); } }
+ else if (e.key === 'ArrowDown') { const first = list.querySelector('.ovc-repo-row'); if (first) { e.preventDefault(); first.focus(); } }
+ });
+ list.addEventListener('keydown', (e) => {
+ const rows = [...list.querySelectorAll('.ovc-repo-row')]; const k = rows.indexOf(document.activeElement);
+ if (e.key === 'ArrowDown' && k < rows.length - 1) { e.preventDefault(); rows[k + 1].focus(); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); (k > 0 ? rows[k - 1] : field).focus(); }
+ else if (e.key === 'Escape') { e.preventDefault(); close(); }
+ });
+ document.addEventListener('pointerdown', (e) => { if (!panel.hidden && !panel.contains(e.target) && !button.contains(e.target)) panel.hidden = true; });
+ form.appendChild(panel);
+ field.focus();
+ }
function tool(id, spec) {
function tool(id, spec) {
if (!/^[\w-]{1,30}$/.test(id || '') || !spec) return;
if (!/^[\w-]{1,30}$/.test(id || '') || !spec) return;
TOOL[id] = Object.assign({}, TOOL[id] || {}, spec);
TOOL[id] = Object.assign({}, TOOL[id] || {}, spec);
@@ -955,6 +955,17 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
@@ -955,6 +955,17 @@ html[data-ov-motion=off] .ovh-face.is-glyph:has(.is-working) { animation: none;
t.addEventListener('pointerdown', (e) => {
t.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
if (e.button !== 0) return;
const start = { x: e.clientX, y: e.clientY, id: e.pointerId };
const start = { x: e.clientX, y: e.clientY, id: e.pointerId };
+ // 4.225.0, a phone (the owner: a long press then a move lost the icon): entering Edit repaints the grid, so
+ // the tile under the finger leaves the page and the grid's own touchmove guard no longer hears this touch;
+ // the browser scrolled the menu and cancelled the press. The guard rides on the pressed tile itself, which
+ // keeps getting the touch's events after it is replaced, until the finger lifts.
+ if (e.pointerType === 'touch') {
+ const hold = (ev) => { if ((state.edit || state.drag) && ev.cancelable) ev.preventDefault(); };
+ const done = () => { t.removeEventListener('touchmove', hold); t.removeEventListener('touchend', done); t.removeEventListener('touchcancel', done); };
+ t.addEventListener('touchmove', hold, { passive: false });
+ t.addEventListener('touchend', done);
+ t.addEventListener('touchcancel', done);
+ }
let timer = 0;
let timer = 0;
const cancel = () => { clearTimeout(timer); window.removeEventListener('pointermove', early); window.removeEventListener('pointerup', cancel); window.removeEventListener('pointercancel', cancel); };
const cancel = () => { clearTimeout(timer); window.removeEventListener('pointermove', early); window.removeEventListener('pointerup', cancel); window.removeEventListener('pointercancel', cancel); };
const early = (m) => { if (m.pointerId === start.id && Math.hypot(m.clientX - start.x, m.clientY - start.y) > 8) { cancel(); if (state.edit) beginDrag(id, m); } };
const early = (m) => { if (m.pointerId === start.id && Math.hypot(m.clientX - start.x, m.clientY - start.y) > 8) { cancel(); if (state.edit) beginDrag(id, m); } };
@@ -152,6 +152,11 @@
@@ -152,6 +152,11 @@
for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };
for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };
const CSS = `
const CSS = `
+.ov-appicon{--ova-h:215;--ova-size:56px;position:relative;display:inline-block;flex:none;width:var(--ova-size);height:var(--ova-size);border-radius:30%;overflow:hidden;vertical-align:middle;background:linear-gradient(150deg,hsl(var(--ova-h) 72% 58% / .36),hsl(var(--ova-h) 70% 38% / .2));box-shadow:inset 0 0 0 1px hsl(var(--ova-h) 70% 64% / .3)}
+.ov-appicon>.ov-icon{position:absolute;inset:0;width:100%;height:100%;display:block;--ovi-glyph:hsl(var(--ova-h) 92% 84%);color:hsl(var(--ova-h) 92% 84%)}
+.ov-appicon>.ov-icon svg{position:absolute;left:-13%;top:-13%;width:126%;height:126%;margin:0;transform:none!important}
+.ov-appicon .ovi-ring,.ov-appicon .ovi-bg,.ov-appicon .ovi-comet,.ov-appicon .ovi-prog{display:none}
+:root[data-theme="light"] .ov-appicon>.ov-icon{--ovi-glyph:hsl(var(--ova-h) 70% 30%);color:hsl(var(--ova-h) 70% 30%)}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
@@ -348,7 +353,36 @@ html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`;
@@ -348,7 +353,36 @@ html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`;
applyProgress(el);
applyProgress(el);
}
}
- function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); }
+ function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-appicon[data-icon]').forEach(fillApp); (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); }
+
+ /** A site's (or an app's) steady hue: the same on every page and device (the menu's and Home's own). */
+ function hue(id) { let x = 7; const t = String(id || 'ov'); let i = 0; while (i < t.length) { x = (x * 31 + t.charCodeAt(i)) % 360; i++; } return x; }
+ /** 4.225.0 (design rule 142): an app icon, the same as the menu's and Home's: the app's own hue on a rounded square,
+ * its glyph at 58% of it. In markup, <span class="ov-appicon" data-icon="live" data-size="64"></span>; or
+ * OpenVibeIcons.app('live', { size: 64, label: 'Live' }). Without a label it is decoration (aria-hidden). */
+ function fillApp(el) {
+ const name = String(el.getAttribute('data-icon') || 'ov');
+ if (el.getAttribute('data-ova') === name) return;
+ el.setAttribute('data-ova', name);
+ el.style.setProperty('--ova-h', String(el.hasAttribute('data-hue') ? Number(el.getAttribute('data-hue')) % 360 : hue(name)));
+ const size = parseInt(el.getAttribute('data-size'), 10);
+ if (size) el.style.setProperty('--ova-size', `${size}px`);
+ const label = el.getAttribute('data-label');
+ if (label) { el.setAttribute('role', 'img'); el.setAttribute('aria-label', label); } else el.setAttribute('aria-hidden', 'true');
+ el.textContent = '';
+ const g = document.createElement('span'); g.className = 'ov-icon'; g.setAttribute('data-icon', name); g.setAttribute('data-fx', 'none');
+ el.appendChild(g);
+ mountOne(g);
+ }
+ function app(site, { size = 56, label = '', hue: h = null } = {}) {
+ const el = document.createElement('span'); el.className = 'ov-appicon';
+ el.setAttribute('data-icon', String(site || 'ov'));
+ if (size) el.setAttribute('data-size', String(size));
+ if (label) el.setAttribute('data-label', String(label));
+ if (h != null) el.setAttribute('data-hue', String(h));
+ fillApp(el);
+ return el;
+ }
function set(el, patch) {
function set(el, patch) {
if (!el || !patch) return;
if (!el || !patch) return;
@@ -364,7 +398,7 @@ html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`;
@@ -364,7 +398,7 @@ html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`;
if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });
if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });
}
}
- const api = { names: () => Object.keys(registry), register, mount, set, svg, sprite, use, resolve, CSS };
+ const api = { names: () => Object.keys(registry), register, mount, set, svg, sprite, use, resolve, app, hue, CSS };
root.OpenVibeIcons = api;
root.OpenVibeIcons = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document === 'undefined') return;
if (typeof document === 'undefined') return;
@@ -375,8 +409,17 @@ html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`;
@@ -375,8 +409,17 @@ html[data-ov-motion=off] .ov-icon .ovi-comet{display:none}`;
try {
try {
new MutationObserver((muts) => {
new MutationObserver((muts) => {
for (const m of muts) {
for (const m of muts) {
- if (m.type === 'attributes') { if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target); continue; }
- m.addedNodes.forEach((n) => { if (n.nodeType !== 1) return; if (n.classList.contains('ov-icon')) mountOne(n); else if (n.querySelector) n.querySelectorAll('.ov-icon').forEach(mountOne); });
+ if (m.type === 'attributes') {
+ if (m.target.classList && m.target.classList.contains('ov-appicon')) { m.target.removeAttribute('data-ova'); fillApp(m.target); continue; }
+ if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target);
+ continue;
+ }
+ m.addedNodes.forEach((n) => {
+ if (n.nodeType !== 1) return;
+ if (n.classList.contains('ov-appicon') && n.hasAttribute('data-icon')) { fillApp(n); return; }
+ if (n.classList.contains('ov-icon')) { mountOne(n); return; }
+ if (n.querySelector) { n.querySelectorAll('.ov-appicon[data-icon]').forEach(fillApp); n.querySelectorAll('.ov-icon').forEach(mountOne); }
+ });
}
}
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-icon', 'data-progress'] });
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-icon', 'data-progress'] });
} catch { /* */ }
} catch { /* */ }
@@ -13,9 +13,23 @@
@@ -13,9 +13,23 @@
* examples: ['A goal bar for my stream', …], // what people make, shown in turn in the empty box
* examples: ['A goal bar for my stream', …], // what people make, shown in turn in the empty box
* start: { kind, target, context }, // what a new run starts with (the box's defaults)
* start: { kind, target, context }, // what a new run starts with (the box's defaults)
* });
* });
- * view.list('code'); view.open('chat:c_8f3k2m'); view.start('A goal bar for my stream'); view.destroy();
+ * view.list('code'); view.open('chat:c_8f3k2m'); view.start('A goal bar for my stream'); view.prefill('Words, unsent');
+ * view.destroy();
* (o.filter: the list's first filter, all | chat | task | code; o.open: a run's ref to open first)
* (o.filter: the list's first filter, all | chat | task | code; o.open: a run's ref to open first)
*
*
+ * 4.225.0, what the menu's Vibe needs to be the one place (the Dock's Vibe and Agents panels did all of it):
+ * o.starters: [[glyph, words], …] things to ask, as chips above the box (All and Chats); a press starts that run
+ * o.actions: { base } the Action Center's waiting and running work from every product (Network
+ * GET /api/v1/me/actions), merged into the list (All and Tasks) when a run does not
+ * already show it; approved or declined in place, and opened on its own page
+ * o.top(el) → { destroy } something of the host's above the list (All and Tasks): the menu puts the Dock's
+ * Now, your Actor and the live agent view there
+ * o.panes: { chat, task, code } a filter the host still draws itself: pane(el) → { destroy }. The menu's Vibe
+ * lends the Dock's Vibe, Agents and Code panels here until this view does all
+ * they do; the pane replaces the rows and the box under the filters
+ * o.onPlace({ filter } | { ref }) where the person is, for the host to remember
+ * view.prefill(text) the words in the list's box, unsent (Ask your agent)
+ *
* OpenVibeRun.card(type, (data, step, ctx) => Node) a rich card for a step's `ui.type` (a site or a mod adds its
* OpenVibeRun.card(type, (data, step, ctx) => Node) a rich card for a step's `ui.type` (a site or a mod adds its
* own); an unknown type shows the step's words.
* own); an unknown type shows the step's words.
*
*
@@ -57,9 +71,13 @@
@@ -57,9 +71,13 @@
compass: '<circle cx="12" cy="12" r="8.5"/><path d="m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1Z"/>',
compass: '<circle cx="12" cy="12" r="8.5"/><path d="m15.6 8.4-2.1 5.1-5.1 2.1 2.1-5.1Z"/>',
page: '<path d="M6.5 3h7.5l4 4v14h-11.5Z"/><path d="M14 3v4h4M9.5 12h5M9.5 15.5h5"/>',
page: '<path d="M6.5 3h7.5l4 4v14h-11.5Z"/><path d="M14 3v4h4M9.5 12h5M9.5 15.5h5"/>',
timer: '<path d="M12 4.5a8.2 8.2 0 1 1 0 16.4 8.2 8.2 0 0 1 0-16.4zM12 8.6v4.2l2.7 1.7M9.6 2.6h4.8"/>',
timer: '<path d="M12 4.5a8.2 8.2 0 1 1 0 16.4 8.2 8.2 0 0 1 0-16.4zM12 8.6v4.2l2.7 1.7M9.6 2.6h4.8"/>',
+ bell: '<path d="M6 16.5V11a6 6 0 0 1 12 0v5.5l1.5 1.5h-15ZM10 20.5a2.2 2.2 0 0 0 4 0"/>',
+ calendar: '<rect x="4" y="5.5" width="16" height="14.5" rx="2.5"/><path d="M4 10h16M8.5 3.5v4M15.5 3.5v4"/>',
+ phone: '<path d="M6.5 3.8h3l1.5 4-2 1.3a10.5 10.5 0 0 0 5.9 5.9l1.3-2 4 1.5v3a1.8 1.8 0 0 1-2 1.8A15.5 15.5 0 0 1 4.7 5.8a1.8 1.8 0 0 1 1.8-2Z"/>',
};
};
const KIND_GLYPH = { chat: 'chat', ask: 'search', task: 'robot', code: 'code', machine: 'tool', goal: 'spark' };
const KIND_GLYPH = { chat: 'chat', ask: 'search', task: 'robot', code: 'code', machine: 'tool', goal: 'spark' };
const KIND_WORD = { chat: 'Chat', ask: 'Answer', task: 'Task', code: 'Code', machine: 'Machine', goal: 'Goal' };
const KIND_WORD = { chat: 'Chat', ask: 'Answer', task: 'Task', code: 'Code', machine: 'Machine', goal: 'Goal' };
+ const SERVICE_NAME = { actor: 'Actor', codes: 'Codes', ai: 'AI', network: 'OpenVibe', run: 'Run', domains: 'Domains' };
const svg = (name, cls = '') => {
const svg = (name, cls = '') => {
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); if (cls) s.setAttribute('class', cls);
s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); if (cls) s.setAttribute('class', cls);
@@ -217,6 +235,38 @@
@@ -217,6 +235,38 @@
};
};
const box = el('div', 'ovr');
const box = el('div', 'ovr');
host.textContent = ''; host.append(box);
host.textContent = ''; host.append(box);
+ const actionsBase = o.actions ? String(o.actions.base || 'https://openvibe.network').replace(/\/+$/, '') : '';
+ const place = (p) => { try { if (typeof o.onPlace === 'function') o.onPlace(p); } catch { /* the host's */ } };
+ let topHandle = null, paneHandle = null, paneId = null;
+ const dropTop = () => { if (topHandle && typeof topHandle.destroy === 'function') { try { topHandle.destroy(); } catch { /* */ } } topHandle = null; };
+ const dropPane = () => { if (paneHandle && typeof paneHandle.destroy === 'function') { try { paneHandle.destroy(); } catch { /* */ } } paneHandle = null; paneId = null; };
+ const paneFor = (f) => (o.panes && typeof o.panes[f] === 'function' ? o.panes[f] : null);
+ /** The Action Center's items (common.action-item@1) that need you or are working, as rows beside the runs. */
+ async function actionRows(runs) {
+ if (!actionsBase || !['all', 'task'].includes(state.filter)) return [];
+ try {
+ const r = await fetch(`${actionsBase}/api/v1/me/actions`, { headers: { Accept: 'application/json', ...headers() }, credentials: 'omit' });
+ if (!r.ok) return [];
+ const doc = await r.json();
+ const shown = new Set(runs.map((x) => x.ref));
+ runs.forEach((x) => { if (x.waiting && x.waiting.approval) shown.add(`approval:${x.waiting.approval}`); });
+ const out = [];
+ for (const bucket of ['waiting', 'running']) {
+ for (const it of (doc && doc.buckets && Array.isArray(doc.buckets[bucket]) ? doc.buckets[bucket] : []).slice(0, 30)) {
+ if (!it || typeof it.id !== 'string' || shown.has(`${it.service}:${it.id}`) || (it.kind === 'approval' && shown.has(`approval:${it.id}`))) continue;
+ out.push({
+ action: it, id: it.id, kind: it.kind === 'code_task' ? 'code' : 'task', title: it.title, url: it.href,
+ status: bucket === 'waiting' ? 'waiting' : 'running', updated_at: it.updated_at,
+ waiting: bucket === 'waiting' ? { for: 'approval', what: it.summary || it.title } : null,
+ doing: bucket === 'running' ? { text: it.step || it.summary || 'Working', glyph: it.kind === 'code_task' ? 'code' : 'robot' } : null,
+ preview: it.summary ? { text: it.summary } : null,
+ agent: { id: `${it.service}`, name: it.agent || SERVICE_NAME[it.service] || 'OpenVibe', glyph: it.kind === 'code_task' ? 'code' : it.kind === 'ai_run' ? 'spark' : 'robot' },
+ });
+ }
+ }
+ return out;
+ } catch { return []; }
+ }
const state = { view: 'list', filter: FILTER_IDS.includes(o.filter) ? o.filter : 'all', runs: [], run: null, steps: new Map(), stopStream: null, abort: null, gen: 0 };
const state = { view: 'list', filter: FILTER_IDS.includes(o.filter) ? o.filter : 'all', runs: [], run: null, steps: new Map(), stopStream: null, abort: null, gen: 0 };
function clearStream() { if (state.stopStream) state.stopStream(); state.stopStream = null; if (state.abort) state.abort.abort(); state.abort = null; }
function clearStream() { if (state.stopStream) state.stopStream(); state.stopStream = null; if (state.abort) state.abort.abort(); state.abort = null; }
@@ -289,10 +339,40 @@
@@ -289,10 +339,40 @@
head.append(tabs);
head.append(tabs);
const rows = el('ul', 'ovr-rows'); rows.setAttribute('aria-label', 'Your runs');
const rows = el('ul', 'ovr-rows'); rows.setAttribute('aria-label', 'Your runs');
const foot = el('div', 'ovr-list-note');
const foot = el('div', 'ovr-list-note');
- box.append(head, rows, foot);
+ const top = el('div', 'ovr-top');
+ const pane = el('div', 'ovr-pane'); pane.hidden = true;
+ box.append(head, top, rows, foot, pane);
+ dropTop(); dropPane();
+ if (Array.isArray(o.starters) && o.starters.length) {
+ const st = el('div', 'ovr-starters'); st.setAttribute('role', 'group'); st.setAttribute('aria-label', 'Things to ask');
+ o.starters.slice(0, 8).forEach((x) => {
+ const [ic, words] = Array.isArray(x) ? x : ['spark', String(x)];
+ const b = el('button', 'ovr-starter'); b.type = 'button'; b.append(svg(G[ic] ? ic : 'spark'), el('span', '', words));
+ b.addEventListener('click', async () => { b.disabled = true; try { await start(words); } catch (err) { b.disabled = false; foot.textContent = ''; foot.append(el('p', 'ovr-note is-err', err.message)); } });
+ st.append(b);
+ });
+ box.append(st);
+ }
composer(box, { placeholder: o.placeholder || 'Ask, or say what to make', onSend: (v) => start(v.text) });
composer(box, { placeholder: o.placeholder || 'Ask, or say what to make', onSend: (v) => start(v.text) });
}
}
+ place({ filter: state.filter });
box.querySelectorAll('.ovr-filter').forEach((b) => { const on = b.dataset.filter === state.filter; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
box.querySelectorAll('.ovr-filter').forEach((b) => { const on = b.dataset.filter === state.filter; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
+ // A filter the host draws itself (a pane): it takes the place of the rows, the starters and the box. The host's
+ // pieces may share one owner (the Dock lends one panel at a time), so the old one goes before the new one comes.
+ const pf = paneFor(state.filter);
+ const topEl = box.querySelector('.ovr-top'), paneEl = box.querySelector('.ovr-pane');
+ const wantTop = !pf && ['all', 'task'].includes(state.filter) && typeof o.top === 'function';
+ if (!wantTop) { dropTop(); topEl.textContent = ''; }
+ if (paneId !== (pf ? state.filter : null)) { dropPane(); paneEl.textContent = ''; }
+ box.classList.toggle('has-pane', !!pf);
+ paneEl.hidden = !pf; topEl.hidden = !wantTop;
+ { const st = box.querySelector('.ovr-starters'); if (st) st.hidden = !!pf || !['all', 'chat'].includes(state.filter); }
+ if (pf) {
+ if (!paneHandle) { paneId = state.filter; try { paneHandle = pf(paneEl) || {}; } catch { paneHandle = {}; } }
+ const rows0 = box.querySelector('.ovr-rows'); rows0.textContent = ''; rows0.removeAttribute('aria-busy'); box.querySelector('.ovr-list-note').textContent = '';
+ return;
+ }
+ if (wantTop && !topHandle) { try { topHandle = o.top(topEl) || null; } catch { topHandle = null; } }
const rows = box.querySelector('.ovr-rows'); const foot = box.querySelector('.ovr-list-note');
const rows = box.querySelector('.ovr-rows'); const foot = box.querySelector('.ovr-list-note');
rows.textContent = ''; foot.textContent = ''; rows.setAttribute('aria-busy', 'true');
rows.textContent = ''; foot.textContent = ''; rows.setAttribute('aria-busy', 'true');
for (let i = 0; i < 3; i++) rows.append(el('li', 'ovr-row is-ghost'));
for (let i = 0; i < 3; i++) rows.append(el('li', 'ovr-row is-ghost'));
@@ -306,13 +386,23 @@
@@ -306,13 +386,23 @@
}
}
if (gen !== state.gen) return;
if (gen !== state.gen) return;
state.runs = Array.isArray(out.runs) ? out.runs : [];
state.runs = Array.isArray(out.runs) ? out.runs : [];
+ const extra = await actionRows(state.runs);
+ if (gen !== state.gen) return;
+ if (extra.length) {
+ // What needs you first, then newest: the Action Center's rows join in that order.
+ const needs = (r) => (needsPerson(r) ? 1 : 0);
+ state.runs = state.runs.concat(extra).sort((x, y) => needs(y) - needs(x) || String(y.updated_at || '').localeCompare(String(x.updated_at || '')));
+ }
rows.textContent = ''; rows.removeAttribute('aria-busy');
rows.textContent = ''; rows.removeAttribute('aria-busy');
if (!state.runs.length) foot.append(empty('Nothing here yet', state.filter === 'code' ? 'Say what to build below: a page, a fix, controls for your stream.' : 'Ask anything below. Chats, tasks and code you start show up here.'));
if (!state.runs.length) foot.append(empty('Nothing here yet', state.filter === 'code' ? 'Say what to build below: a page, a fix, controls for your stream.' : 'Ask anything below. Chats, tasks and code you start show up here.'));
state.runs.forEach((r, i) => { const li = el('li'); li.append(row(r, i)); const acts = answerRow(r); if (acts) { li.classList.add('has-acts'); li.append(acts); } rows.append(li); });
state.runs.forEach((r, i) => { const li = el('li'); li.append(row(r, i)); const acts = answerRow(r); if (acts) { li.classList.add('has-acts'); li.append(acts); } rows.append(li); });
if (Array.isArray(out.partial) && out.partial.length) foot.append(el('p', 'ovr-note', `Some of your runs did not load (${out.partial.join(', ')}).`));
if (Array.isArray(out.partial) && out.partial.length) foot.append(el('p', 'ovr-note', `Some of your runs did not load (${out.partial.join(', ')}).`));
}
}
function row(r, i) {
function row(r, i) {
- const b = el('button', `ovr-row${needsPerson(r) ? ' is-needs' : ''}`); b.type = 'button'; b.style.setProperty('--i', String(Math.min(i, 12)));
+ const away = r.action ? httpsUrl(r.url) : '';
+ const b = away ? el('a', `ovr-row${needsPerson(r) ? ' is-needs' : ''}`) : el('button', `ovr-row${needsPerson(r) ? ' is-needs' : ''}`);
+ if (away) { b.href = away; b.target = '_blank'; b.rel = 'noopener'; } else b.type = 'button';
+ b.style.setProperty('--i', String(Math.min(i, 12)));
const t = el('span', 'ovr-row-t');
const t = el('span', 'ovr-row-t');
const top = el('span', 'ovr-row-top'); top.append(el('b', '', r.title || (r.agent && r.agent.name) || KIND_WORD[r.kind] || 'Run'), el('time', '', ago(r.updated_at)));
const top = el('span', 'ovr-row-top'); top.append(el('b', '', r.title || (r.agent && r.agent.name) || KIND_WORD[r.kind] || 'Run'), el('time', '', ago(r.updated_at)));
const line = el('span', 'ovr-row-line', needsPerson(r) || r.status === 'running' ? statusLine(r) : (r.preview && r.preview.text) || statusLine(r));
const line = el('span', 'ovr-row-line', needsPerson(r) || r.status === 'running' ? statusLine(r) : (r.preview && r.preview.text) || statusLine(r));
@@ -322,13 +412,15 @@
@@ -322,13 +412,15 @@
else if (r.status === 'failed') meta.append(el('i', 'ovr-pill is-failed', 'Did not finish'));
else if (r.status === 'failed') meta.append(el('i', 'ovr-pill is-failed', 'Did not finish'));
t.append(top, line, meta);
t.append(top, line, meta);
b.append(face(r), t);
b.append(face(r), t);
- b.addEventListener('click', () => open(r.ref || r.id, r));
+ if (!away) b.addEventListener('click', () => open(r.ref || r.id, r));
return b;
return b;
}
}
/** A run that asks before it acts is answered from the list (the Action Center's approve in place). */
/** A run that asks before it acts is answered from the list (the Action Center's approve in place). */
function answerRow(r) {
function answerRow(r) {
const w = r && r.waiting;
const w = r && r.waiting;
- if (!needsPerson(r) || w.for !== 'approval' || !w.approval || !/^[A-Za-z0-9._~-]{1,160}$/.test(String(w.approval))) return null;
+ const it = r && r.action;
+ if (it && !(needsPerson(r) && Array.isArray(it.actions) && it.actions.includes('approve'))) return null;
+ if (!it && (!needsPerson(r) || w.for !== 'approval' || !w.approval || !/^[A-Za-z0-9._~-]{1,160}$/.test(String(w.approval)))) return null;
const box2 = el('div', 'ovr-row-acts');
const box2 = el('div', 'ovr-row-acts');
const say = (text, err) => { box2.textContent = ''; box2.append(el('p', `ovr-note${err ? ' is-err' : ''}`, text)); };
const say = (text, err) => { box2.textContent = ''; box2.append(el('p', `ovr-note${err ? ' is-err' : ''}`, text)); };
[['approve', 'Approve', true], ['decline', 'Not now', false]].forEach(([id, label, primary]) => {
[['approve', 'Approve', true], ['decline', 'Not now', false]].forEach(([id, label, primary]) => {
@@ -336,7 +428,13 @@
@@ -336,7 +428,13 @@
b.setAttribute('aria-label', `${label}: ${r.title || 'this run'}`);
b.setAttribute('aria-label', `${label}: ${r.title || 'this run'}`);
b.addEventListener('click', async () => {
b.addEventListener('click', async () => {
[...box2.querySelectorAll('button')].forEach((x) => { x.disabled = true; });
[...box2.querySelectorAll('button')].forEach((x) => { x.disabled = true; });
- try { await api(`/${encodeURIComponent(r.ref || r.id)}/approvals/${encodeURIComponent(w.approval)}`, { method: 'POST', body: JSON.stringify({ answer: id }) }); say(id === 'approve' ? 'Approved' : 'Declined'); }
+ try {
+ if (it) {
+ const res = await fetch(`${actionsBase}/api/v1/me/actions/${encodeURIComponent(it.service)}/${encodeURIComponent(it.id)}/${id}`, { method: 'POST', credentials: 'omit', headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...headers() }, body: '{}' });
+ if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.detail || j.error || j.title || `That did not go through (${res.status})`); }
+ } else await api(`/${encodeURIComponent(r.ref || r.id)}/approvals/${encodeURIComponent(w.approval)}`, { method: 'POST', body: JSON.stringify({ answer: id }) });
+ say(id === 'approve' ? 'Approved' : 'Declined');
+ }
catch (err) { [...box2.querySelectorAll('button')].forEach((x) => { x.disabled = false; }); box2.append(el('p', 'ovr-note is-err', err.message)); }
catch (err) { [...box2.querySelectorAll('button')].forEach((x) => { x.disabled = false; }); box2.append(el('p', 'ovr-note is-err', err.message)); }
});
});
box2.append(b);
box2.append(b);
@@ -348,8 +446,10 @@
@@ -348,8 +446,10 @@
// ── the thread ──
// ── the thread ──
async function open(ref, known) {
async function open(ref, known) {
clearStream();
clearStream();
+ dropTop(); dropPane();
const gen = ++state.gen;
const gen = ++state.gen;
state.view = 'thread'; state.run = known || null; state.steps = new Map();
state.view = 'thread'; state.run = known || null; state.steps = new Map();
+ place({ ref });
box.textContent = ''; box.className = 'ovr is-thread';
box.textContent = ''; box.className = 'ovr is-thread';
const head = el('div', 'ovr-thead');
const head = el('div', 'ovr-thead');
const back = el('button', 'ovr-ib'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your runs'); back.title = 'Your runs'; back.append(svg('back'));
const back = el('button', 'ovr-ib'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your runs'); back.title = 'Your runs'; back.append(svg('back'));
@@ -472,7 +572,19 @@
@@ -472,7 +572,19 @@
n.append(row);
n.append(row);
}
}
- const view = { list, open, start, destroy() { clearStream(); state.gen++; host.textContent = ''; }, get state() { return { view: state.view, run: state.run, filter: state.filter }; } };
+ /** Words in the list's box, unsent, with the focus there (a page's "Ask your agent"). */
+ function prefill(text) {
+ if (state.view !== 'list' || paneFor(state.filter)) list(paneFor(state.filter) ? 'all' : undefined);
+ const put = (n = 0) => {
+ const ta = box.querySelector('.ovr-compose textarea');
+ if (!ta) { if (n < 40) setTimeout(() => put(n + 1), 50); return; }
+ ta.value = String(text || '').slice(0, 16000);
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
+ try { ta.focus({ preventScroll: true }); ta.setSelectionRange(ta.value.length, ta.value.length); } catch { /* */ }
+ };
+ put();
+ }
+ const view = { list, open, start, prefill, destroy() { clearStream(); dropTop(); dropPane(); state.gen++; host.textContent = ''; }, get state() { return { view: state.view, run: state.run, filter: state.filter }; } };
if (o.open) open(o.open); else list();
if (o.open) open(o.open); else list();
return view;
return view;
}
}
@@ -501,9 +613,10 @@
@@ -501,9 +613,10 @@
.ovr-row-acts .ovr-note { margin: 0; }
.ovr-row-acts .ovr-note { margin: 0; }
@media (max-width: 380px) { .ovr-row-acts { padding-left: 12px; } }
@media (max-width: 380px) { .ovr-row-acts { padding-left: 12px; } }
.ovr-rows:empty { display: none; } .ovr-list-note:empty { display: none; } .ovr-list-note { padding: 0 4px; }
.ovr-rows:empty { display: none; } .ovr-list-note:empty { display: none; } .ovr-list-note { padding: 0 4px; }
-.ovr.is-list > .ovr-compose { margin-top: auto; }
+.ovr.is-list > .ovr-compose, .ovr.is-list > .ovr-starters:not([hidden]) { margin-top: auto; }
+.ovr.is-list > .ovr-starters:not([hidden]) + .ovr-compose { margin-top: 0; }
/* On a wide screen the list, the thread and the box keep a reading column. */
/* On a wide screen the list, the thread and the box keep a reading column. */
-.ovr-head, .ovr-rows, .ovr-list-note, .ovr-compose, .ovr-thead { width: 100%; max-width: 780px; margin-inline: auto; box-sizing: border-box; }
+.ovr-head, .ovr-top, .ovr-rows, .ovr-list-note, .ovr-starters, .ovr-compose, .ovr-thead { width: 100%; max-width: 780px; margin-inline: auto; box-sizing: border-box; }
.ovr-thread { padding-inline: max(4px, calc((100% - 772px) / 2)); }
.ovr-thread { padding-inline: max(4px, calc((100% - 772px) / 2)); }
.ovr-row { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 12px; min-height: 72px; padding: 10px 12px; border-radius: 16px; cursor: pointer; background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.07)); transition: background-color .15s, transform .15s; animation: ovr-rise .3s cubic-bezier(.2,.8,.2,1) both; animation-delay: calc(var(--i, 0) * 18ms); }
.ovr-row { all: unset; box-sizing: border-box; display: flex; align-items: center; gap: 12px; min-height: 72px; padding: 10px 12px; border-radius: 16px; cursor: pointer; background: color-mix(in srgb, var(--text-primary, #e6edf7) 4%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.07)); transition: background-color .15s, transform .15s; animation: ovr-rise .3s cubic-bezier(.2,.8,.2,1) both; animation-delay: calc(var(--i, 0) * 18ms); }
.ovr-row.is-needs { box-shadow: inset 0 0 0 1px color-mix(in srgb, #f59e0b 50%, transparent); background: color-mix(in srgb, #f59e0b 7%, transparent); }
.ovr-row.is-needs { box-shadow: inset 0 0 0 1px color-mix(in srgb, #f59e0b 50%, transparent); background: color-mix(in srgb, #f59e0b 7%, transparent); }
@@ -523,6 +636,7 @@
@@ -523,6 +636,7 @@
.ovr-pill.is-failed { color: #f87171; background: color-mix(in srgb, #ef4444 14%, transparent); }
.ovr-pill.is-failed { color: #f87171; background: color-mix(in srgb, #ef4444 14%, transparent); }
.ovr-face { position: relative; flex: none; display: grid; place-items: center; border-radius: 30%; color: hsl(var(--ovr-h, 215) 92% 84%); background: linear-gradient(150deg, hsl(var(--ovr-h, 215) 72% 58% / .4), hsl(var(--ovr-h, 215) 70% 38% / .22)); box-shadow: inset 0 0 0 1px hsl(var(--ovr-h, 215) 70% 64% / .3); }
.ovr-face { position: relative; flex: none; display: grid; place-items: center; border-radius: 30%; color: hsl(var(--ovr-h, 215) 92% 84%); background: linear-gradient(150deg, hsl(var(--ovr-h, 215) 72% 58% / .4), hsl(var(--ovr-h, 215) 70% 38% / .22)); box-shadow: inset 0 0 0 1px hsl(var(--ovr-h, 215) 70% 64% / .3); }
:root[data-theme="light"] .ovr-face { color: hsl(var(--ovr-h, 215) 70% 30%); }
:root[data-theme="light"] .ovr-face { color: hsl(var(--ovr-h, 215) 70% 30%); }
+@media (prefers-color-scheme: light) { :root:not([data-theme="dark"]) .ovr-face { color: hsl(var(--ovr-h, 215) 70% 30%); } }
.ovr-face.is-m { width: 48px; height: 48px; } .ovr-face.is-m > svg { width: 26px; height: 26px; }
.ovr-face.is-m { width: 48px; height: 48px; } .ovr-face.is-m > svg { width: 26px; height: 26px; }
.ovr-face.is-s { width: 38px; height: 38px; } .ovr-face.is-s > svg { width: 21px; height: 21px; }
.ovr-face.is-s { width: 38px; height: 38px; } .ovr-face.is-s > svg { width: 21px; height: 21px; }
.ovr-face img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
.ovr-face img { width: 100%; height: 100%; border-radius: inherit; object-fit: cover; }
@@ -535,6 +649,16 @@
@@ -535,6 +649,16 @@
.ovr-empty > div { display: grid; gap: 2px; } .ovr-empty b { font-size: 14.5px; } .ovr-empty span { font-size: 13px; color: var(--text-muted, #93a0b4); }
.ovr-empty > div { display: grid; gap: 2px; } .ovr-empty b { font-size: 14.5px; } .ovr-empty span { font-size: 13px; color: var(--text-muted, #93a0b4); }
.ovr-note { margin: 6px 4px; font-size: 13px; color: var(--text-muted, #93a0b4); } .ovr-note.is-err { color: #f87171; }
.ovr-note { margin: 6px 4px; font-size: 13px; color: var(--text-muted, #93a0b4); } .ovr-note.is-err { color: #f87171; }
.ovr-compose { flex: none; padding: 8px 4px 4px; }
.ovr-compose { flex: none; padding: 8px 4px 4px; }
+.ovr-top { flex: none; }
+.ovr-pane { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } .ovr-pane[hidden] { display: none; }
+.ovr.has-pane > .ovr-rows, .ovr.has-pane > .ovr-list-note, .ovr.has-pane > .ovr-compose { display: none; } .ovr-top[hidden], .ovr-starters[hidden] { display: none; } .ovr-top:empty { display: none; }
+.ovr-starters { flex: none; display: flex; gap: 6px; overflow-x: auto; padding: 8px 4px 0; scrollbar-width: none; -webkit-mask-image: linear-gradient(90deg, #000 92%, transparent); mask-image: linear-gradient(90deg, #000 92%, transparent); }
+.ovr-starters::-webkit-scrollbar { display: none; }
+.ovr-starter { all: unset; box-sizing: border-box; flex: none; display: inline-flex; align-items: center; gap: 7px; min-height: 38px; padding: 0 13px 0 10px; border-radius: 999px; cursor: pointer; font-size: 13.5px; white-space: nowrap; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); box-shadow: inset 0 0 0 1px var(--border, rgba(255,255,255,.07)); transition: background-color .15s, color .15s; }
+.ovr-starter svg { width: 16px; height: 16px; color: var(--accent-light, #60a5fa); }
+.ovr-starter:disabled { opacity: .5; cursor: default; } .ovr-starter:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
+@media (hover: hover) and (pointer: fine) { .ovr-starter:hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); } }
+a.ovr-row { color: inherit; text-decoration: none; }
.ovr-plain { display: flex; align-items: flex-end; gap: 8px; padding: 8px; border-radius: 18px; border: 1px solid var(--border, rgba(148,163,184,.2)); background: var(--bg-primary, rgba(0,0,0,.2)); }
.ovr-plain { display: flex; align-items: flex-end; gap: 8px; padding: 8px; border-radius: 18px; border: 1px solid var(--border, rgba(148,163,184,.2)); background: var(--bg-primary, rgba(0,0,0,.2)); }
.ovr-plain textarea { flex: 1; min-height: 40px; max-height: 160px; resize: none; border: 0; outline: 0; background: transparent; color: inherit; font: inherit; font-size: 15px; }
.ovr-plain textarea { flex: 1; min-height: 40px; max-height: 160px; resize: none; border: 0; outline: 0; background: transparent; color: inherit; font: inherit; font-size: 15px; }
.ovr-send { all: unset; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; color: #fff; background: var(--accent-strong, var(--accent, #2563eb)); }
.ovr-send { all: unset; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; color: #fff; background: var(--accent-strong, var(--accent, #2563eb)); }
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
// Motion off (html[data-ov-motion=off], theme-loader.js) is the only setting that skips an effect; Calm plays it.
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
var canCopy = !!(root.navigator && navigator.clipboard && navigator.clipboard.writeText);
- const THREAD_REV = { 'ov-composer.js': '2d3782ec0b52', 'ov-combo.js': '3e426e56fc75' };
+ const THREAD_REV = { 'ov-composer.js': '293808b32ad7', 'ov-combo.js': '3e426e56fc75' };
var selfSrc = (document.currentScript && document.currentScript.src) || '';
var selfSrc = (document.currentScript && document.currentScript.src) || '';
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
// 4.202.0: the one dropdown for a field (ov-combo.js: a repository, a person), wherever a page asks for it.
function loadCombo() {
function loadCombo() {
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.224.0",
+ "version": "4.225.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.224.0",
+ "version": "4.225.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.224.0",
+ "version": "4.225.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": {
@@ -100,7 +100,7 @@
@@ -100,7 +100,7 @@
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.
// 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';
+ let door = null, doorEl = null, doorHost = null, doorMount = null, runView = null, runFilter = 'all';
let actsEl = null, actsObs = null, actsTick = 0;
let actsEl = null, actsObs = null, actsTick = 0;
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; } };
@@ -336,7 +336,7 @@ html[data-ov-motion=off] .ovs-grid.is-mini .ovs-ic { transition: none; }
@@ -336,7 +336,7 @@ html[data-ov-motion=off] .ovs-grid.is-mini .ovs-ic { transition: none; }
.ovs-group-ic svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }
.ovs-group-ic svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2.1; stroke-linecap: round; stroke-linejoin: round; }
/* Names in the brand face (openvibe.design rule: a place's or an app's name in OV Brand 800; words you read in the UI
/* Names in the brand face (openvibe.design rule: a place's or an app's name in OV Brand 800; words you read in the UI
face): the site list, the tabs, Vibe's switch. */
face): the site list, the tabs, Vibe's switch. */
-.ovs-grid.is-mini .ovs-tile b, .ovs-door-sw button, .ovs-starttabs .ovs-tab { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-weight: 800; letter-spacing: .005em; }
+.ovs-grid.is-mini .ovs-tile b, .ovs-starttabs .ovs-tab { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-weight: 800; letter-spacing: .005em; }
.ovs-grid.is-mini .ovs-tile b { font-size: 13px; color: color-mix(in srgb, var(--text-primary, #e6edf7) 92%, transparent); }
.ovs-grid.is-mini .ovs-tile b { font-size: 13px; color: color-mix(in srgb, var(--text-primary, #e6edf7) 92%, transparent); }
.ovs-group[data-sec="all"] { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13.5px; font-weight: 800; letter-spacing: .01em; text-transform: none; color: var(--text-primary, #e6edf7); }
.ovs-group[data-sec="all"] { font-family: 'OV Brand', Nunito, Inter, -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13.5px; font-weight: 800; letter-spacing: .01em; text-transform: none; color: var(--text-primary, #e6edf7); }
/* A slow light drifts behind the menu (Calm slows it, only Off stops it; openvibe.design: calm reduces, only off removes). */
/* A slow light drifts behind the menu (Calm slows it, only Off stops it; openvibe.design: calm reduces, only off removes). */
@@ -383,37 +383,10 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
@@ -383,37 +383,10 @@ html[data-ov-motion=off] .ovs-panel::before { animation: none; }
.ovs-act:hover, .ovs-act:focus-visible { background: var(--hover-bg, rgba(127,127,127,.14)); color: var(--text-primary, #ececf1); }
.ovs-act:hover, .ovs-act:focus-visible { background: var(--hover-bg, rgba(127,127,127,.14)); color: var(--text-primary, #ececf1); }
.ovs-act.is-new { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
.ovs-act.is-new { background: color-mix(in srgb, var(--accent, #3b82f6) 18%, transparent); color: var(--accent-light, var(--accent, #60a5fa)); }
.ovs-door-host > .ovdk { flex: 1 1 auto; min-height: 0; }
.ovs-door-host > .ovdk { flex: 1 1 auto; min-height: 0; }
-.ovs-door-sw { flex: none; display: grid; grid-template-columns: repeat(3, minmax(0, 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-top { flex: none; display: flex; align-items: center; gap: 6px; margin: 8px 12px 4px; }
-.ovs-door-top:not(:has(> :not([hidden]))) { display: none; }
-.ovs-door-top > .ovs-door-sw { flex: 1 1 auto; min-width: 0; margin: 0; }
-.ovs-door-top > .ovs-ai-timer { flex: none; width: 50px; min-height: 50px; margin: 0; padding: 0; justify-content: center; border-radius: 14px; }
-.ovs-door-top > .ovs-ai-timer span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
-.ovs-door-top > .ovs-ai-timer svg { width: 20px; height: 20px; }
+/* 4.225.0: Vibe's runs fill the place under the menu's head (ov-run.js keeps its own filters, thread and box). */
+.ovs-door-host > .ovr { flex: 1 1 auto; min-height: 0; padding: 8px 10px 10px; box-sizing: border-box; }
+.ovs-door-wait { margin: 18px 16px; font-size: 14px; color: var(--text-muted, #93a0b4); }
@media (min-width: 641px) { .ovs.ovs-in-door .ovs-panel { height: min(820px, 86vh); max-height: 86vh; } .ovs.ovs-in-door .ovs-foot { display: none; } }
@media (min-width: 641px) { .ovs.ovs-in-door .ovs-panel { height: min(820px, 86vh); max-height: 86vh; } .ovs.ovs-in-door .ovs-foot { 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; }
-/* 4.223.0: one segmented control (You's tabs have the same): the ink slides to the chosen part, names at 15 px, glyphs at
- 20 px, the chosen glyph a touch larger in the accent. */
-.ovs-door-sw { position: relative; isolation: isolate; gap: 4px; padding: 4px; border-radius: 16px; }
-.ovs-door-sw button { min-height: 46px; gap: 8px; font-size: 15px; font-weight: 700; border-radius: 12px; transition: color .18s, background-color .18s; -webkit-tap-highlight-color: transparent; }
-.ovs-door-sw button[aria-selected="true"] { background: transparent; box-shadow: none; }
-.ovs-door-sw .ovs-tab-ic, .ovs-door-sw .ovs-tab-ic > svg { width: 20px; height: 20px; }
-.ovs-door-sw .ovs-tab-ic { transition: transform .25s cubic-bezier(.3, 1.4, .4, 1); }
-.ovs-door-sw button[aria-selected="true"] .ovs-tab-ic { transform: scale(1.08); }
-.ovs-door-sw button:active .ovs-tab-ic { transform: scale(.9); }
-@media (hover: hover) and (pointer: fine) { .ovs-door-sw button:not([aria-selected="true"]):hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); } }
-.ovs-door-ink { position: absolute; left: 0; top: 4px; bottom: 4px; width: 0; z-index: -1; border-radius: 12px; opacity: 0; pointer-events: none;
- 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) 35%, transparent), 0 6px 18px -8px color-mix(in srgb, var(--accent, #3b82f6) 60%, transparent);
- transition: transform .34s cubic-bezier(.3, 1.3, .4, 1), width .34s cubic-bezier(.3, 1.3, .4, 1), opacity .2s; }
-.ovs-door-ink.is-on { opacity: 1; }
-:root[data-theme="light"] .ovs-door-ink { background: #fff; }
-html[data-ov-motion=reduced] .ovs-door-ink { transition: opacity .25s; }
-html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .ovs-tab-ic { transition: none; }
-@media (max-width: 380px) { .ovs-door-sw button { font-size: 14px; gap: 6px; } }
.ovs-in-door .ovs-foot { display: none; }
.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; }
@@ -476,10 +449,6 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o
@@ -476,10 +449,6 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o
.ovs-nhead-n[hidden], .ovs-nhead-more[hidden] { display: none; }
.ovs-nhead-n[hidden], .ovs-nhead-more[hidden] { display: none; }
.ovs-nhead-more { margin-left: auto; min-height: 32px; padding: 0 10px; border: 0; border-radius: 9px; background: none; color: var(--accent-light, var(--accent, #60a5fa)); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; }
.ovs-nhead-more { margin-left: auto; min-height: 32px; padding: 0 10px; border: 0; border-radius: 9px; background: none; color: var(--accent-light, var(--accent, #60a5fa)); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; }
.ovs-nhead-more:hover, .ovs-nhead-more:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
.ovs-nhead-more:hover, .ovs-nhead-more:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 14%, transparent); }
-.ovs-ai-timer { flex: none; display: flex; align-items: center; gap: 10px; margin: 6px 12px 10px; min-height: 44px; padding: 0 14px; border: 0; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); color: var(--text-secondary, #a8b3c4); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }
-.ovs-ai-timer[hidden] { display: none; }
-.ovs-ai-timer:hover, .ovs-ai-timer:focus-visible { background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); color: var(--text-primary, #e6edf7); }
-.ovs-ai-timer svg { flex: none; color: var(--accent-light, var(--accent, #60a5fa)); }
.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }
.ovs-fold svg { width: 16px; height: 16px; flex: none; transition: transform .2s; }
.ovs-feeds { display: grid; gap: 4px; padding: 4px 6px 8px; }
.ovs-feeds { display: grid; gap: 4px; padding: 4px 6px 8px; }
.ovs-feed { display: grid; grid-template-columns: minmax(0, 1fr) 40px 40px; align-items: center; gap: 4px; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
.ovs-feed { display: grid; grid-template-columns: minmax(0, 1fr) 40px 40px; align-items: center; gap: 4px; padding: 4px 4px 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--text-primary, #e8ecf3) 4%, transparent); }
@@ -608,36 +577,10 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o
@@ -608,36 +577,10 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o
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;
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', 'Vibe');
- // Vibe holds three things (asks 335 and 350, the owner: "chat, codes, and agents all in the same place"): your
- // chats with it, your coding tasks, and your agents.
- doorSw.innerHTML = [['vibe', 'Chat', 'talk'], ['code', 'Code', 'code'], ['agent', 'Agents', 'agent']].map(([id, label, ic]) => `<button type="button" role="tab" data-part="${id}" aria-selected="false"><span class="ovs-tab-ic">${BRIDGE_SVG[ic]}</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'); } });
- // 4.223.0: the switch's ink slides to the chosen part (the owner, 11-12: "animations, effects to the tabs"); it
- // follows aria-selected wherever that is set, and the size of the row.
- {
- const ink = document.createElement('i'); ink.className = 'ovs-door-ink'; ink.setAttribute('aria-hidden', 'true'); doorSw.prepend(ink);
- const place = () => {
- const on = doorSw.querySelector('[data-part][aria-selected="true"]');
- if (!on || !on.offsetWidth) { ink.classList.remove('is-on'); return; }
- ink.style.transform = `translateX(${on.offsetLeft}px)`; ink.style.width = `${on.offsetWidth}px`; ink.classList.add('is-on');
- };
- if (typeof MutationObserver === 'function') new MutationObserver(() => requestAnimationFrame(place)).observe(doorSw, { subtree: true, attributes: true, attributeFilter: ['aria-selected', 'hidden'] });
- if (typeof ResizeObserver === 'function') new ResizeObserver(() => { ink.style.transition = 'none'; place(); void ink.offsetWidth; ink.style.transition = ''; }).observe(doorSw);
- doorSw.addEventListener('keydown', (e) => {
- if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
- const all = [...doorSw.querySelectorAll('[data-part]')]; const k = all.indexOf(document.activeElement); if (k < 0) return;
- const next = all[(k + (e.key === 'ArrowRight' ? 1 : all.length - 1)) % all.length];
- e.preventDefault(); next.focus(); next.click();
- });
- }
+ // 4.225.0: Vibe is one place (the owner, 11-12, crossing out the Chat · Code · Agents switch: "one new unified
+ // agent … a shared UI"): every chat, task and coding run in one list with its own filters, drawn by ov-run.js.
doorHost = document.createElement('div'); doorHost.className = 'ovs-door-host';
doorHost = document.createElement('div'); doorHost.className = 'ovs-door-host';
- const timerButton = document.createElement('button'); timerButton.type = 'button'; timerButton.className = 'ovs-ai-timer'; timerButton.setAttribute('aria-label', 'Start a timer'); timerButton.setAttribute('data-ov-tip', 'Start a timer'); timerButton.innerHTML = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="13" r="7.5"/><path d="M12 9.5V13l2.5 1.5M9.5 3h5"/></svg><span>Start a timer</span>'; timerButton.hidden = true;
- timerButton.addEventListener('click', () => { try { if (root.OpenVibeDock && root.OpenVibeDock.timer) root.OpenVibeDock.timer(timerButton); else if (ctx.dockMore) ctx.dockMore('timer'); } catch { /* not loaded */ } });
- // Vibe's switch and its timer share one row (4.220.0): the timer is an icon at its end, never a row under the box.
- // 4.223.0: the timer is in every box's + (the owner: "the timer should be in the plus thing"), not in this row.
- const doorTop = document.createElement('div'); doorTop.className = 'ovs-door-top'; doorTop.append(doorSw); void timerButton;
- doorEl.append(doorTop, doorHost);
+ doorEl.append(doorHost);
listEl.after(doorEl);
listEl.after(doorEl);
doorHost.addEventListener('openvibe:open-vibe', (e) => {
doorHost.addEventListener('openvibe:open-vibe', (e) => {
if (door !== 'chats' || !canDoor()) return;
if (door !== 'chats' || !canDoor()) return;
@@ -733,7 +676,12 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o
@@ -733,7 +676,12 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o
e.preventDefault();
e.preventDefault();
const q = input.value.trim();
const q = input.value.trim();
if (tab === 'feed' && homeLib() && !door) { render(q); return; }
if (tab === 'feed' && homeLib() && !door) { render(q); return; }
- if (door === 'vibe' && q) { try { if (ctx.ask) ctx.ask(q); } catch { /* Dock is optional */ } return; }
+ // Vibe: on Chats (the Dock's Vibe) the words go to its box as before; anywhere else they start a run.
+ if (door === 'vibe' && q) {
+ if (runFilter === 'chat') { try { if (ctx.ask) ctx.ask(q); } catch { /* the Dock is optional */ } }
+ else if (runView) runView.start(q).then(() => { input.value = ''; paintGhost(); }, () => {});
+ return;
+ }
if (door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }
if (door === 'chats') { try { if (root.OpenVibeDock && root.OpenVibeDock.filterChats) root.OpenVibeDock.filterChats(q); } catch { /* Dock is optional */ } return; }
const it = items[active >= 0 ? active : 0];
const it = items[active >= 0 ? active : 0];
// Enter shows every result, unless the person picked a row or typed a place's exact name (live, tools).
// Enter shows every result, unless the person picked a row or typed a place's exact name (live, tools).
@@ -1287,38 +1235,98 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o
@@ -1287,38 +1235,98 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o
}
}
mirrorActs();
mirrorActs();
}
}
- /** A place by the Dock's name ('chats', or Vibe's parts 'vibe' | 'code' | 'agent'), in the open menu. */
+ /** A place by the Dock's name ('chats', or Vibe's lists 'vibe' | 'code' | 'agent' | 'tasks'), in the open menu. */
function showDoor(id) {
function showDoor(id) {
if (!el || !el.classList.contains('is-open') || !canDoor() || !id) return false;
if (!el || !el.classList.contains('is-open') || !canDoor() || !id) return false;
- const want = id === 'agents' ? 'agent' : id;
- if (['vibe', 'code', 'agent'].includes(want)) aiPart = want;
- openDoor(want === 'chats' ? 'chats' : 'vibe');
+ // The Dock's places by name: its Vibe is Chats, its Agents are Tasks.
+ const want = { all: 'all', vibe: 'chat', chat: 'chat', code: 'code', agent: 'task', agents: 'task', tasks: 'task' }[id];
+ if (want) runFilter = want;
+ openDoor(id === 'chats' ? 'chats' : 'vibe');
return door != null;
return door != null;
}
}
- /** Show a place in the menu: Messages, or AI (Vibe, Code or your agents, by the switch). */
+ /** Vibe's runs (ov-run.js, loaded beside the navbar by its hash the first time Vibe opens). */
+ let runLoading = null;
+ function runLib() {
+ if (root.OpenVibeRun) return Promise.resolve(root.OpenVibeRun);
+ if (runLoading || typeof ctx.sibling !== 'function') return runLoading || Promise.reject(new Error('no sibling'));
+ runLoading = new Promise((resolve, reject) => {
+ const sc = document.createElement('script'); sc.src = ctx.sibling('ov-run.js'); sc.async = true;
+ sc.onload = () => (root.OpenVibeRun ? resolve(root.OpenVibeRun) : reject(new Error('ov-run.js')));
+ sc.onerror = () => { runLoading = null; sc.remove(); reject(new Error('ov-run.js')); };
+ document.head.appendChild(sc);
+ });
+ return runLoading;
+ }
+ // What Vibe can do, to tap (they were the Dock's Vibe starters).
+ const VIBE_STARTERS = [
+ ['bell', 'Remind me to stretch every 2 hours'],
+ ['calendar', 'What is on today?'],
+ ['live', 'Who is live right now?'],
+ ['clock', 'Start a 25-minute focus timer'],
+ ['phone', 'Remind me tomorrow at 9 to call Sam'],
+ ['search', 'What is new on OpenVibe this week?'],
+ ];
+ let pendingAsk = null;
+ /** One of the Dock's panels in a slot of the run view (the Dock lends one at a time). */
+ function lend(slot, how) {
+ try { return root.OpenVibeDock && root.OpenVibeDock.mountInto ? root.OpenVibeDock.mountInto(slot, how) : null; } catch { return null; }
+ }
+ function mountRuns() {
+ const me = ctx.token && ctx.user && !ctx.user.is_anon ? ctx.user : null;
+ // Runs and the Action Center are the person's Network places, like the Dock's (navbar.js passes its base).
+ const net = ctx.runsBase || ctx.apiBase || 'https://openvibe.network';
+ const D = () => root.OpenVibeDock;
+ const opts = {
+ base: `${net}/api/frame`, token: () => ctx.token, me, sibling: ctx.sibling, filter: runFilter,
+ // All: every run, with the Action Center's work from every product, the Dock's Now, your Actor and the live
+ // agent view above it, and Vibe's starters. Chats, Tasks and Code are still the Dock's own panels (its Vibe,
+ // Agents and Code: their history, skills, voice, undo, repos), lent whole until the one view does all they do.
+ actions: { base: net }, starters: VIBE_STARTERS,
+ top: (slot) => lend(slot, { tab: 'agent', part: 'top' }),
+ panes: { chat: (slot) => lend(slot, { tab: 'vibe' }), task: (slot) => lend(slot, { tab: 'agent' }), code: (slot) => lend(slot, { tab: 'code' }) },
+ onPlace: (p) => { if (p.filter) runFilter = p.filter; },
+ };
+ const go = (R) => {
+ if (door !== 'vibe' || runView) return;
+ doorHost.textContent = ''; runView = R.mount(doorHost, opts);
+ if (pendingAsk != null) { runView.prefill(pendingAsk); pendingAsk = null; }
+ };
+ if (root.OpenVibeRun) { go(root.OpenVibeRun); return; }
+ doorHost.textContent = '';
+ const wait = document.createElement('p'); wait.className = 'ovs-door-wait'; wait.textContent = 'Opening Vibe…'; doorHost.append(wait);
+ runLib().then(go, () => { if (door === 'vibe') wait.textContent = 'Vibe did not load. Check your connection and open it again.'; });
+ }
+ /** Show a place in the menu: Messages (the Dock's), or Vibe (your runs). */
function openDoor(id) {
function openDoor(id) {
if (!doorEl || !canDoor()) return;
if (!doorEl || !canDoor()) return;
+ if (door && door !== id) closeDoorBody();
door = id;
door = id;
- const want = id === 'chats' ? 'chats' : aiPart;
- doorSw.hidden = id !== 'vibe';
- const timerButton = doorEl.querySelector('.ovs-ai-timer'); if (timerButton) timerButton.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');
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; }
+ el.classList.toggle('ovs-in-vibe', id === 'vibe');
+ if (id === 'vibe') {
+ if (runView) runView.list(runFilter); else mountRuns();
+ } else {
+ try { if (!doorMount) doorMount = root.OpenVibeDock.mountInto(doorHost, { tab: 'chats' }); else doorMount.show('chats'); }
+ catch { leaveDoor(); try { ctx.dock(id); } catch { /* the Dock is optional */ } return; }
+ }
watchActs(true);
watchActs(true);
input.placeholder = id === 'chats' ? 'Search your chats' : `Search ${here()}, or ask Vibe`;
input.placeholder = id === 'chats' ? 'Search your chats' : `Search ${here()}, or ask Vibe`;
paintTabs();
paintTabs();
}
}
+ function closeDoorBody() {
+ if (doorMount) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }
+ if (runView) { try { runView.destroy(); } catch { /* */ } runView = null; }
+ if (doorHost) doorHost.textContent = '';
+ }
/** Back to the menu's own list (a tab, typing, or closing). The Dock's panels go home with their state. */
/** Back to the menu's own list (a tab, typing, or closing). The Dock's panels go home with their state. */
function leaveDoor() {
function leaveDoor() {
if (!door) return;
if (!door) return;
door = null;
door = null;
- if (doorMount) { try { doorMount.destroy(); } catch { /* */ } doorMount = null; }
+ closeDoorBody();
watchActs(false);
watchActs(false);
if (doorEl) doorEl.hidden = true;
if (doorEl) doorEl.hidden = true;
if (listEl) listEl.hidden = false;
if (listEl) listEl.hidden = false;
- if (el) el.classList.remove('ovs-in-door');
+ if (el) el.classList.remove('ovs-in-door', 'ovs-in-vibe');
if (input) input.placeholder = `Search ${here()}, or ask Vibe`;
if (input) input.placeholder = `Search ${here()}, or ask Vibe`;
}
}
@@ -2062,7 +2070,14 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o
@@ -2062,7 +2070,14 @@ html[data-ov-motion=off] .ovs-door-ink, html[data-ov-motion=off] .ovs-door-sw .o
document.addEventListener('ov:boost:load', () => { if (el && el.classList.contains('is-open') && !input.value) render(''); });
document.addEventListener('ov:boost:load', () => { if (el && el.classList.contains('is-open') && !input.value) render(''); });
- const api = { open, close, door: (id) => showDoor(id), suggest, siteOf, registerCard, _state: () => ({ tab, door, from: ctx.from }) };
+ /** Ask your agent: Vibe, with the words in its box, unsent (the Dock's ask() comes here when the bar wires it). */
+ function prefill(text) {
+ const t = String(text || '').slice(0, 4000);
+ if (door === 'vibe' && runView) { runView.prefill(t); return true; }
+ pendingAsk = t; runFilter = 'all';
+ return showDoor('vibe');
+ }
+ const api = { open, close, door: (id) => showDoor(id), prefill, suggest, siteOf, registerCard, _state: () => ({ tab, door, from: ctx.from }) };
root.OpenVibeSearch = api;
root.OpenVibeSearch = api;
if (typeof module === 'object' && module.exports) module.exports = api;
if (typeof module === 'object' && module.exports) module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);
})(typeof globalThis !== 'undefined' ? globalThis : this);
@@ -223,6 +223,11 @@ html{overflow-x:clip}
@@ -223,6 +223,11 @@ html{overflow-x:clip}
.sc-stat dd{font-size:19px;gap:5px;letter-spacing:-.01em}.sc-stat-ic svg,.sc-stat-ic .ov-icon{width:15px;height:15px}
.sc-stat dd{font-size:19px;gap:5px;letter-spacing:-.01em}.sc-stat-ic svg,.sc-stat-ic .ov-icon{width:15px;height:15px}
.sc-stat dt{font-size:11.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.sc-stat dt{font-size:11.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
}
}
+.ov-appicon{--ova-h:215;--ova-size:56px;position:relative;display:inline-block;flex:none;width:var(--ova-size);height:var(--ova-size);border-radius:30%;overflow:hidden;vertical-align:middle;background:linear-gradient(150deg,hsl(var(--ova-h) 72% 58% / .36),hsl(var(--ova-h) 70% 38% / .2));box-shadow:inset 0 0 0 1px hsl(var(--ova-h) 70% 64% / .3)}
+.ov-appicon>.ov-icon{position:absolute;inset:0;width:100%;height:100%;display:block;--ovi-glyph:hsl(var(--ova-h) 92% 84%);color:hsl(var(--ova-h) 92% 84%)}
+.ov-appicon>.ov-icon svg{position:absolute;left:-13%;top:-13%;width:126%;height:126%;margin:0;transform:none!important}
+.ov-appicon .ovi-ring,.ov-appicon .ovi-bg,.ov-appicon .ovi-comet,.ov-appicon .ovi-prog{display:none}
+:root[data-theme="light"] .ov-appicon>.ov-icon{--ovi-glyph:hsl(var(--ova-h) 70% 30%);color:hsl(var(--ova-h) 70% 30%)}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon svg{width:100%;height:100%;overflow:visible;display:block;transition:transform .25s cubic-bezier(.2,1.4,.3,1)}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
@@ -12,8 +12,18 @@ const h = require('../browser-harness');
@@ -12,8 +12,18 @@ const h = require('../browser-harness');
assert.doesNotThrow(() => require('../ov-composer'), 'ov-composer.js loads (browser-only: Node takes the early return)');
assert.doesNotThrow(() => require('../ov-composer'), 'ov-composer.js loads (browser-only: Node takes the early return)');
if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer-v2-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('composer-v2-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
+const searched = [];
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const u = req.url.split('?')[0];
const u = req.url.split('?')[0];
+ // 4.225.0: the Frame's code search, as Network answers it (server/frame/code-search.js).
+ if (u === '/api/frame/code/search') {
+ searched.push(new URL(req.url, 'http://x').searchParams.get('q'));
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify({ items: [
+ { full_name: 'ada/goal-bar', url: 'https://github.com/ada/goal-bar', description: 'A goal bar <b>for</b> streams', language: 'JavaScript', stars: 1250, license: 'MIT', owner_avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' },
+ { full_name: 'bo/tracker', url: 'https://github.com/bo/tracker', owner_avatar_url: 'https://evil.example/p.png', stars: 3 },
+ ] }));
+ }
if (u === '/ov-composer.js' || u === '/ov-window.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }
if (u === '/ov-composer.js' || u === '/ov-window.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, u.slice(1)))); }
res.writeHead(200, { 'content-type': 'text/html' });
res.writeHead(200, { 'content-type': 'text/html' });
res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -135,6 +145,24 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
@@ -135,6 +145,24 @@ window.reply = C.mount(document.getElementById('d'), { preset: 'comment', id: 'r
assert.deepEqual(await page.evaluate(`window.chatSent[0]`), { text: 'half a draft', reply: { id: 7 } });
assert.deepEqual(await page.evaluate(`window.chatSent[0]`), { text: 'half a draft', reply: { id: 7 } });
assert.equal(await page.evaluate(`!!document.querySelector('#c1 .ovc-bar')`), false, 'the reply bar goes with the send');
assert.equal(await page.evaluate(`!!document.querySelector('#c1 .ovc-bar')`), false, 'the reply bar goes with the send');
+ // 4.225.0: the + has GitHub. A repository found through the Frame's code search goes into the box as its address.
+ await page.evaluate(`document.getElementById('g1').setAttribute('data-ovc-frame', location.origin), true`);
+ await page.evaluate(`document.querySelector('#g1 .ovc-more').click(), true`);
+ assert.ok((await page.evaluate(`[...document.querySelectorAll('#g1 .ovc-tray-t')].map((t) => t.textContent)`)).includes('GitHub'), 'GitHub is in the +');
+ await page.evaluate(`[...document.querySelectorAll('#g1 .ovc-tray-i')].find((b) => b.textContent === 'GitHub').click(), true`);
+ assert.equal(await page.evaluate(`document.activeElement === document.querySelector('#g1 .ovc-repo-q')`), true, 'the search field has the focus');
+ await page.evaluate(`(() => { const f = document.querySelector('#g1 .ovc-repo-q'); f.value = 'goal bar'; f.dispatchEvent(new Event('input', { bubbles: true })); })()`);
+ let rows = 0;
+ for (let i = 0; i < 40 && rows < 2; i++) { await wait(50); rows = await page.evaluate(`document.querySelectorAll('#g1 .ovc-repo-row').length`); }
+ assert.equal(rows, 2, 'the repositories as you type');
+ assert.deepEqual(searched, ['goal bar'], 'asked once, a quarter second after the last key');
+ const shown = await page.evaluate(`[...document.querySelectorAll('#g1 .ovc-repo-row')].map((r) => ({ name: r.querySelector('b').textContent, sub: r.querySelector('small').textContent, meta: r.querySelector('i').textContent, img: r.querySelector('img') ? r.querySelector('img').getAttribute('src') : null, bold: !!r.querySelector('small b') }))`);
+ assert.deepEqual(shown[0], { name: 'ada/goal-bar', sub: 'A goal bar <b>for</b> streams', meta: 'JavaScript · ★ 1.3k · MIT', img: 'https://avatars.githubusercontent.com/u/1?v=4', bold: false }, 'everything as text');
+ assert.equal(shown[1].img, null, 'a picture from anywhere but GitHub\'s avatar host is not drawn');
+ await page.evaluate(`document.querySelector('#g1 .ovc-repo-q').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })), true`);
+ assert.match(await page.evaluate(`document.querySelector('#g1 textarea').value`), /https:\/\/github\.com\/ada\/goal-bar $/, 'Enter puts the first one\'s address in the box');
+ assert.equal(await page.evaluate(`document.querySelector('#g1 .ovc-repo').hidden`), true, 'and the panel goes');
+
// Nothing is wider than the phone.
// Nothing is wider than the phone.
assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390);
assert.equal(await page.evaluate(`document.documentElement.scrollWidth`), 390);
await page.close();
await page.close();
@@ -126,6 +126,11 @@ const server = http.createServer((req, res) => {
@@ -126,6 +126,11 @@ const server = http.createServer((req, res) => {
return json(200, { state: scenario.state || defaultState(), counts: scenario.counts || { running: 0, waiting: 0, unread: 0 }, sources_partial: false });
return json(200, { state: scenario.state || defaultState(), counts: scenario.counts || { running: 0, waiting: 0, unread: 0 }, sources_partial: false });
}
}
if (u.pathname === '/api/v1/me/actions') return json(200, scenario.actions || ACTIONS);
if (u.pathname === '/api/v1/me/actions') return json(200, scenario.actions || ACTIONS);
+ // 4.225.0: the menu's Vibe lists runs from the Frame (Network /api/frame/runs, agents.run@1).
+ if (u.pathname === '/api/frame/runs' && req.method === 'GET') {
+ assert.strictEqual(req.headers.authorization, 'Bearer tok_user', 'the runs are read with the person\'s own token');
+ return json(200, { runs: [{ id: 'v-1', ref: 'chat:v-1', kind: 'chat', title: 'Patch notes for 4.162', status: 'waiting', waiting: { for: 'message' }, agent: { id: 'vibe', name: 'Vibe', glyph: 'spark' }, preview: { text: 'I wrote the first draft.' }, updated_at: iso(4 * 60e3) }], next: null, partial: [] });
+ }
// Actor's skill catalogue, as the Dock's overview reads it: through Network's actions.
// Actor's skill catalogue, as the Dock's overview reads it: through Network's actions.
if (u.pathname === '/api/v1/me/actions/skills') return json(200, { listings: (scenario.skills || [1, 2, 3, 4, 5, 6]).map((n) => ({
if (u.pathname === '/api/v1/me/actions/skills') return json(200, { listings: (scenario.skills || [1, 2, 3, 4, 5, 6]).map((n) => ({
id: `skl_${n}`, skill: { slug: `skill-${n}`, name: ['Make an image', 'Make a wiki article', 'Write a blog post', 'Improve an article', 'Make a meme', 'Summarise a thread'][n - 1] },
id: `skl_${n}`, skill: { slug: `skill-${n}`, name: ['Make an image', 'Make a wiki article', 'Write a blog post', 'Improve an article', 'Make a meme', 'Summarise a thread'][n - 1] },
@@ -328,6 +333,44 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
@@ -328,6 +333,44 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
await p.close();
await p.close();
}
}
+ // ── 4.225.0: the menu's Vibe is one list (ov-run.js) under All · Chats · Tasks · Code: runs from the Frame and the
+ // Action Center's work together, the Dock's Now, Actor and live view above; Chats, Tasks and Code are the Dock's
+ // own panels, lent whole. No Chat · Code · Agents switch. ──
+ {
+ scenario = { counts: { running: 0, waiting: 0, unread: 0 } };
+ agentView = null;
+ const p = await fresh(390, 780);
+ await p.goto(`${base}/page`, { maxMs: 5000 });
+ await until(p, "!!window.OpenVibeDock", 'the panels');
+ await p.evaluate(MENU_BUTTON);
+ await until(p, "!!document.querySelector('.ovs.is-open .ovs-starttabs [data-dock=\"vibe\"]')", 'the Vibe place');
+ await p.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`);
+ await until(p, "document.querySelectorAll('.ovs-door-host .ovr-rows .ovr-row:not(.is-ghost)').length === 4", 'the run and the Action Center\'s three');
+ const all = await p.evaluate(`({ filters: [...document.querySelectorAll('.ovs-door-host .ovr-filter')].map((b) => b.textContent + (b.getAttribute('aria-selected') === 'true' ? '*' : '')),
+ titles: [...document.querySelectorAll('.ovs-door-host .ovr-row-top b')].map((b) => b.textContent), sw: !!document.querySelector('.ovs-door-sw, [data-part]'),
+ top: !!document.querySelector('.ovs-door-host .ovr-top .ovdk.ovdk-part-top'), runsList: !!document.querySelector('.ovs-door-host .ovr-top .ovdk-scroll, .ovs-door-host .ovr-top .ovac-item'),
+ starters: document.querySelectorAll('.ovs-door-host .ovr-starter').length, wide: document.documentElement.scrollWidth > innerWidth })`);
+ assert.deepStrictEqual(all.filters, ['All*', 'Chats', 'Tasks', 'Code'], 'All · Chats · Tasks · Code, on All');
+ assert.deepStrictEqual(all.titles, ['Send the weekly digest?', 'Fix the flaky upload test', 'Compare three VPS offers', 'Patch notes for 4.162'], 'what needs you first, then newest (the work a minute ago, the chat four): a run and the Action Center\'s work in one list');
+ assert.deepStrictEqual([all.sw, all.top, all.runsList, all.starters, all.wide], [false, true, false, 6, false], 'no switch; the Dock\'s top above (not its list); the six starters; nothing off the screen');
+ if (process.env.OV_CAPTURE_VIBE) { await wait(500); const r = await p.send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(process.env.OV_CAPTURE_VIBE, 'vibe-all-390.png'), Buffer.from(r.data, 'base64')); }
+ // Tasks: the Dock's Agents panel, whole; the Network hears the place.
+ await p.evaluate(`document.querySelector('.ovs-door-host .ovr-filter[data-filter="task"]').click()`);
+ const place = await until(p, `(() => { const c = ${PLACE}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'Tasks: the Agents panel');
+ assert.strictEqual(place.items, 3, 'its own list, as before');
+ assert.strictEqual(await p.evaluate("!!document.querySelector('.ovs-door-host .ovr-pane .ovdk') && document.querySelector('.ovs-door-host .ovr-top').hidden"), true, 'in the pane, and the top handed back');
+ if (process.env.OV_CAPTURE_VIBE) { await wait(500); const r = await p.send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(process.env.OV_CAPTURE_VIBE, 'vibe-tasks-390.png'), Buffer.from(r.data, 'base64')); }
+ await eventually(async () => assert.ok(puts.some((b) => b.tab === 'agent'), 'the Network heard Tasks'));
+ // Approve in place from All, through the Action Center.
+ await p.evaluate(`document.querySelector('.ovs-door-host .ovr-filter[data-filter="all"]').click()`);
+ await until(p, "!!document.querySelector('.ovs-door-host .ovr-rows li.has-acts .ovr-btn.is-primary')", 'All again, with its answers');
+ decided.length = 0;
+ await p.evaluate("document.querySelector('.ovs-door-host .ovr-rows li.has-acts .ovr-btn.is-primary').click()");
+ await until(p, "(document.querySelector('.ovs-door-host .ovr-rows li.has-acts .ovr-row-acts') || {}).textContent === 'Approved'", 'approved in place from All');
+ assert.deepStrictEqual(decided, ['actor:apr_1:approve'], 'through the Action Center');
+ await p.close();
+ }
+
// ── Chats (K-D2): the list, a thread in place, Enter sends, read, remembered; a phone keeps the sheet open. ──
// ── Chats (K-D2): the list, a thread in place, Enter sends, read, remembered; a phone keeps the sheet open. ──
{
{
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
@@ -181,6 +181,32 @@ const server = http.createServer((req, res) => {
@@ -181,6 +181,32 @@ const server = http.createServer((req, res) => {
assert.deepStrictEqual(news.rows[0], ['Live', 'Updated 1 h ago · Big update', 'Go live from a phone: the Dock no longer covers the button'], 'each says what changed, when, and a big one says so');
assert.deepStrictEqual(news.rows[0], ['Live', 'Updated 1 h ago · Big update', 'Go live from a phone: the Dock no longer covers the button'], 'each says what changed, when, and a big one says so');
assert.deepStrictEqual(news.rows[1], ['Video', 'Updated 2 h ago', 'Clips play from where you left off']);
assert.deepStrictEqual(news.rows[1], ['Video', 'Updated 2 h ago', 'Clips play from where you left off']);
await eventually(async () => assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on Feed'));
await eventually(async () => assert.ok(await p.evaluate(`document.documentElement.scrollWidth <= 390`), 'no sideways scroll on Feed'));
+
+ // 4.225.0, a phone's touch: a long press then a move keeps the app under the finger. Entering Edit repaints the
+ // grid; the first move used to scroll the menu and cancel the press, so the app never moved.
+ await p.evaluate(`window.OpenVibeSearch && OpenVibeSearch.close(), true`); await wait(350);
+ await openTab(p, 'you');
+ assert.ok(await until(p, `document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app').length >= 12`), 'Home again');
+ if (await p.evaluate(`!!document.querySelector('.ovh.is-edit')`)) await p.evaluate(`document.querySelector('.ovh-edit').click(), true`);
+ assert.ok(await until(p, `!document.querySelector('.ovh.is-edit')`, 3000), 'out of Edit before the touch');
+ await wait(400);
+ const before = await order(p);
+ const f0 = await center(p, `.ovh-grid:not(.ovh-recent) .ovh-app[data-id="${before[0]}"]`);
+ const f2 = await center(p, `.ovh-grid:not(.ovh-recent) .ovh-app[data-id="${before[2]}"]`);
+ const scrollOf = `(() => { const l = document.querySelector('.ovs-list'); return l ? l.scrollTop : 0; })()`;
+ const top0 = await p.evaluate(scrollOf);
+ await p.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
+ await p.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: f0.x, y: f0.y }] });
+ assert.ok(await until(p, `!!document.querySelector('.ovh.is-edit')`, 3000), 'a touch long-press starts Edit');
+ await wait(120); // the finger rests a moment after Edit starts, as a person's does
+ // Mostly upward first, the way a thumb that means to drag still nudges the page.
+ for (let k = 1; k <= 8; k++) { await p.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: f0.x + ((f2.x - f0.x) * k) / 8, y: f0.y - (k <= 2 ? 14 * k : 28) + ((f2.y - f0.y) * k) / 8 }] }); await wait(40); }
+ await p.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: f2.x, y: f2.y }] }); await wait(40);
+ await p.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
+ await p.send('Emulation.setTouchEmulationEnabled', { enabled: false });
+ const moved = await until(p, `[...document.querySelectorAll('.ovh-grid:not(.ovh-recent) .ovh-app')].map((a) => a.getAttribute('data-id')).indexOf(${JSON.stringify(before[0])}) === 2`, 3000);
+ assert.ok(moved, `the app moved under the finger to where it was dropped (${await order(p)})`);
+ assert.strictEqual(await p.evaluate(scrollOf), top0, 'and the menu did not scroll under it');
await p.close();
await p.close();
// ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.
// ── Signed in: the Actor strip, Recent, the composer, and the arrangement goes to Network.
@@ -261,7 +287,8 @@ const server = http.createServer((req, res) => {
@@ -261,7 +287,8 @@ const server = http.createServer((req, res) => {
assert.ok(await until(s, `!!document.querySelector('.ovs-door-host .ovdk')`), 'the panel is inside the menu');
assert.ok(await until(s, `!!document.querySelector('.ovs-door-host .ovdk')`), 'the panel is inside the menu');
assert.strictEqual(await s.evaluate(`[...document.querySelectorAll('.ovdk')].every((d) => !!d.closest('.ovs-door-host')) && !document.querySelector('#ovdk-pill, .ovdk-pill')`), true, 'no floating Dock and no pill');
assert.strictEqual(await s.evaluate(`[...document.querySelectorAll('.ovdk')].every((d) => !!d.closest('.ovs-door-host')) && !document.querySelector('#ovdk-pill, .ovdk-pill')`), true, 'no floating Dock and no pill');
await s.evaluate(`window.OpenVibeDock.open({ tab: 'agent' }), true`);
await s.evaluate(`window.OpenVibeDock.open({ tab: 'agent' }), true`);
- assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true' && document.querySelector('.ovs-door-sw [data-part="agent"]').getAttribute('aria-selected') === 'true'`), 'open({ tab: agent }) moves the open menu to Vibe\'s Agents');
+ // 4.224.0: Vibe is one list of runs (ov-run.js, loaded beside the navbar); an agent's place is its Tasks filter.
+ assert.ok(await until(s, `document.querySelector('.ovs [data-dock="vibe"]').getAttribute('aria-selected') === 'true' && (document.querySelector('.ovs-door-host .ovr-filter[aria-selected="true"]') || {}).textContent === 'Tasks'`), 'open({ tab: agent }) moves the open menu to Vibe\'s Tasks');
await s.evaluate(`window.OpenVibeDock.close(), true`);
await s.evaluate(`window.OpenVibeDock.close(), true`);
assert.ok(await until(s, `!document.querySelector('.ovs.is-open')`), 'close() closes the menu');
assert.ok(await until(s, `!document.querySelector('.ovs.is-open')`), 'close() closes the menu');
await s.close();
await s.close();
@@ -76,41 +76,47 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
@@ -76,41 +76,47 @@ const ROW = `Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab')).ma
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);
// With the Dock able to lend its panels (OpenVibeDock.mountInto): Messages and AI open INSIDE the menu, under its
// With the Dock able to lend its panels (OpenVibeDock.mountInto): Messages and AI open INSIDE the menu, under its
- // own head, and become the chosen place; AI has a Chat · Code · Agents switch; Feed and closing hand the panel back.
+ // own head, and become the chosen place; Feed and closing hand the panel back.
// 4.220.0: on every screen (a computer opened a floating Dock; the owner, 11-12: "one unified UI").
// 4.220.0: on every screen (a computer opened a floating Dock; the owner, 11-12: "one unified UI").
+ // 4.224.0: Vibe is one list of runs (ov-run.js) with its own filters: no Chat · Code · Agents switch.
await page.evaluate(`window.went = null; window.log = []; window.OpenVibeDock = { filterChats(q) { window.filtered = q; }, mountInto(host, o) { window.log.push('mount:' + o.tab); host.innerHTML = '<div class="stub"></div>';
await page.evaluate(`window.went = null; window.log = []; window.OpenVibeDock = { filterChats(q) { window.filtered = q; }, 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 = ''; } }; } }`);
+ return { show(t) { window.log.push('show:' + t); }, destroy() { window.log.push('destroy'); host.innerHTML = ''; } }; } };
+ window.OpenVibeRun = { mount(host, o) { window.log.push('runs:' + o.filter + (o.base.endsWith('/api/frame') ? '' : ' wrong base')); host.innerHTML = '<div class="ovr runstub"></div>';
+ return { list(f) { window.log.push('list:' + f); }, start(t) { window.asked = t; return Promise.resolve({}); }, destroy() { window.log.push('runs-destroy'); host.innerHTML = ''; } }; } }`);
await page.evaluate(OPEN(true)); await wait(350);
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'),
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(' ') : '',
+ runs: !!document.querySelector('.ovs-door-host .runstub'), sw: !!document.querySelector('.ovs-door-sw, .ovs-door-top, [data-part]'),
chosen: Array.from(document.querySelectorAll('.ovs-starttabs .ovs-tab[aria-selected="true"]')).map((t) => t.textContent.trim()).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);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(120);
{
{
- await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat* Code Agents', chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its chats`));
+ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: false, runs: true, sw: false, chosen: 'Vibe' }, `${name}: Vibe opens inside the menu, on its runs, with no switch`));
await eventually(async () => assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`));
await eventually(async () => assert.equal(await page.evaluate('window.went'), null, `${name}: no floating Dock`));
- await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="code"]').click()`); await wait(80);
- await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: 'Chat Code* Agents', chosen: 'Vibe' }, `${name}: the switch goes to Code`));
- await page.evaluate(`document.querySelector('.ovs-door-sw [data-part="agent"]').click()`); await wait(80);
- await eventually(async () => assert.equal((await inside()).sw, 'Chat Code Agents*', `${name}: the switch goes to Agents`));
- // 4.223.0: the timer is in every box's + (the owner: "the timer should be in the plus thing"), not in this row.
- assert.equal(await page.evaluate(`!!document.querySelector('.ovs-door-top .ovs-ai-timer')`), false, `${name}: no timer button beside the switch`);
+ // 4.223.0: the timer is in every box's + (the owner: "the timer should be in the plus thing"), not in a row.
+ assert.equal(await page.evaluate(`!!document.querySelector('.ovs-ai-timer')`), false, `${name}: no timer button in Vibe`);
await page.evaluate(`document.querySelector('.ovs-input').value = 'find this'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(170);
await page.evaluate(`document.querySelector('.ovs-input').value = 'find this'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(170);
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));`);
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));`);
- await eventually(async () => assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: AI Enter hands its text to Vibe`));
+ await eventually(async () => assert.equal(await page.evaluate('window.asked'), 'find this', `${name}: Enter in Vibe starts a run with the words`));
await page.evaluate(`document.querySelector('.ovs-input').value = ''; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`);
await page.evaluate(`document.querySelector('.ovs-input').value = ''; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-dock="chats"]').click()`); await wait(80);
- await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, sw: '', chosen: 'Messages' }, `${name}: Messages inside the menu, no switch`));
+ await eventually(async () => assert.deepEqual(await inside(), { door: true, list: false, stub: true, runs: false, sw: false, chosen: 'Messages' }, `${name}: Messages inside the menu, the runs handed back`));
await page.evaluate(`document.querySelector('.ovs-input').value = 'sara'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(180);
await page.evaluate(`document.querySelector('.ovs-input').value = 'sara'; document.querySelector('.ovs-input').dispatchEvent(new Event('input', { bubbles: true }));`); await wait(180);
await eventually(async () => assert.equal(await page.evaluate('window.filtered'), 'sara', `${name}: Messages search filters conversations in place`));
await eventually(async () => assert.equal(await page.evaluate('window.filtered'), 'sara', `${name}: Messages search filters conversations in place`));
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));`);
await page.evaluate(`document.querySelector('.ovs-input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }));`);
await eventually(async () => assert.equal(await page.evaluate(`!!(document.querySelector('.ovs-input').value === '' && document.querySelector('.ovs.is-open'))`), true, `${name}: Escape clears before closing`));
await eventually(async () => assert.equal(await page.evaluate(`!!(document.querySelector('.ovs-input').value === '' && document.querySelector('.ovs.is-open'))`), true, `${name}: Escape clears before closing`));
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`); await wait(80);
await page.evaluate(`document.querySelector('.ovs-starttabs [data-tab="feed"]').click()`); await wait(80);
- await eventually(async () => assert.deepEqual(await inside(), { door: false, list: true, stub: false, sw: '', chosen: 'Feed' }, `${name}: Feed hands the panel back`));
+ await eventually(async () => assert.deepEqual(await inside(), { door: false, list: true, stub: false, runs: false, sw: false, 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(`document.querySelector('.ovs-starttabs [data-dock="vibe"]').click()`); await wait(80);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
await page.evaluate('OpenVibeSearch.close()'); await wait(300);
- await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['mount:vibe', 'show:code', 'show:agent', 'show:chats', 'destroy', 'mount:agent', 'destroy'], `${name}: one mount at a time, handed back on Feed and on close`));
+ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['runs:all', 'runs-destroy', 'mount:chats', 'destroy', 'runs:all', 'runs-destroy'], `${name}: one mount at a time, handed back on Messages, on Feed and on close`));
+ // A place asked for by name (the Dock's open({ tab: 'code' }), the composer's timer): Vibe on that list.
+ await page.evaluate(OPEN(true)); await wait(300);
+ await page.evaluate(`window.log = []; OpenVibeSearch.door('code')`); await wait(80);
+ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['runs:code'], `${name}: door('code') opens Vibe on Code`));
+ await page.evaluate(`OpenVibeSearch.door('agent')`); await wait(80);
+ await eventually(async () => assert.deepEqual(await page.evaluate('window.log'), ['runs:code', 'list:task'], `${name}: door('agent') moves the open Vibe to Tasks`));
+ await page.evaluate('OpenVibeSearch.close()'); await wait(300);
}
}
- await page.evaluate('delete window.OpenVibeDock');
+ await page.evaluate('delete window.OpenVibeDock; delete window.OpenVibeRun');
// 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 },`));
@@ -493,7 +493,9 @@ const created = (subject, over = {}) => ({
@@ -493,7 +493,9 @@ const created = (subject, over = {}) => ({
// 25.7 → 25.75 KB on 2026-11-10 (4.201.0): the one menu's notifications (the count event, the five-row preview
// 25.7 → 25.75 KB on 2026-11-10 (4.201.0): the one menu's notifications (the count event, the five-row preview
// with its count, per-view page sizes; 25.72 KB measured). It cannot be trimmed: every line feeds the menu.
// with its count, per-view page sizes; 25.72 KB measured). It cannot be trimmed: every line feeds the menu.
// 25.75 → 25.9 KB on 2026-11-11 (4.211.0): the You tab's three-row preview (is-preview, its rule, Show all).
// 25.75 → 25.9 KB on 2026-11-11 (4.211.0): the You tab's three-row preview (is-preview, its rule, Show all).
- assert.ok(size('notification-ui.js') <= 25.9 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 25.9 KB`);
+ // 25.9 → 26.1 KB on 2026-11-12 (4.225.0): inside the menu the list is part of the menu's one scroller (the owner: scrolling
+ // in notifications did not work on a phone).
+ assert.ok(size('notification-ui.js') <= 26.1 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 26.1 KB`);
console.log('notification live: all checks passed');
console.log('notification live: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+// The menu's notifications scroll with the menu (4.225.0; the owner, 11-12: "scrolling is very buggy and doesn't work
+// on mobile … in the menu especially in notifications tab"). The list inside the menu was a scroll box of its own that
+// held its overscroll: it never overflowed there, so a swipe that began on a notice moved nothing at all. In a real
+// headless Chrome with touch: a swipe on a notice scrolls the menu's list, and the list inside it is no scroll box.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const http = require('node:http');
+const h = require('../browser-harness');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('notifications-menu-scroll-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
+const root = path.join(__dirname, '..');
+const NOTES = Array.from({ length: 40 }, (_, i) => ({ id: i + 1, type: 'mention', category: 'social', service: 'chat', title: `kai mentioned you ${i + 1}`, message: 'look at the shots?', priority: 'normal', is_read: i > 5 ? 1 : 0, created_at: new Date(Date.now() - i * 600e3).toISOString() }));
+const server = http.createServer((req, res) => {
+ const u = req.url.split('?')[0];
+ if (u === '/notification-ui.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'notification-ui.js'))); }
+ if (u === '/api/notifications/unread-count') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"count":6}'); }
+ if (u === '/api/notifications/unread-by-tab') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"tabs":{"all":6}}'); }
+ if (u === '/api/notifications') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ notifications: NOTES, total: NOTES.length, has_more: false })); }
+ res.writeHead(200, { 'content-type': 'text/html' });
+ // The menu's shape: a fixed sheet whose list is the one scroller, the notifications drawn inside it.
+ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;background:#0b1220;color:#e6edf7">
+<div style="position:fixed;inset:0;display:flex;flex-direction:column"><div style="flex:none;height:96px">the menu's head</div>
+<ul class="ovs-list" style="flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;margin:0;padding:6px;list-style:none"><li><div id="host"></div></li></ul></div>
+<script src="/notification-ui.js"></script>
+<script>OpenVibeNotifications.init({ token: 'tok_1', apiBase: location.origin }); const v = OpenVibeNotifications.renderMenuNotifications(document.getElementById('host'), () => {}); v.showAll();</script>`);
+});
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notes-scroll-'));
+ const browser = await h.launch({ tmpDir });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ try {
+ const p = await h.openPage(browser, await browser.newContext(), { width: 390, height: 844 });
+ await p.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 });
+ await p.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 6000 });
+ let n = 0;
+ for (let i = 0; i < 80 && n < 20; i++) { await wait(50); n = await p.evaluate(`document.querySelectorAll('#host .openvibe-notif-item').length`); }
+ assert.ok(n >= 20, `the notices are drawn (${n})`);
+ const inner = await p.evaluate(`(() => { const l = document.querySelector('#host .openvibe-nv-list'); const cs = getComputedStyle(l); return { overflow: cs.overflowY, overscroll: cs.overscrollBehaviorY }; })()`);
+ assert.deepEqual(inner, { overflow: 'visible', overscroll: 'auto' }, 'inside the menu the list is no scroll box of its own');
+ const at = await p.evaluate(`(() => { const r = document.querySelectorAll('#host .openvibe-notif-item')[2].getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; })()`);
+ await p.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [at] });
+ for (let k = 1; k <= 12; k++) { await p.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: at.x, y: at.y - (400 * k) / 12 }] }); await wait(16); }
+ await p.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
+ await wait(500);
+ const top = await p.evaluate(`document.querySelector('.ovs-list').scrollTop`);
+ assert.ok(top > 150, `a swipe that begins on a notice scrolls the menu (scrollTop ${top}; it was 0)`);
+ await p.close();
+ console.log('notifications-menu-scroll-chrome: a swipe on a notice scrolls the menu; the list inside it is no scroll box');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+// The one app icon (ov-icons.js .ov-appicon, OpenVibeIcons.app(), 4.225.0, design rule 142) in a real headless Chrome:
+// markup and script make the same icon as the menu and Home (the app's own hue on a rounded square, its glyph drawn
+// over it), at the size asked, labelled or decorative, filled when added later or when its app changes, and drawn
+// dark on a light theme.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const http = require('node:http');
+const h = require('../browser-harness');
+const icons = require('../ov-icons');
+assert.equal(typeof icons.app, 'function', 'OpenVibeIcons.app is exported');
+assert.equal(icons.hue('live'), (() => { let x = 7; for (const c of 'live') x = (x * 31 + c.charCodeAt(0)) % 360; return x; })(), 'the same hue as the menu and Home');
+assert.match(icons.CSS, /\.ov-appicon\{/, 'its look ships with the icons');
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) { console.log('ov-appicon-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
+const root = path.join(__dirname, '..');
+const server = http.createServer((req, res) => {
+ const u = req.url.split('?')[0];
+ if (u === '/ov-icons.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-icons.js'))); }
+ res.writeHead(200, { 'content-type': 'text/html' });
+ res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;padding:16px;background:#0b1220">
+<span id="a" class="ov-appicon" data-icon="live" data-size="64"></span>
+<span id="b" class="ov-appicon" data-icon="tools" data-label="Tools"></span>
+<script src="/ov-icons.js"></script>`);
+});
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-appicon-'));
+ const browser = await h.launch({ tmpDir });
+ try {
+ const page = await h.openPage(browser, await browser.newContext(), { width: 390, height: 700 });
+ await page.goto(`http://127.0.0.1:${server.address().port}/`, { maxMs: 5000 });
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+ const look = (sel) => page.evaluate(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); const r = e.getBoundingClientRect(); const cs = getComputedStyle(e); const g = e.querySelector('.ov-icon');
+ return { w: Math.round(r.width), h: Math.round(r.height), radius: cs.borderRadius, hue: e.style.getPropertyValue('--ova-h'), svg: !!e.querySelector('.ov-icon svg .ovi-glyph, .ov-icon svg .ovi-glyph--solid'),
+ ring: g && g.querySelector('.ovi-ring') ? getComputedStyle(g.querySelector('.ovi-ring')).display : 'none', hidden: e.getAttribute('aria-hidden'), role: e.getAttribute('role'), label: e.getAttribute('aria-label'),
+ glyph: g ? getComputedStyle(g).color : null, bg: cs.backgroundImage.startsWith('linear-gradient') }; })()`);
+ await wait(100);
+ const a = await look('#a');
+ assert.deepEqual([a.w, a.h, a.radius, a.hue, a.svg, a.ring, a.hidden, a.bg], [64, 64, '30%', String(icons.hue('live')), true, 'none', 'true', true], 'from markup: its size, its hue, the glyph, no ring, decoration');
+ const b = await look('#b');
+ assert.deepEqual([b.w, b.role, b.label], [56, 'img', 'Tools'], 'the default size, and a label makes it an image with a name');
+ // Script, and markup added later.
+ await page.evaluate(`document.body.append(OpenVibeIcons.app('games', { size: 40, label: 'Games' })); const s = document.createElement('span'); s.id = 'late'; s.className = 'ov-appicon'; s.setAttribute('data-icon', 'chat'); document.body.append(s); true`);
+ await wait(80);
+ const late = await look('#late');
+ assert.deepEqual([late.svg, late.hue], [true, String(icons.hue('chat'))], 'markup added later is filled');
+ assert.equal(await page.evaluate(`document.querySelectorAll('.ov-appicon[data-icon="games"] .ov-icon svg').length`), 1, 'OpenVibeIcons.app() draws one');
+ // Its app changes: it is redrawn in the new hue.
+ await page.evaluate(`document.getElementById('late').setAttribute('data-icon', 'video'), true`);
+ await wait(80);
+ assert.equal((await look('#late')).hue, String(icons.hue('video')), 'a new app, a new hue');
+ // A light theme draws the glyph dark.
+ const darkGlyph = a.glyph;
+ await page.evaluate(`document.documentElement.setAttribute('data-theme', 'light'), true`);
+ await wait(50);
+ assert.notEqual((await look('#a')).glyph, darkGlyph, 'the glyph changes with the theme');
+ await page.close();
+ console.log('ov-appicon-chrome: markup and script, size, hue, label, late markup, a changed app, the light theme');
+ } finally {
+ await browser.close();
+ server.close();
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ }
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -27,11 +27,25 @@ const STEPS = [
@@ -27,11 +27,25 @@ const STEPS = [
{ seq: 5, kind: 'message', from: 'agent', text: 'Done.', ui: { type: 'results', data: { results: [{ kind: 'timer', label: 'Timer, 10 min' }] } } },
{ seq: 5, kind: 'message', from: 'agent', text: 'Done.', ui: { type: 'results', data: { results: [{ kind: 'timer', label: 'Timer, 10 min' }] } } },
{ seq: 6, kind: 'approval', title: 'Post it?', text: 'It goes to your channel.', result: { approval: 'ap_1', options: [{ id: 'approve', label: 'Post it' }, { id: 'decline', label: 'Not now' }] } },
{ seq: 6, kind: 'approval', title: 'Post it?', text: 'It goes to your channel.', result: { approval: 'ap_1', options: [{ id: 'approve', label: 'Post it' }, { id: 'decline', label: 'Not now' }] } },
];
];
+// The Action Center (Network GET /api/v1/me/actions, common.action-item@1): one confirmation that needs you, one AI run
+// working, and two the runs already show (the coding run, and the approval the task waits on): those two are not drawn twice.
+const ACTIONS = { buckets: {
+ waiting: [
+ { service: 'network', id: 'cf_1', kind: 'approval', title: 'Let Clipper post for you', summary: 'A new app wants to post clips', bucket: 'waiting', state: 'waiting', created_at: T, updated_at: '2026-11-12T09:50:00Z', href: 'https://openvibe.network/confirm/cf_1', actions: ['approve', 'decline'] },
+ { service: 'actor', id: 'apr_9', kind: 'approval', title: 'Post the clip', bucket: 'waiting', state: 'waiting', created_at: T, updated_at: T, href: 'https://openvibe.actor/a/apr_9', actions: ['approve', 'decline'] },
+ ],
+ running: [
+ { service: 'ai', id: 'air_4', kind: 'ai_run', title: 'Captions for your VOD', step: 'Listening, 40%', bucket: 'running', state: 'running', created_at: T, updated_at: '2026-11-12T09:40:00Z', href: 'https://openvibe.ai/runs/air_4', actions: [] },
+ { service: 'codes', id: 'k_3', kind: 'code_task', title: 'Fix the overlay', bucket: 'running', state: 'running', created_at: T, updated_at: T, href: 'https://openvibe.codes/tasks/k_3', actions: [] },
+ ],
+ scheduled: [], done: [], failed: [] } };
const seen = { auth: new Set(), kinds: [], lastIds: [], posts: [] };
const seen = { auth: new Set(), kinds: [], lastIds: [], posts: [] };
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const u = new URL(req.url, 'http://x');
if (u.pathname === '/ov-run.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-run.js'))); }
if (u.pathname === '/ov-run.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(root, 'ov-run.js'))); }
if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;background:#0b1020;color:#e6edf7"><div id="host" style="height:100vh"></div><script src="/ov-run.js"></script>'); }
if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end('<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><body style="margin:0;background:#0b1020;color:#e6edf7"><div id="host" style="height:100vh"></div><script src="/ov-run.js"></script>'); }
+ if (u.pathname === '/api/v1/me/actions') { seen.auth.add(req.headers.authorization || ''); res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify(ACTIONS)); }
+ if (u.pathname === '/api/v1/me/actions/network/cf_1/approve' && req.method === 'POST') { seen.posts.push({ path: u.pathname, body: null }); res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"item":{}}'); }
if (!u.pathname.startsWith('/api/frame/runs')) { res.writeHead(404); return res.end(); }
if (!u.pathname.startsWith('/api/frame/runs')) { res.writeHead(404); return res.end(); }
seen.auth.add(req.headers.authorization || '');
seen.auth.add(req.headers.authorization || '');
const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
@@ -154,6 +168,44 @@ const server = http.createServer((req, res) => {
@@ -154,6 +168,44 @@ const server = http.createServer((req, res) => {
assert.deepEqual([...seen.auth], ['Bearer tok_1'], `${width}: every call carries the person's token`);
assert.deepEqual([...seen.auth], ['Bearer tok_1'], `${width}: every call carries the person's token`);
await page.evaluate('window.view.destroy()');
await page.evaluate('window.view.destroy()');
assert.equal(await page.evaluate(`document.getElementById('host').children.length`), 0, `${width}: destroy empties the host`);
assert.equal(await page.evaluate(`document.getElementById('host').children.length`), 0, `${width}: destroy empties the host`);
+
+ // ── 4.225.0, the menu's Vibe: starters, the Action Center's work, the host's slot above, the place, prefill ──
+ await page.evaluate(`window.places = []; window.tops = 0; window.view = OpenVibeRun.mount(document.getElementById('host'), { base: '${base}/api/frame', token: 'tok_1',
+ actions: { base: '${base}' }, starters: [['bell', 'Remind me to stretch'], ['live', 'Who is live right now?']],
+ top(el) { window.tops++; el.innerHTML = '<div class="hosttop">Now</div>'; return { destroy() { window.tops--; } }; },
+ onPlace(p) { window.places.push(p.filter || p.ref); } })`);
+ assert.ok(await until(`document.querySelectorAll('.ovr-rows .ovr-row:not(.is-ghost)').length === 5`), `${width}: three runs and two from the Action Center`);
+ const merged = await page.evaluate(`({ titles: [...document.querySelectorAll('.ovr-row-top b')].map((b) => b.textContent),
+ links: [...document.querySelectorAll('a.ovr-row')].map((a) => a.getAttribute('href')), acts: [...document.querySelectorAll('.ovr-row-acts')].length,
+ starters: [...document.querySelectorAll('.ovr-starter')].map((b) => b.textContent), top: (document.querySelector('.ovr-top .hosttop') || {}).textContent })`);
+ assert.deepEqual(merged.titles, ['Let Clipper post for you', 'Post the clip', 'Goal bar ideas', 'Captions for your VOD', 'Fix the overlay'], `${width}: what needs you first, then newest; nothing twice`);
+ assert.deepEqual(merged.links, ['https://openvibe.network/confirm/cf_1', 'https://openvibe.ai/runs/air_4'], `${width}: the Action Center's rows open their own pages`);
+ assert.equal(merged.acts, 2, `${width}: both that need you can be answered in place`);
+ assert.deepEqual(merged.starters, ['Remind me to stretch', 'Who is live right now?'], `${width}: the starters above the box`);
+ assert.equal(merged.top, 'Now', `${width}: the host's slot above the list`);
+ await page.evaluate(`document.querySelector('.ovr-rows li.has-acts .ovr-btn.is-primary').click()`);
+ assert.ok(await until(`(document.querySelector('.ovr-rows li.has-acts .ovr-row-acts') || {}).textContent === 'Approved'`), `${width}: the Action Center's item approved in place`);
+ assert.ok(seen.posts.some((x) => x.path === '/api/v1/me/actions/network/cf_1/approve'), `${width}: through the Action Center's own route`);
+ // Code hides the slot and the starters; Chats shows the starters only.
+ await page.evaluate(`document.querySelector('.ovr-filter[data-filter="code"]').click()`);
+ assert.ok(await until(`document.querySelectorAll('.ovr-rows .ovr-row:not(.is-ghost)').length === 1`), `${width}: Code`);
+ assert.deepEqual(await page.evaluate(`[document.querySelector('.ovr-top').hidden, document.querySelector('.ovr-starters').hidden]`), [true, true], `${width}: no slot and no starters on Code`);
+ await page.evaluate(`document.querySelector('.ovr-filter[data-filter="chat"]').click()`);
+ assert.ok(await until(`document.querySelector('.ovr-filter[aria-selected="true"]').dataset.filter === 'chat' && !document.querySelector('.ovr-rows .is-ghost')`), `${width}: Chats`);
+ assert.deepEqual(await page.evaluate(`[document.querySelector('.ovr-top').hidden, document.querySelector('.ovr-starters').hidden]`), [true, false], `${width}: the starters on Chats`);
+ // Ask your agent: the words in the box, unsent.
+ await page.evaluate(`window.view.prefill('What is this page about?')`);
+ assert.ok(await until(`document.querySelector('.ovr-compose textarea').value === 'What is this page about?' && document.activeElement === document.querySelector('.ovr-compose textarea')`), `${width}: prefill puts the words in the box, focused`);
+ // A starter starts its run; the slot is handed back when the thread opens.
+ await page.evaluate(`document.querySelector('.ovr-filter[data-filter="all"]').click()`);
+ assert.ok(await until(`!document.querySelector('.ovr-rows .is-ghost') && !document.querySelector('.ovr-top').hidden`), `${width}: All again`);
+ const before = seen.posts.length;
+ await page.evaluate(`document.querySelector('.ovr-starter').click()`);
+ assert.ok(await until(`!!document.querySelector('.ovr.is-thread')`), `${width}: a starter opens its new run`);
+ assert.deepEqual(seen.posts.slice(before).find((x) => x.path === '').body, { text: 'Remind me to stretch' }, `${width}: started with the starter's words`);
+ assert.equal(await page.evaluate('window.tops'), 0, `${width}: the host's slot is handed back in a thread`);
+ assert.deepEqual(await page.evaluate('window.places'), ['all', 'code', 'chat', 'all', 'chat:c_2'], `${width}: each place is told to the host`);
+ await page.evaluate('window.view.destroy()');
await page.close();
await page.close();
}
}
console.log('run-thread-chrome: the list, filters, a thread with every kind of step, the live stream and its resume, approvals, messages and a new run');
console.log('run-thread-chrome: the list, filters, a thread with every kind of step, the live stream and its resume, approvals, messages and a new run');