openvibers/openvibe.chat Commits

The support inbox on the conversation kit; vibe.css loses the old layout (#327)

OP OpenVibers committed 11 days ago · 2a3dd9d91c · 9 files, +330 −332

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 19e2c6e..c907107 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.100.2",
++ "shared": "openvibe-shared v4.102.0",
"capabilities": [
"chat.message.send",
"chat.event.publish",
ddiff --git a/package-lock.json b/package-lock.json
iindex 8267a94..3bd163e 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.100.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.102.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
@@@ -1339,9 +1339,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.100.2",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.100.2",
-- "integrity": "sha512-3NPTQC7+jWcgXIcvZN3E2RlE8Wxc6pgxeI6lNqNyEhR3tmY6nKolyhH5HYkOp05LdoGTJ3Ivy/QpWiUMGhhnlQ==",
++ "version": "4.102.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.102.0",
++ "integrity": "sha512-9giJmk+LwIp6skYUejaBtkHfCRHTQlbGM3PIfGZVXo7rUdwLeFC89CLtQehEnPw2Q9inlffsgVblJjALq88EQg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 1c5766a..03d1dcb 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.100.2",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.102.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
},
ddiff --git a/public/web/inbox.css b/public/web/inbox.css
nnew file mode 100644
iindex 0000000..9e73a46
---- /dev/null
++++ b/public/web/inbox.css
@@@ -0,0 +1,87 @@
++/* The support inbox (server/web/inbox-pages.js /inbox/:id, public/web/inbox.js) on the network's conversation kit
++ (openvibe-shared thread.css and kit.css draw the frame, the chats column, the turns and the composer). These are the
++ inbox's own parts in it: the status chips, a visitor's initial beside their row, unread rows, the head's actions,
++ the bubbles captioned with who wrote them, the conversation's facts, the saved replies, a picture, a translation and
++ the call. Theme tokens only. */
++
++/* Status: Vibe answers, waiting for a person, open with the team, resolved. */
++.ib-st { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .72rem; font-weight: 700; letter-spacing: .02em; line-height: 1.5;
++ border: 1px solid var(--oc-line); color: var(--oc-muted); white-space: nowrap; }
++.ib-st-bot { color: var(--oc-accent); border-color: color-mix(in srgb, var(--oc-accent) 45%, var(--oc-line)); }
++.ib-st-wait { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning, #f59e0b) 55%, var(--oc-line)); background: color-mix(in srgb, var(--warning, #f59e0b) 12%, transparent); }
++.ib-st-open { color: var(--success-text, var(--oc-ok)); border-color: color-mix(in srgb, var(--oc-ok) 50%, var(--oc-line)); }
++.ib-st-done { opacity: .75; }
++
++/* The conversations: a visitor's initial beside both lines of the row; a row with something new is bold. */
++.ibk .ib-av { flex: none; display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 50%; background: var(--oc-panel-2); color: var(--oc-text); font-size: .85rem; font-weight: 700; }
++.ibk .ovk-sidebar-item.is-here .ib-av { background: color-mix(in srgb, var(--oc-accent) 22%, var(--oc-panel-2)); }
++.ibk .ovk-sidebar-item.ib-unread .ovk-sidebar-t { font-weight: 700; }
++.ibk .ovk-sidebar-item.ib-unread .ovk-sidebar-s { color: var(--oc-text); }
++
++/* The head: the visitor's tags, Resolve, Call, and More (who answers, back to Vibe). On a phone the tags give way. */
++.ibk .ib-head-acts { display: flex; align-items: center; gap: 6px; min-width: 0; }
++.ibk .ib-tags { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
++.ib-ok { color: var(--oc-ok, #22c55e); font-weight: 800; }
++.ib-rate { font-size: .9rem; line-height: 1; }
++.ibk .ib-status { margin: 0; }
++.ib-btn { padding: 6px 12px; border-radius: 10px; border: 1px solid var(--oc-line); background: var(--oc-panel); color: var(--oc-text); font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer; white-space: nowrap; }
++.ib-btn:hover { border-color: color-mix(in srgb, var(--oc-accent) 45%, var(--oc-line)); }
++.ib-btn:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
++.ib-call-btn { border-color: transparent; background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, #fff); }
++.ib-call-btn[hidden] { display: none; }
++.ibk .ib-more { position: relative; }
++.ibk .ib-more > summary { list-style: none; cursor: pointer; }
++.ibk .ib-more > summary::-webkit-details-marker { display: none; }
++.ib-pop { position: absolute; right: 0; top: calc(100% + 6px); z-index: 30; display: grid; gap: 10px; min-width: 240px; padding: 12px; border-radius: 14px;
++ border: 1px solid var(--oc-line); background: var(--oc-panel); box-shadow: 0 16px 40px -18px rgba(0, 0, 0, .55); }
++.ib-pop form { margin: 0; }
++.ib-assign { display: grid; gap: 4px; font-size: .85rem; color: var(--oc-muted); }
++.ib-assign select { padding: 6px 8px; border-radius: 10px; border: 1px solid var(--oc-line); background: var(--oc-bg); color: var(--oc-text); font: inherit; font-size: .9rem; }
++.ib-pop .ib-btn { width: 100%; }
++@media (max-width: 560px) { .ibk .ib-tags .ib-st, .ibk .ib-tags .ib-rate { display: none; } }
++
++/* The thread: the visitor's words as written (new lines kept), Vibe's answers tinted, who wrote each bubble above it. */
++.ibk .ib-text { white-space: pre-wrap; overflow-wrap: anywhere; }
++/* Vibe's answers are the assistant's, not a person's: an outlined bubble, its caption in the accent. */
++.ibk [data-author="ai"] .ovt-bubble { background: transparent; border: 1px solid color-mix(in srgb, var(--oc-accent) 45%, var(--oc-line)); }
++.ibk [data-author="ai"] .ovt-caption { color: var(--oc-accent); }
++.ibk .ovt-bubble .oc-pics, .ibk .ovt-reply .oc-pics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
++.ibk .oc-pic img { display: block; max-width: 220px; max-height: 220px; border-radius: 12px; }
++.ibk .ib-typing span { opacity: .8; }
++.ib-facts { display: grid; gap: 2px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--oc-line); color: var(--oc-muted); font-size: .82rem; overflow-wrap: anywhere; }
++.ib-facts p { margin: 0; }
++.ib-facts a { color: var(--oc-accent); }
++.ib-hello { max-width: 34rem; margin: 12vh auto 0; text-align: center; }
++.ib-hello h2 { margin: 0 0 6px; font-size: 1.3rem; }
++.ib-hello p { margin: 0; color: var(--oc-muted); line-height: 1.5; }
++
++/* Translate: under a visitor's message, and the reply into the visitor's language. */
++.ib-tr-btn { all: unset; margin-top: 4px; padding: 1px 4px; border-radius: 6px; color: var(--oc-muted); font-size: .78rem; cursor: pointer; }
++.ib-tr-btn:hover { color: var(--oc-accent); }
++.ib-tr-btn:focus-visible { outline: 2px solid var(--oc-accent); }
++.ib-tr { margin: 4px 0 0; padding-left: 10px; border-left: 2px solid var(--oc-line); color: var(--oc-muted); }
++.ib-tr-pick { display: inline-flex; align-items: center; gap: 4px; color: var(--oc-muted); font-size: .8rem; }
++.ib-tr-pick[hidden] { display: none; }
++.ib-tr-pick select { max-width: 9.5em; padding: 2px 6px; border-radius: 8px; border: 1px solid var(--oc-line); background: var(--oc-bg); color: var(--oc-text); font: inherit; font-size: .8rem; }
++.ibk .ib-pic-btn { cursor: pointer; }
++.ibk .ib-pic-btn:focus-within { outline: 2px solid var(--oc-accent); outline-offset: 1px; }
++.ibk .ib-fine { margin: 6px 4px 0; color: var(--oc-muted); font-size: .78rem; }
++.ibk .ovt-c-note:empty { display: none; }
++
++/* Saved replies: "/" at the start of the box lists them, above it. */
++.ib-pick { list-style: none; margin: 0 0 6px; padding: 4px; max-height: 260px; overflow-y: auto; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); }
++.ib-pick li { display: flex; gap: 8px; align-items: baseline; min-width: 0; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
++.ib-pick li span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--oc-muted); }
++.ib-pick li.is-on, .ib-pick li:hover { background: color-mix(in srgb, var(--oc-accent) 14%, transparent); }
++
++/* A voice call with the visitor: the bar above the reply box while one is open, and their shared screen above it. */
++.ib-callbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 8px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--oc-accent); background: var(--oc-panel); }
++.ib-callbar[hidden], .ib-callbar button[hidden] { display: none; }
++.ib-call-text { flex: 1 1 200px; font-weight: 600; }
++.ib-callbar button { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--oc-line); background: transparent; color: var(--oc-text); font: inherit; font-size: .85rem; font-weight: 700; cursor: pointer; }
++.ib-callbar .ib-call-end { border-color: transparent; background: #dc2626; color: #fff; }
++.ib-screen { display: grid; gap: 6px; margin: 0 0 8px; padding: 8px; border-radius: 12px; border: 1px solid var(--oc-line); background: #000; }
++.ib-screen[hidden] { display: none; }
++.ib-screen figcaption { color: #e5e7eb; font-size: .85rem; font-weight: 600; }
++.ib-screen video { width: 100%; max-height: 50vh; border-radius: 8px; background: #000; object-fit: contain; }
++.ib-screen button { justify-self: end; padding: 4px 10px; border-radius: 999px; border: 1px solid #374151; background: transparent; color: #e5e7eb; font: inherit; font-size: .8rem; cursor: pointer; }
ddiff --git a/public/web/inbox.js b/public/web/inbox.js
iindex d1820a4..a59741f 100644
---- a/public/web/inbox.js
++++ b/public/web/inbox.js
@@@ -1,23 +1,24 @@
--/* A support inbox, live (server/web/inbox-pages.js /inbox/:id): the inbox's people follow every conversation over the
-- chat socket (inbox_watch). New conversations and messages appear in the list as they come, the open one grows, and a
-- reply is sent with Enter. The page works without this file (links and forms). A visitor's or agent's text is set with
-- textContent; Vibe's answers go through VibeMarkdown (vibe-md.js), which escapes everything first. */
++/* A support inbox, live (server/web/inbox-pages.js /inbox/:id), on the network's conversation kit (openvibe-shared
++ thread and kit.sidebar; ov-thread.js grows the reply box, sends on Enter and keeps the thread at the bottom). The
++ inbox's people follow every conversation over the chat socket (inbox_watch): new conversations and messages appear
++ in the list as they come, and the open one grows. The page works without this file (links and forms). A visitor's
++ or agent's text is set with textContent; Vibe's answers go through VibeMarkdown (vibe-md.js), which escapes
++ everything first. Rows and turns are drawn with the same markup as the server's. */
(function () {
'use strict';
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
if (!cfg || cfg.view !== 'inbox') return;
-- var app = document.getElementById('vb');
-- var thread = document.getElementById('vb-thread');
-- var scroll = document.getElementById('vb-scroll');
-- var form = document.getElementById('vb-composer');
-- var input = document.getElementById('vb-input');
-- var list = document.getElementById('ib-list');
++ var thread = document.querySelector('[data-ovt-thread]');
++ var form = document.getElementById('ib-composer');
++ var input = form ? form.querySelector('[data-ovt-text]') : null;
++ var rail = document.getElementById('ib-rail');
++ var list = rail ? rail.querySelector('.ovk-sidebar-sec') : null;
var latest = Number(cfg.latest) || 0, ws = null, delay = 1000, typingEl = null;
-- var STATUS = { bot: ['Vibe', 'vb-st-bot'], waiting: ['Waiting', 'vb-st-wait'], open: ['Open', 'vb-st-open'], resolved: ['Resolved', 'vb-st-done'] };
++ var STATUS = { bot: ['Vibe', 'ib-st-bot'], waiting: ['Waiting', 'ib-st-wait'], open: ['Open', 'ib-st-open'], resolved: ['Resolved', 'ib-st-done'] };
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 chip(s) { var c = el('span', 'vb-st ' + (STATUS[s] ? STATUS[s][1] : ''), STATUS[s] ? STATUS[s][0] : s); return c; }
++ function chip(s) { return el('span', 'ib-st ' + (STATUS[s] ? STATUS[s][1] : ''), STATUS[s] ? STATUS[s][0] : s); }
// OpenVibe.Pics direct links in a person's reply, or a signed-in visitor's, show as pictures (only that host, at most four).
var PICS = /https:\/\/openvibe\.pics\/i\/([2-9a-km-zA-HJ-NP-Z]{8})\.(?:jpg|png|gif|webp|avif)(?![\w\/.-])/g;
function picsOf(text) {
@@@ -32,31 +33,38 @@
}
function bare(t) { return String(t || '').replace(PICS, '').replace(/[ \t]+$/gm, '').trim(); }
function plain(t) { return String(t || '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim(); }
-- function bottom() { if (scroll) scroll.scrollTop = scroll.scrollHeight; }
++ function bottom() { window.scrollTo(0, document.documentElement.scrollHeight); }
function clearTyping() { if (typingEl) { typingEl.remove(); typingEl = null; } }
++ // One message, as the kit's turn (server/web/inbox-pages.js inboxTurn).
function turn(m) {
-- if (m.author === 'system') { var s = el('li', 'ib-sys', m.body); s.dataset.id = String(m.id); return s; }
-- var li;
++ if (m.author === 'system') { var d = el('li', 'ovt-divider'); d.appendChild(el('span', null, m.body)); d.dataset.id = String(m.id); return d; }
++ var li = el('li', 'ovt-turn');
if (m.author === 'visitor') {
-- li = el('li', 'vb-turn vb-ai ib-visitor');
-- li.appendChild(el('span', 'ib-av', (m.author_name || 'V').slice(0, 1).toUpperCase()));
-- var body = el('div', 'vb-body'); body.appendChild(el('p', 'ib-who', m.author_name || 'Visitor'));
++ var name = m.author_name || 'Visitor';
++ var bot = el('div', 'ovt-bot');
++ bot.appendChild(el('span', 'ovt-avatar is-who', name.slice(0, 1).toUpperCase())).setAttribute('aria-hidden', 'true');
++ var reply = el('div', 'ovt-reply');
++ var who = el('p', 'ovt-who'); who.appendChild(el('b', null, name)); reply.appendChild(who);
var vp = m.account ? picsOf(m.body) : null; // a signed-in visitor's pictures show, not their links
var said = vp ? bare(m.body) : m.body;
-- if (said) body.appendChild(el('div', 'vb-md', said));
-- if (vp) body.appendChild(vp);
-- li.appendChild(body);
++ if (said) reply.appendChild(el('div', 'ovt-answer ib-text', said));
++ if (vp) reply.appendChild(vp);
++ bot.appendChild(reply); li.appendChild(bot);
++ li.dataset.author = 'visitor';
} else {
-- li = el('li', 'vb-turn vb-me');
-- var b = el('div', 'vb-bubble' + (m.author === 'ai' ? ' ib-ai' : ''));
-- b.appendChild(el('p', 'ib-who', m.author === 'ai' ? 'Vibe (AI)' : (m.author_name || 'Agent')));
-- var text = el('div', 'vb-md');
-- var pics = m.author === 'agent' ? picsOf(m.body) : null;
-- if (m.author === 'ai' && window.VibeMarkdown) text.innerHTML = window.VibeMarkdown.render(m.body); else text.textContent = pics ? bare(m.body) : m.body;
-- if (text.textContent) b.appendChild(text);
-- if (pics) b.appendChild(pics);
-- li.appendChild(b);
++ var ai = m.author === 'ai';
++ var you = el('div', 'ovt-you');
++ you.appendChild(el('span', 'ovt-caption', ai ? 'Vibe (AI)' : (m.author_name || 'Agent')));
++ var bubble = el('div', 'ovt-bubble');
++ if (ai && window.VibeMarkdown) { var mdEl = el('div', 'vb-md'); mdEl.innerHTML = window.VibeMarkdown.render(m.body); bubble.appendChild(mdEl); }
++ else {
++ var pics = m.author === 'agent' ? picsOf(m.body) : null;
++ bubble.appendChild(el('span', 'ib-text', pics ? bare(m.body) : m.body));
++ if (pics) bubble.appendChild(pics);
++ }
++ you.appendChild(bubble); li.appendChild(you);
++ li.dataset.author = ai ? 'ai' : 'agent';
}
li.dataset.id = String(m.id);
return li;
@@@ -64,32 +72,54 @@
function appendToThread(m) {
if (!thread || thread.querySelector('[data-id="' + m.id + '"]')) return;
// One's own reply, back over the socket before the POST answered: it is the one already shown.
-- if (m.author === 'agent') { var p = thread.querySelector('.vb-me:not([data-id])'); if (p) { p.dataset.id = String(m.id); latest = Math.max(latest, Number(m.id)); return; } }
++ if (m.author === 'agent') { var p = thread.querySelector('[data-author="agent"]:not([data-id])'); if (p) { p.dataset.id = String(m.id); latest = Math.max(latest, Number(m.id)); return; } }
clearTyping();
-- var stick = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 160;
-- var li = turn(m); thread.appendChild(li); addTranslate(li);
++ var li = turn(m);
++ if (window.OVThread) window.OVThread.add(li); else { thread.appendChild(li); bottom(); }
++ addTranslate(li);
latest = Math.max(latest, Number(m.id));
-- if (stick) bottom();
}
++
++ // The rows: one per conversation, found by data-conv (kit.sidebar's item, server/web/inbox-pages.js conversationsRail).
function listItem(convId) { return list ? list.querySelector('[data-conv="' + convId + '"]') : null; }
++ function setDot(a, kind) {
++ var old = a.querySelector('.ovk-sidebar-dot'); if (old) old.remove();
++ if (!kind) return;
++ var dot = el('i', 'ovk-sidebar-dot' + (kind === true ? '' : ' is-' + kind)); dot.setAttribute('aria-hidden', 'true');
++ a.insertBefore(dot, a.querySelector('.ovk-sidebar-s'));
++ }
++ function setLine(a, preview) {
++ var sub = a.querySelector('.ovk-sidebar-s'); if (!sub) return;
++ var who = a.getAttribute('data-who') || '';
++ sub.textContent = (who ? who + ' · ' : '') + plain(preview).slice(0, 140);
++ }
++ function setWhen(a, text) {
++ var meta = a.querySelector('.ovk-sidebar-m'); if (!meta) return;
++ var rate = /^(👍|👎) /.exec(meta.textContent);
++ meta.textContent = (rate ? rate[0] : '') + text;
++ }
++ function toTop(a) { var first = list.querySelector('.ovk-sidebar-item'); if (first && first !== a) list.insertBefore(a, first); }
function touchList(convId, preview, status) {
var a = listItem(convId);
if (!a) return;
-- if (preview != null) { var p = a.querySelector('.ib-prev'); if (p) p.textContent = plain(preview).slice(0, 140); var t = a.querySelector('time'); if (t) t.textContent = 'now'; }
-- if (status) { var old = a.querySelector('.vb-st'); if (old) old.replaceWith(chip(status)); }
-- if (convId !== cfg.conversation && preview != null) a.classList.add('ib-unread');
-- var li = a.parentNode; if (li && li.parentNode && preview != null) li.parentNode.insertBefore(li, li.parentNode.firstChild);
++ if (preview != null) { setLine(a, preview); setWhen(a, 'now'); }
++ if (status) { a.setAttribute('data-status', status); setDot(a, status === 'waiting' ? 'needs' : a.classList.contains('ib-unread') ? true : null); }
++ if (String(convId) !== String(cfg.conversation) && preview != null) { a.classList.add('ib-unread'); if (a.getAttribute('data-status') !== 'waiting') setDot(a, true); }
++ if (preview != null) toTop(a);
}
function addConversation(c) {
-- if (listItem(c.id)) return;
-- if (!list) {
-- var nav = document.querySelector('.vb-rail-list'); var empty = document.getElementById('ib-empty'); if (empty) empty.remove();
-- list = el('ul', 'vb-chats'); list.id = 'ib-list'; nav.appendChild(list);
-- }
-- var li = el('li'); var a = el('a', 'vb-chat ib-conv ib-unread'); a.href = '/inbox/' + cfg.inbox + '?c=' + c.id; a.dataset.conv = c.id;
-- var row = el('span', 'ib-row'); row.appendChild(el('b', null, c.visitor_name || 'Visitor')); row.appendChild(chip(c.status)); row.appendChild(el('time', null, 'now'));
-- a.appendChild(row); a.appendChild(el('span', 'ib-prev', ''));
-- li.appendChild(a); list.insertBefore(li, list.firstChild);
++ if (!list || listItem(c.id)) return;
++ var empty = list.querySelector('.ovk-sidebar-empty'); if (empty) empty.remove();
++ var a = el('a', 'ovk-sidebar-item has-sub has-mark ib-unread'); a.href = '/inbox/' + cfg.inbox + '?c=' + c.id + '&f=' + (cfg.filter || 'active');
++ a.setAttribute('data-conv', c.id); a.setAttribute('data-who', c.status === 'bot' ? 'Vibe' : ''); a.setAttribute('data-status', c.status);
++ var mark = el('span', 'ib-av', String(c.visitor_name || 'V').slice(0, 1).toUpperCase()); mark.setAttribute('aria-hidden', 'true');
++ a.appendChild(mark);
++ a.appendChild(el('span', 'ovk-sidebar-t', c.visitor_name || 'Visitor'));
++ a.appendChild(el('span', 'ovk-sidebar-m', 'now'));
++ a.appendChild(el('span', 'ovk-sidebar-s', c.status === 'bot' ? 'Vibe' : 'New'));
++ setDot(a, c.status === 'waiting' ? 'needs' : true);
++ var first = list.querySelector('.ovk-sidebar-item');
++ if (first) list.insertBefore(a, first); else list.appendChild(a);
}
function connect() {
@@@ -104,19 +134,20 @@
touchList(m.conversation, m.message.body, null);
if (m.conversation === cfg.conversation) appendToThread(m.message);
} else if (m.type === 'inbox_assigned') {
-- var row2 = listItem(m.conversation); var prev = row2 && row2.querySelector('.ib-prev');
-- if (prev) { var who = prev.querySelector('.ib-who-has'); if (who) who.textContent = m.assignee === cfg.me ? 'You' : 'Someone'; }
++ var row = listItem(m.conversation);
++ if (row) { row.setAttribute('data-who', m.assignee == null ? '' : m.assignee === cfg.me ? 'You' : 'Someone'); var sub = row.querySelector('.ovk-sidebar-s'); if (sub) setLine(row, sub.textContent.replace(/^(You|Someone|Vibe|[^·]{1,40}) · /, '')); }
if (m.conversation === cfg.conversation) { var sel = document.querySelector('.ib-assign select'); if (sel) sel.value = m.assignee == null ? '' : String(m.assignee); }
} else if (m.type === 'inbox_rating') {
-- var item = listItem(m.conversation), row = item && item.querySelector('.ib-row');
-- if (row) { var old = row.querySelector('.ib-rate'); if (old) old.remove(); var r = el('span', 'ib-rate', m.rating > 0 ? '👍' : '👎'); if (m.comment) r.title = m.comment; row.insertBefore(r, row.querySelector('time')); }
++ var item = listItem(m.conversation), meta = item && item.querySelector('.ovk-sidebar-m');
++ if (meta) meta.textContent = (m.rating > 0 ? '👍 ' : '👎 ') + meta.textContent.replace(/^(👍|👎) /, '');
} else if (m.type === 'inbox_call' && m.conversation === cfg.conversation) {
if (m.state === 'ringing') callOffered(m.by); else callGone();
} else if (m.type === 'inbox_status') {
touchList(m.conversation, null, m.status);
-- if (m.conversation === cfg.conversation) { var h = document.querySelector('.vb-title .vb-st'); if (h) h.replaceWith(chip(m.status)); }
++ if (m.conversation === cfg.conversation) { var h = document.querySelector('.ib-tags .ib-st'); if (h) h.replaceWith(chip(m.status)); }
} else if (m.type === 'inbox_typing' && m.conversation === cfg.conversation && thread) {
-- clearTyping(); typingEl = el('li', 'ib-sys', (m.who || 'Vibe') + ' is writing…'); thread.appendChild(typingEl); bottom();
++ clearTyping(); typingEl = el('li', 'ovt-divider ib-typing'); typingEl.appendChild(el('span', null, (m.who || 'Vibe') + ' is writing…'));
++ if (window.OVThread) window.OVThread.add(typingEl); else { thread.appendChild(typingEl); bottom(); }
setTimeout(clearTyping, 60000);
}
};
@@@ -130,8 +161,8 @@
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); });
}
function addTranslate(li) {
-- if (!li || !li.classList.contains('ib-visitor') || !li.dataset.id || li.querySelector('.ib-tr-btn')) return;
-- var body = li.querySelector('.vb-body'); if (!body) return;
++ if (!li || li.dataset.author !== 'visitor' || !li.dataset.id || li.querySelector('.ib-tr-btn')) return;
++ var body = li.querySelector('.ovt-reply'); if (!body) return;
var b = el('button', 'ib-tr-btn', 'Translate'); b.type = 'button';
b.addEventListener('click', function () {
b.disabled = true; b.textContent = 'Translating…';
@@@ -142,7 +173,7 @@
});
body.appendChild(b);
}
-- if (thread) Array.prototype.forEach.call(thread.querySelectorAll('.ib-visitor'), addTranslate);
++ if (thread) Array.prototype.forEach.call(thread.querySelectorAll('[data-author="visitor"]'), addTranslate);
var picIn = document.getElementById('ib-pic');
if (picIn && input) {
picIn.addEventListener('change', function () {
@@@ -194,43 +225,39 @@
function usePick(r) { input.value = r.body; closePick(); input.dispatchEvent(new Event('input')); input.focus(); }
if (form && input) {
++ if (saved.length) input.setAttribute('aria-autocomplete', 'list');
input.addEventListener('input', function () { picked = 0; showPick(); });
++ // In the capture phase, so a picked reply wins over ov-thread.js's Enter to send.
input.addEventListener('keydown', function (e) {
if (!pick) return;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); picked = (picked + (e.key === 'ArrowDown' ? 1 : -1) + pick._list.length) % pick._list.length; showPick(); }
else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); e.stopImmediatePropagation(); usePick(pick._list[picked]); }
else if (e.key === 'Escape') closePick();
-- });
-- var grow = function () { input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, window.innerHeight * 0.4) + 'px'; };
-- input.addEventListener('input', grow);
-- input.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit ? form.requestSubmit() : form.submit(); } });
++ }, true);
++ // The kit grows the box and sends on Enter (ov-thread.js); an 'input' event keeps its size and Send in step.
++ var grow = function () { input.dispatchEvent(new Event('input')); };
form.addEventListener('submit', function (e) {
var text = input.value.trim(); if (!text) { e.preventDefault(); return; }
if (!window.fetch) return;
e.preventDefault();
-- var pending = turn({ id: 0, author: 'agent', author_name: 'You', body: text }); pending.removeAttribute('data-id'); thread.appendChild(pending); bottom();
++ var pending = turn({ id: 0, author: 'agent', author_name: 'You', body: text }); pending.removeAttribute('data-id');
++ if (window.OVThread) window.OVThread.add(pending); else { thread.appendChild(pending); bottom(); }
input.value = ''; grow();
fetch('/api/chat/inboxes/' + cfg.inbox + '/conversations/' + cfg.conversation + '/messages', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text }) })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
if (!x.ok) { pending.remove(); input.value = text; grow(); window.alert((x.d && x.d.error) || 'That did not send.'); return; }
-- if (thread.querySelector('[data-id="' + x.d.message.id + '"]')) pending.remove(); else pending.dataset.id = String(x.d.message.id);
++ // The socket may have answered first and given this very turn its id: only another turn with it is a copy.
++ var copy = thread.querySelector('[data-id="' + x.d.message.id + '"]');
++ if (copy && copy !== pending) pending.remove(); else pending.dataset.id = String(x.d.message.id);
touchList(cfg.conversation, text, 'open');
})
.catch(function () { pending.remove(); input.value = text; grow(); });
});
}
-- // The rail on phones.
-- 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');
-- }
// "Answered by": choosing a person assigns at once.
var assignSel = document.querySelector('.ib-assign select[data-autosubmit]');
if (assignSel) assignSel.addEventListener('change', function () { assignSel.form.requestSubmit ? assignSel.form.requestSubmit() : assignSel.form.submit(); });
-- app.addEventListener('click', function (e) { if (e.target.closest('[data-vb-open]')) setRail(true); else if (e.target.closest('[data-vb-close]')) setRail(false); });
-- bottom();
connect();
// A voice call with the visitor (server/inbox/inbox.js startCall, support-call.js): Call starts it and joins; anyone
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex 983791b..f5d498f 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -1,42 +1,10 @@
--/* 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. */
--/* 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; }
++/* Vibe, OpenVibe's assistant, on openvibe.chat (server/web/vibe-pages.js), on the network's conversation kit
++ (openvibe-shared thread.css and kit.css draw the chats, the turns, the composer and the page). These are Vibe's own
++ parts. Colours are the network theme's (chat.css maps them to --oc-*), so every theme, light ones included, carries
++ through. The support inbox has its own stylesheet (inbox.css). */
.vb-wrap { margin: 0; padding: 0; max-width: none; }
--.vb {
-- --vb-rail: 272px;
-- --vb-col: 768px;
-- --vb-radius: 22px;
-- display: grid;
-- grid-template-columns: var(--vb-rail) minmax(0, 1fr);
-- height: calc(100dvh - var(--ovnav-real-h, 52px));
-- min-height: 420px;
--}
--/* ── The rail ─────────────────────────────────────────────────────────────── */
--.vb-rail {
-- display: flex; flex-direction: column; min-height: 0;
-- background: color-mix(in srgb, var(--oc-panel) 70%, var(--oc-bg));
-- border-right: 1px solid var(--oc-line);
-- padding: 10px 8px 8px;
--}
--.vb-rail-top { display: flex; align-items: center; gap: 6px; padding: 2px 2px 8px; }
--.vb-new {
-- flex: 1; display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 0 12px;
-- border-radius: 12px; color: var(--oc-text); text-decoration: none; font-weight: 600; font-size: .95rem;
-- border: 1px solid var(--oc-line); background: var(--oc-bg);
-- transition: background .15s, border-color .15s;
--}
--.vb-new:hover { background: var(--oc-panel-2); border-color: color-mix(in srgb, var(--oc-accent) 40%, var(--oc-line)); }
--.vb svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; flex: none; }
--.vb-icon {
-- all: unset; box-sizing: border-box; cursor: pointer; display: inline-grid; place-items: center;
-- width: 36px; height: 36px; border-radius: 10px; color: var(--oc-muted); flex: none;
--}
--.vb-icon:hover { background: var(--oc-panel-2); color: var(--oc-text); }
--.vb-icon:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 1px; }
--.vb-rail-close, .vb-menu { display: none; }
++/* How Vibe did this, folded under an answer (server/rooms/how.js). */
.vb-how { margin-top: 10px; font-size: .86rem; color: var(--oc-muted); }
.vb-how summary { cursor: pointer; width: fit-content; padding: 2px 0; font-weight: 600; list-style: none; }
.vb-how summary::-webkit-details-marker { display: none; }
@@@ -61,41 +29,16 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
.vb-source-date { color: var(--oc-muted); white-space: nowrap; }
.vb-how .vb-source { margin: 2px 2px 2px 0; color: var(--oc-muted); }
.vb-how .vb-source:hover { color: var(--oc-text); }
--.vb-search { position: relative; margin: 0 2px 6px; }
--.vb-search svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--oc-muted); pointer-events: none; }
--.vb-search input {
-- box-sizing: border-box; width: 100%; min-height: 38px; padding: 0 12px 0 34px; border-radius: 10px;
-- border: 1px solid var(--oc-line); background: var(--oc-bg); color: var(--oc-text); font: inherit; font-size: .9rem;
--}
--.vb-search input:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 1px; border-color: transparent; }
--.vb-chat.vb-hit { white-space: normal; }
++/* The chats column: a search's matches, a group's name, a note, today's allowance. */
.vb-hit span { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.vb-hit small { display: block; margin-top: 2px; color: var(--oc-muted); font-size: .8rem; line-height: 1.35; overflow-wrap: anywhere; }
.vb-hit mark { background: color-mix(in srgb, var(--oc-accent) 28%, transparent); color: inherit; border-radius: 3px; padding: 0 1px; }
--.vb-rail-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 2px; scrollbar-width: thin; }
.vb-group { margin: 14px 10px 4px; font-size: .72rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--oc-muted); }
--.vb-chats { list-style: none; margin: 0; padding: 0; }
--.vb-chat {
-- display: block; padding: 8px 10px; border-radius: 10px; color: var(--oc-text); text-decoration: none;
-- font-size: .92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
--}
--.vb-chat:hover { background: var(--oc-panel-2); }
--.vb-chat[aria-current="page"] { background: color-mix(in srgb, var(--oc-accent) 16%, var(--oc-panel-2)); font-weight: 600; }
.vb-rail-note { margin: 14px 10px; color: var(--oc-muted); font-size: .9rem; line-height: 1.45; }
--.vb-rail-more { border-top: 1px solid var(--oc-line); margin-top: 6px; padding-top: 6px; display: grid; gap: 1px; }
--.vb-rail-more a { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; color: var(--oc-muted); text-decoration: none; font-size: .9rem; }
--@media (hover: hover) { .vb-rail-more a:hover { background: var(--oc-panel-2); color: var(--oc-text); } }
++
.vb-allow { margin: 6px 10px 2px; font-size: .78rem; color: var(--oc-muted); }
--/* ── The conversation ─────────────────────────────────────────────────────── */
--.vb-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; position: relative; }
--.vb-head {
-- display: flex; align-items: center; gap: 6px; min-height: 52px; padding: 6px 12px;
-- border-bottom: 1px solid transparent;
--}
--.vb-title { flex: 1; min-width: 0; margin: 0; font-size: 1rem; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
--.vb-empty .vb-title { color: var(--oc-muted); font-weight: 600; }
--.vb-head-new { color: var(--oc-muted); }
++/* The chat's options (rename, delete), from the head. */
.vb-actions { position: relative; }
.vb-actions > summary { list-style: none; }
.vb-actions > summary::-webkit-details-marker { display: none; }
@@@ -111,60 +54,25 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
.vb-pop .vb-danger { width: 100%; color: var(--oc-red); border-color: color-mix(in srgb, var(--oc-red) 45%, var(--oc-line)); background: transparent; }
.vb-pop .vb-danger:hover { background: color-mix(in srgb, var(--oc-red) 12%, transparent); }
--.vb-scroll { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 8px 16px 24px; scroll-padding-bottom: 24px; }
--.vb-scroll > .oc-notice { max-width: var(--vb-col); margin: 8px auto; }
--.vb-thread { list-style: none; margin: 0 auto; padding: 8px 0 0; max-width: var(--vb-col); display: grid; grid-template-columns: minmax(0, 1fr); gap: 22px; }
--.vb-turn { display: flex; gap: 14px; min-width: 0; }
--/* The writing bubble's Stop (vibe.js showWait): at the bubble's end, quiet like the actions under answers. */
--.vb-stop { align-self: flex-start; margin-left: auto; padding: 4px 10px; border-radius: 8px; border: 1px solid var(--oc-line); background: transparent; color: var(--oc-muted); font: inherit; font-size: .8rem; cursor: pointer; }
--.vb-stop:hover { color: var(--oc-text); background: var(--oc-panel-2); }
--.vb-stop:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 1px; }
--.vb-stop[disabled] { opacity: .5; cursor: default; }
--.vb-me { justify-content: flex-end; }
--.vb-bubble {
-- max-width: min(85%, 620px); padding: 10px 16px; border-radius: 20px 20px 6px 20px;
-- background: var(--oc-panel-2); line-height: 1.55; overflow-wrap: anywhere;
--}
--.vb-bubble .oc-pics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
--.vb-bubble .oc-pic img { max-width: 220px; max-height: 220px; border-radius: 12px; display: block; }
++/* Vibe's mark, beside the welcome's greeting. */
.vb-mark {
flex: none; width: 30px; height: 30px; border-radius: 50%; display: inline-grid; place-items: center; margin-top: 1px;
background: linear-gradient(140deg, var(--oc-accent), color-mix(in srgb, var(--oc-accent) 45%, #a78bfa));
color: var(--oc-on-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--oc-accent) 18%, transparent);
}
.vb-mark svg { width: 16px; height: 16px; stroke-width: 2.6; }
--.vb-body { flex: 1; min-width: 0; padding-top: 3px; }
/* Vibe's answers: the Markdown styles live in chat.css (.vb-md, .md-table), which every page loads. */
--.vb-dots { display: inline-flex; gap: 5px; align-items: center; height: 26px; }
--.vb-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--oc-muted); opacity: .35; animation: vbDot 1.2s infinite ease-in-out; }
--.vb-dots i:nth-child(2) { animation-delay: .15s; }
--.vb-dots i:nth-child(3) { animation-delay: .3s; }
--@keyframes vbDot { 0%, 80%, 100% { opacity: .35; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
--@media (prefers-reduced-motion: reduce) { .vb-dots i { animation: none; opacity: .7; } }
--
--/* An answer as it is written (vibe.js streamText): the text, then the dots, small. */
--.vb-wait .vb-streaming + .vb-dots { margin-top: 6px; transform: scale(.8); transform-origin: left center; }
--
--/* Under an answer: Copy and Try again, quiet until hovered; a code block's own Copy in its corner. */
--.vb-acts { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
--.vb-acts form { display: contents; }
++/* Under an answer: Try again and the like, quiet until hovered (Copy is the kit's). */
.vb-act {
padding: 4px 10px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--oc-muted);
font: inherit; font-size: .8rem; cursor: pointer;
}
.vb-act:hover { color: var(--oc-text); border-color: var(--oc-line); background: var(--oc-panel-2); }
--.vb-act:focus-visible, .vb-copy-code:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 1px; }
--.vb-act.is-done, .vb-copy-code.is-done { color: var(--oc-green, var(--oc-accent)); }
++.vb-act:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 1px; }
++.vb-act.is-done { color: var(--oc-green, var(--oc-accent)); }
.vb-md pre { position: relative; }
--.vb-copy-code {
-- position: absolute; top: 6px; right: 6px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--oc-line);
-- background: var(--oc-panel); color: var(--oc-muted); font: inherit; font-size: .75rem; cursor: pointer;
--}
--.vb-copy-code:hover { color: var(--oc-text); }
--.vb-copy-code::before { content: 'Copy'; }
--.vb-copy-code.is-done::before { content: 'Copied'; }
/* ── A new chat ───────────────────────────────────────────────────────────── */
.vb-hello { max-width: var(--vb-col); margin: 0 auto; min-height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 24px 0 8px; }
@@@ -215,30 +123,6 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
.vb-notes small { grid-column: 1; color: var(--oc-muted); font-size: .8rem; }
.vb-notes form { grid-column: 2; grid-row: 1 / span 2; }
--/* ── The composer ─────────────────────────────────────────────────────────── */
--.vb-dock { padding: 0 16px max(10px, env(safe-area-inset-bottom)); }
--.vb-composer {
-- max-width: var(--vb-col); margin: 0 auto; display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px;
-- padding: 8px 8px 8px 18px; border-radius: var(--vb-radius); border: 1px solid var(--oc-line);
-- background: var(--oc-panel); box-shadow: 0 10px 34px rgba(0, 0, 0, .22);
-- transition: border-color .15s, box-shadow .15s;
--}
--.vb-composer:focus-within { border-color: color-mix(in srgb, var(--oc-accent) 55%, var(--oc-line)); box-shadow: 0 10px 34px rgba(0, 0, 0, .22), 0 0 0 3px color-mix(in srgb, var(--oc-accent) 16%, transparent); }
--.vb-composer textarea {
-- flex: 1; min-width: 0; resize: none; border: 0; outline: 0; background: transparent; color: var(--oc-text);
-- font: inherit; font-size: 1rem; line-height: 1.5; padding: 7px 0; max-height: 40vh; overflow-y: auto;
--}
--.vb-composer textarea::placeholder { color: var(--oc-muted); }
--.vb-composer textarea:focus-visible { outline: none; }
--.vb-send {
-- all: unset; box-sizing: border-box; cursor: pointer; flex: none; width: 38px; height: 38px; border-radius: 50%;
-- display: grid; place-items: center; background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, var(--oc-on-accent));
-- transition: opacity .15s, transform .15s;
--}
--.vb-send svg { stroke-width: 2.4; }
--.vb-send:hover { transform: translateY(-1px); }
--.vb-send:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
--.vb-send[disabled], .vb-composer.is-empty .vb-send { opacity: .4; cursor: default; transform: none; }
/* The modes: a row of chips under the box (Chat · Research · Agent · Code); the checked one is lit. */
.vb-modes { order: 3; flex-basis: 100%; display: flex; gap: 6px; margin: 0 0 0 -10px; padding: 2px 0 0; border: 0; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.vb-modes::-webkit-scrollbar { display: none; }
@@@ -250,42 +134,27 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
@media (hover: hover) { .vb-modes span:hover { color: var(--oc-text); } }
.vb-fine { max-width: var(--vb-col); margin: 6px auto 0; text-align: center; font-size: .76rem; color: var(--oc-muted); }
--/* ── Phones and narrow windows: the rail slides in over the chat ──────────── */
--.vb-scrim { display: none; }
++/* ── Phones ── */
@media (max-width: 860px) {
-- .vb { grid-template-columns: minmax(0, 1fr); }
-- .vb-rail {
-- position: fixed; z-index: 30; top: var(--ovnav-real-h, 52px); bottom: 0; left: 0; width: min(300px, 86vw);
-- transform: translateX(-102%); transition: transform .22s cubic-bezier(.2, .7, .2, 1); box-shadow: 18px 0 50px rgba(0, 0, 0, .35);
-- }
-- .vb.is-open .vb-rail { transform: none; }
-- .vb-scrim { display: block; position: fixed; inset: var(--ovnav-real-h, 52px) 0 0 0; z-index: 25; background: rgba(0, 0, 0, .45); }
-- .vb-scrim[hidden] { display: none; }
-- .vb-rail-close, .vb-menu { display: inline-grid; }
-- .vb-scroll { padding: 4px 12px 18px; }
-- .vb-dock { padding: 0 10px max(8px, env(safe-area-inset-bottom)); }
-- .vb-thread { gap: 18px; }
-- .vb-turn { gap: 10px; }
-- .vb-bubble { max-width: 88%; }
/* The link from "How Vibe did this" to the answer's chips is a tap target on a phone. */
.vb-how-sources a { display: inline-flex; align-items: center; min-height: 44px; }
}
++/* On a phone the four ideas are a row you swipe, every one there: a page is never shortened by hiding what it holds
++ (the owner, 2026-10-31). Two of them used to be hidden, three on a short phone. */
@media (max-width: 520px) {
-- .vb-suggest { grid-template-columns: minmax(0, 1fr); }
-- .vb-suggest li:nth-child(n+3) { display: none; }
++ .vb-suggest { display: flex; gap: 8px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scrollbar-width: none; padding-bottom: 2px; }
++ .vb-suggest::-webkit-scrollbar { display: none; }
++ .vb-suggest li { flex: 0 0 min(78%, 280px); scroll-snap-align: start; }
.vb-hello { justify-content: flex-end; padding-bottom: 4px; }
.vb-sub { font-size: .92rem; margin-bottom: 16px; }
}
--/* A short phone (an SE, a landscape tablet with the keyboard up): the greeting, one suggestion and the composer fit. */
++/* A short phone (an SE, a landscape tablet with the keyboard up): the greeting, the row of ideas and the composer fit. */
@media (max-width: 520px) and (max-height: 700px) {
.vb-hello .vb-mark { display: none; }
.vb-hello h1 { font-size: 1.35rem; }
-- .vb-suggest li:nth-child(n+2) { display: none; }
}
--@media (prefers-reduced-motion: reduce) { .vb-rail { transition: none; } }
/* A picture waiting to go with the next message (vibe.js), above the composer; the attach button inside it. */
--.vb-composer .vb-attach { width: 38px; height: 38px; margin-left: -8px; }
.vb-preview { max-width: var(--vb-col); margin: 0 auto 8px; display: flex; }
.vb-preview[hidden] { display: none; }
.vb-preview figure { position: relative; margin: 0; }
@@@ -293,64 +162,6 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
.vb-preview button { all: unset; box-sizing: border-box; cursor: pointer; position: absolute; top: -7px; right: -7px; width: 22px; height: 22px; border-radius: 50%;
display: grid; place-items: center; background: var(--oc-panel-2); color: var(--oc-text); border: 1px solid var(--oc-line); font-size: 14px; line-height: 1; }
.vb-preview button:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 1px; }
--.vb-bubble img.vb-local { display: block; max-width: 220px; max-height: 220px; border-radius: 12px; margin-top: 8px; }
--
--
--/* ── Support inbox (server/web/inbox-pages.js, public/web/inbox.js): the same app, a conversation per visitor ── */
--.vb-st { display: inline-block; margin-left: 6px; padding: 1px 8px; border-radius: 999px; font-size: .72rem; font-weight: 700; letter-spacing: .02em; vertical-align: 2px;
-- border: 1px solid var(--oc-line); color: var(--oc-muted); }
--.vb-st-bot { color: var(--oc-accent); border-color: color-mix(in srgb, var(--oc-accent) 45%, var(--oc-line)); }
--.vb-st-wait { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning, #f59e0b) 55%, var(--oc-line)); background: color-mix(in srgb, var(--warning, #f59e0b) 12%, transparent); }
--.vb-st-open { color: var(--success-text, var(--oc-ok)); border-color: color-mix(in srgb, var(--oc-ok) 50%, var(--oc-line)); }
--.vb-st-done { opacity: .75; }
--.ib .vb-new .ib-dot { width: 12px; height: 12px; border-radius: 50%; flex: none; }
--.ib-tabs { display: flex; gap: 4px; padding: 0 4px 6px; }
--.ib-tabs a { flex: 1; text-align: center; padding: 6px 8px; border-radius: 9px; color: var(--oc-muted); text-decoration: none; font-size: .85rem; }
--.ib-tabs a[aria-current="page"], .ib-tabs a:hover { background: var(--oc-panel-2); color: var(--oc-text); }
--.ib-conv { white-space: normal; display: grid; gap: 2px; }
--.ib-row { display: flex; align-items: center; gap: 4px; min-width: 0; }
--.ib-row b { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
--.ib-row .vb-st { margin-left: 4px; }
--.ib-rate { font-size: .85rem; line-height: 1; }
--.ib-ok { color: var(--oc-ok, #22c55e); font-weight: 800; margin-left: 4px; font-size: .9em; }
--.ib-assign { display: inline-flex; align-items: center; margin-right: 8px; font-size: .85rem; color: var(--oc-muted); }
--.ib-assign select { margin-left: 6px; font-size: .85rem; padding: 4px 8px; }
--.ib-who-has { color: var(--oc-accent); font-weight: 600; }
--.ib-pic-btn { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; color: var(--oc-muted); flex: none; font-size: 1.1rem; }
--.ib-pic-btn:hover { color: var(--oc-accent); background: color-mix(in srgb, var(--oc-accent) 12%, transparent); }
--.ib-pic-btn:focus-within { outline: 2px solid var(--oc-accent); }
--.ib-tr-btn { all: unset; cursor: pointer; font-size: .78rem; color: var(--oc-muted); margin-top: 2px; border-radius: 6px; padding: 1px 4px; }
--.ib-tr-btn:hover { color: var(--oc-accent); }
--.ib-tr-btn:focus-visible { outline: 2px solid var(--oc-accent); }
--.ib-tr { margin: 4px 0 0; padding-left: 10px; border-left: 2px solid var(--oc-line); color: var(--oc-muted); }
--.ib-tr-pick select { font-size: .8rem; padding: 2px 6px; margin-left: 4px; }
--.ib-pick { list-style: none; margin: 0 0 6px; padding: 4px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); max-height: 260px; overflow-y: auto; }
--.ib-pick li { display: flex; gap: 8px; align-items: baseline; padding: 6px 10px; border-radius: 8px; cursor: pointer; min-width: 0; }
--.ib-pick li span { color: var(--oc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
--.ib-pick li.is-on, .ib-pick li:hover { background: color-mix(in srgb, var(--oc-accent) 14%, transparent); }
--.ib-row time { margin-left: auto; font-size: .75rem; color: var(--oc-muted); flex: none; }
--.ib-prev { color: var(--oc-muted); font-size: .84rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
--.ib-unread .ib-row b, .ib-unread .ib-prev { color: var(--oc-text); font-weight: 700; }
--.ib-actions { display: flex; gap: 6px; }
--.ib-actions button { padding: 6px 12px; border-radius: 10px; border: 1px solid var(--oc-line); background: var(--oc-panel); color: var(--oc-text); font: inherit; font-size: .85rem; cursor: pointer; }
--.ib-actions button:hover { border-color: color-mix(in srgb, var(--oc-accent) 45%, var(--oc-line)); }
--/* A voice call with the visitor: the Call button beside the actions, and the bar above the reply box while one is open. */
--.ib-call-btn { margin-left: 6px; padding: 6px 14px; border-radius: 10px; border: 0; background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, #fff); font: inherit; font-size: .85rem; font-weight: 700; cursor: pointer; }
--.ib-call-btn[hidden], .ib-callbar[hidden], .ib-callbar button[hidden] { display: none; }
--.ib-callbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 8px; padding: 8px 12px; border-radius: 12px; border: 1px solid var(--oc-accent); background: var(--oc-panel); }
--.ib-call-text { flex: 1 1 200px; font-weight: 600; }
--.ib-callbar button { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--oc-line); background: transparent; color: var(--oc-text); font: inherit; font-size: .85rem; font-weight: 700; cursor: pointer; }
--.ib-callbar .ib-call-end { border-color: transparent; background: #dc2626; color: #fff; }
--.ib-screen { margin: 0 0 8px; padding: 8px; border-radius: 12px; border: 1px solid var(--oc-line); background: #000; display: grid; gap: 6px; }
--.ib-screen[hidden] { display: none; }
--.ib-screen figcaption { color: #e5e7eb; font-size: .85rem; font-weight: 600; }
--.ib-screen video { width: 100%; max-height: 50vh; border-radius: 8px; background: #000; object-fit: contain; }
--.ib-screen button { justify-self: end; padding: 4px 10px; border-radius: 999px; border: 1px solid #374151; background: transparent; color: #e5e7eb; font: inherit; font-size: .8rem; cursor: pointer; }
--.ib-sys { text-align: center; color: var(--oc-muted); font-size: .82rem; }
--.ib-who { margin: 0 0 2px; font-size: .74rem; font-weight: 700; color: var(--oc-muted); }
--.ib-av { flex: none; width: 30px; height: 30px; border-radius: 50%; display: inline-grid; place-items: center; background: var(--oc-panel-2); color: var(--oc-text); font-weight: 700; }
--.vb-bubble.ib-ai { background: color-mix(in srgb, var(--oc-accent) 14%, var(--oc-panel-2)); }
--.ib-page { max-width: var(--vb-col); margin: 4px auto 0; font-size: .8rem; color: var(--oc-muted); overflow-wrap: anywhere; }
/* What Vibe did (plan L48 V2): a chip for each reminder or timer it set or stopped, under its answer */
.vb-res { display: grid; gap: 6px; margin: 10px 0 2px; max-width: 560px; }
@@@ -368,7 +179,7 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
/* ── 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.) */
++ the chat's options and the welcome. */
.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; }
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex 27247b7..f9b288a 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -6,8 +6,10 @@
* GET /embed/inbox/:id the chat panel the widget opens in an iframe (public/web/embed.js); its
* Content-Security-Policy frame-ancestors are the inbox's allowed sites (any when none)
* GET /inbox the inboxes a person works in, and a form to make one
-- * GET /inbox/:id the inbox: conversations down the left (waiting first), one open in the middle, a
-- * reply box; live through public/web/inbox.js (inbox_watch). ?c=<conversation>
++ * GET /inbox/:id the inbox, on the network's conversation kit: the conversations in the kit's sidebar
++ * (the filters, then a row per visitor, waiting first), one open as the kit's thread,
++ * the reply box its composer; live through public/web/inbox.js (inbox_watch), styled
++ * by public/web/inbox.css. ?c=<conversation>&f=active|mine|waiting|resolved
* GET /inbox/:id/settings name, greeting, colour, Vibe's instructions and pages, allowed sites, people, the snippet
* POST /inbox · /inbox/:id/settings · /inbox/:id/members · /inbox/:id/members/:uid/delete ·
* /inbox/:id/c/:cid (reply) · /inbox/:id/c/:cid/status forms, so all of it works without JavaScript
@@@ -21,10 +23,16 @@ const pictures = require('../rooms/pictures');
const { embedOf, sendExport } = require('../inbox/routes');
const records = require('../inbox/records');
const { esc, asset } = require('./layout');
++const kit = require('openvibe-shared/kit');
++const thread = require('openvibe-shared/thread');
++// A visitor's initial, in a circle, beside their row.
++const initialMark = (name) => `<span class="ib-av" aria-hidden="true">${esc(String(name || 'V').slice(0, 1).toUpperCase())}</span>`;
++// A preview line without Markdown's marks (Vibe's answers are Markdown).
++const plainText = (t) => String(t || '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim().slice(0, 140);
const WIDGET = path.join(__dirname, '..', '..', 'public', 'web', 'widget.js');
const TR_LANGS = [['en', 'English'], ['es', 'Spanish'], ['pt', 'Portuguese'], ['fr', 'French'], ['de', 'German'], ['it', 'Italian'], ['nl', 'Dutch'], ['pl', 'Polish'], ['tr', 'Turkish'], ['ru', 'Russian'], ['uk', 'Ukrainian'], ['ar', 'Arabic'], ['hi', 'Hindi'], ['id', 'Indonesian'], ['vi', 'Vietnamese'], ['th', 'Thai'], ['ja', 'Japanese'], ['ko', 'Korean'], ['zh', 'Chinese'], ['sv', 'Swedish']];
--const STATUS = { bot: ['Vibe', 'vb-st-bot'], waiting: ['Waiting', 'vb-st-wait'], open: ['Open', 'vb-st-open'], resolved: ['Resolved', 'vb-st-done'] };
++const STATUS = { bot: ['Vibe', 'ib-st-bot'], waiting: ['Waiting', 'ib-st-wait'], open: ['Open', 'ib-st-open'], resolved: ['Resolved', 'ib-st-done'] };
const json = (v, d) => { if (Array.isArray(v)) return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };
const ago = (ts) => {
const t = Date.parse(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`);
@@@ -82,7 +90,7 @@ function mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit
});
// ── The inbox's people ──
-- const statusChip = (s) => `<span class="vb-st ${STATUS[s] ? STATUS[s][1] : ''}">${esc(STATUS[s] ? STATUS[s][0] : s)}</span>`;
++ const statusChip = (s) => `<span class="ib-st ${STATUS[s] ? STATUS[s][1] : ''}">${esc(STATUS[s] ? STATUS[s][0] : s)}</span>`;
router.get('/inbox', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
@@@ -127,53 +135,109 @@ ${list}
const filter = ['active', 'mine', 'waiting', 'resolved'].includes(req.query.f) ? req.query.f : 'active';
const list = await inboxes.conversations(inbox, { status: filter, user: actor.user });
const team = await inboxes.members(inbox);
++ const counts = (await inboxes.mine(actor.user)).find((i) => i.id === inbox.id) || { waiting: 0 };
const nameOf = (id) => { const m = team.find((x) => x.user_id === Number(id)); return m ? (m.display_name || m.username) : null; };
const current = req.query.c ? await inboxes.conversation(inbox, req.query.c) : null;
const saved = await inboxes.savedReplies(inbox);
const msgs = current ? await inboxes.messages(current) : [];
if (current) await inboxes.markRead(inbox, current);
-- const item = (c) => `<li><a class="vb-chat ib-conv" href="/inbox/${esc(inbox.id)}?c=${esc(c.id)}&amp;f=${filter}"${current && c.id === current.id ? ' aria-current="page"' : ''} data-conv="${esc(c.id)}">
--<span class="ib-row"><b>${esc(c.visitor_name || 'Visitor')}</b>${c.verified ? '<span class="ib-ok" title="Verified by your site">✓</span>' : ''}${statusChip(c.status)}${c.rating ? `<span class="ib-rate" title="${esc(c.rating_comment || '')}">${c.rating > 0 ? '👍' : '👎'}</span>` : ''}<time>${esc(ago(c.last_message_at))}</time></span>
--<span class="ib-prev">${c.assignee_id && c.status !== 'resolved' ? `<span class="ib-who-has">${esc(Number(c.assignee_id) === Number(actor.user.id) ? 'You' : nameOf(c.assignee_id) || 'Someone')}</span> · ` : ''}${esc(c.preview || '')}</span></a></li>`;
-- // A person's or a signed-in visitor's message shows its pictures inline, not their links as text.
-- const shown = (m) => (m.author === 'agent' || m.account ? pictures.withoutLinks(m.body) : m.body);
-- const turn = (m) => {
-- if (m.author === 'system') return `<li class="ib-sys" data-id="${m.id}">${esc(m.body)}</li>`;
-- if (m.author === 'visitor') return `<li class="vb-turn vb-ai ib-visitor" data-id="${m.id}"><span class="ib-av" aria-hidden="true">${esc((current.visitor_name || 'V').slice(0, 1).toUpperCase())}</span><div class="vb-body"><p class="ib-who">${esc(current.visitor_name || 'Visitor')}</p>${shown(m) ? `<div class="vb-md">${esc(shown(m)).replace(/\n/g, '<br>')}</div>` : ''}${m.account ? pictures.pageHtml(m.body) : ''}</div></li>`;
-- return `<li class="vb-turn vb-me" data-id="${m.id}"><div class="vb-bubble${m.author === 'ai' ? ' ib-ai' : ''}"><p class="ib-who">${m.author === 'ai' ? 'Vibe (AI)' : esc(m.author_name || 'Agent')}</p>${m.author === 'ai' ? `<div class="vb-md">${md.render(m.body)}</div>` : esc(shown(m)).replace(/\n/g, '<br>')}${m.author === 'agent' ? pictures.pageHtml(m.body) : ''}</div></li>`;
-- };
-- const tabs = [['active', 'Active'], ['mine', 'Mine'], ['waiting', 'Waiting'], ['resolved', 'Resolved']].map(([k, l]) => `<a href="/inbox/${esc(inbox.id)}?f=${k}"${k === filter ? ' aria-current="page"' : ''}>${l}</a>`).join('');
-- const assignForm = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/assign" class="ib-assign"><label>Answered by <select name="assignee" data-autosubmit><option value="">nobody yet</option>${team.map((m) => `<option value="${m.user_id}"${Number(current.assignee_id) === m.user_id ? ' selected' : ''}>${esc(m.user_id === Number(actor.user.id) ? 'me' : (m.display_name || m.username))}</option>`).join('')}</select></label><noscript><button type="submit">Assign</button></noscript></form>` : '';
-- const actions = current ? `${assignForm}<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/status" class="ib-actions">
--${current.status !== 'resolved' ? '<button name="status" value="resolved">Resolve</button>' : '<button name="status" value="open">Reopen</button>'}
--${Number(inbox.ai_enabled) && current.status !== 'bot' && current.status !== 'resolved' ? '<button name="status" value="bot">Back to Vibe</button>' : ''}</form>${current.status !== 'resolved' && config.calls.enabled ? '<button type="button" class="ib-call-btn" id="ib-call" hidden>Call</button>' : ''}` : '';
-- const body = `<div class="vb ib" id="vb" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}">
--<aside class="vb-rail" id="vb-rail" aria-label="Conversations">
--<div class="vb-rail-top"><a class="vb-new" href="/inbox/${esc(inbox.id)}/settings"><span class="ib-dot" style="background:${esc(inbox.color)}"></span><span>${esc(inbox.name)}</span></a>
--<button class="vb-icon vb-rail-close" type="button" data-vb-close aria-label="Close the list">&times;</button></div>
--<nav class="ib-tabs" aria-label="Filter">${tabs}</nav>
--<nav class="vb-rail-list" aria-label="Conversations">${list.length ? `<ul class="vb-chats" id="ib-list">${list.map(item).join('')}</ul>` : '<p class="vb-rail-note" id="ib-empty">No conversations here yet. Add the snippet from Settings to your site.</p>'}</nav>
--<nav class="vb-rail-more" aria-label="More"><a href="/inbox/${esc(inbox.id)}/settings"><span>Settings and snippet</span></a><a href="/inbox"><span>All inboxes</span></a></nav>
--</aside>
--<div class="vb-scrim" data-vb-close hidden></div>
--<section class="vb-main" aria-label="Conversation">
--<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 conversations">&#9776;</button>
--<h1 class="vb-title">${current ? `${esc(current.visitor_name || 'Visitor')}${Number(current.verified) ? ' <span class="ib-ok" title="Verified by your site">✓</span>' : ''} ${statusChip(current.status)}${current.rating ? ` <span class="ib-rate" title="${esc(current.rating_comment || 'The visitor\'s rating')}">${Number(current.rating) > 0 ? '👍' : '👎'}</span>` : ''}` : esc(inbox.name)}</h1>${actions}</header>
--<div class="vb-scroll" id="vb-scroll">
--${current ? `${Number(current.verified) ? `<p class="ib-page">Verified by your site${current.visitor_email ? ` · <a href="mailto:${esc(current.visitor_email)}">${esc(current.visitor_email)}</a>` : ''} · id ${esc(current.external_id || '')}</p>` : ''}${current.rating_comment ? `<p class="ib-page">Rated ${Number(current.rating) > 0 ? '👍' : '👎'}: “${esc(current.rating_comment)}”</p>` : ''}${current.page_url ? `<p class="ib-page">From <a href="${esc(current.page_url)}" rel="noopener nofollow" target="_blank">${esc(current.page_url)}</a></p>` : ''}<ol class="vb-thread" id="vb-thread">${msgs.map(turn).join('')}</ol>`
-- : `<div class="vb-hello"><h1>${list.length ? 'Pick a conversation' : 'No conversations yet'}</h1><p class="vb-sub">${list.length ? 'Waiting ones come first: those visitors asked for a person.' : 'Paste the snippet from Settings into your site. Visitors write; Vibe answers; you take over when it matters.'}</p></div>`}
--</div>
--${current ? `<div class="vb-dock"><figure class="ib-screen" id="ib-screen" hidden><figcaption id="ib-screen-who"></figcaption><video id="ib-screen-video" autoplay playsinline muted></video><button type="button" id="ib-screen-full">Full screen</button></figure><div class="ib-callbar" id="ib-callbar" role="group" aria-label="Voice call" hidden><span class="ib-call-text" id="ib-call-text"></span><button type="button" id="ib-call-join" hidden>Join the call</button><button type="button" id="ib-call-mute" aria-pressed="false" hidden>Mute</button><button type="button" class="ib-call-end" id="ib-call-end">End call</button></div><form class="vb-composer" id="vb-composer" method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}">
--<label class="oc-sr" for="vb-input">Reply</label><textarea id="vb-input" name="message" rows="1" maxlength="4000" placeholder="Reply as ${esc(actor.user.display_name || actor.user.username)}${saved.length ? ' · / for saved replies' : ''}" required aria-autocomplete="list"></textarea>
--${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input type="file" id="ib-pic" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" hidden><span aria-hidden="true">&#128206;</span><span class="oc-sr">Send a picture</span></label>' : ''}<button class="vb-send" type="submit" aria-label="Send">&#8593;</button></form><p class="vb-fine">Replying takes the conversation from Vibe. <label class="ib-tr-pick" hidden>Translate my reply into <select id="ib-tr-to"><option value="">a language…</option>${TR_LANGS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label></p></div>` : ''}
--</section>
--</div>`;
++ const me = Number(actor.user.id);
++ const body = `<div class="ibk" id="ib" data-inbox="${esc(inbox.id)}" data-conv="${current ? esc(current.id) : ''}">${thread.page({
++ sidebar: conversationsRail({ inbox, list, filter, current, me, nameOf, waiting: counts.waiting }),
++ head: thread.head({ title: current ? (current.visitor_name || 'Visitor') : inbox.name, sidebar: 'ib-rail', sidebarLabel: 'Show conversations', actions: headActions({ inbox, current, team, me }) }),
++ turns: current ? [...facts(current), ...msgs.map((m) => inboxTurn(m, current))] : [],
++ empty: `<div class="ib-hello"><h2>${list.length ? 'Pick a conversation' : 'No conversations yet'}</h2><p>${list.length ? 'Waiting ones come first: those visitors asked for a person.' : 'Paste the snippet from Settings into your site. Visitors write; Vibe answers; you take over when it matters.'}</p></div>`,
++ composer: current ? replyBox({ inbox, current, actor, saved }) : '',
++ label: current ? `Conversation with ${current.visitor_name || 'a visitor'}` : inbox.name,
++ })}</div>`;
await page(req, res, 200, {
-- actor, app: true, appScript: 'inbox.js', scripts: ['support-call.js'], active: 'inbox', path: `/inbox/${inbox.id}`, title: `${inbox.name} · Inbox`, robots: 'noindex, nofollow', body,
-- page: { view: 'inbox', inbox: inbox.id, conversation: current ? current.id : null, call: !!(current && inboxes.convShape(current).call), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: actor.user.id, filter, saved: saved.map((r) => ({ shortcut: r.shortcut, body: r.body })) },
++ actor, app: true, kit: true, appScript: 'inbox.js', appStyle: 'inbox.css', scripts: ['support-call.js'], active: 'inbox', path: `/inbox/${inbox.id}`, title: `${current ? `${current.visitor_name || 'Visitor'} · ` : ''}${inbox.name} · Inbox`, robots: 'noindex, nofollow', body,
++ page: { view: 'inbox', inbox: inbox.id, conversation: current ? current.id : null, call: !!(current && inboxes.convShape(current).call), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me, filter, saved: saved.map((r) => ({ shortcut: r.shortcut, body: r.body })) },
});
});
++ // ── The inbox on the network's conversation kit (openvibe-shared/thread and kit.sidebar, plan L55 C4) ──────────
++ // The conversations are the kit's sidebar: the four filters as its rows, then one row per visitor (their initial,
++ // name, when, a dot when they wait for a person, and a second line: who answers, then their last words). The open
++ // conversation is the kit's thread: the visitor on the left with their initial, Vibe and the team on the right,
++ // each bubble captioned with who wrote it; the reply box is the kit's composer. public/web/inbox.js keeps it live
++ // and draws new rows and turns with the same markup.
++ function conversationsRail({ inbox, list, filter, current, me, nameOf, waiting }) {
++ const base = `/inbox/${inbox.id}`;
++ const answeredBy = (c) => (c.assignee_id && c.status !== 'resolved' ? (Number(c.assignee_id) === me ? 'You' : nameOf(c.assignee_id) || 'Someone') : c.status === 'bot' ? 'Vibe' : '');
++ const item = (c) => {
++ const who = answeredBy(c);
++ return {
++ href: `${base}?c=${c.id}&f=${filter}`, label: `${c.visitor_name || 'Visitor'}${Number(c.verified) ? ' ✓' : ''}`,
++ meta: `${c.rating ? (Number(c.rating) > 0 ? '👍 ' : '👎 ') : ''}${ago(c.last_message_at)}`,
++ dot: c.status === 'waiting' ? 'needs' : null, mark: initialMark(c.visitor_name),
++ sub: [who, plainText(c.preview)].filter(Boolean).join(' · ') || 'No messages yet',
++ here: !!(current && c.id === current.id), attrs: { 'data-conv': c.id, 'data-who': who },
++ };
++ };
++ const tab = (k, label, icon, extra = {}) => ({ href: `${base}?f=${k}`, label, icon, here: k === filter, ...extra });
++ return kit.sidebar({
++ id: 'ib-rail', label: 'Conversations',
++ action: { href: `${base}/settings`, label: 'Settings and snippet', icon: 'pencil' },
++ nav: [tab('active', 'Active', 'chat'), tab('mine', 'Mine', 'hand'), tab('waiting', 'Waiting', 'bell', { count: waiting }), tab('resolved', 'Resolved', 'check')],
++ sections: [{ title: inbox.name, items: list.map(item), empty: filter === 'active' ? 'No conversations yet. Add the snippet from Settings to your site.' : 'None here.' }],
++ foot: '<p class="ovt-side-foot"><a href="/inbox">All your inboxes</a></p>',
++ });
++ }
++
++ // The head: who is on the line, then what can be done (resolve or reopen, a call, and More: who answers, back to Vibe).
++ function headActions({ inbox, current, team, me }) {
++ if (!current) return '';
++ const base = `/inbox/${esc(inbox.id)}/c/${esc(current.id)}`;
++ const tags = `<span class="ib-tags">${Number(current.verified) ? '<span class="ib-ok" title="Verified by your site">✓</span>' : ''}${statusChip(current.status)}${current.rating ? `<span class="ib-rate" title="${esc(current.rating_comment || 'The visitor\'s rating')}">${Number(current.rating) > 0 ? '👍' : '👎'}</span>` : ''}</span>`;
++ const status = `<form method="post" action="${base}/status" class="ib-status">${current.status !== 'resolved' ? '<button class="ib-btn" name="status" value="resolved">Resolve</button>' : '<button class="ib-btn" name="status" value="open">Reopen</button>'}</form>`;
++ const call = current.status !== 'resolved' && config.calls.enabled ? '<button type="button" class="ib-btn ib-call-btn" id="ib-call" hidden>Call</button>' : '';
++ const assign = `<form method="post" action="${base}/assign" class="ib-assign"><label for="ib-assignee">Answered by</label><select id="ib-assignee" name="assignee" data-autosubmit><option value="">nobody yet</option>${team.map((m) => `<option value="${m.user_id}"${Number(current.assignee_id) === m.user_id ? ' selected' : ''}>${esc(m.user_id === me ? 'me' : (m.display_name || m.username))}</option>`).join('')}</select><noscript><button type="submit" class="ib-btn">Assign</button></noscript></form>`;
++ const back = Number(inbox.ai_enabled) && current.status !== 'bot' && current.status !== 'resolved' ? `<form method="post" action="${base}/status"><button class="ib-btn" name="status" value="bot">Hand back to Vibe</button></form>` : '';
++ return `<div class="ib-head-acts">${tags}${status}${call}<details class="ib-more"><summary class="ovt-btn" aria-label="More for this conversation" title="More">${thread.icon('more')}</summary><div class="ib-pop">${assign}${back}</div></details></div>`;
++ }
++
++ // What the team should know first: verified by their site, the rating, the page they wrote from. A line, not a turn.
++ function facts(current) {
++ const lines = [];
++ if (Number(current.verified)) lines.push(`Verified by your site${current.visitor_email ? ` · <a href="mailto:${esc(current.visitor_email)}">${esc(current.visitor_email)}</a>` : ''} · id ${esc(current.external_id || '')}`);
++ if (current.rating_comment) lines.push(`Rated ${Number(current.rating) > 0 ? '👍' : '👎'}: “${esc(current.rating_comment)}”`);
++ if (current.page_url) lines.push(`From <a href="${esc(current.page_url)}" rel="noopener nofollow" target="_blank">${esc(current.page_url)}</a>`);
++ return lines.length ? [`<li class="ib-facts">${lines.map((l) => `<p>${l}</p>`).join('')}</li>`] : [];
++ }
++
++ // One message as the kit's turn. The same markup as public/web/inbox.js turn().
++ function inboxTurn(m, current) {
++ const shown = m.author === 'agent' || m.account ? pictures.withoutLinks(m.body) : m.body;
++ if (m.author === 'system') return `<li class="ovt-divider" data-id="${m.id}"><span>${esc(m.body)}</span></li>`;
++ if (m.author === 'visitor') {
++ const name = current.visitor_name || 'Visitor';
++ return thread.turn({
++ who: { name, initials: name.slice(0, 1).toUpperCase() },
++ reply: `${shown ? `<div class="ovt-answer ib-text">${esc(shown)}</div>` : ''}${m.account ? pictures.pageHtml(m.body) : ''}`,
++ attrs: { 'data-id': m.id, 'data-author': 'visitor' },
++ });
++ }
++ const ai = m.author === 'ai';
++ return thread.turn({
++ you: { caption: ai ? 'Vibe (AI)' : (m.author_name || 'Agent'), html: ai ? `<div class="vb-md">${md.render(m.body)}</div>` : `<span class="ib-text">${esc(shown)}</span>${m.author === 'agent' ? pictures.pageHtml(m.body) : ''}` },
++ reply: null, attrs: { 'data-id': m.id, 'data-author': ai ? 'ai' : 'agent' },
++ });
++ }
++
++ // The reply box: the kit's composer, with a picture, a translation and the call above it while one is open.
++ function replyBox({ inbox, current, actor, saved }) {
++ const pic = pictures.enabled() ? `<label class="ovt-btn ib-pic-btn" title="Send a picture"><input type="file" id="ib-pic" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" hidden>${thread.icon('attach')}<span class="ovt-sr">Send a picture</span></label>` : '';
++ const tr = `<label class="ib-tr-pick" hidden><span>Translate my reply into</span> <select id="ib-tr-to"><option value="">a language…</option>${TR_LANGS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label>`;
++ const call = `<figure class="ib-screen" id="ib-screen" hidden><figcaption id="ib-screen-who"></figcaption><video id="ib-screen-video" autoplay playsinline muted></video><button type="button" id="ib-screen-full">Full screen</button></figure><div class="ib-callbar" id="ib-callbar" role="group" aria-label="Voice call" hidden><span class="ib-call-text" id="ib-call-text"></span><button type="button" id="ib-call-join" hidden>Join the call</button><button type="button" id="ib-call-mute" aria-pressed="false" hidden>Mute</button><button type="button" class="ib-call-end" id="ib-call-end">End call</button></div>`;
++ return call + thread.composer({
++ action: `/inbox/${inbox.id}/c/${current.id}`, name: 'message', id: 'ib-composer', label: 'Reply', maxlength: 4000,
++ placeholder: `Reply as ${actor.user.display_name || actor.user.username}${saved.length ? ' · / for saved replies' : ''}`,
++ tools: pic + tr,
++ foot: '<p class="ib-fine">Replying takes the conversation from Vibe.</p>',
++ });
++ }
++
router.post('/inbox/:id/c/:cid', formLimit, sameSite, async (req, res) => {
const a = await agentInbox(req, res); if (!a) return;
const conv = await inboxes.conversation(a.inbox, req.params.cid);
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 18ec5e1..60b3cd2 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -104,7 +104,8 @@ function contentSecurityPolicy(nonce) {
* 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), 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
++ * thread.css, kit-web.js and ov-thread.js; Vibe's pages and the support inbox), appScript and appStyle (the app's own
++ * script and stylesheet instead of vibe.js and vibe.css: inbox.js and inbox.css), 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),
@@@ -146,7 +147,7 @@ ${INSTALL_LINKS}
${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.app ? `<link rel="stylesheet" href="${asset(o.appStyle || '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')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
ddiff --git a/test/inbox.test.js b/test/inbox.test.js
iindex ae821fb..333691b 100644
---- a/test/inbox.test.js
++++ b/test/inbox.test.js
@@@ -552,7 +552,8 @@ t('routing: taking turns hands each waiting visitor to the next person; anyone o
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${one.id}`, bob, { assignee_id: 424242 })).status, 422, 'only to one of the people');
assert.strictEqual((await agentsApi('PATCH', `/${inbox.id}/conversations/${one.id}`, ann, { assignee_id: null })).body.conversation.assignee_id, null);
const app = await fetch(`${h.base}/inbox/${inbox.id}?f=mine&c=${two.id}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());
-- assert.ok(app.includes('Answered by') && app.includes(`data-conv="${two.id}"`) && app.includes('aria-current="page">Mine</a>'));
++ assert.ok(app.includes('Answered by') && app.includes(`data-conv="${two.id}"`));
++ assert.match(app, new RegExp(`<a class="ovk-sidebar-link is-here" href="/inbox/${inbox.id}\\?f=mine" aria-current="page"><svg[^>]*>[\\s\\S]*?</svg><span>Mine</span>`), 'the filter is a row of the sidebar, marked');
await agentsApi('PATCH', `/${inbox.id}`, ann, { route: 'none' });
});
@@@ -581,8 +582,15 @@ t('the embed page and the loader: frame-ancestors follow the allowed sites', asy
t('the people\'s pages: the list, a conversation and the settings with the snippet', async () => {
const page = async (path, u) => { const r = await fetch(`${h.base}${path}`, { headers: { cookie: `ov_token=${u.token}` }, redirect: 'manual' }); return { status: r.status, text: await r.text() }; };
assert.ok((await page('/inbox', ann)).text.includes(`href="/inbox/${inbox.id}"`));
-- const view = await page(`/inbox/${inbox.id}?c=${conv.id}`, bob);
-- assert.ok(view.text.includes('Hi, I can help with the refund.') && view.text.includes('vb-composer'));
++ const view = await page(`/inbox/${inbox.id}?c=${conv.id}&f=resolved`, bob); // resolved by now: its row is under Resolved
++ assert.ok(view.text.includes('Hi, I can help with the refund.') && view.text.includes('id="ib-composer"'));
++ // On the network's conversation kit (11-02): the conversations are the kit's sidebar, a row per visitor with a second
++ // line, found by data-conv; the conversation is the kit's thread, the team's words captioned with who wrote them.
++ assert.ok(view.text.includes('<aside class="ovk-sidebar" id="ib-rail" aria-label="Conversations" data-ovk-sidebar>'), 'the kit\'s sidebar');
++ assert.match(view.text, new RegExp(`<a class="ovk-sidebar-item has-sub has-mark is-here" href="/inbox/${inbox.id}\\?c=${conv.id}&amp;f=resolved" aria-current="page" data-conv="${conv.id}"`), 'the open conversation\'s row');
++ assert.match(view.text, new RegExp(`data-conv="${conv.id}" data-who="[^"]*"><span class="ib-av" aria-hidden="true">[A-Z]</span><span class="ovk-sidebar-t">[^<]+</span><span class="ovk-sidebar-m">[^<]+</span><span class="ovk-sidebar-s">[^<]+</span></a>`), 'the visitor\'s initial, name, when, and a second line');
++ assert.match(view.text, /<li class="ovt-turn" data-id="\d+" data-author="agent">\s*<div class="ovt-you"><span class="ovt-caption">[^<]+<\/span><div class="ovt-bubble"><span class="ib-text">Hi, I can help with the refund\.<\/span>/, 'the reply, captioned');
++ assert.ok(view.text.includes('/web/inbox.css?v=') && !view.text.includes('/web/vibe.css?v=') && view.text.includes('ov-thread.js'), 'the inbox\'s own stylesheet on the kit');
const settings = await page(`/inbox/${inbox.id}/settings`, ann);
assert.ok(settings.text.includes('data-inbox=&quot;') && settings.text.includes('/widget.js'), 'the snippet is shown escaped');
assert.strictEqual((await page(`/inbox/${inbox.id}`, h.addUser('cat', { subject: ids.newId('user') }))).status, 404);