openvibers/openvibe.network Commits
Reorganize account home and machine pairing (#810)
Changes
@@@ -714,3 +714,35 @@ html[data-ov-motion=off] .me-act:hover{transform: none}.mc-desk-live summary { display: inline-block; cursor: pointer; }.mc-desk-live[open] { display: grid; gap: 8px; }.mc-takeover { margin-top: 4px; }++++/* Account home organization and pairing visibility. */++@media (max-width:700px) { body { padding-bottom:112px; } }++@media (min-width:760px) { .td-grid { grid-template-columns:minmax(0,1fr); } }++.td-machines { display:grid; gap:10px; min-width:0; }++.td-machines p { color:var(--text-secondary); font-size:13px; }++.td-machines .ovk-btn { justify-self:start; min-height:44px; }++.setup-fold { margin:0 0 18px; padding:12px 16px; border:1px solid var(--border); border-radius:14px; background:var(--bg-secondary); }++.setup-fold[hidden] { display:none !important; }++.setup-fold summary { min-height:28px; font-weight:700; cursor:pointer; }++.setup-items { display:grid; gap:4px; padding-top:6px; }++.ny-actions { display:flex; flex-wrap:wrap; align-items:center; gap:8px; }++.ny-details { flex:1 1 100%; font-size:12px; color:var(--text-muted); }++.ny-details summary { width:max-content; min-height:36px; cursor:pointer; }++.ny-details p { margin:2px 0 8px; }++.ny-command { display:block; width:fit-content; max-width:100%; overflow-wrap:anywhere; padding:7px 10px; border-radius:8px; background:var(--bg-secondary); color:var(--text); font-size:12px; }++.ny-btn.is-quiet { background:transparent; color:var(--text-secondary); border:1px solid var(--border); }++.mc-steps { margin:0; padding-left:22px; display:grid; gap:5px; }++.mc-pair-code-row { display:flex; flex-wrap:wrap; align-items:center; gap:10px; }++.mc-pair-code-row .mc-code { margin:0; }++.mc-qr { width:176px; height:176px; border-radius:10px; background:#fff; }++body.my-needs-visible #ovdk-pill { display:none !important; }++.mc-action-menu { margin-top: 12px; }++.mc-action-menu summary { display:inline-flex; align-items:center; min-height:40px; padding:0 13px; border:1px solid var(--border); border-radius:10px; cursor:pointer; font-size:13px; font-weight:700; }++.mc-action-menu .mc-acts { margin-top:8px; }++.mc-role-chips { display:flex; flex-wrap:wrap; gap:6px; margin:8px 0; }++.mc-role-chip { display:inline-flex; align-items:center; min-height:28px; padding:2px 10px; border:1px solid var(--border); border-radius:999px; background:var(--bg-secondary); color:var(--text-secondary); font-size:11px; font-weight:650; }++.mc-role-chip.is-empty { opacity:.7; }++.me-acts { margin-bottom: 20px; }++.mc-count { margin:0 0 8px; color:var(--text-muted); font-size:12px; }++#sec-machines { scroll-margin-top: 90px; }++body.my-machines-open { padding-bottom: max(112px, calc(100vh - 90px)); }@@@ -14,6 +14,11 @@ OpenVibeAccountSwitcher.init({ apiBase: API, onSwitch: (account) => {window.location.reload();} });const _signedIn = !!(getAuthToken() || getAnonToken());++const needsYouSection = document.getElementById('needs-you');++if (needsYouSection && 'IntersectionObserver' in window) {++ new IntersectionObserver((entries) => document.body.classList.toggle('my-needs-visible', !!entries[0]?.isIntersecting))++ .observe(needsYouSection);++}loadUser();if (_signedIn) {loadToday();@@@ -155,6 +155,7 @@ async function anonFetch(path, opts = {}) {// Section switchingfunction showSection(id, btn) {++ document.body.classList.toggle('my-machines-open', id === 'machines');document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));document.querySelectorAll('.section-tabs button').forEach(b => b.classList.remove('active'));document.getElementById('sec-' + id).classList.add('active');@@@ -173,7 +174,7 @@ function openInitialSection() {const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/machines': 'machines', '/profile': 'profile', '/history': 'history' }[window.location.pathname];// #delete is the re-authentication return target: the delete step lives in the accounts section.const hash = window.location.hash.replace(/^#/, '');-- const routeSection = pathSection || (hash === 'delete' ? 'accounts' : hash) || 'profile';++ const routeSection = pathSection || (hash === 'delete' ? 'accounts' : hash === 'pair' ? 'machines' : hash) || 'profile';if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines','apps'].includes(routeSection)) {return showSection('anon');@@@ -190,6 +191,8 @@ function openInitialSection() {}if (document.getElementById('sec-' + routeSection)) {showSection(routeSection);++ if (routeSection === 'machines') document.getElementById('sec-machines')?.scrollIntoView?.({ block: 'start' });++ if (routeSection === 'machines' && hash === 'pair') setTimeout(() => mcPair(), 0);}}@@@ -41,9 +41,13 @@ function mcCard(m) {const dot = mcEl('span', `mc-dot ${m.online ? 'is-on' : ''}`, [], { 'aria-hidden': 'true' });const name = mcEl('h4', 'mc-name', [m.name || MC_KIND[m.kind] || 'A machine']);const state = mcEl('span', 'mc-state', [m.online ? 'Online' : (m.last_seen_at ? `Offline · seen ${mcAgo(m.last_seen_at)}` : 'Offline')]);-- const meta = mcEl('p', 'mc-meta', [[MC_KIND[m.kind], m.os, m.node_version ? `Node ${m.node_version}` : ''].filter(Boolean).join(' · ')]);++ const os = String(m.os || '').toLowerCase();++ const osIcon = os.includes('mac') || os.includes('darwin') ? 'fa-apple' : os.includes('linux') ? 'fa-linux' : 'fa-laptop';++ const meta = mcEl('p', 'mc-meta', [mcIcon(osIcon), ' ', [MC_KIND[m.kind], m.os, m.node_version ? `Node ${m.node_version}` : ''].filter(Boolean).join(' · ')]);const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);card.append(head);++ const roleChips = (m.roles || []).map((id) => (mcRoles || []).find((r) => r.id === id)).filter(Boolean);++ card.append(mcEl('div', 'mc-role-chips', roleChips.length ? roleChips.map((r) => mcEl('span', 'mc-role-chip', [r.name])) : [mcEl('span', 'mc-role-chip is-empty', ['No roles assigned'])]));(m.pools || []).forEach((pool) => card.append(mcPoolBlock(pool)));if (Array.isArray(m.running) && m.running.length) {@@@ -76,7 +80,10 @@ function mcCard(m) {const revoke = mcEl('button', 'btn btn-small mc-revoke', [mcIcon('fa-link-slash'), ' Revoke'], { type: 'button' });revoke.addEventListener('click', () => mcConfirmRevoke(acts, m));acts.append(rename, revoke);-- card.append(acts);++ const menu = mcEl('details', 'mc-action-menu');++ menu.append(mcEl('summary', '', ['Actions']));++ menu.append(acts);++ card.append(menu);return card;}@@@ -516,12 +523,19 @@ async function mcPair() {const out = await apiFetch('/api/v1/machines/pairings', { method: 'POST', body: '{}' });const p = (out && out.pairing) || {};const code = mcEl('p', 'mc-code', [String(p.code || '')]);++ code.setAttribute('aria-label', 'Pairing code');++ const copyCode = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-copy'), ' Copy code'], { type: 'button' });++ copyCode.addEventListener('click', () => navigator.clipboard.writeText(String(p.code || '')).then(() => mcSay('Code copied.')));++ const qr = p.qr_data ? mcEl('img', 'mc-qr', [], { src: p.qr_data, alt: 'Scan this QR code to read the pairing code' }) : null;const cmd = mcEl('pre', 'mc-cmd', [mcEl('code', '', [String(p.installer || '')])]);const copy = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-copy'), ' Copy the command'], { type: 'button' });copy.addEventListener('click', () => navigator.clipboard.writeText(String(p.installer || '')).then(() => mcSay('Copied. Run it on the machine.')));const until = p.expires_at ? mcEl('span', 'mc-until', [`The code works until ${new Date(p.expires_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`]) : null;const wait = mcEl('p', 'mc-wait', [mcEl('span', 'mc-wait-dot', [], { 'aria-hidden': 'true' }), 'Waiting for your machine…'], { role: 'status' });-- box.replaceChildren(mcEl('h4', '', ['Run this on the machine']), mcEl('p', 'muted', ['It installs OpenVibe Link and pairs it with your account. Or type the code into Link yourself:']), code, cmd, mcEl('div', 'mc-pair-acts', [copy, until]), wait);++ const pairContent = [mcEl('h4', '', ['Pair your computer']), mcEl('ol', 'mc-steps', [mcEl('li', '', ['Open OpenVibe Link on the computer.']), mcEl('li', '', ['Enter this code or scan the QR code.']), mcEl('li', '', ['Give it a name when it connects.'])]), mcEl('div', 'mc-pair-code-row', [code, copyCode])];++ if (qr) pairContent.push(qr);++ pairContent.push(mcEl('p', 'muted', ['Or install Link with this command:']), cmd, mcEl('div', 'mc-pair-acts', [copy, until]), wait);++ box.replaceChildren(...pairContent);box.hidden = false;clearInterval(mcWatch);const t0 = Date.now();@@@ -551,11 +565,13 @@ async function mcPair() {async function loadMachines() {const list = document.getElementById('mc-list');if (!list) return;++ const count = document.getElementById('mc-count');try {if (!mcRoles) mcRoles = ((await apiFetch('/api/v1/machines/roles').catch(() => null)) || {}).roles || [];const out = await apiFetch('/api/v1/machines');const machines = (out && out.machines) || [];machines.sort((a, b) => (b.online === true) - (a.online === true));++ if (count) count.textContent = `${machines.length} paired · ${machines.filter((m) => m.online).length} online now`;if (!machines.length) {list.replaceChildren(mcEl('div', 'mc-empty', [mcIcon('fa-laptop'), mcEl('p', '', [mcEl('b', '', ['No machines yet.']), ' Pair your computer and it can run your Codes jobs, host your desks and serve your own models, under your account.'])]));return;@@@ -566,6 +582,7 @@ async function loadMachines() {for (const m of machines) m.pools = (pools.pools || []).filter((p) => p.machines.some((x) => x.id === m.id));list.replaceChildren(...machines.map(mcCard), ...mcPoolPickers(pools.candidates || []));} catch (e) {++ if (count) count.textContent = '';list.replaceChildren(mcEl('p', 'muted', [e.status === 401 ? 'Sign in to see your machines.' : 'Your machines could not be read just now.']));}}@@@ -67,13 +67,16 @@ function renderReceipts(doc) {const rows = (doc && Array.isArray(doc.receipts) ? doc.receipts : []).map(rcRow).filter(Boolean);body.replaceChildren();if (!enabled) {-- body.append(rcEl('a', 'rc-quiet', ['Turn on Inbox AI on openvibe.email to see receipts here'], { href: RC_SETTING }));-- box.hidden = false;++ box.hidden = true;++ const fold = document.getElementById('setup-fold');++ if (fold) { fold.hidden = false; fold.querySelector('summary').textContent = 'Set up more (1)'; fold.querySelector('.setup-items').replaceChildren(rcEl('a', 'rc-quiet', ['Set up receipts with Inbox AI'], { href: RC_SETTING })); }return;}++ const fold = document.getElementById('setup-fold');++ if (fold) fold.hidden = true;if (!rows.length) {body.append(rcEl('p', 'rc-quiet', ['No receipts yet.']));-- box.hidden = false;++ box.hidden = true;return;}body.append(...rows.slice(0, RC_SHOWN));@@@ -325,7 +325,11 @@ function nyHref(item) {// An approval is never one tap from here: the hub shows a line, and a step may delete, spend or send. Review opens the// approval itself (what exactly runs, Approve or Decline). Try again stays one tap: it only repeats what already ran.function nyButton(item, action) {-- const label = action === 'approve' ? 'Review' : action === 'retry' ? 'Try again' : 'Open';++ const label = action === 'approve' ? (item.kind === 'approval' ? 'Approve' : 'Review') : action === 'decline' ? 'Decline' : action === 'retry' ? 'Try again' : 'Open';++ if (item.kind === 'approval' && item.service === 'network' && ['approve', 'decline'].includes(action)) {++ const b = document.createElement('button'); b.type = 'button'; b.className = `ny-btn${action === 'decline' ? ' is-quiet' : ''}`; b.textContent = label;++ b.addEventListener('click', () => nyDo(item, action, b)); return b;++ }if (action !== 'retry') {const href = nyHref(item);const a = document.createElement('a');@@@ -353,7 +357,7 @@ async function nyDo(item, action, button) {if (!item || !item.service || !item.id) return;const label = button.textContent;button.disabled = true;-- button.textContent = action === 'retry' ? 'Trying…' : 'Approving…';++ button.textContent = action === 'retry' ? 'Trying…' : action === 'decline' ? 'Declining…' : 'Approving…';try {await apiFetch(`/api/v1/me/actions/${encodeURIComponent(item.service)}/${encodeURIComponent(item.id)}/${action}`, { method: 'POST' });const row = button.closest('.ny-item');@@@ -363,7 +367,7 @@ async function nyDo(item, action, button) {const sub = document.getElementById('needs-you-sub');if (sub) sub.textContent = left ? `${left} more waiting` : '';if (!left && box) box.hidden = true;-- if (typeof toast === 'function') toast(action === 'retry' ? 'Trying it again' : 'Approved', 'success');++ if (typeof toast === 'function') toast(action === 'retry' ? 'Trying it again' : action === 'decline' ? 'Declined' : 'Approved', 'success');} catch (err) {button.disabled = false;button.textContent = label;@@@ -371,15 +375,18 @@ async function nyDo(item, action, button) {}}--function renderNeedsYou(doc) {++function renderNeedsYou(doc, confirmationDoc) {const box = document.getElementById('needs-you');const list = document.getElementById('needs-you-list');if (!box || !list) return;const items = doc && doc.buckets && Array.isArray(doc.buckets.waiting) ? doc.buckets.waiting : [];++ const confirmations = new Map(((confirmationDoc && confirmationDoc.confirmations) || []).map((c) => [c.id, c]));if (!items.length) { box.hidden = true; list.replaceChildren(); return; }const sub = document.getElementById('needs-you-sub');if (sub) sub.textContent = `${items.length} thing${items.length === 1 ? '' : 's'} waiting`;-- list.replaceChildren(...items.map((item) => {++ list.replaceChildren(...items.map((source) => {++ const confirmation = source.service === 'network' ? confirmations.get(source.id) : null;++ const item = confirmation ? { ...source, details: confirmation.details, resources: confirmation.resources, capability: confirmation.capability } : source;const row = document.createElement('article');row.className = 'ny-item';const icon = document.createElement('span');@@@ -391,9 +398,17 @@ function renderNeedsYou(doc) {const body = document.createElement('div');body.className = 'ny-body';const title = document.createElement('h3');-- title.textContent = item.title || 'Something needs you';++ const isApproval = item.kind === 'approval' && item.service === 'network';++ const details = item.details && typeof item.details === 'object' ? item.details : {};++ const host = details.machine_name || details.machine || details.target || item.resources?.find((r) => r && typeof r === 'object' && /machine|computer|host/i.test(String(r.type || '')) && r.name);++ const hostName = typeof host === 'string' ? host : host && (host.name || host.hostname);++ const commandText = typeof details.command === 'string' ? details.command.slice(0, 500) : '';++ const commandRequest = Boolean(commandText || details.action === 'command');++ title.textContent = isApproval ? `${commandRequest ? 'Run a command' : 'Approve a request'}${hostName ? ` on ${hostName}` : ''}` : (item.title || 'Something needs you');body.append(title);-- if (item.summary) {++ if (isApproval && commandText) body.append(Object.assign(document.createElement('code'), { className: 'ny-command', textContent: commandText }));++ if (isApproval && typeof details.reason === 'string') { const why = document.createElement('p'); why.className = 'ny-meta'; why.textContent = details.reason.slice(0, 300); body.append(why); }++ if (!isApproval && item.summary) {const summary = document.createElement('p');summary.textContent = item.summary;body.append(summary);@@@ -407,12 +422,39 @@ function renderNeedsYou(doc) {}const actions = Array.isArray(item.actions) ? item.actions : [];const primary = actions.includes('approve') ? 'approve' : actions.includes('retry') ? 'retry' : 'open';-- row.append(icon, body, nyButton(item, primary));++ const actionsWrap = document.createElement('div'); actionsWrap.className = 'ny-actions';++ if (isApproval) {++ const detailsToggle = document.createElement('details'); detailsToggle.className = 'ny-details';++ const summary = document.createElement('summary'); summary.textContent = 'Details'; detailsToggle.append(summary);++ const agentName = item.agent && !/^(agt|app|svc|usr)[_0-9A-HJKMNP-TV-Z]{20,}$/i.test(String(item.agent)) ? item.agent : '';++ const words = [agentName ? `Asked by ${agentName}` : '', item.summary || ''].filter(Boolean).join(' · ');++ if (words) { const p = document.createElement('p'); p.textContent = words; detailsToggle.append(p); }++ actionsWrap.append(detailsToggle, nyButton(item, 'approve'), nyButton(item, 'decline'));++ } else actionsWrap.append(nyButton(item, primary));++ row.append(icon, body, actionsWrap);return row;}));box.hidden = false;}++function openMachines(pair) {++ showSection('machines');++ document.getElementById('sec-machines')?.scrollIntoView({ behavior: 'smooth', block: 'start' });++ if (pair) mcPair();++}++++function renderMachineSummary(doc) {++ const box = document.getElementById('td-machines-summary');++ const state = document.getElementById('td-machines-state');++ const count = document.getElementById('td-machines-count');++ if (!box || !state) return;++ const machines = (doc && Array.isArray(doc.machines) ? doc.machines : []).filter((m) => !m.revoked_at);++ const online = machines.filter((m) => m.online === true).length;++ count.textContent = `${machines.length} paired`;++ state.textContent = machines.length ? `${online} online now${online ? ` · ${machines.filter((m) => m.online).map((m) => m.name).filter(Boolean).slice(0, 3).join(', ')}` : ''}` : 'Pair a computer to run your work on OpenVibe.';++ box.hidden = false;++}++async function loadToday() {if (!document.getElementById('today')) return;renderGreeting();@@@ -420,7 +462,7 @@ async function loadToday() {const quiet = (p) => p.catch(() => null);renderActions(null);renderStatus();-- const [acts, live, hist, coins, last, unread, sites, nodes, frame, tryNext] = await Promise.all([++ const [acts, live, hist, coins, last, unread, sites, nodes, frame, tryNext, confirmationsDoc, machineList] = await Promise.all([quiet(apiFetch('/api/v1/me/actions')),quiet(apiFetch('/api/v1/me/live-follows')),quiet(apiFetch('/api/history?limit=30')),@@@ -431,9 +473,11 @@ async function loadToday() {quiet(apiFetch('/api/v1/me/nodes')),quiet(apiFetch('/api/frame?host=openvibe.network')),quiet(apiFetch('/api/v1/me/try-next?limit=3')),++ quiet(apiFetch('/api/v1/confirmations?state=pending&limit=100')),++ quiet(apiFetch('/api/v1/machines')),]);globalThis.__tdUnread = unread;-- renderNeedsYou(acts);++ renderNeedsYou(acts, confirmationsDoc);renderActions(unread);renderNet(frame);renderNow(live, unread);@@@ -441,5 +485,11 @@ async function loadToday() {renderMoney(coins, last);renderSites(hist, sites, tryNext);renderStuff(live, hist, nodes);++ renderMachineSummary(machineList);document.getElementById('today').classList.add('td-ready');++ const machineSection = document.getElementById('sec-machines');++ if (machineSection && machineSection.classList.contains('active') && typeof location !== 'undefined' && typeof window !== 'undefined' && location.hash === '#machines') {++ const y = machineSection.getBoundingClientRect().top + window.scrollY - 90;++ window.scrollTo(0, Math.max(0, y));++ }}@@@ -58,27 +58,6 @@An approval says what kind of act it is in plain words (plan HO1: publishing for others to see, sendingsomething to someone, spending your money), which agent asked and in which thread; the agent's name isshown, never its id. Review opens the approval itself — the hub never approves with one tap. -->-- <section class="ny" id="needs-you" hidden aria-labelledby="needs-you-h">-- <div class="ny-h">-- <h2 id="needs-you-h"><span class="ny-dot" aria-hidden="true"></span>Needs you</h2>-- <span class="ny-sub" id="needs-you-sub"></span>-- </div>-- <div class="ny-list" id="needs-you-list"></div>-- </section>---- <!-- Receipts (plan OpenVibe_Seven_Domains_Plan R2 item 13): the latest five receipt-category summaries Inbox AI-- keeps on openvibe.email, each a link to its thread, with a counted fold for the rest and never a tab.-- my/receipts.js fills it from GET /api/v1/me/receipts; with Inbox AI off (or no mailbox) the one quiet line-- links to the switch, and no sample receipt is ever drawn. The card stays out until it has an answer. -->-- <section class="rc" id="receipts" hidden aria-labelledby="receipts-h">-- <div class="rc-h">-- <h2 id="receipts-h"><span class="rc-ic" aria-hidden="true"><i class="fa-solid fa-file-lines"></i></span>Receipts</h2>-- </div>-- <div class="rc-body" id="receipts-body"></div>-- </section>---- <!-- You (11-07 redesign; the owner: "front-face the best stuff we have to offer … awesome design"): who you are in-- your own colour, your balances, and one tap to what you do most, on every site. --><header class="me" id="page-header"><div class="me-top"><div class="me-who">@@@ -100,36 +79,50 @@<a class="me-tip" href="https://openvibe.tips"><i class="fa-solid fa-hand-holding-heart" aria-hidden="true"></i> Tip a creator</a></div></div>-- <nav class="me-acts" id="me-acts" aria-label="Quick actions"></nav></header>++ <section class="ny" id="needs-you" hidden aria-labelledby="needs-you-h">++ <div class="ny-h">++ <h2 id="needs-you-h"><span class="ny-dot" aria-hidden="true"></span>Needs you</h2>++ <span class="ny-sub" id="needs-you-sub"></span>++ </div>++ <div class="ny-list" id="needs-you-list"></div>++ </section>++ <nav class="me-acts" id="me-acts" aria-label="Quick actions"></nav>++<!-- You, today (plan L43): what is happening for you now, what you were doing, your money, your sites and yournumbers. my/today.js fills each block from its own API; a block with nothing in it stays out. --><div class="td" id="today">-- <section class="td-sec" id="td-now" hidden aria-labelledby="td-now-h">-- <div class="td-h"><h2 id="td-now-h"><span class="td-pulse" aria-hidden="true"></span>Live from people you follow</h2><span class="td-sub" id="td-now-sub"></span></div>-- <div class="td-rail" id="td-live"></div>-- </section>-- <div class="td-chips" id="td-chips" hidden></div><section class="td-sec" id="td-continue" hidden aria-labelledby="td-continue-h"><div class="td-h"><h2 id="td-continue-h">Pick up where you left off</h2><a class="td-more" href="#history" onclick="showSection('history');document.getElementById('settings').scrollIntoView({behavior:'smooth'});return false">History</a></div><div class="td-rail" id="td-continue-list"></div></section>-- <section class="td-sec" id="td-net" hidden aria-labelledby="td-net-h">-- <div class="td-h"><h2 id="td-net-h">On OpenVibe now</h2><a class="td-more" href="/sites">Every site</a></div>-- <div class="td-net" id="td-net-cards"></div>-- </section><div class="td-grid"><section class="td-box" id="td-sites" hidden aria-labelledby="td-sites-h"><div class="td-h"><h2 id="td-sites-h">Your sites</h2><a class="td-more" href="/sites">All sites</a></div><div class="td-sites" id="td-sites-grid"></div><div class="td-tries" id="td-try" hidden></div></section>++ <section class="td-box td-machines" id="td-machines-summary" hidden aria-labelledby="td-machines-h">++ <div class="td-h"><h2 id="td-machines-h">Your machines</h2><span class="td-sub" id="td-machines-count"></span></div>++ <p id="td-machines-state">Loading your machines…</p>++ <button class="ovk-btn ovk-btn--primary" type="button" onclick="openMachines(true)">Pair a computer</button>++ </section><section class="td-box td-nums" id="td-nums" hidden aria-labelledby="td-nums-h"><div class="td-h"><h2 id="td-nums-h">Your numbers</h2><a class="td-more" href="https://openvibe.report/you">On openvibe.report</a></div><div class="td-stats" id="td-stuff-row"></div></section></div>++ <section class="td-sec" id="td-now" hidden aria-labelledby="td-now-h">++ <div class="td-h"><h2 id="td-now-h"><span class="td-pulse" aria-hidden="true"></span>Live from people you follow</h2><span class="td-sub" id="td-now-sub"></span></div>++ <div class="td-rail" id="td-live"></div>++ </section>++ <div class="td-chips" id="td-chips" hidden></div>++++ <section class="td-sec" id="td-net" hidden aria-labelledby="td-net-h">++ <div class="td-h"><h2 id="td-net-h">On OpenVibe now</h2><a class="td-more" href="/sites">Every site</a></div>++ <div class="td-net" id="td-net-cards"></div>++ </section><div class="recent-tools" id="my-recent-tools" hidden><h3><i class="fa-solid fa-screwdriver-wrench"></i> Your tools</h3><div class="recent-tools-list" id="my-recent-tools-list"></div>@@@ -137,6 +130,15 @@</div></div>++ <details id="setup-fold" class="setup-fold" hidden><summary>Set up more (0)</summary><div class="setup-items"></div></details>++++ <section class="rc" id="receipts" hidden aria-labelledby="receipts-h">++ <div class="rc-h">++ <h2 id="receipts-h"><span class="rc-ic" aria-hidden="true"><i class="fa-solid fa-file-lines"></i></span>Receipts</h2>++ </div>++ <div class="rc-body" id="receipts-body"></div>++ </section>++<div class="card" id="account-emails-card"><h2><span class="icon"><i class="fa-solid fa-envelope"></i></span> Email addresses</h2><p>Keep several verified addresses on your account. Only you can see this list.</p>@@@ -491,9 +493,10 @@<div class="mc-top"><div><h3><span class="icon"><i class="fa-solid fa-laptop"></i></span> Your machines</h3>++ <p class="mc-count" id="mc-count" aria-live="polite">Loading machines…</p><p class="muted">Computers and servers paired with your account. They run your Codes jobs, host your desks and serve your own models. Only you can pair one or change what it does.</p></div>-- <button class="btn btn-primary" id="mc-pair-btn" type="button" onclick="mcPair()"><i class="fa-solid fa-plus"></i> Pair a machine</button>++ <button class="btn btn-primary" id="mc-pair-btn" type="button" onclick="mcPair()"><i class="fa-solid fa-plus"></i> Pair a computer</button></div><div class="mc-pair" id="mc-pair" hidden></div><p class="mc-status" id="mc-status" role="status"></p>@@@ -226,9 +226,9 @@ async function createMyRoutes({ db, config = require('../config'), sso, pagePathrouter.use((req, res, next) => {const host = hostOf(req);if (host === WWW_HOST) return res.redirect(301, `https://${MY_HOST}${req.originalUrl}`);-- // The machines hub moved to the Link product: /machines goes there from both the apex and the hub,-- // carrying a handoff ticket when the browser is signed in here.++ // The signed-in account hub serves its own Machines section; Network routes keep the Link pairing handoff.if (req.path === '/machines' && ['GET', 'HEAD'].includes(req.method) && (host === MY_HOST || NETWORK_HOSTS.has(host))) {++ if (host === MY_HOST) { req.ovMyHost = true; return next(); }return machinesRedirect(req, res, host).catch(() => res.redirect(302, `https://openvibe.link/machines${queryOf(req)}`));}if (NETWORK_HOSTS.has(host)) {@@@ -35,6 +35,7 @@* .roles_changed and .revoked (the person's own changes).*/const express = require('express');++const QRCode = require('qrcode');const crypto = require('crypto');const { capabilities, http, ids, serviceAuth, validate } = require('openvibe-contracts');const eventRelay = require('../developer/event-relay');@@@ -421,6 +422,7 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.loconst p = (out && out.pairing) || {};res.status(201).json({ pairing: {code: String(p.code || ''), expires_at: p.expires_at, installer: String(p.installer || ''),++ qr_data: await QRCode.toDataURL(String(p.code || ''), { width: 220, margin: 1 }),...(p.pairing_id ? { pairing_id: p.pairing_id } : {}),} });}));@@@ -128,7 +128,8 @@ const MY = 'openvibe.my';assert.strictEqual(r.status, 200, 'a Network guest gets the page quietly, with sso=none');assert.ok(r.body.includes('id="needs-you"'), 'the hub leads with Needs you');assert.ok(r.body.includes('id="needs-you-list"'), 'and has its list');-- assert.ok(r.body.indexOf('id="needs-you"') < r.body.indexOf('id="page-header"'), 'Needs you is above the hero');++ assert.ok(r.body.indexOf('id="page-header"') < r.body.indexOf('id="needs-you"'), 'the person comes first');++ assert.ok(r.body.indexOf('id="needs-you"') < r.body.indexOf('id="me-acts"'), 'actions follow Needs you');assert.ok(r.body.includes('id="page-header"') && r.body.includes('class="section-tabs"'), 'the hero and sections stay');assert.ok(r.body.includes('<link rel="canonical" href="https://openvibe.my/">'), 'the canonical is openvibe.my');assert.strictEqual(r.headers['cache-control'], 'no-store', 'the hub is not cached');@@@ -136,6 +137,10 @@ const MY = 'openvibe.my';r = await get('/', MY, ['ov_token=already']);assert.strictEqual(r.status, 200, 'a session skips the silent redirect');++ r = await get('/machines', MY, ['ov_token=already']);++ assert.strictEqual(r.status, 200, 'signed-in /machines stays in the hub');++ assert.ok(r.body.includes('id="sec-machines"'), 'serves Machines');++// ── The sign-in: PKCE S256, state and verifier in host-only cookies ──r = await get('/auth/login?next=%2Fsecurity', MY);assert.strictEqual(r.status, 302);@@@ -1,6 +1,6 @@'use strict';--// The hub's old paths here mint a one-time handoff ticket for link-web when the browser is signed in here,--// so openvibe.link signs them in with no round trip. Signed out, the plain hop lands on silent sign-in.++// Network's apex sends signed-in /machines visitors to Link with a handoff ticket. openvibe.my keeps its++// signed-in Machines section and silently signs in visitors who arrive there without a session.// ov test test/my-machines-handoff.test.js (or: node test/my-machines-handoff.test.js)const assert = require('assert');const http = require('http');@@@ -31,23 +31,21 @@ const server = http.createServer(app);req.on('error', reject);});-- // Signed in here: the hop carries a ticket minted for link-web.++ // The account hub's own /machines route stays here and serves its Machines section.let r = await get('/machines', 'ov_token=good-token');-- assert.strictEqual(r.status, 302, 'signed in 302s to the hub');-- const to = new URL(r.headers.location);-- assert.strictEqual(to.origin + to.pathname, 'https://openvibe.link/machines');-- assert.ok(/^ovh_[A-Za-z0-9_-]{20,200}$/.test(to.searchParams.get('ov_handoff')), 'a ticket rides along');++ assert.strictEqual(r.status, 200, 'signed in /machines stays on the account hub');++ assert.ok(r.body.includes('id="sec-machines"'));-- // Signed out (or a bad cookie): the plain hop, which lands on the hub's silent sign-in.++ // Signed out: openvibe.my starts its silent sign-in with /machines as the return path.r = await get('/machines');assert.strictEqual(r.status, 302);-- assert.strictEqual(r.headers.location, 'https://openvibe.link/machines', 'no ticket when signed out');++ assert.strictEqual(r.headers.location, '/auth/login?silent=1&next=%2Fmachines');r = await get('/machines', 'ov_token=nope');-- assert.strictEqual(r.headers.location, 'https://openvibe.link/machines', 'no ticket on a bad session');++ assert.strictEqual(r.status, 200, 'an existing session cookie reaches the account hub');-- // The old Network path mints the same way.++ // Network apex arrivals still get Link's signed-in handoff.r = await get('/machines', 'ov_token=good-token', 'openvibe.network');-- assert.ok(new URL(r.headers.location).searchParams.get('ov_handoff'), 'apex arrivals mint too');++ assert.ok(new URL(r.headers.location).searchParams.get('ov_handoff'));console.log('my-machines-handoff.test.js: all assertions passed');server.close();@@@ -61,6 +61,8 @@ const ago = (m) => new Date(Date.now() - m * 60000).toISOString();assert.ok(HTML.includes('id="sec-machines"') && HTML.includes("showSection('machines',this)"), 'a Machines section and tab');assert.ok(HTML.indexOf('<script src="/js/my/machines.js" defer>') > 0, 'its script');assert.match(CORE, /'\/machines': 'machines'/, 'the /machines address opens it');++assert.match(CORE, /hash === 'pair' \? 'machines'/, '#pair opens Machines');++assert.match(CORE, /if \(routeSection === 'machines' && hash === 'pair'\) setTimeout\(\(\) => mcPair\(\), 0\)/, '#pair starts pairing');assert.match(CORE, /if \(id === 'machines' && typeof loadMachines === 'function'\) loadMachines\(\);/, 'it loads when opened');assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the account hub, not a 404');@@@ -15,9 +15,13 @@ const HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'uconst JS = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'receipts.js'), 'utf8');const CSS = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'my.css'), 'utf8');--// ── The page: the card sits under Needs you, above the hero, and starts out hidden ──--assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="receipts"'), 'Receipts follows Needs you');--assert.ok(HTML.indexOf('id="receipts"') < HTML.indexOf('id="page-header"'), 'and is above the hero');++// ── The page: the person leads, then Needs you, actions, history, sites, machines, numbers and receipts ──++assert.ok(HTML.indexOf('id="page-header"') < HTML.indexOf('id="needs-you"'));++assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="me-acts"'));++assert.ok(HTML.indexOf('id="td-continue"') < HTML.indexOf('id="td-sites"'));++assert.ok(HTML.indexOf('id="td-sites"') < HTML.indexOf('id="td-machines-summary"'));++assert.ok(HTML.indexOf('id="td-machines-summary"') < HTML.indexOf('id="td-nums"'));++assert.ok(HTML.indexOf('id="td-nums"') < HTML.indexOf('id="receipts"'));assert.match(HTML, /<section class="rc" id="receipts" hidden/);assert.match(HTML, /<script src="\/js\/my\/receipts\.js" defer><\/script>/, 'the card has its own script');assert.match(CSS, /\.rc-row \{/, 'the rows have a style');@@@ -34,12 +38,16 @@ class Node {get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }set textContent(v) { this._text = String(v); this.children = []; }get href() { return this.attrs.href; }++ querySelector(sel) { return sel === 'summary' ? this.summary : sel === '.setup-items' ? this.items : null; }all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }}function page() {const ids = {};for (const [, id] of HTML.matchAll(/id="([^"]+)"/g)) ids[id] = new Node('div');++ ids['setup-fold'].hidden = true;++ ids['setup-fold'].summary = new Node('summary');++ ids['setup-fold'].items = new Node('div');const sandbox = {document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t) },console, Date, Number, String, Array, Map, Set, Math, JSON, Promise, encodeURIComponent,@@@ -55,16 +63,14 @@ const receipt = (i) => ({});(async () => {-- // ── Inbox AI off (or no mailbox): one quiet line, a link to the switch, and no sample receipt ──++ // ── Inbox AI off: one counted setup fold, no empty Receipts card, and no sample receipt ──let p = page();p.sandbox.renderReceipts({ enabled: false, receipts: [] });-- assert.strictEqual(p.ids.receipts.hidden, false, 'the card says why it is empty');-- const quiet = p.ids['receipts-body'].children;-- assert.strictEqual(quiet.length, 1, 'one line only');-- assert.strictEqual(quiet[0].tagName, 'A');-- assert.strictEqual(quiet[0].textContent, 'Turn on Inbox AI on openvibe.email to see receipts here');-- assert.strictEqual(quiet[0].getAttribute('href'), 'https://openvibe.email/inbox#inbox-ai', 'it links to the switch');-- assert.ok(!/Shop|Order|sample|example/i.test(p.ids['receipts-body'].textContent), 'no sample or fake receipt');++ assert.strictEqual(p.ids.receipts.hidden, true, 'no empty Receipts card');++ assert.strictEqual(p.ids['setup-fold'].hidden, false);++ assert.strictEqual(p.ids['setup-fold'].summary.textContent, 'Set up more (1)');++ assert.strictEqual(p.ids['setup-fold'].items.children[0].textContent, 'Set up receipts with Inbox AI');++ assert.strictEqual(p.ids['setup-fold'].items.children[0].getAttribute('href'), 'https://openvibe.email/inbox#inbox-ai');// ── On with receipts: the latest five, then a counted fold for the rest, each a thread on openvibe.email ──p = page();@@@ -98,11 +104,11 @@ const receipt = (i) => ({p.sandbox.renderReceipts({ enabled: true, receipts: [{ ...receipt(0), message_id: null, thread_id: 'thr_ok' }] });assert.strictEqual(p.ids['receipts-body'].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.email/inbox/thr_ok');-- // ── On with nothing triaged yet: a quiet line, not the turn-it-on line ──++ // ── On with nothing triaged yet: neither receipts nor setup nudge is shown ──p = page();p.sandbox.renderReceipts({ enabled: true, receipts: [] });-- assert.match(p.ids['receipts-body'].textContent, /^No receipts yet\.$/);-- assert.ok(!/Turn on Inbox AI/.test(p.ids['receipts-body'].textContent));++ assert.strictEqual(p.ids.receipts.hidden, true);++ assert.strictEqual(p.ids['setup-fold'].hidden, true);// ── The route: Mail is asked with the person's own token; off hides rows, on passes them, down stays out ──const seen = [];@@@ -175,22 +175,27 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;p.ids['td-stuff-row'].children.find((c) => /history/.test(c.textContent)).click();assert.deepStrictEqual(p.shown, ['history'], 'your history opens below');++ p.sandbox.renderMachineSummary({ machines: [{ name: 'Studio PC', online: true }, { name: 'Laptop', online: false }, { id: 'revoked', revoked_at: 'x', online: true }] });++ assert.strictEqual(p.ids['td-machines-count'].textContent, '2 paired');++ assert.strictEqual(p.ids['td-machines-state'].textContent, '1 online now · Studio PC');++// ── Needs you (plan §4): the Action Center's waiting items, one button each, above the hero ──p = page();-- assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="page-header"'), 'Needs you is the first section');++ assert.ok(HTML.indexOf('id="page-header"') < HTML.indexOf('id="needs-you"'), 'the person is first');++ assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="me-acts"'), 'actions follow Needs you');p.sandbox.renderNeedsYou({ buckets: { waiting: [-- { service: 'network', id: 'cnf_1', kind: 'approval', title: 'Send one email to the venue', summary: 'Trip planner asks to use mail.send', agent: 'Trip planner', href: 'https://openvibe.network/my#approvals', actions: ['approve', 'decline', 'open'] },++ { service: 'network', id: 'cnf_1', kind: 'approval', title: 'Run a command', summary: 'Trip planner asks to run a command', agent: 'Trip planner', href: 'https://openvibe.network/my#approvals', actions: ['approve', 'decline', 'open'] },{ service: 'actor', id: 'tsk_1', kind: 'agent_task', title: 'Find three cheap flights', summary: 'Read 4 pages', href: 'https://openvibe.actor/tasks/tsk_1', actions: ['open'] },-- ] } });++ ] } }, { confirmations: [{ id: 'cnf_1', capability: 'computer.command', details: { command: 'rm -rf /tmp/actor-gate-proof', reason: 'Clean up a test file', arguments: { raw: true } }, resources: [{ type: 'machine', name: 'devprobe' }] }] });assert.strictEqual(p.ids['needs-you'].hidden, false);assert.strictEqual(p.ids['needs-you-sub'].textContent, '2 things waiting');const rows = p.ids['needs-you-list'].children;assert.strictEqual(rows.length, 2);-- const review = rows[0].all((c) => c.tagName === 'A')[0];-- assert.strictEqual(review.textContent, 'Review', 'an approval opens itself: never one tap from the hub');-- assert.strictEqual(rows[0].all((c) => c.tagName === 'BUTTON').length, 0, 'no Approve button here');-- assert.strictEqual(rows[0].all((c) => c.tagName === 'H3')[0].textContent, 'Send one email to the venue');-- assert.strictEqual(rows[0].all((c) => c.tagName === 'P')[0].textContent, 'Trip planner asks to use mail.send');++ assert.strictEqual(rows[0].all((c) => c.tagName === 'BUTTON').length, 2, 'inline approve and decline');++ assert.strictEqual(rows[0].all((c) => c.tagName === 'H3')[0].textContent, 'Run a command on devprobe');++ assert.match(rows[0].textContent, /rm -rf \/tmp\/actor-gate-proof/);++ assert.doesNotMatch(rows[0].textContent, /"arguments"|\[object Object\]/, 'structured arguments stay hidden');++ assert.match(rows[0].textContent, /Details/);assert.strictEqual(rows[1].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.actor/tasks/tsk_1', 'an open item links its page');// An agent known only by its id is not shown by it; Try again stays one tap.p.sandbox.renderNeedsYou({ buckets: { waiting: [
@@ -714,3 +714,35 @@ html[data-ov-motion=off] .me-act:hover{transform: none}
@@ -714,3 +714,35 @@ html[data-ov-motion=off] .me-act:hover{transform: none}
.mc-desk-live summary { display: inline-block; cursor: pointer; }
.mc-desk-live summary { display: inline-block; cursor: pointer; }
.mc-desk-live[open] { display: grid; gap: 8px; }
.mc-desk-live[open] { display: grid; gap: 8px; }
.mc-takeover { margin-top: 4px; }
.mc-takeover { margin-top: 4px; }
+
+/* Account home organization and pairing visibility. */
+@media (max-width:700px) { body { padding-bottom:112px; } }
+@media (min-width:760px) { .td-grid { grid-template-columns:minmax(0,1fr); } }
+.td-machines { display:grid; gap:10px; min-width:0; }
+.td-machines p { color:var(--text-secondary); font-size:13px; }
+.td-machines .ovk-btn { justify-self:start; min-height:44px; }
+.setup-fold { margin:0 0 18px; padding:12px 16px; border:1px solid var(--border); border-radius:14px; background:var(--bg-secondary); }
+.setup-fold[hidden] { display:none !important; }
+.setup-fold summary { min-height:28px; font-weight:700; cursor:pointer; }
+.setup-items { display:grid; gap:4px; padding-top:6px; }
+.ny-actions { display:flex; flex-wrap:wrap; align-items:center; gap:8px; }
+.ny-details { flex:1 1 100%; font-size:12px; color:var(--text-muted); }
+.ny-details summary { width:max-content; min-height:36px; cursor:pointer; }
+.ny-details p { margin:2px 0 8px; }
+.ny-command { display:block; width:fit-content; max-width:100%; overflow-wrap:anywhere; padding:7px 10px; border-radius:8px; background:var(--bg-secondary); color:var(--text); font-size:12px; }
+.ny-btn.is-quiet { background:transparent; color:var(--text-secondary); border:1px solid var(--border); }
+.mc-steps { margin:0; padding-left:22px; display:grid; gap:5px; }
+.mc-pair-code-row { display:flex; flex-wrap:wrap; align-items:center; gap:10px; }
+.mc-pair-code-row .mc-code { margin:0; }
+.mc-qr { width:176px; height:176px; border-radius:10px; background:#fff; }
+body.my-needs-visible #ovdk-pill { display:none !important; }
+.mc-action-menu { margin-top: 12px; }
+.mc-action-menu summary { display:inline-flex; align-items:center; min-height:40px; padding:0 13px; border:1px solid var(--border); border-radius:10px; cursor:pointer; font-size:13px; font-weight:700; }
+.mc-action-menu .mc-acts { margin-top:8px; }
+.mc-role-chips { display:flex; flex-wrap:wrap; gap:6px; margin:8px 0; }
+.mc-role-chip { display:inline-flex; align-items:center; min-height:28px; padding:2px 10px; border:1px solid var(--border); border-radius:999px; background:var(--bg-secondary); color:var(--text-secondary); font-size:11px; font-weight:650; }
+.mc-role-chip.is-empty { opacity:.7; }
+.me-acts { margin-bottom: 20px; }
+.mc-count { margin:0 0 8px; color:var(--text-muted); font-size:12px; }
+#sec-machines { scroll-margin-top: 90px; }
+body.my-machines-open { padding-bottom: max(112px, calc(100vh - 90px)); }
@@ -14,6 +14,11 @@ OpenVibeAccountSwitcher.init({ apiBase: API, onSwitch: (account) => {
@@ -14,6 +14,11 @@ OpenVibeAccountSwitcher.init({ apiBase: API, onSwitch: (account) => {
window.location.reload();
window.location.reload();
} });
} });
const _signedIn = !!(getAuthToken() || getAnonToken());
const _signedIn = !!(getAuthToken() || getAnonToken());
+const needsYouSection = document.getElementById('needs-you');
+if (needsYouSection && 'IntersectionObserver' in window) {
+ new IntersectionObserver((entries) => document.body.classList.toggle('my-needs-visible', !!entries[0]?.isIntersecting))
+ .observe(needsYouSection);
+}
loadUser();
loadUser();
if (_signedIn) {
if (_signedIn) {
loadToday();
loadToday();
@@ -155,6 +155,7 @@ async function anonFetch(path, opts = {}) {
@@ -155,6 +155,7 @@ async function anonFetch(path, opts = {}) {
// Section switching
// Section switching
function showSection(id, btn) {
function showSection(id, btn) {
+ document.body.classList.toggle('my-machines-open', id === 'machines');
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.section-tabs button').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.section-tabs button').forEach(b => b.classList.remove('active'));
document.getElementById('sec-' + id).classList.add('active');
document.getElementById('sec-' + id).classList.add('active');
@@ -173,7 +174,7 @@ function openInitialSection() {
@@ -173,7 +174,7 @@ function openInitialSection() {
const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/machines': 'machines', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/machines': 'machines', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
// #delete is the re-authentication return target: the delete step lives in the accounts section.
// #delete is the re-authentication return target: the delete step lives in the accounts section.
const hash = window.location.hash.replace(/^#/, '');
const hash = window.location.hash.replace(/^#/, '');
- const routeSection = pathSection || (hash === 'delete' ? 'accounts' : hash) || 'profile';
+ const routeSection = pathSection || (hash === 'delete' ? 'accounts' : hash === 'pair' ? 'machines' : hash) || 'profile';
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines','apps'].includes(routeSection)) {
if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines','apps'].includes(routeSection)) {
return showSection('anon');
return showSection('anon');
@@ -190,6 +191,8 @@ function openInitialSection() {
@@ -190,6 +191,8 @@ function openInitialSection() {
}
}
if (document.getElementById('sec-' + routeSection)) {
if (document.getElementById('sec-' + routeSection)) {
showSection(routeSection);
showSection(routeSection);
+ if (routeSection === 'machines') document.getElementById('sec-machines')?.scrollIntoView?.({ block: 'start' });
+ if (routeSection === 'machines' && hash === 'pair') setTimeout(() => mcPair(), 0);
}
}
}
}
@@ -41,9 +41,13 @@ function mcCard(m) {
@@ -41,9 +41,13 @@ function mcCard(m) {
const dot = mcEl('span', `mc-dot ${m.online ? 'is-on' : ''}`, [], { 'aria-hidden': 'true' });
const dot = mcEl('span', `mc-dot ${m.online ? 'is-on' : ''}`, [], { 'aria-hidden': 'true' });
const name = mcEl('h4', 'mc-name', [m.name || MC_KIND[m.kind] || 'A machine']);
const name = mcEl('h4', 'mc-name', [m.name || MC_KIND[m.kind] || 'A machine']);
const state = mcEl('span', 'mc-state', [m.online ? 'Online' : (m.last_seen_at ? `Offline · seen ${mcAgo(m.last_seen_at)}` : 'Offline')]);
const state = mcEl('span', 'mc-state', [m.online ? 'Online' : (m.last_seen_at ? `Offline · seen ${mcAgo(m.last_seen_at)}` : 'Offline')]);
- const meta = mcEl('p', 'mc-meta', [[MC_KIND[m.kind], m.os, m.node_version ? `Node ${m.node_version}` : ''].filter(Boolean).join(' · ')]);
+ const os = String(m.os || '').toLowerCase();
+ const osIcon = os.includes('mac') || os.includes('darwin') ? 'fa-apple' : os.includes('linux') ? 'fa-linux' : 'fa-laptop';
+ const meta = mcEl('p', 'mc-meta', [mcIcon(osIcon), ' ', [MC_KIND[m.kind], m.os, m.node_version ? `Node ${m.node_version}` : ''].filter(Boolean).join(' · ')]);
const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);
const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);
card.append(head);
card.append(head);
+ const roleChips = (m.roles || []).map((id) => (mcRoles || []).find((r) => r.id === id)).filter(Boolean);
+ card.append(mcEl('div', 'mc-role-chips', roleChips.length ? roleChips.map((r) => mcEl('span', 'mc-role-chip', [r.name])) : [mcEl('span', 'mc-role-chip is-empty', ['No roles assigned'])]));
(m.pools || []).forEach((pool) => card.append(mcPoolBlock(pool)));
(m.pools || []).forEach((pool) => card.append(mcPoolBlock(pool)));
if (Array.isArray(m.running) && m.running.length) {
if (Array.isArray(m.running) && m.running.length) {
@@ -76,7 +80,10 @@ function mcCard(m) {
@@ -76,7 +80,10 @@ function mcCard(m) {
const revoke = mcEl('button', 'btn btn-small mc-revoke', [mcIcon('fa-link-slash'), ' Revoke'], { type: 'button' });
const revoke = mcEl('button', 'btn btn-small mc-revoke', [mcIcon('fa-link-slash'), ' Revoke'], { type: 'button' });
revoke.addEventListener('click', () => mcConfirmRevoke(acts, m));
revoke.addEventListener('click', () => mcConfirmRevoke(acts, m));
acts.append(rename, revoke);
acts.append(rename, revoke);
- card.append(acts);
+ const menu = mcEl('details', 'mc-action-menu');
+ menu.append(mcEl('summary', '', ['Actions']));
+ menu.append(acts);
+ card.append(menu);
return card;
return card;
}
}
@@ -516,12 +523,19 @@ async function mcPair() {
@@ -516,12 +523,19 @@ async function mcPair() {
const out = await apiFetch('/api/v1/machines/pairings', { method: 'POST', body: '{}' });
const out = await apiFetch('/api/v1/machines/pairings', { method: 'POST', body: '{}' });
const p = (out && out.pairing) || {};
const p = (out && out.pairing) || {};
const code = mcEl('p', 'mc-code', [String(p.code || '')]);
const code = mcEl('p', 'mc-code', [String(p.code || '')]);
+ code.setAttribute('aria-label', 'Pairing code');
+ const copyCode = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-copy'), ' Copy code'], { type: 'button' });
+ copyCode.addEventListener('click', () => navigator.clipboard.writeText(String(p.code || '')).then(() => mcSay('Code copied.')));
+ const qr = p.qr_data ? mcEl('img', 'mc-qr', [], { src: p.qr_data, alt: 'Scan this QR code to read the pairing code' }) : null;
const cmd = mcEl('pre', 'mc-cmd', [mcEl('code', '', [String(p.installer || '')])]);
const cmd = mcEl('pre', 'mc-cmd', [mcEl('code', '', [String(p.installer || '')])]);
const copy = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-copy'), ' Copy the command'], { type: 'button' });
const copy = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-copy'), ' Copy the command'], { type: 'button' });
copy.addEventListener('click', () => navigator.clipboard.writeText(String(p.installer || '')).then(() => mcSay('Copied. Run it on the machine.')));
copy.addEventListener('click', () => navigator.clipboard.writeText(String(p.installer || '')).then(() => mcSay('Copied. Run it on the machine.')));
const until = p.expires_at ? mcEl('span', 'mc-until', [`The code works until ${new Date(p.expires_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`]) : null;
const until = p.expires_at ? mcEl('span', 'mc-until', [`The code works until ${new Date(p.expires_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`]) : null;
const wait = mcEl('p', 'mc-wait', [mcEl('span', 'mc-wait-dot', [], { 'aria-hidden': 'true' }), 'Waiting for your machine…'], { role: 'status' });
const wait = mcEl('p', 'mc-wait', [mcEl('span', 'mc-wait-dot', [], { 'aria-hidden': 'true' }), 'Waiting for your machine…'], { role: 'status' });
- box.replaceChildren(mcEl('h4', '', ['Run this on the machine']), mcEl('p', 'muted', ['It installs OpenVibe Link and pairs it with your account. Or type the code into Link yourself:']), code, cmd, mcEl('div', 'mc-pair-acts', [copy, until]), wait);
+ const pairContent = [mcEl('h4', '', ['Pair your computer']), mcEl('ol', 'mc-steps', [mcEl('li', '', ['Open OpenVibe Link on the computer.']), mcEl('li', '', ['Enter this code or scan the QR code.']), mcEl('li', '', ['Give it a name when it connects.'])]), mcEl('div', 'mc-pair-code-row', [code, copyCode])];
+ if (qr) pairContent.push(qr);
+ pairContent.push(mcEl('p', 'muted', ['Or install Link with this command:']), cmd, mcEl('div', 'mc-pair-acts', [copy, until]), wait);
+ box.replaceChildren(...pairContent);
box.hidden = false;
box.hidden = false;
clearInterval(mcWatch);
clearInterval(mcWatch);
const t0 = Date.now();
const t0 = Date.now();
@@ -551,11 +565,13 @@ async function mcPair() {
@@ -551,11 +565,13 @@ async function mcPair() {
async function loadMachines() {
async function loadMachines() {
const list = document.getElementById('mc-list');
const list = document.getElementById('mc-list');
if (!list) return;
if (!list) return;
+ const count = document.getElementById('mc-count');
try {
try {
if (!mcRoles) mcRoles = ((await apiFetch('/api/v1/machines/roles').catch(() => null)) || {}).roles || [];
if (!mcRoles) mcRoles = ((await apiFetch('/api/v1/machines/roles').catch(() => null)) || {}).roles || [];
const out = await apiFetch('/api/v1/machines');
const out = await apiFetch('/api/v1/machines');
const machines = (out && out.machines) || [];
const machines = (out && out.machines) || [];
machines.sort((a, b) => (b.online === true) - (a.online === true));
machines.sort((a, b) => (b.online === true) - (a.online === true));
+ if (count) count.textContent = `${machines.length} paired · ${machines.filter((m) => m.online).length} online now`;
if (!machines.length) {
if (!machines.length) {
list.replaceChildren(mcEl('div', 'mc-empty', [mcIcon('fa-laptop'), mcEl('p', '', [mcEl('b', '', ['No machines yet.']), ' Pair your computer and it can run your Codes jobs, host your desks and serve your own models, under your account.'])]));
list.replaceChildren(mcEl('div', 'mc-empty', [mcIcon('fa-laptop'), mcEl('p', '', [mcEl('b', '', ['No machines yet.']), ' Pair your computer and it can run your Codes jobs, host your desks and serve your own models, under your account.'])]));
return;
return;
@@ -566,6 +582,7 @@ async function loadMachines() {
@@ -566,6 +582,7 @@ async function loadMachines() {
for (const m of machines) m.pools = (pools.pools || []).filter((p) => p.machines.some((x) => x.id === m.id));
for (const m of machines) m.pools = (pools.pools || []).filter((p) => p.machines.some((x) => x.id === m.id));
list.replaceChildren(...machines.map(mcCard), ...mcPoolPickers(pools.candidates || []));
list.replaceChildren(...machines.map(mcCard), ...mcPoolPickers(pools.candidates || []));
} catch (e) {
} catch (e) {
+ if (count) count.textContent = '';
list.replaceChildren(mcEl('p', 'muted', [e.status === 401 ? 'Sign in to see your machines.' : 'Your machines could not be read just now.']));
list.replaceChildren(mcEl('p', 'muted', [e.status === 401 ? 'Sign in to see your machines.' : 'Your machines could not be read just now.']));
}
}
}
}
@@ -67,13 +67,16 @@ function renderReceipts(doc) {
@@ -67,13 +67,16 @@ function renderReceipts(doc) {
const rows = (doc && Array.isArray(doc.receipts) ? doc.receipts : []).map(rcRow).filter(Boolean);
const rows = (doc && Array.isArray(doc.receipts) ? doc.receipts : []).map(rcRow).filter(Boolean);
body.replaceChildren();
body.replaceChildren();
if (!enabled) {
if (!enabled) {
- body.append(rcEl('a', 'rc-quiet', ['Turn on Inbox AI on openvibe.email to see receipts here'], { href: RC_SETTING }));
- box.hidden = false;
+ box.hidden = true;
+ const fold = document.getElementById('setup-fold');
+ if (fold) { fold.hidden = false; fold.querySelector('summary').textContent = 'Set up more (1)'; fold.querySelector('.setup-items').replaceChildren(rcEl('a', 'rc-quiet', ['Set up receipts with Inbox AI'], { href: RC_SETTING })); }
return;
return;
}
}
+ const fold = document.getElementById('setup-fold');
+ if (fold) fold.hidden = true;
if (!rows.length) {
if (!rows.length) {
body.append(rcEl('p', 'rc-quiet', ['No receipts yet.']));
body.append(rcEl('p', 'rc-quiet', ['No receipts yet.']));
- box.hidden = false;
+ box.hidden = true;
return;
return;
}
}
body.append(...rows.slice(0, RC_SHOWN));
body.append(...rows.slice(0, RC_SHOWN));
@@ -325,7 +325,11 @@ function nyHref(item) {
@@ -325,7 +325,11 @@ function nyHref(item) {
// An approval is never one tap from here: the hub shows a line, and a step may delete, spend or send. Review opens the
// An approval is never one tap from here: the hub shows a line, and a step may delete, spend or send. Review opens the
// approval itself (what exactly runs, Approve or Decline). Try again stays one tap: it only repeats what already ran.
// approval itself (what exactly runs, Approve or Decline). Try again stays one tap: it only repeats what already ran.
function nyButton(item, action) {
function nyButton(item, action) {
- const label = action === 'approve' ? 'Review' : action === 'retry' ? 'Try again' : 'Open';
+ const label = action === 'approve' ? (item.kind === 'approval' ? 'Approve' : 'Review') : action === 'decline' ? 'Decline' : action === 'retry' ? 'Try again' : 'Open';
+ if (item.kind === 'approval' && item.service === 'network' && ['approve', 'decline'].includes(action)) {
+ const b = document.createElement('button'); b.type = 'button'; b.className = `ny-btn${action === 'decline' ? ' is-quiet' : ''}`; b.textContent = label;
+ b.addEventListener('click', () => nyDo(item, action, b)); return b;
+ }
if (action !== 'retry') {
if (action !== 'retry') {
const href = nyHref(item);
const href = nyHref(item);
const a = document.createElement('a');
const a = document.createElement('a');
@@ -353,7 +357,7 @@ async function nyDo(item, action, button) {
@@ -353,7 +357,7 @@ async function nyDo(item, action, button) {
if (!item || !item.service || !item.id) return;
if (!item || !item.service || !item.id) return;
const label = button.textContent;
const label = button.textContent;
button.disabled = true;
button.disabled = true;
- button.textContent = action === 'retry' ? 'Trying…' : 'Approving…';
+ button.textContent = action === 'retry' ? 'Trying…' : action === 'decline' ? 'Declining…' : 'Approving…';
try {
try {
await apiFetch(`/api/v1/me/actions/${encodeURIComponent(item.service)}/${encodeURIComponent(item.id)}/${action}`, { method: 'POST' });
await apiFetch(`/api/v1/me/actions/${encodeURIComponent(item.service)}/${encodeURIComponent(item.id)}/${action}`, { method: 'POST' });
const row = button.closest('.ny-item');
const row = button.closest('.ny-item');
@@ -363,7 +367,7 @@ async function nyDo(item, action, button) {
@@ -363,7 +367,7 @@ async function nyDo(item, action, button) {
const sub = document.getElementById('needs-you-sub');
const sub = document.getElementById('needs-you-sub');
if (sub) sub.textContent = left ? `${left} more waiting` : '';
if (sub) sub.textContent = left ? `${left} more waiting` : '';
if (!left && box) box.hidden = true;
if (!left && box) box.hidden = true;
- if (typeof toast === 'function') toast(action === 'retry' ? 'Trying it again' : 'Approved', 'success');
+ if (typeof toast === 'function') toast(action === 'retry' ? 'Trying it again' : action === 'decline' ? 'Declined' : 'Approved', 'success');
} catch (err) {
} catch (err) {
button.disabled = false;
button.disabled = false;
button.textContent = label;
button.textContent = label;
@@ -371,15 +375,18 @@ async function nyDo(item, action, button) {
@@ -371,15 +375,18 @@ async function nyDo(item, action, button) {
}
}
}
}
-function renderNeedsYou(doc) {
+function renderNeedsYou(doc, confirmationDoc) {
const box = document.getElementById('needs-you');
const box = document.getElementById('needs-you');
const list = document.getElementById('needs-you-list');
const list = document.getElementById('needs-you-list');
if (!box || !list) return;
if (!box || !list) return;
const items = doc && doc.buckets && Array.isArray(doc.buckets.waiting) ? doc.buckets.waiting : [];
const items = doc && doc.buckets && Array.isArray(doc.buckets.waiting) ? doc.buckets.waiting : [];
+ const confirmations = new Map(((confirmationDoc && confirmationDoc.confirmations) || []).map((c) => [c.id, c]));
if (!items.length) { box.hidden = true; list.replaceChildren(); return; }
if (!items.length) { box.hidden = true; list.replaceChildren(); return; }
const sub = document.getElementById('needs-you-sub');
const sub = document.getElementById('needs-you-sub');
if (sub) sub.textContent = `${items.length} thing${items.length === 1 ? '' : 's'} waiting`;
if (sub) sub.textContent = `${items.length} thing${items.length === 1 ? '' : 's'} waiting`;
- list.replaceChildren(...items.map((item) => {
+ list.replaceChildren(...items.map((source) => {
+ const confirmation = source.service === 'network' ? confirmations.get(source.id) : null;
+ const item = confirmation ? { ...source, details: confirmation.details, resources: confirmation.resources, capability: confirmation.capability } : source;
const row = document.createElement('article');
const row = document.createElement('article');
row.className = 'ny-item';
row.className = 'ny-item';
const icon = document.createElement('span');
const icon = document.createElement('span');
@@ -391,9 +398,17 @@ function renderNeedsYou(doc) {
@@ -391,9 +398,17 @@ function renderNeedsYou(doc) {
const body = document.createElement('div');
const body = document.createElement('div');
body.className = 'ny-body';
body.className = 'ny-body';
const title = document.createElement('h3');
const title = document.createElement('h3');
- title.textContent = item.title || 'Something needs you';
+ const isApproval = item.kind === 'approval' && item.service === 'network';
+ const details = item.details && typeof item.details === 'object' ? item.details : {};
+ const host = details.machine_name || details.machine || details.target || item.resources?.find((r) => r && typeof r === 'object' && /machine|computer|host/i.test(String(r.type || '')) && r.name);
+ const hostName = typeof host === 'string' ? host : host && (host.name || host.hostname);
+ const commandText = typeof details.command === 'string' ? details.command.slice(0, 500) : '';
+ const commandRequest = Boolean(commandText || details.action === 'command');
+ title.textContent = isApproval ? `${commandRequest ? 'Run a command' : 'Approve a request'}${hostName ? ` on ${hostName}` : ''}` : (item.title || 'Something needs you');
body.append(title);
body.append(title);
- if (item.summary) {
+ if (isApproval && commandText) body.append(Object.assign(document.createElement('code'), { className: 'ny-command', textContent: commandText }));
+ if (isApproval && typeof details.reason === 'string') { const why = document.createElement('p'); why.className = 'ny-meta'; why.textContent = details.reason.slice(0, 300); body.append(why); }
+ if (!isApproval && item.summary) {
const summary = document.createElement('p');
const summary = document.createElement('p');
summary.textContent = item.summary;
summary.textContent = item.summary;
body.append(summary);
body.append(summary);
@@ -407,12 +422,39 @@ function renderNeedsYou(doc) {
@@ -407,12 +422,39 @@ function renderNeedsYou(doc) {
}
}
const actions = Array.isArray(item.actions) ? item.actions : [];
const actions = Array.isArray(item.actions) ? item.actions : [];
const primary = actions.includes('approve') ? 'approve' : actions.includes('retry') ? 'retry' : 'open';
const primary = actions.includes('approve') ? 'approve' : actions.includes('retry') ? 'retry' : 'open';
- row.append(icon, body, nyButton(item, primary));
+ const actionsWrap = document.createElement('div'); actionsWrap.className = 'ny-actions';
+ if (isApproval) {
+ const detailsToggle = document.createElement('details'); detailsToggle.className = 'ny-details';
+ const summary = document.createElement('summary'); summary.textContent = 'Details'; detailsToggle.append(summary);
+ const agentName = item.agent && !/^(agt|app|svc|usr)[_0-9A-HJKMNP-TV-Z]{20,}$/i.test(String(item.agent)) ? item.agent : '';
+ const words = [agentName ? `Asked by ${agentName}` : '', item.summary || ''].filter(Boolean).join(' · ');
+ if (words) { const p = document.createElement('p'); p.textContent = words; detailsToggle.append(p); }
+ actionsWrap.append(detailsToggle, nyButton(item, 'approve'), nyButton(item, 'decline'));
+ } else actionsWrap.append(nyButton(item, primary));
+ row.append(icon, body, actionsWrap);
return row;
return row;
}));
}));
box.hidden = false;
box.hidden = false;
}
}
+function openMachines(pair) {
+ showSection('machines');
+ document.getElementById('sec-machines')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ if (pair) mcPair();
+}
+
+function renderMachineSummary(doc) {
+ const box = document.getElementById('td-machines-summary');
+ const state = document.getElementById('td-machines-state');
+ const count = document.getElementById('td-machines-count');
+ if (!box || !state) return;
+ const machines = (doc && Array.isArray(doc.machines) ? doc.machines : []).filter((m) => !m.revoked_at);
+ const online = machines.filter((m) => m.online === true).length;
+ count.textContent = `${machines.length} paired`;
+ state.textContent = machines.length ? `${online} online now${online ? ` · ${machines.filter((m) => m.online).map((m) => m.name).filter(Boolean).slice(0, 3).join(', ')}` : ''}` : 'Pair a computer to run your work on OpenVibe.';
+ box.hidden = false;
+}
+
async function loadToday() {
async function loadToday() {
if (!document.getElementById('today')) return;
if (!document.getElementById('today')) return;
renderGreeting();
renderGreeting();
@@ -420,7 +462,7 @@ async function loadToday() {
@@ -420,7 +462,7 @@ async function loadToday() {
const quiet = (p) => p.catch(() => null);
const quiet = (p) => p.catch(() => null);
renderActions(null);
renderActions(null);
renderStatus();
renderStatus();
- const [acts, live, hist, coins, last, unread, sites, nodes, frame, tryNext] = await Promise.all([
+ const [acts, live, hist, coins, last, unread, sites, nodes, frame, tryNext, confirmationsDoc, machineList] = await Promise.all([
quiet(apiFetch('/api/v1/me/actions')),
quiet(apiFetch('/api/v1/me/actions')),
quiet(apiFetch('/api/v1/me/live-follows')),
quiet(apiFetch('/api/v1/me/live-follows')),
quiet(apiFetch('/api/history?limit=30')),
quiet(apiFetch('/api/history?limit=30')),
@@ -431,9 +473,11 @@ async function loadToday() {
@@ -431,9 +473,11 @@ async function loadToday() {
quiet(apiFetch('/api/v1/me/nodes')),
quiet(apiFetch('/api/v1/me/nodes')),
quiet(apiFetch('/api/frame?host=openvibe.network')),
quiet(apiFetch('/api/frame?host=openvibe.network')),
quiet(apiFetch('/api/v1/me/try-next?limit=3')),
quiet(apiFetch('/api/v1/me/try-next?limit=3')),
+ quiet(apiFetch('/api/v1/confirmations?state=pending&limit=100')),
+ quiet(apiFetch('/api/v1/machines')),
]);
]);
globalThis.__tdUnread = unread;
globalThis.__tdUnread = unread;
- renderNeedsYou(acts);
+ renderNeedsYou(acts, confirmationsDoc);
renderActions(unread);
renderActions(unread);
renderNet(frame);
renderNet(frame);
renderNow(live, unread);
renderNow(live, unread);
@@ -441,5 +485,11 @@ async function loadToday() {
@@ -441,5 +485,11 @@ async function loadToday() {
renderMoney(coins, last);
renderMoney(coins, last);
renderSites(hist, sites, tryNext);
renderSites(hist, sites, tryNext);
renderStuff(live, hist, nodes);
renderStuff(live, hist, nodes);
+ renderMachineSummary(machineList);
document.getElementById('today').classList.add('td-ready');
document.getElementById('today').classList.add('td-ready');
+ const machineSection = document.getElementById('sec-machines');
+ if (machineSection && machineSection.classList.contains('active') && typeof location !== 'undefined' && typeof window !== 'undefined' && location.hash === '#machines') {
+ const y = machineSection.getBoundingClientRect().top + window.scrollY - 90;
+ window.scrollTo(0, Math.max(0, y));
+ }
}
}
@@ -58,27 +58,6 @@
@@ -58,27 +58,6 @@
An approval says what kind of act it is in plain words (plan HO1: publishing for others to see, sending
An approval says what kind of act it is in plain words (plan HO1: publishing for others to see, sending
something to someone, spending your money), which agent asked and in which thread; the agent's name is
something to someone, spending your money), which agent asked and in which thread; the agent's name is
shown, never its id. Review opens the approval itself — the hub never approves with one tap. -->
shown, never its id. Review opens the approval itself — the hub never approves with one tap. -->
- <section class="ny" id="needs-you" hidden aria-labelledby="needs-you-h">
- <div class="ny-h">
- <h2 id="needs-you-h"><span class="ny-dot" aria-hidden="true"></span>Needs you</h2>
- <span class="ny-sub" id="needs-you-sub"></span>
- </div>
- <div class="ny-list" id="needs-you-list"></div>
- </section>
-
- <!-- Receipts (plan OpenVibe_Seven_Domains_Plan R2 item 13): the latest five receipt-category summaries Inbox AI
- keeps on openvibe.email, each a link to its thread, with a counted fold for the rest and never a tab.
- my/receipts.js fills it from GET /api/v1/me/receipts; with Inbox AI off (or no mailbox) the one quiet line
- links to the switch, and no sample receipt is ever drawn. The card stays out until it has an answer. -->
- <section class="rc" id="receipts" hidden aria-labelledby="receipts-h">
- <div class="rc-h">
- <h2 id="receipts-h"><span class="rc-ic" aria-hidden="true"><i class="fa-solid fa-file-lines"></i></span>Receipts</h2>
- </div>
- <div class="rc-body" id="receipts-body"></div>
- </section>
-
- <!-- You (11-07 redesign; the owner: "front-face the best stuff we have to offer … awesome design"): who you are in
- your own colour, your balances, and one tap to what you do most, on every site. -->
<header class="me" id="page-header">
<header class="me" id="page-header">
<div class="me-top">
<div class="me-top">
<div class="me-who">
<div class="me-who">
@@ -100,36 +79,50 @@
@@ -100,36 +79,50 @@
<a class="me-tip" href="https://openvibe.tips"><i class="fa-solid fa-hand-holding-heart" aria-hidden="true"></i> Tip a creator</a>
<a class="me-tip" href="https://openvibe.tips"><i class="fa-solid fa-hand-holding-heart" aria-hidden="true"></i> Tip a creator</a>
</div>
</div>
</div>
</div>
- <nav class="me-acts" id="me-acts" aria-label="Quick actions"></nav>
</header>
</header>
+ <section class="ny" id="needs-you" hidden aria-labelledby="needs-you-h">
+ <div class="ny-h">
+ <h2 id="needs-you-h"><span class="ny-dot" aria-hidden="true"></span>Needs you</h2>
+ <span class="ny-sub" id="needs-you-sub"></span>
+ </div>
+ <div class="ny-list" id="needs-you-list"></div>
+ </section>
+ <nav class="me-acts" id="me-acts" aria-label="Quick actions"></nav>
+
<!-- You, today (plan L43): what is happening for you now, what you were doing, your money, your sites and your
<!-- You, today (plan L43): what is happening for you now, what you were doing, your money, your sites and your
numbers. my/today.js fills each block from its own API; a block with nothing in it stays out. -->
numbers. my/today.js fills each block from its own API; a block with nothing in it stays out. -->
<div class="td" id="today">
<div class="td" id="today">
- <section class="td-sec" id="td-now" hidden aria-labelledby="td-now-h">
- <div class="td-h"><h2 id="td-now-h"><span class="td-pulse" aria-hidden="true"></span>Live from people you follow</h2><span class="td-sub" id="td-now-sub"></span></div>
- <div class="td-rail" id="td-live"></div>
- </section>
- <div class="td-chips" id="td-chips" hidden></div>
<section class="td-sec" id="td-continue" hidden aria-labelledby="td-continue-h">
<section class="td-sec" id="td-continue" hidden aria-labelledby="td-continue-h">
<div class="td-h"><h2 id="td-continue-h">Pick up where you left off</h2><a class="td-more" href="#history" onclick="showSection('history');document.getElementById('settings').scrollIntoView({behavior:'smooth'});return false">History</a></div>
<div class="td-h"><h2 id="td-continue-h">Pick up where you left off</h2><a class="td-more" href="#history" onclick="showSection('history');document.getElementById('settings').scrollIntoView({behavior:'smooth'});return false">History</a></div>
<div class="td-rail" id="td-continue-list"></div>
<div class="td-rail" id="td-continue-list"></div>
</section>
</section>
- <section class="td-sec" id="td-net" hidden aria-labelledby="td-net-h">
- <div class="td-h"><h2 id="td-net-h">On OpenVibe now</h2><a class="td-more" href="/sites">Every site</a></div>
- <div class="td-net" id="td-net-cards"></div>
- </section>
<div class="td-grid">
<div class="td-grid">
<section class="td-box" id="td-sites" hidden aria-labelledby="td-sites-h">
<section class="td-box" id="td-sites" hidden aria-labelledby="td-sites-h">
<div class="td-h"><h2 id="td-sites-h">Your sites</h2><a class="td-more" href="/sites">All sites</a></div>
<div class="td-h"><h2 id="td-sites-h">Your sites</h2><a class="td-more" href="/sites">All sites</a></div>
<div class="td-sites" id="td-sites-grid"></div>
<div class="td-sites" id="td-sites-grid"></div>
<div class="td-tries" id="td-try" hidden></div>
<div class="td-tries" id="td-try" hidden></div>
</section>
</section>
+ <section class="td-box td-machines" id="td-machines-summary" hidden aria-labelledby="td-machines-h">
+ <div class="td-h"><h2 id="td-machines-h">Your machines</h2><span class="td-sub" id="td-machines-count"></span></div>
+ <p id="td-machines-state">Loading your machines…</p>
+ <button class="ovk-btn ovk-btn--primary" type="button" onclick="openMachines(true)">Pair a computer</button>
+ </section>
<section class="td-box td-nums" id="td-nums" hidden aria-labelledby="td-nums-h">
<section class="td-box td-nums" id="td-nums" hidden aria-labelledby="td-nums-h">
<div class="td-h"><h2 id="td-nums-h">Your numbers</h2><a class="td-more" href="https://openvibe.report/you">On openvibe.report</a></div>
<div class="td-h"><h2 id="td-nums-h">Your numbers</h2><a class="td-more" href="https://openvibe.report/you">On openvibe.report</a></div>
<div class="td-stats" id="td-stuff-row"></div>
<div class="td-stats" id="td-stuff-row"></div>
</section>
</section>
</div>
</div>
+ <section class="td-sec" id="td-now" hidden aria-labelledby="td-now-h">
+ <div class="td-h"><h2 id="td-now-h"><span class="td-pulse" aria-hidden="true"></span>Live from people you follow</h2><span class="td-sub" id="td-now-sub"></span></div>
+ <div class="td-rail" id="td-live"></div>
+ </section>
+ <div class="td-chips" id="td-chips" hidden></div>
+
+ <section class="td-sec" id="td-net" hidden aria-labelledby="td-net-h">
+ <div class="td-h"><h2 id="td-net-h">On OpenVibe now</h2><a class="td-more" href="/sites">Every site</a></div>
+ <div class="td-net" id="td-net-cards"></div>
+ </section>
<div class="recent-tools" id="my-recent-tools" hidden>
<div class="recent-tools" id="my-recent-tools" hidden>
<h3><i class="fa-solid fa-screwdriver-wrench"></i> Your tools</h3>
<h3><i class="fa-solid fa-screwdriver-wrench"></i> Your tools</h3>
<div class="recent-tools-list" id="my-recent-tools-list"></div>
<div class="recent-tools-list" id="my-recent-tools-list"></div>
@@ -137,6 +130,15 @@
@@ -137,6 +130,15 @@
</div>
</div>
</div>
</div>
+ <details id="setup-fold" class="setup-fold" hidden><summary>Set up more (0)</summary><div class="setup-items"></div></details>
+
+ <section class="rc" id="receipts" hidden aria-labelledby="receipts-h">
+ <div class="rc-h">
+ <h2 id="receipts-h"><span class="rc-ic" aria-hidden="true"><i class="fa-solid fa-file-lines"></i></span>Receipts</h2>
+ </div>
+ <div class="rc-body" id="receipts-body"></div>
+ </section>
+
<div class="card" id="account-emails-card">
<div class="card" id="account-emails-card">
<h2><span class="icon"><i class="fa-solid fa-envelope"></i></span> Email addresses</h2>
<h2><span class="icon"><i class="fa-solid fa-envelope"></i></span> Email addresses</h2>
<p>Keep several verified addresses on your account. Only you can see this list.</p>
<p>Keep several verified addresses on your account. Only you can see this list.</p>
@@ -491,9 +493,10 @@
@@ -491,9 +493,10 @@
<div class="mc-top">
<div class="mc-top">
<div>
<div>
<h3><span class="icon"><i class="fa-solid fa-laptop"></i></span> Your machines</h3>
<h3><span class="icon"><i class="fa-solid fa-laptop"></i></span> Your machines</h3>
+ <p class="mc-count" id="mc-count" aria-live="polite">Loading machines…</p>
<p class="muted">Computers and servers paired with your account. They run your Codes jobs, host your desks and serve your own models. Only you can pair one or change what it does.</p>
<p class="muted">Computers and servers paired with your account. They run your Codes jobs, host your desks and serve your own models. Only you can pair one or change what it does.</p>
</div>
</div>
- <button class="btn btn-primary" id="mc-pair-btn" type="button" onclick="mcPair()"><i class="fa-solid fa-plus"></i> Pair a machine</button>
+ <button class="btn btn-primary" id="mc-pair-btn" type="button" onclick="mcPair()"><i class="fa-solid fa-plus"></i> Pair a computer</button>
</div>
</div>
<div class="mc-pair" id="mc-pair" hidden></div>
<div class="mc-pair" id="mc-pair" hidden></div>
<p class="mc-status" id="mc-status" role="status"></p>
<p class="mc-status" id="mc-status" role="status"></p>
@@ -226,9 +226,9 @@ async function createMyRoutes({ db, config = require('../config'), sso, pagePath
@@ -226,9 +226,9 @@ async function createMyRoutes({ db, config = require('../config'), sso, pagePath
router.use((req, res, next) => {
router.use((req, res, next) => {
const host = hostOf(req);
const host = hostOf(req);
if (host === WWW_HOST) return res.redirect(301, `https://${MY_HOST}${req.originalUrl}`);
if (host === WWW_HOST) return res.redirect(301, `https://${MY_HOST}${req.originalUrl}`);
- // The machines hub moved to the Link product: /machines goes there from both the apex and the hub,
- // carrying a handoff ticket when the browser is signed in here.
+ // The signed-in account hub serves its own Machines section; Network routes keep the Link pairing handoff.
if (req.path === '/machines' && ['GET', 'HEAD'].includes(req.method) && (host === MY_HOST || NETWORK_HOSTS.has(host))) {
if (req.path === '/machines' && ['GET', 'HEAD'].includes(req.method) && (host === MY_HOST || NETWORK_HOSTS.has(host))) {
+ if (host === MY_HOST) { req.ovMyHost = true; return next(); }
return machinesRedirect(req, res, host).catch(() => res.redirect(302, `https://openvibe.link/machines${queryOf(req)}`));
return machinesRedirect(req, res, host).catch(() => res.redirect(302, `https://openvibe.link/machines${queryOf(req)}`));
}
}
if (NETWORK_HOSTS.has(host)) {
if (NETWORK_HOSTS.has(host)) {
@@ -35,6 +35,7 @@
@@ -35,6 +35,7 @@
* .roles_changed and .revoked (the person's own changes).
* .roles_changed and .revoked (the person's own changes).
*/
*/
const express = require('express');
const express = require('express');
+const QRCode = require('qrcode');
const crypto = require('crypto');
const crypto = require('crypto');
const { capabilities, http, ids, serviceAuth, validate } = require('openvibe-contracts');
const { capabilities, http, ids, serviceAuth, validate } = require('openvibe-contracts');
const eventRelay = require('../developer/event-relay');
const eventRelay = require('../developer/event-relay');
@@ -421,6 +422,7 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
@@ -421,6 +422,7 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
const p = (out && out.pairing) || {};
const p = (out && out.pairing) || {};
res.status(201).json({ pairing: {
res.status(201).json({ pairing: {
code: String(p.code || ''), expires_at: p.expires_at, installer: String(p.installer || ''),
code: String(p.code || ''), expires_at: p.expires_at, installer: String(p.installer || ''),
+ qr_data: await QRCode.toDataURL(String(p.code || ''), { width: 220, margin: 1 }),
...(p.pairing_id ? { pairing_id: p.pairing_id } : {}),
...(p.pairing_id ? { pairing_id: p.pairing_id } : {}),
} });
} });
}));
}));
@@ -128,7 +128,8 @@ const MY = 'openvibe.my';
@@ -128,7 +128,8 @@ const MY = 'openvibe.my';
assert.strictEqual(r.status, 200, 'a Network guest gets the page quietly, with sso=none');
assert.strictEqual(r.status, 200, 'a Network guest gets the page quietly, with sso=none');
assert.ok(r.body.includes('id="needs-you"'), 'the hub leads with Needs you');
assert.ok(r.body.includes('id="needs-you"'), 'the hub leads with Needs you');
assert.ok(r.body.includes('id="needs-you-list"'), 'and has its list');
assert.ok(r.body.includes('id="needs-you-list"'), 'and has its list');
- assert.ok(r.body.indexOf('id="needs-you"') < r.body.indexOf('id="page-header"'), 'Needs you is above the hero');
+ assert.ok(r.body.indexOf('id="page-header"') < r.body.indexOf('id="needs-you"'), 'the person comes first');
+ assert.ok(r.body.indexOf('id="needs-you"') < r.body.indexOf('id="me-acts"'), 'actions follow Needs you');
assert.ok(r.body.includes('id="page-header"') && r.body.includes('class="section-tabs"'), 'the hero and sections stay');
assert.ok(r.body.includes('id="page-header"') && r.body.includes('class="section-tabs"'), 'the hero and sections stay');
assert.ok(r.body.includes('<link rel="canonical" href="https://openvibe.my/">'), 'the canonical is openvibe.my');
assert.ok(r.body.includes('<link rel="canonical" href="https://openvibe.my/">'), 'the canonical is openvibe.my');
assert.strictEqual(r.headers['cache-control'], 'no-store', 'the hub is not cached');
assert.strictEqual(r.headers['cache-control'], 'no-store', 'the hub is not cached');
@@ -136,6 +137,10 @@ const MY = 'openvibe.my';
@@ -136,6 +137,10 @@ const MY = 'openvibe.my';
r = await get('/', MY, ['ov_token=already']);
r = await get('/', MY, ['ov_token=already']);
assert.strictEqual(r.status, 200, 'a session skips the silent redirect');
assert.strictEqual(r.status, 200, 'a session skips the silent redirect');
+ r = await get('/machines', MY, ['ov_token=already']);
+ assert.strictEqual(r.status, 200, 'signed-in /machines stays in the hub');
+ assert.ok(r.body.includes('id="sec-machines"'), 'serves Machines');
+
// ── The sign-in: PKCE S256, state and verifier in host-only cookies ──
// ── The sign-in: PKCE S256, state and verifier in host-only cookies ──
r = await get('/auth/login?next=%2Fsecurity', MY);
r = await get('/auth/login?next=%2Fsecurity', MY);
assert.strictEqual(r.status, 302);
assert.strictEqual(r.status, 302);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
-// The hub's old paths here mint a one-time handoff ticket for link-web when the browser is signed in here,
-// so openvibe.link signs them in with no round trip. Signed out, the plain hop lands on silent sign-in.
+// Network's apex sends signed-in /machines visitors to Link with a handoff ticket. openvibe.my keeps its
+// signed-in Machines section and silently signs in visitors who arrive there without a session.
// ov test test/my-machines-handoff.test.js (or: node test/my-machines-handoff.test.js)
// ov test test/my-machines-handoff.test.js (or: node test/my-machines-handoff.test.js)
const assert = require('assert');
const assert = require('assert');
const http = require('http');
const http = require('http');
@@ -31,23 +31,21 @@ const server = http.createServer(app);
@@ -31,23 +31,21 @@ const server = http.createServer(app);
req.on('error', reject);
req.on('error', reject);
});
});
- // Signed in here: the hop carries a ticket minted for link-web.
+ // The account hub's own /machines route stays here and serves its Machines section.
let r = await get('/machines', 'ov_token=good-token');
let r = await get('/machines', 'ov_token=good-token');
- assert.strictEqual(r.status, 302, 'signed in 302s to the hub');
- const to = new URL(r.headers.location);
- assert.strictEqual(to.origin + to.pathname, 'https://openvibe.link/machines');
- assert.ok(/^ovh_[A-Za-z0-9_-]{20,200}$/.test(to.searchParams.get('ov_handoff')), 'a ticket rides along');
+ assert.strictEqual(r.status, 200, 'signed in /machines stays on the account hub');
+ assert.ok(r.body.includes('id="sec-machines"'));
- // Signed out (or a bad cookie): the plain hop, which lands on the hub's silent sign-in.
+ // Signed out: openvibe.my starts its silent sign-in with /machines as the return path.
r = await get('/machines');
r = await get('/machines');
assert.strictEqual(r.status, 302);
assert.strictEqual(r.status, 302);
- assert.strictEqual(r.headers.location, 'https://openvibe.link/machines', 'no ticket when signed out');
+ assert.strictEqual(r.headers.location, '/auth/login?silent=1&next=%2Fmachines');
r = await get('/machines', 'ov_token=nope');
r = await get('/machines', 'ov_token=nope');
- assert.strictEqual(r.headers.location, 'https://openvibe.link/machines', 'no ticket on a bad session');
+ assert.strictEqual(r.status, 200, 'an existing session cookie reaches the account hub');
- // The old Network path mints the same way.
+ // Network apex arrivals still get Link's signed-in handoff.
r = await get('/machines', 'ov_token=good-token', 'openvibe.network');
r = await get('/machines', 'ov_token=good-token', 'openvibe.network');
- assert.ok(new URL(r.headers.location).searchParams.get('ov_handoff'), 'apex arrivals mint too');
+ assert.ok(new URL(r.headers.location).searchParams.get('ov_handoff'));
console.log('my-machines-handoff.test.js: all assertions passed');
console.log('my-machines-handoff.test.js: all assertions passed');
server.close();
server.close();
@@ -61,6 +61,8 @@ const ago = (m) => new Date(Date.now() - m * 60000).toISOString();
@@ -61,6 +61,8 @@ const ago = (m) => new Date(Date.now() - m * 60000).toISOString();
assert.ok(HTML.includes('id="sec-machines"') && HTML.includes("showSection('machines',this)"), 'a Machines section and tab');
assert.ok(HTML.includes('id="sec-machines"') && HTML.includes("showSection('machines',this)"), 'a Machines section and tab');
assert.ok(HTML.indexOf('<script src="/js/my/machines.js" defer>') > 0, 'its script');
assert.ok(HTML.indexOf('<script src="/js/my/machines.js" defer>') > 0, 'its script');
assert.match(CORE, /'\/machines': 'machines'/, 'the /machines address opens it');
assert.match(CORE, /'\/machines': 'machines'/, 'the /machines address opens it');
+assert.match(CORE, /hash === 'pair' \? 'machines'/, '#pair opens Machines');
+assert.match(CORE, /if \(routeSection === 'machines' && hash === 'pair'\) setTimeout\(\(\) => mcPair\(\), 0\)/, '#pair starts pairing');
assert.match(CORE, /if \(id === 'machines' && typeof loadMachines === 'function'\) loadMachines\(\);/, 'it loads when opened');
assert.match(CORE, /if \(id === 'machines' && typeof loadMachines === 'function'\) loadMachines\(\);/, 'it loads when opened');
assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the account hub, not a 404');
assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the account hub, not a 404');
@@ -15,9 +15,13 @@ const HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'u
@@ -15,9 +15,13 @@ const HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'u
const JS = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'receipts.js'), 'utf8');
const JS = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'receipts.js'), 'utf8');
const CSS = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'my.css'), 'utf8');
const CSS = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'my.css'), 'utf8');
-// ── The page: the card sits under Needs you, above the hero, and starts out hidden ──
-assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="receipts"'), 'Receipts follows Needs you');
-assert.ok(HTML.indexOf('id="receipts"') < HTML.indexOf('id="page-header"'), 'and is above the hero');
+// ── The page: the person leads, then Needs you, actions, history, sites, machines, numbers and receipts ──
+assert.ok(HTML.indexOf('id="page-header"') < HTML.indexOf('id="needs-you"'));
+assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="me-acts"'));
+assert.ok(HTML.indexOf('id="td-continue"') < HTML.indexOf('id="td-sites"'));
+assert.ok(HTML.indexOf('id="td-sites"') < HTML.indexOf('id="td-machines-summary"'));
+assert.ok(HTML.indexOf('id="td-machines-summary"') < HTML.indexOf('id="td-nums"'));
+assert.ok(HTML.indexOf('id="td-nums"') < HTML.indexOf('id="receipts"'));
assert.match(HTML, /<section class="rc" id="receipts" hidden/);
assert.match(HTML, /<section class="rc" id="receipts" hidden/);
assert.match(HTML, /<script src="\/js\/my\/receipts\.js" defer><\/script>/, 'the card has its own script');
assert.match(HTML, /<script src="\/js\/my\/receipts\.js" defer><\/script>/, 'the card has its own script');
assert.match(CSS, /\.rc-row \{/, 'the rows have a style');
assert.match(CSS, /\.rc-row \{/, 'the rows have a style');
@@ -34,12 +38,16 @@ class Node {
@@ -34,12 +38,16 @@ class Node {
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
set textContent(v) { this._text = String(v); this.children = []; }
set textContent(v) { this._text = String(v); this.children = []; }
get href() { return this.attrs.href; }
get href() { return this.attrs.href; }
+ querySelector(sel) { return sel === 'summary' ? this.summary : sel === '.setup-items' ? this.items : null; }
all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
}
}
function page() {
function page() {
const ids = {};
const ids = {};
for (const [, id] of HTML.matchAll(/id="([^"]+)"/g)) ids[id] = new Node('div');
for (const [, id] of HTML.matchAll(/id="([^"]+)"/g)) ids[id] = new Node('div');
+ ids['setup-fold'].hidden = true;
+ ids['setup-fold'].summary = new Node('summary');
+ ids['setup-fold'].items = new Node('div');
const sandbox = {
const sandbox = {
document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t) },
document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t) },
console, Date, Number, String, Array, Map, Set, Math, JSON, Promise, encodeURIComponent,
console, Date, Number, String, Array, Map, Set, Math, JSON, Promise, encodeURIComponent,
@@ -55,16 +63,14 @@ const receipt = (i) => ({
@@ -55,16 +63,14 @@ const receipt = (i) => ({
});
});
(async () => {
(async () => {
- // ── Inbox AI off (or no mailbox): one quiet line, a link to the switch, and no sample receipt ──
+ // ── Inbox AI off: one counted setup fold, no empty Receipts card, and no sample receipt ──
let p = page();
let p = page();
p.sandbox.renderReceipts({ enabled: false, receipts: [] });
p.sandbox.renderReceipts({ enabled: false, receipts: [] });
- assert.strictEqual(p.ids.receipts.hidden, false, 'the card says why it is empty');
- const quiet = p.ids['receipts-body'].children;
- assert.strictEqual(quiet.length, 1, 'one line only');
- assert.strictEqual(quiet[0].tagName, 'A');
- assert.strictEqual(quiet[0].textContent, 'Turn on Inbox AI on openvibe.email to see receipts here');
- assert.strictEqual(quiet[0].getAttribute('href'), 'https://openvibe.email/inbox#inbox-ai', 'it links to the switch');
- assert.ok(!/Shop|Order|sample|example/i.test(p.ids['receipts-body'].textContent), 'no sample or fake receipt');
+ assert.strictEqual(p.ids.receipts.hidden, true, 'no empty Receipts card');
+ assert.strictEqual(p.ids['setup-fold'].hidden, false);
+ assert.strictEqual(p.ids['setup-fold'].summary.textContent, 'Set up more (1)');
+ assert.strictEqual(p.ids['setup-fold'].items.children[0].textContent, 'Set up receipts with Inbox AI');
+ assert.strictEqual(p.ids['setup-fold'].items.children[0].getAttribute('href'), 'https://openvibe.email/inbox#inbox-ai');
// ── On with receipts: the latest five, then a counted fold for the rest, each a thread on openvibe.email ──
// ── On with receipts: the latest five, then a counted fold for the rest, each a thread on openvibe.email ──
p = page();
p = page();
@@ -98,11 +104,11 @@ const receipt = (i) => ({
@@ -98,11 +104,11 @@ const receipt = (i) => ({
p.sandbox.renderReceipts({ enabled: true, receipts: [{ ...receipt(0), message_id: null, thread_id: 'thr_ok' }] });
p.sandbox.renderReceipts({ enabled: true, receipts: [{ ...receipt(0), message_id: null, thread_id: 'thr_ok' }] });
assert.strictEqual(p.ids['receipts-body'].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.email/inbox/thr_ok');
assert.strictEqual(p.ids['receipts-body'].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.email/inbox/thr_ok');
- // ── On with nothing triaged yet: a quiet line, not the turn-it-on line ──
+ // ── On with nothing triaged yet: neither receipts nor setup nudge is shown ──
p = page();
p = page();
p.sandbox.renderReceipts({ enabled: true, receipts: [] });
p.sandbox.renderReceipts({ enabled: true, receipts: [] });
- assert.match(p.ids['receipts-body'].textContent, /^No receipts yet\.$/);
- assert.ok(!/Turn on Inbox AI/.test(p.ids['receipts-body'].textContent));
+ assert.strictEqual(p.ids.receipts.hidden, true);
+ assert.strictEqual(p.ids['setup-fold'].hidden, true);
// ── The route: Mail is asked with the person's own token; off hides rows, on passes them, down stays out ──
// ── The route: Mail is asked with the person's own token; off hides rows, on passes them, down stays out ──
const seen = [];
const seen = [];
@@ -175,22 +175,27 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
@@ -175,22 +175,27 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
p.ids['td-stuff-row'].children.find((c) => /history/.test(c.textContent)).click();
p.ids['td-stuff-row'].children.find((c) => /history/.test(c.textContent)).click();
assert.deepStrictEqual(p.shown, ['history'], 'your history opens below');
assert.deepStrictEqual(p.shown, ['history'], 'your history opens below');
+ p.sandbox.renderMachineSummary({ machines: [{ name: 'Studio PC', online: true }, { name: 'Laptop', online: false }, { id: 'revoked', revoked_at: 'x', online: true }] });
+ assert.strictEqual(p.ids['td-machines-count'].textContent, '2 paired');
+ assert.strictEqual(p.ids['td-machines-state'].textContent, '1 online now · Studio PC');
+
// ── Needs you (plan §4): the Action Center's waiting items, one button each, above the hero ──
// ── Needs you (plan §4): the Action Center's waiting items, one button each, above the hero ──
p = page();
p = page();
- assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="page-header"'), 'Needs you is the first section');
+ assert.ok(HTML.indexOf('id="page-header"') < HTML.indexOf('id="needs-you"'), 'the person is first');
+ assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="me-acts"'), 'actions follow Needs you');
p.sandbox.renderNeedsYou({ buckets: { waiting: [
p.sandbox.renderNeedsYou({ buckets: { waiting: [
- { service: 'network', id: 'cnf_1', kind: 'approval', title: 'Send one email to the venue', summary: 'Trip planner asks to use mail.send', agent: 'Trip planner', href: 'https://openvibe.network/my#approvals', actions: ['approve', 'decline', 'open'] },
+ { service: 'network', id: 'cnf_1', kind: 'approval', title: 'Run a command', summary: 'Trip planner asks to run a command', agent: 'Trip planner', href: 'https://openvibe.network/my#approvals', actions: ['approve', 'decline', 'open'] },
{ service: 'actor', id: 'tsk_1', kind: 'agent_task', title: 'Find three cheap flights', summary: 'Read 4 pages', href: 'https://openvibe.actor/tasks/tsk_1', actions: ['open'] },
{ service: 'actor', id: 'tsk_1', kind: 'agent_task', title: 'Find three cheap flights', summary: 'Read 4 pages', href: 'https://openvibe.actor/tasks/tsk_1', actions: ['open'] },
- ] } });
+ ] } }, { confirmations: [{ id: 'cnf_1', capability: 'computer.command', details: { command: 'rm -rf /tmp/actor-gate-proof', reason: 'Clean up a test file', arguments: { raw: true } }, resources: [{ type: 'machine', name: 'devprobe' }] }] });
assert.strictEqual(p.ids['needs-you'].hidden, false);
assert.strictEqual(p.ids['needs-you'].hidden, false);
assert.strictEqual(p.ids['needs-you-sub'].textContent, '2 things waiting');
assert.strictEqual(p.ids['needs-you-sub'].textContent, '2 things waiting');
const rows = p.ids['needs-you-list'].children;
const rows = p.ids['needs-you-list'].children;
assert.strictEqual(rows.length, 2);
assert.strictEqual(rows.length, 2);
- const review = rows[0].all((c) => c.tagName === 'A')[0];
- assert.strictEqual(review.textContent, 'Review', 'an approval opens itself: never one tap from the hub');
- assert.strictEqual(rows[0].all((c) => c.tagName === 'BUTTON').length, 0, 'no Approve button here');
- assert.strictEqual(rows[0].all((c) => c.tagName === 'H3')[0].textContent, 'Send one email to the venue');
- assert.strictEqual(rows[0].all((c) => c.tagName === 'P')[0].textContent, 'Trip planner asks to use mail.send');
+ assert.strictEqual(rows[0].all((c) => c.tagName === 'BUTTON').length, 2, 'inline approve and decline');
+ assert.strictEqual(rows[0].all((c) => c.tagName === 'H3')[0].textContent, 'Run a command on devprobe');
+ assert.match(rows[0].textContent, /rm -rf \/tmp\/actor-gate-proof/);
+ assert.doesNotMatch(rows[0].textContent, /"arguments"|\[object Object\]/, 'structured arguments stay hidden');
+ assert.match(rows[0].textContent, /Details/);
assert.strictEqual(rows[1].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.actor/tasks/tsk_1', 'an open item links its page');
assert.strictEqual(rows[1].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.actor/tasks/tsk_1', 'an open item links its page');
// An agent known only by its id is not shown by it; Try again stays one tap.
// An agent known only by its id is not shown by it; Try again stays one tap.
p.sandbox.renderNeedsYou({ buckets: { waiting: [
p.sandbox.renderNeedsYou({ buckets: { waiting: [