openvibers/openvibe.codes Commits

The rail is the network's sidebar: Codes' places as vertical rows (L35) (#210)

OP OpenVibers committed 13 days ago · ef29516a9e · 11 files, +90 −90

* The rail is the network's sidebar: Codes' places as vertical rows (L35)

The owner: "improve the left sidebar… the navigation makes more sense in a vertical way… part of the openvibe.design
shared styling." The rail is openvibe-shared kit.sidebar now: New chat and search on top, then Conductor, Agents,
Machines, Memory, Usage, Approvals (with how many wait) and Settings as vertical rows with the page you are on
marked, then the conversations (Pinned, Today, Yesterday, This week, Earlier), which scroll on their own. On a
phone it is the kit's drawer (kit-web.js opens and closes it on the document, so it works after any page move);
chat.js keeps the side panel and the rail's search, filtering the sidebar's items and hiding an emptied section.

* openvibe-shared v4.60.0 (was v4.59.0): kit.sidebar

Changes

ddiff --git a/README.md b/README.md
iindex 02b2adc..bcb0349 100644
---- a/README.md
++++ b/README.md
@@@ -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).
ddiff --git a/STATUS.json b/STATUS.json
iindex 4b3836f..b3f858a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 01da0bc..65bd1b3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 4f46fa1..0f31ef8 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex cc7f172..0c2cada 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -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}}
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 8ec3330..1a31643 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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;
});
});
}
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex c924bcc..0b67f85 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -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 cla
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>
${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. */
ddiff --git a/test/c4b.test.js b/test/c4b.test.js
iindex f7b1f4c..1fa2ad4 100644
---- a/test/c4b.test.js
++++ b/test/c4b.test.js
@@@ -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&rsquo;s own words');
ddiff --git a/test/c4c.test.js b/test/c4c.test.js
iindex 4d58531..02933d9 100644
---- a/test/c4c.test.js
++++ b/test/c4c.test.js
@@@ -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&rsquo;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&rsquo;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&rsquo;s own header');
++ assert.ok(page.text.includes('data-ovk-sidebar-open aria-controls="ch-rail"'), 'the rail opens from the page&rsquo;s own header');
});
await check('the Frame&rsquo;s app bar: none in a signed-in person&rsquo;s app, the product&rsquo;s for a visitor', async () => {
ddiff --git a/test/chat-boost.test.js b/test/chat-boost.test.js
iindex dbc9e97..5d655b4 100644
---- a/test/chat-boost.test.js
++++ b/test/chat-boost.test.js
@@@ -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&rsquo;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); });
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex 5120467..6b80994 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -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&rsquo;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&rsquo;s conversation does not open');
});