openvibers/openvibe.shared Commits
openvibe-shared 4.224.0: one thread view for every run (ov-run.js) (#502)
Changes
@@@ -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@@@ -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 deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.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)",@@@ -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@@@ -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.nnew file mode 100644@@@ -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);@@@ -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",@@@ -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",@@@ -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'] },nnew file mode 100644@@@ -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); });
@@ -1,8 +1,48 @@
@@ -1,8 +1,48 @@
# Changelog
# 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
## 4.223.1 — 2026-11-12
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.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
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.223.1",
+ "version": "4.224.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
@@ -58,6 +58,7 @@ const BROWSER = Object.freeze([
@@ -58,6 +58,7 @@ const BROWSER = Object.freeze([
'messages.js', // Chat's two views: room lines and texting bubbles (openvibe-shared/messages)
'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-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-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-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-combo.js', // shared field dropdown
'ov-window.js', // shared dialog, sheet, and panel
'ov-window.js', // shared dialog, sheet, and panel
@@ -604,7 +604,7 @@
@@ -604,7 +604,7 @@
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
* serves its own pinned copy at /shared/ gets its own), else the network's copy.
*/
*/
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'f34f731c3a94', 'ov-glyphs.js': 'c96584376da1', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': 'dd84a30bb646', 'ov-fx.js': '947820d69097', 'ov-labels.js': 'd40f7ca14bc4', 'ov-forms.js': '2eb9e2604c3e', 'ov-forms.css': '06208fe89eac', 'ov-share.js': 'd419508a797e', 'ov-share.css': 'd609a71f611a', 'search.js': 'b5f1f966bae3', 'dock.js': '1fb16839cb0e', 'presence.js': '5f195d41f8c5', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'a94e76bd1200', 'notification-ui.js': '4086b813ffda', 'history.js': 'f9fb9e096c65', 'bottom.js': '1671b6949137', 'appbar.js': 'c21485176ae9', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'aaa688bf6fc7', 'sso-client.js': 'f574ac3331a5', 'overlay.js': '2bc56b3cf726', 'ov-home.js': '6215643437c0', 'nav-slots.js': '7b11e6916732', 'ov-wallet.js': '3fa6bb23b429', 'ov-wallet.css': '4923c808c9ad', 'purse.js': '9d3fea0dae02', 'ov-drop.js': '445689a5eb80', 'ov-tip.js': '1d918e7de69d', 'ov-arrange.js': 'c8da956d1e28', 'soft.js': '98ab9b8080b1' };
+ 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]}` : ''}`;
const siblingV = (name) => `${sibling(name)}${SIBLING_REV[name] ? `?v=${SIBLING_REV[name]}` : ''}`;
// ov-tip.js loads at the first hover over a tooltip's owner.
// ov-tip.js loads at the first hover over a tooltip's owner.
new file mode 100644
new file mode 100644
@@ -0,0 +1,609 @@
@@ -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);
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.223.1",
+ "version": "4.224.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.223.1",
+ "version": "4.224.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"clean-css": "^5.3.3",
"clean-css": "^5.3.3",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.223.1",
+ "version": "4.224.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
@@ -151,6 +151,7 @@
@@ -151,6 +151,7 @@
"./ov-float": "./ov-float.js",
"./ov-float": "./ov-float.js",
"./ov-combo": "./ov-combo.js",
"./ov-combo": "./ov-combo.js",
"./ov-composer": "./ov-composer.js",
"./ov-composer": "./ov-composer.js",
+ "./ov-run": "./ov-run.js",
"./ov-capture": "./ov-capture.js",
"./ov-capture": "./ov-capture.js",
"./ov-ai-choice": "./ai-choice.js",
"./ov-ai-choice": "./ai-choice.js",
"./ov-ai-choice.js": "./ov-ai-choice.js",
"./ov-ai-choice.js": "./ov-ai-choice.js",
@@ -25,7 +25,9 @@ const FILES = ['ov-mark.js', 'ov-glyphs.js', 'ov-brand.woff2', 'ov-look.js', 'ov
@@ -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.
// 2026-11-11: the Arrange editor, loaded only when someone presses Arrange.
'ov-arrange.js',
'ov-arrange.js',
// 2026-11-10: soft pages (intent prefetch, cross-document view transitions); not loaded by navbar.js yet.
// 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.
// 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'] },
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'] },
'notification-ui.js': { name: 'NOTIFY_REV', files: ['notification-live.js'] },
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -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); });