openvibers/openvibe.codes Commits
Empty states: tasks, keys, notes, machines and repositories use the shared kit (#417)
Changes
@@@ -15,6 +15,7 @@* Every write is a form post from this site (Origin checked), every read is the signed-in person's own.*/const express = require('express');++const kit = require('openvibe-shared/kit');const { send } = require('../render/layout');const { raw, esc } = require('../render/html');const { asyncRouter } = require('./router');@@@ -96,12 +97,15 @@ function createC4Routes(ctx) {// A page may add its own stylesheet (the guided setup's), so c4.css stays the size every page pays for.const css = ['css/c4.css', ...(Array.isArray(extra.css) ? extra.css : [])];const own = Array.isArray(extra.scripts) ? extra.scripts : [];++ // The kit's empty states are on the signed-in/tool pages; the server-rendered front door has none, and its++ // size budget counts every stylesheet.++ const styles = [...(extra.index ? [] : ['kit.css']), ...(Array.isArray(extra.styles) ? extra.styles : [])];if (viewer(req) && ctx.chatShell && !extra.index) {const here = '/' + (String(req.path || '/').split('/')[1] || '');-- return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css, scripts: ['js/c4.js', ...own], styles: extra.styles, sharedScripts: extra.sharedScripts, here });++ return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css, scripts: ['js/c4.js', ...own], styles, sharedScripts: extra.sharedScripts, here });}const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);-- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), body: raw(body), ...extra, css, scripts: [...(wantsScript ? ['js/c4.js'] : []), ...own] });++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), body: raw(body), ...extra, css, scripts: [...(wantsScript ? ['js/c4.js'] : []), ...own], styles });};const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;/**@@@ -443,9 +447,9 @@ ${showcase.faq({ items: [const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" data-ov-combo="/api/v1/me/repos" data-ov-combo-create="/api/v1/repos"></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><div data-ov-machines data-needs="worker" data-name="machine" data-auto-label="Auto" data-value="${esc(req.query.machine || settings?.default_machine || '')}"></div><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>--<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2>${waiting.length ? '<a href="/approvals">The inbox</a>' : ''}</div><p class="c4-sub">Approvals and questions</p>${waiting.length ? waiting.slice(0, 3).map((a) => `<a class="c4-task" href="/approvals"><span class="c4-status-dot c4-running" aria-label="Waiting"></span><span class="c4-task-title">${esc(a.tool || 'a tool')}</span><span class="c4-task-meta">${esc(String(a.summary || '').slice(0, 120))}</span></a>`).join('') : '<div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div>'}</section></div>++<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || kit.empty({ icon: 'task', title: 'Nothing is running', text: 'Start a task above and follow it here.', action: { href: '/#c4-prompt', label: 'Start a task', icon: 'plus' } })}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2>${waiting.length ? '<a href="/approvals">The inbox</a>' : ''}</div><p class="c4-sub">Approvals and questions</p>${waiting.length ? waiting.slice(0, 3).map((a) => `<a class="c4-task" href="/approvals"><span class="c4-status-dot c4-running" aria-label="Waiting"></span><span class="c4-task-title">${esc(a.tool || 'a tool')}</span><span class="c4-task-meta">${esc(String(a.summary || '').slice(0, 120))}</span></a>`).join('') : kit.empty({ icon: 'check', title: 'Nothing needs your attention', text: 'Approvals and questions from an agent appear here.', action: { href: '/tasks', label: 'View tasks', icon: 'list' } })}</section></div>${signedIn ? `<section class="c4-card c4-conductor-cta"><div class="c4-card-head"><h2>Your conductor</h2><a href="/conductor">${conductors.length ? 'Open' : 'Set it up'}</a></div><p class="c4-muted">One long session on the agent you choose, on your own machine, carrying the plan forward and handing the work to your other agents. Each message you send is its next turn${conductors.length ? `: ${conductors.length} running or waiting` : ''}.</p><p><a class="c4-button" href="/conductor">${conductors.length ? 'Open your conductor' : 'Start your conductor'}</a></p></section>` : ''}--<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>++<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || kit.empty({ icon: 'task', title: 'No tasks yet', text: 'Tell an agent what to do above; its work shows up here.', action: { href: '/#c4-prompt', label: 'Start a task', icon: 'plus' } })}</section>${signedIn && !mine.ok && mine.problem ? note(`Your machines could not be read, so the machine picker has only Auto. ${esc(mine.problem.detail || '')}`, 'bad') : ''}${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent', 'Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'], ['Your computer or ours', 'Pair your computer with one command. A hosted sandbox is coming soon.'], ['Proof you can inspect', 'See the changes, tests, screenshots and review before you approve.'], ['A cost you can follow', 'Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a, b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;if (!signedIn) return page(req, res, null, wrap(visitorHome()), { index: true, agent: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });@@@ -483,7 +487,7 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h['Done', filtered.filter(isDone)],["Didn't finish", filtered.filter((x) => x.state === 'failed')],];-- const taskList = filtered.length ? groups.filter(([, items]) => items.length).map(([label, items]) => `<section class="c4-card"><div class="c4-card-head"><h2>${label}</h2></div>${items.map(rowHtml).join('')}</section>`).join('') : `<div class="c4-card c4-emptyline">${q ? 'No matching tasks.' : 'No tasks yet.'} <a href="${q ? '/tasks' : '/#c4-prompt'}">${q ? 'Clear search' : 'Start a task'}</a></div>`;++ const taskList = filtered.length ? groups.filter(([, items]) => items.length).map(([label, items]) => `<section class="c4-card"><div class="c4-card-head"><h2>${label}</h2></div>${items.map(rowHtml).join('')}</section>`).join('') : `<div class="c4-card">${kit.empty({ icon: q ? 'search' : 'task', title: q ? 'No matching tasks' : 'No tasks yet', text: q ? 'Try another word, or clear the search.' : 'Tell an agent what to do and its work appears here.', action: q ? { href: '/tasks', label: 'Clear search', icon: 'close' } : { href: '/#c4-prompt', label: 'Start a task', icon: 'plus' } })}</div>`;const content = `${header('Tasks')}${viewer(req) ? `<form class="c4-search" method="get"><label for="task-search">Search your tasks</label><input id="task-search" name="q" value="${esc(q)}" placeholder="Search by task, project or agent"><button class="c4-button c4-button-light">Search</button></form>${taskList}` : signIn(req)}`;page(req, res, 'Tasks', wrap(content));});@@@ -625,7 +629,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductorsif (!viewer(req)) return page(req, res, 'Approvals', wrap(`${header('Approvals')}${signIn(req)}`));const rows = await approvals.inbox(req.viewer.subject);const cards = rows.map((a) => `<li class="c4-approval-item">${approvalCard(a, `/approvals/${encodeURIComponent(a.id)}`)}<p class="c4-hint"><a href="/tasks/${encodeURIComponent(a.task_id)}">Open the conductor</a></p></li>`).join('');-- const content = `${header('Approvals', 'What your conductors are waiting on.')}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Waiting for you</h2><span class="c4-muted">${rows.length} decision${rows.length === 1 ? '' : 's'}</span></div>${cards ? `<ul class="c4-approval-list">${cards}</ul>` : '<div class="c4-emptyline">Nothing needs your attention.</div>'}</section>`;++ const content = `${header('Approvals', 'What your conductors are waiting on.')}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Waiting for you</h2><span class="c4-muted">${rows.length} decision${rows.length === 1 ? '' : 's'}</span></div>${cards ? `<ul class="c4-approval-list">${cards}</ul>` : kit.empty({ icon: 'check', title: 'Nothing needs your attention', text: 'When a conductor asks for a decision it appears here.', action: { href: '/conductor', label: 'Open your conductor', icon: 'task' } })}</section>`;page(req, res, 'Approvals', wrap(content), { notificationsRealtime: true });});@@@ -669,7 +673,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductorsreturn `<section class="c4-card" id="fleet"><div class="c4-card-head"><h2>Your fleet</h2><span class="c4-muted">${esc(at)}</span></div><p class="c4-sub">What each agent on your machines has left. A machine reports it with its heartbeat; no key or account name leaves it.</p>${problem ? note(`The fleet could not be refreshed: ${esc(problem.detail || 'Bot did not answer')}. ${latest && latest.at ? 'The last reading is below.' : ''}`, 'bad') : ''}--${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="https://openvibe.my/#machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}++${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : kit.empty({ icon: 'computer', title: 'No machine has reported its agents yet', text: 'Pair a machine and its agents appear here while it is connected.', action: { href: 'https://openvibe.my/#machines', label: 'Pair a machine', icon: 'link' } })}</section>`;};@@@ -688,7 +692,7 @@ ${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptylineconst plans = (list.plans || []).map((p) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-skip" aria-hidden="true"></span><span class="c4-row-label">${esc(p.label || p.provider || 'a plan')}</span><span class="c4-row-note">on ${esc(p.machine || 'your machine')}</span>${pill('On your machine', 'skip')}</li>`).join('');return `<section class="c4-card" id="connections"><div class="c4-card-head"><h2>Your AI keys</h2><a class="c4-button" href="${YOUR_AI}">Add or manage keys \u2197</a></div><p class="c4-sub">One place for every OpenVibe site and agent: your tasks here use these first, with no fee.</p>--${own ? `<ul class="c4-rows">${own}</ul>` : `<div class="c4-emptyline">${mine ? 'No keys yet.' : 'Your keys could not be read just now.'} <a href="${YOUR_AI}">Add one on Your AI</a>: Anthropic, OpenAI, Google, DeepSeek and more.</div>`}++${own ? `<ul class="c4-rows">${own}</ul>` : mine ? kit.empty({ icon: 'key', title: 'No keys yet', text: 'Add one on Your AI: Anthropic, OpenAI, Google, DeepSeek and more.', action: { href: YOUR_AI, label: 'Add a key on Your AI', icon: 'key' } }) : kit.empty({ icon: 'key', title: 'Your keys could not be read just now', text: 'Try again in a minute, or open Your AI to check them.' })}${plans ? `<h3 class="c4-subhead">Plans on your machines</h3><ul class="c4-rows">${plans}</ul>` : ''}${problem ? note(`The keys saved in Codes could not be read: ${esc(problem.detail)}`, 'bad') : ''}${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows">${before}</ul><p class="c4-hint">These still work for your tasks. Add them on <a href="${YOUR_AI}">Your AI</a> to use them on every site, then remove them here.</p>` : ''}@@@ -745,7 +749,7 @@ ${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows"const rows = report.days.map((d) => `<tr><td>${esc(d.day)}</td><td>${d.runs}</td><td>${d.attempts}</td><td>${d.minutes} min</td><td>${d.tokens.toLocaleString('en-US')}</td></tr>`).join('');const content = `${header('Usage', `The last ${report.window} days of your tasks.`)}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Runs, minutes and tokens</h2></div><p class="c4-sub">Totals below</p>${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Usage by day"><table class="c4-table"><thead><tr><th scope="col">Day</th><th scope="col">Runs</th><th scope="col">Attempts</th><th scope="col">Minutes</th><th scope="col">Tokens</th></tr></thead><tbody>${rows}</tbody>--<tfoot><tr><th scope="row">Total</th><td>${report.totals.runs}</td><td>${report.totals.attempts}</td><td>${report.totals.minutes} min</td><td>${report.totals.tokens.toLocaleString('en-US')}</td></tr></tfoot></table></div>` : '<div class="c4-emptyline">No usage yet. <a href="/#c4-prompt">Start a task</a> and its runs, minutes and tokens appear here.</div>'}++<tfoot><tr><th scope="row">Total</th><td>${report.totals.runs}</td><td>${report.totals.attempts}</td><td>${report.totals.minutes} min</td><td>${report.totals.tokens.toLocaleString('en-US')}</td></tr></tfoot></table></div>` : kit.empty({ icon: 'chart', title: 'No usage yet', text: 'Start a task and its runs, minutes and tokens appear here.', action: { href: '/#c4-prompt', label: 'Start a task', icon: 'plus' } })}<p class="c4-hint">Runs are tasks, attempts are the tries inside them, minutes are the time an attempt was running, and tokens are what its session reported.</p></section><section class="c4-card"><div class="c4-card-head"><h2>Money</h2></div><p class="c4-muted">No prices are shown here. Money for model use belongs to OpenVibe.Billing, whose per-person usage read is not one Codes may make, so Codes shows what it knows — runs, minutes and tokens — and adds no ledger of its own.</p></section>` : signIn(req)}`;@@@ -11,6 +11,7 @@*/const express = require('express');const { esc } = require('../render/html');++const kit = require('openvibe-shared/kit');const { asyncRouter } = require('./router');function createGitRoutes(ctx) {@@@ -33,7 +34,7 @@ function createGitRoutes(ctx) {const error = String(req.query.error || '').slice(0, 240);return `${ok ? `<div class="notice c4-notice" role="status">${esc(ok)}</div>` : ''}${error ? `<div class="notice c4-notice c4-notice-bad" role="alert">${esc(error)}</div>` : ''}`;};-- const page = (req, res, body) => ctx.chatShell(req, res, { title: 'OpenVibe Git', body: wrap(body), css: ['css/c4.css'], here: '/git' });++ const page = (req, res, body) => ctx.chatShell(req, res, { title: 'OpenVibe Git', body: wrap(body), css: ['css/c4.css'], styles: ['kit.css'], here: '/git' });r.get('/git', async (req, res, next) => {if (!viewer(req)) return next();@@@ -51,7 +52,7 @@ function createGitRoutes(ctx) {const mine = await forge.repos(who);const list = mine.length? `<ul class="c4-rows">${mine.map((x) => `<li class="c4-row c4-row-flat"><span class="c4-row-label"><a href="${esc(x.html_url)}" rel="noopener">${esc(x.full_name)}</a></span><span class="c4-row-note">${x.private ? 'Private' : 'Public'}${x.description ? ` · ${esc(x.description.slice(0, 120))}` : ''}</span><a class="c4-opill" href="/?mode=code&repo=${encodeURIComponent(x.html_url)}">Work on it</a></li>`).join('')}</ul>`-- : '<p class="c4-emptyline">No repositories yet. Make your first one above.</p>';++ : kit.empty({ icon: 'code', title: 'No repositories yet', text: 'Make your first one below: it starts with a README on main and opens straight in a new chat.' });return page(req, res, `${head}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>A new repository</h2></div><p class="c4-sub">It starts with a README on <code class="c4-code-inline">main</code>, in your name (${esc(who.username)}), and opens straight in a new chat.</p>@@@ -29,6 +29,7 @@const express = require('express');const { asyncRouter } = require('./router');const { esc } = require('../render/html');++const ovkKit = require('openvibe-shared/kit');/** The adapter ids the Link reads and the services that write, and the names a person knows them by. */const SOURCE_NAMES = {@@@ -312,9 +313,8 @@ function createMemoryRoutes(ctx, kit) {const head = '<section class="c4-card"><div class="c4-card-head"><h2>Sources</h2><span class="c4-muted">'+ rows.length + (rows.length === 1 ? ' source' : ' sources') + '</span></div>';if (!rows.length) {-- return head + '<div class="c4-emptyline">Nothing here yet. On your computer, run '-- + '<code class="c4-code-inline">openvibe-link memory upload</code>: it shows what it found and asks before sending anything. '-- + '<a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/memory.md#the-upload">The Link docs</a></div></section>';++ return head + ovkKit.empty({ icon: 'file', title: 'Nothing here yet', text: 'On your computer, run '++ + 'openvibe-link memory upload: it shows what it found and asks before sending anything.' }) + '<p class="c4-hint"><a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/memory.md#the-upload">The Link docs</a></p></section>';}const sourceRow = (row) => {const fresh = row.seen && Date.now() - row.seen < SEVEN_DAYS;@@@ -350,7 +350,7 @@ function createMemoryRoutes(ctx, kit) {+ '<button class="c4-button c4-button-light">Search</button></form>';const body = notes.length? '<ul class="c4-rows">' + notes.map((item) => noteRow(item, merged.has(item.id))).join('') + '</ul>'-- : '<div class="c4-emptyline">' + (q || scope ? 'No note matches. <a href="/memory?tab=notes">Show all</a>' : 'No notes yet.') + '</div>';++ : q || scope ? ovkKit.empty({ icon: 'search', title: 'No note matches', text: 'Try another word, or show every note.', action: { href: '/memory?tab=notes', label: 'Show all notes', icon: 'list' } }) : ovkKit.empty({ icon: 'file', title: 'No notes yet', text: 'A note is one thing your tools remembered; it appears here after a task or an import.' });return '<section class="c4-card">'+ search + '<div class="c4-chips" aria-label="Scopes">' + chips + '</div>' + body+ '<p class="c4-hint">A note is one thing your tools remembered. Everything here is yours: edit it, archive it or delete it.</p></section>';@@@ -17,6 +17,7 @@ const express = require('express');const { serviceAuth, capabilities, http } = require('openvibe-contracts');const { asyncRouter } = require('./router');const { send } = require('../render/layout');++const kit = require('openvibe-shared/kit');const { html, raw, esc } = require('../render/html');const { parseArgs } = require('../../harness/job');@@@ -60,7 +61,7 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globaconst r = asyncRouter();const db = store.db;const form = express.urlencoded({ extended: false, limit: '32kb' });-- const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, scripts: ['js/run.js'], css: ['css/run.css'], ...o });++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, scripts: ['js/run.js'], css: ['css/run.css'], styles: ['kit.css'], ...o });const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;const sameOrigin = (req) => {const origin = req.get('origin');@@@ -148,7 +149,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`}function tasksHtml(rows) {-- if (!rows.length) return html`<p class="muted">No tasks yet.</p>`;++ if (!rows.length) return html`${raw(kit.empty({ icon: 'task', title: 'No tasks yet', text: 'Start one above and it appears here with its progress.', action: { href: '#run-form', label: 'Start a task', icon: 'plus' } }))}`;return html`<ul class="run-tasks">${rows.map((t) => html`<li><a href="/run/tasks/${t.id}"><span class="run-pill run-pill-${t.state}">${STATE_LABEL[t.state] || t.state}</span> <b>${repoName(t.repo)}</b> <span class="muted">${t.prompt.split('\n')[0].slice(0, 90)}</span></a></li>`)}</ul>`;}@@@ -22,6 +22,7 @@const { asyncRouter } = require('./router');const { esc } = require('../render/html');++const ovkKit = require('openvibe-shared/kit');const { readiness, optionAttrs, SETUP } = require('../domain/readiness');const { CONDUCTOR_AGENTS, AGENT_NAMES } = require('../domain/conductor-failover');@@@ -119,7 +120,7 @@ function createSetupRoutes(ctx, kit) {const list = (d.keys && d.keys.credentials) || [];const rows = list.map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${k.status === 'ok' ? ' c4-dot-ok' : k.status === 'failing' ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">…${esc(k.hint || '????')}</span>${k.status === 'ok' ? pill('Working', 'ok') : k.status === 'failing' ? pill('Not working', 'bad') : pill('Not tested', 'skip')}</li>`).join('');return `<p class="c4-hint">Only for agents and models you pay for by use (DeepSeek, OpenAI, Anthropic, Google and more). An agent signed in with a plan needs no key. Keys live on Your AI, sealed, and every OpenVibe site uses the same ones.</p>--${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline">No keys yet.</div>' : '<div class="c4-emptyline">Your keys could not be read just now.</div>'}++${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? ovkKit.empty({ icon: 'key', title: 'No keys yet', text: 'Keys are optional: add one on Your AI and the agents can use models paid by use.' }) : ovkKit.empty({ icon: 'key', title: 'Your keys could not be read just now', text: 'Try again in a minute, or open Your AI to check them.' })}<div class="c4-setup-actions"><a class="c4-button${rows ? ' c4-button-light' : ''}" href="${esc(YOUR_AI)}" rel="noopener">${rows ? 'Manage keys' : 'Add a key'} ↗</a></div>`;}@@@ -75,6 +75,14 @@ const { boot, check, done } = require('./helpers/boot');assert.ok(!task.text.includes('>https://github.com/'), 'never a bare URL');assert.ok(!task.text.includes('data-ch-stream'), 'a finished task does not stream');});++ await check('empty states are the shared kit, not bare lines', async () => {++ const newbie = t.network.addUser('newbie');++ const tasks = await t.get('/tasks', { as: newbie });++ assert.strictEqual(tasks.status, 200);++ assert.ok(tasks.text.includes('class="ovk-empty"'), 'the empty task list is a kit empty state');++ assert.ok(tasks.text.includes('No tasks yet'), 'and says what is empty');++ assert.ok(/class="ovk-btn[^"]*"[^>]*href="\/#c4-prompt"/.test(tasks.text), 'with Start a task as a kit button');++ });} finally { await t.close(); }done();})().catch((e) => { console.error(e); process.exit(1); });@@@ -133,7 +133,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';assert.ok(!page.text.includes('action="/agents/connections"'), 'no second place to add a key');t.network.state.selfKeys = [];const empty = await t.get('/agents', { as: ada });-- assert.ok(empty.text.includes('No keys yet.') && empty.text.includes('Add one on Your AI'));++ assert.ok(empty.text.includes('No keys yet') && empty.text.includes('Add one on Your AI'));});await check('Machine management leads to Link', async () => {
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
* Every write is a form post from this site (Origin checked), every read is the signed-in person's own.
* Every write is a form post from this site (Origin checked), every read is the signed-in person's own.
*/
*/
const express = require('express');
const express = require('express');
+const kit = require('openvibe-shared/kit');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { raw, esc } = require('../render/html');
const { raw, esc } = require('../render/html');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
@@ -96,12 +97,15 @@ function createC4Routes(ctx) {
@@ -96,12 +97,15 @@ function createC4Routes(ctx) {
// A page may add its own stylesheet (the guided setup's), so c4.css stays the size every page pays for.
// A page may add its own stylesheet (the guided setup's), so c4.css stays the size every page pays for.
const css = ['css/c4.css', ...(Array.isArray(extra.css) ? extra.css : [])];
const css = ['css/c4.css', ...(Array.isArray(extra.css) ? extra.css : [])];
const own = Array.isArray(extra.scripts) ? extra.scripts : [];
const own = Array.isArray(extra.scripts) ? extra.scripts : [];
+ // The kit's empty states are on the signed-in/tool pages; the server-rendered front door has none, and its
+ // size budget counts every stylesheet.
+ const styles = [...(extra.index ? [] : ['kit.css']), ...(Array.isArray(extra.styles) ? extra.styles : [])];
if (viewer(req) && ctx.chatShell && !extra.index) {
if (viewer(req) && ctx.chatShell && !extra.index) {
const here = '/' + (String(req.path || '/').split('/')[1] || '');
const here = '/' + (String(req.path || '/').split('/')[1] || '');
- return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css, scripts: ['js/c4.js', ...own], styles: extra.styles, sharedScripts: extra.sharedScripts, here });
+ return ctx.chatShell(req, res, { title: title || 'Codes', body: String(body), css, scripts: ['js/c4.js', ...own], styles, sharedScripts: extra.sharedScripts, here });
}
}
const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);
const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);
- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), body: raw(body), ...extra, css, scripts: [...(wantsScript ? ['js/c4.js'] : []), ...own] });
+ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), body: raw(body), ...extra, css, scripts: [...(wantsScript ? ['js/c4.js'] : []), ...own], styles });
};
};
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
/**
/**
@@ -443,9 +447,9 @@ ${showcase.faq({ items: [
@@ -443,9 +447,9 @@ ${showcase.faq({ items: [
const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');
const saved = repos.map((repo) => `<a class="c4-chip" href="/?repo=${encodeURIComponent(repo.url)}#c4-prompt" data-c4-saved-repo="${esc(repo.url)}">${icon('<path d="M4 4h16v16l-8-4-8 4z"/>')}${esc(repo.full_name)}</a>`).join('');
const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}
const content = `${header(signedIn ? 'Home' : 'Every coding agent. One place.', signedIn ? '' : 'Your keys, your machines. Tell an agent what to do and follow the work from your phone.')}
<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" data-ov-combo="/api/v1/me/repos" data-ov-combo-create="/api/v1/repos"></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><div data-ov-machines data-needs="worker" data-name="machine" data-auto-label="Auto" data-value="${esc(req.query.machine || settings?.default_machine || '')}"></div><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>
<section class="c4-card c4-composer"><form method="post" action="/start"><label for="c4-prompt">What should an agent do?</label><textarea id="c4-prompt" name="prompt" placeholder="Describe the change, question or fix" required></textarea><div class="c4-controls"><label class="c4-field-repo">Project<input type="url" name="repo" value="${esc(String(req.query.repo || ''))}" placeholder="Paste a repository URL" autocomplete="url" data-ov-combo="/api/v1/me/repos" data-ov-combo-create="/api/v1/repos"></label><label>Agent <select name="agent"><option value="">Auto</option>${agentOptions}</select></label><div data-ov-machines data-needs="worker" data-name="machine" data-auto-label="Auto" data-value="${esc(req.query.machine || settings?.default_machine || '')}"></div><label>Cost cap <select name="cap"><option>$1.00</option><option>$5.00</option></select></label><button class="c4-button" type="submit">Start a task</button></div><p class="c4-hint">Run places the task on one of your own machines; the machine and agent you pick are saved as your defaults.<span class="c4-hint-status" data-c4-repo-status role="status"></span></p>${saved ? `<div class="c4-chips c4-saved" aria-label="Saved repositories">${saved}</div>` : ''}</form></section>
-<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks running yet. <a href="/#c4-prompt">Start a task</a></div>'}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2>${waiting.length ? '<a href="/approvals">The inbox</a>' : ''}</div><p class="c4-sub">Approvals and questions</p>${waiting.length ? waiting.slice(0, 3).map((a) => `<a class="c4-task" href="/approvals"><span class="c4-status-dot c4-running" aria-label="Waiting"></span><span class="c4-task-title">${esc(a.tool || 'a tool')}</span><span class="c4-task-meta">${esc(String(a.summary || '').slice(0, 120))}</span></a>`).join('') : '<div class="c4-emptyline">Nothing needs your attention. <a href="/tasks">View tasks</a></div>'}</section></div>
+<div class="c4-grid"><section class="c4-card"><div class="c4-card-head"><h2>Running</h2><a href="/tasks?state=running">All tasks</a></div>${rows.filter((x) => !['done', 'succeeded', 'failed', 'cancelled'].includes(x.state)).slice(0, 4).map(rowHtml).join('') || kit.empty({ icon: 'task', title: 'Nothing is running', text: 'Start a task above and follow it here.', action: { href: '/#c4-prompt', label: 'Start a task', icon: 'plus' } })}</section><section class="c4-card"><div class="c4-card-head"><h2>Needs you</h2>${waiting.length ? '<a href="/approvals">The inbox</a>' : ''}</div><p class="c4-sub">Approvals and questions</p>${waiting.length ? waiting.slice(0, 3).map((a) => `<a class="c4-task" href="/approvals"><span class="c4-status-dot c4-running" aria-label="Waiting"></span><span class="c4-task-title">${esc(a.tool || 'a tool')}</span><span class="c4-task-meta">${esc(String(a.summary || '').slice(0, 120))}</span></a>`).join('') : kit.empty({ icon: 'check', title: 'Nothing needs your attention', text: 'Approvals and questions from an agent appear here.', action: { href: '/tasks', label: 'View tasks', icon: 'list' } })}</section></div>
${signedIn ? `<section class="c4-card c4-conductor-cta"><div class="c4-card-head"><h2>Your conductor</h2><a href="/conductor">${conductors.length ? 'Open' : 'Set it up'}</a></div><p class="c4-muted">One long session on the agent you choose, on your own machine, carrying the plan forward and handing the work to your other agents. Each message you send is its next turn${conductors.length ? `: ${conductors.length} running or waiting` : ''}.</p><p><a class="c4-button" href="/conductor">${conductors.length ? 'Open your conductor' : 'Start your conductor'}</a></p></section>` : ''}
${signedIn ? `<section class="c4-card c4-conductor-cta"><div class="c4-card-head"><h2>Your conductor</h2><a href="/conductor">${conductors.length ? 'Open' : 'Set it up'}</a></div><p class="c4-muted">One long session on the agent you choose, on your own machine, carrying the plan forward and handing the work to your other agents. Each message you send is its next turn${conductors.length ? `: ${conductors.length} running or waiting` : ''}.</p><p><a class="c4-button" href="/conductor">${conductors.length ? 'Open your conductor' : 'Start your conductor'}</a></p></section>` : ''}
-<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>
+<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || kit.empty({ icon: 'task', title: 'No tasks yet', text: 'Tell an agent what to do above; its work shows up here.', action: { href: '/#c4-prompt', label: 'Start a task', icon: 'plus' } })}</section>
${signedIn && !mine.ok && mine.problem ? note(`Your machines could not be read, so the machine picker has only Auto. ${esc(mine.problem.detail || '')}`, 'bad') : ''}
${signedIn && !mine.ok && mine.problem ? note(`Your machines could not be read, so the machine picker has only Auto. ${esc(mine.problem.detail || '')}`, 'bad') : ''}
${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent', 'Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'], ['Your computer or ours', 'Pair your computer with one command. A hosted sandbox is coming soon.'], ['Proof you can inspect', 'See the changes, tests, screenshots and review before you approve.'], ['A cost you can follow', 'Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a, b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;
${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent', 'Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'], ['Your computer or ours', 'Pair your computer with one command. A hosted sandbox is coming soon.'], ['Proof you can inspect', 'See the changes, tests, screenshots and review before you approve.'], ['A cost you can follow', 'Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a, b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;
if (!signedIn) return page(req, res, null, wrap(visitorHome()), { index: true, agent: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });
if (!signedIn) return page(req, res, null, wrap(visitorHome()), { index: true, agent: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });
@@ -483,7 +487,7 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
@@ -483,7 +487,7 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
['Done', filtered.filter(isDone)],
['Done', filtered.filter(isDone)],
["Didn't finish", filtered.filter((x) => x.state === 'failed')],
["Didn't finish", filtered.filter((x) => x.state === 'failed')],
];
];
- const taskList = filtered.length ? groups.filter(([, items]) => items.length).map(([label, items]) => `<section class="c4-card"><div class="c4-card-head"><h2>${label}</h2></div>${items.map(rowHtml).join('')}</section>`).join('') : `<div class="c4-card c4-emptyline">${q ? 'No matching tasks.' : 'No tasks yet.'} <a href="${q ? '/tasks' : '/#c4-prompt'}">${q ? 'Clear search' : 'Start a task'}</a></div>`;
+ const taskList = filtered.length ? groups.filter(([, items]) => items.length).map(([label, items]) => `<section class="c4-card"><div class="c4-card-head"><h2>${label}</h2></div>${items.map(rowHtml).join('')}</section>`).join('') : `<div class="c4-card">${kit.empty({ icon: q ? 'search' : 'task', title: q ? 'No matching tasks' : 'No tasks yet', text: q ? 'Try another word, or clear the search.' : 'Tell an agent what to do and its work appears here.', action: q ? { href: '/tasks', label: 'Clear search', icon: 'close' } : { href: '/#c4-prompt', label: 'Start a task', icon: 'plus' } })}</div>`;
const content = `${header('Tasks')}${viewer(req) ? `<form class="c4-search" method="get"><label for="task-search">Search your tasks</label><input id="task-search" name="q" value="${esc(q)}" placeholder="Search by task, project or agent"><button class="c4-button c4-button-light">Search</button></form>${taskList}` : signIn(req)}`;
const content = `${header('Tasks')}${viewer(req) ? `<form class="c4-search" method="get"><label for="task-search">Search your tasks</label><input id="task-search" name="q" value="${esc(q)}" placeholder="Search by task, project or agent"><button class="c4-button c4-button-light">Search</button></form>${taskList}` : signIn(req)}`;
page(req, res, 'Tasks', wrap(content));
page(req, res, 'Tasks', wrap(content));
});
});
@@ -625,7 +629,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
@@ -625,7 +629,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
if (!viewer(req)) return page(req, res, 'Approvals', wrap(`${header('Approvals')}${signIn(req)}`));
if (!viewer(req)) return page(req, res, 'Approvals', wrap(`${header('Approvals')}${signIn(req)}`));
const rows = await approvals.inbox(req.viewer.subject);
const rows = await approvals.inbox(req.viewer.subject);
const cards = rows.map((a) => `<li class="c4-approval-item">${approvalCard(a, `/approvals/${encodeURIComponent(a.id)}`)}<p class="c4-hint"><a href="/tasks/${encodeURIComponent(a.task_id)}">Open the conductor</a></p></li>`).join('');
const cards = rows.map((a) => `<li class="c4-approval-item">${approvalCard(a, `/approvals/${encodeURIComponent(a.id)}`)}<p class="c4-hint"><a href="/tasks/${encodeURIComponent(a.task_id)}">Open the conductor</a></p></li>`).join('');
- const content = `${header('Approvals', 'What your conductors are waiting on.')}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Waiting for you</h2><span class="c4-muted">${rows.length} decision${rows.length === 1 ? '' : 's'}</span></div>${cards ? `<ul class="c4-approval-list">${cards}</ul>` : '<div class="c4-emptyline">Nothing needs your attention.</div>'}</section>`;
+ const content = `${header('Approvals', 'What your conductors are waiting on.')}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Waiting for you</h2><span class="c4-muted">${rows.length} decision${rows.length === 1 ? '' : 's'}</span></div>${cards ? `<ul class="c4-approval-list">${cards}</ul>` : kit.empty({ icon: 'check', title: 'Nothing needs your attention', text: 'When a conductor asks for a decision it appears here.', action: { href: '/conductor', label: 'Open your conductor', icon: 'task' } })}</section>`;
page(req, res, 'Approvals', wrap(content), { notificationsRealtime: true });
page(req, res, 'Approvals', wrap(content), { notificationsRealtime: true });
});
});
@@ -669,7 +673,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
@@ -669,7 +673,7 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
return `<section class="c4-card" id="fleet"><div class="c4-card-head"><h2>Your fleet</h2><span class="c4-muted">${esc(at)}</span></div>
return `<section class="c4-card" id="fleet"><div class="c4-card-head"><h2>Your fleet</h2><span class="c4-muted">${esc(at)}</span></div>
<p class="c4-sub">What each agent on your machines has left. A machine reports it with its heartbeat; no key or account name leaves it.</p>
<p class="c4-sub">What each agent on your machines has left. A machine reports it with its heartbeat; no key or account name leaves it.</p>
${problem ? note(`The fleet could not be refreshed: ${esc(problem.detail || 'Bot did not answer')}. ${latest && latest.at ? 'The last reading is below.' : ''}`, 'bad') : ''}
${problem ? note(`The fleet could not be refreshed: ${esc(problem.detail || 'Bot did not answer')}. ${latest && latest.at ? 'The last reading is below.' : ''}`, 'bad') : ''}
-${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline">No machine has reported its agents yet. <a href="https://openvibe.my/#machines">Pair a machine</a>, and its agents appear here while it is connected.</div>`}
+${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : kit.empty({ icon: 'computer', title: 'No machine has reported its agents yet', text: 'Pair a machine and its agents appear here while it is connected.', action: { href: 'https://openvibe.my/#machines', label: 'Pair a machine', icon: 'link' } })}
</section>`;
</section>`;
};
};
@@ -688,7 +692,7 @@ ${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline
@@ -688,7 +692,7 @@ ${list ? `<ul class="c4-rows c4-fleet">${list}</ul>` : `<div class="c4-emptyline
const plans = (list.plans || []).map((p) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-skip" aria-hidden="true"></span><span class="c4-row-label">${esc(p.label || p.provider || 'a plan')}</span><span class="c4-row-note">on ${esc(p.machine || 'your machine')}</span>${pill('On your machine', 'skip')}</li>`).join('');
const plans = (list.plans || []).map((p) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot c4-dot-skip" aria-hidden="true"></span><span class="c4-row-label">${esc(p.label || p.provider || 'a plan')}</span><span class="c4-row-note">on ${esc(p.machine || 'your machine')}</span>${pill('On your machine', 'skip')}</li>`).join('');
return `<section class="c4-card" id="connections"><div class="c4-card-head"><h2>Your AI keys</h2><a class="c4-button" href="${YOUR_AI}">Add or manage keys \u2197</a></div>
return `<section class="c4-card" id="connections"><div class="c4-card-head"><h2>Your AI keys</h2><a class="c4-button" href="${YOUR_AI}">Add or manage keys \u2197</a></div>
<p class="c4-sub">One place for every OpenVibe site and agent: your tasks here use these first, with no fee.</p>
<p class="c4-sub">One place for every OpenVibe site and agent: your tasks here use these first, with no fee.</p>
-${own ? `<ul class="c4-rows">${own}</ul>` : `<div class="c4-emptyline">${mine ? 'No keys yet.' : 'Your keys could not be read just now.'} <a href="${YOUR_AI}">Add one on Your AI</a>: Anthropic, OpenAI, Google, DeepSeek and more.</div>`}
+${own ? `<ul class="c4-rows">${own}</ul>` : mine ? kit.empty({ icon: 'key', title: 'No keys yet', text: 'Add one on Your AI: Anthropic, OpenAI, Google, DeepSeek and more.', action: { href: YOUR_AI, label: 'Add a key on Your AI', icon: 'key' } }) : kit.empty({ icon: 'key', title: 'Your keys could not be read just now', text: 'Try again in a minute, or open Your AI to check them.' })}
${plans ? `<h3 class="c4-subhead">Plans on your machines</h3><ul class="c4-rows">${plans}</ul>` : ''}
${plans ? `<h3 class="c4-subhead">Plans on your machines</h3><ul class="c4-rows">${plans}</ul>` : ''}
${problem ? note(`The keys saved in Codes could not be read: ${esc(problem.detail)}`, 'bad') : ''}
${problem ? note(`The keys saved in Codes could not be read: ${esc(problem.detail)}`, 'bad') : ''}
${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows">${before}</ul><p class="c4-hint">These still work for your tasks. Add them on <a href="${YOUR_AI}">Your AI</a> to use them on every site, then remove them here.</p>` : ''}
${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows">${before}</ul><p class="c4-hint">These still work for your tasks. Add them on <a href="${YOUR_AI}">Your AI</a> to use them on every site, then remove them here.</p>` : ''}
@@ -745,7 +749,7 @@ ${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows"
@@ -745,7 +749,7 @@ ${before ? `<h3 class="c4-subhead">Saved in Codes before</h3><ul class="c4-rows"
const rows = report.days.map((d) => `<tr><td>${esc(d.day)}</td><td>${d.runs}</td><td>${d.attempts}</td><td>${d.minutes} min</td><td>${d.tokens.toLocaleString('en-US')}</td></tr>`).join('');
const rows = report.days.map((d) => `<tr><td>${esc(d.day)}</td><td>${d.runs}</td><td>${d.attempts}</td><td>${d.minutes} min</td><td>${d.tokens.toLocaleString('en-US')}</td></tr>`).join('');
const content = `${header('Usage', `The last ${report.window} days of your tasks.`)}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Runs, minutes and tokens</h2></div><p class="c4-sub">Totals below</p>
const content = `${header('Usage', `The last ${report.window} days of your tasks.`)}${viewer(req) ? `<section class="c4-card"><div class="c4-card-head"><h2>Runs, minutes and tokens</h2></div><p class="c4-sub">Totals below</p>
${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Usage by day"><table class="c4-table"><thead><tr><th scope="col">Day</th><th scope="col">Runs</th><th scope="col">Attempts</th><th scope="col">Minutes</th><th scope="col">Tokens</th></tr></thead><tbody>${rows}</tbody>
${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-label="Usage by day"><table class="c4-table"><thead><tr><th scope="col">Day</th><th scope="col">Runs</th><th scope="col">Attempts</th><th scope="col">Minutes</th><th scope="col">Tokens</th></tr></thead><tbody>${rows}</tbody>
-<tfoot><tr><th scope="row">Total</th><td>${report.totals.runs}</td><td>${report.totals.attempts}</td><td>${report.totals.minutes} min</td><td>${report.totals.tokens.toLocaleString('en-US')}</td></tr></tfoot></table></div>` : '<div class="c4-emptyline">No usage yet. <a href="/#c4-prompt">Start a task</a> and its runs, minutes and tokens appear here.</div>'}
+<tfoot><tr><th scope="row">Total</th><td>${report.totals.runs}</td><td>${report.totals.attempts}</td><td>${report.totals.minutes} min</td><td>${report.totals.tokens.toLocaleString('en-US')}</td></tr></tfoot></table></div>` : kit.empty({ icon: 'chart', title: 'No usage yet', text: 'Start a task and its runs, minutes and tokens appear here.', action: { href: '/#c4-prompt', label: 'Start a task', icon: 'plus' } })}
<p class="c4-hint">Runs are tasks, attempts are the tries inside them, minutes are the time an attempt was running, and tokens are what its session reported.</p>
<p class="c4-hint">Runs are tasks, attempts are the tries inside them, minutes are the time an attempt was running, and tokens are what its session reported.</p>
</section>
</section>
<section class="c4-card"><div class="c4-card-head"><h2>Money</h2></div><p class="c4-muted">No prices are shown here. Money for model use belongs to OpenVibe.Billing, whose per-person usage read is not one Codes may make, so Codes shows what it knows — runs, minutes and tokens — and adds no ledger of its own.</p></section>` : signIn(req)}`;
<section class="c4-card"><div class="c4-card-head"><h2>Money</h2></div><p class="c4-muted">No prices are shown here. Money for model use belongs to OpenVibe.Billing, whose per-person usage read is not one Codes may make, so Codes shows what it knows — runs, minutes and tokens — and adds no ledger of its own.</p></section>` : signIn(req)}`;
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
*/
*/
const express = require('express');
const express = require('express');
const { esc } = require('../render/html');
const { esc } = require('../render/html');
+const kit = require('openvibe-shared/kit');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
function createGitRoutes(ctx) {
function createGitRoutes(ctx) {
@@ -33,7 +34,7 @@ function createGitRoutes(ctx) {
@@ -33,7 +34,7 @@ function createGitRoutes(ctx) {
const error = String(req.query.error || '').slice(0, 240);
const error = String(req.query.error || '').slice(0, 240);
return `${ok ? `<div class="notice c4-notice" role="status">${esc(ok)}</div>` : ''}${error ? `<div class="notice c4-notice c4-notice-bad" role="alert">${esc(error)}</div>` : ''}`;
return `${ok ? `<div class="notice c4-notice" role="status">${esc(ok)}</div>` : ''}${error ? `<div class="notice c4-notice c4-notice-bad" role="alert">${esc(error)}</div>` : ''}`;
};
};
- const page = (req, res, body) => ctx.chatShell(req, res, { title: 'OpenVibe Git', body: wrap(body), css: ['css/c4.css'], here: '/git' });
+ const page = (req, res, body) => ctx.chatShell(req, res, { title: 'OpenVibe Git', body: wrap(body), css: ['css/c4.css'], styles: ['kit.css'], here: '/git' });
r.get('/git', async (req, res, next) => {
r.get('/git', async (req, res, next) => {
if (!viewer(req)) return next();
if (!viewer(req)) return next();
@@ -51,7 +52,7 @@ function createGitRoutes(ctx) {
@@ -51,7 +52,7 @@ function createGitRoutes(ctx) {
const mine = await forge.repos(who);
const mine = await forge.repos(who);
const list = mine.length
const list = mine.length
? `<ul class="c4-rows">${mine.map((x) => `<li class="c4-row c4-row-flat"><span class="c4-row-label"><a href="${esc(x.html_url)}" rel="noopener">${esc(x.full_name)}</a></span><span class="c4-row-note">${x.private ? 'Private' : 'Public'}${x.description ? ` · ${esc(x.description.slice(0, 120))}` : ''}</span><a class="c4-opill" href="/?mode=code&repo=${encodeURIComponent(x.html_url)}">Work on it</a></li>`).join('')}</ul>`
? `<ul class="c4-rows">${mine.map((x) => `<li class="c4-row c4-row-flat"><span class="c4-row-label"><a href="${esc(x.html_url)}" rel="noopener">${esc(x.full_name)}</a></span><span class="c4-row-note">${x.private ? 'Private' : 'Public'}${x.description ? ` · ${esc(x.description.slice(0, 120))}` : ''}</span><a class="c4-opill" href="/?mode=code&repo=${encodeURIComponent(x.html_url)}">Work on it</a></li>`).join('')}</ul>`
- : '<p class="c4-emptyline">No repositories yet. Make your first one above.</p>';
+ : kit.empty({ icon: 'code', title: 'No repositories yet', text: 'Make your first one below: it starts with a README on main and opens straight in a new chat.' });
return page(req, res, `${head}${flash(req)}
return page(req, res, `${head}${flash(req)}
<section class="c4-card"><div class="c4-card-head"><h2>A new repository</h2></div>
<section class="c4-card"><div class="c4-card-head"><h2>A new repository</h2></div>
<p class="c4-sub">It starts with a README on <code class="c4-code-inline">main</code>, in your name (${esc(who.username)}), and opens straight in a new chat.</p>
<p class="c4-sub">It starts with a README on <code class="c4-code-inline">main</code>, in your name (${esc(who.username)}), and opens straight in a new chat.</p>
@@ -29,6 +29,7 @@
@@ -29,6 +29,7 @@
const express = require('express');
const express = require('express');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { esc } = require('../render/html');
const { esc } = require('../render/html');
+const ovkKit = require('openvibe-shared/kit');
/** The adapter ids the Link reads and the services that write, and the names a person knows them by. */
/** The adapter ids the Link reads and the services that write, and the names a person knows them by. */
const SOURCE_NAMES = {
const SOURCE_NAMES = {
@@ -312,9 +313,8 @@ function createMemoryRoutes(ctx, kit) {
@@ -312,9 +313,8 @@ function createMemoryRoutes(ctx, kit) {
const head = '<section class="c4-card"><div class="c4-card-head"><h2>Sources</h2><span class="c4-muted">'
const head = '<section class="c4-card"><div class="c4-card-head"><h2>Sources</h2><span class="c4-muted">'
+ rows.length + (rows.length === 1 ? ' source' : ' sources') + '</span></div>';
+ rows.length + (rows.length === 1 ? ' source' : ' sources') + '</span></div>';
if (!rows.length) {
if (!rows.length) {
- return head + '<div class="c4-emptyline">Nothing here yet. On your computer, run '
- + '<code class="c4-code-inline">openvibe-link memory upload</code>: it shows what it found and asks before sending anything. '
- + '<a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/memory.md#the-upload">The Link docs</a></div></section>';
+ return head + ovkKit.empty({ icon: 'file', title: 'Nothing here yet', text: 'On your computer, run '
+ + 'openvibe-link memory upload: it shows what it found and asks before sending anything.' }) + '<p class="c4-hint"><a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/memory.md#the-upload">The Link docs</a></p></section>';
}
}
const sourceRow = (row) => {
const sourceRow = (row) => {
const fresh = row.seen && Date.now() - row.seen < SEVEN_DAYS;
const fresh = row.seen && Date.now() - row.seen < SEVEN_DAYS;
@@ -350,7 +350,7 @@ function createMemoryRoutes(ctx, kit) {
@@ -350,7 +350,7 @@ function createMemoryRoutes(ctx, kit) {
+ '<button class="c4-button c4-button-light">Search</button></form>';
+ '<button class="c4-button c4-button-light">Search</button></form>';
const body = notes.length
const body = notes.length
? '<ul class="c4-rows">' + notes.map((item) => noteRow(item, merged.has(item.id))).join('') + '</ul>'
? '<ul class="c4-rows">' + notes.map((item) => noteRow(item, merged.has(item.id))).join('') + '</ul>'
- : '<div class="c4-emptyline">' + (q || scope ? 'No note matches. <a href="/memory?tab=notes">Show all</a>' : 'No notes yet.') + '</div>';
+ : q || scope ? ovkKit.empty({ icon: 'search', title: 'No note matches', text: 'Try another word, or show every note.', action: { href: '/memory?tab=notes', label: 'Show all notes', icon: 'list' } }) : ovkKit.empty({ icon: 'file', title: 'No notes yet', text: 'A note is one thing your tools remembered; it appears here after a task or an import.' });
return '<section class="c4-card">'
return '<section class="c4-card">'
+ search + '<div class="c4-chips" aria-label="Scopes">' + chips + '</div>' + body
+ search + '<div class="c4-chips" aria-label="Scopes">' + chips + '</div>' + body
+ '<p class="c4-hint">A note is one thing your tools remembered. Everything here is yours: edit it, archive it or delete it.</p></section>';
+ '<p class="c4-hint">A note is one thing your tools remembered. Everything here is yours: edit it, archive it or delete it.</p></section>';
@@ -17,6 +17,7 @@ const express = require('express');
@@ -17,6 +17,7 @@ const express = require('express');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { serviceAuth, capabilities, http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
+const kit = require('openvibe-shared/kit');
const { html, raw, esc } = require('../render/html');
const { html, raw, esc } = require('../render/html');
const { parseArgs } = require('../../harness/job');
const { parseArgs } = require('../../harness/job');
@@ -60,7 +61,7 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globa
@@ -60,7 +61,7 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globa
const r = asyncRouter();
const r = asyncRouter();
const db = store.db;
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '32kb' });
const form = express.urlencoded({ extended: false, limit: '32kb' });
- const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, scripts: ['js/run.js'], css: ['css/run.css'], ...o });
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, scripts: ['js/run.js'], css: ['css/run.css'], styles: ['kit.css'], ...o });
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const sameOrigin = (req) => {
const sameOrigin = (req) => {
const origin = req.get('origin');
const origin = req.get('origin');
@@ -148,7 +149,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
@@ -148,7 +149,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
}
}
function tasksHtml(rows) {
function tasksHtml(rows) {
- if (!rows.length) return html`<p class="muted">No tasks yet.</p>`;
+ if (!rows.length) return html`${raw(kit.empty({ icon: 'task', title: 'No tasks yet', text: 'Start one above and it appears here with its progress.', action: { href: '#run-form', label: 'Start a task', icon: 'plus' } }))}`;
return html`<ul class="run-tasks">${rows.map((t) => html`<li><a href="/run/tasks/${t.id}"><span class="run-pill run-pill-${t.state}">${STATE_LABEL[t.state] || t.state}</span> <b>${repoName(t.repo)}</b> <span class="muted">${t.prompt.split('\n')[0].slice(0, 90)}</span></a></li>`)}</ul>`;
return html`<ul class="run-tasks">${rows.map((t) => html`<li><a href="/run/tasks/${t.id}"><span class="run-pill run-pill-${t.state}">${STATE_LABEL[t.state] || t.state}</span> <b>${repoName(t.repo)}</b> <span class="muted">${t.prompt.split('\n')[0].slice(0, 90)}</span></a></li>`)}</ul>`;
}
}
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { esc } = require('../render/html');
const { esc } = require('../render/html');
+const ovkKit = require('openvibe-shared/kit');
const { readiness, optionAttrs, SETUP } = require('../domain/readiness');
const { readiness, optionAttrs, SETUP } = require('../domain/readiness');
const { CONDUCTOR_AGENTS, AGENT_NAMES } = require('../domain/conductor-failover');
const { CONDUCTOR_AGENTS, AGENT_NAMES } = require('../domain/conductor-failover');
@@ -119,7 +120,7 @@ function createSetupRoutes(ctx, kit) {
@@ -119,7 +120,7 @@ function createSetupRoutes(ctx, kit) {
const list = (d.keys && d.keys.credentials) || [];
const list = (d.keys && d.keys.credentials) || [];
const rows = list.map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${k.status === 'ok' ? ' c4-dot-ok' : k.status === 'failing' ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">…${esc(k.hint || '????')}</span>${k.status === 'ok' ? pill('Working', 'ok') : k.status === 'failing' ? pill('Not working', 'bad') : pill('Not tested', 'skip')}</li>`).join('');
const rows = list.map((k) => `<li class="c4-row c4-row-flat"><span class="c4-row-dot${k.status === 'ok' ? ' c4-dot-ok' : k.status === 'failing' ? ' c4-dot-bad' : ''}" aria-hidden="true"></span><span class="c4-row-label">${esc(PROVIDER_NAMES[k.provider] || k.provider)}</span><span class="c4-row-note">…${esc(k.hint || '????')}</span>${k.status === 'ok' ? pill('Working', 'ok') : k.status === 'failing' ? pill('Not working', 'bad') : pill('Not tested', 'skip')}</li>`).join('');
return `<p class="c4-hint">Only for agents and models you pay for by use (DeepSeek, OpenAI, Anthropic, Google and more). An agent signed in with a plan needs no key. Keys live on Your AI, sealed, and every OpenVibe site uses the same ones.</p>
return `<p class="c4-hint">Only for agents and models you pay for by use (DeepSeek, OpenAI, Anthropic, Google and more). An agent signed in with a plan needs no key. Keys live on Your AI, sealed, and every OpenVibe site uses the same ones.</p>
-${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? '<div class="c4-emptyline">No keys yet.</div>' : '<div class="c4-emptyline">Your keys could not be read just now.</div>'}
+${rows ? `<ul class="c4-rows">${rows}</ul>` : d.keys ? ovkKit.empty({ icon: 'key', title: 'No keys yet', text: 'Keys are optional: add one on Your AI and the agents can use models paid by use.' }) : ovkKit.empty({ icon: 'key', title: 'Your keys could not be read just now', text: 'Try again in a minute, or open Your AI to check them.' })}
<div class="c4-setup-actions"><a class="c4-button${rows ? ' c4-button-light' : ''}" href="${esc(YOUR_AI)}" rel="noopener">${rows ? 'Manage keys' : 'Add a key'} ↗</a></div>`;
<div class="c4-setup-actions"><a class="c4-button${rows ? ' c4-button-light' : ''}" href="${esc(YOUR_AI)}" rel="noopener">${rows ? 'Manage keys' : 'Add a key'} ↗</a></div>`;
}
}
@@ -75,6 +75,14 @@ const { boot, check, done } = require('./helpers/boot');
@@ -75,6 +75,14 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok(!task.text.includes('>https://github.com/'), 'never a bare URL');
assert.ok(!task.text.includes('>https://github.com/'), 'never a bare URL');
assert.ok(!task.text.includes('data-ch-stream'), 'a finished task does not stream');
assert.ok(!task.text.includes('data-ch-stream'), 'a finished task does not stream');
});
});
+ await check('empty states are the shared kit, not bare lines', async () => {
+ const newbie = t.network.addUser('newbie');
+ const tasks = await t.get('/tasks', { as: newbie });
+ assert.strictEqual(tasks.status, 200);
+ assert.ok(tasks.text.includes('class="ovk-empty"'), 'the empty task list is a kit empty state');
+ assert.ok(tasks.text.includes('No tasks yet'), 'and says what is empty');
+ assert.ok(/class="ovk-btn[^"]*"[^>]*href="\/#c4-prompt"/.test(tasks.text), 'with Start a task as a kit button');
+ });
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -133,7 +133,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -133,7 +133,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
assert.ok(!page.text.includes('action="/agents/connections"'), 'no second place to add a key');
assert.ok(!page.text.includes('action="/agents/connections"'), 'no second place to add a key');
t.network.state.selfKeys = [];
t.network.state.selfKeys = [];
const empty = await t.get('/agents', { as: ada });
const empty = await t.get('/agents', { as: ada });
- assert.ok(empty.text.includes('No keys yet.') && empty.text.includes('Add one on Your AI'));
+ assert.ok(empty.text.includes('No keys yet') && empty.text.includes('Add one on Your AI'));
});
});
await check('Machine management leads to Link', async () => {
await check('Machine management leads to Link', async () => {