openvibers/openvibe.actor Commits

Your Actor opens as an agent's home: a status bar, what needs you first, a docked composer (#408)

OP OpenVibers committed 3 days ago · 5d487f4359 · 4 files, +98 −6

The page was a column of cards with the message box half way down, three rows of head, and an empty first day.

A status bar stays on screen: the Actor's name, its state as a word with a live signal (Working, Resting until
17:00, Waiting for you, Paused), 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).

What needs the person is first, above Now, and the page says so. Chips under Now jump to Today, Goals, Money and
Computer.

The composer is docked at the bottom of the screen, above the app's bar, one line until more is written, and its
words follow the state (Tell your Actor something, Steer it, Answer). Its note was drawn over the page under it:
gone. What was said, and what came back, is a section of its own.

Goals on the first day is three ways to start, each a sentence (Find work, Brief me, Watch for deals), and comes
before Today. "Add a goal" is no longer said under a goal that exists, and "Earlier days 0" is not drawn.

Same data, same ids, same actions.

Changes

ddiff --git a/public/css/you.css b/public/css/you.css
iindex 27996f7..5cb4092 100644
---- a/public/css/you.css
++++ b/public/css/you.css
@@@ -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}}
ddiff --git a/public/js/you.js b/public/js/you.js
iindex 64255e8..a151e80 100644
---- a/public/js/you.js
++++ b/public/js/you.js
@@@ -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; });
ddiff --git a/server/day/page.js b/server/day/page.js
iindex 5a63a9a..e411df5 100644
---- a/server/day/page.js
++++ b/server/day/page.js
@@@ -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();
ddiff --git a/test/actor-page-chrome.test.js b/test/actor-page-chrome.test.js
iindex e750728..fd764db 100644
---- a/test/actor-page-chrome.test.js
++++ b/test/actor-page-chrome.test.js
@@@ -41,6 +41,8 @@ if (!playwright || !fs.existsSync('/usr/bin/google-chrome') || process.env.OV_SK
await 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_SK
await 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();