openvibers/openvibe.codes Commits
The rail is the network's sidebar: Codes' places as vertical rows (L35) (#210)
Changes
@@@ -94,7 +94,7 @@ More: [docs/cli.md](docs/cli.md).- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).--- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.59.0** (pinned tag tarballs).++- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.60.0** (pinned tag tarballs).No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).@@@ -9,11 +9,11 @@"domain": "openvibe.codes","contracts": "openvibe-contracts v0.247.0","sdk": "openvibe-sdk v0.76.0",-- "shared": "openvibe-shared v4.59.0",++ "shared": "openvibe-shared v4.60.0","packages": ["openvibe-contracts v0.247.0","openvibe-sdk v0.76.0",-- "openvibe-shared v4.59.0"++ "openvibe-shared v4.60.0"],"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.2) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.","works": [@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.60.0","pg": "^8.23.0"},"bin": {@@@ -1193,9 +1193,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.59.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",-- "integrity": "sha512-kaJ6Tcz7WCewK/kCx4NzNWRNI4U1Z1lpwCQHWD115YtcaJps8Ulqpu1y2ArLpywARHtYf3ddPH/hL9wkb6MJlw==",++ "version": "4.60.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.60.0",++ "integrity": "sha512-FFdY/eF7qfghQ1vKbwV3j5fzRgSwoc+T7/JZrYeIncjEFlHH/nAVHIFMpYwhEJR9KulmC5xWQUj/MKWRu1hLQQ==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -32,7 +32,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.60.0","pg": "^8.23.0"},"engines": {@@@ -13,27 +13,7 @@ body.ov-workspace:has(.ch){padding-bottom:0}main.page:has(> .ch){max-width:none;margin:0;padding:0}/* ── The rail ── */--.ch-rail{display:flex;flex-direction:column;min-height:0;background:var(--ch-rail);border-right:1px solid var(--ch-line)}--.ch-rail-top{display:flex;gap:6px;padding:12px 10px 6px}--.ch-new{flex:1;display:flex;align-items:center;gap:9px;padding:9px 12px;border-radius:12px;color:var(--ch-ink);text-decoration:none;font-weight:600;font-size:14px;border:1px solid var(--ch-line);background:var(--ch-card)}--.ch-new:hover{border-color:color-mix(in srgb,var(--ch-accent) 45%,var(--ch-line))}--.ch-find{position:relative;margin:4px 10px 6px}--.ch-find .ch-i{position:absolute;left:10px;top:50%;translate:0 -50%;width:15px;height:15px;color:var(--ch-muted)}--.ch-find input{width:100%;box-sizing:border-box;padding:8px 10px 8px 32px;border-radius:10px;border:1px solid transparent;background:var(--ch-hover);color:var(--ch-ink);font:inherit;font-size:13.5px}--.ch-find input:focus{outline:none;border-color:color-mix(in srgb,var(--ch-accent) 55%,transparent)}--.ch-list{flex:1;min-height:0;overflow-y:auto;padding:2px 8px 10px;scrollbar-width:thin}.ch-group{margin:14px 8px 4px;font-size:11.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--ch-muted)}--.ch-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:10px;color:var(--ch-soft);text-decoration:none;font-size:14px;line-height:1.3;min-height:36px}--.ch-item:hover{background:var(--ch-hover);color:var(--ch-ink)}--.ch-item.is-here{background:var(--ch-hover);color:var(--ch-ink);font-weight:600}--.ch-item-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}--.ch-item-m{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ch-muted);font-weight:500}--.ch-item-cta{color:var(--ch-accent);font-weight:600}--.ch-empty-rail{margin:16px 8px;color:var(--ch-muted);font-size:13px}--.ch-rail-foot{display:grid;grid-template-columns:repeat(5,1fr);gap:2px;padding:8px;border-top:1px solid var(--ch-line)}--.ch-rail-foot a{display:grid;justify-items:center;gap:3px;padding:7px 2px;border-radius:10px;color:var(--ch-muted);text-decoration:none;font-size:10.5px;font-weight:600}--.ch-rail-foot a:hover{background:var(--ch-hover);color:var(--ch-ink)}--.ch-rail-foot a.is-here{background:var(--ch-hover);color:var(--ch-accent)}/* Faces: an agent's initials in its own colour; the conductor's is its sign. */.ch-av{display:inline-grid;place-items:center;width:24px;height:24px;flex:none;border-radius:50%;font-size:10.5px;font-weight:800;letter-spacing:-.02em;color:var(--av,#64748b);background:color-mix(in srgb,var(--av,#64748b) 18%,transparent)}@@@ -56,7 +36,12 @@ main.page:has(> .ch){max-width:none;margin:0;padding:0}.ch-state.is-busy{color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 12%,transparent)}.ch-icon-btn{display:inline-grid;place-items:center;width:36px;height:36px;flex:none;border:0;border-radius:10px;background:transparent;color:var(--ch-soft);cursor:pointer}.ch-icon-btn:hover{background:var(--ch-hover);color:var(--ch-ink)}--.ch-rail-open,.ch-side-open,.ch-icon-btn.ch-rail-close{display:none}++.ch-side-open{display:none}++/* The rail is the network's sidebar (openvibe-shared kit.sidebar): the grid's first column on a computer, a drawer on++ a phone (kit.css). Its avatars are Codes' own. */++@media (min-width:761px){.ch > .ovk-sidebar{position:relative;top:auto;height:auto;min-height:0}}++.ch .ovk-sidebar-item .ch-av{width:24px;height:24px;font-size:11px}++.ch-head .ovk-sidebar-open{color:var(--ch-ink)}.ch-thread{flex:1;min-height:0;overflow-y:auto;padding:28px max(20px,calc((100% - var(--ch-col)) / 2)) 24px;display:flex;flex-direction:column;gap:22px;scroll-behavior:smooth}.ch-turn{display:flex;flex-direction:column;gap:8px;min-width:0}.ch-you{align-items:flex-end}@@@ -219,9 +204,6 @@ details[open]>summary .ch-caret{rotate:180deg}.ch,.ch.has-side{grid-template-columns:minmax(0,1fr);height:calc(100dvh - 52px)}body:has(.ch) .ovnav-appbar{display:none!important}body:has(.ch){--ovnav-appbar-h:0px}-- .ch-rail{position:fixed;z-index:10060;top:0;left:0;bottom:0;width:min(320px,86vw);translate:-100% 0;transition:translate .25s ease;box-shadow:12px 0 32px rgba(0,0,0,.25)}-- .ch.rail-open .ch-rail{translate:0 0}-- .ch-rail-open,.ch-icon-btn.ch-rail-close{display:inline-grid}.ch-head{padding:4px 8px}.ch-thread{padding:18px 14px 16px}.ch-bubble{max-width:88%}@@@ -230,4 +212,4 @@ details[open]>summary .ch-caret{rotate:180deg}.ch.is-empty .ch-thread{padding-top:56px}}.ch-scrim{position:fixed;inset:0;z-index:10050;background:rgba(0,0,0,.4)}--@media (prefers-reduced-motion:reduce){.ch-dot.is-busy,.ch-dots i,.ch-step.is-new{animation:none}.ch-rail,.ch-side{transition:none}.ch-thread{scroll-behavior:auto}}++@media (prefers-reduced-motion:reduce){.ch-dot.is-busy,.ch-dots i,.ch-step.is-new{animation:none}.ch-side{transition:none}.ch-thread{scroll-behavior:auto}}@@@ -13,7 +13,7 @@(() => {const $ = (sel, root = document) => root.querySelector(sel);let page = null; // { app, es, setOpen } for the page on screen-- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && page) { page.setOpen('rail-open', false); page.setOpen('side-open', false); } });++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && page) page.setOpen('side-open', false); });document.addEventListener('ov:boost:before', () => { if (page && page.es) page.es.close(); page = null; });document.addEventListener('ov:boost:load', init);if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();@@@ -24,33 +24,29 @@app.dataset.chReady = '1';page = { app, es: null, setOpen: () => {} };-- // ── The rail and the side on narrow screens ──++ // ── The side on narrow screens (the rail is the network's sidebar: kit-web.js opens and closes it) ──const scrim = $('[data-ch-scrim]', app);const setOpen = (cls, on) => {app.classList.toggle(cls, on);-- const any = app.classList.contains('rail-open') || app.classList.contains('side-open');-- if (scrim) scrim.hidden = !any;-- if (on) { const first = $(cls === 'rail-open' ? '.ch-rail a, .ch-rail input' : '.ch-side button', app); if (first) first.focus({ preventScroll: true }); }++ if (scrim) scrim.hidden = !app.classList.contains('side-open');++ if (on) { const first = $('.ch-side button', app); if (first) first.focus({ preventScroll: true }); }};app.addEventListener('click', (e) => {-- const t = e.target.closest('[data-ch-rail-open],[data-ch-rail-close],[data-ch-side-open],[data-ch-side-close],[data-ch-scrim]');++ const t = e.target.closest('[data-ch-side-open],[data-ch-side-close],[data-ch-scrim]');if (!t) return;-- if (t.matches('[data-ch-rail-open]')) setOpen('rail-open', true);-- else if (t.matches('[data-ch-side-open]')) setOpen('side-open', true);-- else { setOpen('rail-open', false); setOpen('side-open', false); }++ setOpen('side-open', t.matches('[data-ch-side-open]'));});page.setOpen = setOpen;// ── The rail's search: filter as you type (Enter still searches every task) ──-- const find = $('[data-ch-find]', app);++ const find = $('.ovk-sidebar-find input', app);if (find) {find.addEventListener('input', () => {const q = find.value.trim().toLowerCase();-- app.querySelectorAll('.ch-list .ch-item').forEach((a) => { a.hidden = !!q && !a.textContent.toLowerCase().includes(q); });-- app.querySelectorAll('.ch-list .ch-group').forEach((g) => {-- let n = g.nextElementSibling, shown = false;-- while (n && !n.classList.contains('ch-group')) { if (n.classList.contains('ch-item') && !n.hidden) shown = true; n = n.nextElementSibling; }-- g.hidden = !!q && !shown;++ app.querySelectorAll('.ovk-sidebar-sec').forEach((sec) => {++ let shown = false;++ sec.querySelectorAll('.ovk-sidebar-item').forEach((a) => { a.hidden = !!q && !a.textContent.toLowerCase().includes(q); if (!a.hidden) shown = true; });++ sec.hidden = !!q && !shown;});});}@@@ -21,6 +21,7 @@ const { markdown } = require('../render/markdown');const { asyncRouter } = require('./router');const { sessionLogOf } = require('../domain/run-tasks');const models = require('../data/models');++const kit = require('openvibe-shared/kit');const LIVE = ['queued', 'placed', 'running'];const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));@@@ -138,6 +139,8 @@ function createChatRoutes(ctx) {);}function railHtml(rows, currentId, waitingIds, here = '') {++ // The network's one sidebar (openvibe-shared kit.sidebar, plan L35): New chat and search on top, Codes' places as++ // vertical rows, then the conversations by when they last moved. A drawer on a phone (kit-web.js opens it).const now = new Date();const day0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();const bucket = (row) => {@@@ -146,32 +149,38 @@ function createChatRoutes(ctx) {};const names = agentNames();const item = (row) => {-- const [word, tone] = stateOf(row);-- const needs = waitingIds.has(row.id);-- const dot = needs ? '<span class="ch-dot is-needs" title="Needs you"></span>' : tone === 'busy' ? `<span class="ch-dot is-busy" title="${esc(word)}"></span>` : tone === 'bad' ? `<span class="ch-dot is-bad" title="${esc(word)}"></span>` : '';-- const face = row.kind === 'conductor' ? `<span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span>` : avatar(agentOf(row.agent, names));-- return `<a class="ch-item${row.id === currentId ? ' is-here' : ''}" href="/tasks/${encodeURIComponent(row.id)}"${row.id === currentId ? ' aria-current="page"' : ''}>${face}<span class="ch-item-t">${esc(titleOf(row))}</span><span class="ch-item-m">${dot}${esc(ago(row.updated_at || row.created_at))}</span></a>`;++ const [, tone] = stateOf(row);++ const dot = waitingIds.has(row.id) ? 'needs' : tone === 'busy' ? 'busy' : tone === 'bad' ? 'bad' : null;++ const mark = row.kind === 'conductor' ? `<span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span>` : avatar(agentOf(row.agent, names));++ return { href: `/tasks/${encodeURIComponent(row.id)}`, label: titleOf(row), mark, meta: dot ? '' : ago(row.updated_at || row.created_at), here: row.id === currentId, dot };};const pinned = rows.filter((x) => x.kind === 'conductor' && x.outcome !== 'stopped').slice(0, 2);-- const rest = rows.filter((x) => !pinned.includes(x));const groups = [];-- for (const row of rest) {++ for (const row of rows.filter((x) => !pinned.includes(x))) {const b = bucket(row);-- if (!groups.length || groups[groups.length - 1][0] !== b) groups.push([b, []]);-- groups[groups.length - 1][1].push(row);++ if (!groups.length || groups[groups.length - 1].title !== b) groups.push({ title: b, items: [] });++ groups[groups.length - 1].items.push(item(row));}-- const foot = [['Agents', '/agents', 'agents'], ['Machines', '/machines', 'machines'], ['Memory', '/memory', 'memory'], ['Usage', '/usage', 'usage'], ['Settings', '/settings', 'settings']]-- .map(([label, href, i]) => `<a href="${href}" title="${label}"${here === href ? ' class="is-here" aria-current="page"' : ''}>${ico(i)}<span>${label}</span></a>`).join('');-- return `<aside class="ch-rail" id="ch-rail" aria-label="Your conversations">--<div class="ch-rail-top"><a class="ch-new" href="/">${ico('new')}<span>New chat</span></a><button class="ch-icon-btn ch-rail-close" type="button" data-ch-rail-close aria-label="Close the list">${ico('close')}</button></div>--<form class="ch-find" role="search" method="get" action="/tasks"><label class="ch-sr" for="ch-find">Search your conversations</label>${ico('search')}<input id="ch-find" name="q" placeholder="Search" autocomplete="off" data-ch-find></form>--<nav class="ch-list" aria-label="Conversations">--${pinned.length ? `<p class="ch-group">Pinned</p>${pinned.map(item).join('')}` : `<a class="ch-item ch-item-cta" href="/conductor"><span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span><span class="ch-item-t">Start your conductor</span></a>`}--${groups.map(([label, list]) => `<p class="ch-group">${label}</p>${list.map(item).join('')}`).join('')}--${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</p>'}--</nav>--<nav class="ch-rail-foot" aria-label="Codes">${foot}</nav>--</aside>`;++ const place = (label, href, icon, extra = {}) => ({ href, label, iconHtml: ico(icon), here: here === href, ...extra });++ return kit.sidebar({++ id: 'ch-rail', label: 'Codes',++ action: { href: '/', label: 'New chat', icon: 'pencil' },++ find: { action: '/tasks', name: 'q', placeholder: 'Search conversations', label: 'Search your conversations' },++ nav: [++ place('Conductor', '/conductor', 'conductor'),++ place('Agents', '/agents', 'agents'),++ place('Machines', '/machines', 'machines'),++ place('Memory', '/memory', 'memory'),++ place('Usage', '/usage', 'usage'),++ ...(waitingIds.size ? [{ href: '/approvals', label: 'Approvals', icon: 'hand', here: here === '/approvals', count: waitingIds.size }] : []),++ place('Settings', '/settings', 'settings'),++ ],++ sections: [++ ...(pinned.length ? [{ title: 'Pinned', items: pinned.map(item) }] : []),++ ...groups,++ ...(rows.length ? [] : [{ title: '', items: [], empty: 'Your conversations appear here.' }]),++ ],++ });}// ── The thread ───────────────────────────────────────────────────────────────────────────────────────────────@@@ -323,15 +332,16 @@ ${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p claconst waitingIds = new Set(waiting.map((a) => a.task_id));const body = `<div class="ch${side ? ' has-side' : ''}${empty ? ' is-empty' : ''}${page != null ? ' is-page' : ''}" data-chat>${railHtml(rows, currentId, waitingIds, here)}++<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div><div class="ch-scrim" data-ch-scrim hidden></div><section class="ch-main" aria-label="Conversation">--<header class="ch-head"><button class="ch-icon-btn ch-rail-open" type="button" data-ch-rail-open aria-label="Your conversations" aria-controls="ch-rail">${ico('rail')}</button>${head}${side ? `<button class="ch-icon-btn ch-side-open" type="button" data-ch-side-open aria-label="What it did" aria-controls="ch-side">${ico('side')}</button>` : ''}</header>++<header class="ch-head">${kit.sidebarButton({ id: 'ch-rail', label: 'Your conversations' })}${head}${side ? `<button class="ch-icon-btn ch-side-open" type="button" data-ch-side-open aria-label="What it did" aria-controls="ch-side">${ico('side')}</button>` : ''}</header>${page != null ? `<div class="ch-page">${page}</div>` : `<div class="ch-thread" id="ch-thread"${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${thread}</div>${composerHtml}`}</section>${side}</div>`;-- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css'], sharedScripts: ['kit-web.js'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });}/** The side: who worked on it, and where the work is. */@@@ -133,9 +133,9 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';assert.ok(page.text.includes('Online'), 'with its state');assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');-- assert.ok(page.text.includes('"appBar":false') && !page.text.includes('c4-mobile') && page.text.includes('data-ch-rail-open'), 'the app has no tab bar: the rail is its navigation (L31)');++ assert.ok(page.text.includes('"appBar":false') && !page.text.includes('c4-mobile') && page.text.includes('data-ovk-sidebar-open'), 'the app has no tab bar: the rail is its navigation (L31)');const coded = await t.get('/machines?code=abcd-2345', { as: ada });-- assert.ok(coded.text.includes('"appBar":false') && coded.text.includes('data-ch-rail-open'), 'a query string keeps the page in the app');++ assert.ok(coded.text.includes('"appBar":false') && coded.text.includes('data-ovk-sidebar-open'), 'a query string keeps the page in the app');assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');const wrong = await t.get('/machines?code=nope', { as: ada });assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node’s own words');@@@ -36,8 +36,8 @@ const split = (links) => ({const { top, more } = split(navbarOf(page.text).links);assert.deepStrictEqual(top, [], 'the navbar carries no sections');assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'only the product’s places, in More');-- assert.ok(page.text.includes('aria-label="Your conversations"'), 'the rail is on the page');-- assert.ok(/<a href="\/agents" title="Agents" class="is-here" aria-current="page">/.test(page.text), 'and marks the page the person is on');++ assert.ok(page.text.includes('<aside class="ovk-sidebar" id="ch-rail"'), 'the rail is on the page');++ assert.ok(page.text.includes('<a class="ovk-sidebar-link is-here" href="/agents" aria-current="page">'), 'and marks the page the person is on, a row of the network’s sidebar');assert.ok(!page.text.includes('c4-side') && !page.text.includes('c4-layout'), 'no other sidebar');});@@@ -71,7 +71,7 @@ const split = (links) => ({assert.ok(!page.text.includes('c4-mobile'), 'and no bar of its own');const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'chat.css'), 'utf8');assert.ok(css.includes('body.ov-workspace:has(.ch){padding-bottom:0}'), 'so nothing is reserved for one');-- assert.ok(page.text.includes('data-ch-rail-open'), 'the rail opens from the page’s own header');++ assert.ok(page.text.includes('data-ovk-sidebar-open aria-controls="ch-rail"'), 'the rail opens from the page’s own header');});await check('the Frame’s app bar: none in a signed-in person’s app, the product’s for a visitor', async () => {@@@ -1,8 +1,9 @@'use strict';// public/js/chat.js after a page move (the owner, 2026-10-31, a phone: "I can't open… the menu" on Machines). Codes// moves between pages with openvibe-shared/boost, which swaps <main> without running the page's scripts again: a move--// from Agents to Machines (the same scripts) left the new page's rail button with no handler. The script now sets up--// each page on ov:boost:load, and closes a live stream when the page moves away.++// from Agents to Machines (the same scripts) left the new page with no handlers. The rail is now the network's sidebar,++// which kit-web.js opens on the document (openvibe-shared test/kit.test.js); chat.js still owns the side panel and the++// rail's search, and sets them up on each page: its first load and every ov:boost:load.const assert = require('assert');const fs = require('fs');const path = require('path');@@@ -10,29 +11,40 @@ const { parseHTML } = require('linkedom');const { check, done } = require('./helpers/boot');const SRC = fs.readFileSync(path.join(__dirname, '../public/js/chat.js'), 'utf8');--const PAGE = (title) => `<div class="ch is-page" data-chat><aside class="ch-rail" id="ch-rail"><a href="/agents">Agents</a></aside><div class="ch-scrim" data-ch-scrim hidden></div>--<section class="ch-main"><header class="ch-head"><button type="button" data-ch-rail-open>open</button></header><div class="ch-page"><h1>${title}</h1></div></section></div>`;++const PAGE = (title) => `<div class="ch has-side" data-chat><aside class="ovk-sidebar" id="ch-rail" data-ovk-sidebar>++<form class="ovk-sidebar-find"><input></form><div class="ovk-sidebar-secs"><section class="ovk-sidebar-sec"><h2 class="ovk-sidebar-h">Today</h2>++<a class="ovk-sidebar-item" href="/tasks/1"><span class="ovk-sidebar-t">Fit the home page</span></a><a class="ovk-sidebar-item" href="/tasks/2"><span class="ovk-sidebar-t">Fix the fleet card</span></a></section>++<section class="ovk-sidebar-sec"><h2 class="ovk-sidebar-h">Earlier</h2><a class="ovk-sidebar-item" href="/tasks/3"><span class="ovk-sidebar-t">Importer</span></a></section></div></aside>++<div class="ch-scrim" data-ch-scrim hidden></div>++<section class="ch-main"><header class="ch-head"><button type="button" data-ch-side-open>side</button></header><div class="ch-page"><h1>${title}</h1></div></section>++<aside class="ch-side" id="ch-side"><button type="button" data-ch-side-close>close</button></aside></div>`;(async () => {-- await check('the rail opens on a page that boost swapped in, not only on the first', async () => {++ await check('the side panel and the rail’s search work on a page that boost swapped in, not only on the first', async () => {const { window, document, CustomEvent, Event } = parseHTML(`<!doctype html><html><body><main id="main">${PAGE('Agents')}</main></body></html>`);window.EventSource = undefined;new Function('window', 'document', 'location', 'setTimeout', SRC)(window, document, { reload() {} }, setTimeout);-- const tap = () => document.querySelector('[data-ch-rail-open]').dispatchEvent(new Event('click', { bubbles: true }));-- tap();-- assert.ok(document.querySelector('[data-chat]').classList.contains('rail-open'), 'the first page');++ const tap = (sel) => document.querySelector(sel).dispatchEvent(new Event('click', { bubbles: true }));++ tap('[data-ch-side-open]');++ assert.ok(document.querySelector('[data-chat]').classList.contains('side-open'), 'the first page');// A boost move: the old <main> goes, the new one comes in, then ov:boost:load.document.dispatchEvent(new CustomEvent('ov:boost:before'));document.getElementById('main').innerHTML = PAGE('Machines');document.dispatchEvent(new CustomEvent('ov:boost:load'));-- tap();const app = document.querySelector('[data-chat]');-- assert.ok(app.classList.contains('rail-open'), 'the swapped-in page');-- assert.strictEqual(document.querySelector('[data-ch-scrim]').hidden, false, 'with its scrim');++ tap('[data-ch-side-open]');++ assert.ok(app.classList.contains('side-open') && document.querySelector('[data-ch-scrim]').hidden === false, 'the swapped-in page, with its scrim');++ const esc = new Event('keydown'); esc.key = 'Escape'; document.dispatchEvent(esc);++ assert.ok(!app.classList.contains('side-open'), 'Escape closes the side of the page on screen');document.dispatchEvent(new CustomEvent('ov:boost:load'));assert.strictEqual(app.dataset.chReady, '1', 'a second load event does not set it up twice');-- const esc = new Event('keydown'); esc.key = 'Escape'; document.dispatchEvent(esc);-- assert.ok(!app.classList.contains('rail-open'), 'Escape closes the rail of the page on screen');++ // The rail's search filters the conversations as you type, and a section with none left hides.++ const input = document.querySelector('.ovk-sidebar-find input');++ input.value = 'fleet'; input.dispatchEvent(new Event('input'));++ const shown = [...document.querySelectorAll('.ovk-sidebar-item')].filter((a) => !a.hidden).map((a) => a.textContent.trim());++ assert.deepStrictEqual(shown, ['Fix the fleet card']);++ const secs = [...document.querySelectorAll('.ovk-sidebar-sec')].map((x) => x.hidden);++ assert.deepStrictEqual(secs, [false, true], 'Earlier has nothing left, so it hides');});done();})().catch((e) => { console.error(e); process.exit(1); });@@@ -35,7 +35,7 @@ const { stepKind } = require('../server/http/chat');assert.strictEqual(page.status, 200);assert.ok(page.text.includes('Fit the home page to a 360 px phone'), 'their conversation is in the rail');assert.ok(!page.text.includes('Someone else entirely'), 'another person’s is not');-- assert.ok(page.text.includes('<p class="ch-group">Today</p>'), 'grouped by when it last moved');++ assert.ok(page.text.includes('<h2 class="ovk-sidebar-h">Today</h2>'), 'grouped by when it last moved');const theirs = await t.get(`/tasks/${THEIRS}`, { as: ada });assert.notStrictEqual(theirs.status, 200, 'another person’s conversation does not open');});
@@ -94,7 +94,7 @@ More: [docs/cli.md](docs/cli.md).
@@ -94,7 +94,7 @@ More: [docs/cli.md](docs/cli.md).
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
-- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.59.0** (pinned tag tarballs).
+- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.60.0** (pinned tag tarballs).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
"domain": "openvibe.codes",
"domain": "openvibe.codes",
"contracts": "openvibe-contracts v0.247.0",
"contracts": "openvibe-contracts v0.247.0",
"sdk": "openvibe-sdk v0.76.0",
"sdk": "openvibe-sdk v0.76.0",
- "shared": "openvibe-shared v4.59.0",
+ "shared": "openvibe-shared v4.60.0",
"packages": [
"packages": [
"openvibe-contracts v0.247.0",
"openvibe-contracts v0.247.0",
"openvibe-sdk v0.76.0",
"openvibe-sdk v0.76.0",
- "openvibe-shared v4.59.0"
+ "openvibe-shared v4.60.0"
],
],
"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.2) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.2) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"works": [
"works": [
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.60.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"bin": {
"bin": {
@@ -1193,9 +1193,9 @@
@@ -1193,9 +1193,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.59.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",
- "integrity": "sha512-kaJ6Tcz7WCewK/kCx4NzNWRNI4U1Z1lpwCQHWD115YtcaJps8Ulqpu1y2ArLpywARHtYf3ddPH/hL9wkb6MJlw==",
+ "version": "4.60.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.60.0",
+ "integrity": "sha512-FFdY/eF7qfghQ1vKbwV3j5fzRgSwoc+T7/JZrYeIncjEFlHH/nAVHIFMpYwhEJR9KulmC5xWQUj/MKWRu1hLQQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.76.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.59.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.60.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -13,27 +13,7 @@ body.ov-workspace:has(.ch){padding-bottom:0}
@@ -13,27 +13,7 @@ body.ov-workspace:has(.ch){padding-bottom:0}
main.page:has(> .ch){max-width:none;margin:0;padding:0}
main.page:has(> .ch){max-width:none;margin:0;padding:0}
/* ── The rail ── */
/* ── The rail ── */
-.ch-rail{display:flex;flex-direction:column;min-height:0;background:var(--ch-rail);border-right:1px solid var(--ch-line)}
-.ch-rail-top{display:flex;gap:6px;padding:12px 10px 6px}
-.ch-new{flex:1;display:flex;align-items:center;gap:9px;padding:9px 12px;border-radius:12px;color:var(--ch-ink);text-decoration:none;font-weight:600;font-size:14px;border:1px solid var(--ch-line);background:var(--ch-card)}
-.ch-new:hover{border-color:color-mix(in srgb,var(--ch-accent) 45%,var(--ch-line))}
-.ch-find{position:relative;margin:4px 10px 6px}
-.ch-find .ch-i{position:absolute;left:10px;top:50%;translate:0 -50%;width:15px;height:15px;color:var(--ch-muted)}
-.ch-find input{width:100%;box-sizing:border-box;padding:8px 10px 8px 32px;border-radius:10px;border:1px solid transparent;background:var(--ch-hover);color:var(--ch-ink);font:inherit;font-size:13.5px}
-.ch-find input:focus{outline:none;border-color:color-mix(in srgb,var(--ch-accent) 55%,transparent)}
-.ch-list{flex:1;min-height:0;overflow-y:auto;padding:2px 8px 10px;scrollbar-width:thin}
.ch-group{margin:14px 8px 4px;font-size:11.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--ch-muted)}
.ch-group{margin:14px 8px 4px;font-size:11.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--ch-muted)}
-.ch-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:10px;color:var(--ch-soft);text-decoration:none;font-size:14px;line-height:1.3;min-height:36px}
-.ch-item:hover{background:var(--ch-hover);color:var(--ch-ink)}
-.ch-item.is-here{background:var(--ch-hover);color:var(--ch-ink);font-weight:600}
-.ch-item-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.ch-item-m{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ch-muted);font-weight:500}
-.ch-item-cta{color:var(--ch-accent);font-weight:600}
-.ch-empty-rail{margin:16px 8px;color:var(--ch-muted);font-size:13px}
-.ch-rail-foot{display:grid;grid-template-columns:repeat(5,1fr);gap:2px;padding:8px;border-top:1px solid var(--ch-line)}
-.ch-rail-foot a{display:grid;justify-items:center;gap:3px;padding:7px 2px;border-radius:10px;color:var(--ch-muted);text-decoration:none;font-size:10.5px;font-weight:600}
-.ch-rail-foot a:hover{background:var(--ch-hover);color:var(--ch-ink)}
-.ch-rail-foot a.is-here{background:var(--ch-hover);color:var(--ch-accent)}
/* Faces: an agent's initials in its own colour; the conductor's is its sign. */
/* Faces: an agent's initials in its own colour; the conductor's is its sign. */
.ch-av{display:inline-grid;place-items:center;width:24px;height:24px;flex:none;border-radius:50%;font-size:10.5px;font-weight:800;letter-spacing:-.02em;color:var(--av,#64748b);background:color-mix(in srgb,var(--av,#64748b) 18%,transparent)}
.ch-av{display:inline-grid;place-items:center;width:24px;height:24px;flex:none;border-radius:50%;font-size:10.5px;font-weight:800;letter-spacing:-.02em;color:var(--av,#64748b);background:color-mix(in srgb,var(--av,#64748b) 18%,transparent)}
@@ -56,7 +36,12 @@ main.page:has(> .ch){max-width:none;margin:0;padding:0}
@@ -56,7 +36,12 @@ main.page:has(> .ch){max-width:none;margin:0;padding:0}
.ch-state.is-busy{color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 12%,transparent)}
.ch-state.is-busy{color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 12%,transparent)}
.ch-icon-btn{display:inline-grid;place-items:center;width:36px;height:36px;flex:none;border:0;border-radius:10px;background:transparent;color:var(--ch-soft);cursor:pointer}
.ch-icon-btn{display:inline-grid;place-items:center;width:36px;height:36px;flex:none;border:0;border-radius:10px;background:transparent;color:var(--ch-soft);cursor:pointer}
.ch-icon-btn:hover{background:var(--ch-hover);color:var(--ch-ink)}
.ch-icon-btn:hover{background:var(--ch-hover);color:var(--ch-ink)}
-.ch-rail-open,.ch-side-open,.ch-icon-btn.ch-rail-close{display:none}
+.ch-side-open{display:none}
+/* The rail is the network's sidebar (openvibe-shared kit.sidebar): the grid's first column on a computer, a drawer on
+ a phone (kit.css). Its avatars are Codes' own. */
+@media (min-width:761px){.ch > .ovk-sidebar{position:relative;top:auto;height:auto;min-height:0}}
+.ch .ovk-sidebar-item .ch-av{width:24px;height:24px;font-size:11px}
+.ch-head .ovk-sidebar-open{color:var(--ch-ink)}
.ch-thread{flex:1;min-height:0;overflow-y:auto;padding:28px max(20px,calc((100% - var(--ch-col)) / 2)) 24px;display:flex;flex-direction:column;gap:22px;scroll-behavior:smooth}
.ch-thread{flex:1;min-height:0;overflow-y:auto;padding:28px max(20px,calc((100% - var(--ch-col)) / 2)) 24px;display:flex;flex-direction:column;gap:22px;scroll-behavior:smooth}
.ch-turn{display:flex;flex-direction:column;gap:8px;min-width:0}
.ch-turn{display:flex;flex-direction:column;gap:8px;min-width:0}
.ch-you{align-items:flex-end}
.ch-you{align-items:flex-end}
@@ -219,9 +204,6 @@ details[open]>summary .ch-caret{rotate:180deg}
@@ -219,9 +204,6 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch,.ch.has-side{grid-template-columns:minmax(0,1fr);height:calc(100dvh - 52px)}
.ch,.ch.has-side{grid-template-columns:minmax(0,1fr);height:calc(100dvh - 52px)}
body:has(.ch) .ovnav-appbar{display:none!important}
body:has(.ch) .ovnav-appbar{display:none!important}
body:has(.ch){--ovnav-appbar-h:0px}
body:has(.ch){--ovnav-appbar-h:0px}
- .ch-rail{position:fixed;z-index:10060;top:0;left:0;bottom:0;width:min(320px,86vw);translate:-100% 0;transition:translate .25s ease;box-shadow:12px 0 32px rgba(0,0,0,.25)}
- .ch.rail-open .ch-rail{translate:0 0}
- .ch-rail-open,.ch-icon-btn.ch-rail-close{display:inline-grid}
.ch-head{padding:4px 8px}
.ch-head{padding:4px 8px}
.ch-thread{padding:18px 14px 16px}
.ch-thread{padding:18px 14px 16px}
.ch-bubble{max-width:88%}
.ch-bubble{max-width:88%}
@@ -230,4 +212,4 @@ details[open]>summary .ch-caret{rotate:180deg}
@@ -230,4 +212,4 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch.is-empty .ch-thread{padding-top:56px}
.ch.is-empty .ch-thread{padding-top:56px}
}
}
.ch-scrim{position:fixed;inset:0;z-index:10050;background:rgba(0,0,0,.4)}
.ch-scrim{position:fixed;inset:0;z-index:10050;background:rgba(0,0,0,.4)}
-@media (prefers-reduced-motion:reduce){.ch-dot.is-busy,.ch-dots i,.ch-step.is-new{animation:none}.ch-rail,.ch-side{transition:none}.ch-thread{scroll-behavior:auto}}
+@media (prefers-reduced-motion:reduce){.ch-dot.is-busy,.ch-dots i,.ch-step.is-new{animation:none}.ch-side{transition:none}.ch-thread{scroll-behavior:auto}}
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
(() => {
(() => {
const $ = (sel, root = document) => root.querySelector(sel);
const $ = (sel, root = document) => root.querySelector(sel);
let page = null; // { app, es, setOpen } for the page on screen
let page = null; // { app, es, setOpen } for the page on screen
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && page) { page.setOpen('rail-open', false); page.setOpen('side-open', false); } });
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && page) page.setOpen('side-open', false); });
document.addEventListener('ov:boost:before', () => { if (page && page.es) page.es.close(); page = null; });
document.addEventListener('ov:boost:before', () => { if (page && page.es) page.es.close(); page = null; });
document.addEventListener('ov:boost:load', init);
document.addEventListener('ov:boost:load', init);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
@@ -24,33 +24,29 @@
@@ -24,33 +24,29 @@
app.dataset.chReady = '1';
app.dataset.chReady = '1';
page = { app, es: null, setOpen: () => {} };
page = { app, es: null, setOpen: () => {} };
- // ── The rail and the side on narrow screens ──
+ // ── The side on narrow screens (the rail is the network's sidebar: kit-web.js opens and closes it) ──
const scrim = $('[data-ch-scrim]', app);
const scrim = $('[data-ch-scrim]', app);
const setOpen = (cls, on) => {
const setOpen = (cls, on) => {
app.classList.toggle(cls, on);
app.classList.toggle(cls, on);
- const any = app.classList.contains('rail-open') || app.classList.contains('side-open');
- if (scrim) scrim.hidden = !any;
- if (on) { const first = $(cls === 'rail-open' ? '.ch-rail a, .ch-rail input' : '.ch-side button', app); if (first) first.focus({ preventScroll: true }); }
+ if (scrim) scrim.hidden = !app.classList.contains('side-open');
+ if (on) { const first = $('.ch-side button', app); if (first) first.focus({ preventScroll: true }); }
};
};
app.addEventListener('click', (e) => {
app.addEventListener('click', (e) => {
- const t = e.target.closest('[data-ch-rail-open],[data-ch-rail-close],[data-ch-side-open],[data-ch-side-close],[data-ch-scrim]');
+ const t = e.target.closest('[data-ch-side-open],[data-ch-side-close],[data-ch-scrim]');
if (!t) return;
if (!t) return;
- if (t.matches('[data-ch-rail-open]')) setOpen('rail-open', true);
- else if (t.matches('[data-ch-side-open]')) setOpen('side-open', true);
- else { setOpen('rail-open', false); setOpen('side-open', false); }
+ setOpen('side-open', t.matches('[data-ch-side-open]'));
});
});
page.setOpen = setOpen;
page.setOpen = setOpen;
// ── The rail's search: filter as you type (Enter still searches every task) ──
// ── The rail's search: filter as you type (Enter still searches every task) ──
- const find = $('[data-ch-find]', app);
+ const find = $('.ovk-sidebar-find input', app);
if (find) {
if (find) {
find.addEventListener('input', () => {
find.addEventListener('input', () => {
const q = find.value.trim().toLowerCase();
const q = find.value.trim().toLowerCase();
- app.querySelectorAll('.ch-list .ch-item').forEach((a) => { a.hidden = !!q && !a.textContent.toLowerCase().includes(q); });
- app.querySelectorAll('.ch-list .ch-group').forEach((g) => {
- let n = g.nextElementSibling, shown = false;
- while (n && !n.classList.contains('ch-group')) { if (n.classList.contains('ch-item') && !n.hidden) shown = true; n = n.nextElementSibling; }
- g.hidden = !!q && !shown;
+ app.querySelectorAll('.ovk-sidebar-sec').forEach((sec) => {
+ let shown = false;
+ sec.querySelectorAll('.ovk-sidebar-item').forEach((a) => { a.hidden = !!q && !a.textContent.toLowerCase().includes(q); if (!a.hidden) shown = true; });
+ sec.hidden = !!q && !shown;
});
});
});
});
}
}
@@ -21,6 +21,7 @@ const { markdown } = require('../render/markdown');
@@ -21,6 +21,7 @@ const { markdown } = require('../render/markdown');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { sessionLogOf } = require('../domain/run-tasks');
const { sessionLogOf } = require('../domain/run-tasks');
const models = require('../data/models');
const models = require('../data/models');
+const kit = require('openvibe-shared/kit');
const LIVE = ['queued', 'placed', 'running'];
const LIVE = ['queued', 'placed', 'running'];
const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
@@ -138,6 +139,8 @@ function createChatRoutes(ctx) {
@@ -138,6 +139,8 @@ function createChatRoutes(ctx) {
);
);
}
}
function railHtml(rows, currentId, waitingIds, here = '') {
function railHtml(rows, currentId, waitingIds, here = '') {
+ // The network's one sidebar (openvibe-shared kit.sidebar, plan L35): New chat and search on top, Codes' places as
+ // vertical rows, then the conversations by when they last moved. A drawer on a phone (kit-web.js opens it).
const now = new Date();
const now = new Date();
const day0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const day0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const bucket = (row) => {
const bucket = (row) => {
@@ -146,32 +149,38 @@ function createChatRoutes(ctx) {
@@ -146,32 +149,38 @@ function createChatRoutes(ctx) {
};
};
const names = agentNames();
const names = agentNames();
const item = (row) => {
const item = (row) => {
- const [word, tone] = stateOf(row);
- const needs = waitingIds.has(row.id);
- const dot = needs ? '<span class="ch-dot is-needs" title="Needs you"></span>' : tone === 'busy' ? `<span class="ch-dot is-busy" title="${esc(word)}"></span>` : tone === 'bad' ? `<span class="ch-dot is-bad" title="${esc(word)}"></span>` : '';
- const face = row.kind === 'conductor' ? `<span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span>` : avatar(agentOf(row.agent, names));
- return `<a class="ch-item${row.id === currentId ? ' is-here' : ''}" href="/tasks/${encodeURIComponent(row.id)}"${row.id === currentId ? ' aria-current="page"' : ''}>${face}<span class="ch-item-t">${esc(titleOf(row))}</span><span class="ch-item-m">${dot}${esc(ago(row.updated_at || row.created_at))}</span></a>`;
+ const [, tone] = stateOf(row);
+ const dot = waitingIds.has(row.id) ? 'needs' : tone === 'busy' ? 'busy' : tone === 'bad' ? 'bad' : null;
+ const mark = row.kind === 'conductor' ? `<span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span>` : avatar(agentOf(row.agent, names));
+ return { href: `/tasks/${encodeURIComponent(row.id)}`, label: titleOf(row), mark, meta: dot ? '' : ago(row.updated_at || row.created_at), here: row.id === currentId, dot };
};
};
const pinned = rows.filter((x) => x.kind === 'conductor' && x.outcome !== 'stopped').slice(0, 2);
const pinned = rows.filter((x) => x.kind === 'conductor' && x.outcome !== 'stopped').slice(0, 2);
- const rest = rows.filter((x) => !pinned.includes(x));
const groups = [];
const groups = [];
- for (const row of rest) {
+ for (const row of rows.filter((x) => !pinned.includes(x))) {
const b = bucket(row);
const b = bucket(row);
- if (!groups.length || groups[groups.length - 1][0] !== b) groups.push([b, []]);
- groups[groups.length - 1][1].push(row);
+ if (!groups.length || groups[groups.length - 1].title !== b) groups.push({ title: b, items: [] });
+ groups[groups.length - 1].items.push(item(row));
}
}
- const foot = [['Agents', '/agents', 'agents'], ['Machines', '/machines', 'machines'], ['Memory', '/memory', 'memory'], ['Usage', '/usage', 'usage'], ['Settings', '/settings', 'settings']]
- .map(([label, href, i]) => `<a href="${href}" title="${label}"${here === href ? ' class="is-here" aria-current="page"' : ''}>${ico(i)}<span>${label}</span></a>`).join('');
- return `<aside class="ch-rail" id="ch-rail" aria-label="Your conversations">
-<div class="ch-rail-top"><a class="ch-new" href="/">${ico('new')}<span>New chat</span></a><button class="ch-icon-btn ch-rail-close" type="button" data-ch-rail-close aria-label="Close the list">${ico('close')}</button></div>
-<form class="ch-find" role="search" method="get" action="/tasks"><label class="ch-sr" for="ch-find">Search your conversations</label>${ico('search')}<input id="ch-find" name="q" placeholder="Search" autocomplete="off" data-ch-find></form>
-<nav class="ch-list" aria-label="Conversations">
-${pinned.length ? `<p class="ch-group">Pinned</p>${pinned.map(item).join('')}` : `<a class="ch-item ch-item-cta" href="/conductor"><span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span><span class="ch-item-t">Start your conductor</span></a>`}
-${groups.map(([label, list]) => `<p class="ch-group">${label}</p>${list.map(item).join('')}`).join('')}
-${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</p>'}
-</nav>
-<nav class="ch-rail-foot" aria-label="Codes">${foot}</nav>
-</aside>`;
+ const place = (label, href, icon, extra = {}) => ({ href, label, iconHtml: ico(icon), here: here === href, ...extra });
+ return kit.sidebar({
+ id: 'ch-rail', label: 'Codes',
+ action: { href: '/', label: 'New chat', icon: 'pencil' },
+ find: { action: '/tasks', name: 'q', placeholder: 'Search conversations', label: 'Search your conversations' },
+ nav: [
+ place('Conductor', '/conductor', 'conductor'),
+ place('Agents', '/agents', 'agents'),
+ place('Machines', '/machines', 'machines'),
+ place('Memory', '/memory', 'memory'),
+ place('Usage', '/usage', 'usage'),
+ ...(waitingIds.size ? [{ href: '/approvals', label: 'Approvals', icon: 'hand', here: here === '/approvals', count: waitingIds.size }] : []),
+ place('Settings', '/settings', 'settings'),
+ ],
+ sections: [
+ ...(pinned.length ? [{ title: 'Pinned', items: pinned.map(item) }] : []),
+ ...groups,
+ ...(rows.length ? [] : [{ title: '', items: [], empty: 'Your conversations appear here.' }]),
+ ],
+ });
}
}
// ── The thread ───────────────────────────────────────────────────────────────────────────────────────────────
// ── The thread ───────────────────────────────────────────────────────────────────────────────────────────────
@@ -323,15 +332,16 @@ ${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p cla
@@ -323,15 +332,16 @@ ${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p cla
const waitingIds = new Set(waiting.map((a) => a.task_id));
const waitingIds = new Set(waiting.map((a) => a.task_id));
const body = `<div class="ch${side ? ' has-side' : ''}${empty ? ' is-empty' : ''}${page != null ? ' is-page' : ''}" data-chat>
const body = `<div class="ch${side ? ' has-side' : ''}${empty ? ' is-empty' : ''}${page != null ? ' is-page' : ''}" data-chat>
${railHtml(rows, currentId, waitingIds, here)}
${railHtml(rows, currentId, waitingIds, here)}
+<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>
<div class="ch-scrim" data-ch-scrim hidden></div>
<div class="ch-scrim" data-ch-scrim hidden></div>
<section class="ch-main" aria-label="Conversation">
<section class="ch-main" aria-label="Conversation">
-<header class="ch-head"><button class="ch-icon-btn ch-rail-open" type="button" data-ch-rail-open aria-label="Your conversations" aria-controls="ch-rail">${ico('rail')}</button>${head}${side ? `<button class="ch-icon-btn ch-side-open" type="button" data-ch-side-open aria-label="What it did" aria-controls="ch-side">${ico('side')}</button>` : ''}</header>
+<header class="ch-head">${kit.sidebarButton({ id: 'ch-rail', label: 'Your conversations' })}${head}${side ? `<button class="ch-icon-btn ch-side-open" type="button" data-ch-side-open aria-label="What it did" aria-controls="ch-side">${ico('side')}</button>` : ''}</header>
${page != null ? `<div class="ch-page">${page}</div>` : `<div class="ch-thread" id="ch-thread"${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${thread}</div>
${page != null ? `<div class="ch-page">${page}</div>` : `<div class="ch-thread" id="ch-thread"${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${thread}</div>
${composerHtml}`}
${composerHtml}`}
</section>
</section>
${side}
${side}
</div>`;
</div>`;
- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });
+ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css'], sharedScripts: ['kit-web.js'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });
}
}
/** The side: who worked on it, and where the work is. */
/** The side: who worked on it, and where the work is. */
@@ -133,9 +133,9 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -133,9 +133,9 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
assert.ok(page.text.includes('Online'), 'with its state');
assert.ok(page.text.includes('Online'), 'with its state');
assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');
assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');
assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');
assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');
- assert.ok(page.text.includes('"appBar":false') && !page.text.includes('c4-mobile') && page.text.includes('data-ch-rail-open'), 'the app has no tab bar: the rail is its navigation (L31)');
+ assert.ok(page.text.includes('"appBar":false') && !page.text.includes('c4-mobile') && page.text.includes('data-ovk-sidebar-open'), 'the app has no tab bar: the rail is its navigation (L31)');
const coded = await t.get('/machines?code=abcd-2345', { as: ada });
const coded = await t.get('/machines?code=abcd-2345', { as: ada });
- assert.ok(coded.text.includes('"appBar":false') && coded.text.includes('data-ch-rail-open'), 'a query string keeps the page in the app');
+ assert.ok(coded.text.includes('"appBar":false') && coded.text.includes('data-ovk-sidebar-open'), 'a query string keeps the page in the app');
assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');
assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');
const wrong = await t.get('/machines?code=nope', { as: ada });
const wrong = await t.get('/machines?code=nope', { as: ada });
assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node’s own words');
assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node’s own words');
@@ -36,8 +36,8 @@ const split = (links) => ({
@@ -36,8 +36,8 @@ const split = (links) => ({
const { top, more } = split(navbarOf(page.text).links);
const { top, more } = split(navbarOf(page.text).links);
assert.deepStrictEqual(top, [], 'the navbar carries no sections');
assert.deepStrictEqual(top, [], 'the navbar carries no sections');
assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'only the product’s places, in More');
assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'only the product’s places, in More');
- assert.ok(page.text.includes('aria-label="Your conversations"'), 'the rail is on the page');
- assert.ok(/<a href="\/agents" title="Agents" class="is-here" aria-current="page">/.test(page.text), 'and marks the page the person is on');
+ assert.ok(page.text.includes('<aside class="ovk-sidebar" id="ch-rail"'), 'the rail is on the page');
+ assert.ok(page.text.includes('<a class="ovk-sidebar-link is-here" href="/agents" aria-current="page">'), 'and marks the page the person is on, a row of the network’s sidebar');
assert.ok(!page.text.includes('c4-side') && !page.text.includes('c4-layout'), 'no other sidebar');
assert.ok(!page.text.includes('c4-side') && !page.text.includes('c4-layout'), 'no other sidebar');
});
});
@@ -71,7 +71,7 @@ const split = (links) => ({
@@ -71,7 +71,7 @@ const split = (links) => ({
assert.ok(!page.text.includes('c4-mobile'), 'and no bar of its own');
assert.ok(!page.text.includes('c4-mobile'), 'and no bar of its own');
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'chat.css'), 'utf8');
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'chat.css'), 'utf8');
assert.ok(css.includes('body.ov-workspace:has(.ch){padding-bottom:0}'), 'so nothing is reserved for one');
assert.ok(css.includes('body.ov-workspace:has(.ch){padding-bottom:0}'), 'so nothing is reserved for one');
- assert.ok(page.text.includes('data-ch-rail-open'), 'the rail opens from the page’s own header');
+ assert.ok(page.text.includes('data-ovk-sidebar-open aria-controls="ch-rail"'), 'the rail opens from the page’s own header');
});
});
await check('the Frame’s app bar: none in a signed-in person’s app, the product’s for a visitor', async () => {
await check('the Frame’s app bar: none in a signed-in person’s app, the product’s for a visitor', async () => {
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'use strict';
// public/js/chat.js after a page move (the owner, 2026-10-31, a phone: "I can't open… the menu" on Machines). Codes
// public/js/chat.js after a page move (the owner, 2026-10-31, a phone: "I can't open… the menu" on Machines). Codes
// moves between pages with openvibe-shared/boost, which swaps <main> without running the page's scripts again: a move
// moves between pages with openvibe-shared/boost, which swaps <main> without running the page's scripts again: a move
-// from Agents to Machines (the same scripts) left the new page's rail button with no handler. The script now sets up
-// each page on ov:boost:load, and closes a live stream when the page moves away.
+// from Agents to Machines (the same scripts) left the new page with no handlers. The rail is now the network's sidebar,
+// which kit-web.js opens on the document (openvibe-shared test/kit.test.js); chat.js still owns the side panel and the
+// rail's search, and sets them up on each page: its first load and every ov:boost:load.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -10,29 +11,40 @@ const { parseHTML } = require('linkedom');
@@ -10,29 +11,40 @@ const { parseHTML } = require('linkedom');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
const SRC = fs.readFileSync(path.join(__dirname, '../public/js/chat.js'), 'utf8');
const SRC = fs.readFileSync(path.join(__dirname, '../public/js/chat.js'), 'utf8');
-const PAGE = (title) => `<div class="ch is-page" data-chat><aside class="ch-rail" id="ch-rail"><a href="/agents">Agents</a></aside><div class="ch-scrim" data-ch-scrim hidden></div>
-<section class="ch-main"><header class="ch-head"><button type="button" data-ch-rail-open>open</button></header><div class="ch-page"><h1>${title}</h1></div></section></div>`;
+const PAGE = (title) => `<div class="ch has-side" data-chat><aside class="ovk-sidebar" id="ch-rail" data-ovk-sidebar>
+<form class="ovk-sidebar-find"><input></form><div class="ovk-sidebar-secs"><section class="ovk-sidebar-sec"><h2 class="ovk-sidebar-h">Today</h2>
+<a class="ovk-sidebar-item" href="/tasks/1"><span class="ovk-sidebar-t">Fit the home page</span></a><a class="ovk-sidebar-item" href="/tasks/2"><span class="ovk-sidebar-t">Fix the fleet card</span></a></section>
+<section class="ovk-sidebar-sec"><h2 class="ovk-sidebar-h">Earlier</h2><a class="ovk-sidebar-item" href="/tasks/3"><span class="ovk-sidebar-t">Importer</span></a></section></div></aside>
+<div class="ch-scrim" data-ch-scrim hidden></div>
+<section class="ch-main"><header class="ch-head"><button type="button" data-ch-side-open>side</button></header><div class="ch-page"><h1>${title}</h1></div></section>
+<aside class="ch-side" id="ch-side"><button type="button" data-ch-side-close>close</button></aside></div>`;
(async () => {
(async () => {
- await check('the rail opens on a page that boost swapped in, not only on the first', async () => {
+ await check('the side panel and the rail’s search work on a page that boost swapped in, not only on the first', async () => {
const { window, document, CustomEvent, Event } = parseHTML(`<!doctype html><html><body><main id="main">${PAGE('Agents')}</main></body></html>`);
const { window, document, CustomEvent, Event } = parseHTML(`<!doctype html><html><body><main id="main">${PAGE('Agents')}</main></body></html>`);
window.EventSource = undefined;
window.EventSource = undefined;
new Function('window', 'document', 'location', 'setTimeout', SRC)(window, document, { reload() {} }, setTimeout);
new Function('window', 'document', 'location', 'setTimeout', SRC)(window, document, { reload() {} }, setTimeout);
- const tap = () => document.querySelector('[data-ch-rail-open]').dispatchEvent(new Event('click', { bubbles: true }));
- tap();
- assert.ok(document.querySelector('[data-chat]').classList.contains('rail-open'), 'the first page');
+ const tap = (sel) => document.querySelector(sel).dispatchEvent(new Event('click', { bubbles: true }));
+ tap('[data-ch-side-open]');
+ assert.ok(document.querySelector('[data-chat]').classList.contains('side-open'), 'the first page');
// A boost move: the old <main> goes, the new one comes in, then ov:boost:load.
// A boost move: the old <main> goes, the new one comes in, then ov:boost:load.
document.dispatchEvent(new CustomEvent('ov:boost:before'));
document.dispatchEvent(new CustomEvent('ov:boost:before'));
document.getElementById('main').innerHTML = PAGE('Machines');
document.getElementById('main').innerHTML = PAGE('Machines');
document.dispatchEvent(new CustomEvent('ov:boost:load'));
document.dispatchEvent(new CustomEvent('ov:boost:load'));
- tap();
const app = document.querySelector('[data-chat]');
const app = document.querySelector('[data-chat]');
- assert.ok(app.classList.contains('rail-open'), 'the swapped-in page');
- assert.strictEqual(document.querySelector('[data-ch-scrim]').hidden, false, 'with its scrim');
+ tap('[data-ch-side-open]');
+ assert.ok(app.classList.contains('side-open') && document.querySelector('[data-ch-scrim]').hidden === false, 'the swapped-in page, with its scrim');
+ const esc = new Event('keydown'); esc.key = 'Escape'; document.dispatchEvent(esc);
+ assert.ok(!app.classList.contains('side-open'), 'Escape closes the side of the page on screen');
document.dispatchEvent(new CustomEvent('ov:boost:load'));
document.dispatchEvent(new CustomEvent('ov:boost:load'));
assert.strictEqual(app.dataset.chReady, '1', 'a second load event does not set it up twice');
assert.strictEqual(app.dataset.chReady, '1', 'a second load event does not set it up twice');
- const esc = new Event('keydown'); esc.key = 'Escape'; document.dispatchEvent(esc);
- assert.ok(!app.classList.contains('rail-open'), 'Escape closes the rail of the page on screen');
+ // The rail's search filters the conversations as you type, and a section with none left hides.
+ const input = document.querySelector('.ovk-sidebar-find input');
+ input.value = 'fleet'; input.dispatchEvent(new Event('input'));
+ const shown = [...document.querySelectorAll('.ovk-sidebar-item')].filter((a) => !a.hidden).map((a) => a.textContent.trim());
+ assert.deepStrictEqual(shown, ['Fix the fleet card']);
+ const secs = [...document.querySelectorAll('.ovk-sidebar-sec')].map((x) => x.hidden);
+ assert.deepStrictEqual(secs, [false, true], 'Earlier has nothing left, so it hides');
});
});
done();
done();
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -35,7 +35,7 @@ const { stepKind } = require('../server/http/chat');
@@ -35,7 +35,7 @@ const { stepKind } = require('../server/http/chat');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
assert.ok(page.text.includes('Fit the home page to a 360 px phone'), 'their conversation is in the rail');
assert.ok(page.text.includes('Fit the home page to a 360 px phone'), 'their conversation is in the rail');
assert.ok(!page.text.includes('Someone else entirely'), 'another person’s is not');
assert.ok(!page.text.includes('Someone else entirely'), 'another person’s is not');
- assert.ok(page.text.includes('<p class="ch-group">Today</p>'), 'grouped by when it last moved');
+ assert.ok(page.text.includes('<h2 class="ovk-sidebar-h">Today</h2>'), 'grouped by when it last moved');
const theirs = await t.get(`/tasks/${THEIRS}`, { as: ada });
const theirs = await t.get(`/tasks/${THEIRS}`, { as: ada });
assert.notStrictEqual(theirs.status, 200, 'another person’s conversation does not open');
assert.notStrictEqual(theirs.status, 200, 'another person’s conversation does not open');
});
});