openvibers/openvibe.chat Commits
Task pages and rooms reworked: Vibe chats open in the app, rendered results, icon controls, compact footer (#221)
Changes
@@@ -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)); }++}@@@ -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();@@@ -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 <…> above, the brackets go and the address is the link.++ s = s.replace(/<(https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s<])+)>/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;@@@ -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; }@@@ -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)}`,};nnew file mode 100644@@@ -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 };@@@ -205,7 +205,7 @@ ${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><inputconst 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)}">← ${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.shoconst 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">← Messages</a></p>@@@ -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) { /* */ }@@@ -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="postconst 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>@@@ -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: {@@@ -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, formif ((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 };nnew file mode 100644@@@ -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');@@@ -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><i></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>'@@@ -169,7 +169,11 @@ t('the room page shows the queue beside the conversation; the task page has theconst 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: "/);++ 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 ther = 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);});
@@ -369,8 +369,128 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -369,8 +369,128 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-task-full p, .oc-task-details { overflow-wrap: anywhere; }
.oc-task-full p, .oc-task-details { overflow-wrap: anywhere; }
@keyframes oc-work { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
@keyframes oc-work { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
@media (prefers-reduced-motion: reduce) { .oc-task-running .oc-task-state { animation: none; } }
@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. */
/* 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 { 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 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; }
.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)); }
+}
@@ -5,6 +5,10 @@
@@ -5,6 +5,10 @@
'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) return;
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 feed = document.getElementById('oc-feed');
var form = document.getElementById('oc-compose');
var form = document.getElementById('oc-compose');
var input = document.getElementById('oc-input');
var input = document.getElementById('oc-input');
@@ -246,11 +250,15 @@
@@ -246,11 +250,15 @@
var b = badge(m.user_role); if (b) li.appendChild(b);
var b = badge(m.user_role); if (b) li.appendChild(b);
}
}
li.appendChild(document.createTextNode(' '));
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 (!ai && cfg.pics) { var pw = pictureThumbs(m.message); if (pw) li.appendChild(pw); }
if (cfg.moderate || (cfg.me && !ai && m.user_id === cfg.me)) {
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 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);
f.appendChild(x); li.appendChild(f);
}
}
return li;
return li;
@@ -403,6 +411,29 @@
@@ -403,6 +411,29 @@
// A server's channel list folds away on a phone: the channel's own header says where you are.
// 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;
var chans = document.querySelector ? document.querySelector('.oc-srv-channels') : null;
if (chans && window.matchMedia && window.matchMedia('(max-width: 760px)').matches) chans.open = false;
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();
toBottom();
if (cfg.view === 'global') startGlobal();
if (cfg.view === 'global') startGlobal();
else if (cfg.view === 'dm') startDm();
else if (cfg.view === 'dm') startDm();
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
* subset, every character of the source escaped first, so the only markup in the output is the markup made here.
* 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,
* 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
* 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. */
* Links are http(s) only. One file for both sides: CommonJS on the server, window.VibeMarkdown in the browser. */
(function (root) {
(function (root) {
'use strict';
'use strict';
@@ -19,6 +19,8 @@
@@ -19,6 +19,8 @@
var links = [];
var links = [];
// An address ends at white space or at an escaped quote or angle bracket; trailing punctuation stays outside it.
// 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'; });
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 <…> above, the brackets go and the address is the link.
+ s = s.replace(/<(https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s<])+)>/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) {
s = s.replace(/(^|[\s(])(https?:\/\/(?:(?!&(?:quot|#39|lt|gt);)[^\s<])+)/g, function (_, pre, u) {
var tail = /[.,;:!?)]+$/.exec(u);
var tail = /[.,;:!?)]+$/.exec(u);
var href = tail ? u.slice(0, -tail[0].length) : u;
var href = tail ? u.slice(0, -tail[0].length) : u;
@@ -118,32 +118,7 @@ body.oc-app-body { overflow: hidden; }
@@ -118,32 +118,7 @@ body.oc-app-body { overflow: hidden; }
.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; }
.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 { 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 { width: 7px; height: 7px; border-radius: 50%; background: var(--oc-muted); opacity: .35; animation: vbDot 1.2s infinite ease-in-out; }
@@ -47,7 +47,7 @@ function shape(t, room) {
@@ -47,7 +47,7 @@ function shape(t, room) {
quick: QUICK.has(t.kind),
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' },
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 : [],
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,
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)}`,
url: `/r/${room.slug}/tasks/${Number(t.id)}`,
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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 };
@@ -205,7 +205,7 @@ ${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input
@@ -205,7 +205,7 @@ ${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input
const hooks = owner ? await webhooks.list(inbox) : [];
const hooks = owner ? await webhooks.list(inbox) : [];
const acts = owner ? await actions.list(inbox) : [];
const acts = owner ? await actions.list(inbox) : [];
await page(req, res, 200, {
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)}">← ${esc(inbox.name)}</a></p><h1>${esc(inbox.name)}</h1>
body: `<p><a href="/inbox/${esc(inbox.id)}">← ${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)) : ''}
${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>
<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
@@ -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>`
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>`);
: `<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, {
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 },
page: { view: 'support', inbox: inbox.id, conversation: conv.id, latest: msgs.length ? msgs[msgs.length - 1].id : 0 },
scripts: ['vibe-md.js', 'support.js'],
scripts: ['vibe-md.js', 'support.js'],
body: `<p><a href="/messages">← Messages</a></p>
body: `<p><a href="/messages">← Messages</a></p>
@@ -101,7 +101,8 @@ function contentSecurityPolicy(nonce) {
@@ -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:
* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:
* vibe.css + vibe-md.js + vibe.js, and no footer), appScript (the app's own script instead of vibe.js: inbox.js), feedView
* vibe.css + vibe-md.js + vibe.js, and no footer), 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
* (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) {
function renderPage(o) {
const { config } = o;
const { config } = o;
@@ -121,6 +122,7 @@ function renderPage(o) {
@@ -121,6 +122,7 @@ function renderPage(o) {
const p = o.prefs || {};
const p = o.prefs || {};
const cls = [p.compact ? 'oc-compact' : '', p.timestamps ? 'oc-times' : '', p.show_badges === false ? 'oc-nobadges' : ''].filter(Boolean).join(' ');
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 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>
const html = `<!DOCTYPE html>
<html lang="en">
<html lang="en">
<head>
<head>
@@ -151,9 +153,9 @@ ${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label,
@@ -151,9 +153,9 @@ ${frame.noscriptNav({ name: SITE_NAME, home: '/', links: links(o).map(({ label,
<noscript><p class="oc-account">${who}</p></noscript>
<noscript><p class="oc-account">${who}</p></noscript>
${o.body || ''}
${o.body || ''}
</main>
</main>
-${o.app ? '' : frame.footer({ service: 'chat', variant: 'full', updates: '/updates' })}
+${o.app ? '' : frame.footer({ service: 'chat', variant: footerVariant, updates: '/updates' })}
<script>
<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 () {
document.addEventListener('DOMContentLoaded', function () {
try { if (window.OpenVibeNavbar) OpenVibeNavbar.init(window.__OV_PAGE.navbar); } catch (e) { /* the Frame is optional */ }
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) { /* */ }
try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* */ }
@@ -306,7 +306,7 @@ ${parts.more}`,
@@ -306,7 +306,7 @@ ${parts.more}`,
const support = await require('../inbox/inbox').mySupport(me, { limit: 20 }).catch(() => []);
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>` : '';
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, {
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 },
page: { view: 'inbox', unread, me: me.id },
body: `<h1>Messages</h1>${error}${done}
body: `<h1>Messages</h1>${error}${done}
<p class="oc-muted" id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</p>
<p class="oc-muted" id="oc-inbox-status" aria-live="polite">${unread ? `${unread} unread` : 'All caught up'}</p>
@@ -363,7 +363,7 @@ ${blockedList}`,
@@ -363,7 +363,7 @@ ${blockedList}`,
const callForm = other && !closed && require('../config').calls.enabled
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>` : '';
? `<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, {
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 },
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}
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>
<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}`,
@@ -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 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)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
await page(req, res, 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}
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>
<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">
<form class="oc-form" method="post" action="/settings">
@@ -577,10 +577,12 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
@@ -577,10 +577,12 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
const roomHooks = require('../rooms/hooks');
const roomHooks = require('../rooms/hooks');
const pictures = require('../rooms/pictures');
const pictures = require('../rooms/pictures');
const aiMembers = require('../rooms/ai-members');
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.
// 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');
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). */
/** 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)}">
<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)}">
<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>
<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">
@@ -601,11 +603,13 @@ ${key ? `<form class="oc-inline" method="post" action="/settings/ai-key/delete">
const roomMessage = (m, can, slug, me) => {
const roomMessage = (m, can, slug, me) => {
const t = hhmm(m.created_at);
const t = hhmm(m.created_at);
const ai = m.ai_member_id != null;
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
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>`
? `<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)}`;
: `<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);
const applyRoomChange = async (room) => await roomRoutes.applyRoomChange(room);
@@ -706,19 +710,21 @@ ${people}
@@ -706,19 +710,21 @@ ${people}
const x = await loadRoom(req, res); if (!x) return;
const x = await loadRoom(req, res); if (!x) return;
const { actor, me, room, can } = x;
const { actor, me, room, can } = x;
if (room.server_id) return res.redirect(302, await channelUrl(room));
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 });
const msgs = await rooms.history(room, { limit: 60 });
if (me && can.role) await rooms.markRead(room, me);
if (me && can.role) await rooms.markRead(room, me);
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
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';
const joinLabel = room.kind === 'system' ? 'Follow these announcements' : 'Join this room';
let composer;
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>`;
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 (!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 (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 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.');
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 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 yourRole = me && YOU_ARE[can.role] ? ` · you are ${YOU_ARE[can.role]}` : '';
const isCall = room.kind === 'call';
const isCall = room.kind === 'call';
@@ -727,21 +733,26 @@ ${people}
@@ -727,21 +733,26 @@ ${people}
// A feed the owner shows here (server/feeds/): its card above the messages.
// A feed the owner shows here (server/feeds/): its card above the messages.
const feed = await require('../feeds/feeds').forRoom(room.id);
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 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, {
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,
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.`,
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,
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 } } : {}) },
...(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}
${aiHere}${error}
${isCall ? callPanel(room, can, me) : ''}
${isCall ? callPanel(room, can, me) : ''}
${feed ? `<div class="fv oc-room-fv" data-feed="${esc(feed.id)}"></div>` : ''}
${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 && 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') ?
@@ -844,7 +855,7 @@ ${ais.length < aiMembers.MAX_PER_ROOM && !ais.some((a) => a.handle === 'vibe') ?
${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}` : '';
${ais.length < aiMembers.MAX_PER_ROOM ? aiForm(null) : ''}` : '';
if (room.server_id) {
if (room.server_id) {
return await page(req, res, 200, {
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}
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}`,
${aiSection}`,
});
});
@@ -860,7 +871,7 @@ ${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="post
@@ -860,7 +871,7 @@ ${hookList.length < roomHooks.MAX_PER_ROOM ? `<form class="oc-form" method="post
const communityUrl = config.web.communityUrl;
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>'}` : '';
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, {
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}
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>
${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>
<p>Anyone can read it there; people sign in with OpenVibe to write, and the room's moderation applies. A chat bubble:</p>
@@ -130,7 +130,7 @@ ${create}`,
@@ -130,7 +130,7 @@ ${create}`,
].filter(Boolean).join(' · ');
].filter(Boolean).join(' · ');
const canInvite = can.manage || (can.moderate && server.visibility === 'public');
const canInvite = can.manage || (can.moderate && server.visibility === 'public');
await page(req, res, 200, {
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,
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'}.`,
description: server.description || `${server.name}, a server on OpenVibe.Chat with ${server.member_count} member${Number(server.member_count) === 1 ? '' : 's'}.`,
page: {
page: {
@@ -12,10 +12,49 @@
@@ -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/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)
* 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 rooms = require('../rooms/rooms');
const workspace = require('../rooms/workspace');
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' };
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 = { answer: 'answer', research: 'research', web: 'web', code: 'code', computer: 'computer', other: 'other' };
const KIND_HINT = {
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)',
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
@@ -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.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' && !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>`);
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>` : '';
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}">
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>
<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,
@@ -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) => {
router.get('/r/:slug/tasks/:id', async (req, res) => {
const x = await loadRoom(req, res); if (!x) return;
const x = await loadRoom(req, res); if (!x) return;
const { actor, me, room, can } = x;
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;
let t;
try { t = await workspace.get(room, me, req.params.id); } catch (err) {
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 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>
<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>
<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>
<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)) : '';
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, {
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}
${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 };
return { queuePanel };
}
}
-module.exports = { createWorkspacePages, STATE };
+module.exports = { createWorkspacePages, STATE, plain, took };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -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');
@@ -91,6 +91,11 @@ t('the chat is theirs alone and stays out of their rooms', async () => {
@@ -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');
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);
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');
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 () => {
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', () => {
@@ -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('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.ok(!md.render('[x](https://a.com" onmouseover="alert(1))').includes('onmouseover="'));
assert.strictEqual(md.render('```\n<i>\n```'), '<pre><code><i></code></pre>');
assert.strictEqual(md.render('```\n<i>\n```'), '<pre><code><i></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.
// 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 |'),
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>'
'<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>'
@@ -169,7 +169,11 @@ t('the room page shows the queue beside the conversation; the task page has the
@@ -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}`;
const cookie = `ov_token=${staff.token}`;
let r = await h.http('GET', '/r/staff', { headers: { cookie } });
let r = await h.http('GET', '/r/staff', { headers: { cookie } });
assert.strictEqual(r.status, 200);
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: "/);
+ 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, /<aside class="oc-queue" id="oc-queue"[^>]*data-queue="\/r\/staff\/queue"/);
assert.match(r.text, /Add a dark favicon to Tools/);
assert.match(r.text, /Add a dark favicon to Tools/);
r = await h.http('GET', '/r/staff/queue', { headers: { cookie } });
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
@@ -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 } });
r = await h.http('GET', `/r/staff/tasks/${id}`, { headers: { cookie } });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.text, /Three large images block the first paint\./);
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);
assert.strictEqual((await h.http('GET', `/r/staff/tasks/${id}`, { headers: { cookie: `ov_token=${user.token}` } })).status, 404);
});
});