openvibers/openvibe.shared Commits
4.99.0: the conversation UI shows several agents, approvals and what the work produced (#258)
Changes
@@@ -4,6 +4,28 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.99.0 — 2026-11-02++++- **The conversation UI shows several agents, approvals and what the work produced** (plan L55 C4 stage 3, for Codes):++ - `turn({ who })` heads a reply with an agent's face (initials in its colour) and name;++ - `divider()` draws a hand-over between agents;++ - `card({ form, code, icon })` asks and posts without script (an approval: Allow or Deny);++ - `panel()` sits beside the conversation: a column from 1180 px, a drawer from the right below;++ - `page({ panel, main })` places the panel, or a plain page, in the conversation's place;++ - `sidebar({ pinned })` keeps chats above the groups, and an item takes a `dot` and a quiet `meta` word;++ - `sidebar({ findSubmit })`: Enter in Find a chat runs the site's own search (every message). Without it the form is++ filter-only, as before; the kit keeps Enter on the page only for a filter-only form, so a site that draws++ `kit.sidebar` itself (Codes) searches again on Enter;++ - `you.caption` puts a small word over the person's bubble;++ - a work row takes an `icon` (`read`, `find`, `edit`, `run`, `web`, `sub`), and a finished tool with no output is a++ plain row rather than one that opens on "Running…";++ - `composer({ id, attrs, foot })`: the form's id and the site's data attributes, and markup under the bar. The note++ line is always drawn and is the form's status line (`role="status"`);++ - `page({ center })` is a start page: the welcome and the composer sit together until there are turns. `cls` adds++ the site's classes;++ - `OVThread.tool(id, { name, input, icon, key })` and `OVThread.toolDone(id, ok, key)` stream a tool row built from++ text; a result finds its own row by key (tools run side by side), and `ok: null` is a step that ended with no result.++- 4.96.0's own sidebar code is gone from thread.css and ov-thread.js. `thread.sidebar` has drawn `kit.sidebar` since 4.97.0.## 4.98.0 — 2026-11-02- **Solid drawings for ten more glyphs (plan L49/L50).** The places and things the sites use most that had none@@@ -2,7 +2,7 @@> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.--**Status:** alpha, v4.98.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.99.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.98.0",++ "version": "4.99.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)",@@@ -14,21 +14,46 @@ so a conversation looks and behaves the same on every OpenVibe site.| Function | What it draws ||---|---|--| `page({ sidebar, head, turns, empty, composer })` | The screen: the chats beside the conversation; the composer stays at the bottom |--| `sidebar({ newHref, groups, nav, foot })` | New chat, Find a chat, the chats grouped Today / Yesterday / This week / Earlier, footer links. A column on a wide screen, a drawer on a phone |++| `page({ sidebar, head, turns, empty, composer, panel, main, center, cls })` | The screen: the chats beside the conversation; the composer stays at the bottom. `panel` goes beside it; `main` is a plain page in the conversation's place; `center` puts the welcome and the composer together in the middle while there are no turns |++| `sidebar({ newHref, groups, pinned, nav, foot })` | New chat, Find a chat, pinned chats, the chats grouped Today / Yesterday / This week / Earlier, footer links. A column on a wide screen, a drawer on a phone || `group(items, whenOf)` | Items, newest first, into those groups |--| `head({ title, newHref, actions })` | The sticky head: the drawer's button (phones), the title, New chat |--| `turn({ id, state, open, you, reply })` | One turn: the person's bubble (with files) and the reply |--| `work({ open, live, took, rows })` | The work folded under the answer: "Worked for 12 s · 3 tools", or the live line while it works |++| `head({ title, newHref, actions, panel })` | The sticky head: the drawer's button (phones), the title, New chat, and the panel's button where the panel is a drawer |++| `turn({ id, state, open, you, reply, who })` | One turn: the person's bubble (with files and a caption) and the reply. `who` heads the reply with an agent's face and name |++| `divider({ text, icon })` | A line across the thread: a hand-over to another agent, a wait |++| `work({ open, live, took, rows })` | The work folded under the answer: "Worked for 12 s · 3 tools", or the live line while it works. A row can carry an `icon` || `answer({ html, files, sources, meta, actions })` | The answer, its files, its sources as quiet links, and a footer with Copy |--| `card({ tone, title, text, html })` | A question waiting for the person (`wait`), a failure (`bad`), or a note (`info`) |--| `composer({ action, busy, stopAction, attach, tools, starters, note })` | The composer: grows as you type, the site's chips, Send, and Stop while a turn works |++| `card({ tone, title, text, html, icon, code, form })` | A question waiting for the person (`wait`), a failure (`bad`), or a note (`info`). With `form` it asks, and posts without script (an approval: Allow or Deny) |++| `panel({ id, title, sections })` | What the work produced, beside the conversation: a third column from 1180 px, a drawer from the right below |++| `composer({ action, busy, stopAction, attach, tools, starters, note, id, attrs, foot })` | The composer: grows as you type, the site's chips, Send, and Stop while a turn works. `foot` is markup under the bar; the note line is its status line |A `rows` entry for `work()` is one of:- `{ kind: 'step', text }`;- `{ kind: 'note', text, level }`;- `{ kind: 'you', text }`, something the person said while it worked;--- `{ kind: 'tool', name, input, output, ok, running }`.++- `{ kind: 'tool', name, input, output, ok, running, icon }`. A finished tool with no output is a plain row.++++A row's `icon` is one of the thread's icons: a coding agent's `read`, `find`, `edit`, `run`, `web` and `sub`, or `tool`.++++## Several agents, approvals and the panel (4.99.0)++++Codes hands a task from one agent to another inside one conversation. These parts show that:++++```js++thread.turn({ who: { name: 'Codex', initials: 'Cx', color: '#10a37f', sub: 'gpt-5 · Thorough' }, reply });++thread.divider({ text: 'Codex took over: Claude Code ran out of turns' });++thread.card({ tone: 'wait', icon: 'shield', title: 'Bash', text: 'wants to run', code: 'npm test',++ form: { action: '/tasks/1/approvals/9', buttons: [{ name: 'decision', value: 'allow', label: 'Allow' },++ { name: 'decision', value: 'deny', label: 'Deny', quiet: true }] } });++thread.page({ sidebar, head: thread.head({ title, panel: 'side', panelLabel: 'What it did' }), turns, composer,++ panel: thread.panel({ id: 'side', title: 'What it did', sections: [{ title: 'Agents', rows: [{ who, title, sub }] }] }) });++```++++- **Colours:** an agent's colour is only ever a colour: `#rgb` to `#rrggbbaa`, or a theme token (`var(--accent)`).++ Anything else is dropped.++- **The panel:**++ - it is a column from 1180 px;++ - below that, the head's button opens it as a drawer from the right;++ - its scrim, its close button and Escape close it, and the focus returns to the button.## Streaming@@@ -38,6 +63,8 @@ OVThread.live(id, 'Reading'); // the working turn's live lineOVThread.work(id, rowHtml); // a row in its work listOVThread.replace(id, html); // the finished turn; its answer types in, word by wordOVThread.reveal(answerEl); // the same reveal, for an answer a site draws itself++OVThread.tool(id, { name: 'Bash', input: 'npm test', icon: 'run' }); // a running tool row, built from text++OVThread.toolDone(id, ok); // the last running tool row finished (false: it failed)```A site renders those strings on the server (`GET /chat/turn/:id`, as Actor does) or in the page. The helpers keep the@@@ -12,6 +12,9 @@* OVThread.work(id, html) append a row to the turn's work list* OVThread.enhance(el) Copy buttons for answers and code under el (add/replace call it)* OVThread.reveal(answerEl) type an answer in, word by word (replace does it when a working turn finishes)++ * OVThread.tool(id, { name, input, icon, key }) a running tool row in the turn's work list (built here, text only)++ * OVThread.toolDone(id, ok, key) that tool's row finished (the row with key, else the last running one); ok false: it++ * failed, null: it ended with no result (no mark)* Booted on load and after an in-place page swap (openvibe-shared/boost's ov:boost:load).*/(function (root) {@@@ -128,6 +131,27 @@})();}++ // The icons a streamed tool row can carry (thread.js draws the same ones on the server).++ var ICONS = {++ tool: 'M14.7 6.3a4 4 0 0 0-5.4 5.4L4 17l3 3 5.3-5.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2.4-.6-.6-2.4z',++ read: 'M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',++ find: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-3.5-3.5',++ edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4',++ run: 'M3 4h18v16H3zM7 9l3 3-3 3M13 15h4',++ web: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18',++ sub: 'M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18',++ ok: 'M5 12l5 5 9-10',++ bad: 'M6 6l12 12M18 6L6 18',++ };++ function svgIcon(name) {++ var ns = 'http://www.w3.org/2000/svg';++ var s = document.createElementNS(ns, 'svg');++ s.setAttribute('class', 'ovt-ic'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true');++ var p = document.createElementNS(ns, 'path'); p.setAttribute('d', ICONS[name] || ICONS.tool);++ s.appendChild(p);++ return s;++ }++function threadEl() { return document.querySelector('[data-ovt] [data-ovt-thread]'); }function turnEl(id) { return document.querySelector('[data-ovt] [data-turn="' + String(id).replace(/"/g, '') + '"]'); }@@@ -164,6 +188,30 @@var line = q(turnEl(id), '[data-ovt-live]');if (line) line.textContent = String(text) + '…';},++ tool: function (id, t) {++ t = t || {};++ var li = document.createElement('li');++ li.className = 'ovt-w-tool is-run';++ if (t.key != null) li.setAttribute('data-key', String(t.key));++ var row = document.createElement('div');++ row.className = 'ovt-w-row';++ var b = document.createElement('b'); b.textContent = String(t.name || 'Tool');++ row.appendChild(svgIcon(t.icon)); row.appendChild(b);++ if (t.input) { var inp = document.createElement('span'); inp.className = 'ovt-w-in'; inp.textContent = String(t.input).slice(0, 200); row.appendChild(inp); }++ var st = document.createElement('span'); st.className = 'ovt-w-st';++ var dot = document.createElement('span'); dot.className = 'ovt-dot'; dot.setAttribute('aria-hidden', 'true');++ st.appendChild(dot); row.appendChild(st); li.appendChild(row);++ return api.work(id, li);++ },++ toolDone: function (id, ok, key) {++ var rows = qa(turnEl(id), '[data-ovt-work] > .ovt-w-tool.is-run');++ var li = key != null ? rows.filter(function (r) { return r.getAttribute('data-key') === String(key); })[0] : rows[rows.length - 1];++ if (!li) return null;++ li.classList.remove('is-run'); li.classList.add(ok === null ? 'is-done' : ok === false ? 'is-bad' : 'is-ok');++ var st = q(li, '.ovt-w-st');++ if (st) { st.textContent = ''; if (ok !== null) st.appendChild(svgIcon(ok === false ? 'bad' : 'ok')); }++ return li;++ },work: function (id, html) {var list = q(turnEl(id), '[data-ovt-work]');var el = frag(html);@@@ -197,40 +245,37 @@root.addEventListener('resize', onScroll);setTimeout(measure, 400);-- // The drawer on a phone.-- var opener = q(box, '[data-ovt-open]');-- var scrim = q(box, '.ovt-scrim');-- var closeBtn = q(box, '.ovt-side-close');-- function drawer(on) {-- box.classList.toggle('is-drawer', on);-- if (scrim) scrim.hidden = !on;-- if (closeBtn) closeBtn.hidden = !on;-- if (opener) opener.setAttribute('aria-expanded', on ? 'true' : 'false');-- if (on) { var f = q(box, '[data-ovt-find]'); if (f && fine) f.focus(); }-- else if (opener && document.activeElement && box.querySelector('.ovt-side').contains(document.activeElement)) opener.focus();++ // The panel (thread.panel): a drawer from the right below 1180 px, opened from the head's button; its scrim, its++ // close button and Escape close it, and the focus goes in and comes back.++ var pan = q(box, '[data-ovt-panel]');++ var panOpen = q(box, '[data-ovt-panel-open]');++ var panScrim = q(box, '[data-ovt-panel-scrim]');++ function panel(on) {++ if (!pan) return;++ box.classList.toggle('is-panel', on);++ if (panScrim) panScrim.hidden = !on;++ if (panOpen) panOpen.setAttribute('aria-expanded', on ? 'true' : 'false');++ if (on) { var c = q(pan, '[data-ovt-panel-close]'); if (c) c.focus(); }++ else if (panOpen && document.activeElement && pan.contains(document.activeElement)) panOpen.focus();++ }++ if (pan) {++ if (panOpen) panOpen.addEventListener('click', function () { panel(!box.classList.contains('is-panel')); });++ qa(box, '[data-ovt-panel-close], [data-ovt-panel-scrim]').forEach(function (el) { el.addEventListener('click', function () { panel(false); }); });++ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && box.classList.contains('is-panel')) panel(false); });}-- if (opener) opener.addEventListener('click', function () { drawer(true); });-- qa(box, '[data-ovt-close]').forEach(function (el) { el.addEventListener('click', function () { drawer(false); }); });-- document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && box.classList.contains('is-drawer')) drawer(false); });-- // Find a chat, in the network's sidebar (kit.sidebar): it filters as you type, and Enter keeps you here.++ // Find a chat, in the network's sidebar (kit.sidebar): it filters as you type. Enter keeps you here when the++ // form is filter-only (thread.sidebar's default); otherwise it runs the site's own search (every message).var kfind = q(box, '.ovk-sidebar-find input');if (kfind) {var kform = kfind.closest('form');-- if (kform) kform.addEventListener('submit', function (e) { e.preventDefault(); });++ if (kform && kform.hasAttribute('data-ovt-filter-only')) kform.addEventListener('submit', function (e) { e.preventDefault(); });kfind.addEventListener('input', function () {var term = kfind.value.trim().toLowerCase();qa(box, '.ovk-sidebar-secs .ovk-sidebar-item').forEach(function (a) { var tt = q(a, '.ovk-sidebar-t'); a.hidden = !!term && (!tt || tt.textContent.toLowerCase().indexOf(term) === -1); });qa(box, '.ovk-sidebar-secs .ovk-sidebar-sec').forEach(function (sec) { sec.hidden = !!term && !sec.querySelector('.ovk-sidebar-item:not([hidden])'); });});}-- // Find a chat.-- var find = q(box, '[data-ovt-find]');-- if (find) find.addEventListener('input', function () {-- var term = find.value.trim().toLowerCase();-- qa(box, '.ovt-list li').forEach(function (li) { var a = li.querySelector('a'); li.hidden = !!term && (!a || (a.getAttribute('data-title') || '').indexOf(term) === -1); });-- qa(box, '.ovt-group').forEach(function (g) { var ul = g.nextElementSibling; g.hidden = !!ul && !ul.querySelector('li:not([hidden])'); });-- });// The composer.var form = q(box, '[data-ovt-composer]');@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.98.0",++ "version": "4.99.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -25,6 +25,7 @@ assert.ok(side.startsWith('<aside class="ovk-sidebar" id="ovt-chats" aria-label=assert.ok(side.includes('<a class="ovk-sidebar-item is-here" href="/chat/1" aria-current="page"><span class="ovk-sidebar-t">Plan <b>a</b> weekend</span><i class="ovk-sidebar-dot is-busy" aria-hidden="true"></i></a>'), 'escaped, current, live');assert.ok(side.includes('<a class="ovk-sidebar-action" href="/chat">') && side.includes('name="q" placeholder="Find a chat"') && side.includes('<p class="ovt-side-foot"><a href="/memory">Memory</a></p>'));assert.ok(t.sidebar({ groups: [] }).includes('<p class="ovk-sidebar-empty">Your chats show here.</p>'), 'the empty line');++assert.ok(side.includes('<form class="ovk-sidebar-find" data-ovt-filter-only') && !t.sidebar({ findSubmit: true, findAction: '/vibe' }).includes('data-ovt-filter-only'), 'filter-only unless the site searches every message');assert.ok(t.sidebar({ nav: [{ href: '/console', label: 'Console', icon: 'grid' }] }).includes('<nav class="ovk-sidebar-nav"'), 'the site\'s places as rows');const headHtml = t.head({ title: 'A <title>', newHref: '/chat' });assert.ok(headHtml.includes('<h1 class="ovt-title">A <title></h1>') && headHtml.includes('data-ovk-sidebar-open aria-controls="ovt-chats"'), 'the kit\'s button opens the chats on a phone');@@@ -65,6 +66,43 @@ const page = t.page({ sidebar: side, head: t.head({ title: 'Chat' }), turns: [],assert.ok(page.startsWith('<div class="ovt" data-ovt>') && page.includes('<div class="ovt-empty-state"><h2>What can I do for you?</h2></div><ol class="ovt-thread" data-ovt-thread></ol>'));assert.ok(page.includes('</aside><div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>'), 'the kit\'s scrim closes the drawer');++// ── 4.99.0: the parts Codes needs (several agents in one conversation, approvals, what the work produced) ──────++const pinnedSide = t.sidebar({ pinned: [{ href: '/tasks/c', title: 'Conductor', dot: 'needs' }], groups: [{ name: 'Today', items: [{ href: '/tasks/1', title: 'Fix', meta: '2h' }] }], findPlaceholder: 'Search conversations' });++assert.ok(/<h2 class="ovk-sidebar-h">Pinned<\/h2>[\s\S]*Conductor[\s\S]*is-needs[\s\S]*Today/.test(pinnedSide) || (pinnedSide.indexOf('Pinned') > -1 && pinnedSide.indexOf('Pinned') < pinnedSide.indexOf('Today') && pinnedSide.includes('is-needs')), 'pinned chats above the groups, with their dot');++assert.ok(pinnedSide.includes('<span class="ovk-sidebar-m">2h</span>') && pinnedSide.includes('placeholder="Search conversations"'), 'a quiet word after the title, and the find box\'s words');++const agentTurn = t.turn({ id: 'a1', who: { name: 'Codex <x>', initials: 'Cxy', color: '#10a37f', sub: 'gpt-5 · Thorough' }, reply: '<p>Done</p>' });++assert.ok(agentTurn.includes('<span class="ovt-avatar is-who" style="--ovt-who:#10a37f" aria-hidden="true">Cx</span>'), 'the agent\'s face: two letters in its colour');++assert.ok(agentTurn.includes('<p class="ovt-who"><b>Codex <x></b><span>gpt-5 · Thorough</span></p><p>Done</p>'), 'its name over the reply, escaped');++for (const bad of ['red;background:url(//x)', 'expression(1)', '#12345678901']) assert.ok(!t.face({ name: 'A', color: bad }).includes('style='), `no colour that is not one: ${bad}`);++assert.ok(t.face({ name: 'A', color: 'var(--accent)' }).includes('style="--ovt-who:var(--accent)"'), 'a theme token is a colour');++const brief = t.turn({ you: { text: 'Do it', caption: 'Hand-over brief' }, reply: null });++assert.ok(brief.includes('<span class="ovt-caption">Hand-over brief</span>') && !brief.includes('ovt-bot'), 'a caption over the bubble; reply null draws only the person');++assert.strictEqual(t.divider({ text: 'Codex took <over>' }).replace(/<svg[\s\S]*?<\/svg>/, ''), '<li class="ovt-divider"><span>Codex took <over></span></li>', 'a hand-over line, escaped');++const ask = t.card({ tone: 'wait', icon: 'shield', title: 'Bash', text: 'wants to run', code: 'rm -rf "<dir>"', form: { action: '/tasks/1/approvals/2', hidden: { back: '/tasks/1' }, buttons: [{ name: 'decision', value: 'allow', label: 'Allow' }, { name: 'decision', value: 'deny', label: 'Deny', quiet: true }] } });++assert.ok(ask.startsWith('<form class="ovt-card is-wait" method="post" action="/tasks/1/approvals/2"><input type="hidden" name="back" value="/tasks/1">'), 'an approval posts without script');++assert.ok(ask.includes('<pre class="ovt-card-code" tabindex="0">rm -rf "<dir>"</pre>') && ask.includes('<button type="submit" class="ovt-card-btn is-quiet" name="decision" value="deny">Deny</button>'), 'what it runs, escaped; Allow and Deny');++assert.ok(t.card({ tone: 'bad', text: 'x' }).startsWith('<div class="ovt-card is-bad">'), 'a card without a form stays a div');++const pan = t.panel({ id: 'what', title: 'What it did', sections: [{ title: 'Agents', rows: [{ who: { name: 'Codex', initials: 'Cx' }, title: 'Codex', sub: 'finished' }] }, { title: 'Where', rows: [{ icon: 'file', html: '<code>src/</code>' }] }, null] });++assert.ok(pan.startsWith('<aside class="ovt-panel" id="what" aria-label="What it did" data-ovt-panel>') && pan.includes('<h3>Agents</h3>') && pan.includes('<b>Codex</b><small>finished</small>') && pan.includes('<span><code>src/</code></span>'), 'the panel and its rows');++const withPanel = t.page({ sidebar: side, head: t.head({ title: 'Fix', panel: 'what', panelLabel: 'What it did' }), turns: [agentTurn, t.divider({ text: 'Codex took over' })], panel: pan });++assert.ok(withPanel.startsWith('<div class="ovt has-panel" data-ovt>') && withPanel.endsWith('</aside><div class="ovt-panel-scrim" data-ovt-panel-scrim hidden></div></div>'), 'the panel after the conversation, with its scrim');++assert.ok(withPanel.includes('data-ovt-panel-open aria-controls="what" aria-expanded="false" aria-label="What it did"'), 'the head\'s button opens it');++const plain = t.page({ sidebar: side, head: t.head({ title: 'Agents' }), main: '<h2>Your agents</h2>' });++assert.ok(plain.includes('<div class="ovt-scroll ovt-page"><h2>Your agents</h2></div></section>') && !plain.includes('ovt-dock'), 'a plain page in the same frame, no composer');++++const steps = t.work({ rows: [{ kind: 'tool', name: 'Edit', input: 'a.css', icon: 'edit', ok: true }, { kind: 'tool', name: 'Bash', input: 'npm test', output: 'ok', ok: true }] });++assert.ok(steps.includes('<li class="ovt-w-tool is-ok"><div class="ovt-w-row"><svg class="ovt-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L19 9') && !/Edit[\s\S]*?Running…/.test(steps.split('Bash')[0]), 'a finished step with no output is a plain row, with its own icon');++assert.ok(steps.includes('<details><summary>') && steps.includes('<pre class="ovt-w-out">ok</pre>'), 'a step with output still opens');++const ended = t.work({ rows: [{ kind: 'tool', name: 'Read', running: false }] });++assert.ok(ended.includes('<li class="ovt-w-tool is-done">') && ended.includes('<span class="ovt-w-st"></span>'), 'a step that ended with no result has no mark');++assert.ok(!t.head({ title: '' }).includes('<h1') && t.head({ title: 'x' }).includes('<h1 class="ovt-title">x</h1>'), 'no empty heading');++assert.ok(t.panel({ sections: [{ rows: [{ iconHtml: '<svg class="mine"></svg>', title: 'Repo' }] }] }).includes('<li><svg class="mine"></svg><span><b>Repo</b>'), 'a panel row with the site\'s own icon');++const startForm = t.composer({ action: '/chat', id: 'ch-start', attrs: { 'data-ch-api': '/api/x"y' }, foot: '<p class="where">On your computer</p>' });++assert.ok(startForm.includes('<form class="ovt-composer" id="ch-start" method="post" action="/chat" data-ovt-composer data-ch-api="/api/x"y">'), 'the form\'s id and the site\'s attributes, escaped');++assert.ok(startForm.includes('<p class="where">On your computer</p><p class="ovt-c-note" data-ovt-note role="status"></p>'), 'markup under the bar, then a status line that is always there');++assert.ok(t.page({ turns: [], center: true, cls: 'ch' }).startsWith('<div class="ovt is-start ch" data-ovt>'), 'a start page');++assert.ok(t.page({ turns: [agentTurn], center: true }).startsWith('<div class="ovt" data-ovt>'), 'not once the conversation has turns');++// ── The script ─────────────────────────────────────────────────────────────────────────────────────────────────const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-thread.js'), 'utf8');const html = `<!doctype html><html><body>${t.page({ sidebar: side, head: t.head({ title: 'Chat' }), turns: [turn, t.turn({ id: 'tsk_0', state: 'succeeded', you: { text: 'Hi' }, reply: ans })], composer: t.composer({ action: '/chat', starters: ['Plan a weekend'] }) })}</body></html>`;@@@ -114,5 +152,34 @@ if (typed.classList.contains('is-revealing')) assert.ok(typed.querySelectorAll('assert.strictEqual(document.querySelector('[data-turn="tsk_1"] [data-ovt-copy]').hidden, false, 'the new turn has Copy');const added = OV.add(t.turn({ id: 'tsk_2', open: true, you: { text: 'Next' }, reply: t.work({ open: true }) }));assert.ok(added.classList.contains('is-new') && document.querySelectorAll('[data-ovt-thread] > li').length === 3, 'a new turn joins the thread');++// A streamed tool row (4.99.0): built from text, running, then done.++const row = OV.tool('tsk_2', { name: '<Bash>', input: 'npm test', icon: 'run' });++assert.ok(row && row.classList.contains('is-run') && row.querySelector('b').textContent === '<Bash>' && !row.querySelector('b').innerHTML.includes('<B'), 'a running row, its words as text');++assert.ok(row.querySelector('.ovt-dot'), 'its dot while it runs');++OV.toolDone('tsk_2', false);++assert.ok(row.classList.contains('is-bad') && !row.querySelector('.ovt-dot') && row.querySelector('.ovt-w-st svg'), 'it failed: the dot becomes a mark');++const r1 = OV.tool('tsk_2', { name: 'Read', key: 't1' }), r2 = OV.tool('tsk_2', { name: 'Grep', key: 't2' });++OV.toolDone('tsk_2', true, 't1');++assert.ok(r1.classList.contains('is-ok') && r2.classList.contains('is-run'), 'a result finds its own row by key, not the last one');++OV.toolDone('tsk_2', null, 't2');++assert.ok(r2.classList.contains('is-done') && !r2.querySelector('.ovt-w-st svg') && !r2.querySelector('.ovt-dot'), 'ended with no result: no mark');++++// The panel's drawer (4.99.0): the head's button opens it, the scrim and Escape close it, the focus comes back.++{++ const doc2 = parseHTML(`<!doctype html><html><body>${withPanel}</body></html>`);++ delete globalThis.OVThread;++ new Function('window', 'document', 'navigator', 'location', 'setTimeout', SRC)(doc2.window, doc2.document, nav, { hash: '' }, setTimeout);++ const b2 = doc2.document.querySelector('[data-ovt]');++ const opener = doc2.document.querySelector('[data-ovt-panel-open]');++ const scrim = doc2.document.querySelector('[data-ovt-panel-scrim]');++ opener.dispatchEvent(new doc2.window.Event('click'));++ assert.ok(b2.classList.contains('is-panel') && scrim.hidden === false && opener.getAttribute('aria-expanded') === 'true', 'the button opens the panel');++ scrim.dispatchEvent(new doc2.window.Event('click'));++ assert.ok(!b2.classList.contains('is-panel') && scrim.hidden === true && opener.getAttribute('aria-expanded') === 'false', 'the scrim closes it');++ opener.dispatchEvent(new doc2.window.Event('click'));++ const esc = new doc2.window.Event('keydown'); esc.key = 'Escape';++ doc2.document.dispatchEvent(esc);++ assert.ok(!b2.classList.contains('is-panel'), 'Escape closes it');++}console.log('thread: all passed');@@@ -5,7 +5,6 @@.ovt { --ovt-top: 56px; --ovt-rail: 272px; --ovt-line: color-mix(in srgb, var(--border, #1f2d47) 80%, transparent);--ovt-accent: var(--accent, #3b82f6);display: grid; grid-template-columns: minmax(0, 1fr); min-height: calc(100dvh - var(--ovt-top)); color: var(--text-primary, #e6edf7); }--@media (min-width: 900px) { .ovt { grid-template-columns: var(--ovt-rail) minmax(0, 1fr); } }/* The network's one sidebar (kit.sidebar): a column from 761 px, where kit.css makes it one, and sticky under the navbar. */@media (min-width: 761px) {.ovt:has(> .ovk-sidebar) { grid-template-columns: var(--ovk-sidebar-w, var(--ovt-rail)) minmax(0, 1fr); }@@@ -19,34 +18,8 @@.ovt-muted { color: var(--text-muted, #7386a3); }.ovt-ic { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }--/* ── The chats ──────────────────────────────────────────────────────────────────────────────────────────────── */--.ovt-side { display: flex; flex-direction: column; gap: 10px; padding: 12px 10px 14px; background: var(--bg-secondary, #0f1729); border-right: 1px solid var(--ovt-line); }--@media (min-width: 900px) { .ovt-side { position: sticky; top: var(--ovt-top); height: calc(100dvh - var(--ovt-top)); overflow-y: auto; overscroll-behavior: contain; } }--@media (max-width: 899px) {-- .ovt-side { position: fixed; z-index: 60; inset: 0 auto 0 0; width: min(86vw, 320px); padding-top: calc(12px + env(safe-area-inset-top, 0px)); padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));-- overflow-y: auto; overscroll-behavior: contain; transform: translateX(-104%); visibility: hidden; transition: transform .26s cubic-bezier(.2, .8, .2, 1), visibility 0s .26s;-- box-shadow: 0 0 40px rgba(0, 0, 0, .45); }-- .ovt.is-drawer .ovt-side { transform: none; visibility: visible; transition: transform .26s cubic-bezier(.2, .8, .2, 1); }-- .ovt-scrim { position: fixed; inset: 0; z-index: 59; background: rgba(0, 0, 0, .5); }-- .ovt-side-close { display: grid !important; }--}--.ovt-scrim[hidden] { display: none; }--.ovt-side-head { display: flex; align-items: center; gap: 8px; }--.ovt-new { flex: 1; display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--ovt-line);-- background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font-weight: 650; text-decoration: none; }--.ovt-new:hover, .ovt-new:focus-visible { border-color: var(--ovt-accent); }--.ovt-side-close { display: none; place-items: center; width: 42px; height: 42px; border-radius: 12px; border: 1px solid var(--ovt-line); background: none; color: var(--text-secondary, #96a7c2); cursor: pointer; }--.ovt-find input { width: 100%; box-sizing: border-box; min-height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--ovt-line); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; font-size: 14px; }--.ovt-list { flex: 1; min-height: 0; }--.ovt-list ul { list-style: none; margin: 0 0 6px; padding: 0; }--.ovt-group { margin: 12px 8px 4px; font-size: 11.5px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }--.ovt-list a { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 10px; border-radius: 10px; color: var(--text-secondary, #b4c0d4); text-decoration: none; font-size: 14.5px; }--.ovt-list a span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }--.ovt-list a:hover, .ovt-list a:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 5%, transparent)); color: var(--text-primary, #e6edf7); }--.ovt-list a[aria-current=page] { background: color-mix(in srgb, var(--ovt-accent) 16%, transparent); color: var(--text-primary, #e6edf7); font-weight: 600; }--.ovt-list li[hidden], .ovt-group[hidden] { display: none; }++/* ── The chats (kit.sidebar) ──────────────────────────────────────────────────────────────────────────────────────────────── */.ovt-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--ovt-accent); animation: ovt-breathe 1.6s ease-in-out infinite; }--.ovt-empty { margin: 14px 10px; font-size: 14px; color: var(--text-muted, #7386a3); }.ovk-sidebar-foot .ovt-side-foot { border-top: 0; padding-top: 0; }.ovt-side-foot { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0; padding: 10px 8px 0; border-top: 1px solid var(--ovt-line); font-size: 13px; }.ovt-side-foot a { color: var(--text-secondary, #96a7c2); text-decoration: none; }@@@ -62,7 +35,6 @@.ovt-btn { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; border: 0; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; text-decoration: none; }.ovt-btn:hover, .ovt-btn:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); color: var(--text-primary, #e6edf7); }.ovt-btn .ovt-ic { width: 22px; height: 22px; }--@media (min-width: 900px) { .ovt-menu { display: none; } }.ovt-scroll { flex: 1; padding: 8px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px)); }.ovt-thread { list-style: none; max-width: 780px; margin: 0 auto; padding: 0; display: grid; gap: 30px; }.ovt-empty-state { max-width: 780px; margin: 8vh auto 0; text-align: center; }@@@ -99,7 +71,8 @@.ovt-w-list p { margin: 0; min-width: 0; overflow-wrap: anywhere; }.ovt-w-note.is-warn { color: var(--warning-text, #fbbf24); }.ovt-w-tool { display: block !important; }--.ovt-w-tool > details > summary { list-style: none; display: flex; align-items: center; gap: 8px; cursor: pointer; min-height: 28px; }++.ovt-w-tool > details > summary, .ovt-w-tool > .ovt-w-row { list-style: none; display: flex; align-items: center; gap: 8px; cursor: pointer; min-height: 28px; min-width: 0; }++.ovt-w-tool > .ovt-w-row { cursor: default; }.ovt-w-tool > details > summary::-webkit-details-marker { display: none; }.ovt-w-tool b { flex: none; font-weight: 600; color: var(--text-primary, #e6edf7); white-space: nowrap; }.ovt-w-in { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #7386a3); }@@@ -143,6 +116,75 @@ html[data-ov-motion=reduced] .ovt-answer.is-revealing .ovt-sw { opacity: 1 !impo.ovt-card.is-wait { border-color: color-mix(in srgb, var(--ovt-accent) 50%, var(--ovt-line)); }.ovt-card.is-bad { border-color: color-mix(in srgb, var(--danger-text, #f87171) 50%, var(--ovt-line)); }++/* ── The panel (4.99.0): what the work produced. A third column from 1180 px, a drawer from the right below. ─────── */++.ovt { --ovt-panel-w: 300px; }++.ovt-panel { display: flex; flex-direction: column; gap: 4px; padding: 10px 14px 16px; background: var(--bg-secondary, #0f1729); border-left: 1px solid var(--ovt-line); }++.ovt-panel-head { display: flex; align-items: center; gap: 8px; min-height: 42px; }++.ovt-panel-head h2 { flex: 1; margin: 0; font-size: 15px; font-weight: 650; }++.ovt-panel-sec { padding: 10px 0; border-top: 1px solid var(--ovt-line); }++.ovt-panel-head + .ovt-panel-sec { border-top: 0; padding-top: 2px; }++.ovt-panel-sec h3 { margin: 0 0 8px; font-size: 11.5px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.ovt-panel-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }++.ovt-panel-rows > li { display: flex; align-items: flex-start; gap: 10px; min-width: 0; font-size: 14px; }++.ovt-panel-rows > li > svg { flex: none; width: 18px; height: 18px; margin-top: 1px; color: var(--text-secondary, #96a7c2); }++.ovt-panel-rows > li > span:last-child { display: grid; min-width: 0; overflow-wrap: anywhere; }++.ovt-panel-rows small { color: var(--text-muted, #7386a3); font-size: 12.5px; }++.ovt-panel-rows .ovt-avatar { width: 24px; height: 24px; margin-top: 0; font-size: 10.5px; border-radius: 8px; }++.ovt-panel a { color: var(--accent-light, var(--ovt-accent)); }++.ovt-panel code { font-size: 12.5px; overflow-wrap: anywhere; }++@media (min-width: 1180px) {++ .ovt.has-panel { grid-template-columns: minmax(0, 1fr) var(--ovt-panel-w); }++ .ovt.has-panel:has(> .ovk-sidebar) { grid-template-columns: var(--ovk-sidebar-w, var(--ovt-rail)) minmax(0, 1fr) var(--ovt-panel-w); }++ .ovt-panel { position: sticky; top: var(--ovt-top); height: calc(100dvh - var(--ovt-top)); box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain; }++ .ovt-panel-open, .ovt-panel-close, .ovt-panel-scrim { display: none !important; }++}++@media (max-width: 1179px) {++ .ovt-panel { position: fixed; z-index: 10060; top: 0; right: 0; bottom: 0; width: min(340px, 88vw); box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain;++ padding-top: max(10px, env(safe-area-inset-top, 0px)); padding-right: max(14px, env(safe-area-inset-right, 0px)); padding-bottom: max(16px, env(safe-area-inset-bottom, 0px));++ border-left: 0; translate: 100% 0; visibility: hidden; transition: translate .25s ease, visibility 0s linear .25s; box-shadow: -12px 0 32px rgba(0, 0, 0, .25); }++ .ovt.is-panel .ovt-panel { translate: 0 0; visibility: visible; transition: translate .25s ease; }++ .ovt-panel-scrim { position: fixed; inset: 0; z-index: 10050; background: rgba(0, 0, 0, .4); }++}++++/* An agent's face and name over its reply (turn({ who })), and the line across the thread when another takes over. */++.ovt-avatar.is-who { display: grid; place-items: center; background: color-mix(in srgb, var(--ovt-who, var(--ovt-accent)) 22%, var(--bg-card, #131c2e));++ color: color-mix(in srgb, var(--ovt-who, var(--ovt-accent)) 70%, var(--text-primary, #e6edf7)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovt-who, var(--ovt-accent)) 45%, transparent);++ font-size: 11.5px; font-weight: 750; letter-spacing: -.01em; line-height: 1; }++.ovt-avatar.is-who .ovt-ic, .ovt-avatar.is-who svg { width: 16px; height: 16px; }++/* A face in the sidebar (a chat's agent) or a panel row: smaller, beside a line of text. */++.ovk-sidebar-item .ovt-avatar.is-who { flex: none; width: 22px; height: 22px; margin: 0; border-radius: 7px; font-size: 10px; }++.ovk-sidebar-item .ovt-avatar.is-who svg { width: 13px; height: 13px; }++.ovt-who { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; margin: 3px 0 -4px; font-size: 14px; }++.ovt-who b { font-weight: 650; color: var(--text-primary, #e6edf7); }++.ovt-who span { color: var(--text-muted, #7386a3); font-size: 13px; }++.ovt-caption { font-size: 12px; color: var(--text-muted, #7386a3); }++.ovt-divider { display: flex; align-items: center; gap: 12px; color: var(--text-muted, #7386a3); font-size: 13px; }++.ovt-divider::before, .ovt-divider::after { content: ""; flex: 1; height: 1px; background: var(--ovt-line); }++.ovt-divider > span { display: inline-flex; align-items: center; gap: 6px; max-width: min(80%, 560px); text-align: center; }++.ovt-divider .ovt-ic { width: 16px; height: 16px; }++++/* A card that asks (an approval): what it wants to run, then Allow and Deny. */++.ovt-card-h { display: flex; align-items: flex-start; gap: 8px; }++.ovt-card-h > .ovt-ic { margin-top: 2px; color: var(--text-secondary, #96a7c2); }++.ovt-card.is-wait .ovt-card-h > .ovt-ic { color: var(--ovt-accent); }++.ovt-card-code { margin: 4px 0 8px; max-height: 220px; overflow: auto; padding: 8px 10px; border-radius: 10px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--ovt-line);++ font: 12.5px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: break-word; }++.ovt-card-acts { display: flex; flex-wrap: wrap; gap: 8px; }++.ovt-card-btn { min-height: 38px; padding: 0 16px; border-radius: 11px; border: 0; background: var(--ovt-accent); color: var(--on-accent, #fff); font: inherit; font-size: 14px; font-weight: 650; cursor: pointer; }++.ovt-card-btn.is-quiet { background: none; color: var(--text-primary, #e6edf7); box-shadow: inset 0 0 0 1px var(--ovt-line); }++.ovt-card-btn:hover { filter: brightness(1.08); }++.ovt-card-btn.is-quiet:hover { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); filter: none; }++++/* A start page (page({ center })): the welcome and the composer together in the middle, not the composer at the bottom. */++.ovt.is-start .ovt-main { justify-content: center; }++.ovt.is-start .ovt-scroll { flex: none; padding-bottom: 4px; }++.ovt.is-start .ovt-empty-state { margin-top: 0; }++.ovt.is-start .ovt-dock { position: static; background: none; padding-bottom: max(24px, env(safe-area-inset-bottom, 0px)); }++@media (max-width: 760px) { .ovt.is-start .ovt-main { justify-content: flex-start; } .ovt.is-start .ovt-empty-state { margin-top: 4vh; } }++++/* A plain page in the conversation's place (page({ main })). */++.ovt-page { max-width: 980px; width: 100%; box-sizing: border-box; margin: 0 auto; }++/* ── The composer ───────────────────────────────────────────────────────────────────────────────────────────── */.ovt-dock { position: sticky; bottom: var(--ovnav-appbar-h, 0px); z-index: 15; padding: 8px max(12px, env(safe-area-inset-right, 0px)) calc(10px + env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px));background: linear-gradient(to top, var(--bg-primary, #0a0f1c) 70%, transparent); }@@@ -172,5 +214,5 @@ html[data-ov-motion=reduced] .ovt-answer.is-revealing .ovt-sw { opacity: 1 !impo.ovt-dot, .ovt-turn[data-open] .ovt-avatar, .ovt-w-live, .ovt-turn.is-new .ovt-bubble, .ovt-turn.is-fresh .ovt-answer { animation: none; }.ovt-answer.is-revealing .ovt-sw { opacity: 1 !important; transition: none !important; }.ovt-w-live { color: var(--text-secondary, #96a7c2); background: none; }-- .ovt-side, .ovt-chev { transition: none; }++ .ovt-panel, .ovt-chev { transition: none; }}@@@ -42,6 +42,17 @@ const P = {send: 'M5 12l14-7-5 14-2.5-5.5z',stop: 'M7 7h10v10H7z',attach: 'M16.5 6.5l-8 8a2.5 2.5 0 0 0 3.5 3.5l8-8a4.5 4.5 0 0 0-6.4-6.4l-8 8',++ handover: 'M4 8h13l-3-3M20 16H7l3 3',++ wait: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z',++ panel: 'M4 4h16v16H4zM15 4v16',++ shield: 'M12 3 5 6v6c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6zM9 12l2 2 4-4',++ // A step's kind (work rows' icon): read, find, edit, run, web, sub.++ read: 'M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',++ find: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-3.5-3.5',++ edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4',++ run: 'M3 4h18v16H3zM7 9l3 3-3 3M13 15h4',++ web: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18',++ sub: 'M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18',};const icon = (name, cls = '') => `<svg class="ovt-ic${cls ? ` ${cls}` : ''}" viewBox="0 0 24 24" aria-hidden="true"><path d="${P[name] || P.note}"/></svg>`;@@@ -70,30 +81,46 @@ function group(items, whenOf, now = Date.now()) {* shared styling across everything"): New chat on top, Find a chat (it filters as you type with ov-thread.js; without* script it is a GET to `findAction`), the site's places as vertical rows (nav), then the chats in their groups. A column* beside the conversation on a wide screen, a drawer on a phone (kit-web.js opens it from head()'s button).-- * groups: [{ name, items: [{ href, title, current, live, mark }] }]; nav: kit.sidebar's [{ href, label, icon, here, count }]++ * groups: [{ name, items: [{ href, title, current, live, dot, meta, mark }] }]; nav: kit.sidebar's [{ href, label, icon, here, count }]++ * pinned: chats kept above the groups (Codes' conductor), the same shape as a group's items++ * dot: 'busy' (live sets it), 'needs' (waiting for the person) or 'bad'; meta: a quiet word after the title ("2h")++ * findSubmit: Enter in Find a chat runs the site's search at findAction (every message), not only the filter* Draw with kit.css and kit-web.js beside thread.css and ov-thread.js.*/--function sidebar({ id = 'ovt-chats', label = 'Your chats', newHref = null, newLabel = 'New chat', find = true, findAction = '', groups = [], empty = 'Your chats show here.', nav = [], foot = [] } = {}) {-- const sections = groups.filter((g) => g && g.items && g.items.length).map((g) => ({-- title: g.name,-- items: g.items.map((c) => ({ href: c.href, label: clip(c.title, 80), here: !!c.current, dot: c.live ? 'busy' : null, mark: c.mark || '' })),-- }));-- return kit.sidebar({++function sidebar({ id = 'ovt-chats', label = 'Your chats', newHref = null, newLabel = 'New chat', find = true, findAction = '', findPlaceholder = 'Find a chat', findSubmit = false, groups = [], pinned = [], empty = 'Your chats show here.', nav = [], foot = [] } = {}) {++ const item = (c) => ({ href: c.href, label: clip(c.title, 80), here: !!c.current, dot: c.dot || (c.live ? 'busy' : null), meta: c.dot || c.live ? '' : (c.meta || ''), mark: c.mark || '' });++ const sections = [++ ...(pinned && pinned.length ? [{ title: 'Pinned', items: pinned.map(item) }] : []),++ ...groups.filter((g) => g && g.items && g.items.length).map((g) => ({ title: g.name, items: g.items.map(item) })),++ ];++ const html = kit.sidebar({id, label,action: newHref ? { href: newHref, label: newLabel, icon: 'plus' } : null,-- find: find ? { action: findAction || newHref || '', name: 'q', placeholder: 'Find a chat', label: 'Find a chat' } : null,++ find: find ? { action: findAction || newHref || '', name: 'q', placeholder: findPlaceholder, label: findPlaceholder } : null,nav,sections: sections.length ? sections : [{ title: '', items: [], empty }],foot: foot.length ? `<p class="ovt-side-foot">${foot.map((f) => `<a href="${esc(f.href)}">${esc(f.label)}</a>`).join('')}</p>` : '',});++ return findSubmit ? html : html.replace('<form class="ovk-sidebar-find"', '<form class="ovk-sidebar-find" data-ovt-filter-only');}/*** The sticky head over the thread: the button that opens the chats on a phone (kit.sidebarButton), the title, New chat,-- * and the caller's actions. `sidebar` names the sidebar's id (false for none).++ * and the caller's actions. `sidebar` names the sidebar's id (false for none). `panel` names a panel()'s id: a button++ * that opens it where it is a drawer (below 1180 px), labelled `panelLabel`.*/--function head({ title = '', newHref = null, sidebar: sid = 'ovt-chats', actions = '' } = {}) {-- return `<header class="ovt-head" data-ovt-head>${sid ? kit.sidebarButton({ id: sid, label: 'Your chats' }) : ''}<h1 class="ovt-title">${esc(title)}</h1>${actions || ''}${newHref ? `<a class="ovt-btn" href="${esc(newHref)}" aria-label="New chat">${icon('plus')}</a>` : ''}</header>`;++function head({ title = '', newHref = null, sidebar: sid = 'ovt-chats', sidebarLabel = 'Your chats', actions = '', panel: pid = null, panelLabel = 'Details' } = {}) {++ const panelBtn = pid ? `<button type="button" class="ovt-btn ovt-panel-open" data-ovt-panel-open aria-controls="${esc(pid)}" aria-expanded="false" aria-label="${esc(panelLabel)}" title="${esc(panelLabel)}">${icon('panel')}</button>` : '';++ return `<header class="ovt-head" data-ovt-head>${sid ? kit.sidebarButton({ id: sid, label: sidebarLabel }) : ''}${title ? `<h1 class="ovt-title">${esc(title)}</h1>` : '<span class="ovt-title"></span>'}${actions || ''}${newHref ? `<a class="ovt-btn" href="${esc(newHref)}" aria-label="New chat">${icon('plus')}</a>` : ''}${panelBtn}</header>`;++}++++// An agent's colour, only as a colour: #rgb…#rrggbbaa or a theme token, never anything else in a style attribute.++const COLOUR = /^(#[0-9a-f]{3,8}|var\(--[a-z0-9-]+\))$/i;++/** An agent's face: its initials (or the caller's safe mark) in its own colour. who: { name, initials, color, mark } */++function face(who = {}) {++ const colour = who.color && COLOUR.test(String(who.color)) ? ` style="--ovt-who:${who.color}"` : '';++ const inner = who.mark != null ? who.mark : esc(String(who.initials || String(who.name || '?').slice(0, 1).toUpperCase()).slice(0, 2));++ return `<span class="ovt-avatar is-who"${colour} aria-hidden="true">${inner}</span>`;}const bytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${Number(n) || 0} B`);@@@ -108,32 +135,48 @@ function files(list = []) {/*** One turn: what the person wrote (a bubble, with any files), and the reply (the caller's markup: work(), answer(),* card()…). open marks a turn still working (its avatar breathes; ov-thread.js follows it).++ * who: { name, initials, color, sub, mark } heads the reply with an agent's face and name, for a site where several++ * agents work in one conversation (Codes hands a task from one to another); sub is a quiet line ("sonnet · Thorough")++ * you.caption: a small word over the person's bubble ("Hand-over brief"); reply: null draws only the person's side*/--function turn({ id = null, state = null, open = false, you = {}, reply = '', avatar = true, attrs = {} } = {}) {++function turn({ id = null, state = null, open = false, you = {}, reply = '', avatar = true, who = null, attrs = {} } = {}) {const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('');const mine = you && (you.html != null || you.text != null || (you.files && you.files.length))-- ? `<div class="ovt-you">${files(you.files)}${you.html != null ? `<div class="ovt-bubble">${you.html}</div>` : you.text != null ? `<p class="ovt-bubble">${esc(you.text)}</p>` : ''}</div>` : '';++ ? `<div class="ovt-you">${you.caption ? `<span class="ovt-caption">${esc(you.caption)}</span>` : ''}${files(you.files)}${you.html != null ? `<div class="ovt-bubble">${you.html}</div>` : you.text != null ? `<p class="ovt-bubble">${esc(you.text)}</p>` : ''}</div>` : '';++ const mark = who ? face(who) : avatar ? '<span class="ovt-avatar" aria-hidden="true"></span>' : '';++ const name = who ? `<p class="ovt-who"><b>${esc(who.name || 'Agent')}</b>${who.sub ? `<span>${esc(who.sub)}</span>` : ''}</p>` : '';++ const bot = reply == null ? '' : `<div class="ovt-bot">${mark}<div class="ovt-reply">${name}${reply}</div></div>`;return `<li class="ovt-turn${state ? ` state-${esc(state)}` : ''}"${attr('id', id ? `t-${id}` : null)}${attr('data-turn', id)}${attr('data-state', state)}${open ? ' data-open' : ''}${extra}>--${mine}<div class="ovt-bot">${avatar ? '<span class="ovt-avatar" aria-hidden="true"></span>' : ''}<div class="ovt-reply">${reply}</div></div>++${mine}${bot}</li>`;}++/** A line across the thread between turns: a hand-over to another agent ("Codex took over"), a wait. icon: handover | wait */++function divider({ text = '', icon: ic = 'handover' } = {}) {++ return `<li class="ovt-divider"><span>${icon(ic)}${esc(text)}</span></li>`;++}++/*** The work, folded under the answer: "Worked for 12 s · 3 tools", or, while it works, the live line ("Reading the-- * page…"), open. rows: [{ kind: 'step' | 'note' | 'you' | 'tool', text, level, name, input, output, ok, running }].++ * page…"), open. rows: [{ kind: 'step' | 'note' | 'you' | 'tool', text, level, name, input, output, ok, running, icon }].++ * icon: a thread icon for the row (a coding agent's read, find, edit, run, web, sub); a tool that finished with no output++ * is a plain row, with nothing to open. running: false with no ok is a step that ended without a result: no mark.*/function work({ open = false, live = 'Working', took = null, rows = [] } = {}) {const tools = rows.filter((r) => r.kind === 'tool').length;if (!open && !rows.length) return '';const items = rows.map((r) => {if (r.kind === 'tool') {-- const st = r.running || r.ok == null ? 'is-run' : r.ok ? 'is-ok' : 'is-bad';-- return `<li class="ovt-w-tool ${st}"><details><summary>${icon('tool')}<b>${esc(r.name || 'Tool')}</b>${r.input ? `<span class="ovt-w-in">${esc(clip(r.input, 90))}</span>` : ''}<span class="ovt-w-st">${st === 'is-run' ? '<span class="ovt-dot" aria-hidden="true"></span>' : icon(st === 'is-ok' ? 'ok' : 'bad')}</span></summary>++ const st = r.running === false && r.ok == null ? 'is-done' : r.running || r.ok == null ? 'is-run' : r.ok ? 'is-ok' : 'is-bad';++ const mark = st === 'is-run' ? '<span class="ovt-dot" aria-hidden="true"></span>' : st === 'is-done' ? '' : icon(st === 'is-ok' ? 'ok' : 'bad');++ const head = `${icon(r.icon || 'tool')}<b>${esc(r.name || 'Tool')}</b>${r.input ? `<span class="ovt-w-in">${esc(clip(r.input, 90))}</span>` : ''}<span class="ovt-w-st">${mark}</span>`;++ if (r.output == null && st !== 'is-run') return `<li class="ovt-w-tool ${st}"><div class="ovt-w-row">${head}</div></li>`;++ return `<li class="ovt-w-tool ${st}"><details><summary>${head}</summary>${r.output != null ? `<pre class="ovt-w-out">${esc(clip(r.output, 1500))}</pre>` : '<p class="ovt-w-out ovt-muted">Running…</p>'}</details></li>`;}if (r.kind === 'you') return `<li class="ovt-w-you">${icon('you')}<p>${esc(clip(r.text, 300))}</p></li>`;if (r.kind === 'note') return `<li class="ovt-w-note${r.level ? ` is-${esc(r.level)}` : ''}">${icon('note')}<p>${esc(clip(r.text, 700))}</p></li>`;-- return `<li class="ovt-w-step">${icon('step')}<span>${esc(clip(r.text, 300))}</span></li>`;++ return `<li class="ovt-w-step">${icon(r.icon || 'step')}<span>${esc(clip(r.text, 300))}</span></li>`;});const line = open? `<span class="ovt-w-live" data-ovt-live>${esc(live)}…</span>`@@@ -158,9 +201,33 @@ ${sources.length ? `<p class="ovt-sources">${sources.slice(0, 8).map((u) => `<a${meta.length || acts ? `<div class="ovt-meta">${meta.filter(Boolean).map((m) => `<span>${esc(m)}</span>`).join('')}<span class="ovt-acts">${acts}</span></div>` : ''}`;}--/** A card in the reply: a question waiting for the person (wait), a failure (bad), or a note (info). html is safe markup. */--function card({ tone = 'info', title = '', text = null, html = '' } = {}) {-- return `<div class="ovt-card is-${esc(tone)}">${title ? `<p><b>${esc(title)}</b>${text != null ? ` ${esc(text)}` : ''}</p>` : text != null ? `<p>${esc(text)}</p>` : ''}${html}</div>`;++/**++ * A card in the reply: a question waiting for the person (wait), a failure (bad), or a note (info). html is safe markup.++ * With form it is a form that asks something (an agent wanting to run a command: Allow or Deny), posting without script:++ * code: what it wants to run (a block that scrolls inside itself); icon: a thread icon before the title (shield, wait…)++ * form: { action, hidden: { k: v }, buttons: [{ name, value, label, quiet }] }++ */++function card({ tone = 'info', title = '', text = null, html = '', code = null, icon: ic = null, form = null } = {}) {++ const lead = ic ? icon(ic) : '';++ const said = title ? `<b>${esc(title)}</b>${text != null ? ` ${esc(text)}` : ''}` : text != null ? esc(text) : '';++ const words = !said ? '' : ic ? `<p class="ovt-card-h">${lead}<span>${said}</span></p>` : `<p>${said}</p>`;++ const block = code != null ? `<pre class="ovt-card-code" tabindex="0">${esc(code)}</pre>` : '';++ if (!form) return `<div class="ovt-card is-${esc(tone)}">${words}${block}${html}</div>`;++ const hid = Object.entries(form.hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');++ const buttons = (form.buttons || []).map((b) => `<button type="submit" class="ovt-card-btn${b.quiet ? ' is-quiet' : ''}"${attr('name', b.name)}${attr('value', b.value)}>${esc(b.label)}</button>`).join('');++ return `<form class="ovt-card is-${esc(tone)}" method="post" action="${esc(form.action || '')}">${hid}${words}${block}${html}${buttons ? `<div class="ovt-card-acts">${buttons}</div>` : ''}</form>`;++}++++/**++ * The panel beside the conversation: what the work produced (who worked on it, where, the changes, the proof). A third++ * column from 1180 px; below that a drawer from the right that head({ panel: id })'s button opens (ov-thread.js).++ * sections: [{ title, html, rows: [{ who, icon, iconHtml, title, sub, html }] }]; a row's who draws an agent's face (turn's++ * who), icon a thread icon, iconHtml the site's own safe icon; html is the caller's safe markup in place of title and sub.++ */++function panel({ id = 'ovt-panel', title = 'Details', sections = [] } = {}) {++ const row = (r) => `<li>${r.who ? face(r.who) : r.iconHtml != null ? r.iconHtml : r.icon ? icon(r.icon) : ''}<span>${r.html != null ? r.html : `<b>${esc(r.title || '')}</b>${r.sub ? `<small>${esc(r.sub)}</small>` : ''}`}</span></li>`;++ const secs = sections.filter(Boolean).map((s) => `<section class="ovt-panel-sec">${s.title ? `<h3>${esc(s.title)}</h3>` : ''}${s.rows && s.rows.length ? `<ul class="ovt-panel-rows">${s.rows.map(row).join('')}</ul>` : ''}${s.html || ''}</section>`).join('');++ return `<aside class="ovt-panel" id="${esc(id)}" aria-label="${esc(title)}" data-ovt-panel><div class="ovt-panel-head"><h2>${esc(title)}</h2><button type="button" class="ovt-btn ovt-panel-close" data-ovt-panel-close aria-label="Close">${icon('close')}</button></div>${secs}</aside>`;}/**@@@ -169,29 +236,40 @@ function card({ tone = 'info', title = '', text = null, html = '' } = {}) {* hidden: { parent: 'tsk_…' }; starters: ['Plan a weekend in Lisbon', …] (shown on an empty chat)* top: the site's safe markup above the box (picked files, a problem line); maxlength: the box's limit* stopAction: where Stop posts while a turn works; '' draws Stop hidden, for a page whose script starts turns itself++ * id, attrs: the form's id (inputs outside it join it with form="id") and the site's data attributes++ * foot: the site's safe markup under the bar (where the work runs, recent repositories); note: a quiet line under that,++ * which is also the form's status line (role="status": a site's script says "Sending…" there)*/--function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null } = {}) {++function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null, id = null, attrs = {}, foot = '' } = {}) {++ const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('');const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''}--<form class="ovt-composer${busy ? ' is-busy' : ''}" method="${esc(method)}" action="${esc(action)}" data-ovt-composer${attach ? ' enctype="multipart/form-data"' : ''}>${hid}++<form class="ovt-composer${busy ? ' is-busy' : ''}"${attr('id', id)} method="${esc(method)}" action="${esc(action)}" data-ovt-composer${attach ? ' enctype="multipart/form-data"' : ''}${extra}>${hid}${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label><textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea><div class="ovt-c-row"><div class="ovt-c-tools">${attach ? `<label class="ovt-btn ovt-attach" title="Attach files">${icon('attach')}<span class="ovt-sr">Attach files</span><input type="file" name="${esc(attach.name || 'files')}"${attach.accept ? ` accept="${esc(attach.accept)}"` : ''} multiple data-ovt-files hidden></label>` : ''}${tools || ''}</div>${stopAction != null ? `<button type="submit" class="ovt-stop"${stopAction ? ` formaction="${esc(stopAction)}" formnovalidate` : ''}${busy && stopAction ? '' : ' hidden'} data-ovt-stop>${icon('stop')}<span>Stop</span></button>` : ''}<button type="submit" class="ovt-send" data-ovt-send aria-label="${esc(sendLabel)}">${icon('send')}<span class="ovt-sr">${esc(sendLabel)}</span></button></div>--${note ? `<p class="ovt-c-note" data-ovt-note>${esc(note)}</p>` : ''}++${foot || ''}<p class="ovt-c-note" data-ovt-note role="status">${esc(note || '')}</p></form>`;}/*** The whole screen: the sidebar and the conversation (head, the turns or the empty state, the composer). Wrap it in* the site's page; on a phone the composer stays at the bottom above the app bar.++ * panel: a panel() beside the conversation; main: a plain page in the conversation's place (a site's settings,++ * agents or usage, drawn in the same frame as its chats), with no composer++ * center: with no turns yet, the welcome and the composer sit together in the middle of the screen (a start page);++ * cls: the site's classes on the root*/--function page({ sidebar: side = '', head: top = '', turns = [], empty = '', composer: comp = '', label = 'Conversation' } = {}) {-- // The kit's sidebar closes on its scrim (kit-web.js).++function page({ sidebar: side = '', head: top = '', turns = [], empty = '', composer: comp = '', panel: pan = '', label = 'Conversation', main = null, center = false, cls = '' } = {}) {++ // The kit's sidebar closes on its scrim (kit-web.js); the panel on its own (ov-thread.js).const scrim = side && side.includes('data-ovk-sidebar') ? '<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>' : '';-- return `<div class="ovt" data-ovt>${side}${scrim}<section class="ovt-main" aria-label="${esc(label)}">${top}--<div class="ovt-scroll">${turns.length ? `<ol class="ovt-thread" data-ovt-thread>${turns.join('')}</ol>` : `<div class="ovt-empty-state">${empty}</div><ol class="ovt-thread" data-ovt-thread></ol>`}</div>--<div class="ovt-dock">${comp}</div></section></div>`;++ const body = main != null ? `<div class="ovt-scroll ovt-page">${main}</div>`++ : `<div class="ovt-scroll">${turns.length ? `<ol class="ovt-thread" data-ovt-thread>${turns.join('')}</ol>` : `<div class="ovt-empty-state">${empty}</div><ol class="ovt-thread" data-ovt-thread></ol>`}</div>++<div class="ovt-dock">${comp}</div>`;++ const classes = ['ovt', pan ? 'has-panel' : '', center && !turns.length && main == null ? 'is-start' : '', cls || ''].filter(Boolean).map(esc).join(' ');++ return `<div class="${classes}" data-ovt>${side}${scrim}<section class="ovt-main" aria-label="${esc(label)}">${top}++${body}</section>${pan ? `${pan}<div class="ovt-panel-scrim" data-ovt-panel-scrim hidden></div>` : ''}</div>`;}--module.exports = { page, sidebar, head, turn, work, answer, card, composer, files, group, groupOf, icon, esc };++module.exports = { page, sidebar, head, turn, divider, work, answer, card, panel, composer, files, face, group, groupOf, icon, esc };
@@ -4,6 +4,28 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,28 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.99.0 — 2026-11-02
+
+- **The conversation UI shows several agents, approvals and what the work produced** (plan L55 C4 stage 3, for Codes):
+ - `turn({ who })` heads a reply with an agent's face (initials in its colour) and name;
+ - `divider()` draws a hand-over between agents;
+ - `card({ form, code, icon })` asks and posts without script (an approval: Allow or Deny);
+ - `panel()` sits beside the conversation: a column from 1180 px, a drawer from the right below;
+ - `page({ panel, main })` places the panel, or a plain page, in the conversation's place;
+ - `sidebar({ pinned })` keeps chats above the groups, and an item takes a `dot` and a quiet `meta` word;
+ - `sidebar({ findSubmit })`: Enter in Find a chat runs the site's own search (every message). Without it the form is
+ filter-only, as before; the kit keeps Enter on the page only for a filter-only form, so a site that draws
+ `kit.sidebar` itself (Codes) searches again on Enter;
+ - `you.caption` puts a small word over the person's bubble;
+ - a work row takes an `icon` (`read`, `find`, `edit`, `run`, `web`, `sub`), and a finished tool with no output is a
+ plain row rather than one that opens on "Running…";
+ - `composer({ id, attrs, foot })`: the form's id and the site's data attributes, and markup under the bar. The note
+ line is always drawn and is the form's status line (`role="status"`);
+ - `page({ center })` is a start page: the welcome and the composer sit together until there are turns. `cls` adds
+ the site's classes;
+ - `OVThread.tool(id, { name, input, icon, key })` and `OVThread.toolDone(id, ok, key)` stream a tool row built from
+ text; a result finds its own row by key (tools run side by side), and `ok: null` is a step that ended with no result.
+- 4.96.0's own sidebar code is gone from thread.css and ov-thread.js. `thread.sidebar` has drawn `kit.sidebar` since 4.97.0.
## 4.98.0 — 2026-11-02
## 4.98.0 — 2026-11-02
- **Solid drawings for ten more glyphs (plan L49/L50).** The places and things the sites use most that had none
- **Solid drawings for ten more glyphs (plan L49/L50).** The places and things the sites use most that had none
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
-**Status:** alpha, v4.98.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.99.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.98.0",
+ "version": "4.99.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)",
@@ -14,21 +14,46 @@ so a conversation looks and behaves the same on every OpenVibe site.
@@ -14,21 +14,46 @@ so a conversation looks and behaves the same on every OpenVibe site.
| Function | What it draws |
| Function | What it draws |
|---|---|
|---|---|
-| `page({ sidebar, head, turns, empty, composer })` | The screen: the chats beside the conversation; the composer stays at the bottom |
-| `sidebar({ newHref, groups, nav, foot })` | New chat, Find a chat, the chats grouped Today / Yesterday / This week / Earlier, footer links. A column on a wide screen, a drawer on a phone |
+| `page({ sidebar, head, turns, empty, composer, panel, main, center, cls })` | The screen: the chats beside the conversation; the composer stays at the bottom. `panel` goes beside it; `main` is a plain page in the conversation's place; `center` puts the welcome and the composer together in the middle while there are no turns |
+| `sidebar({ newHref, groups, pinned, nav, foot })` | New chat, Find a chat, pinned chats, the chats grouped Today / Yesterday / This week / Earlier, footer links. A column on a wide screen, a drawer on a phone |
| `group(items, whenOf)` | Items, newest first, into those groups |
| `group(items, whenOf)` | Items, newest first, into those groups |
-| `head({ title, newHref, actions })` | The sticky head: the drawer's button (phones), the title, New chat |
-| `turn({ id, state, open, you, reply })` | One turn: the person's bubble (with files) and the reply |
-| `work({ open, live, took, rows })` | The work folded under the answer: "Worked for 12 s · 3 tools", or the live line while it works |
+| `head({ title, newHref, actions, panel })` | The sticky head: the drawer's button (phones), the title, New chat, and the panel's button where the panel is a drawer |
+| `turn({ id, state, open, you, reply, who })` | One turn: the person's bubble (with files and a caption) and the reply. `who` heads the reply with an agent's face and name |
+| `divider({ text, icon })` | A line across the thread: a hand-over to another agent, a wait |
+| `work({ open, live, took, rows })` | The work folded under the answer: "Worked for 12 s · 3 tools", or the live line while it works. A row can carry an `icon` |
| `answer({ html, files, sources, meta, actions })` | The answer, its files, its sources as quiet links, and a footer with Copy |
| `answer({ html, files, sources, meta, actions })` | The answer, its files, its sources as quiet links, and a footer with Copy |
-| `card({ tone, title, text, html })` | A question waiting for the person (`wait`), a failure (`bad`), or a note (`info`) |
-| `composer({ action, busy, stopAction, attach, tools, starters, note })` | The composer: grows as you type, the site's chips, Send, and Stop while a turn works |
+| `card({ tone, title, text, html, icon, code, form })` | A question waiting for the person (`wait`), a failure (`bad`), or a note (`info`). With `form` it asks, and posts without script (an approval: Allow or Deny) |
+| `panel({ id, title, sections })` | What the work produced, beside the conversation: a third column from 1180 px, a drawer from the right below |
+| `composer({ action, busy, stopAction, attach, tools, starters, note, id, attrs, foot })` | The composer: grows as you type, the site's chips, Send, and Stop while a turn works. `foot` is markup under the bar; the note line is its status line |
A `rows` entry for `work()` is one of:
A `rows` entry for `work()` is one of:
- `{ kind: 'step', text }`;
- `{ kind: 'step', text }`;
- `{ kind: 'note', text, level }`;
- `{ kind: 'note', text, level }`;
- `{ kind: 'you', text }`, something the person said while it worked;
- `{ kind: 'you', text }`, something the person said while it worked;
-- `{ kind: 'tool', name, input, output, ok, running }`.
+- `{ kind: 'tool', name, input, output, ok, running, icon }`. A finished tool with no output is a plain row.
+
+A row's `icon` is one of the thread's icons: a coding agent's `read`, `find`, `edit`, `run`, `web` and `sub`, or `tool`.
+
+## Several agents, approvals and the panel (4.99.0)
+
+Codes hands a task from one agent to another inside one conversation. These parts show that:
+
+```js
+thread.turn({ who: { name: 'Codex', initials: 'Cx', color: '#10a37f', sub: 'gpt-5 · Thorough' }, reply });
+thread.divider({ text: 'Codex took over: Claude Code ran out of turns' });
+thread.card({ tone: 'wait', icon: 'shield', title: 'Bash', text: 'wants to run', code: 'npm test',
+ form: { action: '/tasks/1/approvals/9', buttons: [{ name: 'decision', value: 'allow', label: 'Allow' },
+ { name: 'decision', value: 'deny', label: 'Deny', quiet: true }] } });
+thread.page({ sidebar, head: thread.head({ title, panel: 'side', panelLabel: 'What it did' }), turns, composer,
+ panel: thread.panel({ id: 'side', title: 'What it did', sections: [{ title: 'Agents', rows: [{ who, title, sub }] }] }) });
+```
+
+- **Colours:** an agent's colour is only ever a colour: `#rgb` to `#rrggbbaa`, or a theme token (`var(--accent)`).
+ Anything else is dropped.
+- **The panel:**
+ - it is a column from 1180 px;
+ - below that, the head's button opens it as a drawer from the right;
+ - its scrim, its close button and Escape close it, and the focus returns to the button.
## Streaming
## Streaming
@@ -38,6 +63,8 @@ OVThread.live(id, 'Reading'); // the working turn's live line
@@ -38,6 +63,8 @@ OVThread.live(id, 'Reading'); // the working turn's live line
OVThread.work(id, rowHtml); // a row in its work list
OVThread.work(id, rowHtml); // a row in its work list
OVThread.replace(id, html); // the finished turn; its answer types in, word by word
OVThread.replace(id, html); // the finished turn; its answer types in, word by word
OVThread.reveal(answerEl); // the same reveal, for an answer a site draws itself
OVThread.reveal(answerEl); // the same reveal, for an answer a site draws itself
+OVThread.tool(id, { name: 'Bash', input: 'npm test', icon: 'run' }); // a running tool row, built from text
+OVThread.toolDone(id, ok); // the last running tool row finished (false: it failed)
```
```
A site renders those strings on the server (`GET /chat/turn/:id`, as Actor does) or in the page. The helpers keep the
A site renders those strings on the server (`GET /chat/turn/:id`, as Actor does) or in the page. The helpers keep the
@@ -12,6 +12,9 @@
@@ -12,6 +12,9 @@
* OVThread.work(id, html) append a row to the turn's work list
* OVThread.work(id, html) append a row to the turn's work list
* OVThread.enhance(el) Copy buttons for answers and code under el (add/replace call it)
* OVThread.enhance(el) Copy buttons for answers and code under el (add/replace call it)
* OVThread.reveal(answerEl) type an answer in, word by word (replace does it when a working turn finishes)
* OVThread.reveal(answerEl) type an answer in, word by word (replace does it when a working turn finishes)
+ * OVThread.tool(id, { name, input, icon, key }) a running tool row in the turn's work list (built here, text only)
+ * OVThread.toolDone(id, ok, key) that tool's row finished (the row with key, else the last running one); ok false: it
+ * failed, null: it ended with no result (no mark)
* Booted on load and after an in-place page swap (openvibe-shared/boost's ov:boost:load).
* Booted on load and after an in-place page swap (openvibe-shared/boost's ov:boost:load).
*/
*/
(function (root) {
(function (root) {
@@ -128,6 +131,27 @@
@@ -128,6 +131,27 @@
})();
})();
}
}
+ // The icons a streamed tool row can carry (thread.js draws the same ones on the server).
+ var ICONS = {
+ tool: 'M14.7 6.3a4 4 0 0 0-5.4 5.4L4 17l3 3 5.3-5.3a4 4 0 0 0 5.4-5.4l-2.6 2.6-2.4-.6-.6-2.4z',
+ read: 'M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
+ find: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-3.5-3.5',
+ edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4',
+ run: 'M3 4h18v16H3zM7 9l3 3-3 3M13 15h4',
+ web: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18',
+ sub: 'M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18',
+ ok: 'M5 12l5 5 9-10',
+ bad: 'M6 6l12 12M18 6L6 18',
+ };
+ function svgIcon(name) {
+ var ns = 'http://www.w3.org/2000/svg';
+ var s = document.createElementNS(ns, 'svg');
+ s.setAttribute('class', 'ovt-ic'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true');
+ var p = document.createElementNS(ns, 'path'); p.setAttribute('d', ICONS[name] || ICONS.tool);
+ s.appendChild(p);
+ return s;
+ }
+
function threadEl() { return document.querySelector('[data-ovt] [data-ovt-thread]'); }
function threadEl() { return document.querySelector('[data-ovt] [data-ovt-thread]'); }
function turnEl(id) { return document.querySelector('[data-ovt] [data-turn="' + String(id).replace(/"/g, '') + '"]'); }
function turnEl(id) { return document.querySelector('[data-ovt] [data-turn="' + String(id).replace(/"/g, '') + '"]'); }
@@ -164,6 +188,30 @@
@@ -164,6 +188,30 @@
var line = q(turnEl(id), '[data-ovt-live]');
var line = q(turnEl(id), '[data-ovt-live]');
if (line) line.textContent = String(text) + '…';
if (line) line.textContent = String(text) + '…';
},
},
+ tool: function (id, t) {
+ t = t || {};
+ var li = document.createElement('li');
+ li.className = 'ovt-w-tool is-run';
+ if (t.key != null) li.setAttribute('data-key', String(t.key));
+ var row = document.createElement('div');
+ row.className = 'ovt-w-row';
+ var b = document.createElement('b'); b.textContent = String(t.name || 'Tool');
+ row.appendChild(svgIcon(t.icon)); row.appendChild(b);
+ if (t.input) { var inp = document.createElement('span'); inp.className = 'ovt-w-in'; inp.textContent = String(t.input).slice(0, 200); row.appendChild(inp); }
+ var st = document.createElement('span'); st.className = 'ovt-w-st';
+ var dot = document.createElement('span'); dot.className = 'ovt-dot'; dot.setAttribute('aria-hidden', 'true');
+ st.appendChild(dot); row.appendChild(st); li.appendChild(row);
+ return api.work(id, li);
+ },
+ toolDone: function (id, ok, key) {
+ var rows = qa(turnEl(id), '[data-ovt-work] > .ovt-w-tool.is-run');
+ var li = key != null ? rows.filter(function (r) { return r.getAttribute('data-key') === String(key); })[0] : rows[rows.length - 1];
+ if (!li) return null;
+ li.classList.remove('is-run'); li.classList.add(ok === null ? 'is-done' : ok === false ? 'is-bad' : 'is-ok');
+ var st = q(li, '.ovt-w-st');
+ if (st) { st.textContent = ''; if (ok !== null) st.appendChild(svgIcon(ok === false ? 'bad' : 'ok')); }
+ return li;
+ },
work: function (id, html) {
work: function (id, html) {
var list = q(turnEl(id), '[data-ovt-work]');
var list = q(turnEl(id), '[data-ovt-work]');
var el = frag(html);
var el = frag(html);
@@ -197,40 +245,37 @@
@@ -197,40 +245,37 @@
root.addEventListener('resize', onScroll);
root.addEventListener('resize', onScroll);
setTimeout(measure, 400);
setTimeout(measure, 400);
- // The drawer on a phone.
- var opener = q(box, '[data-ovt-open]');
- var scrim = q(box, '.ovt-scrim');
- var closeBtn = q(box, '.ovt-side-close');
- function drawer(on) {
- box.classList.toggle('is-drawer', on);
- if (scrim) scrim.hidden = !on;
- if (closeBtn) closeBtn.hidden = !on;
- if (opener) opener.setAttribute('aria-expanded', on ? 'true' : 'false');
- if (on) { var f = q(box, '[data-ovt-find]'); if (f && fine) f.focus(); }
- else if (opener && document.activeElement && box.querySelector('.ovt-side').contains(document.activeElement)) opener.focus();
+ // The panel (thread.panel): a drawer from the right below 1180 px, opened from the head's button; its scrim, its
+ // close button and Escape close it, and the focus goes in and comes back.
+ var pan = q(box, '[data-ovt-panel]');
+ var panOpen = q(box, '[data-ovt-panel-open]');
+ var panScrim = q(box, '[data-ovt-panel-scrim]');
+ function panel(on) {
+ if (!pan) return;
+ box.classList.toggle('is-panel', on);
+ if (panScrim) panScrim.hidden = !on;
+ if (panOpen) panOpen.setAttribute('aria-expanded', on ? 'true' : 'false');
+ if (on) { var c = q(pan, '[data-ovt-panel-close]'); if (c) c.focus(); }
+ else if (panOpen && document.activeElement && pan.contains(document.activeElement)) panOpen.focus();
+ }
+ if (pan) {
+ if (panOpen) panOpen.addEventListener('click', function () { panel(!box.classList.contains('is-panel')); });
+ qa(box, '[data-ovt-panel-close], [data-ovt-panel-scrim]').forEach(function (el) { el.addEventListener('click', function () { panel(false); }); });
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && box.classList.contains('is-panel')) panel(false); });
}
}
- if (opener) opener.addEventListener('click', function () { drawer(true); });
- qa(box, '[data-ovt-close]').forEach(function (el) { el.addEventListener('click', function () { drawer(false); }); });
- document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && box.classList.contains('is-drawer')) drawer(false); });
- // Find a chat, in the network's sidebar (kit.sidebar): it filters as you type, and Enter keeps you here.
+ // Find a chat, in the network's sidebar (kit.sidebar): it filters as you type. Enter keeps you here when the
+ // form is filter-only (thread.sidebar's default); otherwise it runs the site's own search (every message).
var kfind = q(box, '.ovk-sidebar-find input');
var kfind = q(box, '.ovk-sidebar-find input');
if (kfind) {
if (kfind) {
var kform = kfind.closest('form');
var kform = kfind.closest('form');
- if (kform) kform.addEventListener('submit', function (e) { e.preventDefault(); });
+ if (kform && kform.hasAttribute('data-ovt-filter-only')) kform.addEventListener('submit', function (e) { e.preventDefault(); });
kfind.addEventListener('input', function () {
kfind.addEventListener('input', function () {
var term = kfind.value.trim().toLowerCase();
var term = kfind.value.trim().toLowerCase();
qa(box, '.ovk-sidebar-secs .ovk-sidebar-item').forEach(function (a) { var tt = q(a, '.ovk-sidebar-t'); a.hidden = !!term && (!tt || tt.textContent.toLowerCase().indexOf(term) === -1); });
qa(box, '.ovk-sidebar-secs .ovk-sidebar-item').forEach(function (a) { var tt = q(a, '.ovk-sidebar-t'); a.hidden = !!term && (!tt || tt.textContent.toLowerCase().indexOf(term) === -1); });
qa(box, '.ovk-sidebar-secs .ovk-sidebar-sec').forEach(function (sec) { sec.hidden = !!term && !sec.querySelector('.ovk-sidebar-item:not([hidden])'); });
qa(box, '.ovk-sidebar-secs .ovk-sidebar-sec').forEach(function (sec) { sec.hidden = !!term && !sec.querySelector('.ovk-sidebar-item:not([hidden])'); });
});
});
}
}
- // Find a chat.
- var find = q(box, '[data-ovt-find]');
- if (find) find.addEventListener('input', function () {
- var term = find.value.trim().toLowerCase();
- qa(box, '.ovt-list li').forEach(function (li) { var a = li.querySelector('a'); li.hidden = !!term && (!a || (a.getAttribute('data-title') || '').indexOf(term) === -1); });
- qa(box, '.ovt-group').forEach(function (g) { var ul = g.nextElementSibling; g.hidden = !!ul && !ul.querySelector('li:not([hidden])'); });
- });
// The composer.
// The composer.
var form = q(box, '[data-ovt-composer]');
var form = q(box, '[data-ovt-composer]');
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.98.0",
+ "version": "4.99.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": {
@@ -25,6 +25,7 @@ assert.ok(side.startsWith('<aside class="ovk-sidebar" id="ovt-chats" aria-label=
@@ -25,6 +25,7 @@ assert.ok(side.startsWith('<aside class="ovk-sidebar" id="ovt-chats" aria-label=
assert.ok(side.includes('<a class="ovk-sidebar-item is-here" href="/chat/1" aria-current="page"><span class="ovk-sidebar-t">Plan <b>a</b> weekend</span><i class="ovk-sidebar-dot is-busy" aria-hidden="true"></i></a>'), 'escaped, current, live');
assert.ok(side.includes('<a class="ovk-sidebar-item is-here" href="/chat/1" aria-current="page"><span class="ovk-sidebar-t">Plan <b>a</b> weekend</span><i class="ovk-sidebar-dot is-busy" aria-hidden="true"></i></a>'), 'escaped, current, live');
assert.ok(side.includes('<a class="ovk-sidebar-action" href="/chat">') && side.includes('name="q" placeholder="Find a chat"') && side.includes('<p class="ovt-side-foot"><a href="/memory">Memory</a></p>'));
assert.ok(side.includes('<a class="ovk-sidebar-action" href="/chat">') && side.includes('name="q" placeholder="Find a chat"') && side.includes('<p class="ovt-side-foot"><a href="/memory">Memory</a></p>'));
assert.ok(t.sidebar({ groups: [] }).includes('<p class="ovk-sidebar-empty">Your chats show here.</p>'), 'the empty line');
assert.ok(t.sidebar({ groups: [] }).includes('<p class="ovk-sidebar-empty">Your chats show here.</p>'), 'the empty line');
+assert.ok(side.includes('<form class="ovk-sidebar-find" data-ovt-filter-only') && !t.sidebar({ findSubmit: true, findAction: '/vibe' }).includes('data-ovt-filter-only'), 'filter-only unless the site searches every message');
assert.ok(t.sidebar({ nav: [{ href: '/console', label: 'Console', icon: 'grid' }] }).includes('<nav class="ovk-sidebar-nav"'), 'the site\'s places as rows');
assert.ok(t.sidebar({ nav: [{ href: '/console', label: 'Console', icon: 'grid' }] }).includes('<nav class="ovk-sidebar-nav"'), 'the site\'s places as rows');
const headHtml = t.head({ title: 'A <title>', newHref: '/chat' });
const headHtml = t.head({ title: 'A <title>', newHref: '/chat' });
assert.ok(headHtml.includes('<h1 class="ovt-title">A <title></h1>') && headHtml.includes('data-ovk-sidebar-open aria-controls="ovt-chats"'), 'the kit\'s button opens the chats on a phone');
assert.ok(headHtml.includes('<h1 class="ovt-title">A <title></h1>') && headHtml.includes('data-ovk-sidebar-open aria-controls="ovt-chats"'), 'the kit\'s button opens the chats on a phone');
@@ -65,6 +66,43 @@ const page = t.page({ sidebar: side, head: t.head({ title: 'Chat' }), turns: [],
@@ -65,6 +66,43 @@ const page = t.page({ sidebar: side, head: t.head({ title: 'Chat' }), turns: [],
assert.ok(page.startsWith('<div class="ovt" data-ovt>') && page.includes('<div class="ovt-empty-state"><h2>What can I do for you?</h2></div><ol class="ovt-thread" data-ovt-thread></ol>'));
assert.ok(page.startsWith('<div class="ovt" data-ovt>') && page.includes('<div class="ovt-empty-state"><h2>What can I do for you?</h2></div><ol class="ovt-thread" data-ovt-thread></ol>'));
assert.ok(page.includes('</aside><div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>'), 'the kit\'s scrim closes the drawer');
assert.ok(page.includes('</aside><div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>'), 'the kit\'s scrim closes the drawer');
+// ── 4.99.0: the parts Codes needs (several agents in one conversation, approvals, what the work produced) ──────
+const pinnedSide = t.sidebar({ pinned: [{ href: '/tasks/c', title: 'Conductor', dot: 'needs' }], groups: [{ name: 'Today', items: [{ href: '/tasks/1', title: 'Fix', meta: '2h' }] }], findPlaceholder: 'Search conversations' });
+assert.ok(/<h2 class="ovk-sidebar-h">Pinned<\/h2>[\s\S]*Conductor[\s\S]*is-needs[\s\S]*Today/.test(pinnedSide) || (pinnedSide.indexOf('Pinned') > -1 && pinnedSide.indexOf('Pinned') < pinnedSide.indexOf('Today') && pinnedSide.includes('is-needs')), 'pinned chats above the groups, with their dot');
+assert.ok(pinnedSide.includes('<span class="ovk-sidebar-m">2h</span>') && pinnedSide.includes('placeholder="Search conversations"'), 'a quiet word after the title, and the find box\'s words');
+const agentTurn = t.turn({ id: 'a1', who: { name: 'Codex <x>', initials: 'Cxy', color: '#10a37f', sub: 'gpt-5 · Thorough' }, reply: '<p>Done</p>' });
+assert.ok(agentTurn.includes('<span class="ovt-avatar is-who" style="--ovt-who:#10a37f" aria-hidden="true">Cx</span>'), 'the agent\'s face: two letters in its colour');
+assert.ok(agentTurn.includes('<p class="ovt-who"><b>Codex <x></b><span>gpt-5 · Thorough</span></p><p>Done</p>'), 'its name over the reply, escaped');
+for (const bad of ['red;background:url(//x)', 'expression(1)', '#12345678901']) assert.ok(!t.face({ name: 'A', color: bad }).includes('style='), `no colour that is not one: ${bad}`);
+assert.ok(t.face({ name: 'A', color: 'var(--accent)' }).includes('style="--ovt-who:var(--accent)"'), 'a theme token is a colour');
+const brief = t.turn({ you: { text: 'Do it', caption: 'Hand-over brief' }, reply: null });
+assert.ok(brief.includes('<span class="ovt-caption">Hand-over brief</span>') && !brief.includes('ovt-bot'), 'a caption over the bubble; reply null draws only the person');
+assert.strictEqual(t.divider({ text: 'Codex took <over>' }).replace(/<svg[\s\S]*?<\/svg>/, ''), '<li class="ovt-divider"><span>Codex took <over></span></li>', 'a hand-over line, escaped');
+const ask = t.card({ tone: 'wait', icon: 'shield', title: 'Bash', text: 'wants to run', code: 'rm -rf "<dir>"', form: { action: '/tasks/1/approvals/2', hidden: { back: '/tasks/1' }, buttons: [{ name: 'decision', value: 'allow', label: 'Allow' }, { name: 'decision', value: 'deny', label: 'Deny', quiet: true }] } });
+assert.ok(ask.startsWith('<form class="ovt-card is-wait" method="post" action="/tasks/1/approvals/2"><input type="hidden" name="back" value="/tasks/1">'), 'an approval posts without script');
+assert.ok(ask.includes('<pre class="ovt-card-code" tabindex="0">rm -rf "<dir>"</pre>') && ask.includes('<button type="submit" class="ovt-card-btn is-quiet" name="decision" value="deny">Deny</button>'), 'what it runs, escaped; Allow and Deny');
+assert.ok(t.card({ tone: 'bad', text: 'x' }).startsWith('<div class="ovt-card is-bad">'), 'a card without a form stays a div');
+const pan = t.panel({ id: 'what', title: 'What it did', sections: [{ title: 'Agents', rows: [{ who: { name: 'Codex', initials: 'Cx' }, title: 'Codex', sub: 'finished' }] }, { title: 'Where', rows: [{ icon: 'file', html: '<code>src/</code>' }] }, null] });
+assert.ok(pan.startsWith('<aside class="ovt-panel" id="what" aria-label="What it did" data-ovt-panel>') && pan.includes('<h3>Agents</h3>') && pan.includes('<b>Codex</b><small>finished</small>') && pan.includes('<span><code>src/</code></span>'), 'the panel and its rows');
+const withPanel = t.page({ sidebar: side, head: t.head({ title: 'Fix', panel: 'what', panelLabel: 'What it did' }), turns: [agentTurn, t.divider({ text: 'Codex took over' })], panel: pan });
+assert.ok(withPanel.startsWith('<div class="ovt has-panel" data-ovt>') && withPanel.endsWith('</aside><div class="ovt-panel-scrim" data-ovt-panel-scrim hidden></div></div>'), 'the panel after the conversation, with its scrim');
+assert.ok(withPanel.includes('data-ovt-panel-open aria-controls="what" aria-expanded="false" aria-label="What it did"'), 'the head\'s button opens it');
+const plain = t.page({ sidebar: side, head: t.head({ title: 'Agents' }), main: '<h2>Your agents</h2>' });
+assert.ok(plain.includes('<div class="ovt-scroll ovt-page"><h2>Your agents</h2></div></section>') && !plain.includes('ovt-dock'), 'a plain page in the same frame, no composer');
+
+const steps = t.work({ rows: [{ kind: 'tool', name: 'Edit', input: 'a.css', icon: 'edit', ok: true }, { kind: 'tool', name: 'Bash', input: 'npm test', output: 'ok', ok: true }] });
+assert.ok(steps.includes('<li class="ovt-w-tool is-ok"><div class="ovt-w-row"><svg class="ovt-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 20h4L19 9') && !/Edit[\s\S]*?Running…/.test(steps.split('Bash')[0]), 'a finished step with no output is a plain row, with its own icon');
+assert.ok(steps.includes('<details><summary>') && steps.includes('<pre class="ovt-w-out">ok</pre>'), 'a step with output still opens');
+const ended = t.work({ rows: [{ kind: 'tool', name: 'Read', running: false }] });
+assert.ok(ended.includes('<li class="ovt-w-tool is-done">') && ended.includes('<span class="ovt-w-st"></span>'), 'a step that ended with no result has no mark');
+assert.ok(!t.head({ title: '' }).includes('<h1') && t.head({ title: 'x' }).includes('<h1 class="ovt-title">x</h1>'), 'no empty heading');
+assert.ok(t.panel({ sections: [{ rows: [{ iconHtml: '<svg class="mine"></svg>', title: 'Repo' }] }] }).includes('<li><svg class="mine"></svg><span><b>Repo</b>'), 'a panel row with the site\'s own icon');
+const startForm = t.composer({ action: '/chat', id: 'ch-start', attrs: { 'data-ch-api': '/api/x"y' }, foot: '<p class="where">On your computer</p>' });
+assert.ok(startForm.includes('<form class="ovt-composer" id="ch-start" method="post" action="/chat" data-ovt-composer data-ch-api="/api/x"y">'), 'the form\'s id and the site\'s attributes, escaped');
+assert.ok(startForm.includes('<p class="where">On your computer</p><p class="ovt-c-note" data-ovt-note role="status"></p>'), 'markup under the bar, then a status line that is always there');
+assert.ok(t.page({ turns: [], center: true, cls: 'ch' }).startsWith('<div class="ovt is-start ch" data-ovt>'), 'a start page');
+assert.ok(t.page({ turns: [agentTurn], center: true }).startsWith('<div class="ovt" data-ovt>'), 'not once the conversation has turns');
+
// ── The script ─────────────────────────────────────────────────────────────────────────────────────────────────
// ── The script ─────────────────────────────────────────────────────────────────────────────────────────────────
const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-thread.js'), 'utf8');
const SRC = fs.readFileSync(path.join(__dirname, '..', 'ov-thread.js'), 'utf8');
const html = `<!doctype html><html><body>${t.page({ sidebar: side, head: t.head({ title: 'Chat' }), turns: [turn, t.turn({ id: 'tsk_0', state: 'succeeded', you: { text: 'Hi' }, reply: ans })], composer: t.composer({ action: '/chat', starters: ['Plan a weekend'] }) })}</body></html>`;
const html = `<!doctype html><html><body>${t.page({ sidebar: side, head: t.head({ title: 'Chat' }), turns: [turn, t.turn({ id: 'tsk_0', state: 'succeeded', you: { text: 'Hi' }, reply: ans })], composer: t.composer({ action: '/chat', starters: ['Plan a weekend'] }) })}</body></html>`;
@@ -114,5 +152,34 @@ if (typed.classList.contains('is-revealing')) assert.ok(typed.querySelectorAll('
@@ -114,5 +152,34 @@ if (typed.classList.contains('is-revealing')) assert.ok(typed.querySelectorAll('
assert.strictEqual(document.querySelector('[data-turn="tsk_1"] [data-ovt-copy]').hidden, false, 'the new turn has Copy');
assert.strictEqual(document.querySelector('[data-turn="tsk_1"] [data-ovt-copy]').hidden, false, 'the new turn has Copy');
const added = OV.add(t.turn({ id: 'tsk_2', open: true, you: { text: 'Next' }, reply: t.work({ open: true }) }));
const added = OV.add(t.turn({ id: 'tsk_2', open: true, you: { text: 'Next' }, reply: t.work({ open: true }) }));
assert.ok(added.classList.contains('is-new') && document.querySelectorAll('[data-ovt-thread] > li').length === 3, 'a new turn joins the thread');
assert.ok(added.classList.contains('is-new') && document.querySelectorAll('[data-ovt-thread] > li').length === 3, 'a new turn joins the thread');
+// A streamed tool row (4.99.0): built from text, running, then done.
+const row = OV.tool('tsk_2', { name: '<Bash>', input: 'npm test', icon: 'run' });
+assert.ok(row && row.classList.contains('is-run') && row.querySelector('b').textContent === '<Bash>' && !row.querySelector('b').innerHTML.includes('<B'), 'a running row, its words as text');
+assert.ok(row.querySelector('.ovt-dot'), 'its dot while it runs');
+OV.toolDone('tsk_2', false);
+assert.ok(row.classList.contains('is-bad') && !row.querySelector('.ovt-dot') && row.querySelector('.ovt-w-st svg'), 'it failed: the dot becomes a mark');
+const r1 = OV.tool('tsk_2', { name: 'Read', key: 't1' }), r2 = OV.tool('tsk_2', { name: 'Grep', key: 't2' });
+OV.toolDone('tsk_2', true, 't1');
+assert.ok(r1.classList.contains('is-ok') && r2.classList.contains('is-run'), 'a result finds its own row by key, not the last one');
+OV.toolDone('tsk_2', null, 't2');
+assert.ok(r2.classList.contains('is-done') && !r2.querySelector('.ovt-w-st svg') && !r2.querySelector('.ovt-dot'), 'ended with no result: no mark');
+
+// The panel's drawer (4.99.0): the head's button opens it, the scrim and Escape close it, the focus comes back.
+{
+ const doc2 = parseHTML(`<!doctype html><html><body>${withPanel}</body></html>`);
+ delete globalThis.OVThread;
+ new Function('window', 'document', 'navigator', 'location', 'setTimeout', SRC)(doc2.window, doc2.document, nav, { hash: '' }, setTimeout);
+ const b2 = doc2.document.querySelector('[data-ovt]');
+ const opener = doc2.document.querySelector('[data-ovt-panel-open]');
+ const scrim = doc2.document.querySelector('[data-ovt-panel-scrim]');
+ opener.dispatchEvent(new doc2.window.Event('click'));
+ assert.ok(b2.classList.contains('is-panel') && scrim.hidden === false && opener.getAttribute('aria-expanded') === 'true', 'the button opens the panel');
+ scrim.dispatchEvent(new doc2.window.Event('click'));
+ assert.ok(!b2.classList.contains('is-panel') && scrim.hidden === true && opener.getAttribute('aria-expanded') === 'false', 'the scrim closes it');
+ opener.dispatchEvent(new doc2.window.Event('click'));
+ const esc = new doc2.window.Event('keydown'); esc.key = 'Escape';
+ doc2.document.dispatchEvent(esc);
+ assert.ok(!b2.classList.contains('is-panel'), 'Escape closes it');
+}
console.log('thread: all passed');
console.log('thread: all passed');
@@ -5,7 +5,6 @@
@@ -5,7 +5,6 @@
.ovt { --ovt-top: 56px; --ovt-rail: 272px; --ovt-line: color-mix(in srgb, var(--border, #1f2d47) 80%, transparent);
.ovt { --ovt-top: 56px; --ovt-rail: 272px; --ovt-line: color-mix(in srgb, var(--border, #1f2d47) 80%, transparent);
--ovt-accent: var(--accent, #3b82f6);
--ovt-accent: var(--accent, #3b82f6);
display: grid; grid-template-columns: minmax(0, 1fr); min-height: calc(100dvh - var(--ovt-top)); color: var(--text-primary, #e6edf7); }
display: grid; grid-template-columns: minmax(0, 1fr); min-height: calc(100dvh - var(--ovt-top)); color: var(--text-primary, #e6edf7); }
-@media (min-width: 900px) { .ovt { grid-template-columns: var(--ovt-rail) minmax(0, 1fr); } }
/* The network's one sidebar (kit.sidebar): a column from 761 px, where kit.css makes it one, and sticky under the navbar. */
/* The network's one sidebar (kit.sidebar): a column from 761 px, where kit.css makes it one, and sticky under the navbar. */
@media (min-width: 761px) {
@media (min-width: 761px) {
.ovt:has(> .ovk-sidebar) { grid-template-columns: var(--ovk-sidebar-w, var(--ovt-rail)) minmax(0, 1fr); }
.ovt:has(> .ovk-sidebar) { grid-template-columns: var(--ovk-sidebar-w, var(--ovt-rail)) minmax(0, 1fr); }
@@ -19,34 +18,8 @@
@@ -19,34 +18,8 @@
.ovt-muted { color: var(--text-muted, #7386a3); }
.ovt-muted { color: var(--text-muted, #7386a3); }
.ovt-ic { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.ovt-ic { width: 18px; height: 18px; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
-/* ── The chats ──────────────────────────────────────────────────────────────────────────────────────────────── */
-.ovt-side { display: flex; flex-direction: column; gap: 10px; padding: 12px 10px 14px; background: var(--bg-secondary, #0f1729); border-right: 1px solid var(--ovt-line); }
-@media (min-width: 900px) { .ovt-side { position: sticky; top: var(--ovt-top); height: calc(100dvh - var(--ovt-top)); overflow-y: auto; overscroll-behavior: contain; } }
-@media (max-width: 899px) {
- .ovt-side { position: fixed; z-index: 60; inset: 0 auto 0 0; width: min(86vw, 320px); padding-top: calc(12px + env(safe-area-inset-top, 0px)); padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
- overflow-y: auto; overscroll-behavior: contain; transform: translateX(-104%); visibility: hidden; transition: transform .26s cubic-bezier(.2, .8, .2, 1), visibility 0s .26s;
- box-shadow: 0 0 40px rgba(0, 0, 0, .45); }
- .ovt.is-drawer .ovt-side { transform: none; visibility: visible; transition: transform .26s cubic-bezier(.2, .8, .2, 1); }
- .ovt-scrim { position: fixed; inset: 0; z-index: 59; background: rgba(0, 0, 0, .5); }
- .ovt-side-close { display: grid !important; }
-}
-.ovt-scrim[hidden] { display: none; }
-.ovt-side-head { display: flex; align-items: center; gap: 8px; }
-.ovt-new { flex: 1; display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--ovt-line);
- background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font-weight: 650; text-decoration: none; }
-.ovt-new:hover, .ovt-new:focus-visible { border-color: var(--ovt-accent); }
-.ovt-side-close { display: none; place-items: center; width: 42px; height: 42px; border-radius: 12px; border: 1px solid var(--ovt-line); background: none; color: var(--text-secondary, #96a7c2); cursor: pointer; }
-.ovt-find input { width: 100%; box-sizing: border-box; min-height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--ovt-line); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; font-size: 14px; }
-.ovt-list { flex: 1; min-height: 0; }
-.ovt-list ul { list-style: none; margin: 0 0 6px; padding: 0; }
-.ovt-group { margin: 12px 8px 4px; font-size: 11.5px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
-.ovt-list a { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 10px; border-radius: 10px; color: var(--text-secondary, #b4c0d4); text-decoration: none; font-size: 14.5px; }
-.ovt-list a span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.ovt-list a:hover, .ovt-list a:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 5%, transparent)); color: var(--text-primary, #e6edf7); }
-.ovt-list a[aria-current=page] { background: color-mix(in srgb, var(--ovt-accent) 16%, transparent); color: var(--text-primary, #e6edf7); font-weight: 600; }
-.ovt-list li[hidden], .ovt-group[hidden] { display: none; }
+/* ── The chats (kit.sidebar) ──────────────────────────────────────────────────────────────────────────────────────────────── */
.ovt-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--ovt-accent); animation: ovt-breathe 1.6s ease-in-out infinite; }
.ovt-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--ovt-accent); animation: ovt-breathe 1.6s ease-in-out infinite; }
-.ovt-empty { margin: 14px 10px; font-size: 14px; color: var(--text-muted, #7386a3); }
.ovk-sidebar-foot .ovt-side-foot { border-top: 0; padding-top: 0; }
.ovk-sidebar-foot .ovt-side-foot { border-top: 0; padding-top: 0; }
.ovt-side-foot { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0; padding: 10px 8px 0; border-top: 1px solid var(--ovt-line); font-size: 13px; }
.ovt-side-foot { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0; padding: 10px 8px 0; border-top: 1px solid var(--ovt-line); font-size: 13px; }
.ovt-side-foot a { color: var(--text-secondary, #96a7c2); text-decoration: none; }
.ovt-side-foot a { color: var(--text-secondary, #96a7c2); text-decoration: none; }
@@ -62,7 +35,6 @@
@@ -62,7 +35,6 @@
.ovt-btn { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; border: 0; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; text-decoration: none; }
.ovt-btn { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; border: 0; background: none; color: var(--text-secondary, #b4c0d4); cursor: pointer; text-decoration: none; }
.ovt-btn:hover, .ovt-btn:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); color: var(--text-primary, #e6edf7); }
.ovt-btn:hover, .ovt-btn:focus-visible { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); color: var(--text-primary, #e6edf7); }
.ovt-btn .ovt-ic { width: 22px; height: 22px; }
.ovt-btn .ovt-ic { width: 22px; height: 22px; }
-@media (min-width: 900px) { .ovt-menu { display: none; } }
.ovt-scroll { flex: 1; padding: 8px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px)); }
.ovt-scroll { flex: 1; padding: 8px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px)); }
.ovt-thread { list-style: none; max-width: 780px; margin: 0 auto; padding: 0; display: grid; gap: 30px; }
.ovt-thread { list-style: none; max-width: 780px; margin: 0 auto; padding: 0; display: grid; gap: 30px; }
.ovt-empty-state { max-width: 780px; margin: 8vh auto 0; text-align: center; }
.ovt-empty-state { max-width: 780px; margin: 8vh auto 0; text-align: center; }
@@ -99,7 +71,8 @@
@@ -99,7 +71,8 @@
.ovt-w-list p { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.ovt-w-list p { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.ovt-w-note.is-warn { color: var(--warning-text, #fbbf24); }
.ovt-w-note.is-warn { color: var(--warning-text, #fbbf24); }
.ovt-w-tool { display: block !important; }
.ovt-w-tool { display: block !important; }
-.ovt-w-tool > details > summary { list-style: none; display: flex; align-items: center; gap: 8px; cursor: pointer; min-height: 28px; }
+.ovt-w-tool > details > summary, .ovt-w-tool > .ovt-w-row { list-style: none; display: flex; align-items: center; gap: 8px; cursor: pointer; min-height: 28px; min-width: 0; }
+.ovt-w-tool > .ovt-w-row { cursor: default; }
.ovt-w-tool > details > summary::-webkit-details-marker { display: none; }
.ovt-w-tool > details > summary::-webkit-details-marker { display: none; }
.ovt-w-tool b { flex: none; font-weight: 600; color: var(--text-primary, #e6edf7); white-space: nowrap; }
.ovt-w-tool b { flex: none; font-weight: 600; color: var(--text-primary, #e6edf7); white-space: nowrap; }
.ovt-w-in { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #7386a3); }
.ovt-w-in { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted, #7386a3); }
@@ -143,6 +116,75 @@ html[data-ov-motion=reduced] .ovt-answer.is-revealing .ovt-sw { opacity: 1 !impo
@@ -143,6 +116,75 @@ html[data-ov-motion=reduced] .ovt-answer.is-revealing .ovt-sw { opacity: 1 !impo
.ovt-card.is-wait { border-color: color-mix(in srgb, var(--ovt-accent) 50%, var(--ovt-line)); }
.ovt-card.is-wait { border-color: color-mix(in srgb, var(--ovt-accent) 50%, var(--ovt-line)); }
.ovt-card.is-bad { border-color: color-mix(in srgb, var(--danger-text, #f87171) 50%, var(--ovt-line)); }
.ovt-card.is-bad { border-color: color-mix(in srgb, var(--danger-text, #f87171) 50%, var(--ovt-line)); }
+/* ── The panel (4.99.0): what the work produced. A third column from 1180 px, a drawer from the right below. ─────── */
+.ovt { --ovt-panel-w: 300px; }
+.ovt-panel { display: flex; flex-direction: column; gap: 4px; padding: 10px 14px 16px; background: var(--bg-secondary, #0f1729); border-left: 1px solid var(--ovt-line); }
+.ovt-panel-head { display: flex; align-items: center; gap: 8px; min-height: 42px; }
+.ovt-panel-head h2 { flex: 1; margin: 0; font-size: 15px; font-weight: 650; }
+.ovt-panel-sec { padding: 10px 0; border-top: 1px solid var(--ovt-line); }
+.ovt-panel-head + .ovt-panel-sec { border-top: 0; padding-top: 2px; }
+.ovt-panel-sec h3 { margin: 0 0 8px; font-size: 11.5px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.ovt-panel-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+.ovt-panel-rows > li { display: flex; align-items: flex-start; gap: 10px; min-width: 0; font-size: 14px; }
+.ovt-panel-rows > li > svg { flex: none; width: 18px; height: 18px; margin-top: 1px; color: var(--text-secondary, #96a7c2); }
+.ovt-panel-rows > li > span:last-child { display: grid; min-width: 0; overflow-wrap: anywhere; }
+.ovt-panel-rows small { color: var(--text-muted, #7386a3); font-size: 12.5px; }
+.ovt-panel-rows .ovt-avatar { width: 24px; height: 24px; margin-top: 0; font-size: 10.5px; border-radius: 8px; }
+.ovt-panel a { color: var(--accent-light, var(--ovt-accent)); }
+.ovt-panel code { font-size: 12.5px; overflow-wrap: anywhere; }
+@media (min-width: 1180px) {
+ .ovt.has-panel { grid-template-columns: minmax(0, 1fr) var(--ovt-panel-w); }
+ .ovt.has-panel:has(> .ovk-sidebar) { grid-template-columns: var(--ovk-sidebar-w, var(--ovt-rail)) minmax(0, 1fr) var(--ovt-panel-w); }
+ .ovt-panel { position: sticky; top: var(--ovt-top); height: calc(100dvh - var(--ovt-top)); box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain; }
+ .ovt-panel-open, .ovt-panel-close, .ovt-panel-scrim { display: none !important; }
+}
+@media (max-width: 1179px) {
+ .ovt-panel { position: fixed; z-index: 10060; top: 0; right: 0; bottom: 0; width: min(340px, 88vw); box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain;
+ padding-top: max(10px, env(safe-area-inset-top, 0px)); padding-right: max(14px, env(safe-area-inset-right, 0px)); padding-bottom: max(16px, env(safe-area-inset-bottom, 0px));
+ border-left: 0; translate: 100% 0; visibility: hidden; transition: translate .25s ease, visibility 0s linear .25s; box-shadow: -12px 0 32px rgba(0, 0, 0, .25); }
+ .ovt.is-panel .ovt-panel { translate: 0 0; visibility: visible; transition: translate .25s ease; }
+ .ovt-panel-scrim { position: fixed; inset: 0; z-index: 10050; background: rgba(0, 0, 0, .4); }
+}
+
+/* An agent's face and name over its reply (turn({ who })), and the line across the thread when another takes over. */
+.ovt-avatar.is-who { display: grid; place-items: center; background: color-mix(in srgb, var(--ovt-who, var(--ovt-accent)) 22%, var(--bg-card, #131c2e));
+ color: color-mix(in srgb, var(--ovt-who, var(--ovt-accent)) 70%, var(--text-primary, #e6edf7)); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ovt-who, var(--ovt-accent)) 45%, transparent);
+ font-size: 11.5px; font-weight: 750; letter-spacing: -.01em; line-height: 1; }
+.ovt-avatar.is-who .ovt-ic, .ovt-avatar.is-who svg { width: 16px; height: 16px; }
+/* A face in the sidebar (a chat's agent) or a panel row: smaller, beside a line of text. */
+.ovk-sidebar-item .ovt-avatar.is-who { flex: none; width: 22px; height: 22px; margin: 0; border-radius: 7px; font-size: 10px; }
+.ovk-sidebar-item .ovt-avatar.is-who svg { width: 13px; height: 13px; }
+.ovt-who { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; margin: 3px 0 -4px; font-size: 14px; }
+.ovt-who b { font-weight: 650; color: var(--text-primary, #e6edf7); }
+.ovt-who span { color: var(--text-muted, #7386a3); font-size: 13px; }
+.ovt-caption { font-size: 12px; color: var(--text-muted, #7386a3); }
+.ovt-divider { display: flex; align-items: center; gap: 12px; color: var(--text-muted, #7386a3); font-size: 13px; }
+.ovt-divider::before, .ovt-divider::after { content: ""; flex: 1; height: 1px; background: var(--ovt-line); }
+.ovt-divider > span { display: inline-flex; align-items: center; gap: 6px; max-width: min(80%, 560px); text-align: center; }
+.ovt-divider .ovt-ic { width: 16px; height: 16px; }
+
+/* A card that asks (an approval): what it wants to run, then Allow and Deny. */
+.ovt-card-h { display: flex; align-items: flex-start; gap: 8px; }
+.ovt-card-h > .ovt-ic { margin-top: 2px; color: var(--text-secondary, #96a7c2); }
+.ovt-card.is-wait .ovt-card-h > .ovt-ic { color: var(--ovt-accent); }
+.ovt-card-code { margin: 4px 0 8px; max-height: 220px; overflow: auto; padding: 8px 10px; border-radius: 10px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--ovt-line);
+ font: 12.5px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; overflow-wrap: break-word; }
+.ovt-card-acts { display: flex; flex-wrap: wrap; gap: 8px; }
+.ovt-card-btn { min-height: 38px; padding: 0 16px; border-radius: 11px; border: 0; background: var(--ovt-accent); color: var(--on-accent, #fff); font: inherit; font-size: 14px; font-weight: 650; cursor: pointer; }
+.ovt-card-btn.is-quiet { background: none; color: var(--text-primary, #e6edf7); box-shadow: inset 0 0 0 1px var(--ovt-line); }
+.ovt-card-btn:hover { filter: brightness(1.08); }
+.ovt-card-btn.is-quiet:hover { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); filter: none; }
+
+/* A start page (page({ center })): the welcome and the composer together in the middle, not the composer at the bottom. */
+.ovt.is-start .ovt-main { justify-content: center; }
+.ovt.is-start .ovt-scroll { flex: none; padding-bottom: 4px; }
+.ovt.is-start .ovt-empty-state { margin-top: 0; }
+.ovt.is-start .ovt-dock { position: static; background: none; padding-bottom: max(24px, env(safe-area-inset-bottom, 0px)); }
+@media (max-width: 760px) { .ovt.is-start .ovt-main { justify-content: flex-start; } .ovt.is-start .ovt-empty-state { margin-top: 4vh; } }
+
+/* A plain page in the conversation's place (page({ main })). */
+.ovt-page { max-width: 980px; width: 100%; box-sizing: border-box; margin: 0 auto; }
+
/* ── The composer ───────────────────────────────────────────────────────────────────────────────────────────── */
/* ── The composer ───────────────────────────────────────────────────────────────────────────────────────────── */
.ovt-dock { position: sticky; bottom: var(--ovnav-appbar-h, 0px); z-index: 15; padding: 8px max(12px, env(safe-area-inset-right, 0px)) calc(10px + env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px));
.ovt-dock { position: sticky; bottom: var(--ovnav-appbar-h, 0px); z-index: 15; padding: 8px max(12px, env(safe-area-inset-right, 0px)) calc(10px + env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px));
background: linear-gradient(to top, var(--bg-primary, #0a0f1c) 70%, transparent); }
background: linear-gradient(to top, var(--bg-primary, #0a0f1c) 70%, transparent); }
@@ -172,5 +214,5 @@ html[data-ov-motion=reduced] .ovt-answer.is-revealing .ovt-sw { opacity: 1 !impo
@@ -172,5 +214,5 @@ html[data-ov-motion=reduced] .ovt-answer.is-revealing .ovt-sw { opacity: 1 !impo
.ovt-dot, .ovt-turn[data-open] .ovt-avatar, .ovt-w-live, .ovt-turn.is-new .ovt-bubble, .ovt-turn.is-fresh .ovt-answer { animation: none; }
.ovt-dot, .ovt-turn[data-open] .ovt-avatar, .ovt-w-live, .ovt-turn.is-new .ovt-bubble, .ovt-turn.is-fresh .ovt-answer { animation: none; }
.ovt-answer.is-revealing .ovt-sw { opacity: 1 !important; transition: none !important; }
.ovt-answer.is-revealing .ovt-sw { opacity: 1 !important; transition: none !important; }
.ovt-w-live { color: var(--text-secondary, #96a7c2); background: none; }
.ovt-w-live { color: var(--text-secondary, #96a7c2); background: none; }
- .ovt-side, .ovt-chev { transition: none; }
+ .ovt-panel, .ovt-chev { transition: none; }
}
}
@@ -42,6 +42,17 @@ const P = {
@@ -42,6 +42,17 @@ const P = {
send: 'M5 12l14-7-5 14-2.5-5.5z',
send: 'M5 12l14-7-5 14-2.5-5.5z',
stop: 'M7 7h10v10H7z',
stop: 'M7 7h10v10H7z',
attach: 'M16.5 6.5l-8 8a2.5 2.5 0 0 0 3.5 3.5l8-8a4.5 4.5 0 0 0-6.4-6.4l-8 8',
attach: 'M16.5 6.5l-8 8a2.5 2.5 0 0 0 3.5 3.5l8-8a4.5 4.5 0 0 0-6.4-6.4l-8 8',
+ handover: 'M4 8h13l-3-3M20 16H7l3 3',
+ wait: 'M12 7v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z',
+ panel: 'M4 4h16v16H4zM15 4v16',
+ shield: 'M12 3 5 6v6c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6zM9 12l2 2 4-4',
+ // A step's kind (work rows' icon): read, find, edit, run, web, sub.
+ read: 'M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
+ find: 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-3.5-3.5',
+ edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4',
+ run: 'M3 4h18v16H3zM7 9l3 3-3 3M13 15h4',
+ web: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18',
+ sub: 'M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18',
};
};
const icon = (name, cls = '') => `<svg class="ovt-ic${cls ? ` ${cls}` : ''}" viewBox="0 0 24 24" aria-hidden="true"><path d="${P[name] || P.note}"/></svg>`;
const icon = (name, cls = '') => `<svg class="ovt-ic${cls ? ` ${cls}` : ''}" viewBox="0 0 24 24" aria-hidden="true"><path d="${P[name] || P.note}"/></svg>`;
@@ -70,30 +81,46 @@ function group(items, whenOf, now = Date.now()) {
@@ -70,30 +81,46 @@ function group(items, whenOf, now = Date.now()) {
* shared styling across everything"): New chat on top, Find a chat (it filters as you type with ov-thread.js; without
* shared styling across everything"): New chat on top, Find a chat (it filters as you type with ov-thread.js; without
* script it is a GET to `findAction`), the site's places as vertical rows (nav), then the chats in their groups. A column
* script it is a GET to `findAction`), the site's places as vertical rows (nav), then the chats in their groups. A column
* beside the conversation on a wide screen, a drawer on a phone (kit-web.js opens it from head()'s button).
* beside the conversation on a wide screen, a drawer on a phone (kit-web.js opens it from head()'s button).
- * groups: [{ name, items: [{ href, title, current, live, mark }] }]; nav: kit.sidebar's [{ href, label, icon, here, count }]
+ * groups: [{ name, items: [{ href, title, current, live, dot, meta, mark }] }]; nav: kit.sidebar's [{ href, label, icon, here, count }]
+ * pinned: chats kept above the groups (Codes' conductor), the same shape as a group's items
+ * dot: 'busy' (live sets it), 'needs' (waiting for the person) or 'bad'; meta: a quiet word after the title ("2h")
+ * findSubmit: Enter in Find a chat runs the site's search at findAction (every message), not only the filter
* Draw with kit.css and kit-web.js beside thread.css and ov-thread.js.
* Draw with kit.css and kit-web.js beside thread.css and ov-thread.js.
*/
*/
-function sidebar({ id = 'ovt-chats', label = 'Your chats', newHref = null, newLabel = 'New chat', find = true, findAction = '', groups = [], empty = 'Your chats show here.', nav = [], foot = [] } = {}) {
- const sections = groups.filter((g) => g && g.items && g.items.length).map((g) => ({
- title: g.name,
- items: g.items.map((c) => ({ href: c.href, label: clip(c.title, 80), here: !!c.current, dot: c.live ? 'busy' : null, mark: c.mark || '' })),
- }));
- return kit.sidebar({
+function sidebar({ id = 'ovt-chats', label = 'Your chats', newHref = null, newLabel = 'New chat', find = true, findAction = '', findPlaceholder = 'Find a chat', findSubmit = false, groups = [], pinned = [], empty = 'Your chats show here.', nav = [], foot = [] } = {}) {
+ const item = (c) => ({ href: c.href, label: clip(c.title, 80), here: !!c.current, dot: c.dot || (c.live ? 'busy' : null), meta: c.dot || c.live ? '' : (c.meta || ''), mark: c.mark || '' });
+ const sections = [
+ ...(pinned && pinned.length ? [{ title: 'Pinned', items: pinned.map(item) }] : []),
+ ...groups.filter((g) => g && g.items && g.items.length).map((g) => ({ title: g.name, items: g.items.map(item) })),
+ ];
+ const html = kit.sidebar({
id, label,
id, label,
action: newHref ? { href: newHref, label: newLabel, icon: 'plus' } : null,
action: newHref ? { href: newHref, label: newLabel, icon: 'plus' } : null,
- find: find ? { action: findAction || newHref || '', name: 'q', placeholder: 'Find a chat', label: 'Find a chat' } : null,
+ find: find ? { action: findAction || newHref || '', name: 'q', placeholder: findPlaceholder, label: findPlaceholder } : null,
nav,
nav,
sections: sections.length ? sections : [{ title: '', items: [], empty }],
sections: sections.length ? sections : [{ title: '', items: [], empty }],
foot: foot.length ? `<p class="ovt-side-foot">${foot.map((f) => `<a href="${esc(f.href)}">${esc(f.label)}</a>`).join('')}</p>` : '',
foot: foot.length ? `<p class="ovt-side-foot">${foot.map((f) => `<a href="${esc(f.href)}">${esc(f.label)}</a>`).join('')}</p>` : '',
});
});
+ return findSubmit ? html : html.replace('<form class="ovk-sidebar-find"', '<form class="ovk-sidebar-find" data-ovt-filter-only');
}
}
/**
/**
* The sticky head over the thread: the button that opens the chats on a phone (kit.sidebarButton), the title, New chat,
* The sticky head over the thread: the button that opens the chats on a phone (kit.sidebarButton), the title, New chat,
- * and the caller's actions. `sidebar` names the sidebar's id (false for none).
+ * and the caller's actions. `sidebar` names the sidebar's id (false for none). `panel` names a panel()'s id: a button
+ * that opens it where it is a drawer (below 1180 px), labelled `panelLabel`.
*/
*/
-function head({ title = '', newHref = null, sidebar: sid = 'ovt-chats', actions = '' } = {}) {
- return `<header class="ovt-head" data-ovt-head>${sid ? kit.sidebarButton({ id: sid, label: 'Your chats' }) : ''}<h1 class="ovt-title">${esc(title)}</h1>${actions || ''}${newHref ? `<a class="ovt-btn" href="${esc(newHref)}" aria-label="New chat">${icon('plus')}</a>` : ''}</header>`;
+function head({ title = '', newHref = null, sidebar: sid = 'ovt-chats', sidebarLabel = 'Your chats', actions = '', panel: pid = null, panelLabel = 'Details' } = {}) {
+ const panelBtn = pid ? `<button type="button" class="ovt-btn ovt-panel-open" data-ovt-panel-open aria-controls="${esc(pid)}" aria-expanded="false" aria-label="${esc(panelLabel)}" title="${esc(panelLabel)}">${icon('panel')}</button>` : '';
+ return `<header class="ovt-head" data-ovt-head>${sid ? kit.sidebarButton({ id: sid, label: sidebarLabel }) : ''}${title ? `<h1 class="ovt-title">${esc(title)}</h1>` : '<span class="ovt-title"></span>'}${actions || ''}${newHref ? `<a class="ovt-btn" href="${esc(newHref)}" aria-label="New chat">${icon('plus')}</a>` : ''}${panelBtn}</header>`;
+}
+
+// An agent's colour, only as a colour: #rgb…#rrggbbaa or a theme token, never anything else in a style attribute.
+const COLOUR = /^(#[0-9a-f]{3,8}|var\(--[a-z0-9-]+\))$/i;
+/** An agent's face: its initials (or the caller's safe mark) in its own colour. who: { name, initials, color, mark } */
+function face(who = {}) {
+ const colour = who.color && COLOUR.test(String(who.color)) ? ` style="--ovt-who:${who.color}"` : '';
+ const inner = who.mark != null ? who.mark : esc(String(who.initials || String(who.name || '?').slice(0, 1).toUpperCase()).slice(0, 2));
+ return `<span class="ovt-avatar is-who"${colour} aria-hidden="true">${inner}</span>`;
}
}
const bytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${Number(n) || 0} B`);
const bytes = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${Number(n) || 0} B`);
@@ -108,32 +135,48 @@ function files(list = []) {
@@ -108,32 +135,48 @@ function files(list = []) {
/**
/**
* One turn: what the person wrote (a bubble, with any files), and the reply (the caller's markup: work(), answer(),
* One turn: what the person wrote (a bubble, with any files), and the reply (the caller's markup: work(), answer(),
* card()…). open marks a turn still working (its avatar breathes; ov-thread.js follows it).
* card()…). open marks a turn still working (its avatar breathes; ov-thread.js follows it).
+ * who: { name, initials, color, sub, mark } heads the reply with an agent's face and name, for a site where several
+ * agents work in one conversation (Codes hands a task from one to another); sub is a quiet line ("sonnet · Thorough")
+ * you.caption: a small word over the person's bubble ("Hand-over brief"); reply: null draws only the person's side
*/
*/
-function turn({ id = null, state = null, open = false, you = {}, reply = '', avatar = true, attrs = {} } = {}) {
+function turn({ id = null, state = null, open = false, you = {}, reply = '', avatar = true, who = null, attrs = {} } = {}) {
const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('');
const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('');
const mine = you && (you.html != null || you.text != null || (you.files && you.files.length))
const mine = you && (you.html != null || you.text != null || (you.files && you.files.length))
- ? `<div class="ovt-you">${files(you.files)}${you.html != null ? `<div class="ovt-bubble">${you.html}</div>` : you.text != null ? `<p class="ovt-bubble">${esc(you.text)}</p>` : ''}</div>` : '';
+ ? `<div class="ovt-you">${you.caption ? `<span class="ovt-caption">${esc(you.caption)}</span>` : ''}${files(you.files)}${you.html != null ? `<div class="ovt-bubble">${you.html}</div>` : you.text != null ? `<p class="ovt-bubble">${esc(you.text)}</p>` : ''}</div>` : '';
+ const mark = who ? face(who) : avatar ? '<span class="ovt-avatar" aria-hidden="true"></span>' : '';
+ const name = who ? `<p class="ovt-who"><b>${esc(who.name || 'Agent')}</b>${who.sub ? `<span>${esc(who.sub)}</span>` : ''}</p>` : '';
+ const bot = reply == null ? '' : `<div class="ovt-bot">${mark}<div class="ovt-reply">${name}${reply}</div></div>`;
return `<li class="ovt-turn${state ? ` state-${esc(state)}` : ''}"${attr('id', id ? `t-${id}` : null)}${attr('data-turn', id)}${attr('data-state', state)}${open ? ' data-open' : ''}${extra}>
return `<li class="ovt-turn${state ? ` state-${esc(state)}` : ''}"${attr('id', id ? `t-${id}` : null)}${attr('data-turn', id)}${attr('data-state', state)}${open ? ' data-open' : ''}${extra}>
-${mine}<div class="ovt-bot">${avatar ? '<span class="ovt-avatar" aria-hidden="true"></span>' : ''}<div class="ovt-reply">${reply}</div></div>
+${mine}${bot}
</li>`;
</li>`;
}
}
+/** A line across the thread between turns: a hand-over to another agent ("Codex took over"), a wait. icon: handover | wait */
+function divider({ text = '', icon: ic = 'handover' } = {}) {
+ return `<li class="ovt-divider"><span>${icon(ic)}${esc(text)}</span></li>`;
+}
+
/**
/**
* The work, folded under the answer: "Worked for 12 s · 3 tools", or, while it works, the live line ("Reading the
* The work, folded under the answer: "Worked for 12 s · 3 tools", or, while it works, the live line ("Reading the
- * page…"), open. rows: [{ kind: 'step' | 'note' | 'you' | 'tool', text, level, name, input, output, ok, running }].
+ * page…"), open. rows: [{ kind: 'step' | 'note' | 'you' | 'tool', text, level, name, input, output, ok, running, icon }].
+ * icon: a thread icon for the row (a coding agent's read, find, edit, run, web, sub); a tool that finished with no output
+ * is a plain row, with nothing to open. running: false with no ok is a step that ended without a result: no mark.
*/
*/
function work({ open = false, live = 'Working', took = null, rows = [] } = {}) {
function work({ open = false, live = 'Working', took = null, rows = [] } = {}) {
const tools = rows.filter((r) => r.kind === 'tool').length;
const tools = rows.filter((r) => r.kind === 'tool').length;
if (!open && !rows.length) return '';
if (!open && !rows.length) return '';
const items = rows.map((r) => {
const items = rows.map((r) => {
if (r.kind === 'tool') {
if (r.kind === 'tool') {
- const st = r.running || r.ok == null ? 'is-run' : r.ok ? 'is-ok' : 'is-bad';
- return `<li class="ovt-w-tool ${st}"><details><summary>${icon('tool')}<b>${esc(r.name || 'Tool')}</b>${r.input ? `<span class="ovt-w-in">${esc(clip(r.input, 90))}</span>` : ''}<span class="ovt-w-st">${st === 'is-run' ? '<span class="ovt-dot" aria-hidden="true"></span>' : icon(st === 'is-ok' ? 'ok' : 'bad')}</span></summary>
+ const st = r.running === false && r.ok == null ? 'is-done' : r.running || r.ok == null ? 'is-run' : r.ok ? 'is-ok' : 'is-bad';
+ const mark = st === 'is-run' ? '<span class="ovt-dot" aria-hidden="true"></span>' : st === 'is-done' ? '' : icon(st === 'is-ok' ? 'ok' : 'bad');
+ const head = `${icon(r.icon || 'tool')}<b>${esc(r.name || 'Tool')}</b>${r.input ? `<span class="ovt-w-in">${esc(clip(r.input, 90))}</span>` : ''}<span class="ovt-w-st">${mark}</span>`;
+ if (r.output == null && st !== 'is-run') return `<li class="ovt-w-tool ${st}"><div class="ovt-w-row">${head}</div></li>`;
+ return `<li class="ovt-w-tool ${st}"><details><summary>${head}</summary>
${r.output != null ? `<pre class="ovt-w-out">${esc(clip(r.output, 1500))}</pre>` : '<p class="ovt-w-out ovt-muted">Running…</p>'}</details></li>`;
${r.output != null ? `<pre class="ovt-w-out">${esc(clip(r.output, 1500))}</pre>` : '<p class="ovt-w-out ovt-muted">Running…</p>'}</details></li>`;
}
}
if (r.kind === 'you') return `<li class="ovt-w-you">${icon('you')}<p>${esc(clip(r.text, 300))}</p></li>`;
if (r.kind === 'you') return `<li class="ovt-w-you">${icon('you')}<p>${esc(clip(r.text, 300))}</p></li>`;
if (r.kind === 'note') return `<li class="ovt-w-note${r.level ? ` is-${esc(r.level)}` : ''}">${icon('note')}<p>${esc(clip(r.text, 700))}</p></li>`;
if (r.kind === 'note') return `<li class="ovt-w-note${r.level ? ` is-${esc(r.level)}` : ''}">${icon('note')}<p>${esc(clip(r.text, 700))}</p></li>`;
- return `<li class="ovt-w-step">${icon('step')}<span>${esc(clip(r.text, 300))}</span></li>`;
+ return `<li class="ovt-w-step">${icon(r.icon || 'step')}<span>${esc(clip(r.text, 300))}</span></li>`;
});
});
const line = open
const line = open
? `<span class="ovt-w-live" data-ovt-live>${esc(live)}…</span>`
? `<span class="ovt-w-live" data-ovt-live>${esc(live)}…</span>`
@@ -158,9 +201,33 @@ ${sources.length ? `<p class="ovt-sources">${sources.slice(0, 8).map((u) => `<a
@@ -158,9 +201,33 @@ ${sources.length ? `<p class="ovt-sources">${sources.slice(0, 8).map((u) => `<a
${meta.length || acts ? `<div class="ovt-meta">${meta.filter(Boolean).map((m) => `<span>${esc(m)}</span>`).join('')}<span class="ovt-acts">${acts}</span></div>` : ''}`;
${meta.length || acts ? `<div class="ovt-meta">${meta.filter(Boolean).map((m) => `<span>${esc(m)}</span>`).join('')}<span class="ovt-acts">${acts}</span></div>` : ''}`;
}
}
-/** A card in the reply: a question waiting for the person (wait), a failure (bad), or a note (info). html is safe markup. */
-function card({ tone = 'info', title = '', text = null, html = '' } = {}) {
- return `<div class="ovt-card is-${esc(tone)}">${title ? `<p><b>${esc(title)}</b>${text != null ? ` ${esc(text)}` : ''}</p>` : text != null ? `<p>${esc(text)}</p>` : ''}${html}</div>`;
+/**
+ * A card in the reply: a question waiting for the person (wait), a failure (bad), or a note (info). html is safe markup.
+ * With form it is a form that asks something (an agent wanting to run a command: Allow or Deny), posting without script:
+ * code: what it wants to run (a block that scrolls inside itself); icon: a thread icon before the title (shield, wait…)
+ * form: { action, hidden: { k: v }, buttons: [{ name, value, label, quiet }] }
+ */
+function card({ tone = 'info', title = '', text = null, html = '', code = null, icon: ic = null, form = null } = {}) {
+ const lead = ic ? icon(ic) : '';
+ const said = title ? `<b>${esc(title)}</b>${text != null ? ` ${esc(text)}` : ''}` : text != null ? esc(text) : '';
+ const words = !said ? '' : ic ? `<p class="ovt-card-h">${lead}<span>${said}</span></p>` : `<p>${said}</p>`;
+ const block = code != null ? `<pre class="ovt-card-code" tabindex="0">${esc(code)}</pre>` : '';
+ if (!form) return `<div class="ovt-card is-${esc(tone)}">${words}${block}${html}</div>`;
+ const hid = Object.entries(form.hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
+ const buttons = (form.buttons || []).map((b) => `<button type="submit" class="ovt-card-btn${b.quiet ? ' is-quiet' : ''}"${attr('name', b.name)}${attr('value', b.value)}>${esc(b.label)}</button>`).join('');
+ return `<form class="ovt-card is-${esc(tone)}" method="post" action="${esc(form.action || '')}">${hid}${words}${block}${html}${buttons ? `<div class="ovt-card-acts">${buttons}</div>` : ''}</form>`;
+}
+
+/**
+ * The panel beside the conversation: what the work produced (who worked on it, where, the changes, the proof). A third
+ * column from 1180 px; below that a drawer from the right that head({ panel: id })'s button opens (ov-thread.js).
+ * sections: [{ title, html, rows: [{ who, icon, iconHtml, title, sub, html }] }]; a row's who draws an agent's face (turn's
+ * who), icon a thread icon, iconHtml the site's own safe icon; html is the caller's safe markup in place of title and sub.
+ */
+function panel({ id = 'ovt-panel', title = 'Details', sections = [] } = {}) {
+ const row = (r) => `<li>${r.who ? face(r.who) : r.iconHtml != null ? r.iconHtml : r.icon ? icon(r.icon) : ''}<span>${r.html != null ? r.html : `<b>${esc(r.title || '')}</b>${r.sub ? `<small>${esc(r.sub)}</small>` : ''}`}</span></li>`;
+ const secs = sections.filter(Boolean).map((s) => `<section class="ovt-panel-sec">${s.title ? `<h3>${esc(s.title)}</h3>` : ''}${s.rows && s.rows.length ? `<ul class="ovt-panel-rows">${s.rows.map(row).join('')}</ul>` : ''}${s.html || ''}</section>`).join('');
+ return `<aside class="ovt-panel" id="${esc(id)}" aria-label="${esc(title)}" data-ovt-panel><div class="ovt-panel-head"><h2>${esc(title)}</h2><button type="button" class="ovt-btn ovt-panel-close" data-ovt-panel-close aria-label="Close">${icon('close')}</button></div>${secs}</aside>`;
}
}
/**
/**
@@ -169,29 +236,40 @@ function card({ tone = 'info', title = '', text = null, html = '' } = {}) {
@@ -169,29 +236,40 @@ function card({ tone = 'info', title = '', text = null, html = '' } = {}) {
* hidden: { parent: 'tsk_…' }; starters: ['Plan a weekend in Lisbon', …] (shown on an empty chat)
* hidden: { parent: 'tsk_…' }; starters: ['Plan a weekend in Lisbon', …] (shown on an empty chat)
* top: the site's safe markup above the box (picked files, a problem line); maxlength: the box's limit
* top: the site's safe markup above the box (picked files, a problem line); maxlength: the box's limit
* stopAction: where Stop posts while a turn works; '' draws Stop hidden, for a page whose script starts turns itself
* stopAction: where Stop posts while a turn works; '' draws Stop hidden, for a page whose script starts turns itself
+ * id, attrs: the form's id (inputs outside it join it with form="id") and the site's data attributes
+ * foot: the site's safe markup under the bar (where the work runs, recent repositories); note: a quiet line under that,
+ * which is also the form's status line (role="status": a site's script says "Sending…" there)
*/
*/
-function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null } = {}) {
+function composer({ action = '', method = 'post', name = 'text', placeholder = 'Message', value = '', busy = false, sendLabel = 'Send', stopAction = null, hidden = {}, tools = '', starters = [], note = '', attach = null, label = 'Message', top = '', maxlength = null, id = null, attrs = {}, foot = '' } = {}) {
+ const extra = Object.entries(attrs || {}).map(([k, v]) => attr(k, v)).join('');
const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
const hid = Object.entries(hidden || {}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `<input type="hidden" name="${esc(k)}" value="${esc(v)}">`).join('');
return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''}
return `${starters.length ? `<div class="ovt-starters">${starters.map((s) => `<button type="button" class="ovt-starter" data-ovt-starter="${esc(s)}">${esc(s)}</button>`).join('')}</div>` : ''}
-<form class="ovt-composer${busy ? ' is-busy' : ''}" method="${esc(method)}" action="${esc(action)}" data-ovt-composer${attach ? ' enctype="multipart/form-data"' : ''}>${hid}
+<form class="ovt-composer${busy ? ' is-busy' : ''}"${attr('id', id)} method="${esc(method)}" action="${esc(action)}" data-ovt-composer${attach ? ' enctype="multipart/form-data"' : ''}${extra}>${hid}
${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label>
${top || ''}<label class="ovt-sr" for="ovt-text">${esc(label)}</label>
<textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea>
<textarea id="ovt-text" name="${esc(name)}" rows="1" placeholder="${esc(placeholder)}"${maxlength ? ` maxlength="${Number(maxlength) || ''}"` : ''} data-ovt-text${busy ? '' : ' required'}>${esc(value)}</textarea>
<div class="ovt-c-row"><div class="ovt-c-tools">${attach ? `<label class="ovt-btn ovt-attach" title="Attach files">${icon('attach')}<span class="ovt-sr">Attach files</span><input type="file" name="${esc(attach.name || 'files')}"${attach.accept ? ` accept="${esc(attach.accept)}"` : ''} multiple data-ovt-files hidden></label>` : ''}${tools || ''}</div>
<div class="ovt-c-row"><div class="ovt-c-tools">${attach ? `<label class="ovt-btn ovt-attach" title="Attach files">${icon('attach')}<span class="ovt-sr">Attach files</span><input type="file" name="${esc(attach.name || 'files')}"${attach.accept ? ` accept="${esc(attach.accept)}"` : ''} multiple data-ovt-files hidden></label>` : ''}${tools || ''}</div>
${stopAction != null ? `<button type="submit" class="ovt-stop"${stopAction ? ` formaction="${esc(stopAction)}" formnovalidate` : ''}${busy && stopAction ? '' : ' hidden'} data-ovt-stop>${icon('stop')}<span>Stop</span></button>` : ''}<button type="submit" class="ovt-send" data-ovt-send aria-label="${esc(sendLabel)}">${icon('send')}<span class="ovt-sr">${esc(sendLabel)}</span></button></div>
${stopAction != null ? `<button type="submit" class="ovt-stop"${stopAction ? ` formaction="${esc(stopAction)}" formnovalidate` : ''}${busy && stopAction ? '' : ' hidden'} data-ovt-stop>${icon('stop')}<span>Stop</span></button>` : ''}<button type="submit" class="ovt-send" data-ovt-send aria-label="${esc(sendLabel)}">${icon('send')}<span class="ovt-sr">${esc(sendLabel)}</span></button></div>
-${note ? `<p class="ovt-c-note" data-ovt-note>${esc(note)}</p>` : ''}
+${foot || ''}<p class="ovt-c-note" data-ovt-note role="status">${esc(note || '')}</p>
</form>`;
</form>`;
}
}
/**
/**
* The whole screen: the sidebar and the conversation (head, the turns or the empty state, the composer). Wrap it in
* The whole screen: the sidebar and the conversation (head, the turns or the empty state, the composer). Wrap it in
* the site's page; on a phone the composer stays at the bottom above the app bar.
* the site's page; on a phone the composer stays at the bottom above the app bar.
+ * panel: a panel() beside the conversation; main: a plain page in the conversation's place (a site's settings,
+ * agents or usage, drawn in the same frame as its chats), with no composer
+ * center: with no turns yet, the welcome and the composer sit together in the middle of the screen (a start page);
+ * cls: the site's classes on the root
*/
*/
-function page({ sidebar: side = '', head: top = '', turns = [], empty = '', composer: comp = '', label = 'Conversation' } = {}) {
- // The kit's sidebar closes on its scrim (kit-web.js).
+function page({ sidebar: side = '', head: top = '', turns = [], empty = '', composer: comp = '', panel: pan = '', label = 'Conversation', main = null, center = false, cls = '' } = {}) {
+ // The kit's sidebar closes on its scrim (kit-web.js); the panel on its own (ov-thread.js).
const scrim = side && side.includes('data-ovk-sidebar') ? '<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>' : '';
const scrim = side && side.includes('data-ovk-sidebar') ? '<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>' : '';
- return `<div class="ovt" data-ovt>${side}${scrim}<section class="ovt-main" aria-label="${esc(label)}">${top}
-<div class="ovt-scroll">${turns.length ? `<ol class="ovt-thread" data-ovt-thread>${turns.join('')}</ol>` : `<div class="ovt-empty-state">${empty}</div><ol class="ovt-thread" data-ovt-thread></ol>`}</div>
-<div class="ovt-dock">${comp}</div></section></div>`;
+ const body = main != null ? `<div class="ovt-scroll ovt-page">${main}</div>`
+ : `<div class="ovt-scroll">${turns.length ? `<ol class="ovt-thread" data-ovt-thread>${turns.join('')}</ol>` : `<div class="ovt-empty-state">${empty}</div><ol class="ovt-thread" data-ovt-thread></ol>`}</div>
+<div class="ovt-dock">${comp}</div>`;
+ const classes = ['ovt', pan ? 'has-panel' : '', center && !turns.length && main == null ? 'is-start' : '', cls || ''].filter(Boolean).map(esc).join(' ');
+ return `<div class="${classes}" data-ovt>${side}${scrim}<section class="ovt-main" aria-label="${esc(label)}">${top}
+${body}</section>${pan ? `${pan}<div class="ovt-panel-scrim" data-ovt-panel-scrim hidden></div>` : ''}</div>`;
}
}
-module.exports = { page, sidebar, head, turn, work, answer, card, composer, files, group, groupOf, icon, esc };
+module.exports = { page, sidebar, head, turn, divider, work, answer, card, panel, composer, files, face, group, groupOf, icon, esc };