openvibers/openvibe.chat Commits
The support inbox on the conversation kit; vibe.css loses the old layout (#327)
Changes
@@@ -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",@@@ -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",@@@ -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"},nnew file mode 100644@@@ -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; }@@@ -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 linksvar 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@@@ -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; }@@@ -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)}&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">×</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">☰</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">📎</span><span class="oc-sr">Send a picture</span></label>' : ''}<button class="vb-send" type="submit" aria-label="Send">↑</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);@@@ -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>@@@ -552,7 +552,8 @@ t('routing: taking turns hands each waiting visitor to the next person; anyone oassert.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', asyt('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}&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="') && 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);
@@ -9,7 +9,7 @@
@@ -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)",
"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",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.81.0",
"sdk": "openvibe-sdk v0.81.0",
- "shared": "openvibe-shared v4.100.2",
+ "shared": "openvibe-shared v4.102.0",
"capabilities": [
"capabilities": [
"chat.message.send",
"chat.message.send",
"chat.event.publish",
"chat.event.publish",
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"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-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",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -1339,9 +1339,9 @@
@@ -1339,9 +1339,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"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-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",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -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; }
@@ -1,23 +1,24 @@
@@ -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 () {
(function () {
'use strict';
'use strict';
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
if (!cfg || cfg.view !== 'inbox') return;
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 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 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 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).
// 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;
var PICS = /https:\/\/openvibe\.pics\/i\/([2-9a-km-zA-HJ-NP-Z]{8})\.(?:jpg|png|gif|webp|avif)(?![\w\/.-])/g;
function picsOf(text) {
function picsOf(text) {
@@ -32,31 +33,38 @@
@@ -32,31 +33,38 @@
}
}
function bare(t) { return String(t || '').replace(PICS, '').replace(/[ \t]+$/gm, '').trim(); }
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 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; } }
function clearTyping() { if (typingEl) { typingEl.remove(); typingEl = null; } }
+ // One message, as the kit's turn (server/web/inbox-pages.js inboxTurn).
function turn(m) {
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') {
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 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;
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 {
} 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);
li.dataset.id = String(m.id);
return li;
return li;
@@ -64,32 +72,54 @@
@@ -64,32 +72,54 @@
function appendToThread(m) {
function appendToThread(m) {
if (!thread || thread.querySelector('[data-id="' + m.id + '"]')) return;
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.
// 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();
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));
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 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) {
function touchList(convId, preview, status) {
var a = listItem(convId);
var a = listItem(convId);
if (!a) return;
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) {
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() {
function connect() {
@@ -104,19 +134,20 @@
@@ -104,19 +134,20 @@
touchList(m.conversation, m.message.body, null);
touchList(m.conversation, m.message.body, null);
if (m.conversation === cfg.conversation) appendToThread(m.message);
if (m.conversation === cfg.conversation) appendToThread(m.message);
} else if (m.type === 'inbox_assigned') {
} 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); }
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') {
} 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) {
} else if (m.type === 'inbox_call' && m.conversation === cfg.conversation) {
if (m.state === 'ringing') callOffered(m.by); else callGone();
if (m.state === 'ringing') callOffered(m.by); else callGone();
} else if (m.type === 'inbox_status') {
} else if (m.type === 'inbox_status') {
touchList(m.conversation, null, m.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) {
} 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);
setTimeout(clearTyping, 60000);
}
}
};
};
@@ -130,8 +161,8 @@
@@ -130,8 +161,8 @@
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); });
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); });
}
}
function addTranslate(li) {
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';
var b = el('button', 'ib-tr-btn', 'Translate'); b.type = 'button';
b.addEventListener('click', function () {
b.addEventListener('click', function () {
b.disabled = true; b.textContent = 'Translating…';
b.disabled = true; b.textContent = 'Translating…';
@@ -142,7 +173,7 @@
@@ -142,7 +173,7 @@
});
});
body.appendChild(b);
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');
var picIn = document.getElementById('ib-pic');
if (picIn && input) {
if (picIn && input) {
picIn.addEventListener('change', function () {
picIn.addEventListener('change', function () {
@@ -194,43 +225,39 @@
@@ -194,43 +225,39 @@
function usePick(r) { input.value = r.body; closePick(); input.dispatchEvent(new Event('input')); input.focus(); }
function usePick(r) { input.value = r.body; closePick(); input.dispatchEvent(new Event('input')); input.focus(); }
if (form && input) {
if (form && input) {
+ if (saved.length) input.setAttribute('aria-autocomplete', 'list');
input.addEventListener('input', function () { picked = 0; showPick(); });
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) {
input.addEventListener('keydown', function (e) {
if (!pick) return;
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(); }
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 === 'Enter' || e.key === 'Tab') { e.preventDefault(); e.stopImmediatePropagation(); usePick(pick._list[picked]); }
else if (e.key === 'Escape') closePick();
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) {
form.addEventListener('submit', function (e) {
var text = input.value.trim(); if (!text) { e.preventDefault(); return; }
var text = input.value.trim(); if (!text) { e.preventDefault(); return; }
if (!window.fetch) return;
if (!window.fetch) return;
e.preventDefault();
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();
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 }) })
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 (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
.then(function (x) {
.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 (!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');
touchList(cfg.conversation, text, 'open');
})
})
.catch(function () { pending.remove(); input.value = text; grow(); });
.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.
// "Answered by": choosing a person assigns at once.
var assignSel = document.querySelector('.ib-assign select[data-autosubmit]');
var assignSel = document.querySelector('.ib-assign select[data-autosubmit]');
if (assignSel) assignSel.addEventListener('change', function () { assignSel.form.requestSubmit ? assignSel.form.requestSubmit() : assignSel.form.submit(); });
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();
connect();
// A voice call with the visitor (server/inbox/inbox.js startCall, support-call.js): Call starts it and joins; anyone
// A voice call with the visitor (server/inbox/inbox.js startCall, support-call.js): Call starts it and joins; anyone
@@ -1,42 +1,10 @@
@@ -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-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 { 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 { cursor: pointer; width: fit-content; padding: 2px 0; font-weight: 600; list-style: none; }
.vb-how summary::-webkit-details-marker { display: none; }
.vb-how summary::-webkit-details-marker { display: none; }
@@ -61,41 +29,16 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
@@ -61,41 +29,16 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
.vb-source-date { color: var(--oc-muted); white-space: nowrap; }
.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 { margin: 2px 2px 2px 0; color: var(--oc-muted); }
.vb-how .vb-source:hover { color: var(--oc-text); }
.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 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 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-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-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-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); }
.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 { position: relative; }
.vb-actions > summary { list-style: none; }
.vb-actions > summary { list-style: none; }
.vb-actions > summary::-webkit-details-marker { display: none; }
.vb-actions > summary::-webkit-details-marker { display: none; }
@@ -111,60 +54,25 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
@@ -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 { 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-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 {
.vb-mark {
flex: none; width: 30px; height: 30px; border-radius: 50%; display: inline-grid; place-items: center; margin-top: 1px;
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));
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);
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-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. */
/* 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 {
.vb-act {
padding: 4px 10px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--oc-muted);
padding: 4px 10px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--oc-muted);
font: inherit; font-size: .8rem; cursor: pointer;
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: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-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 ───────────────────────────────────────────────────────────── */
/* ── 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; }
.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; }
@@ -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 small { grid-column: 1; color: var(--oc-muted); font-size: .8rem; }
.vb-notes form { grid-column: 2; grid-row: 1 / span 2; }
.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. */
/* 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 { 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; }
.vb-modes::-webkit-scrollbar { display: none; }
@@ -250,42 +134,27 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
@@ -250,42 +134,27 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
@media (hover: hover) { .vb-modes span:hover { color: var(--oc-text); } }
@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); }
.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) {
@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. */
/* 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; }
.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) {
@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-hello { justify-content: flex-end; padding-bottom: 4px; }
.vb-sub { font-size: .92rem; margin-bottom: 16px; }
.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) {
@media (max-width: 520px) and (max-height: 700px) {
.vb-hello .vb-mark { display: none; }
.vb-hello .vb-mark { display: none; }
.vb-hello h1 { font-size: 1.35rem; }
.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. */
/* 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 { max-width: var(--vb-col); margin: 0 auto 8px; display: flex; }
.vb-preview[hidden] { display: none; }
.vb-preview[hidden] { display: none; }
.vb-preview figure { position: relative; margin: 0; }
.vb-preview figure { position: relative; margin: 0; }
@@ -293,64 +162,6 @@ body.oc-app-body:has(.vb) { overflow: hidden; }
@@ -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%;
.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; }
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-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 */
/* 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; }
.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; }
@@ -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) ───────────────────────────────────
/* ── 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:
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,
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 { --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 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 .ovt-btn svg:not(.ovt-ic) { width: 20px; height: 20px; }
@@ -6,8 +6,10 @@
@@ -6,8 +6,10 @@
* GET /embed/inbox/:id the chat panel the widget opens in an iframe (public/web/embed.js); its
* 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)
* 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 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
* 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 ·
* 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
* /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');
@@ -21,10 +23,16 @@ const pictures = require('../rooms/pictures');
const { embedOf, sendExport } = require('../inbox/routes');
const { embedOf, sendExport } = require('../inbox/routes');
const records = require('../inbox/records');
const records = require('../inbox/records');
const { esc, asset } = require('./layout');
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 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 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 json = (v, d) => { if (Array.isArray(v)) return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };
const ago = (ts) => {
const ago = (ts) => {
const t = Date.parse(String(ts || '').includes('T') ? ts : `${String(ts || '').replace(' ', 'T')}Z`);
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
@@ -82,7 +90,7 @@ function mountInboxPages(router, { page, viewerOf, needUser, sameSite, formLimit
});
});
// ── The inbox's people ──
// ── 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) => {
router.get('/inbox', async (req, res) => {
const actor = await needUser(req, res); if (!actor) return;
const actor = await needUser(req, res); if (!actor) return;
@@ -127,53 +135,109 @@ ${list}
@@ -127,53 +135,109 @@ ${list}
const filter = ['active', 'mine', 'waiting', 'resolved'].includes(req.query.f) ? req.query.f : 'active';
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 list = await inboxes.conversations(inbox, { status: filter, user: actor.user });
const team = await inboxes.members(inbox);
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 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 current = req.query.c ? await inboxes.conversation(inbox, req.query.c) : null;
const saved = await inboxes.savedReplies(inbox);
const saved = await inboxes.savedReplies(inbox);
const msgs = current ? await inboxes.messages(current) : [];
const msgs = current ? await inboxes.messages(current) : [];
if (current) await inboxes.markRead(inbox, 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)}&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">×</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">☰</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">📎</span><span class="oc-sr">Send a picture</span></label>' : ''}<button class="vb-send" type="submit" aria-label="Send">↑</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, {
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) => {
router.post('/inbox/:id/c/:cid', formLimit, sameSite, async (req, res) => {
const a = await agentInbox(req, res); if (!a) return;
const a = await agentInbox(req, res); if (!a) return;
const conv = await inboxes.conversation(a.inbox, req.params.cid);
const conv = await inboxes.conversation(a.inbox, req.params.cid);
@@ -104,7 +104,8 @@ function contentSecurityPolicy(nonce) {
@@ -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
* 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:
* 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,
* 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
* (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;
* 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),
* 'compact', one quiet line, on pages people work in: a room, a channel, a conversation, a task, settings),
@@ -146,7 +147,7 @@ ${INSTALL_LINKS}
@@ -146,7 +147,7 @@ ${INSTALL_LINKS}
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${(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.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.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')}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('theme-loader.js')}" defer></script>
@@ -552,7 +552,8 @@ t('routing: taking turns hands each waiting visitor to the next person; anyone o
@@ -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}`, 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);
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());
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' });
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
@@ -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 () => {
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() }; };
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}"`));
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}&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);
const settings = await page(`/inbox/${inbox.id}/settings`, ann);
assert.ok(settings.text.includes('data-inbox="') && settings.text.includes('/widget.js'), 'the snippet is shown escaped');
assert.ok(settings.text.includes('data-inbox="') && 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);
assert.strictEqual((await page(`/inbox/${inbox.id}`, h.addUser('cat', { subject: ids.newId('user') }))).status, 404);