openvibers/openvibe.actor Commits
Your Actor opens as an agent's home: a status bar, what needs you first, a docked composer (#408)
Changes
@@@ -7,3 +7,70 @@.ay-run-model-row select{flex:1;min-width:0}.ay-run-model-row .ovk-btn{flex:none;min-height:44px}.ay-run-model-note{margin:0;font-size:.82rem;line-height:1.45;color:var(--text-secondary)}++++/* ── /you as an agent's home (2026-11-10). The status bar stays on screen; what needs the person is first; the composer++ is docked at the bottom of the screen, above the app's bar; the page keeps room for it. ── */++.ay-page{padding-bottom:calc(84px + env(safe-area-inset-bottom,0px))}++.ay-bar{position:sticky;top:calc(var(--ovnav-h,56px) + 6px);z-index:6;display:flex;align-items:center;gap:10px;min-width:0;margin:0 0 12px;padding:8px 8px 8px 10px;border-radius:20px;border:1px solid var(--border,rgba(255,255,255,.1));background:color-mix(in srgb,var(--bg-card,#131c2e) 90%,transparent);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 10px 28px -18px rgba(0,0,0,.7)}++.ay-bar .ay-avatar{width:40px;height:40px;border-radius:14px;flex:none}++.ay-id{display:grid;gap:1px;min-width:0;flex:1}++.ay-bar .ay-name-button{all:unset;cursor:pointer;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:800;font-size:1rem;line-height:1.25;color:var(--text-primary,#e6edf7)}++.ay-bar .ay-name-button:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px;border-radius:6px}++.ay-bar .ay-state{display:inline-flex;align-items:center;gap:6px;min-width:0;font-size:.8rem;font-weight:650;line-height:1.25;color:var(--text-secondary,#96a7c2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}++.ay-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--text-muted,#7386a3)}++.ay-bar[data-state=working] .ay-state{color:var(--success-text,var(--success,#34d399))}++.ay-bar[data-state=waiting] .ay-state{color:var(--warning-text,var(--warning,#fbbf24))}++.ay-bar[data-state=waiting] .ay-dot{background:currentColor;animation:ay-wait 1.6s ease-in-out infinite}++.ay-bar[data-state=waiting]{border-color:color-mix(in srgb,var(--warning,#fbbf24) 45%,transparent)}++@keyframes ay-wait{50%{opacity:.35;transform:scale(.8)}}++.ay-bar-spend{display:grid;justify-items:end;flex:none;line-height:1.15;text-decoration:none;color:var(--text-primary,#e6edf7);font-variant-numeric:tabular-nums}++.ay-bar-spend strong{font-size:.95rem}++.ay-bar-spend small{font-size:.68rem;color:var(--text-muted,#7386a3)}++.ay-bar-btn{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:6px;flex:none;min-width:44px;height:44px;padding:0 12px;border-radius:14px;cursor:pointer;font-size:.86rem;font-weight:750;color:var(--text-primary,#e6edf7);background:color-mix(in srgb,var(--text-primary,#e6edf7) 8%,transparent);transition:background-color .15s ease,transform .12s ease}++.ay-bar-btn .ovk-i{width:18px;height:18px}++.ay-bar-btn:hover{background:color-mix(in srgb,var(--text-primary,#e6edf7) 14%,transparent)}++.ay-bar-btn:active{transform:scale(.95)}++.ay-bar-btn:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}++.ay-bar-btn.is-go{background:var(--accent-strong,var(--accent,#2563eb));color:var(--on-accent-strong,#fff)}++.ay-bar-btn--icon{padding:0;width:44px}++.ay-more{position:relative;flex:none}++.ay-more>summary{list-style:none}.ay-more>summary::-webkit-details-marker{display:none}++.ay-more-pop{position:absolute;right:0;top:calc(100% + 8px);z-index:8;display:grid;gap:2px;min-width:230px;padding:6px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.1));background:var(--bg-card,#131c2e);box-shadow:0 18px 40px rgba(0,0,0,.5);animation:ay-pop .18s cubic-bezier(.2,.9,.3,1.1)}++@keyframes ay-pop{from{opacity:0;transform:translateY(calc(-6px*var(--ov-move,1))) scale(.98)}}++.ay-more-item{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:10px;min-height:48px;padding:6px 10px;border-radius:11px;cursor:pointer;color:var(--text-primary,#e6edf7)}++.ay-more-item:hover,.ay-more-item:focus-visible{background:color-mix(in srgb,var(--text-primary,#e6edf7) 8%,transparent)}++.ay-more-item .ovk-i{width:20px;height:20px;color:var(--accent,#3b82f6);flex:none}++.ay-more-item b{display:block;font-size:.9rem}.ay-more-item small{display:block;font-size:.78rem;color:var(--text-muted,#7386a3)}++@media (max-width:420px){.ay-bar-btn:not(.ay-bar-btn--icon) span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}.ay-bar-btn:not(.ay-bar-btn--icon){padding:0;width:44px}}++/* What needs the person comes first and says so. */++.ay-page[data-state=waiting] #ay-asks{border-color:color-mix(in srgb,var(--warning,#fbbf24) 50%,transparent);box-shadow:0 0 0 3px color-mix(in srgb,var(--warning,#fbbf24) 12%,transparent)}++/* The chips that jump to the page's sections. */++.ay-tabs{grid-column:1/-1;display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;margin:0 -2px;padding:2px}++.ay-tabs::-webkit-scrollbar{display:none}++.ay-tab{display:inline-flex;align-items:center;gap:7px;flex:none;min-height:44px;padding:0 14px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));background:color-mix(in srgb,var(--text-primary,#e6edf7) 4%,transparent);color:var(--text-secondary,#96a7c2);font-size:.88rem;font-weight:700;text-decoration:none;transition:color .15s ease,border-color .15s ease}++.ay-tab .ovk-i{width:16px;height:16px;color:var(--accent,#3b82f6)}++.ay-tab:hover,.ay-tab:focus-visible{color:var(--text-primary,#e6edf7);border-color:color-mix(in srgb,var(--accent,#3b82f6) 50%,transparent)}++.ay-card{scroll-margin-top:calc(var(--ovnav-h,56px) + 84px)}++.ay-said{grid-column:1/-1}.ay-said[hidden]{display:none}++/* Goals on the first day: three ways to start, each a sentence. */++.ay-starters{display:grid;gap:10px}++.ay-starters-lede{margin:0;color:var(--text-secondary,#96a7c2)}++.ay-starter-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:8px}++.ay-starter{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:12px;min-height:64px;padding:10px 12px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.1));background:color-mix(in srgb,var(--accent,#3b82f6) 6%,transparent);cursor:pointer;color:var(--text-primary,#e6edf7);transition:border-color .15s ease,transform .12s ease,background-color .15s ease}++.ay-starter:hover,.ay-starter:focus-visible{border-color:color-mix(in srgb,var(--accent,#3b82f6) 60%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}++.ay-starter:active{transform:scale(.985)}++.ay-starter-i{display:grid;place-items:center;flex:none;width:40px;height:40px;border-radius:13px;background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:var(--accent,#3b82f6)}++.ay-starter-i .ovk-i{width:20px;height:20px}++.ay-starter b{display:block}.ay-starter small{display:block;margin-top:1px;font-size:.82rem;color:var(--text-secondary,#96a7c2)}++/* The composer, docked. */++.ay-dock{position:fixed;left:0;right:0;bottom:var(--ovnav-appbar-h,0px);z-index:7;padding:18px 10px calc(8px + max(0px,env(safe-area-inset-bottom,0px) - var(--ovnav-appbar-h,0px)));background:linear-gradient(to bottom,transparent,var(--bg-primary,#0a0f1c) 38%);pointer-events:none}++.ay-dock .ay-thread{max-width:760px;margin:0 auto;pointer-events:auto}++.ay-dock .ovt-composer,.ay-dock form{box-shadow:0 14px 36px -14px rgba(0,0,0,.75)}++/* In the dock the composer is one line until the person writes more: Add, the text, Send in a row. */++.ay-dock .ovt-composer{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:0 6px;padding:6px 6px 6px 14px;border-radius:26px}++.ay-dock .ovt-composer textarea{grid-column:1;grid-row:1;min-height:44px;max-height:38vh;padding:11px 0;resize:none;field-sizing:content}++.ay-dock .ovt-c-row{grid-column:2;grid-row:1;display:flex;align-items:center;gap:4px;margin:0;padding:0}++.ay-dock .ovt-c-note{grid-column:1/-1;margin:0}++.ay-dock .ovt-c-note:empty{display:none}++.ay-dock .ovt-send{width:44px;height:44px;border-radius:50%}++@media (max-width:420px){.ay-bar{gap:8px;padding:7px 7px 7px 9px}.ay-bar .ay-avatar{width:36px;height:36px}.ay-bar-spend small{display:none}}@@@ -120,3 +120,5 @@try { const r=await fetch(api+'/computer/still',{credentials:'same-origin'}); if(!r.ok)return; const next=URL.createObjectURL(await r.blob()); img.src=next; if(oldFrame)URL.revokeObjectURL(oldFrame); oldFrame=next; } catch {}},2000);})();++// More (the status bar): it closes when one of its items is chosen, or on a press anywhere else.++document.addEventListener('click', (e) => { const m = document.querySelector('.ay-more[open]'); if (m && (!m.contains(e.target) || (e.target.closest && e.target.closest('.ay-more-item')))) m.open = false; });@@@ -19,9 +19,20 @@ function render(data, viewer = {}) {const pulse = '<span class="ay-pulse" aria-hidden="true"></span>';const avatarUrl = data.avatar_url || viewer.avatar;const avatar = avatarUrl && /^https:\/\//.test(avatarUrl) ? '<img src="' + e(avatarUrl) + '" alt="">' : e(a.name.slice(0, 1));-- const head = '<header id="ay-header" class="ay-header"><span class="ay-avatar">' + avatar + '</span><div class="ay-name"><span class="ay-eyebrow">YOUR ACTOR</span><button type="button" class="ay-name-button" data-rename>' + e(a.name) + '</button></div><span class="ovk-badge ay-state" data-state="' + state + '">' + (state === 'Working' ? pulse : '') + state + (state === 'Resting' && now.next ? ' until ' + time(now.next.at) : '') + '</span>' + button(a.enabled ? 'Pause' : 'Resume', 'data-action="' + (a.enabled ? 'pause' : 'resume') + '"') + '<div class="ay-chips">' + button(model + ' ▾', 'data-ovk-sheet="#ay-brain"') + button(pc, 'data-jump="ay-computer"') + '</div></header>';++ // The status bar: it stays on screen. Who, the state as a word with a live signal, today's spend against the cap,++ // Pause or Resume as one button, and More (the model and the computer, which were two chips on a row of their own).++ const stateKey = { 'Waiting for you': 'waiting', Paused: 'paused', Working: 'working', Resting: 'resting' }[state];++ const stateLine = (state === 'Working' ? pulse : '<span class="ay-dot" aria-hidden="true"></span>') + '<span>' + state + (asks.length > 1 ? ' · ' + asks.length + ' asks' : '') + (state === 'Resting' && now.next ? ' until ' + time(now.next.at) : '') + '</span>';++ const head = '<header id="ay-header" class="ay-bar" data-state="' + stateKey + '"><span class="ay-avatar">' + avatar + '</span>'++ + '<div class="ay-id"><button type="button" class="ay-name-button" data-rename title="Rename your Actor">' + e(a.name) + '</button><span class="ay-state" data-state="' + state + '">' + stateLine + '</span></div>'++ + '<a class="ay-bar-spend" href="#ay-money" data-jump="ay-money" title="Today’s spending, of your daily cap"><strong>' + money(now.cost_today) + '</strong><small>of ' + money(a.caps.usd) + '</small></a>'++ + '<button type="button" class="ay-bar-btn' + (a.enabled ? '' : ' is-go') + '" data-action="' + (a.enabled ? 'pause' : 'resume') + '" aria-label="' + (a.enabled ? 'Pause your Actor' : 'Resume your Actor') + '" title="' + (a.enabled ? 'Pause' : 'Resume') + '">' + kit.icon(a.enabled ? 'pause' : 'play') + '<span>' + (a.enabled ? 'Pause' : 'Resume') + '</span></button>'++ + '<details class="ay-more"><summary class="ay-bar-btn ay-bar-btn--icon" aria-label="More: its model and its computer" title="More">' + kit.icon('more') + '</summary><div class="ay-more-pop">'++ + '<button type="button" class="ay-more-item" data-ovk-sheet="#ay-brain">' + kit.icon('spark') + '<span><b>Model</b><small>' + e(model) + '</small></span></button>'++ + '<button type="button" class="ay-more-item" data-jump="ay-computer">' + kit.icon('computer') + '<span><b>Computer</b><small>' + e(pc) + '</small></span></button>'++ + '</div></details></header>';const screen = session ? '<div class="ay-screen" data-session="' + e(session) + '"><img id="ay-screen" src="/api/v1/me/actor/computer/still" alt="Your Actor’s live computer screen" tabindex="0"><div class="ay-screen-note" role="status"></div></div><div class="ay-actions">' + button('Take over', 'data-take') + button('Watch full screen', 'data-fullscreen') + '<button type="button" class="ovk-btn" data-back hidden>Hand back</button></div><form id="ay-type" hidden>' + input('text','Type on its screen','autocomplete="off" type="password" maxlength="8000"') + '<button class="ovk-btn">Type</button>' + button('Enter','data-key="Return"') + button('Tab','data-key="Tab"') + button('Backspace','data-key="BackSpace"') + '</form>' : '';-- const next = now.next ? '<p class="ay-next">Next: ' + e(now.next.text) + ' · ' + time(now.next.at) + '</p>' : '<p class="ay-next">Add a goal to give your Actor something to do.</p>';++ const next = now.next ? '<p class="ay-next">Next: ' + e(now.next.text) + ' · ' + time(now.next.at) + '</p>' : '<p class="ay-next">' + (goals.length ? 'No goal is due right now.' : 'Give it a goal and it starts working.') + '</p>';const spend = '<div class="ay-spend"><span>Today <strong>' + money(now.cost_today) + '</strong> of ' + money(a.caps.usd) + '</span><progress value="' + Math.min(Number(now.cost_today || 0), a.caps.usd) + '" max="' + (a.caps.usd || 1) + '" aria-label="Today’s spending"></progress></div>';const rawRunBrain = activeRun?.model_override || activeRun?.brain || a.brain;const runBrain = rawRunBrain === 'own:node' ? 'own:link' : rawRunBrain;@@@ -38,22 +49,31 @@ function render(data, viewer = {}) {+ '<p class="ay-run-model-note">Steps already run keep the model and cost they used. A tool the new model cannot use stays off.</p></form>' : '';const nowHtml = card('ay-now','Now', screen + '<p class="ay-current">' + (state === 'Working' ? pulse : '') + e(now.current_step || state) + '</p>' + (now.looking_at ? '<p class="ay-looking">' + thread.icon('read') + e(now.looking_at) + '</p>' : '') + runModel + '<ol class="ay-steps">' + (now.last_three_steps || []).map(j => '<li>' + time(j.created_at) + '<span>' + e(j.text) + '</span></li>').join('') + '</ol>' + next + (state === 'Resting' && now.next ? button('Do it now','data-action="do-now"',true) : '') + spend);const asksHtml = asks.length ? card('ay-asks','Asks <span class="ovk-badge">' + asks.length + '</span>', asks.map(q => '<article class="ay-ask"><h3>' + e(q.text) + '</h3>' + (q.evidence?.text ? '<blockquote>' + e(q.evidence.text) + '</blockquote>' : '') + '<div class="ay-actions">' + q.options.map(o => q.kind === 'sign_in' && o !== 'Done' ? button('Sign in on its screen','data-sign-in',true) : button(o,'data-answer="' + e(q.id) + '" data-text="' + e(o) + '"',true)).join('') + button('Not now','data-decline="' + e(q.id) + '"') + '</div>' + (q.kind === 'answer_question' ? '<form data-answer-form="' + e(q.id) + '">' + input('text','Your answer','required maxlength="4000"') + '<button class="ovk-btn">Answer</button></form>' : '') + '</article>').join('')) : '<div id="ay-asks" hidden></div>';-- const tell = card('ay-tell','Tell it','<div class="ovt ay-thread" data-ov-thread>' + thread.composer({ id:'ay-tell-form',action:'/api/v1/me/actor/tell',placeholder:'Tell your Actor something…',maxlength:4000,attach:{name:'files',accept:'.txt,.md,.csv,.json'},note:'Small text files welcome. Your Actor reads this on its next step.' }) + '<div id="ay-replies">' + messages.slice().reverse().map(m => thread.turn({ id:m.id,you:{text:m.text},reply:m.reply ? thread.answer({html:'<p>' + e(m.reply.answer || m.reply.text || '') + '</p>'}) : '<p class="ay-muted">Kept for the next step.</p>' })).join('') + '</div></div>');++ // The composer is docked at the bottom of the screen, above the app's bar: it was a card half way down the page, and++ // its note was drawn over the page under it. What the person said, and what came back, is a section of its own.++ const tellWord = state === 'Waiting for you' ? 'Answer, or tell it something…' : state === 'Working' ? 'Steer it: say what to change…' : 'Tell your Actor something…';++ const dock = '<div class="ay-dock"><div class="ovt ay-thread" data-ov-thread>' + thread.composer({ id:'ay-tell-form',action:'/api/v1/me/actor/tell',placeholder:tellWord,maxlength:4000,attach:{name:'files',accept:'.txt,.md,.csv,.json'} }) + '</div></div>';++ const said = '<section id="ay-tell" class="ay-card ovk-answer ay-said"' + (messages.length ? '' : ' hidden') + '><header class="ay-section-head"><h2>You told it</h2></header><div class="ovt ay-thread"><div id="ay-replies">' + messages.slice().reverse().map(m => thread.turn({ id:m.id,you:{text:m.text},reply:m.reply ? thread.answer({html:'<p>' + e(m.reply.answer || m.reply.text || '') + '</p>'}) : '<p class="ay-muted">Kept for the next step.</p>' })).join('') + '</div></div></section>';function timeline(list) {const groups = new Map();for (const j of list) { const g = groups.get(j.goal_id) || []; g.push(j); groups.set(j.goal_id,g); }return [...groups].map(([id,rows]) => '<div class="ay-goal-journal"><h3>' + e(goals.find(g => g.id === id)?.text || 'Earlier goal') + '</h3><ol class="ay-journal">' + rows.map(j => '<li>' + time(j.created_at) + '<div>' + (j.thumbnail?.media_id && j.task_id ? '<img loading="lazy" alt="Screen from this step" src="/api/v1/tasks/' + e(j.task_id) + '/artifacts/' + e(j.thumbnail.media_id) + '">' : '') + '<p>' + e(j.text) + '</p>' + (j.task_id ? '<a href="/tasks/' + e(j.task_id) + '">See this step ↗</a>' : '') + '</div><small>' + money(j.cost_usd) + '</small></li>').join('') + '</ol></div>').join('') || '<p class="ay-muted">Your Actor’s steps will appear here.</p>';}const earlier = journal.filter(j => j.created_at.slice(0,10) !== today);-- const todayHtml = card('ay-today','Today',timeline(journal.filter(j => j.created_at.slice(0,10) === today)) + '<details><summary>Earlier days <span class="ovk-badge">' + earlier.length + '</span></summary>' + timeline(earlier) + button('Load earlier steps','data-earlier data-before="' + e(journal.at(-1)?.id || '') + '"') + '</details>');-- const goalsHtml = card('ay-goals','Goals','<div class="ay-goal-grid">' + goals.map(g => '<article class="ay-goal"><header>' + thread.icon('step') + '<span>' + e(labels[g.lane]) + '</span><label class="ay-switch"><input type="checkbox" role="switch" aria-label="' + e(g.text) + '" data-goal-toggle="' + e(g.id) + '" ' + (g.enabled ? 'checked' : '') + '><span>' + (g.enabled ? 'On' : 'Off') + '</span></label></header><h3>' + e(g.text) + '</h3><p class="ay-muted">' + (g.schedule ? e(g.schedule.days.map(d => ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d]).join(', ')) + ' · ' + g.schedule.start + ':00–' + g.schedule.end + ':00 · ' + e(g.schedule.zone) : 'Every ' + (g.every_minutes / 60) + ' hours') + ' · ' + money(g.budget.usd) + '/day</p><p>' + e(g.last_result || 'Ready for its first step.') + '</p><footer><small>' + Number(g.successes || 0) + ' results</small>' + button('Edit','data-edit-goal="' + e(g.id) + '" data-goal="' + e(JSON.stringify(g)) + '"') + '</footer></article>').join('') + '</div>' + button('Add a goal','data-ovk-sheet="#ay-lanes"',true));++ const todayHtml = card('ay-today','Today',timeline(journal.filter(j => j.created_at.slice(0,10) === today)) + (earlier.length ? '<details><summary>Earlier days <span class="ovk-badge">' + earlier.length + '</span></summary>' + timeline(earlier) + button('Load earlier steps','data-earlier data-before="' + e(journal.at(-1)?.id || '') + '"') + '</details>' : ''));++ const goalsHtml = card('ay-goals','Goals','<div class="ay-goal-grid">' + goals.map(g => '<article class="ay-goal"><header>' + thread.icon('step') + '<span>' + e(labels[g.lane]) + '</span><label class="ay-switch"><input type="checkbox" role="switch" aria-label="' + e(g.text) + '" data-goal-toggle="' + e(g.id) + '" ' + (g.enabled ? 'checked' : '') + '><span>' + (g.enabled ? 'On' : 'Off') + '</span></label></header><h3>' + e(g.text) + '</h3><p class="ay-muted">' + (g.schedule ? e(g.schedule.days.map(d => ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d]).join(', ')) + ' · ' + g.schedule.start + ':00–' + g.schedule.end + ':00 · ' + e(g.schedule.zone) : 'Every ' + (g.every_minutes / 60) + ' hours') + ' · ' + money(g.budget.usd) + '/day</p><p>' + e(g.last_result || 'Ready for its first step.') + '</p><footer><small>' + Number(g.successes || 0) + ' results</small>' + button('Edit','data-edit-goal="' + e(g.id) + '" data-goal="' + e(JSON.stringify(g)) + '"') + '</footer></article>').join('') + '</div>' + (goals.length ? button('Add a goal','data-ovk-sheet="#ay-lanes"',true)++ : '<div class="ay-starters"><p class="ay-starters-lede">Give it one goal. It works on it every day, inside a budget you set, and asks before anything you list.</p><div class="ay-starter-row">'++ + [['find_work','Find work','It looks for roles that fit you and brings the best three.','flag'],['brief_me','Brief me','A short morning read on what you follow.','book'],['watch_deals','Watch for deals','It watches a price and tells you when it drops.','tag']].map(([l,t,d,ic]) => '<button type="button" class="ay-starter" data-lane="' + l + '"><span class="ay-starter-i" aria-hidden="true">' + kit.icon(ic) + '</span><span><b>' + t + '</b><small>' + d + '</small></span></button>').join('')++ + '</div>' + button('Something else','data-ovk-sheet="#ay-lanes"') + '</div>'));const moneyHtml = card('ay-money','Money','<div class="ay-money-total"><div><span>Model spend today</span><strong>' + money(now.cost_today) + '</strong></div><div><span>Confirmed earnings</span><strong>' + money(earnings.reduce((n,x)=>n+Number(x.usd),0)) + '</strong></div></div><p class="ay-muted"><a href="https://openvibe.network/wallet">Model and computer charges ↗</a></p><details><summary>Model spending</summary><ul>' + journal.filter(j=>Number(j.cost_usd)>0).map(j=>'<li>' + time(j.created_at) + ' · ' + e(j.text) + ' · ' + money(j.cost_usd) + '</li>').join('') + '</ul></details>' + (a.computer.kind==='openvibe'?'<div id="ay-computer-time"></div>' + button('Show computer time','data-computer-time'):'') + '<h3>This week · cost per result</h3>' + (weekly.map(w => { const n = Number(w.results || 0); return '<div class="ay-week"><span>' + e(labels[w.lane]) + '</span><meter min="0" max="' + Math.max(1,...weekly.map(x=>x.results?Number(x.usd)/x.results:0)) + '" value="' + (n?Number(w.usd)/n:0) + '" aria-label="' + e(labels[w.lane]) + ' cost"></meter><span>' + (n ? money(Number(w.usd)/n) : money(w.usd) + ' · no result yet') + '</span></div>'; }).join('') || '<p class="ay-muted">Your first result starts the weekly view.</p>') + '<details><summary>Confirmed earnings <span class="ovk-badge">' + earnings.length + '</span></summary><ul>' + earnings.map(x=>'<li>' + e(x.text) + ' · ' + money(x.usd) + '</li>').join('') + '</ul></details><form id="ay-earn">' + input('text','What did you earn?','required maxlength="500" placeholder="A paid project"') + input('usd','Amount in USD','type="number" min="0.01" step="0.01" required') + '<label class="ay-check"><input name="confirmed" type="checkbox" required> I received this money</label><button class="ovk-btn">Add earnings</button></form>');const computerHtml = card('ay-computer','Computer','<p class="ay-current">' + e(pc) + '</p><form id="ay-computer-form"><label>Where it works<select name="kind">' + [['none','No computer'],['openvibe','OpenVibe computer'],['link','Your Link computer']].map(([k,v])=>'<option value="' + k + '" ' + (a.computer.kind===k?'selected':'') + '>' + v + '</option>').join('') + '</select></label>' + input('machine_id','Link machine ID','value="' + e(a.computer.machine_id || '') + '" placeholder="Choose a computer from Link"') + '<button class="ovk-btn">Save computer</button> <a href="https://openvibe.link">Open Link ↗</a></form><h3>Signed in to</h3><div id="ay-sites"><p class="ay-muted">' + (a.computer.kind==='openvibe'?'Open saved sites to see its sign-ins.':'Sign-ins stay on your selected computer.') + '</p></div>' + (a.computer.kind==='openvibe'?button('Show saved sites','data-sites'):'') + '<details><summary>Take-over history</summary><div id="ay-history"><p class="ay-muted">' + (session?'Open the history to see who used this screen.':'No computer is open.') + '</p></div>' + (a.computer.kind==='openvibe'?button('Show history','data-history'):'') + '</details>');const modelButton = (id,text,sub='') => '<button type="button" class="ay-model ovk-btn ovk-btn--quiet" data-brain="' + e(id) + '"><span>' + e(text) + '</span><small>' + e(sub) + '</small>' + (a.brain===id?'<span aria-label="Selected">✓</span>':'') + '</button>';const brain = kit.sheet({id:'ay-brain',title:'Brain',body:'<p>From the next step.</p><label>Use for<select id="ay-brain-goal"><option value="">Your Actor</option>' + goals.map(g=>'<option value="' + e(g.id) + '">' + e(g.text) + '</option>').join('') + '</select></label>' + modelButton('auto','Auto','Recommended') + '<h3>OpenVibe</h3>' + modelButton('openvibe','OpenVibe','Chooses a model for the step') + models.filter(m=>!/uncensored|18\+/i.test(JSON.stringify(m))).map(m=>modelButton(m.id,m.name||m.id,m.hourly_usd!=null?money(m.hourly_usd)+'/hour of work':require('../runtime/ai-catalog').priceDescription(m.output_nano_usd_per_m))).join('') + '<h3>Your keys</h3><p class="ay-muted">No OpenVibe model charge. Your provider may bill you.</p>' + ['openai','anthropic','deepseek','google','openrouter','mistral'].map(p=>modelButton('own:'+p,p[0].toUpperCase()+p.slice(1))).join('') + '<a href="/models">Manage your keys ↗</a><h3>Your plans</h3><a class="ovk-btn ovk-btn--quiet" href="https://openvibe.codes">Connect your Codex plan ↗</a> <a class="ovk-btn ovk-btn--quiet" href="https://openvibe.codes">Connect your Claude Code plan ↗</a>' + (a.adult_opt_in?'<h3>Uncensored · 18+</h3>' + models.filter(m=>/uncensored|18\+/i.test(JSON.stringify(m))).map(m=>modelButton(m.id,m.name||m.id)).join(''):'') + '<h3>Local</h3>' + modelButton('own:node','Your Link computer','No model charge') + '<label class="ay-check"><input type="checkbox" data-adult ' + (a.adult_opt_in?'checked':'') + '> I am 18 or older and want to see adult models</label>'});const lanes = kit.sheet({id:'ay-lanes',title:'What should your Actor do?',body:'<div class="ay-lanes">' + ['find_work','earn_gig','grow_channel','brief_me','watch_deals'].map(l=>'<button type="button" class="ay-lane ovk-btn" data-lane="' + l + '">' + thread.icon('step') + '<span>' + labels[l] + '</span></button>').join('') + '</div>' + button('Something else','data-lane="custom"')});const editor = kit.sheet({id:'ay-goal-editor',title:'Your goal',body:'<form id="ay-goal-form"><input name="id" type="hidden"><label>Goal<textarea name="text" required maxlength="4000" placeholder="Tell it what a good result looks like"></textarea></label><label>Kind<select name="lane">' + Object.entries(labels).map(([k,v])=>'<option value="' + k + '">' + v + '</option>').join('') + '</select></label><fieldset><legend>Days</legend><div class="ay-days">' + ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map((d,i)=>'<label><input type="checkbox" name="day" value="' + i + '" checked>' + d + '</label>').join('') + '</div></fieldset><div class="ay-form-grid">' + input('start','From hour','type="number" min="0" max="23" value="9" required') + input('end','Until hour','type="number" min="1" max="24" value="18" required') + input('zone','Time zone','value="UTC" required') + input('hours','Every (hours)','type="number" min="0.1" max="8760" step="0.1" value="24" required') + input('usd','Daily budget (USD)','type="number" min="0" max="1000" step="0.01" value="0.25" required') + input('minutes','Minutes per day','type="number" min="0" max="1440" value="20" required') + '</div><label>Ask me before (one per line)<textarea name="asks_before" rows="3"></textarea></label><div class="ay-actions"><button class="ovk-btn ovk-btn--primary">Save goal</button>' + button('Delete goal','data-delete-goal hidden') + '</div></form>'});-- return '<div class="ay-page" data-actor-page>' + head + '<p id="ay-notice" role="status" aria-live="polite"></p><div class="ay-layout">' + nowHtml + asksHtml + tell + todayHtml + goalsHtml + moneyHtml + computerHtml + '</div>' + brain + lanes + editor + '</div>';++ const tab = (id, text, ic) => '<a class="ay-tab" href="#' + id + '" data-jump="' + id + '">' + kit.icon(ic) + '<span>' + text + '</span></a>';++ const tabs = '<nav class="ay-tabs" aria-label="On this page">' + tab('ay-today','Today','clock') + tab('ay-goals','Goals','flag') + tab('ay-money','Money','coin') + tab('ay-computer','Computer','computer') + '</nav>';++ return '<div class="ay-page" data-actor-page data-state="' + stateKey + '">' + head + '<p id="ay-notice" role="status" aria-live="polite"></p><div class="ay-layout">' + asksHtml + nowHtml + tabs + (goals.length ? todayHtml + goalsHtml : goalsHtml + todayHtml) + said + moneyHtml + computerHtml + '</div>' + dock + brain + lanes + editor + '</div>';}function router(ctx) {const r = require('../http/router').asyncRouter();@@@ -41,6 +41,8 @@ if (!playwright || !fs.existsSync('/usr/bin/google-chrome') || process.env.OV_SKawait page.waitForSelector('#ay-now');assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth),width,'no horizontal scroll');assert.equal(await page.locator('.ay-state').textContent(),'Working');++ // The model is under More in the status bar (it was a chip on a row of its own).++ await page.locator('.ay-more > summary').click();await page.locator('[data-ovk-sheet="#ay-brain"]').click();await page.waitForSelector('#ay-brain[open]');assert.ok(!(await page.locator('#ay-brain').innerText()).includes('Uncensored'));@@@ -57,6 +59,7 @@ if (!playwright || !fs.existsSync('/usr/bin/google-chrome') || process.env.OV_SKawait page.evaluate(()=>window.scrollTo(0,0));assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth),width,'no overflow after updating goals');if(out)await page.screenshot({path:path.join(out,'actor-'+width+'-'+scheme+'.png'),fullPage:true});++ await page.locator('.ay-more > summary').click();await page.locator('[data-ovk-sheet="#ay-brain"]').click();await page.waitForSelector('#ay-brain[open]');await page.locator('#ay-brain [data-ovk-close]').click();
@@ -7,3 +7,70 @@
@@ -7,3 +7,70 @@
.ay-run-model-row select{flex:1;min-width:0}
.ay-run-model-row select{flex:1;min-width:0}
.ay-run-model-row .ovk-btn{flex:none;min-height:44px}
.ay-run-model-row .ovk-btn{flex:none;min-height:44px}
.ay-run-model-note{margin:0;font-size:.82rem;line-height:1.45;color:var(--text-secondary)}
.ay-run-model-note{margin:0;font-size:.82rem;line-height:1.45;color:var(--text-secondary)}
+
+/* ── /you as an agent's home (2026-11-10). The status bar stays on screen; what needs the person is first; the composer
+ is docked at the bottom of the screen, above the app's bar; the page keeps room for it. ── */
+.ay-page{padding-bottom:calc(84px + env(safe-area-inset-bottom,0px))}
+.ay-bar{position:sticky;top:calc(var(--ovnav-h,56px) + 6px);z-index:6;display:flex;align-items:center;gap:10px;min-width:0;margin:0 0 12px;padding:8px 8px 8px 10px;border-radius:20px;border:1px solid var(--border,rgba(255,255,255,.1));background:color-mix(in srgb,var(--bg-card,#131c2e) 90%,transparent);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);box-shadow:0 10px 28px -18px rgba(0,0,0,.7)}
+.ay-bar .ay-avatar{width:40px;height:40px;border-radius:14px;flex:none}
+.ay-id{display:grid;gap:1px;min-width:0;flex:1}
+.ay-bar .ay-name-button{all:unset;cursor:pointer;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:800;font-size:1rem;line-height:1.25;color:var(--text-primary,#e6edf7)}
+.ay-bar .ay-name-button:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px;border-radius:6px}
+.ay-bar .ay-state{display:inline-flex;align-items:center;gap:6px;min-width:0;font-size:.8rem;font-weight:650;line-height:1.25;color:var(--text-secondary,#96a7c2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.ay-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--text-muted,#7386a3)}
+.ay-bar[data-state=working] .ay-state{color:var(--success-text,var(--success,#34d399))}
+.ay-bar[data-state=waiting] .ay-state{color:var(--warning-text,var(--warning,#fbbf24))}
+.ay-bar[data-state=waiting] .ay-dot{background:currentColor;animation:ay-wait 1.6s ease-in-out infinite}
+.ay-bar[data-state=waiting]{border-color:color-mix(in srgb,var(--warning,#fbbf24) 45%,transparent)}
+@keyframes ay-wait{50%{opacity:.35;transform:scale(.8)}}
+.ay-bar-spend{display:grid;justify-items:end;flex:none;line-height:1.15;text-decoration:none;color:var(--text-primary,#e6edf7);font-variant-numeric:tabular-nums}
+.ay-bar-spend strong{font-size:.95rem}
+.ay-bar-spend small{font-size:.68rem;color:var(--text-muted,#7386a3)}
+.ay-bar-btn{all:unset;box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;gap:6px;flex:none;min-width:44px;height:44px;padding:0 12px;border-radius:14px;cursor:pointer;font-size:.86rem;font-weight:750;color:var(--text-primary,#e6edf7);background:color-mix(in srgb,var(--text-primary,#e6edf7) 8%,transparent);transition:background-color .15s ease,transform .12s ease}
+.ay-bar-btn .ovk-i{width:18px;height:18px}
+.ay-bar-btn:hover{background:color-mix(in srgb,var(--text-primary,#e6edf7) 14%,transparent)}
+.ay-bar-btn:active{transform:scale(.95)}
+.ay-bar-btn:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
+.ay-bar-btn.is-go{background:var(--accent-strong,var(--accent,#2563eb));color:var(--on-accent-strong,#fff)}
+.ay-bar-btn--icon{padding:0;width:44px}
+.ay-more{position:relative;flex:none}
+.ay-more>summary{list-style:none}.ay-more>summary::-webkit-details-marker{display:none}
+.ay-more-pop{position:absolute;right:0;top:calc(100% + 8px);z-index:8;display:grid;gap:2px;min-width:230px;padding:6px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.1));background:var(--bg-card,#131c2e);box-shadow:0 18px 40px rgba(0,0,0,.5);animation:ay-pop .18s cubic-bezier(.2,.9,.3,1.1)}
+@keyframes ay-pop{from{opacity:0;transform:translateY(calc(-6px*var(--ov-move,1))) scale(.98)}}
+.ay-more-item{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:10px;min-height:48px;padding:6px 10px;border-radius:11px;cursor:pointer;color:var(--text-primary,#e6edf7)}
+.ay-more-item:hover,.ay-more-item:focus-visible{background:color-mix(in srgb,var(--text-primary,#e6edf7) 8%,transparent)}
+.ay-more-item .ovk-i{width:20px;height:20px;color:var(--accent,#3b82f6);flex:none}
+.ay-more-item b{display:block;font-size:.9rem}.ay-more-item small{display:block;font-size:.78rem;color:var(--text-muted,#7386a3)}
+@media (max-width:420px){.ay-bar-btn:not(.ay-bar-btn--icon) span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}.ay-bar-btn:not(.ay-bar-btn--icon){padding:0;width:44px}}
+/* What needs the person comes first and says so. */
+.ay-page[data-state=waiting] #ay-asks{border-color:color-mix(in srgb,var(--warning,#fbbf24) 50%,transparent);box-shadow:0 0 0 3px color-mix(in srgb,var(--warning,#fbbf24) 12%,transparent)}
+/* The chips that jump to the page's sections. */
+.ay-tabs{grid-column:1/-1;display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;margin:0 -2px;padding:2px}
+.ay-tabs::-webkit-scrollbar{display:none}
+.ay-tab{display:inline-flex;align-items:center;gap:7px;flex:none;min-height:44px;padding:0 14px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));background:color-mix(in srgb,var(--text-primary,#e6edf7) 4%,transparent);color:var(--text-secondary,#96a7c2);font-size:.88rem;font-weight:700;text-decoration:none;transition:color .15s ease,border-color .15s ease}
+.ay-tab .ovk-i{width:16px;height:16px;color:var(--accent,#3b82f6)}
+.ay-tab:hover,.ay-tab:focus-visible{color:var(--text-primary,#e6edf7);border-color:color-mix(in srgb,var(--accent,#3b82f6) 50%,transparent)}
+.ay-card{scroll-margin-top:calc(var(--ovnav-h,56px) + 84px)}
+.ay-said{grid-column:1/-1}.ay-said[hidden]{display:none}
+/* Goals on the first day: three ways to start, each a sentence. */
+.ay-starters{display:grid;gap:10px}
+.ay-starters-lede{margin:0;color:var(--text-secondary,#96a7c2)}
+.ay-starter-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:8px}
+.ay-starter{all:unset;box-sizing:border-box;display:flex;align-items:center;gap:12px;min-height:64px;padding:10px 12px;border-radius:16px;border:1px solid var(--border,rgba(255,255,255,.1));background:color-mix(in srgb,var(--accent,#3b82f6) 6%,transparent);cursor:pointer;color:var(--text-primary,#e6edf7);transition:border-color .15s ease,transform .12s ease,background-color .15s ease}
+.ay-starter:hover,.ay-starter:focus-visible{border-color:color-mix(in srgb,var(--accent,#3b82f6) 60%,transparent);background:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}
+.ay-starter:active{transform:scale(.985)}
+.ay-starter-i{display:grid;place-items:center;flex:none;width:40px;height:40px;border-radius:13px;background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:var(--accent,#3b82f6)}
+.ay-starter-i .ovk-i{width:20px;height:20px}
+.ay-starter b{display:block}.ay-starter small{display:block;margin-top:1px;font-size:.82rem;color:var(--text-secondary,#96a7c2)}
+/* The composer, docked. */
+.ay-dock{position:fixed;left:0;right:0;bottom:var(--ovnav-appbar-h,0px);z-index:7;padding:18px 10px calc(8px + max(0px,env(safe-area-inset-bottom,0px) - var(--ovnav-appbar-h,0px)));background:linear-gradient(to bottom,transparent,var(--bg-primary,#0a0f1c) 38%);pointer-events:none}
+.ay-dock .ay-thread{max-width:760px;margin:0 auto;pointer-events:auto}
+.ay-dock .ovt-composer,.ay-dock form{box-shadow:0 14px 36px -14px rgba(0,0,0,.75)}
+/* In the dock the composer is one line until the person writes more: Add, the text, Send in a row. */
+.ay-dock .ovt-composer{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:0 6px;padding:6px 6px 6px 14px;border-radius:26px}
+.ay-dock .ovt-composer textarea{grid-column:1;grid-row:1;min-height:44px;max-height:38vh;padding:11px 0;resize:none;field-sizing:content}
+.ay-dock .ovt-c-row{grid-column:2;grid-row:1;display:flex;align-items:center;gap:4px;margin:0;padding:0}
+.ay-dock .ovt-c-note{grid-column:1/-1;margin:0}
+.ay-dock .ovt-c-note:empty{display:none}
+.ay-dock .ovt-send{width:44px;height:44px;border-radius:50%}
+@media (max-width:420px){.ay-bar{gap:8px;padding:7px 7px 7px 9px}.ay-bar .ay-avatar{width:36px;height:36px}.ay-bar-spend small{display:none}}
@@ -120,3 +120,5 @@
@@ -120,3 +120,5 @@
try { const r=await fetch(api+'/computer/still',{credentials:'same-origin'}); if(!r.ok)return; const next=URL.createObjectURL(await r.blob()); img.src=next; if(oldFrame)URL.revokeObjectURL(oldFrame); oldFrame=next; } catch {}
try { const r=await fetch(api+'/computer/still',{credentials:'same-origin'}); if(!r.ok)return; const next=URL.createObjectURL(await r.blob()); img.src=next; if(oldFrame)URL.revokeObjectURL(oldFrame); oldFrame=next; } catch {}
},2000);
},2000);
})();
})();
+// More (the status bar): it closes when one of its items is chosen, or on a press anywhere else.
+document.addEventListener('click', (e) => { const m = document.querySelector('.ay-more[open]'); if (m && (!m.contains(e.target) || (e.target.closest && e.target.closest('.ay-more-item')))) m.open = false; });
@@ -19,9 +19,20 @@ function render(data, viewer = {}) {
@@ -19,9 +19,20 @@ function render(data, viewer = {}) {
const pulse = '<span class="ay-pulse" aria-hidden="true"></span>';
const pulse = '<span class="ay-pulse" aria-hidden="true"></span>';
const avatarUrl = data.avatar_url || viewer.avatar;
const avatarUrl = data.avatar_url || viewer.avatar;
const avatar = avatarUrl && /^https:\/\//.test(avatarUrl) ? '<img src="' + e(avatarUrl) + '" alt="">' : e(a.name.slice(0, 1));
const avatar = avatarUrl && /^https:\/\//.test(avatarUrl) ? '<img src="' + e(avatarUrl) + '" alt="">' : e(a.name.slice(0, 1));
- const head = '<header id="ay-header" class="ay-header"><span class="ay-avatar">' + avatar + '</span><div class="ay-name"><span class="ay-eyebrow">YOUR ACTOR</span><button type="button" class="ay-name-button" data-rename>' + e(a.name) + '</button></div><span class="ovk-badge ay-state" data-state="' + state + '">' + (state === 'Working' ? pulse : '') + state + (state === 'Resting' && now.next ? ' until ' + time(now.next.at) : '') + '</span>' + button(a.enabled ? 'Pause' : 'Resume', 'data-action="' + (a.enabled ? 'pause' : 'resume') + '"') + '<div class="ay-chips">' + button(model + ' ▾', 'data-ovk-sheet="#ay-brain"') + button(pc, 'data-jump="ay-computer"') + '</div></header>';
+ // The status bar: it stays on screen. Who, the state as a word with a live signal, today's spend against the cap,
+ // Pause or Resume as one button, and More (the model and the computer, which were two chips on a row of their own).
+ const stateKey = { 'Waiting for you': 'waiting', Paused: 'paused', Working: 'working', Resting: 'resting' }[state];
+ const stateLine = (state === 'Working' ? pulse : '<span class="ay-dot" aria-hidden="true"></span>') + '<span>' + state + (asks.length > 1 ? ' · ' + asks.length + ' asks' : '') + (state === 'Resting' && now.next ? ' until ' + time(now.next.at) : '') + '</span>';
+ const head = '<header id="ay-header" class="ay-bar" data-state="' + stateKey + '"><span class="ay-avatar">' + avatar + '</span>'
+ + '<div class="ay-id"><button type="button" class="ay-name-button" data-rename title="Rename your Actor">' + e(a.name) + '</button><span class="ay-state" data-state="' + state + '">' + stateLine + '</span></div>'
+ + '<a class="ay-bar-spend" href="#ay-money" data-jump="ay-money" title="Today’s spending, of your daily cap"><strong>' + money(now.cost_today) + '</strong><small>of ' + money(a.caps.usd) + '</small></a>'
+ + '<button type="button" class="ay-bar-btn' + (a.enabled ? '' : ' is-go') + '" data-action="' + (a.enabled ? 'pause' : 'resume') + '" aria-label="' + (a.enabled ? 'Pause your Actor' : 'Resume your Actor') + '" title="' + (a.enabled ? 'Pause' : 'Resume') + '">' + kit.icon(a.enabled ? 'pause' : 'play') + '<span>' + (a.enabled ? 'Pause' : 'Resume') + '</span></button>'
+ + '<details class="ay-more"><summary class="ay-bar-btn ay-bar-btn--icon" aria-label="More: its model and its computer" title="More">' + kit.icon('more') + '</summary><div class="ay-more-pop">'
+ + '<button type="button" class="ay-more-item" data-ovk-sheet="#ay-brain">' + kit.icon('spark') + '<span><b>Model</b><small>' + e(model) + '</small></span></button>'
+ + '<button type="button" class="ay-more-item" data-jump="ay-computer">' + kit.icon('computer') + '<span><b>Computer</b><small>' + e(pc) + '</small></span></button>'
+ + '</div></details></header>';
const screen = session ? '<div class="ay-screen" data-session="' + e(session) + '"><img id="ay-screen" src="/api/v1/me/actor/computer/still" alt="Your Actor’s live computer screen" tabindex="0"><div class="ay-screen-note" role="status"></div></div><div class="ay-actions">' + button('Take over', 'data-take') + button('Watch full screen', 'data-fullscreen') + '<button type="button" class="ovk-btn" data-back hidden>Hand back</button></div><form id="ay-type" hidden>' + input('text','Type on its screen','autocomplete="off" type="password" maxlength="8000"') + '<button class="ovk-btn">Type</button>' + button('Enter','data-key="Return"') + button('Tab','data-key="Tab"') + button('Backspace','data-key="BackSpace"') + '</form>' : '';
const screen = session ? '<div class="ay-screen" data-session="' + e(session) + '"><img id="ay-screen" src="/api/v1/me/actor/computer/still" alt="Your Actor’s live computer screen" tabindex="0"><div class="ay-screen-note" role="status"></div></div><div class="ay-actions">' + button('Take over', 'data-take') + button('Watch full screen', 'data-fullscreen') + '<button type="button" class="ovk-btn" data-back hidden>Hand back</button></div><form id="ay-type" hidden>' + input('text','Type on its screen','autocomplete="off" type="password" maxlength="8000"') + '<button class="ovk-btn">Type</button>' + button('Enter','data-key="Return"') + button('Tab','data-key="Tab"') + button('Backspace','data-key="BackSpace"') + '</form>' : '';
- const next = now.next ? '<p class="ay-next">Next: ' + e(now.next.text) + ' · ' + time(now.next.at) + '</p>' : '<p class="ay-next">Add a goal to give your Actor something to do.</p>';
+ const next = now.next ? '<p class="ay-next">Next: ' + e(now.next.text) + ' · ' + time(now.next.at) + '</p>' : '<p class="ay-next">' + (goals.length ? 'No goal is due right now.' : 'Give it a goal and it starts working.') + '</p>';
const spend = '<div class="ay-spend"><span>Today <strong>' + money(now.cost_today) + '</strong> of ' + money(a.caps.usd) + '</span><progress value="' + Math.min(Number(now.cost_today || 0), a.caps.usd) + '" max="' + (a.caps.usd || 1) + '" aria-label="Today’s spending"></progress></div>';
const spend = '<div class="ay-spend"><span>Today <strong>' + money(now.cost_today) + '</strong> of ' + money(a.caps.usd) + '</span><progress value="' + Math.min(Number(now.cost_today || 0), a.caps.usd) + '" max="' + (a.caps.usd || 1) + '" aria-label="Today’s spending"></progress></div>';
const rawRunBrain = activeRun?.model_override || activeRun?.brain || a.brain;
const rawRunBrain = activeRun?.model_override || activeRun?.brain || a.brain;
const runBrain = rawRunBrain === 'own:node' ? 'own:link' : rawRunBrain;
const runBrain = rawRunBrain === 'own:node' ? 'own:link' : rawRunBrain;
@@ -38,22 +49,31 @@ function render(data, viewer = {}) {
@@ -38,22 +49,31 @@ function render(data, viewer = {}) {
+ '<p class="ay-run-model-note">Steps already run keep the model and cost they used. A tool the new model cannot use stays off.</p></form>' : '';
+ '<p class="ay-run-model-note">Steps already run keep the model and cost they used. A tool the new model cannot use stays off.</p></form>' : '';
const nowHtml = card('ay-now','Now', screen + '<p class="ay-current">' + (state === 'Working' ? pulse : '') + e(now.current_step || state) + '</p>' + (now.looking_at ? '<p class="ay-looking">' + thread.icon('read') + e(now.looking_at) + '</p>' : '') + runModel + '<ol class="ay-steps">' + (now.last_three_steps || []).map(j => '<li>' + time(j.created_at) + '<span>' + e(j.text) + '</span></li>').join('') + '</ol>' + next + (state === 'Resting' && now.next ? button('Do it now','data-action="do-now"',true) : '') + spend);
const nowHtml = card('ay-now','Now', screen + '<p class="ay-current">' + (state === 'Working' ? pulse : '') + e(now.current_step || state) + '</p>' + (now.looking_at ? '<p class="ay-looking">' + thread.icon('read') + e(now.looking_at) + '</p>' : '') + runModel + '<ol class="ay-steps">' + (now.last_three_steps || []).map(j => '<li>' + time(j.created_at) + '<span>' + e(j.text) + '</span></li>').join('') + '</ol>' + next + (state === 'Resting' && now.next ? button('Do it now','data-action="do-now"',true) : '') + spend);
const asksHtml = asks.length ? card('ay-asks','Asks <span class="ovk-badge">' + asks.length + '</span>', asks.map(q => '<article class="ay-ask"><h3>' + e(q.text) + '</h3>' + (q.evidence?.text ? '<blockquote>' + e(q.evidence.text) + '</blockquote>' : '') + '<div class="ay-actions">' + q.options.map(o => q.kind === 'sign_in' && o !== 'Done' ? button('Sign in on its screen','data-sign-in',true) : button(o,'data-answer="' + e(q.id) + '" data-text="' + e(o) + '"',true)).join('') + button('Not now','data-decline="' + e(q.id) + '"') + '</div>' + (q.kind === 'answer_question' ? '<form data-answer-form="' + e(q.id) + '">' + input('text','Your answer','required maxlength="4000"') + '<button class="ovk-btn">Answer</button></form>' : '') + '</article>').join('')) : '<div id="ay-asks" hidden></div>';
const asksHtml = asks.length ? card('ay-asks','Asks <span class="ovk-badge">' + asks.length + '</span>', asks.map(q => '<article class="ay-ask"><h3>' + e(q.text) + '</h3>' + (q.evidence?.text ? '<blockquote>' + e(q.evidence.text) + '</blockquote>' : '') + '<div class="ay-actions">' + q.options.map(o => q.kind === 'sign_in' && o !== 'Done' ? button('Sign in on its screen','data-sign-in',true) : button(o,'data-answer="' + e(q.id) + '" data-text="' + e(o) + '"',true)).join('') + button('Not now','data-decline="' + e(q.id) + '"') + '</div>' + (q.kind === 'answer_question' ? '<form data-answer-form="' + e(q.id) + '">' + input('text','Your answer','required maxlength="4000"') + '<button class="ovk-btn">Answer</button></form>' : '') + '</article>').join('')) : '<div id="ay-asks" hidden></div>';
- const tell = card('ay-tell','Tell it','<div class="ovt ay-thread" data-ov-thread>' + thread.composer({ id:'ay-tell-form',action:'/api/v1/me/actor/tell',placeholder:'Tell your Actor something…',maxlength:4000,attach:{name:'files',accept:'.txt,.md,.csv,.json'},note:'Small text files welcome. Your Actor reads this on its next step.' }) + '<div id="ay-replies">' + messages.slice().reverse().map(m => thread.turn({ id:m.id,you:{text:m.text},reply:m.reply ? thread.answer({html:'<p>' + e(m.reply.answer || m.reply.text || '') + '</p>'}) : '<p class="ay-muted">Kept for the next step.</p>' })).join('') + '</div></div>');
+ // The composer is docked at the bottom of the screen, above the app's bar: it was a card half way down the page, and
+ // its note was drawn over the page under it. What the person said, and what came back, is a section of its own.
+ const tellWord = state === 'Waiting for you' ? 'Answer, or tell it something…' : state === 'Working' ? 'Steer it: say what to change…' : 'Tell your Actor something…';
+ const dock = '<div class="ay-dock"><div class="ovt ay-thread" data-ov-thread>' + thread.composer({ id:'ay-tell-form',action:'/api/v1/me/actor/tell',placeholder:tellWord,maxlength:4000,attach:{name:'files',accept:'.txt,.md,.csv,.json'} }) + '</div></div>';
+ const said = '<section id="ay-tell" class="ay-card ovk-answer ay-said"' + (messages.length ? '' : ' hidden') + '><header class="ay-section-head"><h2>You told it</h2></header><div class="ovt ay-thread"><div id="ay-replies">' + messages.slice().reverse().map(m => thread.turn({ id:m.id,you:{text:m.text},reply:m.reply ? thread.answer({html:'<p>' + e(m.reply.answer || m.reply.text || '') + '</p>'}) : '<p class="ay-muted">Kept for the next step.</p>' })).join('') + '</div></div></section>';
function timeline(list) {
function timeline(list) {
const groups = new Map();
const groups = new Map();
for (const j of list) { const g = groups.get(j.goal_id) || []; g.push(j); groups.set(j.goal_id,g); }
for (const j of list) { const g = groups.get(j.goal_id) || []; g.push(j); groups.set(j.goal_id,g); }
return [...groups].map(([id,rows]) => '<div class="ay-goal-journal"><h3>' + e(goals.find(g => g.id === id)?.text || 'Earlier goal') + '</h3><ol class="ay-journal">' + rows.map(j => '<li>' + time(j.created_at) + '<div>' + (j.thumbnail?.media_id && j.task_id ? '<img loading="lazy" alt="Screen from this step" src="/api/v1/tasks/' + e(j.task_id) + '/artifacts/' + e(j.thumbnail.media_id) + '">' : '') + '<p>' + e(j.text) + '</p>' + (j.task_id ? '<a href="/tasks/' + e(j.task_id) + '">See this step ↗</a>' : '') + '</div><small>' + money(j.cost_usd) + '</small></li>').join('') + '</ol></div>').join('') || '<p class="ay-muted">Your Actor’s steps will appear here.</p>';
return [...groups].map(([id,rows]) => '<div class="ay-goal-journal"><h3>' + e(goals.find(g => g.id === id)?.text || 'Earlier goal') + '</h3><ol class="ay-journal">' + rows.map(j => '<li>' + time(j.created_at) + '<div>' + (j.thumbnail?.media_id && j.task_id ? '<img loading="lazy" alt="Screen from this step" src="/api/v1/tasks/' + e(j.task_id) + '/artifacts/' + e(j.thumbnail.media_id) + '">' : '') + '<p>' + e(j.text) + '</p>' + (j.task_id ? '<a href="/tasks/' + e(j.task_id) + '">See this step ↗</a>' : '') + '</div><small>' + money(j.cost_usd) + '</small></li>').join('') + '</ol></div>').join('') || '<p class="ay-muted">Your Actor’s steps will appear here.</p>';
}
}
const earlier = journal.filter(j => j.created_at.slice(0,10) !== today);
const earlier = journal.filter(j => j.created_at.slice(0,10) !== today);
- const todayHtml = card('ay-today','Today',timeline(journal.filter(j => j.created_at.slice(0,10) === today)) + '<details><summary>Earlier days <span class="ovk-badge">' + earlier.length + '</span></summary>' + timeline(earlier) + button('Load earlier steps','data-earlier data-before="' + e(journal.at(-1)?.id || '') + '"') + '</details>');
- const goalsHtml = card('ay-goals','Goals','<div class="ay-goal-grid">' + goals.map(g => '<article class="ay-goal"><header>' + thread.icon('step') + '<span>' + e(labels[g.lane]) + '</span><label class="ay-switch"><input type="checkbox" role="switch" aria-label="' + e(g.text) + '" data-goal-toggle="' + e(g.id) + '" ' + (g.enabled ? 'checked' : '') + '><span>' + (g.enabled ? 'On' : 'Off') + '</span></label></header><h3>' + e(g.text) + '</h3><p class="ay-muted">' + (g.schedule ? e(g.schedule.days.map(d => ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d]).join(', ')) + ' · ' + g.schedule.start + ':00–' + g.schedule.end + ':00 · ' + e(g.schedule.zone) : 'Every ' + (g.every_minutes / 60) + ' hours') + ' · ' + money(g.budget.usd) + '/day</p><p>' + e(g.last_result || 'Ready for its first step.') + '</p><footer><small>' + Number(g.successes || 0) + ' results</small>' + button('Edit','data-edit-goal="' + e(g.id) + '" data-goal="' + e(JSON.stringify(g)) + '"') + '</footer></article>').join('') + '</div>' + button('Add a goal','data-ovk-sheet="#ay-lanes"',true));
+ const todayHtml = card('ay-today','Today',timeline(journal.filter(j => j.created_at.slice(0,10) === today)) + (earlier.length ? '<details><summary>Earlier days <span class="ovk-badge">' + earlier.length + '</span></summary>' + timeline(earlier) + button('Load earlier steps','data-earlier data-before="' + e(journal.at(-1)?.id || '') + '"') + '</details>' : ''));
+ const goalsHtml = card('ay-goals','Goals','<div class="ay-goal-grid">' + goals.map(g => '<article class="ay-goal"><header>' + thread.icon('step') + '<span>' + e(labels[g.lane]) + '</span><label class="ay-switch"><input type="checkbox" role="switch" aria-label="' + e(g.text) + '" data-goal-toggle="' + e(g.id) + '" ' + (g.enabled ? 'checked' : '') + '><span>' + (g.enabled ? 'On' : 'Off') + '</span></label></header><h3>' + e(g.text) + '</h3><p class="ay-muted">' + (g.schedule ? e(g.schedule.days.map(d => ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d]).join(', ')) + ' · ' + g.schedule.start + ':00–' + g.schedule.end + ':00 · ' + e(g.schedule.zone) : 'Every ' + (g.every_minutes / 60) + ' hours') + ' · ' + money(g.budget.usd) + '/day</p><p>' + e(g.last_result || 'Ready for its first step.') + '</p><footer><small>' + Number(g.successes || 0) + ' results</small>' + button('Edit','data-edit-goal="' + e(g.id) + '" data-goal="' + e(JSON.stringify(g)) + '"') + '</footer></article>').join('') + '</div>' + (goals.length ? button('Add a goal','data-ovk-sheet="#ay-lanes"',true)
+ : '<div class="ay-starters"><p class="ay-starters-lede">Give it one goal. It works on it every day, inside a budget you set, and asks before anything you list.</p><div class="ay-starter-row">'
+ + [['find_work','Find work','It looks for roles that fit you and brings the best three.','flag'],['brief_me','Brief me','A short morning read on what you follow.','book'],['watch_deals','Watch for deals','It watches a price and tells you when it drops.','tag']].map(([l,t,d,ic]) => '<button type="button" class="ay-starter" data-lane="' + l + '"><span class="ay-starter-i" aria-hidden="true">' + kit.icon(ic) + '</span><span><b>' + t + '</b><small>' + d + '</small></span></button>').join('')
+ + '</div>' + button('Something else','data-ovk-sheet="#ay-lanes"') + '</div>'));
const moneyHtml = card('ay-money','Money','<div class="ay-money-total"><div><span>Model spend today</span><strong>' + money(now.cost_today) + '</strong></div><div><span>Confirmed earnings</span><strong>' + money(earnings.reduce((n,x)=>n+Number(x.usd),0)) + '</strong></div></div><p class="ay-muted"><a href="https://openvibe.network/wallet">Model and computer charges ↗</a></p><details><summary>Model spending</summary><ul>' + journal.filter(j=>Number(j.cost_usd)>0).map(j=>'<li>' + time(j.created_at) + ' · ' + e(j.text) + ' · ' + money(j.cost_usd) + '</li>').join('') + '</ul></details>' + (a.computer.kind==='openvibe'?'<div id="ay-computer-time"></div>' + button('Show computer time','data-computer-time'):'') + '<h3>This week · cost per result</h3>' + (weekly.map(w => { const n = Number(w.results || 0); return '<div class="ay-week"><span>' + e(labels[w.lane]) + '</span><meter min="0" max="' + Math.max(1,...weekly.map(x=>x.results?Number(x.usd)/x.results:0)) + '" value="' + (n?Number(w.usd)/n:0) + '" aria-label="' + e(labels[w.lane]) + ' cost"></meter><span>' + (n ? money(Number(w.usd)/n) : money(w.usd) + ' · no result yet') + '</span></div>'; }).join('') || '<p class="ay-muted">Your first result starts the weekly view.</p>') + '<details><summary>Confirmed earnings <span class="ovk-badge">' + earnings.length + '</span></summary><ul>' + earnings.map(x=>'<li>' + e(x.text) + ' · ' + money(x.usd) + '</li>').join('') + '</ul></details><form id="ay-earn">' + input('text','What did you earn?','required maxlength="500" placeholder="A paid project"') + input('usd','Amount in USD','type="number" min="0.01" step="0.01" required') + '<label class="ay-check"><input name="confirmed" type="checkbox" required> I received this money</label><button class="ovk-btn">Add earnings</button></form>');
const moneyHtml = card('ay-money','Money','<div class="ay-money-total"><div><span>Model spend today</span><strong>' + money(now.cost_today) + '</strong></div><div><span>Confirmed earnings</span><strong>' + money(earnings.reduce((n,x)=>n+Number(x.usd),0)) + '</strong></div></div><p class="ay-muted"><a href="https://openvibe.network/wallet">Model and computer charges ↗</a></p><details><summary>Model spending</summary><ul>' + journal.filter(j=>Number(j.cost_usd)>0).map(j=>'<li>' + time(j.created_at) + ' · ' + e(j.text) + ' · ' + money(j.cost_usd) + '</li>').join('') + '</ul></details>' + (a.computer.kind==='openvibe'?'<div id="ay-computer-time"></div>' + button('Show computer time','data-computer-time'):'') + '<h3>This week · cost per result</h3>' + (weekly.map(w => { const n = Number(w.results || 0); return '<div class="ay-week"><span>' + e(labels[w.lane]) + '</span><meter min="0" max="' + Math.max(1,...weekly.map(x=>x.results?Number(x.usd)/x.results:0)) + '" value="' + (n?Number(w.usd)/n:0) + '" aria-label="' + e(labels[w.lane]) + ' cost"></meter><span>' + (n ? money(Number(w.usd)/n) : money(w.usd) + ' · no result yet') + '</span></div>'; }).join('') || '<p class="ay-muted">Your first result starts the weekly view.</p>') + '<details><summary>Confirmed earnings <span class="ovk-badge">' + earnings.length + '</span></summary><ul>' + earnings.map(x=>'<li>' + e(x.text) + ' · ' + money(x.usd) + '</li>').join('') + '</ul></details><form id="ay-earn">' + input('text','What did you earn?','required maxlength="500" placeholder="A paid project"') + input('usd','Amount in USD','type="number" min="0.01" step="0.01" required') + '<label class="ay-check"><input name="confirmed" type="checkbox" required> I received this money</label><button class="ovk-btn">Add earnings</button></form>');
const computerHtml = card('ay-computer','Computer','<p class="ay-current">' + e(pc) + '</p><form id="ay-computer-form"><label>Where it works<select name="kind">' + [['none','No computer'],['openvibe','OpenVibe computer'],['link','Your Link computer']].map(([k,v])=>'<option value="' + k + '" ' + (a.computer.kind===k?'selected':'') + '>' + v + '</option>').join('') + '</select></label>' + input('machine_id','Link machine ID','value="' + e(a.computer.machine_id || '') + '" placeholder="Choose a computer from Link"') + '<button class="ovk-btn">Save computer</button> <a href="https://openvibe.link">Open Link ↗</a></form><h3>Signed in to</h3><div id="ay-sites"><p class="ay-muted">' + (a.computer.kind==='openvibe'?'Open saved sites to see its sign-ins.':'Sign-ins stay on your selected computer.') + '</p></div>' + (a.computer.kind==='openvibe'?button('Show saved sites','data-sites'):'') + '<details><summary>Take-over history</summary><div id="ay-history"><p class="ay-muted">' + (session?'Open the history to see who used this screen.':'No computer is open.') + '</p></div>' + (a.computer.kind==='openvibe'?button('Show history','data-history'):'') + '</details>');
const computerHtml = card('ay-computer','Computer','<p class="ay-current">' + e(pc) + '</p><form id="ay-computer-form"><label>Where it works<select name="kind">' + [['none','No computer'],['openvibe','OpenVibe computer'],['link','Your Link computer']].map(([k,v])=>'<option value="' + k + '" ' + (a.computer.kind===k?'selected':'') + '>' + v + '</option>').join('') + '</select></label>' + input('machine_id','Link machine ID','value="' + e(a.computer.machine_id || '') + '" placeholder="Choose a computer from Link"') + '<button class="ovk-btn">Save computer</button> <a href="https://openvibe.link">Open Link ↗</a></form><h3>Signed in to</h3><div id="ay-sites"><p class="ay-muted">' + (a.computer.kind==='openvibe'?'Open saved sites to see its sign-ins.':'Sign-ins stay on your selected computer.') + '</p></div>' + (a.computer.kind==='openvibe'?button('Show saved sites','data-sites'):'') + '<details><summary>Take-over history</summary><div id="ay-history"><p class="ay-muted">' + (session?'Open the history to see who used this screen.':'No computer is open.') + '</p></div>' + (a.computer.kind==='openvibe'?button('Show history','data-history'):'') + '</details>');
const modelButton = (id,text,sub='') => '<button type="button" class="ay-model ovk-btn ovk-btn--quiet" data-brain="' + e(id) + '"><span>' + e(text) + '</span><small>' + e(sub) + '</small>' + (a.brain===id?'<span aria-label="Selected">✓</span>':'') + '</button>';
const modelButton = (id,text,sub='') => '<button type="button" class="ay-model ovk-btn ovk-btn--quiet" data-brain="' + e(id) + '"><span>' + e(text) + '</span><small>' + e(sub) + '</small>' + (a.brain===id?'<span aria-label="Selected">✓</span>':'') + '</button>';
const brain = kit.sheet({id:'ay-brain',title:'Brain',body:'<p>From the next step.</p><label>Use for<select id="ay-brain-goal"><option value="">Your Actor</option>' + goals.map(g=>'<option value="' + e(g.id) + '">' + e(g.text) + '</option>').join('') + '</select></label>' + modelButton('auto','Auto','Recommended') + '<h3>OpenVibe</h3>' + modelButton('openvibe','OpenVibe','Chooses a model for the step') + models.filter(m=>!/uncensored|18\+/i.test(JSON.stringify(m))).map(m=>modelButton(m.id,m.name||m.id,m.hourly_usd!=null?money(m.hourly_usd)+'/hour of work':require('../runtime/ai-catalog').priceDescription(m.output_nano_usd_per_m))).join('') + '<h3>Your keys</h3><p class="ay-muted">No OpenVibe model charge. Your provider may bill you.</p>' + ['openai','anthropic','deepseek','google','openrouter','mistral'].map(p=>modelButton('own:'+p,p[0].toUpperCase()+p.slice(1))).join('') + '<a href="/models">Manage your keys ↗</a><h3>Your plans</h3><a class="ovk-btn ovk-btn--quiet" href="https://openvibe.codes">Connect your Codex plan ↗</a> <a class="ovk-btn ovk-btn--quiet" href="https://openvibe.codes">Connect your Claude Code plan ↗</a>' + (a.adult_opt_in?'<h3>Uncensored · 18+</h3>' + models.filter(m=>/uncensored|18\+/i.test(JSON.stringify(m))).map(m=>modelButton(m.id,m.name||m.id)).join(''):'') + '<h3>Local</h3>' + modelButton('own:node','Your Link computer','No model charge') + '<label class="ay-check"><input type="checkbox" data-adult ' + (a.adult_opt_in?'checked':'') + '> I am 18 or older and want to see adult models</label>'});
const brain = kit.sheet({id:'ay-brain',title:'Brain',body:'<p>From the next step.</p><label>Use for<select id="ay-brain-goal"><option value="">Your Actor</option>' + goals.map(g=>'<option value="' + e(g.id) + '">' + e(g.text) + '</option>').join('') + '</select></label>' + modelButton('auto','Auto','Recommended') + '<h3>OpenVibe</h3>' + modelButton('openvibe','OpenVibe','Chooses a model for the step') + models.filter(m=>!/uncensored|18\+/i.test(JSON.stringify(m))).map(m=>modelButton(m.id,m.name||m.id,m.hourly_usd!=null?money(m.hourly_usd)+'/hour of work':require('../runtime/ai-catalog').priceDescription(m.output_nano_usd_per_m))).join('') + '<h3>Your keys</h3><p class="ay-muted">No OpenVibe model charge. Your provider may bill you.</p>' + ['openai','anthropic','deepseek','google','openrouter','mistral'].map(p=>modelButton('own:'+p,p[0].toUpperCase()+p.slice(1))).join('') + '<a href="/models">Manage your keys ↗</a><h3>Your plans</h3><a class="ovk-btn ovk-btn--quiet" href="https://openvibe.codes">Connect your Codex plan ↗</a> <a class="ovk-btn ovk-btn--quiet" href="https://openvibe.codes">Connect your Claude Code plan ↗</a>' + (a.adult_opt_in?'<h3>Uncensored · 18+</h3>' + models.filter(m=>/uncensored|18\+/i.test(JSON.stringify(m))).map(m=>modelButton(m.id,m.name||m.id)).join(''):'') + '<h3>Local</h3>' + modelButton('own:node','Your Link computer','No model charge') + '<label class="ay-check"><input type="checkbox" data-adult ' + (a.adult_opt_in?'checked':'') + '> I am 18 or older and want to see adult models</label>'});
const lanes = kit.sheet({id:'ay-lanes',title:'What should your Actor do?',body:'<div class="ay-lanes">' + ['find_work','earn_gig','grow_channel','brief_me','watch_deals'].map(l=>'<button type="button" class="ay-lane ovk-btn" data-lane="' + l + '">' + thread.icon('step') + '<span>' + labels[l] + '</span></button>').join('') + '</div>' + button('Something else','data-lane="custom"')});
const lanes = kit.sheet({id:'ay-lanes',title:'What should your Actor do?',body:'<div class="ay-lanes">' + ['find_work','earn_gig','grow_channel','brief_me','watch_deals'].map(l=>'<button type="button" class="ay-lane ovk-btn" data-lane="' + l + '">' + thread.icon('step') + '<span>' + labels[l] + '</span></button>').join('') + '</div>' + button('Something else','data-lane="custom"')});
const editor = kit.sheet({id:'ay-goal-editor',title:'Your goal',body:'<form id="ay-goal-form"><input name="id" type="hidden"><label>Goal<textarea name="text" required maxlength="4000" placeholder="Tell it what a good result looks like"></textarea></label><label>Kind<select name="lane">' + Object.entries(labels).map(([k,v])=>'<option value="' + k + '">' + v + '</option>').join('') + '</select></label><fieldset><legend>Days</legend><div class="ay-days">' + ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map((d,i)=>'<label><input type="checkbox" name="day" value="' + i + '" checked>' + d + '</label>').join('') + '</div></fieldset><div class="ay-form-grid">' + input('start','From hour','type="number" min="0" max="23" value="9" required') + input('end','Until hour','type="number" min="1" max="24" value="18" required') + input('zone','Time zone','value="UTC" required') + input('hours','Every (hours)','type="number" min="0.1" max="8760" step="0.1" value="24" required') + input('usd','Daily budget (USD)','type="number" min="0" max="1000" step="0.01" value="0.25" required') + input('minutes','Minutes per day','type="number" min="0" max="1440" value="20" required') + '</div><label>Ask me before (one per line)<textarea name="asks_before" rows="3"></textarea></label><div class="ay-actions"><button class="ovk-btn ovk-btn--primary">Save goal</button>' + button('Delete goal','data-delete-goal hidden') + '</div></form>'});
const editor = kit.sheet({id:'ay-goal-editor',title:'Your goal',body:'<form id="ay-goal-form"><input name="id" type="hidden"><label>Goal<textarea name="text" required maxlength="4000" placeholder="Tell it what a good result looks like"></textarea></label><label>Kind<select name="lane">' + Object.entries(labels).map(([k,v])=>'<option value="' + k + '">' + v + '</option>').join('') + '</select></label><fieldset><legend>Days</legend><div class="ay-days">' + ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].map((d,i)=>'<label><input type="checkbox" name="day" value="' + i + '" checked>' + d + '</label>').join('') + '</div></fieldset><div class="ay-form-grid">' + input('start','From hour','type="number" min="0" max="23" value="9" required') + input('end','Until hour','type="number" min="1" max="24" value="18" required') + input('zone','Time zone','value="UTC" required') + input('hours','Every (hours)','type="number" min="0.1" max="8760" step="0.1" value="24" required') + input('usd','Daily budget (USD)','type="number" min="0" max="1000" step="0.01" value="0.25" required') + input('minutes','Minutes per day','type="number" min="0" max="1440" value="20" required') + '</div><label>Ask me before (one per line)<textarea name="asks_before" rows="3"></textarea></label><div class="ay-actions"><button class="ovk-btn ovk-btn--primary">Save goal</button>' + button('Delete goal','data-delete-goal hidden') + '</div></form>'});
- return '<div class="ay-page" data-actor-page>' + head + '<p id="ay-notice" role="status" aria-live="polite"></p><div class="ay-layout">' + nowHtml + asksHtml + tell + todayHtml + goalsHtml + moneyHtml + computerHtml + '</div>' + brain + lanes + editor + '</div>';
+ const tab = (id, text, ic) => '<a class="ay-tab" href="#' + id + '" data-jump="' + id + '">' + kit.icon(ic) + '<span>' + text + '</span></a>';
+ const tabs = '<nav class="ay-tabs" aria-label="On this page">' + tab('ay-today','Today','clock') + tab('ay-goals','Goals','flag') + tab('ay-money','Money','coin') + tab('ay-computer','Computer','computer') + '</nav>';
+ return '<div class="ay-page" data-actor-page data-state="' + stateKey + '">' + head + '<p id="ay-notice" role="status" aria-live="polite"></p><div class="ay-layout">' + asksHtml + nowHtml + tabs + (goals.length ? todayHtml + goalsHtml : goalsHtml + todayHtml) + said + moneyHtml + computerHtml + '</div>' + dock + brain + lanes + editor + '</div>';
}
}
function router(ctx) {
function router(ctx) {
const r = require('../http/router').asyncRouter();
const r = require('../http/router').asyncRouter();
@@ -41,6 +41,8 @@ if (!playwright || !fs.existsSync('/usr/bin/google-chrome') || process.env.OV_SK
@@ -41,6 +41,8 @@ if (!playwright || !fs.existsSync('/usr/bin/google-chrome') || process.env.OV_SK
await page.waitForSelector('#ay-now');
await page.waitForSelector('#ay-now');
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth),width,'no horizontal scroll');
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth),width,'no horizontal scroll');
assert.equal(await page.locator('.ay-state').textContent(),'Working');
assert.equal(await page.locator('.ay-state').textContent(),'Working');
+ // The model is under More in the status bar (it was a chip on a row of its own).
+ await page.locator('.ay-more > summary').click();
await page.locator('[data-ovk-sheet="#ay-brain"]').click();
await page.locator('[data-ovk-sheet="#ay-brain"]').click();
await page.waitForSelector('#ay-brain[open]');
await page.waitForSelector('#ay-brain[open]');
assert.ok(!(await page.locator('#ay-brain').innerText()).includes('Uncensored'));
assert.ok(!(await page.locator('#ay-brain').innerText()).includes('Uncensored'));
@@ -57,6 +59,7 @@ if (!playwright || !fs.existsSync('/usr/bin/google-chrome') || process.env.OV_SK
@@ -57,6 +59,7 @@ if (!playwright || !fs.existsSync('/usr/bin/google-chrome') || process.env.OV_SK
await page.evaluate(()=>window.scrollTo(0,0));
await page.evaluate(()=>window.scrollTo(0,0));
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth),width,'no overflow after updating goals');
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth),width,'no overflow after updating goals');
if(out)await page.screenshot({path:path.join(out,'actor-'+width+'-'+scheme+'.png'),fullPage:true});
if(out)await page.screenshot({path:path.join(out,'actor-'+width+'-'+scheme+'.png'),fullPage:true});
+ await page.locator('.ay-more > summary').click();
await page.locator('[data-ovk-sheet="#ay-brain"]').click();
await page.locator('[data-ovk-sheet="#ay-brain"]').click();
await page.waitForSelector('#ay-brain[open]');
await page.waitForSelector('#ay-brain[open]');
await page.locator('#ay-brain [data-ovk-close]').click();
await page.locator('#ay-brain [data-ovk-close]').click();