openvibers/openvibe.shared Commits

openvibe-shared 4.224.0: one thread view for every run (ov-run.js) (#502)

OP OpenVibers committed 22 h ago · 77f1e6065a · 10 files, +828 −10

* openvibe-shared 4.224.0: one thread view for every run (ov-run.js)

OpenVibeRun draws any run from the Frame Run API: the list (needs you first, All/Chats/Tasks/Code,
approve in place) and the thread (words with sources, thinking folded, tool rows, approval cards,
rich cards, the live stream resumed after its last step, the composer). The menu keeps the Dock's
panels until this view does everything they do.

* Every site serves ov-run.js (files.js)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e787e0b..a352047 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,8 +1,48 @@
# Changelog
--## Unreleased — a plain Stop
--
--- `ov-arrange.js`: a Stop part docks right like any other optional part; no special E-STOP size or position.
++## 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
++switch: "one new unified agent … a shared UI … a full conversation with rich embedding").
++
++- **New module `ov-run.js` (`OpenVibeRun`)** draws any run from the Frame's Run API (Network `/api/frame/runs`; Contracts
++ `agents.run@1`, `agents.step@1`), on every site:
++ - `OpenVibeRun.mount(host, { base, token, me, sibling, examples, start, filter, open })` returns
++ `{ list(filter), open(ref), start(text), destroy() }`.
++ - `OpenVibeRun.card(type, render)` adds a rich card for a step's `ui.type`; built in: `results`, `table`, `link`,
++ `code`. A card it does not know shows the step's words.
++- **The list:**
++ - Filters: All · Chats · Tasks · Code.
++ - Order: what needs you first, then newest.
++ - Each run shows its agent's face (its picture, or its glyph on its own hue, with a badge saying what it is doing),
++ its title in the brand face, its last line and when.
++ - Pills say "Needs you", "Working" and "Did not finish". A product that did not answer is named under the list.
++ - A run that asks before it acts is answered from the list: Approve or Not now, in place.
++ - A filter reloads only the rows, so the words you are typing stay.
++- **The thread:**
++ - Your words on the right. The agent's on the left: paragraphs, lists, bold, inline code, links, and numbered
++ sources whose [n] marks link to them.
++ - Thinking is folded. Each tool is one quiet row (it opens to its output).
++ - An approval is a card with its answers. Child runs are rows that open them; files are chips or pictures.
++ - It follows the run live: server-sent events read with fetch, so the bearer goes along, resuming after the last step
++ it has.
++ - The composer underneath says the next thing. A new run starts from the list's box, sent once with an idempotency
++ key.
++ - Page text is never markup, and only https links are drawn.
++- **The menu keeps the Dock's panels for now.** Its Vibe moves to this view (and the switch goes) once the view does
++ everything those panels do:
++ - the Action Center's items from every product, approved in place;
++ - the live agent view (Pause, Curtain, Mute, End);
++ - Vibe's starters and voice;
++ - "Ask your agent" from a page;
++ - the place the Network remembers.
++- **Arrange:** Stop is an ordinary optional part; it docks right like any other (#501).
++- `ov-run.js` and `ov-composer.js` have content hashes in `SIBLING_REV`, ready for the menu. Every site serves
++ `ov-run.js` (`files.js`), and the package exports `openvibe-shared/ov-run`.
++- **Tests:**
++ - New `run-thread-chrome` (390 and 1280 px): the list and filters; approve in place; every kind of step; the live
++ stream and where it resumes; approvals, messages and a new run reaching the API with the person's token; no markup
++ from page text; no overflow.
## 4.223.1 — 2026-11-12
ddiff --git a/README.md b/README.md
iindex 06b4a83..d9c1cc6 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.223.1 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.224.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 2a2823f..70739f1 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.223.1",
++ "version": "4.224.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/files.js b/files.js
iindex 73ce078..425e68f 100644
---- a/files.js
++++ b/files.js
@@@ -58,6 +58,7 @@ const BROWSER = Object.freeze([
'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)
'ov-thread.js', // one conversation UI (L55 C4): the drawer, search, composer, copy, streaming turns; markup from openvibe-shared/thread, drawn with thread.css
'ov-composer.js', // shared composer
++ 'ov-run.js', // 4.224.0: the one run thread (OpenVibeRun), loaded beside the navbar
'ov-capture.js', // OVCapture: camera, microphone, screen, photo and recording for Live, Cam and the composer (4.216.0)
'ov-combo.js', // shared field dropdown
'ov-window.js', // shared dialog, sheet, and panel
ddiff --git a/navbar.js b/navbar.js
iindex d1201d2..ee0805f 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' };
++ 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 siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
ddiff --git a/ov-run.js b/ov-run.js
nnew file mode 100644
iindex 0000000..3824627
---- /dev/null
++++ b/ov-run.js
@@@ -0,0 +1,609 @@
++/**
++ * ov-run.js (openvibe-shared 4.224.0): the one run thread. Everything a person asks an AI to do on OpenVibe is a run
++ * with a thread of steps (a chat with Vibe, a task for an agent, a coding task); this draws every one of them the same
++ * way, on every site, from the Frame's Run API (Network /api/frame/runs, Contracts `agents.run@1`, `agents.step@1`,
++ * `agents.event@1`). The owner, 11-12, crossing out the menu's Chat · Code · Agents switch: "one new unified agent …
++ * a shared UI … a full conversation with rich embedding … agent mode".
++ *
++ * const view = OpenVibeRun.mount(host, {
++ * base: 'https://openvibe.network/api/frame', // the Frame (default)
++ * token: () => token, // the person's Network token (or a string)
++ * me: { name, avatar_url }, // the person, for their own bubbles
++ * sibling: (file) => url, // where ov-composer.js is (the navbar's sibling())
++ * 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();
++ * (o.filter: the list's first filter, all | chat | task | code; o.open: a run's ref to open first)
++ *
++ * 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.
++ *
++ * 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,
++ * an approval as a card with its buttons, child runs as rows, files as chips. It follows the run live (the stream,
++ * resumed after its last step) and the composer below says the next thing, or starts a new run from the list.
++ */
++(function (root) {
++ 'use strict';
++ if (root.OpenVibeRun || typeof document === 'undefined') return;
++
++ // ── glyphs (the registry's drawings, openvibe-shared/glyphs.js; 24-unit strokes) ──
++ const G = {
++ back: '<path d="M20.8 12H4M10.3 5.7l-6.3 6.3l6.3 6.3"/>',
++ open: '<path d="M7 17L17 7M9 7h8v8"/>',
++ send: '<path d="M12 19.5V5M5.8 11.2L12 5l6.2 6.2"/>',
++ plus: '<path d="M12 5v14M5 12h14"/>',
++ spark: '<path d="M10.4 3.6l1.7 4.8L17 10.1l-4.8 1.7L10.4 16.7l-1.7-4.8L3.9 10.1l4.8-1.7ZM17 14.8l0.8 2.1L19.8 17.6l-2.1 0.8L17 20.3l-0.8-2.1L14.3 17.6l2.1-0.8Z"/>',
++ chat: '<path d="M3.8 4.3h16.8v11.7H8l-4.2 4.2Z"/>',
++ code: '<path d="M7.8 7.8l-4.2 4.2l4.2 4.2M16.2 7.8l4.2 4.2l-4.2 4.2M14.2 4.6l-4.2 14.8"/>',
++ robot: '<rect x="5.4" y="8.3" width="13.2" height="10.4" rx="2.8"/><path d="M12 4.6v3.8M9.2 13.1h0M14.8 13.1h0M9.6 16.4h4.7"/>',
++ tool: '<path d="M14.6 3.6a5.2 5.2 0 0 0-4.6 7.3L3.7 17.3V20.3h3.1l6.4-6.4a5.2 5.2 0 0 0 7.3-4.6l-3.2 1.2l-2.5-2.5l1.2-3.2Z"/>',
++ think: '<path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9V16h7v-2.1A6 6 0 0 0 12 3Z"/>',
++ file: '<path d="M7 3h7l5 5v12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1zM14 3v5h5"/>',
++ check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
++ x: '<path d="M7 7l10 10M17 7L7 17"/>',
++ pr: '<circle cx="6" cy="6" r="2.2"/><circle cx="6" cy="18" r="2.2"/><circle cx="18" cy="18" r="2.2"/><path d="M6 8.2v7.6M18 15.8V9a3 3 0 0 0-3-3h-4M13 4l-2 2 2 2"/>',
++ link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
++ child: '<path d="M6 4v8a4 4 0 0 0 4 4h8M14 12l4 4-4 4"/>',
++ clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
++ search: '<circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/>',
++ book: '<path d="M12 6.3C10 4.8 7 4.3 3.5 4.9v13.2c3.5-0.6 6.5-0.1 8.5 1.4c2-1.5 5-2 8.5-1.4V4.9C17 4.3 14 4.8 12 6.3Z"/><path d="M12 6.3v13.2"/>',
++ pencil: '<path d="M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4"/>',
++ live: '<circle cx="12" cy="11.8" r="2"/><path d="M8.6 8.4a4.9 4.9 0 0 0 0 7.1M15.4 8.4a4.9 4.9 0 0 1 0 7.1M6.3 6.1a8.1 8.1 0 0 0 0 11.9M17.7 6.1a8.1 8.1 0 0 1 0 11.9"/>',
++ store: '<path d="M3.7 8.8L5.3 3.7h13.4L20.3 8.8M3.7 8.8h16.5v1.6a2.8 2.8 0 0 1-5.4 0a2.8 2.8 0 0 1-5.6 0A2.8 2.8 0 0 1 3.7 10.4ZM4.8 12.9v7.2h14.5v-7.2"/>',
++ game: '<rect x="3.7" y="7.2" width="16.7" height="9.7" rx="4.4"/><path d="M7.6 10.7v2.7M6.4 12h2.7M15.1 11.6h0M17.4 12.9h0"/>',
++ 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"/>',
++ };
++ 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 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);
++ s.innerHTML = G[name] || G.spark; // the registry's constants above, never page text
++ return s;
++ };
++ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = String(text); return n; };
++ const httpsUrl = (v) => { try { const u = new URL(String(v)); return u.protocol === 'https:' ? u.href : ''; } catch { return ''; } };
++ const hueOf = (id) => { let x = 7; for (const c of String(id || 'ov')) x = (x * 31 + c.codePointAt(0)) % 360; return x; };
++ const ago = (iso) => {
++ const t = Date.parse(iso || ''); if (!t) return '';
++ const s = Math.max(1, Math.round((Date.now() - t) / 1000));
++ if (s < 60) return 'now'; if (s < 3600) return `${Math.round(s / 60)} min ago`; if (s < 86400) return `${Math.round(s / 3600)} h ago`;
++ const d = Math.round(s / 86400); return d === 1 ? 'yesterday' : `${d} days ago`;
++ };
++ const needsPerson = (r) => !!r && r.status === 'waiting' && !!r.waiting && r.waiting.for !== 'message';
++ const isEnd = (r) => !!r && ['done', 'failed', 'stopped'].includes(r.status);
++
++ // ── rich cards ──
++ const CARDS = new Map();
++ function card(type, render) { if (typeof type === 'string' && /^[a-z][a-z0-9-]{0,40}$/.test(type) && typeof render === 'function') CARDS.set(type, render); }
++ card('results', (data) => {
++ const row = el('div', 'ovr-chips');
++ for (const r of (Array.isArray(data.results) ? data.results : []).slice(0, 12)) {
++ const c = el('span', 'ovr-chip'); c.append(svg(r.kind === 'timer' ? 'timer' : r.kind === 'reminder' ? 'clock' : 'check'), el('span', '', r.label || r.text || r.kind || 'Done'));
++ if (r.undone) c.classList.add('is-undone');
++ row.append(c);
++ }
++ return row;
++ });
++ card('table', (data) => {
++ const wrap = el('div', 'ovr-table-wrap'); const t = el('table', 'ovr-table');
++ const cols = Array.isArray(data.columns) ? data.columns.slice(0, 8) : [];
++ if (cols.length) { const tr = el('tr'); cols.forEach((c) => tr.append(el('th', '', c))); const th = el('thead'); th.append(tr); t.append(th); }
++ const tb = el('tbody');
++ for (const row of (Array.isArray(data.rows) ? data.rows : []).slice(0, 50)) { const tr = el('tr'); (Array.isArray(row) ? row : []).slice(0, 8).forEach((v) => tr.append(el('td', '', v))); tb.append(tr); }
++ t.append(tb); wrap.append(t); return wrap;
++ });
++ card('link', (data) => {
++ const url = httpsUrl(data.url); if (!url) return null;
++ const a = el('a', 'ovr-linkcard'); a.href = url; a.target = '_blank'; a.rel = 'noopener noreferrer';
++ const img = httpsUrl(data.image); if (img) { const i = el('img'); i.src = img; i.alt = ''; i.loading = 'lazy'; a.append(i); }
++ const t = el('span', 'ovr-linkcard-t'); t.append(el('b', '', data.title || url), el('small', '', data.site || new URL(url).hostname)); a.append(t);
++ return a;
++ });
++ card('code', (data) => { const pre = el('pre', 'ovr-code'); pre.append(el('code', '', String(data.code || data.text || '').slice(0, 20000))); return pre; });
++
++ // ── words: paragraphs, lists, links, inline code, numbered sources ──
++ function words(text, sources) {
++ const box = el('div', 'ovr-words');
++ const raw = String(text || '').replace(/\r\n/g, '\n').slice(0, 65536);
++ const blocks = raw.split(/\n{2,}/);
++ for (const b of blocks) {
++ const lines = b.split('\n');
++ if (lines.every((l) => /^\s*[-*•]\s+/.test(l) || !l.trim())) {
++ const ul = el('ul'); lines.filter((l) => l.trim()).forEach((l) => { const li = el('li'); inline(li, l.replace(/^\s*[-*•]\s+/, ''), sources); ul.append(li); }); box.append(ul);
++ } else if (lines.every((l) => /^\s*\d+[.)]\s+/.test(l) || !l.trim())) {
++ const ol = el('ol'); lines.filter((l) => l.trim()).forEach((l) => { const li = el('li'); inline(li, l.replace(/^\s*\d+[.)]\s+/, ''), sources); ol.append(li); }); box.append(ol);
++ } else if (/^```/.test(b.trim())) {
++ const pre = el('pre', 'ovr-code'); pre.append(el('code', '', b.trim().replace(/^```[a-z0-9]*\n?/i, '').replace(/```$/, ''))); box.append(pre);
++ } else { const p = el('p'); lines.forEach((l, i) => { if (i) p.append(el('br')); inline(p, l, sources); }); box.append(p); }
++ }
++ return box;
++ }
++ function inline(node, text, sources) {
++ const re = /(https?:\/\/[^\s<>"')\]]+)|`([^`]{1,200})`|\*\*([^*]{1,200})\*\*|\[(\d{1,2})\]/g;
++ let last = 0, m;
++ while ((m = re.exec(text))) {
++ if (m.index > last) node.append(document.createTextNode(text.slice(last, m.index)));
++ if (m[1]) { const a = el('a', 'ovr-a', m[1]); a.href = m[1]; a.target = '_blank'; a.rel = 'nofollow noopener noreferrer'; node.append(a); }
++ else if (m[2]) node.append(el('code', 'ovr-ic', m[2]));
++ else if (m[3]) node.append(el('strong', '', m[3]));
++ else if (m[4]) {
++ const src = (sources || []).find((s) => Number(s.n) === Number(m[4]));
++ const url = src && httpsUrl(src.url);
++ if (url) { const a = el('a', 'ovr-cite', m[4]); a.href = url; a.target = '_blank'; a.rel = 'nofollow noopener noreferrer'; a.title = src.title || src.site || url; node.append(a); }
++ else node.append(document.createTextNode(m[0]));
++ }
++ last = m.index + m[0].length;
++ }
++ if (last < text.length) node.append(document.createTextNode(text.slice(last)));
++ }
++
++ // ── an agent's face: its picture, or its glyph on its hue; what it is doing as a small badge ──
++ function face(run, size = 'm') {
++ const a = (run && run.agent) || {};
++ const f = el('span', `ovr-face is-${size}`);
++ f.style.setProperty('--ovr-h', String(Number.isInteger(a.hue) ? a.hue : hueOf(a.id || (run && run.kind))));
++ const pic = httpsUrl(a.avatar_url);
++ if (pic) { const i = el('img'); i.src = pic; i.alt = ''; i.loading = 'lazy'; f.append(i); }
++ else f.append(svg(G[a.glyph] ? a.glyph : KIND_GLYPH[run && run.kind] || 'spark'));
++ if (run && run.doing && run.status === 'running') { const d = el('i', 'ovr-doing'); d.append(svg(G[run.doing.glyph] ? run.doing.glyph : 'spark')); f.append(d); f.classList.add('is-working'); }
++ if (needsPerson(run)) f.classList.add('is-needs');
++ return f;
++ }
++ function statusLine(run) {
++ if (!run) return '';
++ if (needsPerson(run)) return (run.waiting && run.waiting.what) || 'Needs you';
++ if (run.status === 'running') return (run.doing && run.doing.text) || 'Working';
++ if (run.status === 'planning') return 'Starting';
++ if (run.status === 'waiting') return 'Your turn';
++ return { done: 'Done', failed: 'Did not finish', stopped: 'Stopped' }[run.status] || '';
++ }
++
++ // ── the stream: a fetch reader over server-sent events (EventSource cannot send a bearer) ──
++ function follow({ url, headers, after, onEvent, signal }) {
++ let last = String(after || 0);
++ let stopped = false;
++ const once = async () => {
++ const r = await fetch(url, { headers: { ...headers, Accept: 'text/event-stream', 'Last-Event-ID': last }, signal, credentials: 'omit' });
++ if (!r.ok || !r.body) throw new Error(`stream ${r.status}`);
++ const reader = r.body.getReader(); const dec = new TextDecoder(); let buf = '';
++ for (;;) {
++ const { value, done } = await reader.read(); if (done) return 'closed';
++ buf += dec.decode(value, { stream: true });
++ let i;
++ while ((i = buf.indexOf('\n\n')) >= 0) {
++ const raw = buf.slice(0, i); buf = buf.slice(i + 2);
++ let id = null, event = 'message', data = '';
++ for (const line of raw.split('\n')) {
++ if (line.startsWith('id:')) id = line.slice(3).trim();
++ else if (line.startsWith('event:')) event = line.slice(6).trim();
++ else if (line.startsWith('data:')) data += line.slice(5).trim();
++ }
++ let d = null; try { d = JSON.parse(data); } catch { d = null; }
++ if (event === 'step' && id && /^\d+$/.test(id) && Number(id) > Number(last)) last = id;
++ if (onEvent(event, d) === 'end') return 'end';
++ }
++ }
++ };
++ (async () => {
++ let failures = 0;
++ while (!stopped && !(signal && signal.aborted)) {
++ try { const how = await once(); failures = 0; if (how === 'end') return; }
++ catch { if (signal && signal.aborted) return; failures++; }
++ await new Promise((r) => setTimeout(r, Math.min(30000, 800 * 2 ** Math.min(failures, 5))));
++ }
++ })();
++ return () => { stopped = true; };
++ }
++
++ const FILTERS = [['all', 'All', 'spark'], ['chat', 'Chats', 'chat'], ['task', 'Tasks', 'robot'], ['code', 'Code', 'code']];
++ const FILTER_IDS = FILTERS.map(([id]) => id);
++
++ function mount(host, o = {}) {
++ styles();
++ const base = String(o.base || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
++ const tokenOf = () => (typeof o.token === 'function' ? o.token() : o.token) || '';
++ const headers = () => { const t = tokenOf(); return t ? { Authorization: `Bearer ${t}` } : {}; };
++ const api = async (path, init = {}) => {
++ const r = await fetch(`${base}/runs${path}`, { ...init, credentials: 'omit', headers: { Accept: 'application/json', ...(init.body ? { 'Content-Type': 'application/json' } : {}), ...headers(), ...(init.headers || {}) } });
++ const j = await r.json().catch(() => ({}));
++ if (!r.ok) { const e = new Error(j.error || `The runs did not answer (${r.status})`); e.status = r.status; e.code = j.code; throw e; }
++ return j;
++ };
++ const box = el('div', 'ovr');
++ host.textContent = ''; host.append(box);
++ 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; }
++
++ // ── the composer: the shared one when it is there, else a plain box ──
++ function composer(where, { placeholder, onSend }) {
++ const slot = el('div', 'ovr-compose');
++ where.append(slot);
++ const plain = () => {
++ const f = el('form', 'ovr-plain'); const ta = el('textarea'); ta.rows = 2; ta.placeholder = placeholder; ta.setAttribute('aria-label', placeholder);
++ const go = el('button', 'ovr-send'); go.type = 'submit'; go.setAttribute('aria-label', 'Send'); go.append(svg('send'));
++ f.append(ta, go);
++ f.addEventListener('submit', async (e) => {
++ e.preventDefault(); const t = ta.value.trim(); if (!t) return; go.disabled = true;
++ const old = slot.querySelector('.ovr-note'); if (old) old.remove();
++ try { if (await onSend({ text: t }) !== false) ta.value = ''; }
++ catch (err) { slot.append(el('p', 'ovr-note is-err', (err && err.message) || 'That did not send')); }
++ finally { go.disabled = false; }
++ });
++ ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); f.requestSubmit ? f.requestSubmit() : f.dispatchEvent(new Event('submit', { cancelable: true })); } });
++ slot.append(f);
++ };
++ const C = root.OpenVibeComposer;
++ const real = () => {
++ try {
++ // The composer notes a failed send itself and keeps the words.
++ root.OpenVibeComposer.mount(slot, { preset: 'agent', placeholder, label: placeholder, examples: o.examples, user: o.me, noDraft: true,
++ onSubmit: (v) => onSend({ text: v.text, files: v.files, mode: v.mode }) });
++ } catch { slot.textContent = ''; plain(); }
++ };
++ if (C && C.mount) real();
++ else if (typeof o.sibling === 'function') {
++ 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 });
++ } 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);
++ return run;
++ }
++
++ // ── the list ──
++ async function list(filter) {
++ if (filter && FILTER_IDS.includes(filter)) state.filter = filter;
++ clearStream();
++ const gen = ++state.gen;
++ // The list's frame (filters, the box) is drawn once; a filter only reloads the rows, so the words being typed stay.
++ if (state.view !== 'list' || !box.querySelector('.ovr-rows')) {
++ state.view = 'list'; state.run = null;
++ box.textContent = ''; box.className = 'ovr is-list';
++ const head = el('div', 'ovr-head');
++ const tabs = el('div', 'ovr-filters'); tabs.setAttribute('role', 'tablist'); tabs.setAttribute('aria-label', 'Show');
++ for (const [id, label, ic] of FILTERS) {
++ const b = el('button', 'ovr-filter'); b.type = 'button'; b.setAttribute('role', 'tab'); b.dataset.filter = id;
++ b.append(svg(ic), el('span', '', label));
++ b.addEventListener('click', () => { if (state.filter !== id || state.view !== 'list') list(id); });
++ tabs.append(b);
++ }
++ tabs.addEventListener('keydown', (e) => {
++ if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
++ const all = [...tabs.children]; 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();
++ });
++ 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);
++ composer(box, { placeholder: o.placeholder || 'Ask, or say what to make', onSend: (v) => start(v.text) });
++ }
++ box.querySelectorAll('.ovr-filter').forEach((b) => { const on = b.dataset.filter === state.filter; b.setAttribute('aria-selected', String(on)); b.tabIndex = on ? 0 : -1; });
++ 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'));
++ let out;
++ try { out = await api(`?limit=40${state.filter !== 'all' ? `&kind=${state.filter}` : ''}`); }
++ catch (err) {
++ if (gen !== state.gen) return;
++ rows.textContent = ''; rows.removeAttribute('aria-busy');
++ foot.append(empty(err.status === 401 ? 'Sign in to see your runs' : 'Your runs did not load', err.status === 401 ? 'Your chats, tasks and code are here once you sign in.' : 'Check your connection; they come back on their own.'));
++ return;
++ }
++ if (gen !== state.gen) return;
++ state.runs = Array.isArray(out.runs) ? out.runs : [];
++ 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 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));
++ const meta = el('span', 'ovr-row-meta'); meta.append(svg(KIND_GLYPH[r.kind] || 'spark'), el('span', '', [KIND_WORD[r.kind] || 'Task', r.agent && r.agent.name && r.agent.name !== r.title ? r.agent.name : ''].filter(Boolean).join(' · ')));
++ if (needsPerson(r)) meta.append(el('i', 'ovr-pill is-needs', 'Needs you'));
++ else if (r.status === 'running') meta.append(el('i', 'ovr-pill is-working', 'Working'));
++ 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));
++ 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 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]) => {
++ const b = el('button', primary ? 'ovr-btn is-primary' : 'ovr-btn', label); b.type = 'button';
++ 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'); }
++ catch (err) { [...box2.querySelectorAll('button')].forEach((x) => { x.disabled = false; }); box2.append(el('p', 'ovr-note is-err', err.message)); }
++ });
++ box2.append(b);
++ });
++ return box2;
++ }
++ 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) {
++ clearStream();
++ const gen = ++state.gen;
++ state.view = 'thread'; state.run = known || null; state.steps = new Map();
++ 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'));
++ back.addEventListener('click', () => list());
++ const who = el('div', 'ovr-who'); const title = el('b'); const sub = el('small');
++ who.append(title, sub);
++ const away = el('a', 'ovr-ib'); away.setAttribute('aria-label', 'Open on its own page'); away.title = 'Open on its own page'; away.target = '_blank'; away.rel = 'noopener'; away.append(svg('open'));
++ const faceSlot = el('span', 'ovr-face-slot');
++ head.append(back, faceSlot, who, away);
++ const thread = el('div', 'ovr-thread'); thread.setAttribute('role', 'log'); thread.setAttribute('aria-live', 'polite');
++ box.append(head, thread);
++ const paintHead = (r) => {
++ state.run = r;
++ faceSlot.textContent = ''; faceSlot.append(face(r, 's'));
++ title.textContent = r.title || (r.agent && r.agent.name) || 'Run';
++ sub.textContent = [KIND_WORD[r.kind] || 'Task', statusLine(r)].filter(Boolean).join(' · ');
++ const url = httpsUrl(r.url); away.hidden = !url; if (url) away.href = url;
++ box.classList.toggle('is-needs', needsPerson(r));
++ };
++ if (known) paintHead(known);
++ 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;
++ 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();
++ const n = stepNode(s);
++ state.steps.set(s.seq, n);
++ const after = [...state.steps.keys()].filter((k) => k > s.seq).sort((a, b) => a - b)[0];
++ if (after !== undefined && state.steps.get(after).parentNode === thread) thread.insertBefore(n, state.steps.get(after)); else thread.append(n);
++ if (wasNear) requestAnimationFrame(() => { thread.scrollTop = thread.scrollHeight; });
++ };
++ try {
++ const [r, page] = await Promise.all([api(`/${encodeURIComponent(ref)}`), api(`/${encodeURIComponent(ref)}/steps?limit=200`)]);
++ if (gen !== state.gen) return;
++ 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.'));
++ } 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());
++ state.abort = new AbortController();
++ state.stopStream = follow({ url: `${base}/runs/${encodeURIComponent(ref)}/events`, headers: headers(), after: last, signal: state.abort.signal,
++ onEvent(event, d) {
++ if (gen !== state.gen) return 'end';
++ if (event === 'step' && d && d.step) add(d.step);
++ else if (event === 'delta' && d && state.steps.has(d.seq)) { const w = state.steps.get(d.seq).querySelector('.ovr-words'); if (w) w.append(document.createTextNode(String(d.text || ''))); }
++ else if (event === 'run' && d && d.run) paintHead(d.run);
++ else if (event === 'end') { if (state.run) paintHead({ ...state.run, status: (d && d.status) || 'done' }); return 'end'; }
++ return null;
++ } });
++ }
++
++ function stepNode(s) {
++ const from = s.from || (s.kind === 'message' ? 'agent' : 'agent');
++ if (s.kind === 'message' && from === 'person') {
++ const n = el('div', 'ovr-step is-you'); const b = el('div', 'ovr-bubble'); b.append(words(s.text)); n.append(b);
++ attachments(n, s); return n;
++ }
++ if (s.kind === 'message' || (s.kind !== 'tool' && s.kind !== 'think' && s.kind !== 'approval' && s.kind !== 'child' && s.kind !== 'file' && s.text)) {
++ const n = el('div', `ovr-step is-agent${s.status === 'running' ? ' is-writing' : ''}`);
++ if (s.author && s.author.name) n.append(el('small', 'ovr-author', s.author.name));
++ n.append(words(s.text, s.sources));
++ cardFor(n, s);
++ if (Array.isArray(s.sources) && s.sources.length) {
++ const src = el('div', 'ovr-sources');
++ s.sources.slice(0, 12).forEach((x) => { const url = httpsUrl(x.url); if (!url) return; const a = el('a', 'ovr-source'); a.href = url; a.target = '_blank'; a.rel = 'nofollow noopener noreferrer'; a.append(el('i', '', String(x.n)), el('span', '', x.title || x.site || new URL(url).hostname)); src.append(a); });
++ n.append(src);
++ }
++ attachments(n, s); return n;
++ }
++ if (s.kind === 'think') {
++ const d = el('details', 'ovr-step is-think'); const sm = el('summary'); sm.append(svg('think'), el('span', '', s.title || 'Thinking'));
++ d.append(sm); if (s.text) d.append(words(s.text)); return d;
++ }
++ if (s.kind === 'approval') {
++ const n = el('div', 'ovr-step is-approval'); const head = el('div', 'ovr-approval-h'); head.append(svg('check'), el('b', '', s.title || 'It asks before it goes on'));
++ n.append(head); if (s.text) n.append(words(s.text));
++ const r = s.result && typeof s.result === 'object' ? s.result : {};
++ const opts = Array.isArray(r.options) ? r.options : [{ id: 'approve', label: 'Approve' }, { id: 'decline', label: 'Not now' }];
++ const row = el('div', 'ovr-approval-acts');
++ opts.slice(0, 4).forEach((op, i) => {
++ const b = el('button', i === 0 ? 'ovr-btn is-primary' : 'ovr-btn', op.label || op.id); b.type = 'button';
++ b.addEventListener('click', async () => {
++ [...row.children].forEach((x) => { x.disabled = true; });
++ try { await api(`/${encodeURIComponent(state.run.ref || state.run.id)}/approvals/${encodeURIComponent(r.approval || r.id || String(s.seq))}`, { method: 'POST', body: JSON.stringify({ answer: op.id }) }); row.replaceWith(el('p', 'ovr-note', `You answered: ${op.label || op.id}`)); }
++ catch (err) { [...row.children].forEach((x) => { x.disabled = false; }); n.append(el('p', 'ovr-note is-err', err.message)); }
++ });
++ row.append(b);
++ });
++ if (!r.answer) n.append(row); else n.append(el('p', 'ovr-note', `Answered: ${r.answer}`));
++ return n;
++ }
++ // tool, child, file: one quiet row
++ const n = el(s.text && s.kind === 'tool' ? 'details' : 'div', `ovr-step is-row is-${s.kind}${s.status === 'running' ? ' is-running' : s.status === 'failed' ? ' is-failed' : ''}`);
++ const line = el(s.text && s.kind === 'tool' ? 'summary' : 'div', 'ovr-row-step');
++ const glyph = G[s.glyph] ? s.glyph : s.kind === 'child' ? 'child' : s.kind === 'file' ? 'file' : 'tool';
++ line.append(svg(glyph), el('span', '', s.title || (s.tool_call && s.tool_call.name) || (s.kind === 'file' ? s.text : s.kind === 'child' ? 'A helper took part of the work' : 'A step')));
++ if (s.status === 'running') line.append(el('i', 'ovr-pulse'));
++ if (s.status === 'failed') line.append(svg('x', 'ovr-fail'));
++ n.append(line);
++ if (s.text && s.kind === 'tool') n.append(words(s.text));
++ if (s.kind === 'child' && s.child_run) { n.classList.add('is-link'); n.addEventListener('click', () => open(s.child_run)); }
++ return n;
++ }
++ function cardFor(n, s) {
++ if (!s.ui || !s.ui.type) return;
++ const render = CARDS.get(s.ui.type); if (!render) return;
++ try { const c = render(s.ui.data || {}, s, { open }); if (c) { c.classList && c.classList.add('ovr-card'); n.append(c); } } catch { /* the words above say it */ }
++ }
++ function attachments(n, s) {
++ const list = Array.isArray(s.attachments) ? s.attachments : []; if (!list.length) return;
++ const row = el('div', 'ovr-files');
++ list.slice(0, 8).forEach((a) => {
++ const url = httpsUrl(a.url);
++ if (a.kind === 'image' && url) { const i = el('img', 'ovr-thumb'); i.src = url; i.alt = a.alt || ''; i.loading = 'lazy'; row.append(i); }
++ else { const c = el('span', 'ovr-chip'); c.append(svg('file'), el('span', '', a.name || a.kind)); row.append(c); }
++ });
++ 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 }; } };
++ if (o.open) open(o.open); else list();
++ return view;
++ }
++
++ let styled = false;
++ function styles() {
++ if (styled || document.getElementById('ovr-styles')) { styled = true; return; }
++ styled = true;
++ const st = document.createElement('style'); st.id = 'ovr-styles';
++ st.textContent = `
++.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-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)); }
++.ovr-filter:focus-visible, .ovr-row:focus-visible, .ovr-ib:focus-visible, .ovr-btn:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++@media (max-width: 380px) { .ovr-filter { font-size: 13px; gap: 5px; } .ovr-filter svg { width: 16px; height: 16px; } }
++.ovr-rows { flex: 0 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; display: grid; align-content: start; gap: 6px; margin: 0; padding: 2px 4px 10px; list-style: none; }
++.ovr-rows > li { display: grid; min-width: 0; }
++.ovr-rows > li.has-acts { border-radius: 16px; background: color-mix(in srgb, #f59e0b 7%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, #f59e0b 50%, transparent); }
++.ovr-rows > li.has-acts > .ovr-row { background: transparent; box-shadow: none; }
++.ovr-row-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 0 12px 12px 72px; }
++.ovr-row-acts .ovr-btn { min-height: 38px; font-size: 13.5px; }
++.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; }
++/* 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-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); }
++.ovr-row:active { transform: scale(.985); }
++@media (hover: hover) and (pointer: fine) { .ovr-row:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 8%, transparent); } }
++.ovr-row.is-ghost { min-height: 72px; animation: ovr-shimmer 1.4s ease-in-out infinite; cursor: default; }
++.ovr-row-t { flex: 1; min-width: 0; display: grid; gap: 3px; }
++.ovr-row-top { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
++.ovr-row-top b { flex: 1; min-width: 0; font-family: 'OV Brand', Nunito, Inter, sans-serif; font-size: 15px; font-weight: 800; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow-wrap: anywhere; }
++.ovr-row-top time { flex: none; font-size: 12px; color: var(--text-muted, #93a0b4); }
++.ovr-row-line { font-size: 13.5px; color: var(--text-secondary, #a8b3c4); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow-wrap: anywhere; line-height: 1.35; }
++.ovr-row-meta { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted, #93a0b4); }
++.ovr-row-meta svg { width: 14px; height: 14px; }
++.ovr-pill { font-style: normal; font-size: 11.5px; font-weight: 750; padding: 1px 8px; border-radius: 999px; margin-left: auto; }
++.ovr-pill.is-needs { color: #fbbf24; background: color-mix(in srgb, #f59e0b 16%, transparent); }
++.ovr-pill.is-working { color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
++.ovr-pill.is-failed { color: #f87171; background: color-mix(in srgb, #ef4444 14%, transparent); }
++.ovr-face { position: relative; flex: none; display: grid; place-items: center; border-radius: 30%; color: hsl(var(--ovr-h, 215) 92% 84%); background: linear-gradient(150deg, hsl(var(--ovr-h, 215) 72% 58% / .4), hsl(var(--ovr-h, 215) 70% 38% / .22)); box-shadow: inset 0 0 0 1px hsl(var(--ovr-h, 215) 70% 64% / .3); }
++:root[data-theme="light"] .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; }
++.ovr-face.is-needs { box-shadow: 0 0 0 2px #f59e0b; }
++.ovr-face.is-working { animation: ovr-breathe 2.4s ease-in-out infinite; }
++.ovr-doing { position: absolute; right: -4px; bottom: -4px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; color: #fff; background: linear-gradient(140deg, var(--accent, #3b82f6), color-mix(in srgb, var(--accent, #3b82f6) 45%, #a855f7)); box-shadow: 0 0 0 2px var(--bg-secondary, #121826); }
++.ovr-doing svg { width: 12px; height: 12px; stroke-width: 2.3; }
++.ovr-empty { display: flex; align-items: center; gap: 14px; margin: 6px 0; padding: 16px; border-radius: 16px; border: 1px dashed color-mix(in srgb, var(--text-muted, #93a0b4) 45%, transparent); }
++.ovr-empty > svg { width: 24px; height: 24px; color: var(--accent-light, #60a5fa); }
++.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-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)); }
++.ovr-thead { flex: none; display: flex; align-items: center; gap: 10px; padding: 4px 4px 10px; border-bottom: 1px solid var(--border, rgba(255,255,255,.07)); }
++.ovr-ib { all: unset; box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; color: var(--text-secondary, #a8b3c4); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); transition: background-color .15s, transform .12s; }
++.ovr-ib[hidden] { display: none; } .ovr-ib:active { transform: scale(.92); }
++@media (hover: hover) { .ovr-ib:hover { color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 13%, transparent); } }
++.ovr-who { flex: 1; min-width: 0; display: grid; gap: 1px; }
++.ovr-who b { font-family: 'OV Brand', Nunito, Inter, sans-serif; font-size: 15.5px; font-weight: 800; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
++.ovr-who small { font-size: 12.5px; color: var(--text-secondary, #a8b3c4); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
++.ovr.is-needs .ovr-who small { color: #fbbf24; }
++.ovr-thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; display: flex; flex-direction: column; gap: 10px; padding: 12px 4px; }
++.ovr-step { max-width: min(640px, 92%); animation: ovr-rise .26s cubic-bezier(.2,.8,.2,1) both; }
++.ovr-step.is-you { align-self: flex-end; }
++.ovr-bubble { padding: 9px 13px; border-radius: 18px 18px 6px 18px; color: var(--on-accent-strong, #fff); background: var(--accent-strong, var(--accent, #2563eb)); }
++.ovr-bubble .ovr-a { color: inherit; text-decoration-color: color-mix(in srgb, currentColor 55%, transparent); text-underline-offset: 2px; }
++.ovr-step.is-agent { align-self: flex-start; padding: 10px 14px; border-radius: 18px 18px 18px 6px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
++.ovr-step.is-writing .ovr-words > :last-child::after { content: ''; display: inline-block; width: .5em; height: 1em; margin-left: 2px; vertical-align: -2px; border-radius: 2px; background: currentColor; opacity: .5; animation: ovr-caret 1s steps(2) infinite; }
++.ovr-author { display: block; margin-bottom: 3px; font-size: 12px; font-weight: 750; color: var(--text-muted, #93a0b4); }
++.ovr-words { font-size: 15px; line-height: 1.5; overflow-wrap: anywhere; }
++.ovr-words p { margin: 0; } .ovr-words p + p, .ovr-words p + ul, .ovr-words ul + p, .ovr-words ol + p, .ovr-words p + ol { margin-top: 8px; }
++.ovr-words ul, .ovr-words ol { margin: 0; padding-left: 20px; } .ovr-words li + li { margin-top: 3px; }
++.ovr-a { color: var(--accent-light, #60a5fa); text-decoration: underline; text-underline-offset: 2px; }
++.ovr-ic { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: 1px 5px; border-radius: 6px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); }
++.ovr-cite { display: inline-grid; place-items: center; min-width: 18px; height: 18px; margin: 0 2px; padding: 0 4px; border-radius: 6px; font-size: 11px; font-weight: 800; text-decoration: none; vertical-align: 2px; color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
++.ovr-code { margin: 8px 0 0; padding: 10px 12px; border-radius: 12px; overflow-x: auto; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; background: color-mix(in srgb, #000 30%, transparent); }
++.ovr-sources { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
++.ovr-source { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 10px 4px 4px; border-radius: 999px; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); text-decoration: none; background: color-mix(in srgb, var(--text-primary, #e6edf7) 6%, transparent); }
++.ovr-source i { font-style: normal; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; font-size: 11px; font-weight: 800; color: var(--accent-light, #60a5fa); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
++.ovr-source span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; max-width: 220px; }
++.ovr-card { margin-top: 10px; }
++.ovr-chips { display: flex; flex-wrap: wrap; gap: 6px; }
++.ovr-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px 5px 8px; border-radius: 999px; font-size: 13px; background: color-mix(in srgb, var(--accent, #3b82f6) 13%, transparent); }
++.ovr-chip svg { width: 15px; height: 15px; color: var(--accent-light, #60a5fa); } .ovr-chip.is-undone { opacity: .5; text-decoration: line-through; }
++.ovr-table-wrap { overflow-x: auto; } .ovr-table { border-collapse: collapse; font-size: 13.5px; min-width: 100%; }
++.ovr-table th, .ovr-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border, rgba(255,255,255,.08)); } .ovr-table th { font-weight: 750; color: var(--text-secondary, #a8b3c4); }
++.ovr-linkcard { display: flex; gap: 10px; align-items: center; padding: 8px; border-radius: 14px; color: inherit; text-decoration: none; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); }
++.ovr-linkcard img { width: 64px; height: 48px; object-fit: cover; border-radius: 10px; } .ovr-linkcard-t { display: grid; min-width: 0; } .ovr-linkcard-t b { font-size: 14px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .ovr-linkcard-t small { font-size: 12px; color: var(--text-muted, #93a0b4); }
++.ovr-step.is-row, .ovr-step.is-think { align-self: flex-start; max-width: 100%; }
++.ovr-row-step, .ovr-step.is-think > summary { display: flex; align-items: center; gap: 8px; min-height: 30px; padding: 2px 8px; border-radius: 10px; font-size: 13.5px; color: var(--text-secondary, #a8b3c4); cursor: default; list-style: none; }
++.ovr-step.is-think > summary, details.ovr-step > .ovr-row-step { cursor: pointer; }
++.ovr-step.is-think > summary::-webkit-details-marker, details.ovr-step > summary::-webkit-details-marker { display: none; }
++.ovr-row-step svg, .ovr-step.is-think > summary svg { width: 16px; height: 16px; color: var(--text-muted, #93a0b4); }
++.ovr-step.is-row.is-running .ovr-row-step { color: var(--text-primary, #e6edf7); }
++.ovr-step.is-row.is-failed .ovr-row-step { color: #f87171; }
++.ovr-step.is-row.is-link { cursor: pointer; }
++.ovr-step.is-think .ovr-words, details.ovr-step.is-row .ovr-words { margin: 4px 0 2px 32px; font-size: 13.5px; color: var(--text-secondary, #a8b3c4); }
++.ovr-pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--accent-light, #60a5fa); animation: ovr-pulse 1.2s ease-in-out infinite; }
++.ovr-fail { width: 15px; height: 15px; color: #f87171; }
++.ovr-step.is-approval { align-self: stretch; max-width: 100%; padding: 12px 14px; border-radius: 16px; background: color-mix(in srgb, #f59e0b 8%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, #f59e0b 40%, transparent); }
++.ovr-approval-h { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } .ovr-approval-h svg { color: #fbbf24; }
++.ovr-approval-acts { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
++.ovr-btn { all: unset; box-sizing: border-box; display: inline-flex; align-items: center; min-height: 40px; padding: 0 16px; border-radius: 999px; cursor: pointer; font-weight: 750; font-size: 14px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); }
++.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; }
++@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); } }
++@keyframes ovr-pulse { 50% { opacity: .35; transform: scale(.7); } }
++@keyframes ovr-caret { 50% { opacity: 0; } }
++html[data-ov-motion=reduced] .ovr-row, html[data-ov-motion=reduced] .ovr-step { animation-name: ovr-fade; animation-delay: 0s; }
++@keyframes ovr-fade { from { opacity: 0; } }
++html[data-ov-motion=reduced] .ovr-face.is-working { animation-duration: 5s; }
++html[data-ov-motion=off] .ovr-row, html[data-ov-motion=off] .ovr-step, html[data-ov-motion=off] .ovr-face.is-working, html[data-ov-motion=off] .ovr-pulse, html[data-ov-motion=off] .ovr-row.is-ghost { animation: none; }
++`;
++ document.head.appendChild(st);
++ }
++
++ root.OpenVibeRun = { mount, card, version: 1 };
++ if (typeof module !== 'undefined' && module.exports) module.exports = root.OpenVibeRun;
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/package-lock.json b/package-lock.json
iindex d91b8f2..4ebe615 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.223.1",
++ "version": "4.224.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.223.1",
++ "version": "4.224.0",
"license": "MIT",
"dependencies": {
"clean-css": "^5.3.3",
ddiff --git a/package.json b/package.json
iindex 16d9da9..9442cde 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.223.1",
++ "version": "4.224.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
@@@ -151,6 +151,7 @@
"./ov-float": "./ov-float.js",
"./ov-combo": "./ov-combo.js",
"./ov-composer": "./ov-composer.js",
++ "./ov-run": "./ov-run.js",
"./ov-capture": "./ov-capture.js",
"./ov-ai-choice": "./ai-choice.js",
"./ov-ai-choice.js": "./ov-ai-choice.js",
ddiff --git a/scripts/sibling-revs.js b/scripts/sibling-revs.js
iindex f101b79..be1f798 100644
---- a/scripts/sibling-revs.js
++++ b/scripts/sibling-revs.js
@@@ -25,7 +25,9 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
// 2026-11-11: the Arrange editor, loaded only when someone presses Arrange.
'ov-arrange.js',
// 2026-11-10: soft pages (intent prefetch, cross-document view transitions); not loaded by navbar.js yet.
-- 'soft.js'];
++ 'soft.js',
++ // 4.224.0: Vibe's runs in the menu, and the one composer they write with.
++ 'ov-run.js', 'ov-composer.js'];
// The files dock.js and notification-ui.js load from beside themselves: their own tables, in their own files.
const OWN = { 'dock.js': { name: 'DOCK_REV', files: ['dock-vibe.js', 'dock-code.js', 'dock-mount.js', 'dock-runs.js', 'ai-marks-client.js', 'action-center.js', 'dock-timeline.js', 'dock-actor.js', 'dock-agent.js', 'dock-timer.js', 'dock-share.js', 'dock-card.js', 'dock-chat-edits.js'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
ddiff --git a/test/run-thread-chrome.test.js b/test/run-thread-chrome.test.js
nnew file mode 100644
iindex 0000000..dd2476f
---- /dev/null
++++ b/test/run-thread-chrome.test.js
@@@ -0,0 +1,165 @@
++'use strict';
++// The one run thread (ov-run.js, 4.224.0) in a real headless Chrome, against a stand-in for the Frame's Run API
++// (Network /api/frame/runs): the list puts what needs you first and filters by kind without losing the words being
++// typed; a thread draws your words, the agent's (links, numbered sources), thinking folded, a tool row, an approval card
++// and a results card; it follows the run live and resumes after its last step; an approval and a message reach the API
++// with the person's token; a new run starts from the list's box; page text never becomes markup; and nothing is wider
++// than a phone.
++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('run-thread-chrome: skipped (no Chrome; set CHROME_BIN)'); process.exit(0); }
++const root = path.join(__dirname, '..');
++const T = '2026-11-12T10:00:00Z';
++const RUNS = [
++ { id: 'c_1', ref: 'chat:c_1', kind: 'chat', title: 'Goal bar ideas', status: 'waiting', waiting: { for: 'message' }, agent: { id: 'vibe', name: 'Vibe', glyph: 'spark' }, preview: { text: 'Three ideas for your goal bar' }, updated_at: T, url: 'https://openvibe.chat/vibe/c_1' },
++ { id: 't_9', ref: 'actor:t_9', kind: 'task', title: 'Post the clip', status: 'waiting', waiting: { for: 'approval', what: 'Post it to your channel?', approval: 'apr_9' }, agent: { id: 'ag_1', name: 'Clipper', hue: 140 }, updated_at: '2026-11-12T09:00:00Z' },
++ { id: 'k_3', ref: 'codes:k_3', kind: 'code', title: 'Fix the overlay', status: 'running', doing: { text: 'Running the tests', glyph: 'code' }, agent: { id: 'codes', name: 'Codes', glyph: 'code' }, updated_at: '2026-11-12T09:30:00Z' },
++];
++const STEPS = [
++ { seq: 1, kind: 'message', from: 'person', text: 'Ideas for a goal bar <img src=x onerror="window.pwned=1">' },
++ { seq: 2, kind: 'think', title: 'Thinking it over', text: 'A bar that fills as the goal nears.' },
++ { seq: 3, kind: 'tool', title: 'Searched the web', glyph: 'search', status: 'done' },
++ { seq: 4, kind: 'message', from: 'agent', text: 'Here are three:\n\n- A **filling** bar [1]\n- A ring\n- See https://openvibe.live/docs/whip', sources: [{ n: 1, url: 'https://example.org/bars', title: 'Bars' }, { n: 2, url: 'javascript:alert(1)', title: 'Bad' }] },
++ { 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' }] } },
++];
++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.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)); };
++ const rest = decodeURIComponent(u.pathname.slice('/api/frame/runs'.length));
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ if (req.method === 'POST') seen.posts.push({ path: rest, body: body ? JSON.parse(body) : null, key: req.headers['idempotency-key'] || '' });
++ if (rest === '' && req.method === 'GET') { const k = u.searchParams.get('kind'); seen.kinds.push(k || 'all'); return json(200, { runs: k ? RUNS.filter((r) => r.kind === k) : RUNS, next: null, partial: k ? [] : ['codes'] }); }
++ if (rest === '' && req.method === 'POST') return json(201, { ...RUNS[0], id: 'c_2', ref: 'chat:c_2', title: 'A new one' });
++ if (/^\/chat:c_[12]$/.test(rest)) return json(200, RUNS[0]);
++ if (/^\/chat:c_[12]\/steps$/.test(rest)) return json(200, { steps: rest.includes('c_2') ? [] : STEPS, next: null });
++ if (rest === '/chat:c_1/messages') return json(201, { seq: 8, kind: 'message', from: 'person', text: JSON.parse(body).text });
++ if (rest === '/chat:c_1/approvals/ap_1' || rest === '/actor:t_9/approvals/apr_9') return json(200, { ok: true });
++ if (/\/events$/.test(rest)) {
++ seen.lastIds.push(req.headers['last-event-id'] || '');
++ res.writeHead(200, { 'content-type': 'text/event-stream' });
++ if (seen.lastIds.length === 1) {
++ res.write(`id: 7\nevent: step\ndata: ${JSON.stringify({ type: 'step', step: { seq: 7, kind: 'message', from: 'agent', text: 'One more thing.' } })}\n\n`);
++ res.write(`id: 7\nevent: run\ndata: ${JSON.stringify({ type: 'run', run: { ...RUNS[0], title: 'Goal bar ideas, live' } })}\n\n`);
++ }
++ return setTimeout(() => res.end(), 150);
++ }
++ json(404, { error: 'No such run' });
++ });
++});
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-run-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ for (const width of [390, 1280]) {
++ seen.kinds.length = 0; seen.lastIds.length = 0; seen.posts.length = 0;
++ const page = await h.openPage(browser, await browser.newContext(), { width, height: 820 });
++ await page.goto(`${base}/`, { maxMs: 6000 });
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++ const until = async (expr, ms = 5000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await page.evaluate(expr); if (v) return v; await wait(50); } return false; };
++ const wide = () => page.evaluate('document.documentElement.scrollWidth > innerWidth + 1');
++ await page.evaluate(`window.view = OpenVibeRun.mount(document.getElementById('host'), { base: '${base}/api/frame', token: () => 'tok_1', me: { name: 'Sam' } })`);
++
++ // ── the list ──
++ assert.ok(await until(`document.querySelectorAll('.ovr-rows .ovr-row:not(.is-ghost)').length === 3`), `${width}: three runs`);
++ const list = await page.evaluate(`({ titles: [...document.querySelectorAll('.ovr-row-top b')].map((b) => b.textContent),
++ needs: [...document.querySelectorAll('.ovr-row')].map((r) => r.classList.contains('is-needs')),
++ filters: [...document.querySelectorAll('.ovr-filter')].map((b) => b.textContent + (b.getAttribute('aria-selected') === 'true' ? '*' : '')),
++ pills: [...document.querySelectorAll('.ovr-pill')].map((p) => p.textContent), note: (document.querySelector('.ovr-list-note .ovr-note') || {}).textContent,
++ lines: [...document.querySelectorAll('.ovr-row-line')].map((l) => l.textContent), box: !!document.querySelector('.ovr-compose form') })`);
++ assert.deepEqual(list.titles, ['Goal bar ideas', 'Post the clip', 'Fix the overlay'], `${width}: in the API's order (it puts needs-you first)`);
++ assert.deepEqual(list.needs, [false, true, false], `${width}: the run that needs you is marked`);
++ assert.deepEqual(list.filters, ['All*', 'Chats', 'Tasks', 'Code'], `${width}: All · Chats · Tasks · Code, All chosen`);
++ assert.deepEqual(list.pills, ['Needs you', 'Working'], `${width}: needs you and working, as pills`);
++ assert.deepEqual(list.lines, ['Three ideas for your goal bar', 'Post it to your channel?', 'Running the tests'], `${width}: a waiting run says what it waits for; a working one, what it does`);
++ assert.match(list.note, /did not load \(codes\)/, `${width}: a product that did not answer is named`);
++ assert.ok(list.box, `${width}: a box to start a new run (the plain one: no composer beside it here)`);
++ assert.equal(await wide(), false, `${width}: nothing wider than the screen`);
++ const shot = async (name) => { if (!process.env.OV_CAPTURE_RUN) return; await wait(450); const r = await page.send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(process.env.OV_CAPTURE_RUN, `${name}-${width}.png`), Buffer.from(r.data, 'base64')); };
++ await shot('list');
++ // A run that asks before it acts is answered from the list.
++ assert.deepEqual(await page.evaluate(`[...document.querySelectorAll('.ovr-row-acts .ovr-btn')].map((b) => b.textContent)`), ['Approve', 'Not now'], `${width}: approve in place, on the run that asks`);
++ await page.evaluate(`document.querySelector('.ovr-row-acts .ovr-btn').click()`);
++ assert.ok(await until(`(document.querySelector('.ovr-row-acts') || {}).textContent === 'Approved'`), `${width}: the answer replaces the buttons`);
++ assert.deepEqual(seen.posts.find((x) => x.path === '/actor:t_9/approvals/apr_9').body, { answer: 'approve' }, `${width}: and reaches the API`);
++ // A filter reloads the rows only: the words being typed stay.
++ await page.evaluate(`document.querySelector('.ovr-compose textarea').value = 'half typed'; 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 shows the coding run`);
++ assert.deepEqual(seen.kinds, ['all', 'code'], `${width}: the filter asks the API by kind`);
++ assert.equal(await page.evaluate(`document.querySelector('.ovr-compose textarea').value`), 'half typed', `${width}: the box keeps its words across a filter`);
++ assert.equal(await page.evaluate(`document.querySelector('.ovr-filter[aria-selected="true"]').textContent`), 'Code', `${width}: Code is chosen`);
++ await page.evaluate(`document.querySelector('.ovr-filter[data-filter="all"]').click()`);
++ assert.ok(await until(`document.querySelectorAll('.ovr-rows .ovr-row:not(.is-ghost)').length === 3`), `${width}: back to all`);
++
++ // ── a thread ──
++ await page.evaluate(`document.querySelector('.ovr-row').click()`);
++ assert.ok(await until(`document.querySelectorAll('.ovr-thread .ovr-step').length >= 6`), `${width}: the thread draws its steps`);
++ const t = await page.evaluate(`(() => { const q = (s) => document.querySelector(s);
++ return { you: q('.ovr-step.is-you').textContent, think: q('details.ovr-step.is-think > summary').textContent, thinkOpen: q('details.ovr-step.is-think').open,
++ tool: q('.ovr-step.is-row.is-tool').textContent, bullets: [...document.querySelectorAll('.ovr-step.is-agent li')].map((l) => l.textContent),
++ strong: (q('.ovr-step.is-agent strong') || {}).textContent, link: (q('.ovr-step.is-agent .ovr-a') || {}).href, cite: (q('.ovr-cite') || {}).href,
++ sources: [...document.querySelectorAll('.ovr-source')].map((a) => a.href), chips: [...document.querySelectorAll('.ovr-chip')].map((c) => c.textContent),
++ approve: [...document.querySelectorAll('.ovr-approval-acts .ovr-btn')].map((b) => b.textContent), title: q('.ovr-who b').textContent,
++ away: q('.ovr-thead a.ovr-ib').href, imgs: document.querySelectorAll('.ovr-thread img').length, pwned: !!window.pwned }; })()`);
++ assert.equal(t.you, 'Ideas for a goal bar <img src=x onerror="window.pwned=1">', `${width}: your words, as text`);
++ assert.equal(t.imgs, 0, `${width}: page text never becomes markup`);
++ assert.equal(t.pwned, false, `${width}: nothing ran`);
++ assert.deepEqual([t.think, t.thinkOpen], ['Thinking it over', false], `${width}: thinking is folded`);
++ assert.equal(t.tool, 'Searched the web', `${width}: a tool is one quiet row`);
++ assert.deepEqual(t.bullets, ['A filling bar 1', 'A ring', 'See https://openvibe.live/docs/whip'], `${width}: a list, with its citation`);
++ assert.equal(t.strong, 'filling', `${width}: bold words`);
++ assert.equal(t.link, 'https://openvibe.live/docs/whip', `${width}: an address is a link`);
++ assert.equal(t.cite, 'https://example.org/bars', `${width}: [1] links to its source`);
++ assert.deepEqual(t.sources, ['https://example.org/bars'], `${width}: sources listed, a javascript: one left out`);
++ assert.deepEqual(t.chips, ['Timer, 10 min'], `${width}: a results card`);
++ assert.deepEqual(t.approve, ['Post it', 'Not now'], `${width}: an approval card with its answers`);
++ assert.equal(t.away, 'https://openvibe.chat/vibe/c_1', `${width}: its own page, one press away`);
++ await shot('thread');
++ // Live: the stream adds step 7 and renames the run; the next connection resumes after it.
++ assert.ok(await until(`[...document.querySelectorAll('.ovr-step.is-agent')].some((s) => s.textContent === 'One more thing.')`), `${width}: a step arrives live`);
++ assert.ok(await until(`document.querySelector('.ovr-who b').textContent === 'Goal bar ideas, live'`), `${width}: the run's head follows it`);
++ assert.ok(await until(`true`) && await (async () => { const end = Date.now() + 5000; while (Date.now() < end && seen.lastIds.length < 2) await wait(50); return seen.lastIds.length >= 2; })(), `${width}: it reconnects`);
++ assert.deepEqual(seen.lastIds.slice(0, 2), ['6', '7'], `${width}: from after the last step it has, then after the one that came live`);
++ // An approval.
++ await page.evaluate(`document.querySelector('.ovr-approval-acts .ovr-btn').click()`);
++ assert.ok(await until(`/You answered: Post it/.test(document.querySelector('.ovr-step.is-approval').textContent)`), `${width}: the answer replaces the buttons`);
++ assert.deepEqual(seen.posts.find((p) => p.path === '/chat:c_1/approvals/ap_1').body, { answer: 'approve' }, `${width}: the answer reaches the API`);
++ // The next thing said.
++ await page.evaluate(`document.querySelector('.ovr-compose textarea').value = 'And a ring?'; document.querySelector('.ovr-compose form').requestSubmit()`);
++ assert.ok(await until(`[...document.querySelectorAll('.ovr-step.is-you')].some((s) => s.textContent === 'And a ring?')`), `${width}: your next words join the thread`);
++ assert.deepEqual(seen.posts.find((p) => p.path === '/chat:c_1/messages').body, { text: 'And a ring?' }, `${width}: and reach the API`);
++ assert.equal(await wide(), false, `${width}: the thread is no wider than the screen`);
++
++ // ── back, and a new run from the list's box ──
++ 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`);
++ 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)`);
++ 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`);
++ 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');
++ } finally {
++ await browser.close();
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exit(1); });