openvibers/openvibe.chat Commits

Vibe is the network's conversation UI: the one sidebar, turns, the kit's composer and start page (#323)

OP OpenVibers committed 11 days ago · 6e1faf8b92 · 12 files, +368 −324

* Vibe is the network's conversation UI (openvibe-shared/thread): the one sidebar, turns, the kit's composer and start page; Vibe keeps its modes, attach, results, sources and the socket

* openvibe-shared v4.99.1 (was v4.99.0): the conversation kit, and its head follows the navbar on a phone

* Merge remote-tracking branch 'origin/main' into design/vibe-on-thread-kit

# Conflicts:
#	STATUS.json
#	package-lock.json
#	package.json

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 630b37a..c6aba51 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.chat (Chat's own site: global chat, messages, rooms, settings; the service manifest records it live since 2026-09-24) and openvibe.live (/ws/chat, /api/chat/, /api/dm/, /api/tts/, /api/sounds via an nginx include in openvibe.live's vhost)",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.81.0",
-- "shared": "openvibe-shared v4.99.0",
++ "shared": "openvibe-shared v4.99.1",
"capabilities": [
"chat.message.send",
"chat.event.publish",
ddiff --git a/package-lock.json b/package-lock.json
iindex 37e41bb..2aabe14 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.1",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
@@@ -1339,9 +1339,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.99.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",
-- "integrity": "sha512-i8iioHF2nBboP5RHAoPKCVn6pYjfOqgDhkX4k8KIGLKqt5ItClwKL8S1giWJ7hFZc2XkuSsRHEC05nArMHqtyg==",
++ "version": "4.99.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.1",
++ "integrity": "sha512-9UhImTuFiSurZ1XnDfDeihlexwzWfCk2Pg+v6QAJ7GYf3HXdIaNoQO2XQJVu5+izf3FD+Ed0PyDYCRalSfPjOw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex e01cc8d..2af3602 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.1",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex c230279..983791b 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -1,7 +1,8 @@
/* Vibe, OpenVibe's assistant, on openvibe.chat (server/web/vibe-pages.js): the full-window app. The chats down the left,
the conversation in a readable column, one composer docked at the bottom. Colours are the network theme's (chat.css
maps them to --oc-*), so every theme, light ones included, carries through. */
--body.oc-app-body { overflow: hidden; }
++/* The inbox is a full-window app (its own scroll box); Vibe's pages are the conversation kit's, and the page scrolls. */
++body.oc-app-body:has(.vb) { overflow: hidden; }
.vb-wrap { margin: 0; padding: 0; max-width: none; }
.vb {
--vb-rail: 272px;
@@@ -363,3 +364,34 @@ body.oc-app-body { overflow: hidden; }
.vb-res-c.is-stopped { border-color: var(--border, color-mix(in srgb, var(--text-primary, #fff) 10%, transparent)); background: transparent; }
.vb-res-c.is-stopped > svg { stroke: var(--text-muted, #8b8b96); }
.vb-res-c.is-stopped .vb-res-t { color: var(--text-muted, #8b8b96); }
++
++/* ── Vibe on the network's conversation UI (openvibe-shared/thread, plan L55 C4) ───────────────────────────────────
++ The kit draws the chats (kit.sidebar), the turns, Copy, the composer and the page; these are Vibe's own parts in it:
++ its icons, the modes and attach in the composer's tools, the picture preview, the fine print, the search's snippets,
++ the chat's options and the welcome. (The inbox still draws the older .vb layout above.) */
++.vbk { --vb-col: 780px; }
++.vbk svg:not(.ovt-ic):not(.ovk-i) { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; flex: none; }
++.vbk .ovt-btn svg:not(.ovt-ic) { width: 20px; height: 20px; }
++.vbk .vb-modes { order: 0; flex-basis: auto; margin: 0; padding: 0; }
++.vbk .vb-attach { flex: none; width: 36px; height: 36px; margin: 0; }
++.vbk .vb-preview { max-width: none; margin: 0 0 8px; }
++.vbk .vb-fine { margin: 6px 4px 0; text-align: left; }
++.vbk .vb-fine a { color: var(--oc-accent); }
++.vbk .ovt-c-note:empty { display: none; }
++.vbk .vb-allow { margin: 8px 8px 0; }
++.vbk .vb-local { display: block; max-width: 220px; max-height: 180px; margin-top: 8px; border-radius: 12px; object-fit: cover; }
++/* A search: where each chat matched, under its name. */
++.vbk .ovk-sidebar-item.vb-hit { flex-wrap: wrap; row-gap: 2px; padding-block: 6px; }
++.vbk .vb-hit-s { flex-basis: 100%; min-width: 0; font-size: .78rem; line-height: 1.35; color: var(--oc-muted); overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
++.vbk .vb-hit-s mark { background: color-mix(in srgb, var(--oc-accent) 28%, transparent); color: inherit; border-radius: 3px; padding: 0 1px; }
++/* The chat's options (rename, delete) open from the head. */
++.vbk .vb-actions > summary { cursor: pointer; }
++.vbk .vb-pop { right: 0; }
++/* What Vibe did, its sources and how, under the answer: the kit's spacing. */
++.vbk .ovt-reply > .vb-res, .vbk .ovt-reply > .vb-sources, .vbk .ovt-reply > .vb-how { margin-top: 0; }
++.vbk .vb-retry { display: inline; margin: 0; }
++/* The welcome: Vibe's mark, the greeting, four ideas (the kit's start page puts the composer under them). */
++.vbk .vb-hello { min-height: 0; padding: 0; }
++.vbk .ovt-answer.vb-streaming { opacity: .92; }
++/* Memory: a plain page in the frame; on a wide screen its head held only New chat, which the chats column has. */
++@media (min-width: 761px) { .vbk .ovt.is-page .ovt-head { display: none; } }
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex 29618df..6bb085c 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -1,8 +1,11 @@
--/* Vibe on openvibe.chat (server/web/vibe-pages.js): makes the app live. The page works without this file (forms post,
-- the server renders every message); here a first message starts the chat in place, answers arrive over the room
-- socket (/ws/chat join_room, room_message, room_ai_typing), the composer grows with its text and sends on Enter, the
-- rail slides in on phones, and a draft typed while signed out survives the sign-in. A person's text is always set
-- with textContent; Vibe's answers go through VibeMarkdown (public/web/vibe-md.js), which escapes everything first. */
++/* Vibe on openvibe.chat (server/web/vibe-pages.js): makes the app live. The page is the network's conversation UI
++ (openvibe-shared/thread, plan L55 C4): ov-thread.js grows the composer, sends on Enter, adds Copy, filters the chats
++ and keeps the thread at the bottom; kit-web.js opens the chats on a phone. The page works without either (forms
++ post, the server renders every message). Here: a first message starts the chat in place, answers arrive over the
++ room socket (/ws/chat join_room, room_message, room_ai_typing, room_ai_delta) and are written in as they come, a
++ picture or a text file goes with a message, the modes, Stop, Try again, and a draft typed while signed out survives
++ the sign-in. A person's text is always set with textContent; Vibe's answers go through VibeMarkdown
++ (public/web/vibe-md.js), which escapes everything first. */
(function () {
'use strict';
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
@@@ -10,44 +13,42 @@
var MD = window.VibeMarkdown;
var DRAFT = 'ov-vibe-draft';
var app = document.getElementById('vb');
-- var scroll = document.getElementById('vb-scroll');
++ if (!app) return;
++ var box = app.querySelector('[data-ovt]');
var form = document.getElementById('vb-composer');
-- var input = document.getElementById('vb-input');
-- var titleEl = document.getElementById('vb-title');
++ var input = form && form.querySelector('[data-ovt-text]');
++ var sendBtn = form && form.querySelector('[data-ovt-send]');
++ var stopBtn = form && form.querySelector('[data-ovt-stop]');
var room = cfg.room || null;
var latest = Number(cfg.latest) || 0;
var ws = null, delay = 1000, sending = false, waitTimer = null;
var fileInput = document.getElementById('vb-file');
var previewEl = document.getElementById('vb-preview');
var picture = null; // { file, url }: a picture waiting to go with the next message
++ var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function token() { var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || ''); return m ? decodeURIComponent(m[1]) : undefined; }
function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }
// The browser's IANA zone (America/New_York) with each message: Vibe's prompt then states the person's local date.
function timezone() { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch (e) { return null; } }
-- function nearBottom() { return scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 160; }
-- function toBottom() { scroll.scrollTop = scroll.scrollHeight; }
-- function markSvg() {
-- var s = el('span', 'vb-mark'); s.setAttribute('aria-hidden', 'true');
-- s.innerHTML = '<svg viewBox="0 0 24 24"><path d="M5 5l7 14 7-14"/></svg>';
-- return s;
-- }
++ // The page scrolls as a whole (the kit has no inner scroll box).
++ function nearBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 220; }
++ function toBottom() { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: reduce ? 'auto' : 'smooth' }); }
++ function kitEnhance(scope) { if (window.OVThread && window.OVThread.enhance) window.OVThread.enhance(scope); }
++ // The kit's composer reads its own input event (it grows and wakes Send); a box filled from here sends one too.
++ function changed() { if (input) input.dispatchEvent(new Event('input', { bubbles: true })); }
// ── The composer ──
-- function grow() { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, window.innerHeight * 0.4) + 'px'; form.classList.toggle('is-empty', !input.value.trim() && !picture); }
if (input) {
-- input.addEventListener('input', grow);
-- input.addEventListener('keydown', function (e) {
-- if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && window.matchMedia('(hover: hover)').matches) { e.preventDefault(); form.requestSubmit ? form.requestSubmit() : form.submit(); }
-- });
++ // A picture alone may be sent: Send wakes for it even with no words (the kit wakes it only for words).
++ input.addEventListener('input', function () { if (picture && sendBtn) sendBtn.disabled = false; });
var saved = cfg.signedIn ? store(function (s) { var v = s.getItem(DRAFT); s.removeItem(DRAFT); return v; }) : null;
-- if (saved && !input.value) input.value = saved;
-- grow();
++ if (saved && !input.value) { input.value = saved; changed(); }
if (!('ontouchstart' in window)) input.focus();
}
-- document.querySelectorAll('.vb-chip[data-prompt]').forEach(function (b) {
-- b.addEventListener('click', function () { input.value = b.getAttribute('data-prompt'); grow(); form.requestSubmit ? form.requestSubmit() : form.submit(); });
++ [].forEach.call(document.querySelectorAll('.vb-chip[data-prompt]'), function (b) {
++ b.addEventListener('click', function () { input.value = b.getAttribute('data-prompt'); changed(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); });
});
// ── A picture with the message: it goes to OpenVibe.Pics as the person, and Vibe sees it ──
@@@ -58,6 +59,7 @@
if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; }
if (fileInput) fileInput.value = '';
if (input) input.required = true;
++ changed();
}
// A text file goes into the message itself, as a fenced block named after it, cut to what still fits in the box.
var TEXT_EXT = /\.(txt|md|markdown|csv|tsv|json|log|xml|ya?ml|toml|ini|html|css|m?js|ts|py|go|rs|java|c|h|cpp|rb|php|sh|sql)$/i;
@@@ -74,7 +76,7 @@
if (space < 200) { notice('The box is nearly full. Send what is there first.'); return; }
var cut = text.length > space;
input.value = head + (cut ? text.slice(0, space - 20) + '\n… (cut to fit)' : text) + '\n```';
-- grow(); input.focus();
++ changed(); input.focus();
if (cut) notice('The file was longer than a message: its first part is in the box.');
};
reader.onerror = function () { notice('That file could not be read.'); };
@@@ -89,12 +91,12 @@
picture = { file: file, url: URL.createObjectURL(file) };
var fig = el('figure');
var img = el('img'); img.src = picture.url; img.alt = 'The picture you are sending';
-- var rm = el('button', null, '\u00d7'); rm.type = 'button'; rm.setAttribute('aria-label', 'Remove the picture');
++ var rm = el('button', null, '×'); rm.type = 'button'; rm.setAttribute('aria-label', 'Remove the picture');
rm.addEventListener('click', function () { clearPicture(); input.focus(); });
fig.appendChild(img); fig.appendChild(rm);
previewEl.appendChild(fig); previewEl.hidden = false;
input.required = false;
-- form.classList.remove('is-empty');
++ if (sendBtn) sendBtn.disabled = false;
input.focus();
}
var attachBtn = app.querySelector('[data-vb-attach]');
@@@ -108,92 +110,81 @@
});
}
-- // ── The thread ──
-- function thread() {
-- var t = document.getElementById('vb-thread');
-- if (t) return t;
-- var hello = scroll.querySelector('.vb-hello'); if (hello) hello.remove();
-- app.classList.remove('vb-empty');
-- t = el('ol', 'vb-thread'); t.id = 'vb-thread';
-- scroll.appendChild(t);
++ // ── The thread (the kit's markup: a turn is the person's message and the answer under it) ──
++ function threadEl() {
++ var t = app.querySelector('[data-ovt-thread]');
++ // The first message: the welcome goes, and the composer docks at the bottom.
++ if (box) box.classList.remove('is-start');
++ var hello = app.querySelector('.ovt-empty-state'); if (hello) hello.hidden = true;
return t;
}
-- function have(id) { return id && document.querySelector('.vb-turn[data-id="' + Number(id) + '"]'); }
++ function have(id) { return id && app.querySelector('[data-me="' + Number(id) + '"],[data-ai="' + Number(id) + '"]'); }
++ function lastTurn() { var t = threadEl(); return t && t.lastElementChild; }
function meTurn(text, id, localPicture) {
-- var li = el('li', 'vb-turn vb-me'); if (id) li.dataset.id = String(id);
-- var b = el('div', 'vb-bubble'); b.appendChild(el('p', 'oc-sr', 'You said:'));
-- String(text).split('\n').forEach(function (line, i) { if (i) b.appendChild(document.createElement('br')); b.appendChild(document.createTextNode(line)); });
++ var li = el('li', 'ovt-turn is-new'); if (id) li.setAttribute('data-me', String(id));
++ var you = el('div', 'ovt-you');
++ var b = el('div', 'ovt-bubble');
++ b.appendChild(el('span', 'oc-sr', 'You said:'));
++ b.appendChild(document.createTextNode(String(text)));
if (localPicture) { var img = el('img', 'vb-local'); img.src = localPicture; img.alt = 'Your picture'; b.appendChild(img); }
-- li.appendChild(b);
++ you.appendChild(b); li.appendChild(you);
return li;
}
-- function aiTurn(m) {
-- var li = el('li', 'vb-turn vb-ai'); li.dataset.id = String(m.id);
-- li.appendChild(markSvg());
-- var body = el('div', 'vb-body'); body.appendChild(el('p', 'oc-sr', 'Vibe said:'));
-- var md = el('div', 'vb-md'); md.innerHTML = MD ? MD.render(m.message) : '';
-- if (!MD) md.textContent = m.message;
-- body.appendChild(md); li.appendChild(body);
-- // What Vibe did, as on the server's page (resultChips in server/web/vibe-pages.js): Undo is the same plain form.
++ // The answer side of a turn: Vibe's mark, then its reply.
++ function botOf(li) {
++ var bot = li.querySelector('.ovt-bot');
++ if (!bot) {
++ bot = el('div', 'ovt-bot');
++ var av = el('span', 'ovt-avatar'); av.setAttribute('aria-hidden', 'true');
++ bot.appendChild(av); bot.appendChild(el('div', 'ovt-reply'));
++ li.appendChild(bot);
++ }
++ return bot.querySelector('.ovt-reply');
++ }
++ // Vibe's answer, as the server draws it (answerOf in server/web/vibe-pages.js): Undo is the same plain form.
++ function fillAnswer(reply, m) {
++ reply.textContent = '';
++ reply.appendChild(el('p', 'oc-sr', 'Vibe said:'));
++ var ans = el('div', 'ovt-answer'); ans.setAttribute('data-ovt-answer', '');
++ if (MD) ans.innerHTML = MD.render(m.message); else ans.textContent = m.message;
++ reply.appendChild(ans);
var res = Array.isArray(m.results) ? m.results : [];
if (res.length && room) {
-- var box = el('div', 'vb-res');
++ var resBox = el('div', 'vb-res');
res.slice(0, 4).forEach(function (r, i) {
if (!r || (r.kind !== 'reminder' && r.kind !== 'timer')) return;
var set = r.action === 'set';
var c = el('div', 'vb-res-c' + (set ? '' : ' is-stopped'));
var t = el('div', 'vb-res-t');
-- var b = el('b', '', (r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped')) + ':');
-- t.appendChild(b); t.appendChild(document.createTextNode(' ' + String(r.title || '')));
++ t.appendChild(el('b', '', (r.kind === 'timer' ? (set ? 'Timer' : 'Timer stopped') : (set ? 'Reminder set' : 'Reminder stopped')) + ':'));
++ t.appendChild(document.createTextNode(' ' + String(r.title || '')));
if (r.when && set) t.appendChild(el('small', '', String(r.when)));
c.appendChild(t);
var a = el('div', 'vb-res-a');
if (set) {
var f = el('form'); f.method = 'post'; f.action = '/vibe/' + room + '/undo';
[['message_id', String(m.id)], ['index', String(i)]].forEach(function (kv) { var h = el('input'); h.type = 'hidden'; h.name = kv[0]; h.value = kv[1]; f.appendChild(h); });
-- var u = el('button', 'vb-act', 'Undo'); u.type = 'submit'; f.appendChild(u); a.appendChild(f);
++ var u = el('button', 'ovt-act', 'Undo'); u.type = 'submit'; f.appendChild(u); a.appendChild(f);
}
-- if (r.url && /^https:\/\//.test(r.url)) { var o = el('a', 'vb-act', 'Open'); o.href = r.url; a.appendChild(o); }
++ if (r.url && /^https:\/\//.test(r.url)) { var o = el('a', 'ovt-act', 'Open'); o.href = r.url; a.appendChild(o); }
if (a.childNodes.length) c.appendChild(a);
-- box.appendChild(c);
++ resBox.appendChild(c);
});
-- body.appendChild(box);
++ reply.appendChild(resBox);
}
-- // Copy, and Try again (it moves to this, the newest answer, when the person's message came just before).
-- var row = el('div', 'vb-acts');
-- var copy = el('button', 'vb-act', 'Copy'); copy.type = 'button'; copy.setAttribute('data-vb-copy', '');
-- row.appendChild(copy);
-- var prev = thread().lastElementChild;
-- if (room && prev && prev.classList.contains('vb-me')) {
-- [].forEach.call(document.querySelectorAll('.vb-retry'), function (f) { f.remove(); });
-- var form = el('form', 'vb-retry'); form.method = 'post'; form.action = '/vibe/' + room + '/retry';
-- var again = el('button', 'vb-act', 'Try again'); again.type = 'submit'; form.appendChild(again);
-- row.appendChild(form);
++ // Copy (ov-thread.js shows it where the clipboard works), and Try again under the newest answer to the person's
++ // last message (it moves here from an older one).
++ var meta = el('div', 'ovt-meta'); var acts = el('span', 'ovt-acts');
++ var copy = el('button', 'ovt-act'); copy.type = 'button'; copy.setAttribute('data-ovt-copy', ''); copy.hidden = true;
++ copy.appendChild(el('span', null, 'Copy'));
++ acts.appendChild(copy);
++ if (room && reply.closest('.ovt-turn').querySelector('.ovt-you')) {
++ [].forEach.call(app.querySelectorAll('.vb-retry'), function (f) { f.remove(); });
++ var again = el('form', 'vb-retry'); again.method = 'post'; again.action = '/vibe/' + room + '/retry';
++ var b = el('button', 'ovt-act', 'Try again'); b.type = 'submit'; again.appendChild(b);
++ acts.appendChild(again);
}
-- body.appendChild(row);
-- enhance(li);
-- return li;
-- }
-- // Copy buttons: the whole answer as its text, and each code block on its own. Shown only where the clipboard works.
-- function copyText(btn, text) {
-- if (!navigator.clipboard) return;
-- navigator.clipboard.writeText(text).then(function () {
-- // A code block's button draws its label in CSS (so copying the whole answer never picks it up): a class says Copied.
-- var code = btn.classList.contains('vb-copy-code');
-- var was = btn.textContent;
-- if (!code) btn.textContent = 'Copied';
-- btn.classList.add('is-done');
-- setTimeout(function () { if (!code) btn.textContent = was; btn.classList.remove('is-done'); }, 1500);
-- }, function () { notice('Copying did not work here. Select the text instead.'); });
-- }
-- function enhance(scope) {
-- if (!navigator.clipboard) return;
-- [].forEach.call(scope.querySelectorAll('[data-vb-copy]'), function (b) { b.hidden = false; });
-- [].forEach.call(scope.querySelectorAll('.vb-ai .vb-md pre'), function (pre) {
-- if (pre.querySelector('.vb-copy-code')) return;
-- var b = el('button', 'vb-copy-code'); b.type = 'button'; b.setAttribute('aria-label', 'Copy this code');
-- pre.appendChild(b);
-- });
++ meta.appendChild(acts); reply.appendChild(meta);
}
// "How Vibe did this" under an agent task's answer that arrives while the page is open (the server folds it on load).
function attachHow(li, id) {
@@@ -232,110 +223,120 @@
}
// The id "How Vibe did this" links to: the answer's one set of chips, never a second copy.
function sourcesId(li) {
-- var id = li && li.dataset ? li.dataset.id : null;
++ var id = li && li.getAttribute('data-ai');
return id ? 'vb-sources-' + String(id).replace(/[^a-zA-Z0-9_-]/g, '') : null;
}
function renderSources(li, info) {
-- var body = li.querySelector('.vb-body');
-- if (!info || !body || body.querySelector('.vb-sources')) return;
++ var reply = li.querySelector('.ovt-reply');
++ if (!info || !reply || reply.querySelector('.vb-sources')) return;
var links = sourceChips(info);
if (!links.length) return;
var ul = el('ul', 'vb-sources'); ul.setAttribute('aria-label', 'Sources');
var sid = sourcesId(li); if (sid) ul.id = sid;
links.forEach(function (c) { var row = el('li'); row.appendChild(chipLink(c)); ul.appendChild(row); });
-- var md = body.querySelector('.vb-md');
-- if (md && md.nextSibling) body.insertBefore(ul, md.nextSibling);
-- else if (md) body.appendChild(ul);
-- else body.insertBefore(ul, body.firstChild);
++ var ans = reply.querySelector('.ovt-answer');
++ if (ans && ans.nextSibling) reply.insertBefore(ul, ans.nextSibling); else reply.appendChild(ul);
}
function stepsOf(raw) {
var h = null; try { h = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (e) { h = null; }
return h && h.steps && h.steps.length ? { lines: h.steps, sources: h.sources || [], citations: h.citations || [] } : null;
}
function renderHow(li, info) {
-- var body = li.querySelector('.vb-body');
-- if (!info || !body || body.querySelector('.vb-how')) return;
-- renderSources(li, info);
-- var det = el('details', 'vb-how'); det.appendChild(el('summary', null, 'How Vibe did this'));
-- var ul = el('ul');
-- (info.lines || []).forEach(function (l) { ul.appendChild(el('li', null, l)); });
-- var links = sourceChips(info);
-- if (links.length) {
-- var src = el('li', 'vb-how-sources');
-- var link = el('a', null, 'Sources (' + links.length + ') under this answer');
-- var sid = sourcesId(li); if (sid) link.href = '#' + sid;
-- src.appendChild(link);
-- ul.appendChild(src);
-- }
-- if (info.actor_url && /^https:\/\/openvibe\.actor\//.test(info.actor_url)) { var li2 = el('li'); var a2 = el('a', null, 'Every step on OpenVibe Agents'); a2.href = info.actor_url; li2.appendChild(a2); ul.appendChild(li2); }
-- det.appendChild(ul);
-- var acts = body.querySelector('.vb-acts');
-- if (acts) body.insertBefore(det, acts); else body.appendChild(det);
++ var reply = li.querySelector('.ovt-reply');
++ if (!info || !reply || reply.querySelector('.vb-how')) return;
++ renderSources(li, info);
++ var det = el('details', 'vb-how'); det.appendChild(el('summary', null, 'How Vibe did this'));
++ var ul = el('ul');
++ (info.lines || []).forEach(function (l) { ul.appendChild(el('li', null, l)); });
++ var links = sourceChips(info);
++ if (links.length) {
++ var src = el('li', 'vb-how-sources');
++ var link = el('a', null, 'Sources (' + links.length + ') under this answer');
++ var sid = sourcesId(li); if (sid) link.href = '#' + sid;
++ src.appendChild(link);
++ ul.appendChild(src);
++ }
++ if (info.actor_url && /^https:\/\/openvibe\.actor\//.test(info.actor_url)) { var li2 = el('li'); var a2 = el('a', null, 'Every step on OpenVibe Agents'); a2.href = info.actor_url; li2.appendChild(a2); ul.appendChild(li2); }
++ det.appendChild(ul);
++ var meta = reply.querySelector('.ovt-meta');
++ if (meta) reply.insertBefore(det, meta); else reply.appendChild(det);
}
-- function clearWait() { var w = document.getElementById('vb-wait'); if (w) w.remove(); if (waitTimer) { clearTimeout(waitTimer); waitTimer = null; } }
-- // Stop: what Vibe wrote so far stays as its answer (POST /api/chat/vibe/:slug/stop); shown once the chat exists (a new
-- // chat's first bubble is drawn before it does, so the first words add it).
-- function addStop(li) {
-- if (!room || !li || li.querySelector('.vb-stop')) return;
-- var stop = el('button', 'vb-act vb-stop', 'Stop'); stop.type = 'button';
-- stop.addEventListener('click', function () {
-- stop.disabled = true;
-- fetch('/api/chat/vibe/' + encodeURIComponent(room) + '/stop', { method: 'POST', credentials: 'same-origin' }).catch(function () { stop.disabled = false; });
-- });
-- li.appendChild(stop);
++
++ // ── Vibe writing ──
++ // The turn that waits for Vibe: the newest one, open, its live line breathing (nothing spins).
++ function waitingTurn() { return app.querySelector('[data-ovt-thread] > .ovt-turn[data-open]'); }
++ function setStop(on) { if (stopBtn) stopBtn.hidden = !(on && room); if (sendBtn) sendBtn.hidden = !!(on && room); }
++ function clearWait() {
++ var w = waitingTurn();
++ if (w) {
++ w.removeAttribute('data-open');
++ var bot = w.querySelector('.ovt-bot');
++ if (bot && !bot.querySelector('[data-ovt-answer]:not(.vb-streaming)')) bot.remove();
++ }
++ if (waitTimer) { clearTimeout(waitTimer); waitTimer = null; }
++ setStop(false);
}
-- function showWait() {
-- if (document.getElementById('vb-wait')) return;
-- var li = el('li', 'vb-turn vb-ai vb-wait'); li.id = 'vb-wait';
-- li.appendChild(markSvg());
-- var body = el('div', 'vb-body'); var dots = el('span', 'vb-dots'); dots.setAttribute('aria-label', 'Vibe is writing');
-- dots.appendChild(el('i')); dots.appendChild(el('i')); dots.appendChild(el('i'));
-- body.appendChild(dots); li.appendChild(body);
-- addStop(li);
-- thread().appendChild(li);
-- // An answer takes up to a minute with tools; after two the dots give way to a way forward.
++ function giveUpLater() {
++ if (waitTimer) clearTimeout(waitTimer);
++ // An answer takes up to a minute with tools; after two the live line gives way to a way forward.
waitTimer = setTimeout(function () { clearWait(); notice('Vibe did not answer this time. Send your message again, or try again in a minute.'); }, 120000);
}
-- // The answer as it is written (room_ai_delta: the whole text so far): the waiting bubble shows it, rendered like the
++ function showWait() {
++ var li = waitingTurn();
++ if (!li) {
++ li = lastTurn();
++ if (!li || li.querySelector('.ovt-bot')) { li = el('li', 'ovt-turn'); threadEl().appendChild(li); }
++ li.setAttribute('data-open', '');
++ var reply = botOf(li);
++ var work = el('details', 'ovt-work'); work.open = true; work.setAttribute('data-open', '');
++ var sum = el('summary'); var line = el('span', 'ovt-w-live', 'Vibe is writing…'); line.setAttribute('data-ovt-live', '');
++ sum.appendChild(line); work.appendChild(sum);
++ reply.appendChild(work);
++ }
++ setStop(true);
++ giveUpLater();
++ }
++ // The answer as it is written (room_ai_delta: the whole text so far): the waiting turn shows it, rendered like the
// finished answer, until that message arrives and takes its place. Screen readers hear the finished one only.
function streamText(text) {
showWait();
-- var w = document.getElementById('vb-wait'); if (!w) return;
-- addStop(w);
++ var w = waitingTurn(); if (!w) return;
var stick = nearBottom();
-- var body = w.querySelector('.vb-body');
-- var md = body.querySelector('.vb-md');
-- if (!md) {
-- body.textContent = '';
-- md = el('div', 'vb-md vb-streaming'); md.setAttribute('aria-hidden', 'true');
-- body.appendChild(md);
-- var dots = el('span', 'vb-dots'); dots.setAttribute('aria-label', 'Vibe is writing');
-- dots.appendChild(el('i')); dots.appendChild(el('i')); dots.appendChild(el('i'));
-- body.appendChild(dots);
-- }
-- if (MD) md.innerHTML = MD.render(String(text || '')); else md.textContent = String(text || '');
-- if (waitTimer) { clearTimeout(waitTimer); waitTimer = setTimeout(function () { clearWait(); notice('Vibe did not answer this time. Send your message again, or try again in a minute.'); }, 120000); }
++ var reply = botOf(w);
++ var ans = reply.querySelector('.vb-streaming');
++ if (!ans) { ans = el('div', 'ovt-answer vb-streaming'); ans.setAttribute('aria-hidden', 'true'); reply.appendChild(ans); }
++ if (MD) ans.innerHTML = MD.render(String(text || '')); else ans.textContent = String(text || '');
++ giveUpLater();
if (stick) toBottom();
}
function notice(text) {
-- var old = scroll.querySelector('.vb-live-notice'); if (old) old.remove();
++ var old = app.querySelector('.vb-live-notice'); if (old) old.remove();
var p = el('p', 'oc-notice oc-error vb-live-notice', text); p.setAttribute('role', 'alert');
-- scroll.appendChild(p); toBottom();
++ var scroll = app.querySelector('.ovt-scroll'); (scroll || app).appendChild(p); toBottom();
}
function add(m) {
if (!m || have(m.id)) return;
latest = Math.max(latest, Number(m.id) || 0);
var stick = nearBottom();
if (m.ai_member_id) {
-- clearWait(); var turnEl = aiTurn(m); thread().appendChild(turnEl); refreshRail();
-- if (/^✅ Done:/.test(String(m.message || ''))) attachHow(turnEl, m.id);
-- else if (m.how) renderHow(turnEl, stepsOf(m.how));
-- }
-- else {
++ // The answer takes the waiting turn's place, or the newest turn that has none yet, or a turn of its own.
++ var li = waitingTurn() || lastTurn();
++ if (!li || (li.querySelector('.ovt-bot') && !li.hasAttribute('data-open'))) { li = el('li', 'ovt-turn'); threadEl().appendChild(li); }
++ if (waitTimer) { clearTimeout(waitTimer); waitTimer = null; }
++ setStop(false);
++ li.removeAttribute('data-open');
++ li.setAttribute('data-ai', String(m.id));
++ fillAnswer(botOf(li), m);
++ li.classList.add('is-fresh');
++ kitEnhance(li);
++ refreshRail();
++ if (/^✅ Done:/.test(String(m.message || ''))) attachHow(li, m.id);
++ else if (m.how) renderHow(li, stepsOf(m.how));
++ } else {
// The line sent from here already shows (without an id until the server answered): give it its id.
-- var pending = document.querySelector('.vb-turn.vb-me:not([data-id])');
-- if (pending && Number(m.user_id) === Number(cfg.me)) pending.dataset.id = String(m.id);
-- else thread().appendChild(meTurn(m.message, m.id));
++ var pending = app.querySelector('[data-ovt-thread] > .ovt-turn:not([data-me]) > .ovt-you');
++ if (pending && Number(m.user_id) === Number(cfg.me)) pending.parentNode.setAttribute('data-me', String(m.id));
++ else threadEl().appendChild(meTurn(m.message, m.id));
}
if (stick) toBottom();
}
@@@ -348,6 +349,11 @@
.then(function (d) { if (d && d.messages) d.messages.forEach(add); })
.catch(function () {});
}
++ function removeMessage(id) {
++ var li = have(id); if (!li) return;
++ if (li.getAttribute('data-me') === String(Number(id))) li.remove();
++ else { var bot = li.querySelector('.ovt-bot'); if (bot) bot.remove(); li.removeAttribute('data-ai'); if (!li.querySelector('.ovt-you')) li.remove(); }
++ }
function connect() {
if (!room || !window.WebSocket) return;
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
@@@ -358,62 +364,43 @@
else if (m.type === 'room_message' && m.room === room) add(m.message);
else if (m.type === 'room_ai_typing' && m.room === room) { showWait(); if (nearBottom()) toBottom(); }
else if (m.type === 'room_ai_delta' && m.room === room) streamText(m.text);
-- else if (m.type === 'room_message_deleted' && m.room === room) { var x = have(m.id); if (x) x.remove(); }
++ else if (m.type === 'room_message_deleted' && m.room === room) removeMessage(m.id);
else if (m.type === 'auth_revoked') notice('You were signed out. Sign in again to keep chatting.');
};
ws.onclose = function () { ws = null; if (room) { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); } };
}
-- // ── The rail ──
++ // ── The chats (the network's sidebar, kit.sidebar): rebuilt from data with text nodes after each answer ──
function refreshRail() {
fetch('/api/chat/vibe?limit=100', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
if (!d) return;
var allow = document.getElementById('vb-allow');
if (allow && d.allowance && !allow.hasAttribute('data-own')) allow.textContent = Math.max(0, d.allowance.per_day - d.allowance.used_today) + ' of ' + d.allowance.per_day + ' answers left today';
-- var list = document.querySelector('.vb-rail-list'); if (!list || !d.chats) return;
-- // Rebuilt from data with text nodes (the server's grouping, kept simple here: Today and Earlier).
++ var secs = app.querySelector('#vb-rail .ovk-sidebar-secs'); if (!secs || !d.chats) return;
++ if (new URLSearchParams(location.search).get('q')) return; // search results stay until the search is left
var start = new Date(); start.setUTCHours(0, 0, 0, 0);
-- list.textContent = '';
var groups = [{ label: 'Today', chats: [] }, { label: 'Earlier', chats: [] }];
d.chats.forEach(function (c) { var at = Date.parse(String(c.last_message_at).indexOf('T') < 0 ? String(c.last_message_at).replace(' ', 'T') + 'Z' : c.last_message_at); (at >= start.getTime() ? groups[0] : groups[1]).chats.push(c); });
++ secs.textContent = '';
groups.forEach(function (g) {
if (!g.chats.length) return;
-- list.appendChild(el('p', 'vb-group', g.label));
-- var ul = el('ul', 'vb-chats');
++ var sec = el('section', 'ovk-sidebar-sec');
++ sec.appendChild(el('h2', 'ovk-sidebar-h', g.label));
g.chats.forEach(function (c) {
-- var li = el('li'); var a = el('a', 'vb-chat', c.name); a.href = '/vibe/' + c.slug;
++ var a = el('a', 'ovk-sidebar-item' + (c.slug === room ? ' is-here' : '')); a.href = '/vibe/' + c.slug;
if (c.slug === room) a.setAttribute('aria-current', 'page');
-- li.appendChild(a); ul.appendChild(li);
++ a.appendChild(el('span', 'ovk-sidebar-t', c.name));
++ sec.appendChild(a);
});
-- list.appendChild(ul);
++ secs.appendChild(sec);
});
}).catch(function () {});
}
-- function setRail(open) {
-- app.classList.toggle('is-open', open);
-- var scrim = app.querySelector('.vb-scrim'); if (scrim) scrim.hidden = !open;
-- var btn = app.querySelector('[data-vb-open]'); if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
-- }
-- app.addEventListener('click', function (e) {
-- if (e.target.closest('[data-vb-open]')) setRail(true);
-- else if (e.target.closest('[data-vb-close]')) setRail(false);
-- });
-- document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && app.classList.contains('is-open')) setRail(false); });
-- // ── Search chats: typing narrows the listed chats by name at once; Enter searches inside them too (?q=) ──
-- var q = document.getElementById('vb-q');
++ // The search's words come back into the box from the address (the page never repeats them).
++ var q = app.querySelector('#vb-rail .ovk-sidebar-find input');
var searched = null;
try { searched = new URLSearchParams(location.search).get('q'); } catch (e) { searched = null; }
-- if (q && searched) q.value = searched.slice(0, 80); // the page never repeats it (server/web/vibe-pages.js)
-- if (q && !searched) q.addEventListener('input', function () {
-- var v = q.value.trim().toLowerCase();
-- var list = document.querySelector('.vb-rail-list'); if (!list) return;
-- [].forEach.call(list.querySelectorAll('.vb-chats'), function (ul) {
-- var shown = 0;
-- [].forEach.call(ul.children, function (li) { var hit = !v || li.textContent.toLowerCase().indexOf(v) >= 0; li.hidden = !hit; if (hit) shown++; });
-- ul.hidden = !shown;
-- var head = ul.previousElementSibling; if (head && head.classList.contains('vb-group')) head.hidden = !shown;
-- });
-- });
++ if (q && searched) q.value = searched.slice(0, 80);
// Forms that undo something (Memory's "Forget everything") ask first.
[].forEach.call(app.querySelectorAll('form[data-vb-confirm]'), function (f) { f.addEventListener('submit', function (e) { if (!window.confirm(f.getAttribute('data-vb-confirm'))) e.preventDefault(); }); });
var del = app.querySelector('[data-vb-delete]');
@@@ -424,8 +411,18 @@
r.addEventListener('change', function () { if (r.checked && input && r.getAttribute('data-placeholder') && !form.dataset.signin) input.placeholder = r.getAttribute('data-placeholder'); input.focus(); });
});
++ // ── Stop: what Vibe wrote so far stays as its answer (POST /api/chat/vibe/:slug/stop) ──
++ if (stopBtn) stopBtn.addEventListener('click', function (e) {
++ e.preventDefault();
++ if (!room) return;
++ stopBtn.disabled = true;
++ fetch('/api/chat/vibe/' + encodeURIComponent(room) + '/stop', { method: 'POST', credentials: 'same-origin' })
++ .catch(function () {}).then(function () { stopBtn.disabled = false; });
++ });
++
// ── Sending ──
if (form) form.addEventListener('submit', function (e) {
++ if (e.submitter && e.submitter === stopBtn) { e.preventDefault(); return; }
var text = input.value.trim();
if (!text && !picture) { e.preventDefault(); return; }
if (form.dataset.signin) {
@@@ -438,12 +435,12 @@
if (!window.fetch || sending) return;
e.preventDefault();
sending = true;
-- var old = scroll.querySelector('.vb-live-notice'); if (old) old.remove();
++ var old = app.querySelector('.vb-live-notice'); if (old) old.remove();
var sendingPicture = picture;
var mine = meTurn(text, null, sendingPicture && sendingPicture.url);
-- thread().appendChild(mine);
++ threadEl().appendChild(mine);
showWait(); toBottom();
-- input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } if (fileInput) fileInput.value = ''; input.required = true; grow();
++ input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } if (fileInput) fileInput.value = ''; input.required = true; changed();
var url, init;
var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';
if (mode !== 'chat' && !sendingPicture) {
@@@ -458,48 +455,42 @@
url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/messages' : '/api/chat/vibe';
init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, timezone: timezone() }) };
}
++ function putBack(msg) { mine.remove(); clearWait(); input.value = text; if (sendingPicture) choosePicture(sendingPicture.file); changed(); notice(msg); }
fetch(url, init)
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); })
.then(function (x) {
-- if (!x.ok) { mine.remove(); clearWait(); input.value = text; if (sendingPicture) choosePicture(sendingPicture.file); grow(); notice((x.d && x.d.error) || 'That did not send. Try again.'); return; }
-- if (x.d.message && x.d.message.id) { mine.dataset.id = String(x.d.message.id); latest = Math.max(latest, Number(x.d.message.id)); }
++ if (!x.ok) { putBack((x.d && x.d.error) || 'That did not send. Try again.'); return; }
++ if (x.d.message && x.d.message.id) { mine.setAttribute('data-me', String(x.d.message.id)); latest = Math.max(latest, Number(x.d.message.id)); }
if (!room && x.d.room) {
room = x.d.room.slug;
history.replaceState(null, '', '/vibe/' + room);
-- if (titleEl) titleEl.textContent = x.d.room.name;
++ var titleEl = app.querySelector('.ovt-title'); if (titleEl) titleEl.textContent = x.d.room.name;
document.title = x.d.room.name + ' · Vibe · OpenVibe.Chat';
form.action = '/vibe/' + room;
++ setStop(true);
refreshRail();
connect();
}
})
-- .catch(function () { mine.remove(); clearWait(); input.value = text; if (sendingPicture) choosePicture(sendingPicture.file); grow(); notice('You seem to be offline. Your message is back in the box.'); })
++ .catch(function () { putBack('You seem to be offline. Your message is back in the box.'); })
.then(function () { sending = false; });
});
-- enhance(app);
-- app.addEventListener('click', function (e) {
-- var whole = e.target.closest('[data-vb-copy]');
-- if (whole) { var md = whole.closest('.vb-body').querySelector('.vb-md'); if (md) copyText(whole, md.innerText.trim()); return; }
-- var code = e.target.closest('.vb-copy-code');
-- if (code) { var c = code.parentNode.querySelector('code'); if (c) copyText(code, c.textContent); }
-- });
// Try again: Vibe's last answer goes and a new one is written (POST /api/chat/vibe/:slug/retry).
app.addEventListener('submit', function (e) {
var f = e.target.closest('.vb-retry'); if (!f || !room) return;
e.preventDefault();
-- var li = f.closest('.vb-turn');
++ var li = f.closest('.ovt-turn');
fetch('/api/chat/vibe/' + encodeURIComponent(room) + '/retry', { method: 'POST', credentials: 'same-origin' })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }, function () { return { ok: r.ok, d: null }; }); })
.then(function (x) {
if (!x.ok) { notice((x.d && x.d.error) || 'That did not work. Try again in a minute.'); return; }
-- if (li) li.remove();
++ if (li) { var bot = li.querySelector('.ovt-bot'); if (bot) bot.remove(); li.removeAttribute('data-ai'); }
showWait(); toBottom();
})
.catch(function () { notice('You seem to be offline.'); });
});
if (cfg.waiting) showWait();
connect();
-- if (!cfg.memory) toBottom(); // the Memory page reads from the top
window.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });
})();
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 6c5a118..dd1aa37 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -102,7 +102,8 @@ function contentSecurityPolicy(nonce) {
* prefs (the person's chat preferences: timestamps, compact, font_scale, show_badges), counts (unread
* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call
* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:
-- * vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js), feedView
++ * vibe.css + vibe-md.js + vibe.js, and no footer), kit (the network's conversation UI, openvibe-shared/thread: kit.css,
++ * thread.css, kit-web.js and ov-thread.js; Vibe's pages), appScript (the app's own script instead of vibe.js: inbox.js), feedView
* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of
* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;
* 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),
@@@ -143,6 +144,7 @@ ${INSTALL_LINKS}
<link rel="stylesheet" href="${ovServe.url('items.css')}">
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
++${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}
${o.app ? `<link rel="stylesheet" href="${asset('vibe.css')}">` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
@@@ -152,6 +154,7 @@ ${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join
${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
++${o.kit ? `<script src="${ovServe.url('kit-web.js')}" defer></script>\n<script src="${ovServe.url('ov-thread.js')}" defer></script>` : ''}
${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${asset(o.appScript || 'vibe.js')}" defer></script>` : ''}
</head>
<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex 04541b2..45e0b7c 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -23,6 +23,8 @@ const vibe = require('../rooms/vibe');
const how = require('../rooms/how');
const memory = require('../rooms/vibe-memory');
const { esc } = require('./layout');
++const kit = require('openvibe-shared/kit');
++const thread = require('openvibe-shared/thread');
const SUGGESTIONS = [
{ title: 'Catch me up', text: 'on today\'s tech news', prompt: 'What happened in tech news today? Give me the highlights with links.' },
@@@ -79,48 +81,52 @@ function snippet(text, q) {
const MARK = '<span class="vb-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 5l7 14 7-14"/></svg></span>';
function rail({ chats, current, signedIn, allowance, query = '', here = null }) {
-- const groups = groupChats(chats);
++ // The network's one sidebar (openvibe-shared kit.sidebar, plan L35; the conversation kit's, L55 C4): New chat, the
++ // search, Vibe's places as rows, then the chats by when they last moved. A drawer on a phone (kit-web.js).
// The search's own words are never written back into the page (a link could carry anyone's words): the box gets
-- // them from the address in the browser, and the results say only what matched in the person's own chats.
-- const found = query
-- ? (chats.length
-- ? `<p class="vb-group">Chats that match</p><ul class="vb-chats">${chats.map((c) => `<li><a class="vb-chat vb-hit" href="/vibe/${esc(c.slug)}"${c.slug === current ? ' aria-current="page"' : ''}><span>${esc(c.name)}</span>${c.hit ? `<small>${snippet(c.hit, query)}</small>` : ''}</a></li>`).join('')}</ul>`
-- : '<p class="vb-rail-note">No chat matches your search.</p>') + '<p class="vb-rail-note"><a href="/vibe">All your chats</a></p>'
-- : null;
-- const list = !signedIn
-- ? '<p class="vb-rail-note">Sign in to keep your chats. They are private to you.</p>'
-- : found || (groups.length
-- ? groups.map((g) => `<p class="vb-group">${esc(g.label)}</p><ul class="vb-chats">${g.chats.map((c) => `<li><a class="vb-chat" href="/vibe/${esc(c.slug)}"${c.slug === current ? ' aria-current="page"' : ''}>${esc(c.name)}</a></li>`).join('')}</ul>`).join('')
-- : '<p class="vb-rail-note">Your chats with Vibe show up here.</p>');
++ // them from the address in the browser, and the results say only which of the person's own chats matched.
++ const item = (c) => ({ href: `/vibe/${c.slug}`, label: c.name, here: c.slug === current });
++ let sections;
++ if (!signedIn) sections = [{ title: '', items: [], empty: 'Sign in to keep your chats. They are private to you.' }];
++ else if (query) {
++ sections = chats.length ? [{ title: 'Chats that match', items: chats.map(item) }] : [{ title: '', items: [], empty: 'No chat matches your search.' }];
++ sections.push({ title: '', items: [{ href: '/vibe', label: 'All your chats' }] });
++ } else {
++ const groups = groupChats(chats);
++ sections = groups.length ? groups.map((g) => ({ title: g.label, items: g.chats.map(item) })) : [{ title: '', items: [], empty: 'Your chats with Vibe show up here.' }];
++ }
const left = allowance ? Math.max(0, allowance.per_day - allowance.used_today) : null;
const own = allowance && allowance.engine && allowance.engine.own ? allowance.engine : null; // Vibe on their own model
-- return `<aside class="vb-rail" id="vb-rail" aria-label="Your chats with Vibe">
--<div class="vb-rail-top">
--<a class="vb-new" href="/vibe">${ICON.new}<span>New chat</span></a>
--<button class="vb-icon vb-rail-close" type="button" data-vb-close aria-label="Close the chat list">${ICON.close}</button>
--</div>
--${signedIn ? `<form class="vb-search" action="/vibe" method="get" role="search">${ICON.search}<label class="oc-sr" for="vb-q">Search your chats</label><input id="vb-q" name="q" type="search" maxlength="${vibe.SEARCH_MAX}" placeholder="Search chats" autocomplete="off" enterkeyhint="search"></form>` : ''}
--<nav class="vb-rail-list" aria-label="Chats with Vibe">${list}</nav>
--<nav class="vb-rail-more" aria-label="More of OpenVibe.Chat">
--<a href="https://openvibe.actor/tasks" title="Every task you gave an agent, one-off or recurring, with what it cost">${ICON.tasks}<span>Tasks</span></a>
--<a href="https://openvibe.actor/me/agents" title="Recurring work in plain words: an agent with standing instructions, a memory you can edit, a schedule and quiet hours">${ICON.agents}<span>Your agents</span></a>
--${signedIn ? `<a href="/vibe/memory" title="What Vibe remembers about you across your chats: see, add or delete each note"${here === 'memory' ? ' aria-current="page"' : ''}>${ICON.memory}<span>Memory</span></a>` : ''}
--<a href="https://openvibe.bot/machines" title="Your computers and robots: pair one, see its screen, set what agents may do on it">${ICON.computer}<span>Computers &amp; robots</span></a>
--<a href="/work" title="A room where your team and Vibe work together: a work queue, alerts and a shared screen">${ICON.team}<span>Work together</span></a>
--<a href="/messages">${ICON.messages}<span>Messages</span></a>
--<a href="/rooms">${ICON.rooms}<span>Rooms and servers</span></a>
--<a href="/">${ICON.globe}<span>Global chat</span></a>
--</nav>
--${own ? `<p class="vb-allow" id="vb-allow" data-own="1">No daily limit: Vibe thinks with ${esc(own.you)}</p>` : left != null ? `<p class="vb-allow" id="vb-allow">${left} of ${allowance.per_day} answers left today</p>` : ''}
--</aside>`;
++ const allow = own ? `<p class="vb-allow" id="vb-allow" data-own="1">No daily limit: Vibe thinks with ${esc(own.you)}</p>`
++ : left != null ? `<p class="vb-allow" id="vb-allow">${left} of ${allowance.per_day} answers left today</p>` : '';
++ // Chat's other places, quietly at the foot (the network's navbar reaches them too).
++ const more = [['https://openvibe.bot/machines', 'Computers & robots'], ['/work', 'Work together'], ['/messages', 'Messages'], ['/rooms', 'Rooms and servers'], ['/', 'Global chat']];
++ const html = kit.sidebar({
++ id: 'vb-rail', label: 'Your chats with Vibe',
++ action: { href: '/vibe', label: 'New chat', icon: 'pencil' },
++ find: signedIn ? { action: '/vibe', name: 'q', placeholder: 'Search chats', label: 'Search your chats' } : null,
++ nav: [
++ { href: 'https://openvibe.actor/tasks', label: 'Tasks', icon: 'list' },
++ { href: 'https://openvibe.actor/me/agents', label: 'Your agents', icon: 'robot' },
++ ...(signedIn ? [{ href: '/vibe/memory', label: 'Memory', icon: 'book', here: here === 'memory' }] : []),
++ ],
++ sections,
++ foot: `<p class="ovt-side-foot">${more.map(([href, label]) => `<a href="${esc(href)}">${esc(label)}</a>`).join('')}</p>${allow}`,
++ });
++ if (!query) return html;
++ // A search: under each chat that matched, where it matched (the person's own words or Vibe's), the match marked.
++ return chats.reduce((out, c) => (c.hit ? out.replace(
++ new RegExp(`<a class="ovk-sidebar-item( is-here)?" href="/vibe/${c.slug}"([^>]*)><span class="ovk-sidebar-t">([^<]*)</span>`),
++ (m, here, rest, name) => `<a class="ovk-sidebar-item vb-hit${here || ''}" href="/vibe/${c.slug}"${rest}><span class="ovk-sidebar-t">${name}</span><small class="vb-hit-s">${snippet(c.hit, query)}</small>`,
++ ) : out), html);
}
/**
-- * Under each answer: Copy (shown by vibe.js; it needs the clipboard) and, under the answer to the person's last message,
-- * Try again (a form, so it works without JavaScript too).
++ * Under each answer: Copy (shown by ov-thread.js where the clipboard works) and, under the answer to the person's last
++ * message, Try again (a form, so it works without JavaScript too).
*/
function acts(slug) {
-- return `<div class="vb-acts"><button type="button" class="vb-act" data-vb-copy hidden>Copy</button>${slug ? `<form method="post" action="/vibe/${esc(slug)}/retry" class="vb-retry"><button type="submit" class="vb-act">Try again</button></form>` : ''}</div>`;
++ return `<div class="ovt-meta"><span class="ovt-acts"><button type="button" class="ovt-act" data-ovt-copy hidden>${thread.icon('copy')}<span>Copy</span></button>${slug ? `<form method="post" action="/vibe/${esc(slug)}/retry" class="vb-retry"><button type="submit" class="ovt-act">Try again</button></form>` : ''}</span></div>`;
}
const RES_ICON = {
@@@ -146,14 +152,36 @@ function resultChips(m, slug) {
}).join('')}</div>`;
}
--function turn(m, me, hows = new Map(), retrySlug = null, slug = null) {
-- const ai = !!m.ai_member_id;
-- if (ai) {
-- const info = hows.get(Number(m.id));
-- return `<li class="vb-turn vb-ai" data-id="${Number(m.id) || 0}">${MARK}<div class="vb-body"><p class="oc-sr">Vibe said:</p><div class="vb-md">${md.render(m.message)}</div>${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}</div></li>`;
-- }
++/** What the person said, for their bubble: their words (the bubble keeps line breaks) and any picture. */
++function youOf(m, me) {
const mine = me && Number(m.user_id) === Number(me.id);
-- return `<li class="vb-turn vb-me" data-id="${Number(m.id) || 0}"><div class="vb-bubble"><p class="oc-sr">${mine ? 'You said:' : `${esc(m.display_name || m.username)} said:`}</p>${esc(m.message).replace(/\n/g, '<br>')}${pictures.pageHtml(m.message)}</div></li>`;
++ return { html: `<span class="oc-sr">${mine ? 'You said:' : `${esc(m.display_name || m.username)} said:`}</span>${esc(m.message)}${pictures.pageHtml(m.message)}` };
++}
++/** Vibe's answer: the rendered Markdown, what it did (reminders, timers), its sources and how, then Copy and Try again. */
++function answerOf(m, hows, retrySlug, slug) {
++ const info = hows.get(Number(m.id));
++ return `<p class="oc-sr">Vibe said:</p>${thread.answer({ html: md.render(m.message), copy: false })}${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}`;
++}
++/** Vibe writing: the live line, open (ov-thread.js and vibe.js follow it). */
++const WRITING = thread.work({ open: true, live: 'Vibe is writing' });
++/**
++ * The chat as turns: each message of the person's with the answer that follows it. data-me / data-ai carry the two
++ * message ids, so a message that arrives over the socket is never drawn twice.
++ */
++function turnsOf(messages, me, hows, { retrySlug = null, slug = null, waiting = false } = {}) {
++ const specs = [];
++ for (const [i, m] of messages.entries()) {
++ const last = i === messages.length - 1;
++ if (m.ai_member_id) {
++ const reply = answerOf(m, hows, last ? retrySlug : null, slug);
++ const open = specs[specs.length - 1];
++ if (open && open.reply == null) { open.reply = reply; open.ai = m.id; }
++ else specs.push({ reply, ai: m.id });
++ } else specs.push({ you: youOf(m, me), me: m.id, reply: null });
++ }
++ const tail = specs[specs.length - 1];
++ if (waiting && tail && tail.reply == null) { tail.reply = WRITING; tail.open = true; }
++ return specs.map((t) => thread.turn({ you: t.you || {}, reply: t.reply, open: !!t.open, attrs: { 'data-me': t.me, 'data-ai': t.ai } }));
}
// The composer's modes (plan track K): Chat answers here and now; the others are agent tasks that answer here when done.
@@@ -174,18 +202,23 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null }) {
const pics = pictures.enabled();
const label = pics ? 'Add a picture or a text file' : 'Add a text file';
const attach = signedIn && enabled
-- ? `<input type="file" id="vb-file" accept="${pics ? 'image/jpeg,image/png,image/gif,image/webp,image/avif,' : ''}${TEXT_FILES}" hidden><button class="vb-icon vb-attach" type="button" data-vb-attach aria-label="${label}" title="${label}">${ICON.attach}</button>`
++ ? `<input type="file" id="vb-file" accept="${pics ? 'image/jpeg,image/png,image/gif,image/webp,image/avif,' : ''}${TEXT_FILES}" hidden><button class="ovt-btn vb-attach" type="button" data-vb-attach aria-label="${label}" title="${label}">${ICON.attach}</button>`
: '';
-- return `<div class="vb-preview" id="vb-preview" hidden></div>
--<form class="vb-composer" id="vb-composer" method="${signedIn ? 'post' : 'get'}" action="${action}"${signedIn ? '' : ' data-signin="1"'}>
--${signedIn ? '' : `<input type="hidden" name="next" value="${slug ? `/vibe/${esc(slug)}` : (prefill ? `/vibe?ask=${encodeURIComponent(prefill)}` : '/vibe')}">`}
--${attach}
--<label class="oc-sr" for="vb-input">Message Vibe</label>
--<textarea id="vb-input" name="message" rows="${prefill ? 3 : 1}" maxlength="12000" placeholder="${signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)'}"${enabled ? '' : ' disabled'}${prefill ? ' autofocus' : ''} required>${esc(prefill)}</textarea>
--<button class="vb-send" type="submit" aria-label="Send"${enabled ? '' : ' disabled'}>${ICON.send}</button>
--<fieldset class="vb-modes" aria-label="What Vibe does with it">${MODES.map(([value, label, hint, placeholder], i) => `<label title="${esc(hint)}"><input type="radio" name="mode" value="${value}" data-placeholder="${esc(placeholder)}"${i ? '' : ' checked'}><span>${label}</span></label>`).join('')}</fieldset>
--</form>
--<p class="vb-fine">${!enabled ? 'Vibe is resting right now. Try again later.' : engine && engine.own ? `Vibe thinks with ${esc(engine.you)} (<a href="/settings#oc-ai-key">change</a>). It can get things wrong: check what matters.` : 'Vibe can get things wrong. Check what matters.'}</p>`;
++ const modes = `<fieldset class="vb-modes" aria-label="What Vibe does with it">${MODES.map(([value, label, hint, placeholder], i) => `<label title="${esc(hint)}"><input type="radio" name="mode" value="${value}" data-placeholder="${esc(placeholder)}"${i ? '' : ' checked'}><span>${label}</span></label>`).join('')}</fieldset>`;
++ const fine = !enabled ? 'Vibe is resting right now. Try again later.' : engine && engine.own ? `Vibe thinks with ${esc(engine.you)} (<a href="/settings#oc-ai-key">change</a>). It can get things wrong: check what matters.` : 'Vibe can get things wrong. Check what matters.';
++ let html = thread.composer({
++ action, method: signedIn ? 'post' : 'get', name: 'message', id: 'vb-composer', attrs: signedIn ? {} : { 'data-signin': '1' },
++ label: 'Message Vibe', placeholder: signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)', value: prefill, maxlength: 12000,
++ hidden: signedIn ? {} : { next: slug ? `/vibe/${slug}` : (prefill ? `/vibe?ask=${encodeURIComponent(prefill)}` : '/vibe') },
++ top: '<div class="vb-preview" id="vb-preview" hidden></div>', tools: attach + modes,
++ // Stop is drawn hidden: vibe.js shows it while Vibe writes, and it keeps what was written so far.
++ stopAction: signedIn ? '' : null,
++ foot: `<p class="vb-fine">${fine}</p>`,
++ });
++ // A box filled from "Ask Vibe" is where the cursor goes, script or not.
++ if (prefill) html = html.replace('data-ovt-text', 'data-ovt-text autofocus');
++ if (!enabled) html = html.replace('data-ovt-text', 'data-ovt-text disabled').replace('data-ovt-send aria-label', 'data-ovt-send disabled aria-label');
++ return html;
}
function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit, notice }) {
@@@ -232,31 +265,25 @@ ${MARK}
<p class="vb-sub">OpenVibe's assistant. I search the web and all of OpenVibe (news, deals, jobs, rentals, videos), read the pages and pictures you share, remember what you ask me to, and set up work that runs every day for you.</p>
<ul class="vb-suggest">${SUGGESTIONS.map((s) => `<li><button type="button" class="vb-chip" data-prompt="${esc(s.prompt)}"><b>${esc(s.title)}</b><span>${esc(s.text)}</span></button></li>`).join('')}</ul>
</div>`;
-- const thread = room
-- ? `<ol class="vb-thread" id="vb-thread">${messages.map((m, i) => turn(m, user, hows, i === messages.length - 1 && retryable ? room.slug : null, room.slug)).join('')}${waiting ? `<li class="vb-turn vb-ai vb-wait" id="vb-wait">${MARK}<div class="vb-body"><span class="vb-dots" aria-label="Vibe is writing"><i></i><i></i><i></i></span><noscript><a href="/vibe/${esc(room.slug)}">Refresh to see Vibe's answer</a></noscript></div></li>` : ''}</ol>`
-- : '';
-- const head = `<header class="vb-head">
--<button class="vb-icon vb-menu" type="button" data-vb-open aria-controls="vb-rail" aria-expanded="false" aria-label="Show your chats">${ICON.menu}</button>
--${room ? `<h1 class="vb-title" id="vb-title">${esc(title)}</h1>` : `<p class="vb-title" id="vb-title">${esc(title)}</p>`}
--${room ? `<details class="vb-actions"><summary class="vb-icon" aria-label="Chat options">${ICON.more}</summary><div class="vb-pop">
++ const head = thread.head({
++ title: room ? title : '', sidebar: 'vb-rail', sidebarLabel: 'Show your chats',
++ actions: room ? `<details class="vb-actions"><summary class="ovt-btn" aria-label="Chat options">${ICON.more}</summary><div class="vb-pop">
<form method="post" action="/vibe/${esc(room.slug)}/rename" class="vb-rename"><label for="vb-name">Name</label><input id="vb-name" name="name" maxlength="${vibe.TITLE_MAX}" value="${esc(room.name)}" required><button type="submit">Rename</button></form>
<form method="post" action="/vibe/${esc(room.slug)}/delete" data-vb-delete><button type="submit" class="vb-danger">Delete this chat</button></form>
--</div></details>` : `<a class="vb-icon vb-head-new" href="/vibe" aria-label="New chat">${ICON.new}</a>`}
--</header>`;
-- const body = `<div class="vb${room ? '' : ' vb-empty'}" id="vb">
--${rail({ chats, current: room && room.slug, signedIn: !!user, allowance, query: searching ? query : '' })}
--<div class="vb-scrim" data-vb-close hidden></div>
--<section class="vb-main" aria-label="${room ? 'Your chat with Vibe' : 'A new chat with Vibe'}">
--${head}
--<div class="vb-scroll" id="vb-scroll">
--${error ? notice('error', error) : ''}
--${room ? thread : hello}
--</div>
--<div class="vb-dock">${composer({ slug: room && room.slug, signedIn: !!user, enabled, prefill: room ? '' : prefillOf(req.query || {}), engine: allowance && allowance.engine })}</div>
--</section>
--</div>`;
++</div></details>` : '',
++ });
++ const turns = room ? turnsOf(messages, user, hows, { retrySlug: retryable ? room.slug : null, slug: room.slug, waiting: !!waiting }) : [];
++ const problem = error ? notice('error', error) : '';
++ const body = `<div class="vbk" id="vb">${thread.page({
++ sidebar: rail({ chats, current: room && room.slug, signedIn: !!user, allowance, query: searching ? query : '' }),
++ head,
++ turns: problem && room ? [thread.turn({ avatar: false, reply: problem }), ...turns] : turns,
++ empty: problem + hello,
++ composer: composer({ slug: room && room.slug, signedIn: !!user, enabled, prefill: room ? '' : prefillOf(req.query || {}), engine: allowance && allowance.engine }),
++ center: !room, label: room ? 'Your chat with Vibe' : 'A new chat with Vibe',
++ })}</div>`;
await page(req, res, status, {
-- actor, app: true, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',
++ actor, app: true, kit: true, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',
title: room ? `${room.name} · Vibe` : 'Vibe, OpenVibe\'s assistant',
fullTitle: room ? undefined : 'Vibe: chat with OpenVibe\'s assistant · OpenVibe.Chat',
description: 'Chat with Vibe, OpenVibe\'s free assistant: it searches news, deals, jobs, rentals and videos across OpenVibe, reads pages you link and helps you think.',
@@@ -276,18 +303,11 @@ ${room ? thread : hello}
async function renderMemory(req, res, actor, { status = 200, error = null, draft = '' } = {}) {
const user = actor.user;
const [notes, on, chats, allowance] = await Promise.all([memory.list(user), memory.isOn(user), vibe.list(user), allowanceOf(user)]);
-- const body = `<div class="vb" id="vb">
--${rail({ chats, current: null, signedIn: true, allowance, here: 'memory' })}
--<div class="vb-scrim" data-vb-close hidden></div>
--<section class="vb-main" aria-label="What Vibe remembers about you">
--<header class="vb-head">
--<button class="vb-icon vb-menu" type="button" data-vb-open aria-controls="vb-rail" aria-expanded="false" aria-label="Show your chats">${ICON.menu}</button>
--<p class="vb-title" id="vb-title">Memory</p>
--<a class="vb-icon vb-head-new" href="/vibe" aria-label="New chat">${ICON.new}</a>
--</header>
--<div class="vb-scroll" id="vb-scroll">
--${error ? notice('error', error) : ''}
--<div class="vb-page">
++ const body = `<div class="vbk" id="vb">${thread.page({
++ sidebar: rail({ chats, current: null, signedIn: true, allowance, here: 'memory' }),
++ head: thread.head({ title: '', sidebar: 'vb-rail', sidebarLabel: 'Show your chats', newHref: '/vibe' }),
++ label: 'What Vibe remembers about you', cls: 'is-page',
++ main: `${error ? notice('error', error) : ''}<div class="vb-page">
<h1>What Vibe remembers about you</h1>
<p class="vb-sub">Short notes Vibe keeps across your chats with it: what you asked it to remember, or a preference you told it. Only your own chats with Vibe read them, never a room it was added to. It never keeps passwords, keys or card numbers.</p>
<form class="vb-mem-switch" method="post" action="/vibe/memory/switch">
@@@ -304,12 +324,10 @@ ${notes.length ? `<h2 class="vb-group">${notes.length} of ${memory.MAX_NOTES} no
<form method="post" action="/vibe/memory/${n.id}/delete"><button type="submit" class="vb-btn vb-btn-quiet" aria-label="Delete the note: ${esc(n.text.slice(0, 60))}">Delete</button></form></li>`).join('')}</ul>
<form method="post" action="/vibe/memory/clear" data-vb-confirm="Delete every note? Vibe forgets them all."><button type="submit" class="vb-btn vb-danger">Forget everything</button></form>`
: '<p class="vb-rail-note">Nothing yet. Tell Vibe “remember that I…” in a chat, or add a note above.</p>'}
--</div>
--</div>
--</section>
--</div>`;
++</div>`,
++ })}</div>`;
await page(req, res, status, {
-- actor, app: true, active: 'vibe', path: '/vibe/memory', title: 'Memory · Vibe', robots: 'noindex, nofollow', body,
++ actor, app: true, kit: true, active: 'vibe', path: '/vibe/memory', title: 'Memory · Vibe', robots: 'noindex, nofollow', body,
page: { view: 'vibe', memory: true, room: null, me: user.id, latest: 0, waiting: false, enabled: vibe.enabled(), signedIn: true },
});
}
ddiff --git a/test/home-vibe.test.js b/test/home-vibe.test.js
iindex 3281596..01412bd 100644
---- a/test/home-vibe.test.js
++++ b/test/home-vibe.test.js
@@@ -25,7 +25,7 @@ t('the front page leads to Vibe: the first action, an Ask Vibe box, the question
t('the box opens Vibe with the question in it, signed out too', async () => {
const vibe = await fetch(`${h.base}/vibe?ask=${encodeURIComponent('Plan a picnic for six')}`).then((r) => r.text());
-- assert.match(vibe, /<textarea id="vb-input"[^>]*>Plan a picnic for six<\/textarea>/);
++ assert.match(vibe, /<textarea id="ovt-text"[^>]*>Plan a picnic for six<\/textarea>/);
});
t.run(async () => { if (h && h.close) await h.close(); });
ddiff --git a/test/vibe-long-text.test.js b/test/vibe-long-text.test.js
iindex 32d127f..17e3f89 100644
---- a/test/vibe-long-text.test.js
++++ b/test/vibe-long-text.test.js
@@@ -48,7 +48,7 @@ t('everywhere else the limits stay: 2000 characters, spam refused', async () =>
t('the composer takes 12000 characters and the attach button takes text files', async () => {
const page = await fetch(`${h.base}/vibe`, { headers: { cookie: `ov_token=${ann.token}` } }).then((x) => x.text());
-- assert.match(page, /<textarea id="vb-input" name="message" rows="1" maxlength="12000"/);
++ assert.match(page, /<textarea id="ovt-text" name="message" rows="1"[^>]*maxlength="12000"/);
assert.match(page, /<input type="file" id="vb-file" accept="[^"]*text\/plain[^"]*\.csv[^"]*\.py[^"]*" hidden>/);
assert.match(page, /aria-label="Add (a picture or )?a text file"/);
});
ddiff --git a/test/vibe-memory.test.js b/test/vibe-memory.test.js
iindex 7188d80..b608e7c 100644
---- a/test/vibe-memory.test.js
++++ b/test/vibe-memory.test.js
@@@ -166,7 +166,7 @@ t('the page: the notes with Delete, add, switch, forget everything; signed out a
assert.match(page.text, /<h1>What Vibe remembers about you<\/h1>/);
assert.match(page.text, /<b>Memory is on\.<\/b>/);
assert.match(page.text, /<li><p>Uses kilometres<\/p><small>Added by you · /);
-- assert.match(page.text, /<a href="\/vibe\/memory" title="[^"]+" aria-current="page">/, 'the rail marks Memory');
++ assert.match(page.text, /<a class="ovk-sidebar-link is-here" href="\/vibe\/memory" aria-current="page">/, 'the rail marks Memory');
assert.match(page.text, /noindex, nofollow/);
const added = await web('POST', '/vibe/memory', ann, { text: 'Prefers short answers' });
assert.deepStrictEqual([added.status, added.location], [303, '/vibe/memory']);
@@@ -185,7 +185,7 @@ t('the page: the notes with Delete, add, switch, forget everything; signed out a
await web('POST', '/vibe/memory/clear', ann, {});
assert.deepStrictEqual(await memory.list(userOf(ann)), []);
assert.match((await web('GET', '/vibe/memory', ann)).text, /Nothing yet/);
-- assert.match((await web('GET', '/vibe', ann)).text, /<a href="\/vibe\/memory" title="What Vibe remembers about you/, 'the rail links it from Vibe');
++ assert.match((await web('GET', '/vibe', ann)).text, /<a class="ovk-sidebar-link" href="\/vibe\/memory">/, 'the rail links it from Vibe');
assert.ok(!(await web('GET', '/vibe', null)).text.includes('href="/vibe/memory"'), 'signed out: no Memory link');
});
ddiff --git a/test/vibe-retry.test.js b/test/vibe-retry.test.js
iindex ff37c1f..bd4c0aa 100644
---- a/test/vibe-retry.test.js
++++ b/test/vibe-retry.test.js
@@@ -74,8 +74,8 @@ t('the page offers Try again under the last answer only, and Copy (shown by the
await answers(s2, 2);
const page = await web('GET', `/vibe/${s2}`, ann);
assert.strictEqual((page.text.match(/class="vb-retry"/g) || []).length, 1, 'one Try again');
-- assert.match(page.text, /<button type="button" class="vb-act" data-vb-copy hidden>Copy<\/button><form method="post" action="\/vibe\/v-[0-9a-f]{12}\/retry" class="vb-retry"><button type="submit" class="vb-act">Try again<\/button><\/form><\/div><\/div><\/li><\/ol>/, 'under the last answer');
-- assert.strictEqual((page.text.match(/data-vb-copy hidden/g) || []).length, 2, 'Copy under each answer');
++ assert.match(page.text, /data-ovt-copy hidden>[\s\S]{0,400}?<span>Copy<\/span><\/button><form method="post" action="\/vibe\/v-[0-9a-f]{12}\/retry" class="vb-retry"><button type="submit" class="ovt-act">Try again<\/button><\/form><\/span><\/div><\/div><\/div>\s*<\/li><\/ol>/, 'under the last answer');
++ assert.strictEqual((page.text.match(/data-ovt-copy hidden/g) || []).length, 2, 'Copy under each answer');
const form = await web('POST', `/vibe/${s2}/retry`, ann, {});
assert.deepStrictEqual([form.status, form.location], [303, `/vibe/${s2}`]);
});
ddiff --git a/test/vibe.test.js b/test/vibe.test.js
iindex 7801dd4..391ae25 100644
---- a/test/vibe.test.js
++++ b/test/vibe.test.js
@@@ -148,12 +148,12 @@ t('the app page: signed out it invites a sign-in, signed in it lists the chats a
const signed = await web('GET', '/vibe', ann);
assert.ok(signed.text.includes('What can I help with, Ann?'));
assert.strictEqual((signed.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on a new chat: the greeting (the header names it in a <p>)');
-- assert.ok(signed.text.includes('class="vb-chat" href="/vibe/v-'), 'the rail lists their chats');
++ assert.ok(signed.text.includes('class="ovk-sidebar-item" href="/vibe/v-'), 'the rail lists their chats (the network\'s one sidebar, kit.sidebar)');
// The rail leads to the rest of the person's agent: their tasks, their computers and robots, team workspaces.
assert.ok(/href="https:\/\/openvibe\.actor\/tasks"[^>]*>[\s\S]*?<span>Tasks<\/span>/.test(signed.text), 'Tasks');
assert.ok(/href="https:\/\/openvibe\.actor\/me\/agents"[^>]*>[\s\S]*?<span>Your agents<\/span>/.test(signed.text), 'Your agents: recurring work');
-- assert.ok(/href="https:\/\/openvibe\.bot\/machines"[^>]*>[\s\S]*?<span>Computers &amp; robots<\/span>/.test(signed.text), 'Computers & robots');
-- assert.ok(/href="\/work"[^>]*>[\s\S]*?<span>Work together<\/span>/.test(signed.text), 'Work together');
++ assert.ok(signed.text.includes('<a href="https://openvibe.bot/machines">Computers &amp; robots</a>'), 'Computers & robots, at the foot');
++ assert.ok(signed.text.includes('<a href="/work">Work together</a>'), 'Work together, at the foot');
const form = await web('POST', '/vibe', ann, { message: 'hello from a form' });
assert.strictEqual(form.status, 303);
assert.ok(/^\/vibe\/v-[0-9a-f]{12}$/.test(form.location), form.location);
@@@ -166,9 +166,9 @@ t('the app page: signed out it invites a sign-in, signed in it lists the chats a
t('Ask Vibe from any OpenVibe site: the selection and the page fill a new chat\'s box, never send it', async () => {
let r = await web('GET', `/vibe?ask=${encodeURIComponent('What is <b>WHIP</b>?')}&from=${encodeURIComponent('https://openvibe.live/docs/whip#top')}`, ann);
assert.strictEqual(r.status, 200);
-- assert.match(r.text, /<textarea id="vb-input"[^>]*autofocus[^>]*>About this, on https:\/\/openvibe\.live\/docs\/whip:\n\nWhat is &lt;b&gt;WHIP&lt;\/b&gt;\?<\/textarea>/);
++ assert.match(r.text, /<textarea id="ovt-text"[^>]*autofocus[^>]*>About this, on https:\/\/openvibe\.live\/docs\/whip:\n\nWhat is &lt;b&gt;WHIP&lt;\/b&gt;\?<\/textarea>/);
r = await web('GET', `/vibe?from=${encodeURIComponent('https://evil.test/x')}`, ann);
-- assert.match(r.text, /<textarea id="vb-input"[^>]*><\/textarea>/, 'only an OpenVibe site\'s address is taken');
++ assert.match(r.text, /<textarea id="ovt-text"[^>]*><\/textarea>/, 'only an OpenVibe site\'s address is taken');
r = await web('GET', `/vibe?ask=${encodeURIComponent('hello')}`);
assert.match(r.text, /name="next" value="\/vibe\?ask=hello"/, 'signed out, the question survives the sign-in');
});
@@@ -178,12 +178,12 @@ t('search chats: the rail\'s box finds a chat by its name or by any message in i
assert.strictEqual(made.status, 201, made.text);
await answers(made.body.room.slug, 1); // Vibe's answer lists rice and lentils
const signed = await web('GET', '/vibe', ann);
-- assert.match(signed.text, /<form class="vb-search" action="\/vibe" method="get" role="search">/);
-- assert.ok(!(await web('GET', '/vibe')).text.includes('vb-search'), 'signed out, there is nothing to search');
++ assert.match(signed.text, /<form class="ovk-sidebar-find" role="search" method="get" action="\/vibe">/);
++ assert.ok(!(await web('GET', '/vibe')).text.includes('ovk-sidebar-find'), 'signed out, there is nothing to search');
let r = await web('GET', '/vibe?q=BICYCLE', ann);
-- assert.ok(r.text.includes('<p class="vb-group">Chats that match</p>'));
++ assert.ok(r.text.includes('<h2 class="ovk-sidebar-h">Chats that match</h2>'));
assert.ok(!r.text.includes('BICYCLE'), 'the search\'s own words are never written back into the page');
-- assert.match(r.text, new RegExp(`class="vb-chat vb-hit" href="/vibe/${made.body.room.slug}"><span>Find me a cheap blue bicycle near the…</span><small>Find me a cheap blue <mark>bicycle</mark> near the river</small>`));
++ assert.match(r.text, new RegExp(`class="ovk-sidebar-item vb-hit" href="/vibe/${made.body.room.slug}"><span class="ovk-sidebar-t">Find me a cheap blue bicycle near the…</span><small class="vb-hit-s">Find me a cheap blue <mark>bicycle</mark> near the river</small>`));
r = await web('GET', '/vibe?q=lentils', ann);
assert.ok(r.text.includes('<mark>lentils</mark>'), 'a match in Vibe\'s answer counts');
assert.ok((await web('GET', '/vibe?q=100%25', ann)).text.includes('No chat matches your search.'), '% is a character, not a wildcard');