openvibers/openvibe.codes Commits

Empty states: tasks, keys, notes, machines and repositories use the shared kit (#417)

OP OpenVibers committed 2 days ago · 10e859cdaa · 7 files, +34 −19

* Empty states: tasks, keys, notes, machines and repositories use the shared kit

The home's running/needs-you/recent panels, the task list and approvals inbox, the AI keys on Your
AI, the setup page's keys, Memory's notes and sources, Run's task list and Codes' Git repository list
are kit.empty now, with Start a task, View tasks, Add a key, Show all notes and Pair a machine as kit
buttons. kit.css joins the pages that draw them.

Checked: ov test test/c4.test.js test/forge.test.js (the empty task list renders the kit state and
its button; the Git page still answers 200).

* Empty states: load kit.css only off the front door; no shadowed kit

The setup route's context parameter is named kit, so the shared module is ovkKit there (kit.empty was
not a function). Memory draws kit.empty and now imports it. The front door keeps its three stylesheets
and its budget: kit.css joins the signed-in and tool pages that draw the empty states.

Checked: ov test test/setup.test.js test/perf-budget.test.js test/c4.test.js test/forge.test.js.

* Memory: the shared module is ovkKit, not the route context's kit

* Test: the empty keys state is the kit's words

Changes

ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex 3fc38d3..ed078c7 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -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 conductors
if (!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 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>
<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-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('');
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)}`;
ddiff --git a/server/http/git.js b/server/http/git.js
iindex d84619e..7e19f9f 100644
---- a/server/http/git.js
++++ b/server/http/git.js
@@@ -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&amp;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>
ddiff --git a/server/http/memory.js b/server/http/memory.js
iindex f31f09b..0e42602 100644
---- a/server/http/memory.js
++++ b/server/http/memory.js
@@@ -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>';
ddiff --git a/server/http/run.js b/server/http/run.js
iindex 1104d29..f8697f5 100644
---- a/server/http/run.js
++++ b/server/http/run.js
@@@ -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 = globa
const 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>`;
}
ddiff --git a/server/http/setup.js b/server/http/setup.js
iindex 9decd10..df00f44 100644
---- a/server/http/setup.js
++++ b/server/http/setup.js
@@@ -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>`;
}
ddiff --git a/test/c4.test.js b/test/c4.test.js
iindex be8dd8f..2c03e75 100644
---- a/test/c4.test.js
++++ b/test/c4.test.js
@@@ -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); });
ddiff --git a/test/c4b.test.js b/test/c4b.test.js
iindex c765184..04f881b 100644
---- a/test/c4b.test.js
++++ b/test/c4b.test.js
@@@ -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 () => {