openvibers/openvibe.codes Commits

A new chat starts three ways: something new on openvibe.space, an OpenVibe site from a list, or any Git; ideas become a drifting rail (#218)

OP OpenVibers committed 12 days ago · ecec286a1b · 7 files, +502 −27

* A new chat starts three ways, and each says where it runs: something new (a website, game or app, handed to openvibe.space's builder), an OpenVibe site picked from a list, or your own code from any Git address. The idea buttons become a drifting rail that fills the box

* Your code: the repositories used last are a tap away again, as a sideways row in the composer

Changes

ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 0c2cada..a8136f2 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -142,7 +142,7 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-pick:focus-within{border-color:color-mix(in srgb,var(--ch-accent) 55%,var(--ch-line))}
.ch-pick .ch-caret{position:absolute;right:8px;pointer-events:none}
.ch-pick .ch-i:not(.ch-caret){width:15px;height:15px}
--.ch-pick-repo input{width:min(260px,52vw);border:0;background:transparent;color:inherit;font:inherit;font-size:13px}
++.ch-pick-repo input{width:min(240px,48vw);border:0;background:transparent;color:inherit;font:inherit;font-size:13px}
.ch-pick-repo input:focus{outline:none}
.ch-pick-file{cursor:pointer;padding:0 9px}
.ch-pick-file input{position:absolute;inset:0;opacity:0;cursor:pointer}
@@@ -167,9 +167,54 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-hello{display:grid;justify-items:center;gap:10px;text-align:center}
.ch-hello h1{margin:6px 0 0;font-size:clamp(24px,3.2vw,32px);letter-spacing:-.03em;line-height:1.15;text-wrap:balance}
.ch-hello p{margin:0;max-width:52ch;color:var(--ch-soft);line-height:1.55}
--.ch-ideas{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:10px}
--.ch-recent{margin-top:2px}
--.ch-recent .ch-chip{font-size:12.5px;padding:4px 10px;color:var(--ch-muted)}
++/* What you are making: three cards that belong to the composer's form. Each says where its work runs. */
++.ch-modes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;width:100%;margin:14px 0 0;padding:0;border:0;min-width:0}
++.ch-mode{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr);align-content:start;gap:2px 11px;padding:12px 14px;border:1px solid var(--ch-line);border-radius:16px;background:var(--ch-card);text-align:left;cursor:pointer;transition:border-color .15s,background-color .15s}
++.ch-mode:hover{border-color:color-mix(in srgb,var(--ch-accent) 35%,var(--ch-line))}
++.ch-mode input{position:absolute;inset:0;margin:0;opacity:0;cursor:pointer}
++.ch-mode-i{grid-row:span 2;display:grid;place-items:center;width:34px;height:34px;border-radius:11px;color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 12%,transparent)}
++.ch-mode-i .ch-i{width:18px;height:18px}
++.ch-mode b{font-size:14.5px;letter-spacing:-.01em;color:var(--ch-ink)}
++.ch-mode small{font-size:12.5px;line-height:1.4;color:var(--ch-muted)}
++.ch-mode:has(input:checked){border-color:color-mix(in srgb,var(--ch-accent) 70%,var(--ch-line));background:color-mix(in srgb,var(--ch-accent) 9%,var(--ch-card))}
++.ch-mode:has(input:checked) .ch-mode-i{color:var(--ch-on-accent);background:var(--ch-accent)}
++.ch-mode:has(input:focus-visible){outline:2px solid var(--ch-accent);outline-offset:2px}
++/* Each picker and line shows for the way it belongs to. */
++.ch:has([data-ch-mode][value="new"]:checked) :is(.ch-for-openvibe,.ch-for-code,.ch-for-agents),
++.ch:has([data-ch-mode][value="openvibe"]:checked) :is(.ch-for-new,.ch-for-code),
++.ch:has([data-ch-mode][value="code"]:checked) :is(.ch-for-new,.ch-for-openvibe){display:none}
++/* The repositories used last, one tap each: a sideways row that never wraps the composer taller. */
++.ch-recent{display:flex;gap:6px;margin:8px 0 0;overflow-x:auto;scrollbar-width:none}
++.ch-recent .ch-chip{flex:none;font-size:12.5px;padding:4px 10px;color:var(--ch-muted)}
++/* Where the work runs, under the bar. */
++.ch-where{margin:6px 2px 0;font-size:12.5px;line-height:1.45;color:var(--ch-soft)}
++.ch-where>span{display:flex;align-items:flex-start;gap:7px}
++.ch-where .ch-i{width:15px;height:15px;margin-top:1px;color:var(--ch-accent)}
++.ch-where b{color:var(--ch-ink);font-weight:650}
++.ch-where a{color:var(--ch-accent);font-weight:600}
++.ch-where-tail{color:var(--ch-muted)}
++.ch-on{display:inline-flex;align-items:center;gap:4px;font-style:normal;font-weight:600;color:var(--ch-ok)}
++.ch-on::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor}
++/* The ideas rail: it drifts sideways and stops under a pointer, the keyboard or its own pause. Two copies make the
++ loop seamless; the second is hidden from assistive tech and the keyboard. Calm motion (either preference) makes
++ it a still row that scrolls by hand. */
++.ch-spark{display:flex;align-items:center;gap:4px;margin:0 max(16px,calc((100% - var(--ch-col)) / 2)) 12px}
++.ch-spark-view{flex:1;min-width:0;overflow:hidden;-webkit-mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent);mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent)}
++.ch-spark-track{display:flex;width:max-content;animation:ch-drift var(--ch-drift,80s) linear infinite}
++.ch-spark-list{display:flex;gap:8px;margin:0;padding:2px 8px 2px 0;list-style:none}
++.ch-spark:hover .ch-spark-track,.ch-spark:focus-within .ch-spark-track,.ch-spark.is-paused .ch-spark-track{animation-play-state:paused}
++@keyframes ch-drift{to{translate:-50% 0}}
++.ch-idea{display:inline-flex;align-items:center;gap:8px;padding:8px 14px 8px 11px;border-radius:14px;border:1px solid var(--ch-line);background:var(--ch-card);color:var(--ch-soft);font:inherit;font-size:13px;white-space:nowrap;cursor:pointer;transition:color .15s,border-color .15s}
++.ch-idea:hover,.ch-idea:focus-visible{color:var(--ch-ink);border-color:color-mix(in srgb,var(--ch-accent) 45%,var(--ch-line))}
++.ch-idea .ch-i{width:15px;height:15px;color:var(--ch-accent)}
++.ch-spark-pause{width:32px;height:32px}
++.ch-spark-pause .ch-i{width:15px;height:15px}
++.ch-spark .ch-i-play,.ch-spark.is-paused .ch-i-pause{display:none}
++.ch-spark.is-paused .ch-i-play{display:block}
++@media (prefers-reduced-motion:reduce){.ch-spark-track{animation:none}.ch-spark-view{overflow-x:auto;scrollbar-width:none}.ch-spark-list[aria-hidden]{display:none}.ch-spark-pause{display:none}}
++html[data-ov-motion=reduced] .ch-spark-track{animation:none}
++html[data-ov-motion=reduced] .ch-spark-view{overflow-x:auto;scrollbar-width:none}
++html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=reduced] .ch-spark-pause{display:none}
/* The workspace's other pages (Agents, Machines, Usage, Settings…) in the conversation's place: they scroll there and
keep their own heading, so the bar above them is only the phone's way to the rail. */
@@@ -208,8 +253,18 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-thread{padding:18px 14px 16px}
.ch-bubble{max-width:88%}
.ch-composer{margin:0 8px calc(8px + env(safe-area-inset-bottom,0px));border-radius:20px}
-- .ch-pick-repo input{width:46vw}
-- .ch.is-empty .ch-thread{padding-top:56px}
++ .ch-pick-repo{flex:1 1 100%}
++ .ch-pick-repo input{width:auto;flex:1;min-width:0}
++ .ch.is-empty .ch-thread{padding-top:52px}
++ .ch-hello{gap:8px}
++ .ch-hello h1{font-size:23px}
++ .ch-hello p{font-size:14px}
++ .ch-modes{grid-template-columns:minmax(0,1fr);gap:6px;margin-top:8px}
++ .ch-mode{gap:1px 11px;padding:9px 12px;border-radius:14px}
++ .ch-mode-i{width:32px;height:32px;border-radius:10px;align-self:center}
++ .ch-mode b{font-size:14px}
++ .ch-mode small{font-size:12px;line-height:1.35}
++ .ch-spark{margin:0 8px 10px}
}
.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-side{transition:none}.ch-thread{scroll-behavior:auto}}
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 1a31643..d85dce1 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -18,6 +18,76 @@
document.addEventListener('ov:boost:load', init);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
++ // ── A new chat: the three ways to start, and the ideas rail ──
++ // The cards are radio buttons of the composer's form (CSS shows each one's pickers); here a card also swaps the
++ // placeholder and the rail's ideas, and an idea fills the box and picks the card it belongs to.
++ function startPage(app, form, text, grow) {
++ const rail = $('[data-ch-ideas]', app);
++ const track = rail && $('[data-ch-ideas-track]', rail);
++ const kind = $('[data-ch-kind]', form);
++ const modes = Array.from(app.querySelectorAll('[data-ch-mode]'));
++ let sets = {};
++ try { sets = rail ? JSON.parse(rail.dataset.chIdeasets || '{}') : {}; } catch { sets = {}; }
++ const PLACEHOLDER = { new: 'Describe your website, game or app', openvibe: 'Describe the change, question or fix', code: 'Describe the change, question or fix' };
++ const ICON = (i) => (i.kind === 'game' ? 'game' : i.kind === 'app' ? 'app' : i.kind === 'website' ? 'web' : i.mode === 'openvibe' ? 'ov' : 'spark');
++ // The rail's speed follows its length: about 32 px a second, whatever the ideas.
++ const pace = () => { if (track) track.style.setProperty('--ch-drift', `${Math.max(30, Math.round(track.scrollWidth / 2 / 32))}s`); };
++ function card(i, copy) {
++ const li = document.createElement('li');
++ const b = document.createElement('button');
++ b.type = 'button'; b.className = 'ch-idea';
++ if (copy) b.tabIndex = -1;
++ b.dataset.chIdea = i.text; b.dataset.chIdeaMode = i.mode;
++ if (i.kind) b.dataset.chIdeaKind = i.kind;
++ const svg = icons.get(ICON(i));
++ if (svg) { const c = svg.cloneNode(true); c.removeAttribute('data-icon'); b.appendChild(c); }
++ const span = document.createElement('span'); span.textContent = i.text; b.appendChild(span);
++ li.appendChild(b);
++ return li;
++ }
++ // The icons come from the page (a <template> of the rail's five); this script draws none of its own.
++ const icons = new Map();
++ const tpl = rail && $('template[data-ch-idea-icons]', rail);
++ if (tpl) tpl.content.querySelectorAll('svg[data-icon]').forEach((svg) => icons.set(svg.dataset.icon, svg));
++ function fill(mode) {
++ if (!track || !sets[mode]) return;
++ const lists = track.querySelectorAll('.ch-spark-list');
++ lists.forEach((ul, n) => { ul.textContent = ''; sets[mode].forEach((i) => ul.appendChild(card(i, n > 0))); });
++ pace();
++ }
++ function choose(mode, { focus = false } = {}) {
++ const radio = modes.find((r) => r.value === mode);
++ if (radio && !radio.checked) radio.checked = true;
++ if (text && PLACEHOLDER[mode]) text.placeholder = PLACEHOLDER[mode];
++ if (rail && rail.dataset.chShown !== mode) { rail.dataset.chShown = mode; fill(mode); }
++ if (focus && text) text.focus();
++ }
++ modes.forEach((r) => r.addEventListener('change', () => { if (r.checked) choose(r.value, { focus: true }); }));
++ const current = modes.find((r) => r.checked);
++ if (rail && current) rail.dataset.chShown = current.value;
++ pace();
++ if (rail) {
++ rail.addEventListener('click', (e) => {
++ const b = e.target.closest('[data-ch-idea]');
++ if (!b) return;
++ choose(b.dataset.chIdeaMode);
++ if (kind && b.dataset.chIdeaKind) kind.value = b.dataset.chIdeaKind;
++ text.value = b.dataset.chIdea;
++ grow();
++ text.focus();
++ // The words are the person's to change: the caret goes to the end.
++ text.setSelectionRange(text.value.length, text.value.length);
++ });
++ const pause = $('[data-ch-ideas-pause]', rail);
++ if (pause) pause.addEventListener('click', () => {
++ const paused = rail.classList.toggle('is-paused');
++ pause.setAttribute('aria-pressed', String(paused));
++ pause.setAttribute('aria-label', paused ? 'Play the ideas' : 'Pause the ideas');
++ pause.title = pause.getAttribute('aria-label');
++ });
++ }
++ }
++
function init() {
const app = document.querySelector('[data-chat]');
if (!app || app.dataset.chReady) return;
@@@ -66,7 +136,7 @@
e.preventDefault();
if (text.value.trim()) form.requestSubmit();
});
-- app.querySelectorAll('[data-ch-idea]').forEach((b) => b.addEventListener('click', () => { text.value = b.dataset.chIdea; grow(); text.focus(); }));
++ startPage(app, form, text, grow);
// The repository: a recent one is a tap away, and typing searches every repository the person can reach.
const repo = $('[data-ch-repo]', form);
app.querySelectorAll('[data-ch-saved-repo]').forEach((b) => b.addEventListener('click', () => { if (repo) { repo.value = b.dataset.chSavedRepo; text.focus(); } }));
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex 0b67f85..a9a8a9d 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -22,6 +22,7 @@ const { asyncRouter } = require('./router');
const { sessionLogOf } = require('../domain/run-tasks');
const models = require('../data/models');
const kit = require('openvibe-shared/kit');
++const start = require('./start');
const LIVE = ['queued', 'placed', 'running'];
const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
@@@ -57,6 +58,12 @@ const P = {
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
repo: '<path d="M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3Z"/><path d="M5 17a3 3 0 0 1 3-3h11"/>',
chevron: '<path d="m6 9 6 6 6-6"/>',
++ spark: '<path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9Z"/><path d="M19 15v4M17 17h4"/>',
++ ov: '<circle cx="8.5" cy="12" r="5"/><path d="m14 7 3.5 10L21 7"/>',
++ game: '<rect x="2.5" y="7" width="19" height="11" rx="5.5"/><path d="M7 11v3M5.5 12.5h3M15.5 12h.01M18 13.5h.01"/>',
++ app: '<rect x="4" y="4" width="7" height="7" rx="2"/><rect x="13" y="4" width="7" height="7" rx="2"/><rect x="4" y="13" width="7" height="7" rx="2"/><rect x="13" y="13" width="7" height="7" rx="2"/>',
++ pause: '<path d="M9 6v12M15 6v12"/>',
++ play: '<path d="M8 5.5v13l10-6.5Z"/>',
close: '<path d="m6 6 12 12M18 6 6 18"/>',
};
const ico = (name, cls = '') => `<svg class="ch-i${cls ? ` ${cls}` : ''}" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${P[name] || P.tool}</svg>`;
@@@ -120,7 +127,7 @@ const repoLabel = (repo) => { try { return new URL(String(repo)).pathname.split(
function createChatRoutes(ctx) {
const r = asyncRouter();
-- const { config, store, harnesses, tasks, conductor, approvals, userSettings, github } = ctx;
++ const { config, store, harnesses, tasks, conductor, approvals, userSettings, github, machines } = ctx;
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '32kb' });
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
@@@ -302,26 +309,26 @@ function createChatRoutes(ctx) {
}
// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────
-- function whoPicker(selected, { label = 'Who works on it', auto = null } = {}) {
++ function whoPicker(selected, { label = 'Who works on it', auto = null, cls = '' } = {}) {
const all = models.choices(harnesses);
const groups = new Map();
for (const c of all.slice(1)) { if (!groups.has(c.group)) groups.set(c.group, []); groups.get(c.group).push(c); }
const opt = (c) => `<option value="${esc(c.value)}"${selected === c.value ? ' selected' : ''}${c.note ? ` title="${esc(c.note)}"` : ''}>${esc(c.label)}</option>`;
const first = auto ? `<option value="">${esc(auto)}</option>` : opt(all[0]);
-- return `<label class="ch-pick" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span>${ico('agents')}<select name="who" data-ch-who>${first}${[...groups].map(([g, list]) => list.length > 1 ? `<optgroup label="${esc(g)}">${list.map(opt).join('')}</optgroup>` : opt(list[0])).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
++ return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span>${ico('agents')}<select name="who" data-ch-who>${first}${[...groups].map(([g, list]) => list.length > 1 ? `<optgroup label="${esc(g)}">${list.map(opt).join('')}</optgroup>` : opt(list[0])).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
}
// The effort in plain words (the shared ladder underneath; each agent gets the nearest level it accepts).
const EFFORT = [['', 'Effort: auto'], ['low', 'Quick'], ['medium', 'Balanced'], ['high', 'Thorough'], ['max', 'Deepest']];
const effortWord = (v) => (EFFORT.find(([k]) => k === v) || [null, v])[1];
-- function effortPicker(selected = '') {
-- return `<label class="ch-pick" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort>${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
++ function effortPicker(selected = '', cls = '') {
++ return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort>${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
}
-- function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', hidden = '', note = '' }) {
-- return `<form class="ch-composer" method="post" action="${esc(action)}"${api ? ` data-ch-api="${esc(api)}"` : ''} data-ch-composer>${hidden}
++ function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', hidden = '', note = '', id = '', where = '' }) {
++ return `<form class="ch-composer"${id ? ` id="${esc(id)}"` : ''} method="post" action="${esc(action)}"${api ? ` data-ch-api="${esc(api)}"` : ''} data-ch-composer>${hidden}
<label class="ch-sr" for="ch-text">${esc(placeholder)}</label>
<textarea id="ch-text" name="text" rows="1" placeholder="${esc(placeholder)}" required data-ch-text></textarea>
<div class="ch-bar"><div class="ch-picks">${pickers}</div>${live && stopAction ? `<button class="ch-icon-btn ch-stop" formaction="${esc(stopAction)}" formnovalidate aria-label="Stop" title="Stop">${ico('stop')}</button>` : ''}<button class="ch-send" type="submit" aria-label="Send" title="Send">${ico('send')}</button></div>
--${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p class="ch-note" data-ch-status role="status"></p>'}
++${where}${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p class="ch-note" data-ch-status role="status"></p>'}
</form>`;
}
@@@ -366,20 +373,76 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
title, page: body, here, css, scripts, head: '',
});
-- // A new conversation: the greeting, the repository and the agent, and the composer.
++ /** The person's machines, for the line that says where the work runs: never more than 1.5 s, never fatal. */
++ async function machinesFor(req) {
++ // The current token only: a refresh here would rotate the session with no response to save it on.
++ const token = req.token || (req.viewer && req.viewer.token) || null;
++ if (!token || !machines || !machines.list) return null;
++ let timer;
++ const late = new Promise((resolve) => { timer = setTimeout(resolve, 1500, null); if (timer.unref) timer.unref(); });
++ const read = Promise.resolve().then(() => machines.list(token)).catch(() => null);
++ const out = await Promise.race([read, late]).finally(() => clearTimeout(timer));
++ return out && out.ok ? (out.machines || []).filter((m) => m && m.state !== 'revoked') : null;
++ }
++ /** Where an agent's work runs, in one line: the person's own computer (which one, or how to pair one). */
++ function whereAgents(list) {
++ const tail = '<span class="ch-where-tail">in a fresh copy of the code, with a pull request at the end</span>';
++ if (list == null) return `${ico('machines')}<span>Runs on your computer ${tail}</span>`;
++ if (!list.length) return `${ico('machines')}<span>Runs on your computer. <a href="/machines">Pair it first</a>: it takes a minute, and the task waits for it.</span>`;
++ const on = list.find((m) => m.state === 'online');
++ const m = on || list[0];
++ const name = esc(m.name || m.node_id || 'your computer');
++ return `${ico('machines')}<span>Runs on <b>${name}</b>${on ? ' <em class="ch-on">online</em>' : ' when it is on'} ${tail}</span>`;
++ }
++
++ // A new conversation: what you are making (something new, an OpenVibe project or your own code), the ideas rail,
++ // and the composer. Every choice is a form field, so it all works without JavaScript; public/js/chat.js only makes
++ // the rail move, fills the box from an idea and swaps the placeholder.
r.get('/', async (req, res, next) => {
if (!viewer(req)) return next();
const settings = await userSettings.get(req.viewer.subject).catch(() => ({}));
-- const repos = github ? await github.recent(req.viewer.subject, 6).catch(() => []) : [];
-- const repo = String(req.query.repo || (repos[0] && repos[0].url) || '');
++ const [repos, mine] = await Promise.all([
++ github ? github.recent(req.viewer.subject, 6).catch(() => []) : [],
++ machinesFor(req),
++ ]);
++ const asked = String(req.query.repo || '');
++ const repo = asked || (repos[0] && repos[0].url) || '';
++ const mode = start.MODES.includes(req.query.mode) ? req.query.mode : asked ? 'code' : repos.length ? 'code' : 'new';
++ const kind = start.KINDS[req.query.kind] ? req.query.kind : 'website';
++ const chosenProject = start.project(req.query.project) ? String(req.query.project) : 'OpenVibers/OpenVibe.Live';
const error = String(req.query.error || '').slice(0, 200);
const name = String(req.viewer.displayName || req.viewer.username || '').split(' ')[0];
-- const ideas = ['Fix the failing tests', 'Explain how this project is laid out', 'Make the home page faster on phones', 'Find and fix one bug'];
-- const pickers = `<label class="ch-pick ch-pick-repo" title="Repository"><span class="ch-sr">Repository</span>${ico('repo')}<input name="repo" type="url" value="${esc(repo)}" placeholder="Repository URL" list="ch-repos" autocomplete="url" required data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>${whoPicker(settings.default_agent ? `${settings.default_agent}|` : '')}${effortPicker('')}`;
-- const thread = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a colleague. Codes picks the agent, runs it on your machine, and shows you what changed.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}<div class="ch-ideas" aria-label="Ideas">${ideas.map((t) => `<button class="ch-chip" type="button" data-ch-idea="${esc(t)}">${esc(t)}</button>`).join('')}</div>${repos.length > 1 ? `<div class="ch-ideas ch-recent" aria-label="Your recent repositories">${repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('')}</div>` : ''}</div>`;
++
++ // The three ways to start: radio cards above the composer (they belong to its form by id).
++ const MODE_CARDS = [
++ ['new', 'spark', 'Something new', 'A website, game or app. OpenVibe AI builds it online: nothing to install.'],
++ ['openvibe', 'ov', 'An OpenVibe site', `Improve Live, Chat, Games or any of ${start.PROJECTS.length} projects.`],
++ ['code', 'repo', 'Your code', 'Any Git repository, private ones too. Agents work on your computer.'],
++ ];
++ const modes = `<fieldset class="ch-modes"><legend class="ch-sr">What are you making?</legend>${MODE_CARDS.map(([v, icon, title, line]) => `<label class="ch-mode"><input type="radio" name="mode" value="${v}" form="ch-start"${mode === v ? ' checked' : ''} data-ch-mode><span class="ch-mode-i">${ico(icon)}</span><b>${esc(title)}</b><small>${esc(line)}</small></label>`).join('')}</fieldset>`;
++
++ // The pickers: each shows for the way it belongs to (CSS reads the checked card).
++ const kindPick = `<label class="ch-pick ch-for-new" title="What it is"><span class="ch-sr">What it is</span>${ico('web')}<select name="kind" data-ch-kind>${Object.entries(start.KINDS).map(([v, k]) => `<option value="${v}"${kind === v ? ' selected' : ''}>${esc(k.label)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
++ const groups = [...new Set(start.PROJECTS.map((p) => p.group))];
++ const projectPick = `<label class="ch-pick ch-for-openvibe" title="Which OpenVibe project"><span class="ch-sr">Which OpenVibe project</span>${ico('ov')}<select name="project" data-ch-project>${groups.map((g) => `<optgroup label="${esc(g)}">${start.PROJECTS.filter((p) => p.group === g).map((p) => `<option value="${esc(p.repository)}"${chosenProject === p.repository ? ' selected' : ''}${p.line ? ` title="${esc(p.line)}"` : ''}>${esc(p.name)}${p.domain ? ` · ${esc(p.domain)}` : ''}</option>`).join('')}</optgroup>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
++ const repoPick = `<label class="ch-pick ch-pick-repo ch-for-code" title="Repository"><span class="ch-sr">Repository: owner/name or any Git URL</span>${ico('repo')}<input name="repo" type="text" inputmode="url" spellcheck="false" autocapitalize="off" value="${esc(repo)}" placeholder="owner/name or any Git URL" list="ch-repos" autocomplete="off" data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>`;
++ const pickers = kindPick + projectPick + repoPick + whoPicker(settings.default_agent ? `${settings.default_agent}|` : '', { cls: 'ch-for-agents' }) + effortPicker('', 'ch-for-agents');
++ // The repositories a person used last are a tap away (the box also searches every one they can reach).
++ const recent = repos.length > 1 ? `<div class="ch-recent ch-for-code" role="group" aria-label="Your recent repositories">${repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('')}</div>` : '';
++ const where = `${recent}<p class="ch-where"><span class="ch-for-new">${ico('web')}<span>Built on OpenVibe’s servers at <b>openvibe.space</b> <span class="ch-where-tail">and you get a live link; you check it before anything is built</span></span></span><span class="ch-for-agents">${whereAgents(mine)}</span></p>`;
++
++ // The ideas rail: it drifts sideways, stops under a pointer or the keyboard, and has its own pause. A press fills
++ // the box (and picks the way it belongs to), ready to be changed. The second copy is only there for the loop.
++ const sets = Object.fromEntries(start.MODES.map((m) => [m, start.ideasFor(m)]));
++ const kindIcon = (i) => (i.kind === 'game' ? 'game' : i.kind === 'app' ? 'app' : i.kind === 'website' ? 'web' : i.mode === 'openvibe' ? 'ov' : 'spark');
++ const card = (i, copy) => `<li><button class="ch-idea" type="button"${copy ? ' tabindex="-1"' : ''} data-ch-idea="${esc(i.text)}" data-ch-idea-mode="${esc(i.mode)}"${i.kind ? ` data-ch-idea-kind="${esc(i.kind)}"` : ''}>${ico(kindIcon(i))}<span>${esc(i.text)}</span></button></li>`;
++ const rail = (list) => `<ul class="ch-spark-list">${list.map((i) => card(i, false)).join('')}</ul><ul class="ch-spark-list" aria-hidden="true">${list.map((i) => card(i, true)).join('')}</ul>`;
++ const ideas = `<section class="ch-spark" aria-label="Ideas to start from" data-ch-ideas data-ch-ideasets="${esc(JSON.stringify(sets))}"><div class="ch-spark-view"><div class="ch-spark-track" data-ch-ideas-track>${rail(sets[mode])}</div></div><template data-ch-idea-icons>${['web', 'game', 'app', 'ov', 'spark'].map((n) => ico(n).replace('<svg ', `<svg data-icon="${n}" `)).join('')}</template><button class="ch-icon-btn ch-spark-pause" type="button" data-ch-ideas-pause aria-pressed="false" aria-label="Pause the ideas" title="Pause the ideas">${ico('pause', 'ch-i-pause')}${ico('play', 'ch-i-play')}</button></section>`;
++
++ const thread = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend. Pick what it is, and Codes sends it to the right place.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;
return frame(req, res, {
title: 'New chat', empty: true, head: '<h1 class="ch-title">New chat</h1>', thread,
-- composerHtml: composer({ action: '/chat', placeholder: 'Describe the change, question or fix', pickers }),
++ composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'Describe the change, question or fix', pickers, where }) + ideas,
});
});
@@@ -388,16 +451,33 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2F');
if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
const b = req.body || {};
-- const back = (msg) => res.redirect(303, `/?error=${encodeURIComponent(msg)}${b.repo ? `&repo=${encodeURIComponent(String(b.repo).slice(0, 300))}` : ''}`);
++ // The way it starts: a form from before the three cards (or the API's shape) names only a repo or a project.
++ const mode = start.MODES.includes(b.mode) ? b.mode : (b.project && !b.repo ? 'openvibe' : 'code');
++ // A typed address that carries a password never goes back into a URL (logs keep URLs).
++ const typed = String(b.repo || '');
++ const keep = [['mode', mode], ['repo', /:\/\/[^/]*@|^[^/]*:[^/]*@/.test(typed) ? '' : typed], ['project', b.project], ['kind', b.kind]].filter(([, v]) => v).map(([k, v]) => `&${k}=${encodeURIComponent(String(v).slice(0, 300))}`).join('');
++ const back = (msg) => res.redirect(303, `/?error=${encodeURIComponent(msg)}${keep}`);
const text = String(b.text || '').trim().slice(0, 16000);
-- if (!text) return back('Write what you want done first.');
++ if (!text) return back('Write what you want made or changed first.');
++ // Something new: OpenVibe AI builds it on openvibe.space. The words go there in the box; nothing is built
++ // until the person presses Build, so a handed-over description is never spent behind their back.
++ if (mode === 'new') return res.redirect(303, start.spaceHref({ kind: b.kind, text }));
++ let repoUrl = '';
++ if (mode === 'openvibe') {
++ const p = start.project(b.project);
++ if (!p) return back('Pick one of the OpenVibe projects from the list.');
++ repoUrl = p.url;
++ } else {
++ repoUrl = start.normalizeRepo(b.repo);
++ if (!repoUrl) return back(String(b.repo || '').trim() ? 'That is not a Git address Codes can clone: use https://…, git@host:owner/name or owner/name, with no password in it.' : 'Pick a repository (owner/name or any Git URL), choose an OpenVibe site, or start something new.');
++ }
const who = models.parse(b.who !== undefined ? b.who : (b.agent ? `${String(b.agent).trim()}|` : ''), harnesses);
if (!who) return back('Choose who works on it from the list.');
const agent = who.agent || '';
let args;
try {
const { parseArgs } = require('../../harness/job');
-- args = parseArgs(JSON.stringify({ repo: String(b.repo || '').trim(), prompt: text, agent: agent || null, ...(who.model ? { model: who.model } : {}), permission: 'edit', ...(b.effort ? { effort: String(b.effort) } : {}) }));
++ args = parseArgs(JSON.stringify({ repo: repoUrl, prompt: text, agent: agent || null, ...(who.model ? { model: who.model } : {}), permission: 'edit', ...(b.effort ? { effort: String(b.effort) } : {}) }));
} catch (e) { return back(`Check the repository: ${e.message}.`); }
let project = null;
try { project = (await tasks.latestProject?.(req.viewer.subject)) || await conductor.projectFor(req.viewer.token || null, req.viewer.subject); } catch { project = null; }
ddiff --git a/server/http/start.js b/server/http/start.js
nnew file mode 100644
iindex 0000000..2557f4e
---- /dev/null
++++ b/server/http/start.js
@@@ -0,0 +1,142 @@
++'use strict';
++/**
++ * What a new conversation starts from. The owner, 2026-11-01: "it should be built in to select the repositories that
++ * have to do with OpenVibe… without having to put a repository URL… consider supporting any Git… a person who doesn't
++ * know shit should be able to go on there and somewhat easily start vibe coding a website or a game… openvibe.space,
++ * openvibe.website integrated directly into codes… instead of these buttons, a scrolling thing that has different
++ * ideas, and you can click that… tailor it."
++ *
++ * Three ways to start, and each says where the work runs:
++ * new a website, a game or an app, described in words. OpenVibe AI builds it on openvibe.space (OpenVibe's
++ * servers: nothing to install) and the person gets a live link. Codes hands the words over, and nothing
++ * is built until they press Build there.
++ * openvibe one of OpenVibe's own projects, picked from a list (the pinned contracts' live services, plus the shared
++ * libraries): an agent works on it on the person's computer and opens a pull request.
++ * code the person's own repository: a recent one, one found by name, or any https Git URL. An ssh address or an
++ * owner/name is read as its https twin; a private repository clones with the computer's own git sign-in.
++ */
++const contracts = require('openvibe-contracts');
++
++const MODES = ['new', 'openvibe', 'code'];
++
++// What "Something new" can be, and the words Space's builder is handed so it knows which.
++const KINDS = {
++ website: { label: 'Website', lead: 'A website: ', words: /\b(site|website|page|portfolio|landing|blog)\b/i },
++ game: { label: 'Game', lead: 'A browser game: ', words: /\b(game|play|puzzle|quiz|arcade|platformer)\b/i },
++ app: { label: 'App or tool', lead: '', words: null },
++};
++
++// The ideas on the rail: each one fills the box, ready to be changed. Plain, specific, and nothing that copies a
++// named game or product.
++const IDEAS = {
++ website: [
++ 'A bakery site with today’s hours, the menu and how to order',
++ 'A portfolio for my photos, with a contact form',
++ 'A wedding page with the date, the place and an RSVP',
++ 'A one-page site for my band with our next shows',
++ 'A landing page for my app with a waitlist',
++ 'A links page for my socials, in my colours',
++ ],
++ game: [
++ 'A snake game with a leaderboard',
++ 'A trivia quiz about 90s cartoons',
++ 'A memory card game with emoji',
++ 'A typing race against the clock',
++ 'A tiny platformer with three levels',
++ 'A space shooter that gets faster every wave',
++ ],
++ app: [
++ 'A habit tracker with streaks',
++ 'A poll my stream chat can vote in',
++ 'A shared shopping list for my house',
++ 'A tip calculator that splits the bill',
++ 'A countdown to my next stream',
++ 'Flashcards for learning Japanese',
++ ],
++ code: [
++ 'Fix the failing tests',
++ 'Explain how this project is laid out',
++ 'Make the home page faster on phones',
++ 'Find and fix one bug',
++ 'Make every page work at 320 px',
++ 'Write tests for the part that has none',
++ 'Add a dark mode that follows the system',
++ 'Find a security hole and close it',
++ ],
++ openvibe: [
++ 'Add the feature you wish it had',
++ 'Make the home page faster on phones',
++ 'Fix the thing that annoyed you last time',
++ 'Make every page work at 320 px',
++ 'Explain how this project is laid out',
++ 'Find and fix one bug',
++ ],
++};
++
++/** The rail for a mode: "new" mixes the three kinds, one of each in turn. → [{ text, mode, kind }] */
++function ideasFor(mode) {
++ if (mode === 'new') {
++ const out = [];
++ for (let i = 0; i < 6; i++) for (const kind of ['website', 'game', 'app']) if (IDEAS[kind][i]) out.push({ text: IDEAS[kind][i], mode: 'new', kind });
++ return out;
++ }
++ return (IDEAS[mode] || IDEAS.code).map((text) => ({ text, mode, kind: null }));
++}
++
++// OpenVibe's own projects: every live service with a public site, and the shared libraries under them. GitHub is
++// where the pull requests open today; OpenVibe Git (git.openvibe.codes) mirrors every one of them.
++const LIBRARIES = [
++ { repository: 'OpenVibers/OpenVibe.Shared', name: 'Shared', line: 'The navbar, themes and pieces every site uses' },
++ { repository: 'OpenVibers/OpenVibe.SDK', name: 'SDK', line: 'The toolkit every service is built on' },
++ { repository: 'OpenVibers/OpenVibe.Contracts', name: 'Contracts', line: 'How the services talk to each other' },
++];
++const PROJECTS = (() => {
++ const seen = new Set();
++ const sites = contracts.services.manifests
++ .filter((m) => m && m.repository && /^OpenVibers\/[A-Za-z0-9._-]+$/.test(m.repository) && m.site && m.site.name && m.exposure && m.exposure.state === 'live')
++ .map((m) => ({ repository: m.repository, name: m.site.name, line: m.site.tagline || '', domain: String(m.publicOrigin || '').replace(/^https?:\/\//, ''), group: 'Sites' }))
++ .sort((a, b) => a.name.localeCompare(b.name));
++ return [...sites, ...LIBRARIES.map((l) => ({ ...l, domain: '', group: 'Libraries' }))]
++ .filter((p) => !seen.has(p.repository) && seen.add(p.repository))
++ .map((p) => ({ ...p, url: `https://github.com/${p.repository}` }));
++})();
++const project = (repository) => PROJECTS.find((p) => p.repository === String(repository || '')) || null;
++
++/**
++ * Any Git address the person types, as the https URL a task clones. "owner/name" is a GitHub repository; an ssh address
++ * (git@host:owner/name.git or ssh://git@host/owner/name) becomes its https twin; a bare host/path gains https://.
++ * → string, or '' when it cannot be a repository address (credentials in it, another scheme, no path).
++ */
++function normalizeRepo(raw) {
++ let s = String(raw || '').trim();
++ if (!s || s.length > 500 || /\s/.test(s)) return '';
++ let m;
++ if ((m = /^[A-Za-z0-9_.-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,}):(.+)$/.exec(s))) s = `https://${m[1]}/${m[2]}`;
++ else if ((m = /^ssh:\/\/[A-Za-z0-9_.-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})(?::\d+)?\/(.+)$/.exec(s))) s = `https://${m[1]}/${m[2]}`;
++ else if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(s) && !/^[A-Za-z0-9-]+\.[A-Za-z]{2,}\//.test(s)) s = `https://github.com/${s}`;
++ else if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s) && /^[A-Za-z0-9.-]+\.[A-Za-z]{2,}\/.+/.test(s)) s = `https://${s}`;
++ let u;
++ try { u = new URL(s); } catch { return ''; }
++ if (u.protocol !== 'https:' || u.username || u.password || u.pathname.split('/').filter(Boolean).length < 1) return '';
++ u.hash = '';
++ return u.toString().replace(/\/+$/, '');
++}
++
++const SPACE = String((contracts.services.get('space') || {}).publicOrigin || 'https://openvibe.space').replace(/\/+$/, '');
++const MAX_HANDOVER_URL = 6000; // a request line nginx and every browser take whole
++/**
++ * Where "Something new" goes: Space's new-space page with the words in the box, through Space's sign-in (which brings
++ * the person back with the query; Space's own sign-in page would drop it). Long words are cut until the address fits,
++ * and the person sees exactly what arrived before anything is built.
++ */
++function spaceHref({ kind, text, origin = SPACE }) {
++ const k = KINDS[kind] ? kind : 'website';
++ let words = String(text || '').trim();
++ const lead = KINDS[k].lead && !(KINDS[k].words && KINDS[k].words.test(words)) ? KINDS[k].lead : '';
++ const build = (w) => `${origin}/auth/login?next=${encodeURIComponent(`/spaces/new?prompt=${encodeURIComponent(`${lead}${w}`.slice(0, 4000))}&from=codes`)}`;
++ let href = build(words);
++ while (href.length > MAX_HANDOVER_URL && words.length > 40) { words = words.slice(0, Math.floor(words.length * 0.85)).trimEnd(); href = build(words); }
++ return href;
++}
++
++module.exports = { MODES, KINDS, IDEAS, ideasFor, PROJECTS, project, normalizeRepo, spaceHref, SPACE };
ddiff --git a/test/c4.test.js b/test/c4.test.js
iindex 48df7bb..9cdebfa 100644
---- a/test/c4.test.js
++++ b/test/c4.test.js
@@@ -46,7 +46,7 @@ const { boot, check, done } = require('./helpers/boot');
const home = await t.get('/', { as: ada });
assert.ok(!home.text.includes('Built for the way you work'));
assert.ok(!home.text.includes('Recently shipped on'));
-- assert.ok(home.text.includes('What should we build') && home.text.includes('Repository URL'), 'the greeting and the repository');
++ assert.ok(home.text.includes('What should we build') && home.text.includes('owner/name or any Git URL') && home.text.includes('name="mode"'), 'the greeting, the three ways to start and the repository');
assert.ok(home.text.includes('action="/chat"') && home.text.includes('data-ch-composer'), 'one composer that starts the conversation');
const id = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV';
await t.ctx.store.db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, error, created_at, updated_at)
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex 6b80994..2682a68 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -59,7 +59,7 @@ const { stepKind } = require('../server/http/chat');
await check('a new chat starts a task from the composer, and an empty one is refused in words', async () => {
const empty = await t.get('/chat', { as: ada, form: { text: ' ', repo: 'https://github.com/OpenVibers/OpenVibe.Live' } });
assert.strictEqual(empty.status, 303);
-- assert.match(empty.headers.get('location'), /^\/\?error=Write%20what%20you%20want%20done%20first/);
++ assert.match(empty.headers.get('location'), /^\/\?error=Write%20what%20you%20want%20made%20or%20changed%20first.*&repo=/, 'refused in words, and the repository is kept');
const made = await t.get('/chat', { as: ada, form: { text: 'Make the footer quieter', repo: 'https://github.com/OpenVibers/OpenVibe.Live', agent: '' } });
assert.strictEqual(made.status, 303, made.text.slice(0, 200));
const at = made.headers.get('location');
@@@ -68,6 +68,67 @@ const { stepKind } = require('../server/http/chat');
assert.ok(row && row.prompt === 'Make the footer quieter' && row.subject === ada.subject, 'the brief is the person&rsquo;s own task');
});
++ // The owner, 2026-11-01: pick an OpenVibe project without typing a URL, any Git, and "something new" for people
++ // who have never coded, handed to openvibe.space.
++ await check('a new chat offers three ways to start, each saying where it runs, and an ideas rail', async () => {
++ const home = await t.get('/', { as: ada });
++ const h = home.text;
++ ['new', 'openvibe', 'code'].forEach((v) => assert.match(h, new RegExp(`<input type="radio" name="mode" value="${v}" form="ch-start"`), `the ${v} card belongs to the form`));
++ assert.ok(h.includes('<form class="ch-composer" id="ch-start"'), 'the composer is the form the cards name');
++ assert.ok(!/name="repo"[^>]*required/.test(h), 'the repository is not required: something new needs none');
++ assert.match(h, /<select name="project"[^>]*>[\s\S]*?<option value="OpenVibers\/OpenVibe\.Live"[^>]*>Live · openvibe\.live<\/option>/, 'OpenVibe projects are a list');
++ assert.match(h, /<option value="OpenVibers\/OpenVibe\.Shared"/, 'the shared libraries too');
++ assert.match(h, /<select name="kind"[^>]*>[\s\S]*?>Website<\/option>[\s\S]*?>Game<\/option>/, 'something new is a website, a game or an app');
++ assert.match(h, /class="ch-where"[\s\S]*?openvibe\.space[\s\S]*?Runs on/, 'each way says where it runs');
++ assert.match(h, /<section class="ch-spark" aria-label="Ideas to start from"[^>]*data-ch-ideasets="/, 'the ideas rail, with every set for the script');
++ assert.match(h, /<ul class="ch-spark-list" aria-hidden="true">[\s\S]*?tabindex="-1"/, 'the loop copy is hidden from the keyboard and assistive tech');
++ assert.match(h, /data-ch-ideas-pause aria-pressed="false" aria-label="Pause the ideas"/, 'the moving rail has a pause');
++ const fresh = await t.get('/?mode=new&kind=game', { as: ada });
++ assert.match(fresh.text, /value="new" form="ch-start" checked/, 'the chosen way is kept');
++ assert.match(fresh.text, /<option value="game" selected>Game<\/option>/);
++ assert.match(fresh.text, /data-ch-idea-mode="new" data-ch-idea-kind="website"[\s\S]*?data-ch-idea-kind="game"[\s\S]*?data-ch-idea-kind="app"/, 'something new mixes the three kinds');
++ });
++
++ await check('something new goes to openvibe.space with the words in the box, and nothing is built here', async () => {
++ const before = await db.maybe('SELECT count(*)::int AS n FROM run_tasks WHERE subject = $1', [ada.subject]);
++ const r = await t.get('/chat', { as: ada, form: { mode: 'new', kind: 'game', text: 'Snake with a leaderboard & sound' } });
++ assert.strictEqual(r.status, 303);
++ const at = new URL(r.headers.get('location'));
++ assert.strictEqual(at.origin, 'https://openvibe.space');
++ assert.strictEqual(at.pathname, '/auth/login', 'through sign-in, which keeps the query');
++ const next = new URL(at.searchParams.get('next'), 'https://openvibe.space');
++ assert.strictEqual(next.pathname, '/spaces/new');
++ assert.strictEqual(next.searchParams.get('prompt'), 'A browser game: Snake with a leaderboard & sound');
++ assert.strictEqual(next.searchParams.get('from'), 'codes');
++ const site = await t.get('/chat', { as: ada, form: { mode: 'new', kind: 'website', text: 'My bakery website with hours' } });
++ const said = new URL(new URL(site.headers.get('location')).searchParams.get('next'), 'https://x').searchParams.get('prompt');
++ assert.strictEqual(said, 'My bakery website with hours', 'words that already say what it is are not led');
++ const long = await t.get('/chat', { as: ada, form: { mode: 'new', kind: 'app', text: 'ほ'.repeat(3000) } });
++ assert.ok(long.headers.get('location').length <= 6000, 'a long description is cut until the address fits');
++ const after = await db.maybe('SELECT count(*)::int AS n FROM run_tasks WHERE subject = $1', [ada.subject]);
++ assert.strictEqual(after.n, before.n, 'no task was made');
++ });
++
++ await check('an OpenVibe project is picked from the list, and any Git address is read as https', async () => {
++ const pick = await t.get('/chat', { as: ada, form: { mode: 'openvibe', project: 'OpenVibers/OpenVibe.Games', text: 'Add a daily challenge' } });
++ assert.match(pick.headers.get('location'), /^\/tasks\/tsk_/, pick.headers.get('location'));
++ const row = await db.maybe('SELECT repo FROM run_tasks WHERE id = $1', [pick.headers.get('location').split('/').pop()]);
++ assert.strictEqual(row.repo, 'https://github.com/OpenVibers/OpenVibe.Games');
++ const stranger = await t.get('/chat', { as: ada, form: { mode: 'openvibe', project: 'evil/repo', text: 'x y z' } });
++ assert.match(stranger.headers.get('location'), /error=Pick%20one%20of%20the%20OpenVibe%20projects/);
++ const ssh = await t.get('/chat', { as: ada, form: { mode: 'code', repo: '[email protected]:someone/thing.git', text: 'Fix the build' } });
++ const sshRow = await db.maybe('SELECT repo FROM run_tasks WHERE id = $1', [ssh.headers.get('location').split('/').pop()]);
++ assert.strictEqual(sshRow.repo, 'https://gitlab.com/someone/thing.git', 'an ssh address becomes its https twin');
++ const short = await t.get('/chat', { as: ada, form: { mode: 'code', repo: 'OpenVibers/OpenVibe.Chat', text: 'Fix the build' } });
++ const shortRow = await db.maybe('SELECT repo FROM run_tasks WHERE id = $1', [short.headers.get('location').split('/').pop()]);
++ assert.strictEqual(shortRow.repo, 'https://github.com/OpenVibers/OpenVibe.Chat', 'owner/name is a GitHub repository');
++ const secret = await t.get('/chat', { as: ada, form: { mode: 'code', repo: 'https://user:[email protected]/a/b', text: 'Fix it' } });
++ assert.match(secret.headers.get('location'), /error=That%20is%20not%20a%20Git%20address/, 'a password in the address is refused');
++ assert.ok(!/user(%3A|:)pass/.test(secret.headers.get('location')), 'and it is not written back into the address');
++ const none = await t.get('/chat', { as: ada, form: { mode: 'code', repo: '', text: 'Fix it' } });
++ assert.match(none.headers.get('location'), /error=Pick%20a%20repository/, 'no repository is asked for in words');
++ });
++
await check('the effort a person picks rides on the first attempt, and the thread says it in words', async () => {
const made = await t.get('/chat', { as: ada, form: { text: 'Think hard about the cache', repo: 'https://github.com/OpenVibers/OpenVibe.Live', effort: 'high' } });
assert.strictEqual(made.status, 303);
ddiff --git a/test/start.test.js b/test/start.test.js
nnew file mode 100644
iindex 0000000..30e0a87
---- /dev/null
++++ b/test/start.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++/**
++ * What a new conversation starts from (server/http/start.js): any Git address read as the https URL a task clones,
++ * the hand-over of "Something new" to openvibe.space, OpenVibe's own projects as a list, and the ideas rail.
++ */
++const assert = require('assert');
++const { check, done } = require('./helpers/boot');
++const start = require('../server/http/start');
++
++(async () => {
++ await check('any Git address becomes the https URL a task clones, and nothing with a password does', async () => {
++ const cases = [
++ ['https://github.com/OpenVibers/OpenVibe.Live', 'https://github.com/OpenVibers/OpenVibe.Live'],
++ ['https://github.com/OpenVibers/OpenVibe.Live/', 'https://github.com/OpenVibers/OpenVibe.Live'],
++ ['OpenVibers/OpenVibe.Chat', 'https://github.com/OpenVibers/OpenVibe.Chat'],
++ ['[email protected]:someone/thing.git', 'https://github.com/someone/thing.git'],
++ ['[email protected]:group/sub/project.git', 'https://gitlab.com/group/sub/project.git'],
++ ['ssh://[email protected]/someone/thing', 'https://codeberg.org/someone/thing'],
++ ['ssh://[email protected]:2222/team/app.git', 'https://git.example.com/team/app.git'],
++ ['git.openvibe.codes/OpenVibers/OpenVibe.Codes', 'https://git.openvibe.codes/OpenVibers/OpenVibe.Codes'],
++ ['https://git.openvibe.codes/OpenVibers/OpenVibe.Codes.git#readme', 'https://git.openvibe.codes/OpenVibers/OpenVibe.Codes.git'],
++ ];
++ cases.forEach(([raw, want]) => assert.strictEqual(start.normalizeRepo(raw), want, raw));
++ ['', ' ', 'http://github.com/a/b', 'ftp://example.com/a/b', 'https://user:[email protected]/a/b', 'https://[email protected]/a/b',
++ 'file:///etc/passwd', 'https://github.com', 'not a url', 'javascript:alert(1)', `https://x.com/${'a'.repeat(600)}`]
++ .forEach((raw) => assert.strictEqual(start.normalizeRepo(raw), '', `refused: ${raw.slice(0, 40)}`));
++ });
++
++ await check('something new is handed to openvibe.space through sign-in, led by what it is, and always fits', async () => {
++ const read = (href) => {
++ const u = new URL(href);
++ assert.strictEqual(u.origin, 'https://openvibe.space');
++ assert.strictEqual(u.pathname, '/auth/login');
++ const next = new URL(u.searchParams.get('next'), 'https://openvibe.space');
++ assert.strictEqual(next.pathname, '/spaces/new');
++ assert.strictEqual(next.searchParams.get('from'), 'codes');
++ return next.searchParams.get('prompt');
++ };
++ assert.strictEqual(read(start.spaceHref({ kind: 'game', text: 'Snake with a leaderboard' })), 'A browser game: Snake with a leaderboard');
++ assert.strictEqual(read(start.spaceHref({ kind: 'game', text: 'A puzzle about trains' })), 'A puzzle about trains', 'words that say it already are not led');
++ assert.strictEqual(read(start.spaceHref({ kind: 'website', text: 'Hours and a menu for my bakery' })), 'A website: Hours and a menu for my bakery');
++ assert.strictEqual(read(start.spaceHref({ kind: 'app', text: 'A habit tracker' })), 'A habit tracker');
++ assert.strictEqual(read(start.spaceHref({ kind: 'nope', text: 'Hours for my shop' })), 'A website: Hours for my shop', 'an unknown kind is a website');
++ const long = start.spaceHref({ kind: 'app', text: 'ほ'.repeat(4000) });
++ assert.ok(long.length <= 6000, `the address fits (${long.length})`);
++ assert.ok(read(long).length > 100, 'and still carries the start of the words');
++ });
++
++ await check('OpenVibe projects are the live sites and the shared libraries, each cloned from GitHub', async () => {
++ assert.ok(start.PROJECTS.length >= 20, `a real list (${start.PROJECTS.length})`);
++ const live = start.project('OpenVibers/OpenVibe.Live');
++ assert.ok(live && live.name === 'Live' && live.domain === 'openvibe.live' && live.url === 'https://github.com/OpenVibers/OpenVibe.Live');
++ assert.ok(start.project('OpenVibers/OpenVibe.Shared'), 'the shared libraries are there too');
++ assert.strictEqual(start.project('someone/else'), null);
++ assert.strictEqual(new Set(start.PROJECTS.map((p) => p.repository)).size, start.PROJECTS.length, 'no project twice');
++ start.PROJECTS.forEach((p) => assert.match(p.url, /^https:\/\/github\.com\/OpenVibers\/[A-Za-z0-9._-]+$/, p.repository));
++ });
++
++ await check('the ideas rail: something new mixes the three kinds, every way has its own ideas', async () => {
++ const fresh = start.ideasFor('new');
++ assert.deepStrictEqual(fresh.slice(0, 3).map((i) => i.kind), ['website', 'game', 'app']);
++ assert.ok(fresh.every((i) => i.mode === 'new' && i.text));
++ start.MODES.forEach((m) => assert.ok(start.ideasFor(m).length >= 6, m));
++ assert.ok(start.ideasFor('code').every((i) => i.kind === null));
++ });
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });