openvibers/openvibe.shared Commits

4.240.0: one agent in the menu's Vibe, and a menu button drawn from the menu (#523)

OP OpenVibers committed 52 min ago · cbfb3ee09b · 13 files, +325 −146

* 4.240.0: one agent in the menu's Vibe, and a menu button drawn from the menu

The owner, 11-13: "all this separation of tasks and codes and chats … It's just like filters … your actor is just
your agent … it can orchestrate to openvibe.codes, or timer, task, desktop, browser"; "I tried typing test in and
just like nothing happens"; "regenerate this icon … based on what the menu actually looks like … glow".

- ov-run.js: your agent heads the list (face, state, today's spend, Pause or Resume); filters are chips over one list;
  one box starts everything and the thread opens at once with your words and your agent typing; a handed-on run says
  where it went; a failed start returns the words with why; Now shows on every filter; words carry into the shared
  composer.
- search.js: the menu's Vibe no longer lends the Dock's Chat, Agents and Code panels or its Actor card (only Now and
  the live agent view, dock.js part 'live'); Ask your agent fills the one box.
- navbar.js: the menu button is the menu, small (ask bar with your face, words and spark over the place tabs, one
  lit), with a walking light, glow, twinkle and the fold into the close mark.
- Tests: run-thread-chrome, dock-chrome (both halves).

* 4.240.0: Calm slows the menu button's light and spark (only Off stills them); navbar budget 52.6 KB; the slot shows on every filter

* wallet-chrome: measure the buttons after the opening animation settles (it failed about one run in four)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 3d8fb86..4d317f0 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,32 @@
# Changelog
++## 4.240.0 — 2026-11-13
++
++**One agent in the menu's Vibe, and a menu button drawn from the menu** (the owner, 11-13: "all this separation of
++tasks and codes and chats … we shouldn't even have that. It's just like filters … your actor is just your agent … it
++can orchestrate to openvibe.codes, or timer, task, desktop, browser"; "I tried typing test in and just like nothing
++happens … not seamless and sleek"; "regenerate this icon … based on what the menu actually looks like and what the
++menu has to offer … good animations and effects … highlight differently and glow").
++
++- **The menu's Vibe is one agent (ov-run.js, search.js).**
++ - Your agent heads the list (`o.agent`): its face with a state dot (ready, working, asks you, paused), its name,
++ what it is doing and today's spend, and Pause or Resume. A tap shows its work. It reads the Frame's `/actor`.
++ - All · Chats · Tasks · Code are filter chips over one list, never modes: the Dock's Chat, Agents and Code panels
++ and its Actor card are no longer lent into the menu (only Now and the live view of an agent at work, the Dock's
++ new `part: 'live'`).
++ - One box starts everything ("Ask, or say what to do"); the Frame places the words (Network #911: Codes for a change
++ to code, your computer for "on my computer", Vibe for the rest). The thread opens at once with your words and
++ your agent typing until it answers; a run your agent handed on says where it went ("Sent to Codes"). A start that
++ fails goes back to the list with the words in the box and why.
++ - Now (timers, what is on) shows above the list on every filter; Ask your agent (`[data-ov-ask]`, the Dock's
++ `ask()`) puts the words in the one box, unsent; words put in the box before the shared composer loads carry over.
++- **The menu button (navbar.js)** is the menu, small: the ask bar you lead (your face, the words, Vibe's spark) over
++ the place tabs, one lit. Now and then the light walks across the tabs; under the pointer the tabs lift and light,
++ the spark turns and the mark glows; while an agent works the spark turns slowly (`.is-working`); open, the bar and
++ the tabs fold into the ×. Calm and Off keep it still. Class names are M57's, so callers and tests are unchanged.
++- Tests: `run-thread-chrome` (a new run opens with your words and the typing), `dock-chrome` (the menu's Vibe is one
++ list with your agent; Tasks is a filter; the live view above it).
++
## 4.239.0 — 2026-11-13
**One floating action, a composer that fits its column, a close button with depth** (the owner, 11-13: "this button
ddiff --git a/README.md b/README.md
iindex 35dece6..1010d28 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.239.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.240.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 c61b15f..47e7b11 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.239.0",
++ "version": "4.240.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.js b/dock.js
iindex cf6943b..2b9d22a 100644
---- a/dock.js
++++ b/dock.js
@@@ -271,9 +271,11 @@
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>';
++ if (id === 'agent' && (part === 'top' || part === 'live')) {
++ // 4.225.0: only the top is lent (the menu's Vibe draws the runs and the Action Center's work itself). 4.240.0,
++ // part 'live': Now (timers, what is on) and the live view of an agent at work, without the Actor card (the
++ // menu's Vibe draws your agent itself).
++ panel.innerHTML = part === 'live' ? '<div class="ovdk-timeline"></div><div class="ovdk-agent"></div>' : '<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;
@@@ -310,7 +312,7 @@
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 (panel.querySelector('.ovdk-actor')) 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) => {
@@@ -689,6 +691,14 @@
// ── 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) {
++ // 4.240.0: the menu's Vibe is one agent with one box (ov-run.js): the words wait there, unsent.
++ const S = root.OpenVibeSearch;
++ if (S && typeof S.prefill === 'function') {
++ if (!n) open({ tab: 'vibe' });
++ if (S.prefill(text)) return;
++ if (n < 40) setTimeout(() => ask({ text }, n + 1), 100);
++ return;
++ }
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 = '' } = {}) {
@@@ -735,7 +745,7 @@
mountHandle = handle;
load('dock-mount.js', () => !!root.OpenVibeDockMount).then((ok) => {
if (ok && !gone) api = root.OpenVibeDockMount.mount(host, wanted, {
-- start(wrap) { if (!bodyEl) build(); part = lend === 'top' ? 'top' : null; wrap.classList.toggle('ovdk-part-top', part === 'top'); inline = wrap; wrap.append(bodyEl); },
++ start(wrap) { if (!bodyEl) build(); part = lend === 'top' || lend === 'live' ? lend : null; wrap.classList.toggle('ovdk-part-top', !!part); 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(); },
ddiff --git a/navbar.js b/navbar.js
iindex 7523dc2..cfefe0f 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -318,35 +318,50 @@
.openvibe-navbar .ovnav-orb::before { content: ""; position: absolute; inset: -3px; border-radius: 50%; background: radial-gradient(closest-side, color-mix(in srgb, var(--accent, #3b82f6) 38%, transparent), transparent); opacity: 0; transform: scale(.5); transition: opacity .25s, transform .4s cubic-bezier(.2,1.4,.3,1); pointer-events: none; }
.openvibe-navbar .ovnav-orb:hover::before, .openvibe-navbar .ovnav-orb:focus-visible::before, .openvibe-navbar .ovnav-orb[aria-expanded="true"]::before { opacity: 1; transform: none; }
.openvibe-navbar .ovnav-orb svg { position: relative; display: block; overflow: visible; }
-- .ovmb { display: block; overflow: visible; }
-- .ovmb-bar { fill: transparent; stroke: currentColor; stroke-width: 1.9; }
-- .ovmb-app { fill: currentColor; }
-- .ovmb-dot, .ovmb-a1 { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }
-- .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { transform-box: fill-box; transform-origin: center; transition: transform .5s cubic-bezier(.2,1.3,.3,1), fill .3s, stroke-width .3s, opacity .25s; }
++ .ovmb { display: block; overflow: visible; transition: filter .3s; }
++ .ovmb-bar { fill: transparent; stroke: currentColor; stroke-width: 1.7; }
++ .ovmb-dot { fill: currentColor; }
++ .ovmb-spark { fill: var(--ovo-b, #e879f9); }
++ .ovmb-words { fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; opacity: .55; }
++ .ovmb-app { fill: currentColor; opacity: .42; }
++ .ovmb-lit { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }
++ .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-words, .ovmb-spark, .ovmb-app, .ovmb-lit) { transform-box: fill-box; transform-origin: center; transition: transform .5s cubic-bezier(.2,1.3,.3,1), fill .3s, stroke-width .3s, opacity .25s; }
.ovmb-a0 { transition-delay: .03s; } .ovmb-a1 { transition-delay: .06s; } .ovmb-a2 { transition-delay: .09s; }
-- /* Under the pointer (and once, the first time it is seen): the bar and the three apps lift in turn. */
-- :is(.ovnav-orb, .ovs-orb):is(:hover, .ovo-intro) .ovmb:not(.is-x) :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: ovmb-wave .7s ease-in-out both; }
-- .ovmb .ovmb-a0 { animation-delay: .08s; } .ovmb .ovmb-a1 { animation-delay: .15s; } .ovmb .ovmb-a2 { animation-delay: .22s; }
-- :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }
-- :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb-a1 { fill: var(--ovo-b, #e879f9); }
-- /* While the menu loads: the apps breathe in turn. */
++ /* Under the pointer (and once, the first time it is seen): the tabs lift in turn and light up, the spark
++ turns, your face brightens, and the mark glows in the accent. */
++ :is(.ovnav-orb, .ovs-orb):is(:hover, .ovo-intro) .ovmb:not(.is-x) :is(.ovmb-bar, .ovmb-app, .ovmb-lit) { animation: ovmb-wave .7s ease-in-out both; }
++ .ovmb .ovmb-a0, .ovmb .ovmb-lit { animation-delay: .08s; } .ovmb .ovmb-a1 { animation-delay: .15s; } .ovmb .ovmb-a2 { animation-delay: .22s; }
++ :is(.ovnav-orb, .ovs-orb):is(:hover, .ovo-intro) .ovmb:not(.is-x) .ovmb-spark { animation: ovmb-twinkle .8s cubic-bezier(.3,1.4,.4,1) both; }
++ :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb:not(.is-x) { filter: drop-shadow(0 0 3px color-mix(in srgb, var(--ovo-a, #60a5fa) 70%, transparent)); }
++ :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); opacity: .75; }
++ :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb-dot { fill: var(--text-primary, #e6edf7); }
++ :is(.ovnav-orb, .ovs-orb):is(:hover, :focus-visible) .ovmb-words { opacity: .9; transform: scaleX(1.25); }
++ /* Something runs for you (an agent working, set by the menu's stack): the spark turns slowly. */
++ .ovnav-orb.is-working .ovmb:not(.is-x) .ovmb-spark { animation: ovmb-spin 2.4s linear infinite; }
++ /* While the menu loads: the tabs breathe in turn. */
.ovnav-orb.is-busy .ovmb-app { animation: ovmb-busy .9s ease-in-out infinite; }
-- /* Open: the bar is one stroke of the close mark, the three apps the other. */
-- :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-app), .ovmb.is-x :is(.ovmb-bar, .ovmb-dot, .ovmb-app) { animation: none; }
-- :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-bar, .ovmb.is-x .ovmb-bar { transform: translateY(4px) rotate(45deg) scale(.9, .43); fill: var(--ovo-a, var(--accent-light, #60a5fa)); stroke-width: 0; }
-- :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-dot, .ovmb.is-x .ovmb-dot { transform: translate(4.6px, 4px) scale(0); opacity: 0; }
-- :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-app, .ovmb.is-x .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); }
-- :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a0, .ovmb.is-x .ovmb-a0 { transform: translate(2.4px, -1.2px) rotate(-45deg) scale(1.3, .6); }
-- :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a1, .ovmb.is-x .ovmb-a1 { transform: translate(0, -4.6px) rotate(-45deg) scale(1.3, .6); }
-- :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a2, .ovmb.is-x .ovmb-a2 { transform: translate(-2.4px, -8px) rotate(-45deg) scale(1.3, .6); }
++ /* Open: the bar is one stroke of the close mark, the three tabs the other; your face and the spark go. */
++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-words, .ovmb-spark, .ovmb-app, .ovmb-lit), .ovmb.is-x :is(.ovmb-bar, .ovmb-dot, .ovmb-words, .ovmb-spark, .ovmb-app, .ovmb-lit) { animation: none; }
++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-bar, .ovmb.is-x .ovmb-bar { transform: translateY(4.1px) rotate(45deg) scale(.92, .38); fill: var(--ovo-a, var(--accent-light, #60a5fa)); stroke-width: 0; }
++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb :is(.ovmb-dot, .ovmb-words, .ovmb-spark), .ovmb.is-x :is(.ovmb-dot, .ovmb-words, .ovmb-spark) { transform: scale(0); opacity: 0; }
++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-app, .ovmb.is-x .ovmb-app { fill: var(--ovo-a, var(--accent-light, #60a5fa)); opacity: 1; }
++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a0, .ovmb.is-x .ovmb-a0 { transform: translate(2.4px, -1.2px) rotate(-45deg) scale(1.12, .55); }
++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a1, .ovmb.is-x .ovmb-a1 { transform: translate(0, -5.2px) rotate(-45deg) scale(1.12, .55); }
++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-a2, .ovmb.is-x .ovmb-a2 { transform: translate(-2.4px, -9.2px) rotate(-45deg) scale(1.12, .55); }
++ :is(.ovnav-orb[aria-expanded="true"], .ovs-orb.is-x) .ovmb .ovmb-lit, .ovmb.is-x .ovmb-lit { transform: translate(2.4px, -1.2px) rotate(-45deg) scale(1.12, .55); opacity: 0; }
/* A close mark that arrives already made (the Dock's sheet) forms from the button it came from. */
.ovmb.is-forming :is(.ovmb-bar, .ovmb-app) { animation: ovmb-form .5s cubic-bezier(.2,1.3,.3,1) both; }
@keyframes ovmb-form { from { transform: none; } }
-- .ovnav-orb:not([aria-expanded="true"]) .ovmb:not(.is-x) .ovmb-app { animation: ovmb-idle 9s ease-in-out 3s infinite; }
-- .ovnav-orb .ovmb .ovmb-a1 { animation-delay: 3.09s !important; } .ovnav-orb .ovmb .ovmb-a2 { animation-delay: 3.18s !important; }
-- @keyframes ovmb-idle { 0%, 4%, 100% { transform: none; } 2% { transform: translateY(calc(-2.2px * var(--ov-move, 1))); } }
-- @keyframes ovmb-wave { 0%, 100% { transform: translateY(0); } 40% { transform: translateY(calc(-2.6px * var(--ov-move, 1))); } }
-- @keyframes ovmb-busy { 0%, 100% { transform: scale(.72); opacity: .5; } 50% { transform: scale(1.18); opacity: 1; } }
++ /* At rest, every 9 s: the light walks across the places and comes home (Calm and Off leave it still). */
++ .ovnav-orb:not([aria-expanded="true"]):not(:hover) .ovmb:not(.is-x) .ovmb-lit { animation: ovmb-walk 9s cubic-bezier(.5,0,.3,1) 3s infinite; }
++ @keyframes ovmb-walk { 0%, 62%, 100% { transform: none; } 70%, 74% { transform: translateX(6.4px); } 82%, 86% { transform: translateX(12.8px); } 94% { transform: translateX(0); } }
++ @keyframes ovmb-wave { 0%, 100% { transform: translateY(0); } 40% { transform: translateY(calc(-2.4px * var(--ov-move, 1))); } }
++ @keyframes ovmb-twinkle { 0% { transform: none; } 50% { transform: rotate(45deg) scale(1.45); } 100% { transform: rotate(90deg); } }
++ @keyframes ovmb-spin { to { transform: rotate(360deg); } }
++ @keyframes ovmb-busy { 0%, 100% { transform: scale(.72); opacity: .35; } 50% { transform: scale(1.12); opacity: .9; } }
++ html[data-ov-motion=reduced] .ovmb :is(.ovmb-lit, .ovmb-spark, .ovmb-app) { animation-duration: 18s; }
++ html[data-ov-motion=reduced] :is(.ovnav-orb, .ovs-orb):is(:hover, .ovo-intro) .ovmb :is(.ovmb-bar, .ovmb-app, .ovmb-lit, .ovmb-spark) { animation-duration: 1.4s; }
++ html[data-ov-motion=off] .ovmb :is(.ovmb-bar, .ovmb-dot, .ovmb-words, .ovmb-spark, .ovmb-app, .ovmb-lit) { animation: none !important; transition: none; }
.openvibe-navbar .ovnav-orb[aria-expanded="true"] { color: var(--text-primary, #e6edf7); }
.openvibe-navbar :is(.ovnav-orb, .openvibe-bell, .openvibe-navbar-avatar) { transition: transform .18s cubic-bezier(.2, 1.4, .3, 1), background-color .15s, color .15s; }
.openvibe-navbar :is(.ovnav-orb, .openvibe-bell, .openvibe-navbar-avatar):active { transform: scale(.9); }
@@@ -609,7 +624,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': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '228a974d7922', 'dock.js': '6adcf85f7bd8', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '4e7d405117de', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '504bfeabcd49', 'ov-composer.js': '79b34aff4452' };
++ const SIBLING_REV = { 'ov-mark.js': 'f490466f57df', 'ov-glyphs.js': '5fa4615e0c7a', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2f6cab5e0778', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '07e8f59d9e6d', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': '73c7387a6ec5', 'dock.js': '8de9086a2cfd', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': '3e6f2784c85f', 'notification-ui.js': 'c993615033ae', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'b2a16f53eae0', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': '8701ebc6f8a3', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '4e7d405117de', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': 'f1a2d6774087', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'ae24086bd2be', 'soft.js': '98ab9b8080b1', 'ov-run.js': '34f19495bc12', 'ov-composer.js': '79b34aff4452' };
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.
@@@ -1015,12 +1030,19 @@
try { const sc = document.createElement('script'); sc.id = 'ov-slots-loader'; sc.src = siblingV('nav-slots.js'); sc.async = true; sc.onload = () => paintSlots(); document.head.appendChild(sc); } catch { /* */ }
}
-- // The OpenVibe menu's button (the owner's pick, 2026-11-10: the Arena's M57, "Search and apps"): the menu itself,
-- // small: a search bar over three apps. Open, the bar turns into one stroke of the close mark and the three apps
-- // line up as the other, so the button that opens the menu is, in the same place, the one that closes it. One
-- // drawing for the bar, the menu's sheet and the Dock's (root.OpenVibeOrb); its styles are below (.ovmb).
-- const ORB_PARTS = '<rect class="ovmb-bar" x="4" y="5.2" width="16" height="5.6" rx="2.8"/><circle class="ovmb-dot" cx="7.4" cy="8" r="1.15"/>'
-- + '<rect class="ovmb-app ovmb-a0" x="4.2" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a1" x="10" y="14.6" width="4" height="4" rx="1.3"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.6" width="4" height="4" rx="1.3"/>';
++ // The OpenVibe menu's button, 4.240.0 (the owner, 11-13: "regenerate based on what the menu actually looks like and
++ // what the menu has to offer"): the menu itself, small. On top, the ask bar you lead: your face at its left, Vibe's
++ // spark at its right. Under it, the place tabs, the one you are on lit. Now and then the light walks across the tabs
++ // (the menu's places); under the pointer the tabs lift in turn, the spark turns and the whole mark glows. Open, the
++ // bar is one stroke of the close mark and the tabs line up as the other, so the button that opens the menu is, in
++ // the same place, the one that closes it (rule opens-it-closes-it). One drawing for the bar, the menu's sheet and
++ // the Dock's (root.OpenVibeOrb); its styles are above (.ovmb). Class names kept from M57 (.ovmb-bar, .ovmb-dot for
++ // the face, .ovmb-app .ovmb-a0..2 for the tabs) so every caller and test still finds its parts.
++ const ORB_PARTS = '<rect class="ovmb-bar" x="3.2" y="4.3" width="17.6" height="7.2" rx="3.6"/>'
++ + '<circle class="ovmb-dot" cx="6.9" cy="7.9" r="1.5"/><path class="ovmb-words" d="M10 7.9h3.4"/>'
++ + '<path class="ovmb-spark" d="M16.6 5.75l.62 1.53 1.53.62-1.53.62-.62 1.53-.62-1.53-1.53-.62 1.53-.62z"/>'
++ + '<rect class="ovmb-app ovmb-a0" x="3" y="14.7" width="5.2" height="5" rx="1.6"/><rect class="ovmb-app ovmb-a1" x="9.4" y="14.7" width="5.2" height="5" rx="1.6"/><rect class="ovmb-app ovmb-a2" x="15.8" y="14.7" width="5.2" height="5" rx="1.6"/>'
++ + '<rect class="ovmb-lit" x="3" y="14.7" width="5.2" height="5" rx="1.6"/>';
function orbSVG(size = 22, open = false) {
return `<svg class="ovmb${open ? ' is-x' : ''}" viewBox="0 0 24 24" width="${size}" height="${size}" aria-hidden="true">${ORB_PARTS}</svg>`;
}
ddiff --git a/ov-run.js b/ov-run.js
iindex 9144741..15d6bb7 100644
---- a/ov-run.js
++++ b/ov-run.js
@@@ -33,6 +33,15 @@
* 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.
*
++ * 4.240.0, one agent (the owner, 11-13: "all this separation of tasks and codes and chats … we shouldn't even have
++ * that. It's just like filters … your actor is just your agent … it can orchestrate to openvibe.codes, or timer, task,
++ * desktop, browser"):
++ * o.agent: true | { name } your agent heads the list: its face, its state and today's spend (the Frame's
++ * /actor, Actor's GET /api/v1/me/actor), Pause or Resume; a tap on it shows its work
++ * the filters are chips over one list, never modes; one box starts everything (no kind: the Frame hands the words to
++ * chat, Codes, a task or a machine), and the thread opens at once with your words and your agent typing. A run your
++ * agent handed on says where it went ("Sent to Codes").
++ *
* The list: needs-you first, then newest, filtered All · Chats · Tasks · Code; each run is its agent's face (glyph and
* hue, or its picture, with what it is doing), its title, its last line and when. The thread: your words on the right,
* the agent's on the left (paragraphs, lists, links, code, numbered sources), thinking folded, each tool one quiet row,
@@@ -268,6 +277,61 @@
} 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 };
++ // Your agent (4.240.0): one, whatever it is doing. The Frame relays Actor's /api/v1/me/actor (every site's
++ // connect-src allows openvibe.network); its name and picture are the person's choice, "Vibe" until they choose.
++ let agent = null, agentAt = 0;
++ const agentName = () => String((agent && agent.name) || (o.agent && o.agent.name) || 'Vibe').slice(0, 40);
++ const agentState = () => { const a = agent; if (!a) return 'resting'; if (a.status === 'paused' || a.enabled === false) return 'paused'; if (Array.isArray(a.asks) && a.asks.length) return 'asks'; return a.status === 'working' ? 'working' : 'resting'; };
++ function agentFace(size, st) {
++ const f = el('span', `ovr-face is-${size} ovr-me is-${st || agentState()}`);
++ f.style.setProperty('--ovr-h', String(hueOf(agentName())));
++ const pic = agent && agent.avatar && httpsUrl(agent.avatar.url);
++ if (pic) { const i = el('img'); i.src = pic; i.alt = ''; f.append(i); } else f.append(svg('spark'));
++ return f;
++ }
++ async function loadAgent() {
++ if (!o.agent || !tokenOf()) return null;
++ if (agentAt && Date.now() - agentAt < 20000) return agent;
++ try { const r = await fetch(`${base}/actor`, { credentials: 'omit', headers: { Accept: 'application/json', ...headers() } }); agent = r.ok ? await r.json() : null; }
++ catch { agent = null; }
++ agentAt = Date.now();
++ return agent;
++ }
++ const money = (v) => `$${(Number(v) || 0).toFixed(2)}`;
++ const AGENT_WORD = { working: 'Working', asks: 'Asks you something', resting: 'Ready', paused: 'Paused' };
++ /** The list's head: your agent, what it is doing, and the one action that fits. */
++ function paintAgent(slot) {
++ slot.textContent = '';
++ if (!o.agent || !tokenOf()) { slot.hidden = true; return; }
++ slot.hidden = false;
++ const st = agentState();
++ const card = el('div', `ovr-agent is-${st}`);
++ const who = el('button', 'ovr-agent-who'); who.type = 'button';
++ who.setAttribute('aria-label', `${agentName()}, your agent: ${AGENT_WORD[st]}. Show its work`);
++ const now = (agent && agent.now) || {};
++ const step = st === 'working' ? String(now.current_step || '').slice(0, 120) : st === 'asks' ? String((agent.asks[0] && agent.asks[0].text) || '').slice(0, 120) : '';
++ const cap = agent && agent.caps && Number(agent.caps.usd) > 0 ? ` of ${money(agent.caps.usd)}` : '';
++ const t = el('span', 'ovr-agent-t');
++ const top = el('span', 'ovr-agent-top'); top.append(el('b', '', agentName()), el('i', 'ovr-agent-tag', 'your agent'));
++ t.append(top, el('span', 'ovr-agent-line', [AGENT_WORD[st], step, agent ? `${money(now.cost_today)}${cap} today` : ''].filter(Boolean).join(' · ')));
++ who.append(agentFace('m', st), t);
++ who.addEventListener('click', () => list('task'));
++ card.append(who);
++ if (agent && agent.id) {
++ const paused = st === 'paused';
++ const go = el('button', `ovr-agent-go${paused ? ' is-main' : ''}`); go.type = 'button';
++ go.setAttribute('aria-label', paused ? `Resume ${agentName()}` : `Pause ${agentName()}`); go.title = paused ? 'Resume' : 'Pause';
++ go.innerHTML = paused ? '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13l10.5-6.5z" fill="currentColor"/></svg>' : '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8.5 5.5v13M15.5 5.5v13"/></svg>';
++ go.addEventListener('click', async () => {
++ go.disabled = true;
++ try { const r = await fetch(`${base}/actor/${paused ? 'resume' : 'pause'}`, { method: 'POST', credentials: 'omit', headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...headers() }, body: '{}' }); if (r.ok) { agentAt = 0; await loadAgent(); } }
++ catch { /* the row stays as it was */ }
++ if (slot.isConnected) paintAgent(slot);
++ });
++ card.append(go);
++ }
++ slot.append(card);
++ }
function clearStream() { if (state.stopStream) state.stopStream(); state.stopStream = null; if (state.abort) state.abort.abort(); state.abort = null; }
@@@ -302,16 +366,47 @@
let sc = document.getElementById('ov-composer-script');
if (!sc) { sc = document.createElement('script'); sc.id = 'ov-composer-script'; sc.async = true; sc.src = o.sibling('ov-composer.js'); document.head.appendChild(sc); }
plain();
-- sc.addEventListener('load', () => { if (slot.isConnected && root.OpenVibeComposer) { slot.textContent = ''; real(); } }, { once: true });
++ sc.addEventListener('load', () => { if (slot.isConnected && root.OpenVibeComposer) { const was = (slot.querySelector('textarea') || {}).value || ''; slot.textContent = ''; real(); if (was) { const ta = slot.querySelector('textarea'); if (ta) { ta.value = was; ta.dispatchEvent(new Event('input', { bubbles: true })); } } } }, { once: true }); // words put in the plain box (Ask your agent) carry over
} else plain();
}
/** A new run with these words (Vibe answers, or hands the work on), then its thread. */
async function start(text) {
-- const run = await api('', { method: 'POST', headers: { 'Idempotency-Key': `ovr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` }, body: JSON.stringify({ ...(o.start || {}), text: String(text || '').slice(0, 16000) }) });
-- open(run.ref || run.id, run);
++ const words = String(text || '').slice(0, 16000);
++ const gen0 = pending(words);
++ let run;
++ try { run = await api('', { method: 'POST', headers: { 'Idempotency-Key': `ovr-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` }, body: JSON.stringify({ ...(o.start || {}), text: words }) }); }
++ catch (err) {
++ // It did not start: back to the list, the words back in the box, and why, in plain words.
++ if (gen0 !== state.gen) return null;
++ await list();
++ const foot = box.querySelector('.ovr-list-note'); if (foot) foot.prepend(el('p', 'ovr-note is-err', err.status === 401 ? 'Sign in to talk to your agent.' : (err && err.message) || 'That did not start. Try again.'));
++ const ta = box.querySelector('.ovr-compose textarea'); if (ta && !ta.value) { ta.value = words; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.focus(); }
++ return null;
++ }
++ if (gen0 !== state.gen) return run; // the person went elsewhere while it started
++ open(run.ref || run.id, run, { said: words });
return run;
}
++ /** The thread before the run exists: your words on the right, your agent typing on the left. */
++ function pending(words) {
++ clearStream(); dropTop(); dropPane();
++ const gen = ++state.gen;
++ state.view = 'thread'; state.run = null; state.steps = new Map();
++ box.textContent = ''; box.className = 'ovr is-thread is-pending';
++ const head = el('div', 'ovr-thead');
++ const back = el('button', 'ovr-ib'); back.type = 'button'; back.setAttribute('aria-label', 'Back to your runs'); back.append(svg('back'));
++ back.addEventListener('click', () => list());
++ const who = el('div', 'ovr-who'); who.append(el('b', '', agentName()), el('small', '', 'Starting'));
++ head.append(back, agentFace('s', 'working'), who);
++ const thread = el('div', 'ovr-thread'); thread.setAttribute('role', 'log'); thread.setAttribute('aria-live', 'polite');
++ const me = el('div', 'ovr-step is-you'); const b = el('div', 'ovr-bubble'); b.append(document.createTextNode(words)); me.append(b);
++ thread.append(me, typing());
++ box.append(head, thread);
++ return gen;
++ }
++ function typing() { const t = el('div', 'ovr-typing'); t.setAttribute('aria-label', `${agentName()} is writing`); t.append(el('i'), el('i'), el('i')); return t; }
++
// ── the list ──
async function list(filter) {
@@@ -337,6 +432,8 @@
e.preventDefault(); next.focus(); next.click();
});
head.append(tabs);
++ const me = el('div', 'ovr-agent-slot'); me.hidden = true;
++ if (o.agent) { box.append(me); paintAgent(me); loadAgent().then(() => { if (me.isConnected) paintAgent(me); }); }
const rows = el('ul', 'ovr-rows'); rows.setAttribute('aria-label', 'Your runs');
const foot = el('div', 'ovr-list-note');
const top = el('div', 'ovr-top');
@@@ -353,7 +450,7 @@
});
box.append(st);
}
-- composer(box, { placeholder: o.placeholder || 'Ask, or say what to make', onSend: (v) => start(v.text) });
++ composer(box, { placeholder: o.placeholder || (o.agent ? 'Ask, or say what to do' : '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; });
@@@ -361,7 +458,7 @@
// 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';
++ const wantTop = !pf && typeof o.top === 'function'; // 4.240.0: Now (timers) and an agent at work show on every filter
if (!wantTop) { dropTop(); topEl.textContent = ''; }
if (paneId !== (pf ? state.filter : null)) { dropPane(); paneEl.textContent = ''; }
box.classList.toggle('has-pane', !!pf);
@@@ -394,7 +491,7 @@
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.'));
++ 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. Your agent hands it to Codes.' : o.agent ? `Ask ${agentName()} anything below. It answers, sets timers and reminders, and hands coding and computer work on.` : '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(', ')}).`));
}
@@@ -444,7 +541,7 @@
function empty(title, sub) { const e = el('div', 'ovr-empty'); e.append(svg('spark')); const w = el('div'); w.append(el('b', '', title), el('span', '', sub)); e.append(w); return e; }
// ── the thread ──
-- async function open(ref, known) {
++ async function open(ref, known, how = {}) {
clearStream();
dropTop(); dropPane();
const gen = ++state.gen;
@@@ -470,9 +567,17 @@
box.classList.toggle('is-needs', needsPerson(r));
};
if (known) paintHead(known);
++ // A run your agent handed on (it started from the one box and is not a chat) says where it went.
++ const WENT = { code: ['code', 'Sent to Codes: it works on the code and shows you the change'], task: ['robot', `Sent to ${agentName()}'s tasks: it works on it and asks before anything that matters`], machine: ['tool', 'Sent to your computer: it runs there and shows you each step'], goal: ['spark', 'A goal: your agent plans the steps'] };
++ if (how.said && known && WENT[known.kind]) { const w = el('p', 'ovr-went'); w.append(svg(WENT[known.kind][0]), el('span', '', WENT[known.kind][1])); thread.append(w); }
++ let wait = null;
++ if (how.said) { const me = el('div', 'ovr-step is-you is-said'); const b = el('div', 'ovr-bubble'); b.append(document.createTextNode(how.said)); me.append(b); thread.append(me); wait = typing(); thread.append(wait); }
composer(box, { placeholder: 'Say the next thing', onSend: async (v) => { const step = await api(`/${encodeURIComponent(ref)}/messages`, { method: 'POST', body: JSON.stringify({ text: v.text }) }); add(step); } });
const add = (s) => {
if (!s || !Number.isInteger(s.seq)) return;
++ // Your words came back as a step: the stand-in goes. Your agent's first words: the typing goes.
++ if (s.kind === 'message' && (s.from === 'person') && how.said) { const said = thread.querySelector(':scope > .is-said'); if (said) said.remove(); }
++ if (wait && !(s.kind === 'message' && s.from === 'person')) { wait.remove(); wait = null; }
if (state.steps.has(s.seq)) { const old = state.steps.get(s.seq); const n = stepNode(s); old.replaceWith(n); state.steps.set(s.seq, n); return; }
const wasNear = thread.scrollHeight - thread.scrollTop - thread.clientHeight < 160;
const none = thread.querySelector(':scope > .ovr-none'); if (none) none.remove();
@@@ -488,7 +593,8 @@
paintHead(r);
(page.steps || []).forEach(add);
requestAnimationFrame(() => { thread.scrollTop = thread.scrollHeight; });
-- if (!(page.steps || []).length) thread.append(el('p', 'ovr-note ovr-none', 'Nothing here yet.'));
++ if (!(page.steps || []).length && !how.said) thread.append(el('p', 'ovr-note ovr-none', 'Nothing here yet.'));
++ if (wait) thread.append(wait); // still writing: the dots stay last
} catch (err) { if (gen !== state.gen) return; thread.append(empty('This run did not load', err.message)); return; }
if (isEnd(state.run)) return;
const last = Math.max(0, ...state.steps.keys());
@@@ -598,7 +704,8 @@
.ovr { --ovr-gap: 12px; display: flex; flex-direction: column; min-height: 0; height: 100%; font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: var(--text-primary, #e6edf7); }
.ovr svg { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
.ovr-head { padding: 4px 4px 8px; }
--.ovr-filters { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; padding: 4px; border-radius: 16px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); }
++.ovr-filters { display: flex; gap: 6px; overflow-x: auto; padding: 2px 2px 4px; scrollbar-width: none; }
++.ovr-filters::-webkit-scrollbar { display: none; }
.ovr-filter { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 42px; border-radius: 12px; cursor: pointer; font-family: 'OV Brand', Nunito, Inter, sans-serif; font-weight: 800; font-size: 14px; color: var(--text-secondary, #a8b3c4); transition: background-color .18s, color .18s; }
.ovr-filter[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) 35%, transparent); }
.ovr-filter[aria-selected="true"] svg { color: var(--accent-light, var(--accent, #60a5fa)); }
@@@ -723,6 +830,44 @@ a.ovr-row { color: inherit; text-decoration: none; }
.ovr-btn.is-primary { color: var(--on-accent-strong, #fff); background: var(--accent-strong, var(--accent, #2563eb)); } .ovr-btn:disabled { opacity: .5; cursor: default; }
.ovr-files { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } .ovr-step.is-you .ovr-files { justify-content: flex-end; }
.ovr-thumb { width: 120px; height: 90px; object-fit: cover; border-radius: 12px; }
++/* 4.240.0: one agent. The filters are chips over one list; your agent heads it; the thread opens at once. */
++.ovr-filter { flex: none; min-height: 36px !important; padding: 0 13px 0 11px; border-radius: 999px !important; font-size: 13.5px !important; gap: 6px !important; border: 1px solid color-mix(in srgb, var(--text-primary, #e6edf7) 12%, transparent); background: transparent !important; box-shadow: none !important; transition: background-color .2s, color .2s, border-color .2s, transform .18s cubic-bezier(.3,1.4,.4,1); }
++.ovr-filter svg { width: 16px !important; height: 16px !important; }
++.ovr-filter[aria-selected="true"] { color: var(--accent-light, #93c5fd) !important; border-color: color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent) !important; }
++.ovr-filter:active { transform: scale(.94); }
++.ovr-agent-slot { flex: none; width: 100%; max-width: 780px; margin-inline: auto; box-sizing: border-box; }
++.ovr-agent-slot[hidden] { display: none; }
++.ovr-agent { display: flex; align-items: center; gap: 8px; padding: 8px 8px 8px 6px; border-radius: 20px; background: linear-gradient(135deg, color-mix(in srgb, hsl(var(--ovr-ah, 220) 70% 55%) 14%, transparent), color-mix(in srgb, var(--text-primary, #e6edf7) 3%, transparent)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); animation: ovr-rise .32s cubic-bezier(.2,.9,.3,1.1) both; }
++.ovr-agent-who { all: unset; box-sizing: border-box; flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 2px 6px; border-radius: 14px; cursor: pointer; }
++.ovr-agent-who:focus-visible, .ovr-agent-go:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.ovr-agent-t { min-width: 0; display: grid; gap: 2px; }
++.ovr-agent-top { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
++.ovr-agent-top b { font: 800 16px/1.2 'OV Brand', Nunito, Inter, sans-serif; color: var(--text-primary, #e6edf7); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovr-agent-tag { font-style: normal; font-size: 12px; font-weight: 650; color: var(--text-muted, #8b98ad); white-space: nowrap; }
++.ovr-agent-line { font-size: 13px; color: var(--text-secondary, #a8b3c4); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.ovr-agent.is-asks .ovr-agent-line { color: #fbbf24; }
++.ovr-agent-go { all: unset; box-sizing: border-box; flex: none; display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; cursor: pointer; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); transition: transform .18s cubic-bezier(.3,1.4,.4,1), background-color .2s; }
++.ovr-agent-go svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; }
++.ovr-agent-go.is-main { color: var(--on-accent, #fff); background: var(--accent, #3b82f6); box-shadow: 0 6px 18px -6px color-mix(in srgb, var(--accent, #3b82f6) 70%, transparent); }
++.ovr-agent-go:active { transform: scale(.9); }
++.ovr-agent-go:disabled { opacity: .5; }
++.ovr-face.ovr-me { position: relative; }
++.ovr-face.ovr-me::after { content: ""; position: absolute; right: -1px; bottom: -1px; width: 11px; height: 11px; border-radius: 50%; border: 2px solid var(--bg-secondary, #121826); background: #64748b; }
++.ovr-face.ovr-me.is-resting::after { background: #22c55e; }
++.ovr-face.ovr-me.is-working::after { background: var(--accent, #3b82f6); animation: ovr-pulse 1.2s ease-in-out infinite; }
++.ovr-face.ovr-me.is-asks::after { background: #f59e0b; }
++.ovr-face.ovr-me.is-working { animation: ovr-breathe 2.2s ease-in-out infinite; }
++.ovr-face.ovr-me.is-paused { filter: saturate(.4); opacity: .8; }
++.ovr-typing { align-self: flex-start; display: inline-flex; gap: 5px; padding: 12px 14px; margin: 2px 0 6px; border-radius: 18px 18px 18px 6px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); animation: ovr-rise .25s ease both; }
++.ovr-typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--text-secondary, #a8b3c4); animation: ovr-dot 1.1s ease-in-out infinite; }
++.ovr-typing i:nth-child(2) { animation-delay: .15s; } .ovr-typing i:nth-child(3) { animation-delay: .3s; }
++@keyframes ovr-dot { 0%, 60%, 100% { transform: translateY(0); opacity: .45; } 30% { transform: translateY(-4px); opacity: 1; } }
++.ovr.is-pending .ovr-step.is-you, .ovr-step.is-said { animation: ovr-send .32s cubic-bezier(.2,.9,.3,1.1) both; }
++@keyframes ovr-send { from { opacity: 0; transform: translateY(18px) scale(.96); } }
++.ovr-went { display: flex; align-items: center; gap: 8px; align-self: center; max-width: 92%; margin: 4px auto 8px; padding: 7px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 650; color: var(--accent-light, #93c5fd); background: color-mix(in srgb, var(--accent, #3b82f6) 12%, transparent); animation: ovr-rise .3s ease both; }
++.ovr-went svg { flex: none; width: 15px; height: 15px; }
++html[data-ov-motion=reduced] .ovr-typing i, html[data-ov-motion=reduced] .ovr-face.ovr-me.is-working { animation-duration: 3s; }
++html[data-ov-motion=off] :is(.ovr-agent, .ovr-typing, .ovr-typing i, .ovr-went, .ovr-step.is-said, .ovr-face.ovr-me, .ovr-face.ovr-me::after) { animation: none !important; }
@keyframes ovr-rise { from { opacity: 0; transform: translateY(6px); } }
@keyframes ovr-shimmer { 50% { opacity: .55; } }
@keyframes ovr-breathe { 50% { box-shadow: 0 0 0 5px color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); } }
ddiff --git a/package-lock.json b/package-lock.json
iindex 5dd2a79..12a3f3d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.239.0",
++ "version": "4.240.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.239.0",
++ "version": "4.240.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 8a6aa46..bbc7007 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.239.0",
++ "version": "4.240.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 4330303..314e4d3 100644
---- a/search.js
++++ b/search.js
@@@ -1651,12 +1651,12 @@ html[data-ov-motion=off] .ovs-youpanel { animation: none; }
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' }) },
++ // 4.240.0 (the owner, 11-13: "it's just like filters … your actor is just your agent"): one view. Your agent
++ // heads the list, Chats, Tasks and Code are filters over the same runs, and one box starts everything; the
++ // Dock's Vibe, Agents and Code panels are no longer lent here.
++ actions: { base: net }, starters: VIBE_STARTERS, agent: true,
++ // Above the list: Now (timers, what is on) and the live view of an agent at work, from the Dock's agent top.
++ top: (slot) => lend(slot, { tab: 'agent', part: 'live' }),
onPlace: (p) => { if (p.filter) runFilter = p.filter; },
};
const go = (R) => {
ddiff --git a/test/dock-chrome.test.js b/test/dock-chrome.test.js
iindex 37d394d..15829dc 100644
---- a/test/dock-chrome.test.js
++++ b/test/dock-chrome.test.js
@@@ -126,6 +126,19 @@ 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.240.0: one agent. A run starts through the Frame with the words alone; its thread and stream follow.
++ if (u.pathname === '/api/frame/runs' && req.method === 'POST') {
++ const b = JSON.parse(Buffer.concat(chunks).toString() || '{}');
++ asked.push({ message: b.text, kind: b.kind || null });
++ return json(201, { id: 'c_new', ref: 'chat:c_new', kind: 'chat', title: String(b.text || '').slice(0, 60), status: 'running', agent: { id: 'vibe', name: 'Vibe', glyph: 'spark' }, updated_at: new Date().toISOString() });
++ }
++ if (decodeURIComponent(u.pathname) === '/api/frame/runs/chat:c_new') return json(200, { id: 'c_new', ref: 'chat:c_new', kind: 'chat', title: (asked.at(-1) || {}).message || 'Chat', status: 'waiting', waiting: { for: 'message' }, agent: { id: 'vibe', name: 'Vibe', glyph: 'spark' }, updated_at: new Date().toISOString() });
++ if (decodeURIComponent(u.pathname) === '/api/frame/runs/chat:c_new/steps') return json(200, { steps: [
++ { seq: 1, kind: 'message', from: 'person', text: (asked.at(-1) || {}).message || '' },
++ { seq: 2, kind: 'message', from: 'agent', text: 'Reminder set: Stretch, every 2 hours from 9 am to 5 pm.', ui: { type: 'results', data: { results: [{ kind: 'reminder', label: 'Stretch, every 2 hours' }] } } },
++ ], next: null });
++ if (decodeURIComponent(u.pathname) === '/api/frame/runs/chat:c_new/events') { res.writeHead(200, { 'content-type': 'text/event-stream' }); return res.end('event: end\ndata: {"status":"waiting"}\n\n'); }
++ if (u.pathname === '/api/frame/actor') return json(200, { id: 'act_1', name: 'Juniper', status: 'resting', enabled: true, asks: [], now: { cost_today: 0.12 }, caps: { usd: 1 } });
// 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');
@@@ -320,22 +333,23 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
const st = await until(p, STATUS, 'the status');
assert.deepStrictEqual([st.tone, st.label], ['waiting', '1 needs you'], 'what needs you comes first');
await p.evaluate(MENU_BUTTON);
-- const place = await until(p, `(() => { const c = ${PLACE}; return c && c.items ? c : null; })()`, 'the menu, open on Agents');
-- assert.deepStrictEqual([place.tab, place.groups, place.items], ['agent', ['waiting', 'running'], 3], 'what needs you first, then what runs');
++ // 4.240.0 (one agent): the menu opens on Vibe with Tasks chosen, one list where what needs you comes first; no
++ // Dock panel is lent into it.
++ const place = await until(p, `(() => { const f = document.querySelector('.ovs.is-open .ovs-door-host .ovr-filter[aria-selected="true"]'); const rows = [...document.querySelectorAll('.ovs-door-host .ovr-rows .ovr-row:not(.is-ghost)')]; return f && rows.length >= 3 ? { filter: f.dataset.filter, needs: rows.map((r) => r.classList.contains('is-needs')), lent: !!document.querySelector('.ovs-door-host .ovdk .ovac-item, .ovs-door-host .ovr-pane .ovdk') } : null; })()`, 'the menu, open on your agent\'s tasks');
++ assert.deepStrictEqual([place.filter, place.needs[0], place.lent], ['task', true, false], 'Tasks chosen, what needs you first, and no Dock panel lent');
assert.strictEqual(await p.evaluate(NO_FLOATING), true, 'inside the menu, nothing floating');
-- await p.evaluate("document.querySelector('.ovdk .ovac-item[data-bucket=waiting] button[data-do=approve]').click()");
-- await until(p, "document.querySelector('.ovdk .ovac-item[data-bucket=waiting] .primary').textContent === 'Approved'", 'approved in place');
++ await until(p, "!!document.querySelector('.ovs-door-host .ovr-rows li.has-acts .ovr-btn.is-primary')", 'its answer buttons');
++ 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');
assert.deepStrictEqual(decided, ['actor:apr_1:approve'], 'the approval goes to the Network for that item');
await p.evaluate("document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))");
await until(p, "!document.querySelector('.ovs.is-open')", 'Escape closes the menu');
-- await eventually(async () => assert.ok(puts.some((b) => b.tab === 'agent'), `the Network heard the place (${JSON.stringify(puts)})`));
-- assert.ok(puts.length <= 3, `changes go as debounced PUTs, not one per click (${puts.length})`);
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. ──
++ // ── 4.240.0: the menu's Vibe is one agent: one list (ov-run.js) with All · Chats · Tasks · Code as filters over it,
++ // runs from the Frame and the Action Center's work together, your agent at the top; no Dock panel lent. ──
{
scenario = { counts: { running: 0, waiting: 0, unread: 0 } };
agentView = null;
@@@ -348,19 +362,17 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
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'),
++ top: !!document.querySelector('.ovs-door-host .ovr-agent'), runsList: !!document.querySelector('.ovs-door-host .ovdk .ovac-item, .ovs-door-host .ovdk .ovdk-actor, .ovs-door-host .ovr-pane .ovdk'),
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');
++ assert.deepStrictEqual([all.sw, all.top, all.runsList, all.starters, all.wide], [false, true, false, 6, false], 'no switch; your agent above, no Dock list or Actor card; 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.
++ // Tasks: a filter over the same list, never a panel of its own.
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');
++ await until(p, "document.querySelector('.ovs-door-host .ovr-filter[data-filter=task]').getAttribute('aria-selected') === 'true' && document.querySelectorAll('.ovs-door-host .ovr-rows .ovr-row:not(.is-ghost)').length >= 3", 'Tasks: the same list, filtered');
++ assert.strictEqual(await p.evaluate("!document.querySelector('.ovs-door-host .ovr-pane .ovdk') && !document.querySelector('.ovs-door-host .ovdk .ovac-item')"), true, 'no Dock panel');
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');
@@@ -481,7 +493,8 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
await p.goto(`${base}/page`, { maxMs: 8000 });
await until(p, "!!window.OpenVibeDock", 'the panels');
await p.evaluate(OPEN('agent'));
-- await until(p, `(() => { const c = ${PLACE}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents place');
++ // 4.240.0: the agent at work shows above your one list in the menu's Vibe (Tasks chosen).
++ await until(p, "!!document.querySelector('.ovs-door-host .ovr-top .ovdk-agent')", 'the Agents place');
// The stream: Live's embed, the task, the numbers.
const src = await until(p, "document.querySelector('.ovda iframe') && document.querySelector('.ovda iframe').getAttribute('src')", 'the embed');
assert.strictEqual(src, view().embed_url, 'the embed Codes answered, with the ticket in its address');
@@@ -507,16 +520,16 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
assert.deepStrictEqual(controls, ['curtain', 'pause', 'mute', 'end'], 'Pause, Mute and End reached Codes, which puts them to Live');
await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovda iframe')"), false, 'no player once it has ended'));
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda-meta').textContent"), 'Ended · worked 12 min'));
-- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the agents list is untouched under the stream'));
++ await eventually(async () => assert.ok(await p.evaluate("document.querySelectorAll('.ovs-door-host .ovr-rows .ovr-row:not(.is-ghost)').length >= 3"), 'the one list is untouched under the stream'));
// No stream at all: the view hides itself and the tab is just the list.
agentView = null;
await p.goto(`${base}/page`, { maxMs: 8000 });
await until(p, "!!window.OpenVibeDock", 'the panels again');
await p.evaluate(OPEN('agent'));
-- await until(p, `(() => { const c = ${PLACE}; return c && c.tab === 'agent' && c.items ? c : null; })()`, 'the Agents place again');
++ await until(p, "!!document.querySelector('.ovs-door-host .ovr-top .ovdk-agent') && document.querySelectorAll('.ovs-door-host .ovr-rows .ovr-row:not(.is-ghost)').length >= 3", 'the Agents place again');
await wait(400);
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovda').classList.contains('on')"), false, 'nothing to show, and nowhere to show it'));
-- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelectorAll('.ovac-item').length"), 3, 'the list alone'));
++ await eventually(async () => assert.ok(await p.evaluate("document.querySelectorAll('.ovs-door-host .ovr-rows .ovr-row:not(.is-ghost)').length >= 3"), 'the list alone'));
await p.close();
}
@@@ -528,80 +541,36 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
const st = await until(p, STATUS, 'the status at 320');
assert.strictEqual(st.tone, 'running');
await p.evaluate(OPEN('agent'));
-- await until(p, PLACE, 'the menu on Agents at 320');
++ await until(p, "!!document.querySelector('.ovs-door-host .ovr-rows .ovr-row:not(.is-ghost)')", 'the menu on Agents at 320');
await eventually(async () => assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 320, 'no sideways scroll'));
await p.close();
}
}
if (PART === 1) { console.log('dock-chrome (1 of 2): no pill and no floating Dock, the status on the menu button, Agents in the menu, Chats, Send to, the agent view, 320 px clean'); return; }
-- // ── Vibe (L48 V2): starters, a question, Vibe's answer in place; the Network keeps the tab. ──
++ // ── 4.240.0, one agent: your agent heads the menu's Vibe; a starter starts a conversation at once (your words, the
++ // typing), the Frame's run carries Vibe's answer and what it did. The Dock's own Vibe panel is not used. ──
{
-- scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true } };
-- puts.length = 0;
-- const p = await fresh(1280, 860);
++ scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState() } };
++ asked.length = 0;
++ const p = await fresh(390, 780);
await p.goto(`${base}/page`, { maxMs: 5000 });
await until(p, "!!window.OpenVibeDock", 'the panels');
await p.evaluate(OPEN('vibe'));
await until(p, "document.querySelector('.ovs.is-open [data-dock=\"vibe\"]') && document.querySelector('.ovs [data-dock=\"vibe\"]').getAttribute('aria-selected') === 'true'", 'the menu on Vibe');
-- await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the conversation list');
-- await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
-- await until(p, "document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip').length === 6", 'the starters before the first message, each with its icon');
-- assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-chip')].filter((c) => c.querySelector('.ovdk-chip-ic svg')).length"), 6, 'every starter has its icon');
-- await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] .ovdk-chip').click()");
-- await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')", 'my question in place');
++ const me = await until(p, "(() => { const a = document.querySelector('.ovs-door-host .ovr-agent'); return a && /Juniper/.test(a.innerText) ? { text: a.innerText.replace(/\\s+/g, ' '), go: !!a.querySelector('.ovr-agent-go') } : null; })()", 'your agent at the top');
++ assert.match(me.text, /Juniper your agent Ready · \$0\.12 of \$1\.00 today/, 'its name, its state and today\'s spend');
++ assert.ok(me.go, 'and Pause beside it');
++ await p.evaluate("document.querySelector('.ovs-door-host .ovr-filter[data-filter=chat]').click()");
++ await until(p, "document.querySelectorAll('.ovs-door-host .ovr-starter').length === 6 && [...document.querySelectorAll('.ovs-door-host .ovr-starter')].every((b) => b.querySelector('svg'))", 'the starters, each with its glyph');
++ await p.evaluate("document.querySelector('.ovs-door-host .ovr-starter').click()");
++ await until(p, "!!document.querySelector('.ovs-door-host .ovr-thread .ovr-step.is-you')", 'my words at once');
assert.strictEqual(asked[0].message, 'Remind me to stretch every 2 hours');
-- assert.ok(asked[0].timezone, 'with the person\'s time zone, for "at 9"');
-- const answer = await until(p, "(() => { const m = document.querySelector('.ovdk [data-panel=vibe] .ovdk-msg.is-vibe:not(.ovdk-typing)'); return m ? { text: m.innerText, link: (m.querySelector('a') || {}).href || null } : null; })()", 'Vibe\'s answer', 6000);
-- assert.match(answer.text, /Reminder set: Stretch, every 2 hours/);
-- assert.strictEqual(answer.link, 'https://openvibe.today/reminders', 'an https link is a link');
-- // What Vibe did, under its answer: a reminder chip with Undo and Open (plan L48 V2, result chips).
-- const chip = await until(p, "(() => { const c = document.querySelector('.ovdk [data-panel=vibe] .ovdk-res-c'); return c ? { text: c.innerText, open: (c.querySelector('a') || {}).href, undo: !!c.querySelector('button') } : null; })()", 'the result chip');
-- assert.match(chip.text, /Reminder set: Stretch/);
-- assert.match(chip.text, /Every 2 hours, 9 am–5 pm/);
-- assert.strictEqual(chip.open, 'https://openvibe.today/me#reminders');
-- assert.ok(chip.undo, 'Undo while it can be undone');
-- await p.evaluate("document.querySelector('.ovdk .ovdk-res-c button').click()");
-- await until(p, "!!document.querySelector('.ovdk .ovdk-res-c.is-stopped')", 'the chip says it is stopped');
-- assert.deepStrictEqual(undone[0], { message_id: vibeMsgs.find((m) => m.from === 'vibe').id, index: 0 }, 'undo names the answer and the result');
-- await eventually(async () => assert.match(await p.evaluate("document.querySelector('.ovdk .ovdk-res-c').innerText"), /Reminder stopped: Stretch/));
-- await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-res-c button')"), false, 'and is not offered again'));
-- await eventually(async () => assert.strictEqual(await p.evaluate("!!document.querySelector('.ovdk .ovdk-typing')"), false, 'the writing dots go when it answers'));
-- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-away').href"), 'https://openvibe.chat/vibe/v1', 'Open in Chat goes to the same chat'));
-- await eventually(async () => assert.ok(puts.some((b) => b.tab === 'vibe'), `the Network keeps the Vibe tab, so it follows the person to the next site (${JSON.stringify(puts)})`));
-- await p.close();
-- }
--
-- // ── Vibe by voice without the browser's speech recognition (Firefox): the mic records, Chat's /api/vibe/listen
-- // says what was said, and it is asked like typed text. The recorder is a stand-in: no microphone in CI. ──
-- {
-- scenario = { counts: { running: 0, waiting: 0, unread: 0 }, state: { ...defaultState(), pinned: true, tab: 'vibe' } };
-- asked.length = 0;
-- const p = await fresh(1280, 860);
-- await p.send('Page.addScriptToEvaluateOnNewDocument', { source: `
-- delete window.SpeechRecognition; delete window.webkitSpeechRecognition;
-- window.__tracksStopped = 0;
-- navigator.mediaDevices.getUserMedia = async () => ({ getTracks: () => [{ stop() { window.__tracksStopped++; } }] });
-- window.MediaRecorder = class {
-- static isTypeSupported(t) { return t === 'audio/ogg;codecs=opus'; }
-- constructor(stream, o) { this.mimeType = (o && o.mimeType) || ''; this.state = 'inactive'; }
-- start() { this.state = 'recording'; window.__recording = true; }
-- stop() { this.state = 'inactive'; window.__recording = false; this.ondataavailable({ data: new Blob([new Uint8Array(4096)], { type: 'audio/ogg' }) }); this.onstop(); }
-- };` });
-- await p.goto(`${base}/page`, { maxMs: 5000 });
-- await until(p, "!!window.OpenVibeDock", 'the panels');
-- await p.evaluate(OPEN('vibe'));
-- await until(p, "!!document.querySelector('.ovdk-vibe-new')", 'the Vibe list');
-- await p.evaluate("document.querySelector('.ovdk-vibe-new').click()");
-- await until(p, "!!document.querySelector('.ovdk [data-panel=vibe] .ovdk-mic')", 'a mic, though the browser has no speech recognition');
-- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk [data-panel=vibe] textarea').placeholder"), 'Ask Vibe, or hold the mic'));
-- await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
-- await until(p, "window.__recording === true && document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed') === 'true'", 'recording, and the mic says so');
-- await p.evaluate("document.querySelector('.ovdk .ovdk-mic').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))");
-- await until(p, "[...document.querySelectorAll('.ovdk [data-panel=vibe] .ovdk-msg.is-mine')].some((m) => /drink water at 3/.test(m.innerText))", 'what was said, asked');
-- assert.deepStrictEqual(listened[0], { type: 'audio/ogg', bytes: 4096, lang: 'en', auth: 'Bearer tok_user' }, 'the recording goes to Chat as itself, with the person\'s token');
-- assert.strictEqual(asked[0].message, 'remind me to drink water at 3');
-- await eventually(async () => assert.strictEqual(await p.evaluate('window.__tracksStopped'), 1, 'the microphone is released'));
-- await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovdk .ovdk-mic').getAttribute('aria-pressed')"), 'false'));
++ assert.strictEqual(asked[0].kind, null, 'no kind: the Frame places the words');
++ const answer = await until(p, "(() => { const m = document.querySelector('.ovs-door-host .ovr-step.is-agent'); return m ? m.innerText : null; })()", 'the answer in place');
++ assert.match(answer, /Reminder set: Stretch, every 2 hours/);
++ await until(p, "!!document.querySelector('.ovs-door-host .ovr-chip') && !document.querySelector('.ovs-door-host .ovr-typing')", 'what it did as a chip, and the typing gone');
++ assert.strictEqual(await p.evaluate("[...document.querySelectorAll('.ovs-door-host .ovr-step.is-you')].length"), 1, 'my words once, not twice');
++ assert.strictEqual(await p.evaluate('document.documentElement.scrollWidth'), 390, 'nothing off the screen');
await p.close();
}
@@@ -629,7 +598,7 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
await p.goto(`${base}/page`, { maxMs: 5000 });
await until(p, "!!window.OpenVibeDock", 'the panels');
await p.evaluate(OPEN('vibe'));
-- await until(p, PLACE, 'the menu on Vibe');
++ await until(p, "!!document.querySelector('.ovs.is-open .ovs-door-host .ovr-compose textarea')", 'the menu on Vibe');
// 4.223.0: the timer is in the box's + (every composer's), not a button of Vibe's own.
await p.evaluate("(document.querySelector('.ovs .ovdk-vibe-new') || { click() {} }).click()");
await until(p, "!!document.querySelector('.ovs .ovc-more')", 'the box and its +');
@@@ -696,8 +665,9 @@ const NO_FLOATING = "!document.querySelector('#ovdk-pill, .ovdk-pill') && [...do
await p.goto(`${base}/page`, { maxMs: 5000 });
await until(p, "!!window.OpenVibeNavbar && typeof OpenVibeNavbar.askAgent === 'function'", 'the navbar');
await p.evaluate("(() => { const b = document.createElement('button'); b.type = 'button'; b.id = 'ask'; b.setAttribute('data-ov-ask', 'About this stream: night walk in Asakusa. '); document.body.appendChild(b); b.click(); })()");
-- await until(p, PLACE, 'the menu opens');
-- const box = await until(p, "(() => { const t = document.querySelector('.ovdk [data-panel=vibe] textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');
++ await until(p, "!!document.querySelector('.ovs.is-open')", 'the menu opens');
++ // 4.240.0: the question waits in the one box of the menu's Vibe (your agent), unsent.
++ const box = await until(p, "(() => { const t = document.querySelector('.ovs-door-host .ovr-compose textarea'); return t && t.value ? { value: t.value } : null; })()", 'the question in the box');
assert.strictEqual(box.value, 'About this stream: night walk in Asakusa. ', 'the page question, as given');
await eventually(async () => assert.strictEqual(await p.evaluate("document.querySelector('.ovs [data-dock=\"vibe\"]').getAttribute('aria-selected')"), 'true', 'on Vibe'));
await wait(400);
ddiff --git a/test/nav-icons.test.js b/test/nav-icons.test.js
iindex ac8c5bd..6141842 100644
---- a/test/nav-icons.test.js
++++ b/test/nav-icons.test.js
@@@ -13,7 +13,7 @@ const { brotli } = require('../scripts/size-report');
const ROOT = path.join(__dirname, '..');
const NAVBAR_PATH = path.join(ROOT, 'navbar.js');
const ICONS = require('../nav-icons');
--const NAVBAR_BROTLI_BUDGET = 52 * 1024; // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
++const NAVBAR_BROTLI_BUDGET = 52.6 * 1024; // 52.3 KB with the menu button drawn from the menu (4.240.0, 2026-11-13: the owner's "regenerate this icon … based on what the menu actually looks like … glow"); before: // 51.6 KB with menu addresses (data-ov-menu, #ov-menu=, menuAddress; 4.229.0, 2026-11-12: the owner's "other tools and buttons open specific areas of the menu"; budgets grow for a feature); before that: 50.9 KB with Back to the menu, the links' warm-up and token() (4.229.0, 2026-11-12: the owner's back button and fast links; budgets grow for a feature); before that: 50.1 KB with the app icon's squares held at their size before ov-icons.js (4.227.0, 2026-11-12: design rule 142; budgets grow for a feature); before that: 49.9 KB with the content hashes of 4.211.0's siblings (2026-11-11: no new code in the bar; the agent sheet is a sibling); 49.8 KB with the page view a guest sends at once (4.191.0, 2026-11-10); 49.7 KB with the links that step out when the bar is too narrow (4.190.0, 2026-11-10: it lays out the first paint, so it cannot be a sibling; a diet of the bar is owed); 49.3 KB with Sign In's drawn icon and the menu button's idle hop (4.191.0, 2026-11-10; a signed-out first paint, so it cannot be a sibling); 49.0 KB with the press on the site name that opens its pages (4.191.0, 2026-11-10: the list itself is ov-drop.js); 48.7 KB with the page-view beacon's signed-in marker (4.189.0, 2026-11-10); 48.6 KB with the wallet's cookie mode and its two content hashes (4.173.0, 2026-11-09); 48.2 KB with the same-site prerender rule, cross-site prefetch and the file-cache worker registration (4.165.0, 2026-11-09); 47.6 KB with the six glyph styles' refined paths and the sheets' surface loader (4.162.0, 2026-11-09); 47.1 KB with the shared pop motion every site's menus use and the account menu's Wallet and Profile (4.161.0, 2026-11-09); 46.6 KB with the keyboard watcher every site needs and the slots' loader (4.160.0, 2026-11-09; the slots themselves are a sibling, nav-slots.js); 45.0 KB with the floating cards' loader and link warming (4.152.0, 2026-11-08; the cards themselves are a sibling, overlay.js); 44.3 KB with the phone's menu button drawn as the orb (4.148.0, 2026-11-08); 43.5 KB with the menu warmed at idle and on intent (4.137.0); 43.2 KB with every sibling's ?v= content hash (4.137.0, 2026-11-07: the owner's menu was a day stale without them); 42.8 KB keeping the menu's Frame fields (4.132.0, 2026-11-07); 42.6 KB with Messages and the balances on every site (4.130.0, 2026-11-07); 42.1 KB with the featured link's phone button (4.119.0, 2026-11-06); 41.4 KB with the page-move code (4.70.0: busy(), the mark on plain links, prefetch rules on unboosted sites); was 41 KB with the Dock's loader (4.51.0)
// ── size budget and generated block ──────────────────────────
{
ddiff --git a/test/run-thread-chrome.test.js b/test/run-thread-chrome.test.js
iindex b270bbb..dafba0f 100644
---- a/test/run-thread-chrome.test.js
++++ b/test/run-thread-chrome.test.js
@@@ -161,7 +161,8 @@ const server = http.createServer((req, res) => {
await page.evaluate(`document.querySelector('.ovr-thead .ovr-ib').click()`);
assert.ok(await until(`document.querySelectorAll('.ovr-rows .ovr-row:not(.is-ghost)').length === 3`), `${width}: back to the list`);
await page.evaluate(`document.querySelector('.ovr-compose textarea').value = 'A raid alert'; document.querySelector('.ovr-compose form').requestSubmit()`);
-- assert.ok(await until(`document.querySelector('.ovr-who b') && document.querySelector('.ovr-thread .ovr-none')`), `${width}: the new run opens`);
++ // 4.240.0: the thread opens at once with your words and your agent typing, until its first words arrive.
++ assert.ok(await until(`document.querySelector('.ovr-who b') && [...document.querySelectorAll('.ovr-thread .ovr-step.is-you')].some((s) => s.textContent === 'A raid alert') && document.querySelector('.ovr-thread .ovr-typing')`), `${width}: the new run opens with your words and your agent typing`);
const start = seen.posts.find((p) => p.path === '');
assert.deepEqual(start.body, { text: 'A raid alert' }, `${width}: started with the words`);
assert.match(start.key, /^ovr-[a-z0-9]+-[a-z0-9]+$/, `${width}: once (an idempotency key)`);
@@@ -189,10 +190,11 @@ const server = http.createServer((req, res) => {
// 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`);
++ // 4.240.0: the host's slot (Now: timers, an agent at work) shows on every filter; the starters stay on All and Chats.
++ assert.deepEqual(await page.evaluate(`[document.querySelector('.ovr-top').hidden, document.querySelector('.ovr-starters').hidden]`), [false, true], `${width}: the 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`);
++ assert.deepEqual(await page.evaluate(`[document.querySelector('.ovr-top').hidden, document.querySelector('.ovr-starters').hidden]`), [false, false], `${width}: the slot and 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`);
ddiff --git a/test/wallet-chrome.test.js b/test/wallet-chrome.test.js
iindex 8ec72fe..75a0dd5 100644
---- a/test/wallet-chrome.test.js
++++ b/test/wallet-chrome.test.js
@@@ -21,6 +21,9 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
await p.evaluate(`document.querySelector('[data-chip-id]').click()`); await wait(500);
// 4.202.0: the wallet opens in the one window, which grows in (.28 s): measure once it has settled.
{ const end = Date.now() + 4000; while (Date.now() < end && !(await p.evaluate(`(() => { const w = document.querySelector('.ovw'); return !w || (w.getAnimations ? w.getAnimations().every((a) => a.playState !== 'running') : true); })()`))) await wait(50); }
++ // The wallet opens with a scale animation; sizes are read once it has settled (a measurement mid-animation read
++ // 40 px buttons as smaller, about one run in four).
++ await p.evaluate(`Promise.all(document.getAnimations().filter((a) => a.effect && a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {}))).then(() => true)`);
const metrics = await p.evaluate(`(() => { const d=document.querySelector('.ov-wallet'); return {open:!!(d&&(d.open||d.closest('.ovw'))), width:document.documentElement.scrollWidth, inner:d&&d.scrollWidth, box:d&&d.clientWidth, lock:document.documentElement.style.overflow, text:d&&d.innerText, buttons:[...d.querySelectorAll('button,a')].map(b=>b.getBoundingClientRect().height)} })()`);
assert(metrics.open, 'first chip tap opens wallet'); assert.strictEqual(metrics.width, width); assert(metrics.inner <= metrics.box + 1);
assert.strictEqual(metrics.lock, 'hidden'); assert(metrics.text.includes('$0.00')); assert(metrics.text.includes('Buy Vibes')); assert(metrics.text.includes('Top up'));