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)

OP OpenVibers committed 20 h ago · bb1981864f · 24 files, +786 −139

* openvibe-shared 4.225.0: Vibe is one place; the menu scrolls on a phone; GitHub in the +; one app icon

The menu's Vibe: All . Chats . Tasks . Code, no switch (runs and the Action Center's work in one list,
the Dock's top above, its panels lent whole as panes). A swipe that begins on a notice scrolls the menu.
Home's touch drag keeps the app under the finger. The composer's + searches GitHub through the Frame.
.ov-appicon and OpenVibeIcons.app() draw the menu's app icon anywhere.

* Tests: poll instead of sleeping in the touch drag; notification-ui's budget 26.1 KB for the scroll fix

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a352047..45a3205 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex d9c1cc6..68e46ee 100644
---- a/README.md
++++ b/README.md
@@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
--**Status:** alpha, v4.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
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex 70739f1..c871c44 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/dock-vibe.js b/dock-vibe.js
iindex dfe42a2..5e07848 100644
---- a/dock-vibe.js
++++ b/dock-vibe.js
@@@ -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 box
form.setAttribute('data-ovc-no-files', '');
ddiff --git a/dock.js b/dock.js
iindex cc397ad..7ad9b8e 100644
---- a/dock.js
++++ b/dock.js
@@@ -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;
ddiff --git a/navbar.js b/navbar.js
iindex ee0805f..7ef5a14 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -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); },
ddiff --git a/notification-ui.js b/notification-ui.js
iindex 83c7dff..4e4c6da 100644
---- a/notification-ui.js
++++ b/notification-ui.js
@@@ -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; }
ddiff --git a/ov-composer.js b/ov-composer.js
iindex b3b640e..6dbe883 100644
---- a/ov-composer.js
++++ b/ov-composer.js
@@@ -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 { 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',
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 { ani
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
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
if (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);
ddiff --git a/ov-home.js b/ov-home.js
iindex 030aa48..ff7b803 100644
---- a/ov-home.js
++++ b/ov-home.js
@@@ -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); } };
ddiff --git a/ov-icons.js b/ov-icons.js
iindex df432ce..66baec5 100644
---- a/ov-icons.js
++++ b/ov-icons.js
@@@ -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 { /* */ }
ddiff --git a/ov-run.js b/ov-run.js
iindex 3824627..a8d7ae5 100644
---- a/ov-run.js
++++ b/ov-run.js
@@@ -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)); }
ddiff --git a/ov-thread.js b/ov-thread.js
iindex 9086d5e..4a802d6 100644
---- a/ov-thread.js
++++ b/ov-thread.js
@@@ -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() {
ddiff --git a/package-lock.json b/package-lock.json
iindex 4ebe615..a38dfc9 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 9442cde..34f3787 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/search.js b/search.js
iindex a163fa2..1cfef49 100644
---- a/search.js
++++ b/search.js
@@@ -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 UI
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-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 .o
ghost = el.querySelector('.ovs-ghost');
listEl = el.querySelector('.ovs-list');
doorEl = document.createElement('div'); doorEl.className = 'ovs-door'; doorEl.hidden = true;
-- doorSw = document.createElement('div'); doorSw.className = 'ovs-door-sw'; doorSw.setAttribute('role', 'tablist'); doorSw.setAttribute('aria-label', '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 .o
e.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 .o
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;
if (typeof module === 'object' && module.exports) module.exports = api;
})(typeof globalThis !== 'undefined' ? globalThis : this);
ddiff --git a/showcase.css b/showcase.css
iindex dfdad1b..db230c5 100644
---- a/showcase.css
++++ b/showcase.css
@@@ -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}
ddiff --git a/test/composer-v2-chrome.test.js b/test/composer-v2-chrome.test.js
iindex e016ff1..afa3abf 100644
---- a/test/composer-v2-chrome.test.js
++++ b/test/composer-v2-chrome.test.js
@@@ -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: 'r
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');
++ // 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();
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 893e582..37d394d 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -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') && [...do
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. ──
{
scenario = { counts: { running: 1, waiting: 0, unread: 0 } };
ddiff --git a/test/menu-home-screen-chrome.test.js b/test/menu-home-screen-chrome.test.js
iindex bb7e0b2..c192174 100644
---- a/test/menu-home-screen-chrome.test.js
++++ b/test/menu-home-screen-chrome.test.js
@@@ -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();
ddiff --git a/test/menu-places-chrome.test.js b/test/menu-places-chrome.test.js
iindex 42eeda5..39cc08c 100644
---- a/test/menu-places-chrome.test.js
++++ b/test/menu-places-chrome.test.js
@@@ -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`);
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 },`));
ddiff --git a/test/notification-live.test.js b/test/notification-live.test.js
iindex 2bc3cb4..f10f267 100644
---- a/test/notification-live.test.js
++++ b/test/notification-live.test.js
@@@ -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); });
ddiff --git a/test/notifications-menu-scroll-chrome.test.js b/test/notifications-menu-scroll-chrome.test.js
nnew file mode 100644
iindex 0000000..5356713
---- /dev/null
++++ b/test/notifications-menu-scroll-chrome.test.js
@@@ -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); });
ddiff --git a/test/ov-appicon-chrome.test.js b/test/ov-appicon-chrome.test.js
nnew file mode 100644
iindex 0000000..3f3f1e6
---- /dev/null
++++ b/test/ov-appicon-chrome.test.js
@@@ -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); });
ddiff --git a/test/run-thread-chrome.test.js b/test/run-thread-chrome.test.js
iindex dd2476f..b270bbb 100644
---- a/test/run-thread-chrome.test.js
++++ b/test/run-thread-chrome.test.js
@@@ -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');