openvibers/openvibe.chat Commits

Task pages and rooms reworked: Vibe chats open in the app, rendered results, icon controls, compact footer (#221)

OP OpenVibers committed 17 days ago · 2e5101c338 · 14 files, +379 −74

* Task pages and rooms, reworked: a Vibe chat opens in the Vibe app; a task's result is rendered Markdown under a status header, facts, sources and icon actions; a room's header, delete and composer are icons; AI members' Markdown rendered; the one-line footer on pages people work in

* The room's AI line still names each member's model (people see which model and whose key)

Changes

ddiff --git a/public/web/chat.css b/public/web/chat.css
iindex 4a8008e..4bda897 100644
---- a/public/web/chat.css
++++ b/public/web/chat.css
@@@ -369,8 +369,128 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-task-full p, .oc-task-details { overflow-wrap: anywhere; }
@keyframes oc-work { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
@media (prefers-reduced-motion: reduce) { .oc-task-running .oc-task-state { animation: none; } }
--@media (max-width: 900px) { .oc-ws { grid-template-columns: minmax(0, 1fr); } .oc-queue { position: static; max-height: none; order: -1; } }
++/* On a phone the conversation comes first and the queue follows it, without its how-to (the composer's @vibe says it). */
++@media (max-width: 900px) { .oc-ws { grid-template-columns: minmax(0, 1fr); } .oc-queue { position: static; max-height: none; } }
++@media (max-width: 560px) { .oc-queue-how { display: none; } }
/* A workspace room's shared screen (rooms.screen_url, a screen share page from OpenVibe.Bot), above the conversation. */
.oc-screen { margin: 10px 0 14px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); overflow: hidden; }
.oc-screen summary { cursor: pointer; padding: 8px 12px; font-weight: 600; }
.oc-screen iframe { display: block; width: 100%; aspect-ratio: 16 / 10; min-height: 260px; max-height: 78vh; border: 0; background: #000; }
++
++/* Markdown (public/web/vibe-md.js): Vibe's answers, an AI member's messages in a room, a task's result */
++.vb-md { line-height: 1.65; overflow-wrap: anywhere; }
++.vb-md > :first-child { margin-top: 0; }
++.vb-md > :last-child { margin-bottom: 0; }
++.vb-md p { margin: 0 0 .8em; }
++.vb-md h3, .vb-md h4 { margin: 1.2em 0 .45em; line-height: 1.3; }
++.vb-md h3 { font-size: 1.12rem; }
++.vb-md h4 { font-size: 1rem; }
++.vb-md ul, .vb-md ol { margin: 0 0 .9em; padding-left: 1.4em; }
++.vb-md li { margin: .25em 0; }
++.vb-md li::marker { color: var(--oc-muted); }
++.vb-md a { color: var(--oc-accent); text-underline-offset: 2px; }
++.vb-md strong { font-weight: 700; }
++.vb-md code { font: .88em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--oc-panel-2); padding: .12em .38em; border-radius: 6px; }
++.vb-md pre { margin: 0 0 1em; padding: 14px 16px; border-radius: 14px; background: color-mix(in srgb, var(--oc-panel) 80%, #000); border: 1px solid var(--oc-line); overflow-x: auto; }
++.vb-md pre code { background: none; padding: 0; font-size: .86rem; white-space: pre; }
++.vb-md blockquote { margin: 0 0 .9em; padding: 2px 0 2px 14px; border-left: 3px solid var(--oc-line); color: var(--oc-muted); }
++.vb-md li > ul, .vb-md li > ol { margin: .2em 0 .3em; }
++.md-table { overflow-x: auto; margin: 0 0 1em; border: 1px solid var(--oc-line); border-radius: 12px; }
++.md-table:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
++.md-table table { border-collapse: collapse; width: 100%; font-size: .94em; line-height: 1.45; }
++.md-table th, .md-table td { padding: 8px 12px; border-bottom: 1px solid var(--oc-line); text-align: left; vertical-align: top; min-width: 6em; }
++.md-table thead th { background: var(--oc-panel-2); font-weight: 650; }
++.md-table tbody tr:last-child td { border-bottom: 0; }
++.md-table .al-c { text-align: center; }
++.md-table .al-r { text-align: right; }
++
++
++/* Line icons (server/web/icons.js), icon buttons and pills: one look for every control that is an icon or an icon
++ and a word. */
++.oc-i { width: 1.15em; height: 1.15em; flex: none; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; vertical-align: -.2em; }
++.oc-icon-btn { display: inline-grid; place-items: center; width: 40px; height: 40px; padding: 0; border-radius: 12px; border: 1px solid transparent; background: transparent; color: var(--oc-muted); cursor: pointer; text-decoration: none; }
++.oc-icon-btn:hover { color: var(--oc-text); background: var(--oc-panel-2); border-color: var(--oc-line); }
++.oc-icon-btn[hidden] { display: none; }
++.oc-icon-btn.is-done { color: var(--oc-ok); }
++.oc-pill { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; padding: 8px 14px; border-radius: 999px; border: 1px solid var(--oc-line); background: var(--oc-panel); color: var(--oc-text); font: inherit; font-size: .92rem; font-weight: 600; text-decoration: none; cursor: pointer; }
++.oc-pill:hover { border-color: var(--oc-accent); }
++.oc-pill-strong { background: var(--accent-strong, var(--oc-accent)); color: var(--on-accent-strong, var(--oc-on-accent)); border-color: transparent; }
++.oc-pill-danger { color: var(--oc-red); }
++.oc-pill-danger:hover { border-color: var(--oc-red); }
++
++/* A task's page (server/web/workspace-pages.js): status and kind, the title, the facts in a grid, then cards. */
++.oc-tp { max-width: 760px; margin: 0 auto; display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; }
++.oc-tp-back { display: inline-flex; align-items: center; gap: 6px; width: max-content; max-width: 100%; color: var(--oc-muted); text-decoration: none; font-size: .92rem; padding: 4px 8px 4px 2px; border-radius: 8px; }
++.oc-tp-back:hover { color: var(--oc-text); }
++.oc-tp-back span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.oc-tp h1 { margin: 2px 0 0; font-size: clamp(1.35rem, 4.5vw, 1.75rem); letter-spacing: -.01em; }
++.oc-tp-head { display: grid; gap: 8px; }
++.oc-tp-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; }
++.oc-tp-state, .oc-tp-kind { display: inline-flex; align-items: center; gap: 6px; padding: 3px 11px 3px 9px; border-radius: 999px; font-size: .82rem; font-weight: 700; border: 1px solid var(--oc-line); color: var(--oc-muted); }
++.oc-tp-succeeded { color: var(--oc-ok); border-color: color-mix(in srgb, var(--oc-ok) 45%, transparent); background: color-mix(in srgb, var(--oc-ok) 10%, transparent); }
++.oc-tp-failed { color: var(--oc-red); border-color: color-mix(in srgb, var(--oc-red) 45%, transparent); background: color-mix(in srgb, var(--oc-red) 10%, transparent); }
++.oc-tp-running { color: var(--oc-accent); border-color: color-mix(in srgb, var(--oc-accent) 45%, transparent); }
++.oc-tp-running .oc-i { animation: oc-spin 1s linear infinite; }
++@keyframes oc-spin { to { transform: rotate(360deg); } }
++@media (prefers-reduced-motion: reduce) { .oc-tp-running .oc-i { animation: none; } }
++.oc-tp-says { margin: 0; }
++.oc-tp-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 8px; margin: 6px 0 0; }
++.oc-tp-facts div { padding: 8px 12px; border-radius: 12px; background: var(--oc-panel); border: 1px solid var(--oc-line); min-width: 0; }
++.oc-tp-facts dt { font-size: .74rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--oc-muted); }
++.oc-tp-facts dd { margin: 2px 0 0; font-size: .95rem; overflow-wrap: anywhere; }
++.oc-tp-card { padding: 16px 18px; border-radius: 16px; background: var(--oc-panel); border: 1px solid var(--oc-line); min-width: 0; }
++.oc-tp-card h2 { margin: 0 0 10px; font-size: 1rem; display: flex; gap: 8px; align-items: baseline; }
++.oc-tp-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: -6px -8px 4px 0; }
++.oc-tp-card-head h2 { margin: 0; }
++.oc-tp-result { border-color: color-mix(in srgb, var(--oc-accent) 30%, var(--oc-line)); }
++.oc-tp-check { display: flex; gap: 8px; align-items: flex-start; margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--oc-line); color: var(--oc-muted); font-size: .88rem; }
++.oc-tp-check .oc-i { color: var(--oc-ok); margin-top: 2px; }
++.oc-tp-sources { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 6px; }
++.oc-tp-sources a { display: flex; flex-direction: column; padding: 8px 12px; border-radius: 10px; background: var(--oc-panel-2); text-decoration: none; color: var(--oc-text); min-width: 0; }
++.oc-tp-sources a:hover { outline: 1px solid var(--oc-accent); }
++.oc-tp-sources span { color: var(--oc-muted); font-size: .84rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++.oc-tp-actions { display: flex; flex-wrap: wrap; gap: 8px; }
++.oc-tp-cancel { margin: 0; }
++.oc-tp-form { max-width: none; }
++@media (max-width: 560px) {
++ .oc-tp-card { padding: 14px; border-radius: 14px; }
++ .oc-tp-facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }
++}
++
++/* A room (server/web/pages.js /r/:slug): a compact header with icon buttons, the messages filling the screen, the
++ composer docked under them with the picture button beside it. */
++.oc-room-head { display: flex; align-items: center; gap: 6px; margin: 0 0 6px; }
++.oc-room-head > .oc-icon-btn { margin-left: -8px; }
++.oc-room-id { flex: 1; min-width: 0; }
++.oc-room-id h1 { margin: 0; font-size: clamp(1.1rem, 4vw, 1.4rem); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.oc-room-lock { display: inline-flex; margin-left: 6px; color: var(--oc-muted); vertical-align: -1px; }
++.oc-room-lock .oc-i { width: .85em; height: .85em; }
++.oc-room-sub { margin: 0; font-size: .86rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.oc-room-tools { display: flex; gap: 2px; margin-right: -8px; }
++.oc-ai-here { display: flex; align-items: flex-start; gap: 8px; margin: 0 0 8px; padding: 6px 10px; border-radius: 10px; background: color-mix(in srgb, #a78bfa 8%, transparent); color: var(--oc-muted); font-size: .88rem; }
++.oc-ai-here .oc-i { color: #c4b5fd; margin-top: 2px; }
++.oc-ai-here strong { color: #ddd6fe; }
++.oc-msg-ai .vb-md { display: block; margin-top: 2px; }
++.oc-msg-ai .vb-md p { margin: 0 0 .5em; }
++.oc-del button { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; vertical-align: middle; }
++.oc-del .oc-i { width: 16px; height: 16px; }
++@media (hover: none) {
++ .oc-js .oc-del button { visibility: hidden; }
++ .oc-js .oc-msg.oc-sel .oc-del button { visibility: visible; }
++ .oc-msg.oc-sel { background: var(--oc-panel-2); }
++}
++.oc-dock { display: flex; align-items: flex-end; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
++.oc-dock > .oc-compose { flex: 1 1 0; min-width: 0; }
++.oc-dock > .oc-pic-compose { margin: 0; order: 0; }
++.oc-dock > .oc-pic-compose > summary { list-style: none; width: 44px; height: 44px; }
++.oc-dock > .oc-pic-compose > summary::-webkit-details-marker { display: none; }
++.oc-dock > .oc-pic-compose[open] { order: 2; flex: 1 1 100%; }
++.oc-dock > .oc-pic-compose[open] > summary { color: var(--oc-accent); }
++.oc-compose textarea { min-height: 44px; max-height: 40vh; border-radius: 22px; padding: 10px 16px; resize: none; field-sizing: content; }
++.oc-send { display: inline-grid; place-items: center; width: 44px; height: 44px; padding: 0; border-radius: 50%; flex: none; }
++.oc-send .oc-i { width: 20px; height: 20px; stroke-width: 2.4; }
++/* The conversation takes the screen's height (header, notes and composer around it), not a fixed box. */
++.oc-room-feed { max-height: none; height: max(320px, calc(100svh - 300px)); }
++@media (max-width: 560px) {
++ .oc-room-feed { height: max(300px, calc(100svh - 250px)); }
++}
ddiff --git a/public/web/chat.js b/public/web/chat.js
iindex 24fb7e1..9a7b5f3 100644
---- a/public/web/chat.js
++++ b/public/web/chat.js
@@@ -5,6 +5,10 @@
'use strict';
var cfg = (window.__OV_PAGE && window.__OV_PAGE.chat) || null;
if (!cfg) return;
++ // With this file running, a message's delete button waits for a tap on the message on a touch screen (chat.css).
++ if (document.documentElement && document.documentElement.classList) document.documentElement.classList.add('oc-js');
++ // The icons the server draws (server/web/icons.js), for controls made here: constant markup, never user text.
++ var TRASH = '<svg class="oc-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/></svg>';
var feed = document.getElementById('oc-feed');
var form = document.getElementById('oc-compose');
var input = document.getElementById('oc-input');
@@@ -246,11 +250,15 @@
var b = badge(m.user_role); if (b) li.appendChild(b);
}
li.appendChild(document.createTextNode(' '));
-- var t = el('span', 'oc-text'); textWithLinks(t, m.message); li.appendChild(t);
++ // An AI member writes Markdown: rendered by VibeMarkdown, which escapes every character first (as the server does).
++ var t;
++ if (ai && window.VibeMarkdown) { t = el('div', 'oc-text vb-md'); t.innerHTML = window.VibeMarkdown.render(m.message); }
++ else { t = el('span', 'oc-text'); textWithLinks(t, m.message); }
++ li.appendChild(t);
if (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }
if (cfg.moderate || (cfg.me && !ai && m.user_id === cfg.me)) {
var f = el('form', 'oc-del'); f.method = 'post'; f.action = '/r/' + encodeURIComponent(cfg.room) + '/delete/' + m.id;
-- var x = el('button', null, '×'); x.type = 'submit'; x.title = 'Delete this message'; x.setAttribute('aria-label', 'Delete this message');
++ var x = el('button'); x.type = 'submit'; x.title = 'Delete this message'; x.setAttribute('aria-label', 'Delete this message'); x.innerHTML = TRASH;
f.appendChild(x); li.appendChild(f);
}
return li;
@@@ -403,6 +411,29 @@
// A server's channel list folds away on a phone: the channel's own header says where you are.
var chans = document.querySelector ? document.querySelector('.oc-srv-channels') : null;
if (chans && window.matchMedia && window.matchMedia('(max-width: 760px)').matches) chans.open = false;
++ // A tap on a message (not on its links or buttons) shows its delete button on a touch screen; another hides it.
++ if (feed) feed.addEventListener('click', function (e) {
++ if (!e.target.closest || e.target.closest('a, button, input, summary, .oc-spoiler')) return;
++ var li = e.target.closest('.oc-msg');
++ if (!li || !li.querySelector('.oc-del')) return;
++ var on = !li.classList.contains('oc-sel');
++ Array.prototype.forEach.call(feed.querySelectorAll('.oc-sel'), function (x) { x.classList.remove('oc-sel'); });
++ if (on) li.classList.add('oc-sel');
++ });
++ // Copy buttons (a task's result): shown only where the clipboard works.
++ if (navigator.clipboard && document.querySelectorAll) {
++ Array.prototype.forEach.call(document.querySelectorAll('[data-oc-copy]'), function (b) {
++ var target = document.querySelector(b.getAttribute('data-oc-copy'));
++ if (!target) return;
++ b.hidden = false;
++ b.addEventListener('click', function () {
++ navigator.clipboard.writeText(target.innerText || target.textContent || '').then(function () {
++ b.classList.add('is-done'); b.setAttribute('aria-label', 'Copied'); b.title = 'Copied';
++ setTimeout(function () { b.classList.remove('is-done'); b.setAttribute('aria-label', 'Copy the result'); b.title = 'Copy'; }, 1800);
++ }, function () {});
++ });
++ });
++ }
toBottom();
if (cfg.view === 'global') startGlobal();
else if (cfg.view === 'dm') startDm();
ddiff --git a/public/web/vibe-md.js b/public/web/vibe-md.js
iindex 94b1d4d..2237c15 100644
---- a/public/web/vibe-md.js
++++ b/public/web/vibe-md.js
@@@ -2,7 +2,7 @@
* subset, every character of the source escaped first, so the only markup in the output is the markup made here.
* blocks: ``` fenced code ```, # / ## / ### headings (drawn as h3/h4), - * • lists and 1. lists (nested by indenting,
* 4 deep), | pipe | tables | (with :-: alignment; they scroll inside their own box), > quotes, paragraphs
-- * inline: `code`, **bold**, *italic* / _italic_, [text](https://…), bare https:// links (rel nofollow, new tab)
++ * inline: `code`, **bold**, *italic* / _italic_, [text](https://…), <https://…>, bare https:// links (rel nofollow, new tab)
* Links are http(s) only. One file for both sides: CommonJS on the server, window.VibeMarkdown in the browser. */
(function (root) {
'use strict';
@@@ -19,6 +19,8 @@
var links = [];
// An address ends at white space or at an escaped quote or angle bracket; trailing punctuation stays outside it.
s = s.replace(/\[([^\]\n]{1,300})\]\((https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s)])+)\)/g, function (_, t, u) { links.push(link(u, t)); return '\u0001' + (links.length - 1) + '\u0001'; });
++ // <https://…>, Markdown's autolink: escaped to &lt;…&gt; above, the brackets go and the address is the link.
++ s = s.replace(/&lt;(https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s<])+)&gt;/g, function (_, u) { links.push(link(u, u)); return '\u0001' + (links.length - 1) + '\u0001'; });
s = s.replace(/(^|[\s(])(https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s<])+)/g, function (_, pre, u) {
var tail = /[.,;:!?)]+$/.exec(u);
var href = tail ? u.slice(0, -tail[0].length) : u;
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex 6576f44..fa8836c 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -118,32 +118,7 @@ body.oc-app-body { overflow: hidden; }
.vb-mark svg { width: 16px; height: 16px; stroke-width: 2.6; }
.vb-body { flex: 1; min-width: 0; padding-top: 3px; }
--/* Vibe's answers (public/web/vibe-md.js) */
--.vb-md { line-height: 1.65; overflow-wrap: anywhere; }
--.vb-md > :first-child { margin-top: 0; }
--.vb-md > :last-child { margin-bottom: 0; }
--.vb-md p { margin: 0 0 .8em; }
--.vb-md h3, .vb-md h4 { margin: 1.2em 0 .45em; line-height: 1.3; }
--.vb-md h3 { font-size: 1.12rem; }
--.vb-md h4 { font-size: 1rem; }
--.vb-md ul, .vb-md ol { margin: 0 0 .9em; padding-left: 1.4em; }
--.vb-md li { margin: .25em 0; }
--.vb-md li::marker { color: var(--oc-muted); }
--.vb-md a { color: var(--oc-accent); text-underline-offset: 2px; }
--.vb-md strong { font-weight: 700; }
--.vb-md code { font: .88em/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--oc-panel-2); padding: .12em .38em; border-radius: 6px; }
--.vb-md pre { margin: 0 0 1em; padding: 14px 16px; border-radius: 14px; background: color-mix(in srgb, var(--oc-panel) 80%, #000); border: 1px solid var(--oc-line); overflow-x: auto; }
--.vb-md pre code { background: none; padding: 0; font-size: .86rem; white-space: pre; }
--.vb-md blockquote { margin: 0 0 .9em; padding: 2px 0 2px 14px; border-left: 3px solid var(--oc-line); color: var(--oc-muted); }
--.vb-md li > ul, .vb-md li > ol { margin: .2em 0 .3em; }
--.md-table { overflow-x: auto; margin: 0 0 1em; border: 1px solid var(--oc-line); border-radius: 12px; }
--.md-table:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
--.md-table table { border-collapse: collapse; width: 100%; font-size: .94em; line-height: 1.45; }
--.md-table th, .md-table td { padding: 8px 12px; border-bottom: 1px solid var(--oc-line); text-align: left; vertical-align: top; min-width: 6em; }
--.md-table thead th { background: var(--oc-panel-2); font-weight: 650; }
--.md-table tbody tr:last-child td { border-bottom: 0; }
--.md-table .al-c { text-align: center; }
--.md-table .al-r { text-align: right; }
++/* 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; }
ddiff --git a/server/rooms/workspace.js b/server/rooms/workspace.js
iindex ae64c5c..0eeaf6a 100644
---- a/server/rooms/workspace.js
++++ b/server/rooms/workspace.js
@@@ -47,7 +47,7 @@ function shape(t, room) {
quick: QUICK.has(t.kind),
requested_by: { id: Number(t.requested_by), username: t.requester_username || null, name: t.requester_name || t.requester_username || 'someone' },
runner: t.runner || null, result: t.result || null, result_url: t.result_url || null, sources: Array.isArray(sources) ? sources : [],
-- error: t.error || null, cost_usd: t.cost_usd == null ? null : Number(t.cost_usd),
++ error: t.error || null, cost_usd: t.cost_usd == null ? null : Number(t.cost_usd), how: t.how || null,
created_at: t.created_at, started_at: t.started_at || null, finished_at: t.finished_at || null,
url: `/r/${room.slug}/tasks/${Number(t.id)}`,
};
ddiff --git a/server/web/icons.js b/server/web/icons.js
nnew file mode 100644
iindex 0000000..c57b0bb
---- /dev/null
++++ b/server/web/icons.js
@@@ -0,0 +1,36 @@
++'use strict';
++/**
++ * The line icons the room and task pages draw (24×24, stroked in the text's colour: chat.css .oc-i). Decorative
++ * always: the control around one carries its name (aria-label or visible text).
++ */
++const svg = (d) => `<svg class="oc-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${d}</svg>`;
++
++const ICONS = {
++ back: svg('<path d="M15 5l-7 7 7 7"/>'),
++ send: svg('<path d="M12 19V5M5 12l7-7 7 7"/>'),
++ image: svg('<rect x="3" y="5" width="18" height="14" rx="3"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 8"/>'),
++ trash: svg('<path d="M4 7h16M10 11v6M14 11v6M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4h6v3"/>'),
++ gear: svg('<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>'),
++ members: svg('<circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 4.8a3.2 3.2 0 0 1 0 6.4M18 14.8c1.9.6 3 2.4 3 5.2"/>'),
++ leave: svg('<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4M10 16l-4-4 4-4M6 12h10"/>'),
++ lock: svg('<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/>'),
++ spark: svg('<path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8ZM19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8Z"/>'),
++ check: svg('<path d="m5 12.5 4.5 4.5L19 7.5"/>'),
++ clock: svg('<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>'),
++ alert: svg('<path d="M12 4 2.8 19.5h18.4Z"/><path d="M12 10v4M12 17h.01"/>'),
++ stop: svg('<circle cx="12" cy="12" r="8.5"/><path d="m8.5 8.5 7 7"/>'),
++ working: svg('<path d="M12 3a9 9 0 1 0 9 9"/>'),
++ copy: svg('<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>'),
++ open: svg('<path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/>'),
++ steps: svg('<path d="M9 6h11M9 12h11M9 18h11"/><path d="m3 6 1.5 1.5L7 5M3 12l1.5 1.5L7 11M3 18l1.5 1.5L7 17"/>'),
++ computer: svg('<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>'),
++ search: svg('<circle cx="11" cy="11" r="6.5"/><path d="m20 20-4.2-4.2"/>'),
++ globe: svg('<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18"/>'),
++ code: svg('<path d="m8 8-4 4 4 4M16 8l4 4-4 4M14 5l-4 14"/>'),
++ chat: svg('<path d="M4 5h16v11H8l-4 4Z"/>'),
++ task: svg('<rect x="4" y="4" width="16" height="16" rx="3"/><path d="m8 12 3 3 5-6"/>'),
++ retry: svg('<path d="M4 12a8 8 0 0 1 13.7-5.7L20 8.5M20 4v4.5h-4.5M20 12a8 8 0 0 1-13.7 5.7L4 15.5M4 20v-4.5h4.5"/>'),
++ hand: svg('<path d="M8 13V5.5a1.5 1.5 0 0 1 3 0V11M11 10V4.5a1.5 1.5 0 0 1 3 0V11M14 10.5V6a1.5 1.5 0 0 1 3 0v7c0 4-2.5 7-6.5 7-2.6 0-4.2-1.3-5.7-3.6L3 13.6a1.5 1.5 0 0 1 2.4-1.8L8 15"/>'),
++};
++
++module.exports = { ICONS };
ddiff --git a/server/web/inbox-pages.js b/server/web/inbox-pages.js
iindex d8d41bd..27247b7 100644
---- a/server/web/inbox-pages.js
++++ b/server/web/inbox-pages.js
@@@ -205,7 +205,7 @@ ${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input
const hooks = owner ? await webhooks.list(inbox) : [];
const acts = owner ? await actions.list(inbox) : [];
await page(req, res, 200, {
-- actor, title: `${inbox.name}: settings`, path: `/inbox/${inbox.id}/settings`, active: 'inbox', robots: 'noindex, nofollow',
++ actor, title: `${inbox.name}: settings`, path: `/inbox/${inbox.id}/settings`, active: 'inbox', robots: 'noindex, nofollow', footer: 'compact',
body: `<p><a href="/inbox/${esc(inbox.id)}">&larr; ${esc(inbox.name)}</a></p><h1>${esc(inbox.name)}</h1>
${req.query.made ? notice('ok', 'Your inbox is ready. Paste the snippet into your site, before </body>.') : ''}${req.query.saved ? notice('ok', 'Saved.') : ''}${req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : ''}
<h2>Add it to your site</h2>
@@@ -299,7 +299,7 @@ ${saved.length ? `<ul class="oc-members">${saved.map((r) => `<li><b>/${esc(r.sho
const line = (m) => (m.author === 'system' ? `<li class="oc-muted sp-sys" data-id="${m.id}">${esc(m.body)}</li>`
: `<li class="sp-msg${m.author === 'visitor' ? ' sp-me' : ''}" data-id="${m.id}"><b>${esc(who(m))}</b> <span class="sp-text">${m.author === 'ai' ? md.render(m.body) : esc(m.author === 'agent' || m.account ? pictures.withoutLinks(m.body) : m.body).replace(/\n/g, '<br>')}</span>${m.author === 'agent' || m.account ? pictures.pageHtml(m.body) : ''}</li>`);
await page(req, res, 200, {
-- actor, title: `${inbox.name}: support`, path: `/support/${conv.id}`, active: 'messages', robots: 'noindex, nofollow',
++ actor, title: `${inbox.name}: support`, path: `/support/${conv.id}`, active: 'messages', robots: 'noindex, nofollow', footer: 'compact',
page: { view: 'support', inbox: inbox.id, conversation: conv.id, latest: msgs.length ? msgs[msgs.length - 1].id : 0 },
scripts: ['vibe-md.js', 'support.js'],
body: `<p><a href="/messages">&larr; Messages</a></p>
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 13fdcd8..677f86d 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -101,7 +101,8 @@ function contentSecurityPolicy(nonce) {
* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:
* vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js), feedView
* (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)
++ * 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)
*/
function renderPage(o) {
const { config } = o;
@@@ -121,6 +122,7 @@ function renderPage(o) {
const p = o.prefs || {};
const cls = [p.compact ? 'oc-compact' : '', p.timestamps ? 'oc-times' : '', p.show_badges === false ? 'oc-nobadges' : ''].filter(Boolean).join(' ');
const scale = typeof p.font_scale === 'number' ? ` style="--oc-scale:${Math.min(1.4, Math.max(0.8, p.font_scale))}"` : '';
++ const footerVariant = o.footer === 'compact' ? 'compact' : 'full';
const html = `<!DOCTYPE html>
<html lang="en">
<head>
@@@ -151,9 +153,9 @@ ${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label,
<noscript><p class="oc-account">${who}</p></noscript>
${o.body || ''}
</main>
--${o.app ? '' : frame.footer({ service: 'chat', variant: 'full', updates: '/updates' })}
++${o.app ? '' : frame.footer({ service: 'chat', variant: footerVariant, updates: '/updates' })}
<script>
--window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'chat', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};
++window.__OV_PAGE = ${JSON.stringify({ navbar: navConfig(o, config), footer: { service: 'chat', variant: footerVariant, mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' }, chat: o.page || null }).replace(/</g, '\\u003c')};
document.addEventListener('DOMContentLoaded', function () {
try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the Frame is optional */ }
try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }
ddiff --git a/server/web/pages.js b/server/web/pages.js
iindex 142022a..9764545 100644
---- a/server/web/pages.js
++++ b/server/web/pages.js
@@@ -306,7 +306,7 @@ ${parts.more}`,
const support = await require('../inbox/inbox').mySupport(me, { limit: 20 }).catch(() => []);
const supportList = support.length ? `<h2>Support</h2><ul class="oc-convs">${support.map((c) => { const t = hhmm(c.last_message_at); return `<li class="oc-conv"><a href="/support/${esc(c.id)}"><strong>${esc(c.inbox.name)} <span class="oc-badge">${esc({ bot: 'Vibe', waiting: 'Waiting', open: 'Open', resolved: 'Resolved' }[c.status] || c.status)}</span></strong><span class="oc-muted oc-last">${esc(c.preview || '')}</span></a>${t.text ? `<time class="oc-time" datetime="${t.iso}">${t.iso.slice(0, 10)}</time>` : ''}</li>`; }).join('')}</ul>` : '';
await page(req, res, 200, {
-- actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true,
++ actor, title: 'Messages', path: '/messages', active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',
page: { view: 'inbox', unread, me: me.id },
body: `<h1>Messages</h1>${error}${done}
<p class="oc-muted" id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</p>
@@@ -363,7 +363,7 @@ ${blockedList}`,
const callForm = other && !closed && require('../config').calls.enabled
? `<form class="oc-inline oc-dm-call" method="post" action="/messages/${id}/call"><button type="submit" class="oc-button--quiet">Call ${otherName}</button></form>` : '';
await page(req, res, 200, {
-- actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true,
++ actor, title, path: `/messages/${id}`, active: 'messages', robots: 'noindex, nofollow', script: true, footer: 'compact',
page: { view: 'dm', conversation: id, latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me.id },
body: `<p><a href="/messages">← Messages</a></p><div class="oc-dm-head"><h1>${esc(title)}</h1>${callForm}</div>${error}
<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => dmMessage(m, me)).join('\n') || '<li class="oc-empty oc-muted">No messages yet.</li>'}</ol>
@@@ -462,7 +462,7 @@ ${composer}${tools}`,
const saved = req.query.saved ? notice('ok', 'Saved. These settings follow you to Live chat and every device.') : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
await page(req, res, 200, {
-- actor, title: 'Chat settings', path: '/settings', active: 'settings', robots: 'noindex, nofollow',
++ actor, title: 'Chat settings', path: '/settings', active: 'settings', robots: 'noindex, nofollow', footer: 'compact',
body: `<h1>Chat settings</h1>${saved}${error}
<p class="oc-muted">Saved to your OpenVibe account, so they apply here, in Live chat and on every device you sign in to.</p>
<form class="oc-form" method="post" action="/settings">
@@@ -577,10 +577,12 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
const roomHooks = require('../rooms/hooks');
const pictures = require('../rooms/pictures');
const aiMembers = require('../rooms/ai-members');
++ const md = require('../../public/web/vibe-md.js');
++ const { ICONS } = require('./icons');
// A picture sent from the room's form (server/rooms/pictures.js): one image, kept in memory only until Pics has it.
const pictureForm = require('multer')({ storage: require('multer').memoryStorage(), limits: { fileSize: (require('../config').pics.maxBytes || 20 * 1024 * 1024), files: 1, fields: 4 } }).single('picture');
/** The "Add a picture" form under a room's or channel's composer (rooms where the person may post). */
-- const pictureComposer = (room, back) => (pictures.enabled() ? `<details class="oc-pic-compose"><summary>Add a picture</summary>
++ const pictureComposer = (room, back) => (pictures.enabled() ? `<details class="oc-pic-compose"><summary class="oc-icon-btn" aria-label="Add a picture" title="Add a picture">${ICONS.image}</summary>
<form class="oc-form oc-pic-form" method="post" action="/r/${esc(room.slug)}/picture" enctype="multipart/form-data" data-room="${esc(room.slug)}">
<input type="hidden" name="back" value="${esc(back)}">
<label>Picture <input type="file" name="picture" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" required></label>
@@@ -601,11 +603,13 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
const roomMessage = (m, can, slug, me) => {
const t = hhmm(m.created_at);
const ai = m.ai_member_id != null;
-- const del = (can.moderate || (me && !ai && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">×</button></form>` : '';
++ const del = (can.moderate || (me && !ai && m.user_id === me.id)) ? `<form class="oc-del" method="post" action="/r/${esc(slug)}/delete/${Number(m.id)}"><button type="submit" title="Delete this message" aria-label="Delete this message">${ICONS.trash}</button></form>` : '';
const who = ai
? `<span class="oc-name oc-ai-name">${esc(m.display_name || 'AI')}</span> <span class="oc-badge oc-ai" title="An AI member of this room">AI</span>`
: `<a class="oc-name" href="${LIVE}/@${encodeURIComponent(m.username || '')}"${/^#[0-9a-f]{3,8}$/i.test(m.profile_color || '') ? ` style="--nc:${esc(m.profile_color)}"` : ''}>${esc(m.display_name || m.username || 'someone')}</a>${roleBadge(m.user_role)}`;
-- return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} <span class="oc-text">${linkify(m.message)}</span>${ai ? '' : pictures.pageHtml(m.message)}${del}</li>`;
++ // An AI member writes Markdown (lists, bold, links): vibe-md.js escapes every character first, then marks it up.
++ const text = ai ? `<div class="oc-text vb-md">${md.render(m.message)}</div>` : `<span class="oc-text">${linkify(m.message)}</span>`;
++ return `<li class="oc-msg${ai ? ' oc-msg-ai' : ''}" data-id="${Number(m.id) || 0}" data-user="${Number(m.user_id) || 0}"><time class="oc-time" datetime="${t.iso}">${t.text}</time> ${who} ${text}${ai ? '' : pictures.pageHtml(m.message)}${del}</li>`;
};
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
@@@ -706,19 +710,21 @@ ${people}
const x = await loadRoom(req, res); if (!x) return;
const { actor, me, room, can } = x;
if (room.server_id) return res.redirect(302, await channelUrl(room));
++ // A person's own Vibe chat opens in the Vibe app (its links in task pages and messages name /r/<slug>).
++ if (me && Number(room.assistant) === 1 && await require('../rooms/vibe').ownChat(room.slug, me)) return res.redirect(302, `/vibe/${encodeURIComponent(room.slug)}`);
const msgs = await rooms.history(room, { limit: 60 });
if (me && can.role) await rooms.markRead(room, me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
let composer;
if (!me) composer = `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=${encodeURIComponent(`/r/${room.slug}`)}">Sign in with OpenVibe</a> to join the conversation.</p>`;
-- else if (can.post) composer = `<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="2" required placeholder="${room.kind === 'system' ? 'Post an announcement' : `Message ${esc(room.name)}`}"></textarea><button type="submit">Send</button></form>${room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`)}`;
++ else if (can.post) composer = `<div class="oc-dock">${room.kind === 'system' ? '' : pictureComposer(room, `/r/${room.slug}`)}<form class="oc-compose" method="post" action="/r/${esc(room.slug)}" id="oc-compose"><label for="oc-input" class="oc-sr">Message to ${esc(room.name)}</label><textarea id="oc-input" name="message" maxlength="2000" rows="1" required placeholder="${room.kind === 'system' ? 'Post an announcement' : 'Write a message'}"></textarea><button type="submit" class="oc-send" aria-label="Send" title="Send">${ICONS.send}</button></form></div>`;
else if (!can.role) composer = `<form method="post" action="/r/${esc(room.slug)}/join"><button type="submit">${joinLabel}</button></form>`;
else if (room.kind === 'system') composer = `<p class="oc-muted">Announcements from OpenVibe staff. You will see new ones counted in Rooms.</p>`;
else if (can.role === 'viewer') composer = `<p class="oc-muted">You can read this room but not write in it (viewer).</p>`;
else composer = notice('error', 'You cannot post here right now.');
-- const tools = me && can.role && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/leave"><button type="submit" class="oc-link">Leave room</button></form>` : '';
-- const manage = can.moderate ? ` · <a href="/r/${esc(room.slug)}/settings">${can.manage ? 'Room settings' : 'Members'}</a>` : '';
++ const leave = me && can.role && can.role !== 'owner' ? `<form class="oc-inline" method="post" action="/r/${esc(room.slug)}/leave"><button type="submit" class="oc-icon-btn" aria-label="Leave this room" title="Leave this room">${ICONS.leave}</button></form>` : '';
++ const manage = can.moderate ? `<a class="oc-icon-btn" href="/r/${esc(room.slug)}/settings" aria-label="${can.manage ? 'Room settings' : 'Members'}" title="${can.manage ? 'Room settings' : 'Members'}">${can.manage ? ICONS.gear : ICONS.members}</a>` : '';
const YOU_ARE = { owner: 'the owner', mod: 'a moderator', speaker: 'a speaker', participant: 'a participant', member: 'a member', viewer: 'a viewer' };
const yourRole = me && YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : '';
const isCall = room.kind === 'call';
@@@ -727,21 +733,26 @@ ${people}
// A feed the owner shows here (server/feeds/): its card above the messages.
const feed = await require('../feeds/feeds').forRoom(room.id);
const work = rooms.isWorkspace(room) ? await require('../rooms/workspace').list(room, me, { limit: 40 }) : null;
-- const aiHere = ais.length ? `<p class="oc-ai-here">${ais.map((a) => `<span class="oc-badge oc-ai">AI</span> <strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(aiMembers.modelOf(a))})</span>`).join(' · ')} <span class="oc-muted">· write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask${ais.length > 1 ? ' one of them' : ''} something</span></p>` : '';
++ const aiHere = ais.length ? `<p class="oc-ai-here">${ICONS.spark}<span>${ais.map((a) => `<strong>${esc(a.name)}</strong> <span class="oc-muted oc-ai-model">(${esc(aiMembers.modelOf(a))})</span>`).join(', ')} ${ais.length > 1 ? 'are' : 'is'} here: write ${ais.map((a) => `<code>@${esc(a.handle)}</code>`).join(' or ')} to ask</span></p>` : '';
++ const memberCount = (await rooms.publicRoom(room)).members;
await page(req, res, 200, {
actor, title: room.name, path: `/r/${room.slug}`, active: 'rooms', robots: room.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady, feedView: !!feed,
++ footer: 'compact', scripts: ais.length ? ['vibe-md.js'] : [], wide: !!work,
description: room.topic || `${room.name}, a ${isCall ? 'call' : room.kind === 'system' ? 'announcements' : 'chat'} room on OpenVibe.`,
page: { view: 'room', room: room.slug, kind: room.kind, pics: pictures.enabled() ? pictures.publicUrl() : null, ai: ais.map((a) => a.handle), latest: msgs.length ? msgs[msgs.length - 1].id : 0, me: me ? me.id : null, moderate: can.moderate, post: can.post, role: can.role,
...(callReady ? { call: { channel: `room-${room.slug}`, join: can.join, talk: can.talk } } : {}) },
-- body: `<p><a href="/rooms">← Rooms</a></p>
--<h1>${esc(room.name)}${kindBadge(room)}${room.visibility === 'private' ? ' <span class="oc-badge">private</span>' : ''}</h1>
--<p class="oc-muted">${room.topic ? `${esc(room.topic)} · ` : ''}${(await rooms.publicRoom(room)).members} members${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole}${manage} <span class="oc-live" id="oc-live" hidden>● live</span></p>
++ body: `<header class="oc-room-head">
++<a class="oc-icon-btn" href="/rooms" aria-label="All rooms" title="All rooms">${ICONS.back}</a>
++<div class="oc-room-id"><h1>${esc(room.name)}${room.visibility === 'private' ? `<span class="oc-room-lock" title="Private: only people invited read and post">${ICONS.lock}<span class="oc-sr">private</span></span>` : ''}${kindBadge(room)}</h1>
++<p class="oc-muted oc-room-sub">${room.topic ? `${esc(room.topic)} · ` : ''}${memberCount} ${memberCount === 1 ? 'member' : 'members'}${room.slow_seconds ? ` · slow mode ${room.slow_seconds}s` : ''}${yourRole} <span class="oc-live" id="oc-live" hidden>● live</span></p></div>
++<div class="oc-room-tools">${manage}${leave}</div>
++</header>
${aiHere}${error}
${isCall ? callPanel(room, can, me) : ''}
${feed ? `<div class="fv oc-room-fv" data-feed="${esc(feed.id)}"></div>` : ''}
${work && room.screen_url && can.role ? `<details class="oc-screen" open><summary>Shared screen</summary><iframe src="${esc(room.screen_url)}" title="The room's shared screen" loading="lazy" referrerpolicy="no-referrer" allow="clipboard-write; fullscreen"></iframe></details>` : ''}
--${work ? '<div class="oc-ws"><div class="oc-ws-main">' : ''}<ol class="oc-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
--${composer}${tools}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
++${work ? '<div class="oc-ws"><div class="oc-ws-main">' : ''}<ol class="oc-feed oc-room-feed" id="oc-feed" aria-live="polite" aria-label="Messages">${msgs.map((m) => roomMessage(m, can, room.slug, me)).join('\n') || `<li class="oc-empty oc-muted">${room.kind === 'system' ? 'No announcements yet.' : 'No messages yet. Say hello.'}</li>`}</ol>
++${composer}${work ? `</div>${queuePanel(room, can, me, work)}</div>` : ''}`,
});
});
@@@ -844,7 +855,7 @@ ${ais.length < aiMembers.MAX_PER_ROOM && !ais.some((a) => a.handle === 'vibe') ?
${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}` : '';
if (room.server_id) {
return await page(req, res, 200, {
-- actor, title: `#${room.name}: AI members`, path: `/r/${room.slug}/settings`, active: 'servers', robots: 'noindex, nofollow',
++ actor, title: `#${room.name}: AI members`, path: `/r/${room.slug}/settings`, active: 'servers', robots: 'noindex, nofollow', footer: 'compact',
body: `<p><a href="${esc(await channelUrl(room, '/settings'))}">← Server settings</a> · <a href="${esc(await channelUrl(room))}">#${esc(room.name)}</a></p><h1>AI members in #${esc(room.name)}</h1>${saved}${error}
${aiSection}`,
});
@@@ -860,7 +871,7 @@ ${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="post
const communityUrl = config.web.communityUrl;
const attachList = can.manage ? `<h2>Attached to</h2>${attachments.length ? `<ul class="oc-members">${attachments.map((a) => `<li><a href="${esc(a.service === 'community' ? `${communityUrl}/s/${encodeURIComponent(a.resource)}` : '#')}">${esc(a.title || a.resource)}</a> <span class="oc-muted">${esc(a.service === 'community' ? 'OpenVibe.Community space' : a.service)}</span><form class="oc-inline" method="post" action="/r/${esc(room.slug)}/attachments/${encodeURIComponent(a.service)}/${encodeURIComponent(a.resource)}/detach"><button type="submit" class="oc-link">Detach</button></form></li>`).join('')}</ul>` : '<p class="oc-muted">Not attached anywhere. A Community space you own can attach this room from the space\'s page.</p>'}` : '';
await page(req, res, 200, {
-- actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow',
++ actor, title: `${room.name}: settings`, path: `/r/${room.slug}/settings`, active: 'rooms', robots: 'noindex, nofollow', footer: 'compact',
body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p><h1>Room settings</h1>${saved}${error}
${can.manage && await require('./room-embed-pages').embeddable(room) ? `<h2>Put this room on your site</h2>
<p>Anyone can read it there; people sign in with OpenVibe to write, and the room's moderation applies. A chat bubble:</p>
ddiff --git a/server/web/server-pages.js b/server/web/server-pages.js
iindex 3f69885..b9d063b 100644
---- a/server/web/server-pages.js
++++ b/server/web/server-pages.js
@@@ -130,7 +130,7 @@ ${create}`,
].filter(Boolean).join(' · ');
const canInvite = can.manage || (can.moderate && server.visibility === 'public');
await page(req, res, 200, {
-- actor, wide: true, title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: back, active: 'servers',
++ actor, wide: true, footer: 'compact', title: `${isVoice ? '' : '#'}${room.name} · ${server.name}`, path: back, active: 'servers',
robots: server.visibility === 'public' ? 'index, follow' : 'noindex, nofollow', script: true, callScript: callReady,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
page: {
ddiff --git a/server/web/workspace-pages.js b/server/web/workspace-pages.js
iindex 6d1c233..74c2410 100644
---- a/server/web/workspace-pages.js
++++ b/server/web/workspace-pages.js
@@@ -12,10 +12,49 @@
* POST /r/:slug/tasks/:id/run the asker runs their code or computer task on their own computer (Actor, Codes, Run)
* POST /r/:slug/tasks/:id/report say how it went (whoever took it)
*/
++const md = require('../../public/web/vibe-md.js');
const rooms = require('../rooms/rooms');
const workspace = require('../rooms/workspace');
++const how = require('../rooms/how');
++const { ICONS } = require('./icons');
const STATE = { queued: 'Waiting', running: 'Working', succeeded: 'Done', failed: 'Stuck', cancelled: 'Cancelled' };
++// A task page's status pill and kind chip: an icon and a word each.
++const STATE_ICON = { queued: ICONS.clock, running: ICONS.working, succeeded: ICONS.check, failed: ICONS.alert, cancelled: ICONS.stop };
++const KIND_CHIP = {
++ answer: [ICONS.chat, 'Answer'], research: [ICONS.search, 'Research'], web: [ICONS.globe, 'Web answer'],
++ code: [ICONS.code, 'Code'], computer: [ICONS.computer, 'On a computer'], other: [ICONS.task, 'Task'],
++};
++// What a status means for the person who asked, under the title.
++const STATE_SAYS = {
++ queued: 'Waiting for someone to take it, or for you to run it on your own computer.',
++ running: 'Being worked on now. This page and the chat update when it is done.',
++ failed: 'It could not be finished. What went wrong is below.',
++ cancelled: 'Cancelled before it was done.',
++};
++/** A result's first lines as plain text (the queue's preview): the Markdown marks go, the words stay. */
++function plain(text) {
++ return String(text || '')
++ .replace(/```[\s\S]*?```/g, ' ')
++ .replace(/\[([^\]\n]+)\]\((https?:[^)\s]+)\)/g, '$1')
++ .replace(/^\s{0,3}(#{1,6}|>|[-*•]|\d+[.)])\s+/gm, '')
++ .replace(/(\*\*|__|`)/g, '')
++ .replace(/(^|\W)[*_]([^*_\n]+)[*_](?=\W|$)/g, '$1$2')
++ .replace(/\s+/g, ' ')
++ .trim();
++}
++const hostOf = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return u; } };
++const atOf = (ts) => { const v = String(ts || ''); const t = Date.parse(v.includes('T') ? v : `${v.replace(' ', 'T')}Z`); return Number.isFinite(t) ? t : null; };
++/** How long a task took, in plain words, or null before it ends. */
++function took(t) {
++ const a = atOf(t.started_at || t.created_at), b = atOf(t.finished_at);
++ if (a == null || b == null || b < a) return null;
++ const s = Math.round((b - a) / 1000);
++ if (s < 60) return s < 5 ? 'a few seconds' : `${s} seconds`;
++ const m = Math.round(s / 60);
++ return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
++}
++const money = (usd) => (usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`);
const KIND = { answer: 'answer', research: 'research', web: 'web', code: 'code', computer: 'computer', other: 'other' };
const KIND_HINT = {
research: 'a short report with sources (runs now)', web: 'a current answer from the web (runs now)', answer: 'an answer (runs now)',
@@@ -37,7 +76,8 @@ function createWorkspacePages({ router, page, loadRoom, needUser, sameSite, form
if ((t.status === 'queued' || t.status === 'running') && (mine || can.moderate)) actions.push(`<form method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/cancel"><button type="submit" class="oc-link">Cancel</button></form>`);
if (t.status === 'queued' && !t.quick && can.post) actions.push(`<form method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/claim"><button type="submit" class="oc-link">I'll take it</button></form>`);
if (t.status === 'queued' && mine && (t.kind === 'code' || t.kind === 'computer')) actions.push(`<form method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/run" title="OpenVibe.Actor does it on your own computer (OpenVibe.Codes for code, through OpenVibe.Run)"><button type="submit" class="oc-link">Run on my computer</button></form>`);
-- const result = t.status === 'succeeded' && t.result ? `<p class="oc-task-result">${linkify(t.result.length > 280 ? `${t.result.slice(0, 280).replace(/\s+\S*$/, '')}…` : t.result)}</p>` : '';
++ const flat = t.status === 'succeeded' && t.result ? plain(t.result) : '';
++ const result = flat ? `<p class="oc-task-result">${esc(flat.length > 220 ? `${flat.slice(0, 220).replace(/\s+\S*$/, '')}…` : flat)}</p>` : '';
const err = t.status === 'failed' && t.error ? `<p class="oc-task-error">${esc(t.error)}</p>` : '';
return `<li class="oc-task oc-task-${esc(t.status)}" data-task="${t.id}">
<a class="oc-task-title" href="${esc(t.url)}"><span class="oc-task-state">${STATE[t.status] || esc(t.status)}</span> ${esc(t.title)}</a>
@@@ -102,36 +142,69 @@ ${tasks.length ? `<ol class="oc-tasks">${tasks.map((t) => taskItem(t, room, can,
router.get('/r/:slug/tasks/:id', async (req, res) => {
const x = await loadRoom(req, res); if (!x) return;
const { actor, me, room, can } = x;
++ // A Vibe chat's tasks lead back to the chat in the Vibe app, where the person asked; a room's to the room.
++ const vibeChat = Number(room.assistant) === 1;
++ const back = vibeChat ? { href: `/vibe/${esc(room.slug)}`, label: 'Back to the chat' } : { href: `/r/${esc(room.slug)}`, label: esc(room.name) };
let t;
try { t = await workspace.get(room, me, req.params.id); } catch (err) {
-- return page(req, res, 404, { actor, title: 'No such task', path: req.path, robots: 'noindex', body: `<h1>No such task</h1><p><a href="/r/${esc(room.slug)}">Back to ${esc(room.name)}</a></p>` });
++ return page(req, res, 404, { actor, title: 'No such task', path: req.path, robots: 'noindex', footer: 'compact', body: `<div class="oc-tp"><a class="oc-tp-back" href="${back.href}">${ICONS.back}<span>${back.label}</span></a><h1>No such task</h1><p class="oc-muted">It may have been removed with its chat.</p></div>` });
}
const mine = me && (t.runner === `user:${me.id}` || String(t.runner || '').endsWith(`(for ${me.username || me.id})`));
-- const reportForm = t.status === 'running' && !t.quick && (mine || can.moderate) ? `<form class="oc-form" id="oc-report" method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/report">
--<h2>Report</h2>
--<label>What you did <textarea name="result" rows="6" maxlength="20000" placeholder="What changed, what to check"></textarea></label>
++ const asker = me && t.requested_by.id === Number(me.id);
++ const reportForm = t.status === 'running' && !t.quick && (mine || can.moderate) ? `<section class="oc-tp-card" id="oc-report" aria-labelledby="oc-report-h">
++<h2 id="oc-report-h">Report back</h2>
++<form class="oc-form oc-tp-form" method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/report">
++<label>What you did <textarea name="result" rows="6" maxlength="20000" placeholder="What changed, what to check. Markdown works: **bold**, lists, links."></textarea></label>
<label>Where it landed (https, optional) <input name="result_url" type="url" placeholder="https://github.com/OpenVibers/…/pull/…"></label>
<fieldset class="oc-fieldset"><legend>How it went</legend><label class="oc-check"><input type="radio" name="status" value="succeeded" checked> Done</label><label class="oc-check"><input type="radio" name="status" value="failed"> Could not finish</label></fieldset>
<label>If it could not be finished, why <input name="error" maxlength="500"></label>
--<p><button type="submit">Send the report</button></p></form>` : '';
++<p><button type="submit">Send the report</button></p></form></section>` : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
++ // What ran it and how (the same facts as "How Vibe did this" under the answer in the chat).
++ const info = how.of({ ...t, how: t.how, sources: JSON.stringify(t.sources) }, await how.names().catch(() => new Map()));
++ const runBy = t.runner === 'actor' ? 'OpenVibe.Actor' : t.runner ? (t.runner.startsWith('user:') ? 'a member of the room' : t.runner) : null;
++ const facts = [
++ ['Asked by', `${esc(t.requested_by.name)}${t.via === 'vibe' ? ' <span class="oc-muted">through Vibe</span>' : ''}`],
++ runBy ? [t.runner === 'actor' ? 'Done by' : 'Taken by', esc(runBy)] : null,
++ ['Asked', when(t.created_at)],
++ t.finished_at ? ['Took', esc(took(t) || '—')] : null,
++ t.cost_usd != null ? ['Cost', `${money(t.cost_usd)}${t.runner === 'actor' ? ' <span class="oc-muted">of your daily allowance</span>' : ''}`] : null,
++ ].filter(Boolean);
++ const [kindIcon, kindLabel] = KIND_CHIP[t.kind] || KIND_CHIP.other;
++ // The details are shown when they say more than the title.
++ const details = t.details && t.details.trim().toLowerCase() !== String(t.title).trim().toLowerCase() ? t.details : '';
++ const actions = [
++ `<a class="oc-pill" href="${back.href}">${ICONS.chat}<span>${vibeChat ? 'Chat' : 'Room'}</span></a>`,
++ t.result_url ? `<a class="oc-pill oc-pill-strong" href="${esc(t.result_url)}" rel="noopener nofollow">${ICONS.open}<span>Open where it landed</span></a>` : '',
++ info && info.actor_url ? `<a class="oc-pill" href="${esc(info.actor_url)}" title="Every step the agent took, on OpenVibe.Actor">${ICONS.steps}<span>Every step</span></a>` : '',
++ t.kind === 'computer' || t.kind === 'code' ? `<a class="oc-pill" href="https://openvibe.bot/machines" title="Your computers: pair one, see its screen, set what agents may do on it">${ICONS.computer}<span>Your computers</span></a>` : '',
++ ].filter(Boolean).join('');
++ const checked = info ? info.lines.find((l) => l.startsWith('Checked by')) : null;
++ const sources = t.sources.length ? `<section class="oc-tp-card" aria-labelledby="oc-src-h"><h2 id="oc-src-h">Sources <span class="oc-muted">${t.sources.length}</span></h2>
++<ol class="oc-tp-sources">${t.sources.map((u) => `<li><a href="${esc(u)}" rel="noopener nofollow ugc" target="_blank"><strong>${esc(hostOf(u))}</strong><span>${esc(u.replace(/^https?:\/\/(www\.)?[^/]+/, '') || '/')}</span></a></li>`).join('')}</ol></section>` : '';
await page(req, res, 200, {
-- actor, title: t.title, path: req.path, active: 'rooms', robots: 'noindex, nofollow',
-- body: `<p><a href="/r/${esc(room.slug)}">← ${esc(room.name)}</a></p>
--<h1>${esc(t.title)}</h1>${error}
--<p class="oc-muted"><span class="oc-badge oc-task-${esc(t.status)}">${STATE[t.status] || esc(t.status)}</span> ${KIND[t.kind] || 'task'} · asked by ${esc(t.requested_by.name)}${t.via === 'vibe' ? ' through Vibe' : ''} · ${when(t.created_at)}${t.runner ? ` · ${t.runner === 'actor' ? 'run by OpenVibe.Actor' : `taken by ${esc(t.runner.startsWith('user:') ? 'a member' : t.runner)}`}` : ''}${t.finished_at ? ` · finished ${when(t.finished_at)}` : ''}${t.cost_usd != null ? ` · cost $${t.cost_usd.toFixed(4)}` : ''}</p>
--${t.details ? `<h2>Details</h2><p class="oc-task-details">${linkify(t.details)}</p>` : ''}
--${t.result ? `<h2>Result</h2><div class="oc-task-full">${t.result.split(/\n{2,}/).map((p) => `<p>${linkify(p)}</p>`).join('')}</div>` : ''}
--${t.result_url ? `<p><a class="oc-button" href="${esc(t.result_url)}" rel="noopener nofollow">Open where it landed</a></p>` : ''}
--${t.sources.length ? `<h2>Sources</h2><ol class="oc-task-sources">${t.sources.map((u) => `<li>${linkify(u)}</li>`).join('')}</ol>` : ''}
--${t.error ? notice('error', t.error) : ''}
++ actor, title: t.title, path: req.path, active: vibeChat ? 'vibe' : 'rooms', robots: 'noindex, nofollow', footer: 'compact', script: true, page: { view: 'task' },
++ body: `<article class="oc-tp">
++<a class="oc-tp-back" href="${back.href}">${ICONS.back}<span>${back.label}</span></a>
++<header class="oc-tp-head">
++<p class="oc-tp-chips"><span class="oc-tp-state oc-tp-${esc(t.status)}">${STATE_ICON[t.status] || ''}<span>${STATE[t.status] || esc(t.status)}</span></span><span class="oc-tp-kind">${kindIcon}<span>${kindLabel}</span></span></p>
++<h1>${esc(t.title)}</h1>
++${STATE_SAYS[t.status] ? `<p class="oc-muted oc-tp-says">${STATE_SAYS[t.status]}</p>` : ''}
++<dl class="oc-tp-facts">${facts.map(([k, v]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).join('')}</dl>
++</header>
++${error}${t.error ? notice('error', t.error) : ''}
++${details ? `<section class="oc-tp-card" aria-labelledby="oc-ask-h"><h2 id="oc-ask-h">What was asked</h2><div class="vb-md">${md.render(details)}</div></section>` : ''}
++${t.result ? `<section class="oc-tp-card oc-tp-result" aria-labelledby="oc-res-h"><div class="oc-tp-card-head"><h2 id="oc-res-h">Result</h2><button type="button" class="oc-icon-btn" data-oc-copy="#oc-res" aria-label="Copy the result" title="Copy" hidden>${ICONS.copy}</button></div><div class="vb-md" id="oc-res">${md.render(t.result)}</div>${checked ? `<p class="oc-tp-check">${ICONS.check}<span>${esc(checked)}</span></p>` : ''}</section>` : ''}
++${sources}
++${actions ? `<nav class="oc-tp-actions" aria-label="Next">${actions}</nav>` : ''}
${reportForm}
--${(t.status === 'queued' || t.status === 'running') && me && (t.requested_by.id === Number(me.id) || can.moderate) ? `<form method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/cancel"><button type="submit" class="oc-link">Cancel this task</button></form>` : ''}
--${t.status === 'queued' && !t.quick && can.post ? `<form method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/claim"><button type="submit">I'll take it</button></form>` : ''}`,
++${(t.status === 'queued' || t.status === 'running') && me && (asker || can.moderate) ? `<form method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/cancel" class="oc-tp-cancel"><button type="submit" class="oc-pill oc-pill-danger">${ICONS.stop}<span>Cancel this task</span></button></form>` : ''}
++${t.status === 'queued' && !t.quick && can.post ? `<form method="post" action="/r/${esc(room.slug)}/tasks/${t.id}/claim"><button type="submit">I'll take it</button></form>` : ''}
++</article>`,
});
});
return { queuePanel };
}
--module.exports = { createWorkspacePages, STATE };
++module.exports = { createWorkspacePages, STATE, plain, took };
ddiff --git a/test/task-page.test.js b/test/task-page.test.js
nnew file mode 100644
iindex 0000000..97f01de
---- /dev/null
++++ b/test/task-page.test.js
@@@ -0,0 +1,37 @@
++'use strict';
++/**
++ * A task's page and the queue's preview (server/web/workspace-pages.js; the owner, 2026-10-28: "there's no formatting of
++ * the text"): an agent writes Markdown, so the queue's one-line preview drops the marks and keeps the words, and the
++ * page's facts say how long it took in plain words. The page's icons (server/web/icons.js) are decorative: the control
++ * around each one carries its name. The live room client (public/web/chat.js) renders an AI member's Markdown with the
++ * same escaping renderer as the server and makes its delete control an icon.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { plain, took } = require('../server/web/workspace-pages');
++const { ICONS } = require('../server/web/icons');
++
++assert.strictEqual(
++ plain("I couldn't do it: **no computer is open to me**.\n\n**What I did**\n1. Asked for `list_my_computers`\n- see [the machines page](https://openvibe.bot/machines)"),
++ "I couldn't do it: no computer is open to me. What I did Asked for list_my_computers see the machines page");
++assert.strictEqual(plain('```\ncode\n```\nafter'), 'after');
++assert.strictEqual(plain('snake_case and 2*3*4 stay'), 'snake_case and 2*3*4 stay');
++
++assert.strictEqual(took({ started_at: '2026-10-28 10:00:00', finished_at: '2026-10-28 10:00:02' }), 'a few seconds');
++assert.strictEqual(took({ started_at: '2026-10-28 10:00:00', finished_at: '2026-10-28 10:00:40' }), '40 seconds');
++assert.strictEqual(took({ created_at: '2026-10-28 10:00:00', finished_at: '2026-10-28 10:07:10' }), '7 min');
++assert.strictEqual(took({ started_at: '2026-10-28 10:00:00', finished_at: '2026-10-28 11:30:00' }), '1 h 30 min');
++assert.strictEqual(took({ started_at: '2026-10-28 10:00:00', finished_at: null }), null);
++
++for (const [name, svg] of Object.entries(ICONS)) {
++ assert.match(svg, /^<svg class="oc-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false">/, `${name} is decorative`);
++ assert.doesNotMatch(svg, /<script|on\w+=/i, `${name} is markup only`);
++}
++
++const client = fs.readFileSync(path.join(__dirname, '..', 'public', 'web', 'chat.js'), 'utf8');
++assert.match(client, /if \(ai && window\.VibeMarkdown\) \{ t = el\('div', 'oc-text vb-md'\); t\.innerHTML = window\.VibeMarkdown\.render\(m\.message\); \}/);
++assert.match(client, /x\.innerHTML = TRASH;/);
++assert.ok(!/el\('button', null, '×'\)/.test(client), 'no bare × for delete');
++
++console.log('task page: preview, durations, icons and the room client checked');
ddiff --git a/test/vibe.test.js b/test/vibe.test.js
iindex 3a3bd97..304a0f3 100644
---- a/test/vibe.test.js
++++ b/test/vibe.test.js
@@@ -91,6 +91,11 @@ t('the chat is theirs alone and stays out of their rooms', async () => {
assert.ok(!mine.mine.some((r) => r.slug === slug) && !mine.public.some((r) => r.slug === slug), 'not listed with rooms');
for (let i = 0; i < 3; i++) assert.strictEqual((await vibeApi('POST', '', ann, { message: `question ${i}` })).status, 201);
assert.strictEqual((await rooms('POST', '/', ann, { name: 'Ann Room' })).status, 201, 'Vibe chats do not use up the rooms a person may own');
++ // Its room page (links in tasks and messages name /r/<slug>) opens the chat in the Vibe app, for its owner only.
++ const own = await web('GET', `/r/${slug}`, ann);
++ assert.strictEqual(own.status, 302);
++ assert.strictEqual(own.location, `/vibe/${slug}`);
++ assert.strictEqual((await web('GET', `/r/${slug}`, bob)).status, 404, 'nobody else is sent anywhere');
});
t('the daily allowance is the person\'s across all their chats', async () => {
@@@ -179,6 +184,9 @@ t('answers render as escaped Markdown with http(s) links only', () => {
assert.ok(md.render('see https://openvibe.live/docs.').includes('<a href="https://openvibe.live/docs" rel="nofollow ugc noopener" target="_blank">https://openvibe.live/docs</a>.'));
assert.ok(!md.render('[x](https://a.com" onmouseover="alert(1))').includes('onmouseover="'));
assert.strictEqual(md.render('```\n<i>\n```'), '<pre><code>&lt;i&gt;</code></pre>');
++ // <https://…> (Markdown's autolink, which agents write): the address is the link; anything else in brackets stays text.
++ assert.strictEqual(md.render('Go to <https://openvibe.bot/machines>.'), '<p>Go to <a href="https://openvibe.bot/machines" rel="nofollow ugc noopener" target="_blank">https://openvibe.bot/machines</a>.</p>');
++ assert.ok(!md.render('<javascript:alert(1)>').includes('href'));
// Tables and nested lists (answers are model-written Markdown, which uses both): escaped like everything else.
assert.strictEqual(md.render('| Plan | Cost |\n|---|--:|\n| Rice | $2 |\n| <b>x</b> | 1 \\| 2 |'),
'<div class="md-table" tabindex="0" role="region" aria-label="Table"><table><thead><tr><th>Plan</th><th class="al-r">Cost</th></tr></thead>'
ddiff --git a/test/workspace.test.js b/test/workspace.test.js
iindex a50e988..18f9b4a 100644
---- a/test/workspace.test.js
++++ b/test/workspace.test.js
@@@ -169,7 +169,11 @@ t('the room page shows the queue beside the conversation; the task page has the
const cookie = `ov_token=${staff.token}`;
let r = await h.http('GET', '/r/staff', { headers: { cookie } });
assert.strictEqual(r.status, 200);
-- assert.match(r.text, /<div class="oc-ws"><div class="oc-ws-main"><ol class="oc-feed"/);
++ assert.match(r.text, /<div class="oc-ws"><div class="oc-ws-main"><ol class="oc-feed oc-room-feed"/);
++ // The room's header: its controls are icons with names; an AI member's Markdown is rendered, escaped first.
++ assert.match(r.text, /<a class="oc-icon-btn" href="\/r\/staff\/settings" aria-label="Room settings"/);
++ assert.match(r.text, /<div class="oc-text vb-md"><p>✅ Done: &quot;/);
++ assert.match(r.text, /data-variant="compact"/, 'a room is a page people work in: the one-line footer');
assert.match(r.text, /<aside class="oc-queue" id="oc-queue"[^>]*data-queue="\/r\/staff\/queue"/);
assert.match(r.text, /Add a dark favicon to Tools/);
r = await h.http('GET', '/r/staff/queue', { headers: { cookie } });
@@@ -178,7 +182,13 @@ t('the room page shows the queue beside the conversation; the task page has the
r = await h.http('GET', `/r/staff/tasks/${id}`, { headers: { cookie } });
assert.strictEqual(r.status, 200);
assert.match(r.text, /Three large images block the first paint\./);
-- assert.match(r.text, /run by OpenVibe\.Actor/);
++ assert.match(r.text, /<dt>Done by<\/dt><dd>OpenVibe\.Actor<\/dd>/);
++ assert.match(r.text, /<span class="oc-tp-state oc-tp-succeeded">.*<span>Done<\/span><\/span><span class="oc-tp-kind">.*<span>Research<\/span>/);
++ assert.match(r.text, /<a class="oc-tp-back" href="\/r\/staff">/);
++ assert.match(r.text, /data-variant="compact"/);
++ assert.match(r.text, /<h2 id="oc-ask-h">What was asked<\/h2><div class="vb-md"><p>Look at the last week\.<\/p><\/div>/);
++ assert.match(r.text, /<ol class="oc-tp-sources"><li><a href="https:\/\/example\.com\/a"[^>]*><strong>example\.com<\/strong><span>\/a<\/span>/);
++ assert.doesNotMatch(r.text, /javascript:/, 'only http(s) sources');
assert.strictEqual((await h.http('GET', `/r/staff/tasks/${id}`, { headers: { cookie: `ov_token=${user.token}` } })).status, 404);
});