openvibers/openvibe.codes Commits

Codes C9b, the review: one tab bar, a model list, the machine's folders, room for the title (#189)

OP OpenVibers committed 14 days ago · 1bab253d69 · 8 files, +141 −54

- A phone had two tab bars: Codes' own below the Frame's. Codes' bar and its CSS are gone. On a signed-in
  workspace page the Frame's bar is the workspace's sections — Home, Tasks, Conductor, Agents, and More, which opens
  the navbar's drawer with Machines, Usage and Settings — and the current one is marked; four places and More, five at
  most. A visitor keeps the product's four. C4c's bottom inset stays, sized for the Frame's bar.
- The model is a list of what the machine's Claude Code login offers (Opus 5.5 by default, Sonnet 5.5, Haiku 5.5),
  with Other… for a model the person names. The alias is what the CLI is given, so it survives a point release.
- The working folder offers the folders these machines have worked in, newest first, beside the typed path; choosing
  one fills the field, and the pick has its own name so it works without the script.
- The heading has more room and stays above the season's falling leaves (openvibe-shared/ov-mark.js, a fixed layer at
  z-index 10010); at 320 px the controls stack and the state sits under the title, so no label is cut or broken.

Changes

ddiff --git a/public/css/c4.css b/public/css/c4.css
iindex 1ebbdd5..e3ae719 100644
---- a/public/css/c4.css
++++ b/public/css/c4.css
@@@ -1,8 +1,7 @@
.c4{--c4-bg:#f7f8fa;--c4-surface:#fff;--c4-ink:#18212b;--c4-muted:#687583;--c4-line:#e1e6eb;--c4-accent:#176b5b;--c4-dot:#98a5aa;--c4-dot-ok:#69a24f;--c4-dot-bad:#ce7441;--c4-dot-skip:#b3bcc1;--c4-dot-open:#179579;--c4-ok-bg:#edf6e9;--c4-ok-ink:#47713d;--c4-bad-bg:#fff0e9;--c4-bad-ink:#995128;--c4-bad-line:#e7c4b2;--c4-skip-bg:#f0f2f3;--c4-skip-ink:#5b6870;--c4-open-bg:#e9f4f1;--c4-open-ink:#176b5b;--c4-quiet-bg:#eef2f3;--c4-quiet-ink:#4f5d67;--c4-quiet:#f3f5f6;--c4-ghost:#f7f8f9;color:var(--c4-ink);max-width:1540px;margin:0 auto;padding:0 32px 44px}
/* One navigation per page: the shared Frame navbar carries the workspace's sections, so the page has no sidebar
of its own (openvibe.design/patterns/single-navbar). */
--.c4-main{min-width:0;max-width:1200px;width:100%;padding-top:26px}
--.c4-mobile a[aria-current]{color:var(--c4-accent);background:#e9f4f1;font-weight:650}
++.c4-main{min-width:0;max-width:1200px;width:100%;padding-top:34px}
.c4-title{display:flex;justify-content:space-between;align-items:end;margin:0 0 22px}
.c4-title h1{font-size:clamp(26px,3vw,36px);letter-spacing:-.045em;margin:0;line-height:1.12}
.c4-title p{margin:8px 0 0;color:var(--c4-muted);font-size:14px;line-height:1.5}
@@@ -15,6 +14,12 @@
.c4-composer form>label{display:block;font-size:15px;font-weight:650;margin-bottom:12px}
.c4-composer textarea{width:100%;min-height:105px;resize:vertical;border:1px solid var(--c4-line);border-radius:9px;padding:13px;font:inherit;color:var(--c4-ink);background:#fff}
.c4-controls{display:flex;align-items:end;gap:10px;flex-wrap:wrap;margin-top:12px}
++.c4-folder-row{display:flex;gap:8px;flex-wrap:wrap;margin:0 0 12px}
++.c4-folder-row input{flex:1 1 260px;min-width:0}
++.c4-folder-row select{flex:0 1 300px;max-width:100%}
++.c4-folder-row input,.c4-folder-row select{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:8px 10px;color:var(--c4-ink);font:inherit;font-size:13px;min-height:44px}
++.c4-model-other{display:grid;gap:5px;font-size:11px;color:var(--c4-muted);min-width:150px}
++.c4-model-other[hidden]{display:none}
.c4-controls label{display:grid;gap:5px;font-size:11px;color:var(--c4-muted);min-width:120px}
.c4-controls select,.c4-controls input,.c4-search input{background:#fff;border:1px solid var(--c4-line);border-radius:7px;padding:8px;color:var(--c4-ink);font:inherit;font-size:13px;min-height:44px}
.c4-button{border:0;border-radius:8px;background:var(--c4-accent);color:#fff;padding:10px 16px;font:inherit;font-size:13px;font-weight:650;cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;min-height:44px}
@@@ -31,17 +36,17 @@
.c4-status{display:inline-flex;border-radius:20px;padding:5px 9px;background:#eef2f3;color:#4f5d67;font-size:11px;font-weight:650;white-space:nowrap}
.c4-running,.c4-queued{background:#e9f4f1;color:#176b5b}.c4-failed{background:#fff0e9;color:#995128}.c4-done,.c4-succeeded{background:#edf6e9;color:#47713d}.c4-cancelled{background:#f0eff5;color:#635a7a}
.c4-reasons{padding:18px 2px}.c4-reasons>h2{font-size:17px}.c4-reasons article{border-top:1px solid var(--c4-line);padding-top:12px}.c4-reasons h3{font-size:14px;margin:0 0 6px}.c4-reasons p{font-size:12px;line-height:1.5;color:var(--c4-muted);margin:0}
--.c4-mobile{display:none}.c4-search{display:flex;align-items:end;gap:8px;margin:0 0 14px}.c4-search label{font-size:12px;color:var(--c4-muted)}.c4-search input{min-width:0;flex:1}
++.c4-search{display:flex;align-items:end;gap:8px;margin:0 0 14px}.c4-search label{font-size:12px;color:var(--c4-muted)}.c4-search input{min-width:0;flex:1}
.c4-outcome{display:grid;grid-template-columns:30px minmax(0,1fr) auto;align-items:center;gap:10px;margin:0 0 16px}.c4-obadge{width:30px;height:30px;border-radius:50%;display:grid;place-items:center;background:#fff0e9;color:#995128}.c4-obadge svg{width:15px;height:15px}.c4-outcome-copy{min-width:0;display:grid;gap:2px}.c4-outcome-copy>b{font-size:13px}.c4-outcome-copy>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--c4-muted);font-size:12px}.c4-outcome-actions{display:flex;justify-content:flex-end;gap:6px;flex-wrap:wrap}.c4-opill{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:44px;padding:0 14px 0 12px;border:1px solid var(--c4-line);border-radius:99px;background:var(--c4-surface);color:var(--c4-ink);font-size:12px;font-weight:650;text-decoration:none;white-space:nowrap}.c4-opill svg{width:15px;height:15px}.c4-opill-primary{background:var(--c4-accent);border-color:var(--c4-accent);color:#fff}.c4-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}
.c4-task-detail{padding:24px}.c4-task-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.c4-task-title-wrap{min-width:0;flex:1}.c4-brief{font-size:clamp(24px,3vw,34px);letter-spacing:-.04em;line-height:1.18;margin:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere}.c4-brief.is-expanded{-webkit-line-clamp:unset}.c4-brief-expand{background:none;border:0;color:var(--c4-accent);padding:9px 0;cursor:pointer;font:inherit;font-size:13px;min-height:44px}.c4-chips{display:flex;flex-wrap:wrap;gap:7px;margin:16px 0}.c4-chip{display:inline-flex;align-items:center;min-height:30px;padding:5px 10px;border:1px solid var(--c4-line);border-radius:18px;color:var(--c4-muted);font-size:12px;text-decoration:none}.c4-chip[href]{color:var(--c4-accent)}
.c4-progress-toggle{width:100%;display:flex;align-items:center;gap:9px;text-align:left;border:0;border-radius:9px;background:#edf5f2;color:var(--c4-ink);padding:12px 14px;min-height:46px;font:inherit;font-size:13px;cursor:pointer}.c4-progress-meta{color:var(--c4-muted);font-size:12px;margin-left:auto}.c4-tabs{display:flex;gap:5px;border-bottom:1px solid var(--c4-line);margin:16px 0 0;overflow-x:auto}.c4-tabs a{padding:11px 12px;color:var(--c4-muted);text-decoration:none;white-space:nowrap;border-bottom:2px solid transparent;font-size:13px;min-height:44px}.c4-tabs a[aria-selected=true]{color:var(--c4-accent);border-color:var(--c4-accent);font-weight:650}.c4-panel{padding:14px 0 4px;min-height:58px}.c4-panel[hidden]{display:none}.c4-panel p{font-size:13px;color:var(--c4-muted)}.c4-log{white-space:pre-wrap;overflow-wrap:anywhere;max-height:360px;overflow:auto;background:#f6f8f8;border-radius:8px;padding:12px;font-size:12px;color:#314047}.c4-code{white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f7f7;padding:12px;border-radius:8px;font-size:12px}
.c4-settings{display:grid;gap:12px;font-size:13px}.c4-safety{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin-top:16px}.c4-safety-card{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--c4-line);border-radius:10px;padding:14px;cursor:pointer;min-height:90px}.c4-safety-card:has(input:checked){border-color:var(--c4-accent);box-shadow:0 0 0 1px var(--c4-accent)}.c4-safety-card input{margin-top:3px;accent-color:var(--c4-accent)}.c4-safety-card span{display:grid;gap:6px}.c4-safety-card small{color:var(--c4-muted);font-size:12px;line-height:1.4}
.c4 a:focus-visible,.c4 button:focus-visible,.c4 input:focus-visible,.c4 textarea:focus-visible,.c4 select:focus-visible{outline:2px solid #188c75;outline-offset:2px}
@media(max-width:900px){.c4{padding-left:22px;padding-right:22px}}
--@media(max-width:760px){.c4{padding:0 15px 24px}.c4-main{padding-top:18px}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:44px;padding:0 11px;font-size:12px}.c4-mobile{display:grid;position:fixed;z-index:20;bottom:0;left:0;right:0;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);height:var(--c4-tabs-h);box-sizing:border-box;padding:0 max(6px,env(safe-area-inset-left)) env(safe-area-inset-bottom,0px);background:var(--c4-surface);border-top:1px solid var(--c4-line)}.c4-mobile a{display:flex;min-width:0;min-height:44px;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;font-size:10px;padding:4px 2px;border-radius:8px;color:var(--c4-muted);text-decoration:none}.c4-mobile a span{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.c4-mobile svg{width:19px;height:19px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}
++@media(max-width:760px){.c4{padding:0 15px 24px}.c4-main{padding-top:24px}.c4-title{margin-bottom:16px}.c4-card{padding:16px;border-radius:11px}.c4-grid{grid-template-columns:1fr;gap:0}.c4-controls{display:grid;grid-template-columns:1fr 1fr}.c4-controls label{min-width:0}.c4-controls select,.c4-controls input{width:100%}.c4-controls .c4-button{grid-column:1/-1}.c4-task-detail{padding:17px}.c4-task-heading{gap:8px}.c4-task-heading>.c4-status{flex:none}.c4-chips{gap:6px}.c4-chip{font-size:11px;padding:5px 8px}.c4-outcome{grid-template-columns:30px minmax(0,1fr);gap:8px}.c4-outcome-actions{grid-column:1/-1;justify-content:flex-start;padding-left:38px}.c4-opill{min-height:44px;padding:0 11px;font-size:12px}.c4-search{align-items:stretch;flex-wrap:wrap}.c4-search label{width:100%}.c4-safety{grid-template-columns:1fr}.c4-safety-card{min-height:68px}.c4-progress-toggle{flex-wrap:wrap}.c4-progress-meta{margin-left:0}.c4-task-meta{font-size:10px}}
:root[data-theme-mode="dark"] .c4{--c4-bg:#101518;--c4-surface:#171f23;--c4-ink:#eaf0f1;--c4-muted:#a3b0b4;--c4-line:#2d393d;--c4-accent:#75d0ba;--c4-dot:#5b6870;--c4-dot-ok:#afd39c;--c4-dot-bad:#f0b18c;--c4-dot-skip:#5b6870;--c4-dot-open:#75d0ba;--c4-ok-bg:#243423;--c4-ok-ink:#afd39c;--c4-bad-bg:#39291f;--c4-bad-ink:#f0b18c;--c4-bad-line:#5a4034;--c4-skip-bg:#263034;--c4-skip-ink:#cad4d6;--c4-open-bg:#1d3831;--c4-open-ink:#75d0ba;--c4-quiet-bg:#20292d;--c4-quiet-ink:#cad4d6;--c4-quiet:#20292d;--c4-ghost:#11181b}
:root[data-theme-mode="dark"] .c4-composer{background:linear-gradient(140deg,#171f23,#182722);border-color:#315148}:root[data-theme-mode="dark"] .c4 textarea,:root[data-theme-mode="dark"] .c4 select,:root[data-theme-mode="dark"] .c4 input{background:#11181b;color:var(--c4-ink)}
--:root[data-theme-mode="dark"] .c4-mobile a[aria-current]{background:#1d3831}:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}
++:root[data-theme-mode="dark"] .c4-log,:root[data-theme-mode="dark"] .c4-code{background:#11181b;color:#d3dfdf}:root[data-theme-mode="dark"] .c4-button-light{background:#20292d;color:var(--c4-ink)}:root[data-theme-mode="dark"] .c4-obadge{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-progress-toggle{background:#1d3831;color:var(--c4-ink)}
:root[data-theme-mode="dark"] .c4-status{background:#263034;color:#cad4d6}:root[data-theme-mode="dark"] .c4-running,:root[data-theme-mode="dark"] .c4-queued{background:#1d3831;color:#75d0ba}:root[data-theme-mode="dark"] .c4-failed{background:#39291f;color:#f0b18c}:root[data-theme-mode="dark"] .c4-done,:root[data-theme-mode="dark"] .c4-succeeded{background:#243423;color:#afd39c}
.c4-progress-content{grid-template-rows:0fr;opacity:0;overflow:hidden;transition:grid-template-rows .24s ease,opacity .24s ease}.c4-progress-content.is-open,.c4-progress-content[data-live="false"]{grid-template-rows:1fr;opacity:1}.c4-progress-inner{min-height:0;overflow:hidden}.c4-usage-row{grid-template-columns:minmax(0,1fr) auto}.c4-usage-row .c4-task-title,.c4-usage-row .c4-task-meta{grid-column:1}.c4-usage-row>b{grid-column:2;grid-row:1/3;align-self:center}
.page:has(.c4){width:100%;max-width:none;padding:0;min-height:0}.c4-attempts{display:grid;gap:6px;margin:10px 0;padding:0;list-style:none}.c4-attempt:before{content:"";display:inline-block;width:7px;height:7px;margin:0 8px 1px 1px;border-radius:50%;background:var(--c4-accent)}.c4-attempt{color:var(--c4-muted);font-size:12px}.c4-log[hidden]{display:none}
@@@ -183,9 +188,20 @@
.c4-composer textarea[name="note"]{width:100%;font:inherit;padding:9px 11px;border:1px solid var(--c4-line);border-radius:9px;background:var(--c4-surface);color:var(--c4-ink)}
:root[data-theme-mode="dark"] .c4-approval{background:#241d16;border-color:#4a3a2a}
:root[data-theme-mode="dark"] .c4-turn-answer,:root[data-theme-mode="dark"] .c4-approval-cmd{background:#11181b}
--/* C4c: the phone tab bar's own height, and the page's bottom inset for it (openvibe.design/patterns/bottom-tabs).
-- The workspace's tab bar is fixed at the bottom, so the scrollable content reserves its height and the safe area:
-- the last line of the page and the footer clear it when the page is scrolled to the end. */
++/* C4c: the page's bottom inset for the Frame's phone app bar (openvibe.design/patterns/bottom-tabs). The bar is fixed
++ at the bottom, so the scrollable content reserves its height and the safe area: the last line of the page and the
++ footer clear it when the page is scrolled to the end. Shared sets --ovnav-appbar-h when it draws the bar. */
@media(max-width:760px){
-- body.ov-workspace{--c4-tabs-h:calc(58px + env(safe-area-inset-bottom,0px));padding-bottom:var(--c4-tabs-h)}
++ body.ov-workspace{padding-bottom:var(--ovnav-appbar-h,calc(58px + env(safe-area-inset-bottom,0px)))}
}
++/* The narrowest phones: one control per row, and a heading over its state, so no label is cut or broken
++ (a 320 px screen has ~290 px of text; "Opus 5.5 (default)" and "Conductor" both fit whole). */
++@media(max-width:380px){
++ .c4-controls{grid-template-columns:1fr}
++ .c4-task-heading{flex-wrap:wrap}
++ .c4-task-heading .c4-task-title-wrap{flex:1 1 100%}
++}
++/* The season's leaves fall from the Frame's mark over the page (openvibe-shared/ov-mark.js, .ovm-fall, a fixed layer at
++ z-index 10010, 120-280 px down). The page's own title stays above them: a once-a-day decoration may pass behind a
++ heading, never across it. Reported to Shared so every site's heading is clear of the fall. */
++.c4 h1{position:relative;z-index:10011}
ddiff --git a/public/js/c4.js b/public/js/c4.js
iindex bac7894..7bad149 100644
---- a/public/js/c4.js
++++ b/public/js/c4.js
@@@ -144,6 +144,43 @@
if (stream.readyState === EventSource.CLOSED) { stream.close(); window.clearInterval(timer); }
});
});
++ // The conductor's start form: the folders this machine has worked in, and a model picked from the ones the plan
++ // login offers (Other… opens a field for one). Both work without this script — the pick has its own name and the
++ // server reads it — this only makes them visible and keeps the two in step.
++ document.querySelectorAll('form[action="/conductor"]').forEach((form) => {
++ const machine = form.querySelector('[data-c4-machine]');
++ const pick = form.querySelector('[data-c4-folder-pick]');
++ const folder = form.querySelector('#c4-folder');
++ if (pick) {
++ const options = Array.from(pick.options);
++ const filter = () => {
++ const want = machine ? machine.value : '';
++ const at = pick.selectedIndex;
++ options.forEach((o, i) => {
++ if (i === 0) return; // "A recent folder…"
++ const forMachine = (o.getAttribute('data-machine') || '');
++ o.hidden = !!want && !!forMachine && forMachine !== want;
++ });
++ if (at > 0 && pick.options[at].hidden) { pick.selectedIndex = 0; if (folder) folder.value = ''; }
++ else pick.selectedIndex = at;
++ };
++ filter();
++ if (machine) machine.addEventListener('change', filter);
++ pick.addEventListener('change', () => { if (pick.value && folder) folder.value = pick.value; });
++ }
++ const model = form.querySelector('[data-c4-model]');
++ const other = form.querySelector('[data-c4-model-other]');
++ const otherInput = other && other.querySelector('input');
++ if (model && other && otherInput) {
++ const toggle = () => {
++ const custom = model.value === 'other';
++ other.hidden = !custom;
++ if (custom) otherInput.focus();
++ };
++ toggle();
++ model.addEventListener('change', toggle);
++ }
++ });
// The conductor (Codes C9b): a message with images goes through the JSON API (the form alone sends the words), and
// the live turn's lines fill the transcript. When the turn ends the page reloads once, so the turn's answer and its
// state are the server's own words.
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex b4fe4fe..143ccd8 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -20,18 +20,12 @@ const { asyncRouter } = require('./router');
const showcase = require('openvibe-shared/showcase');
const { homeJsonLd } = require('./discovery');
--const LINKS = [
-- ['Home', '/', '<path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/>'],
-- ['Tasks', '/tasks', '<path d="M8 6h12M8 12h12M8 18h12M3 6h.01M3 12h.01M3 18h.01"/>'],
-- ['Conductor', '/conductor', '<path d="M4 17V9m4 8V5m4 12v-6m4 6V8m4 9v-4"/>'],
-- ['Agents', '/agents', '<circle cx="12" cy="8" r="3"/><path d="M5 20a7 7 0 0 1 14 0M12 2v3M3 8h3m12 0h3"/>'],
-- ['Machines', '/machines', '<rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4"/>'],
-- ['Usage', '/usage', '<path d="M4 19V5m0 14h17M8 15v-4m5 4V7m5 8v-6"/>'],
-- ['Settings', '/settings', '<circle cx="12" cy="12" r="3"/><path d="m19.4 15 .1.1 1.4 1.1-1.4 2.4-1.7-.7a8 8 0 0 1-1.7 1l-.3 1.8h-2.8l-.3-1.8a8 8 0 0 1-1.7-1l-1.7.7-1.4-2.4 1.4-1.1a8 8 0 0 1 0-2l-1.4-1.1 1.4-2.4 1.7.7a8 8 0 0 1 1.7-1l.3-1.8h2.8l.3 1.8a8 8 0 0 1 1.7 1l1.7-.7 1.4 2.4-1.4 1.1a8 8 0 0 1 0 2Z"/>'],
--];
const stateLabel = (s) => ({ running: 'Running', queued: 'Queued', placed: 'On its way', done: 'Done', succeeded: 'Done', failed: "Didn't finish", cancelled: 'Stopped by you', expired: 'Expired' }[s] || s || 'Unknown');
const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
const money = (n) => Number.isFinite(Number(n)) ? `$${Number(n).toFixed(2)}` : '—';
++// The models a Claude Code plan login offers; the alias is what the CLI is given, so it survives a point release.
++const MODELS = [['opus', 'Opus 5.5 (default)'], ['sonnet', 'Sonnet 5.5'], ['haiku', 'Haiku 5.5']];
++const modelLabel = (m) => ({ opus: 'Opus 5.5', sonnet: 'Sonnet 5.5', haiku: 'Haiku 5.5' }[m] || m || 'Opus 5.5');
const age = (value) => {
const numeric = Number(value);
const ms = Number.isFinite(numeric) && numeric > 0 ? numeric : new Date(value).getTime();
@@@ -64,16 +58,16 @@ function createC4Routes(ctx) {
const db = store.db;
const form = express.urlencoded({ extended: false, limit: '16kb' });
const page = (req, res, title, body, extra = {}) => {
-- const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines)/.test(req.path);
++ const wantsScript = req.path === '/' || /^\/tasks\/tsk_/.test(req.path) || /^\/(agents|machines|conductor)/.test(req.path);
return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, ...(title ? { title: `${title} · Codes` } : {}), css: ['css/c4.css'], scripts: wantsScript ? ['js/c4.js'] : [], body: raw(body), ...extra });
};
const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
/**
-- * The page's frame. There is no sidebar: the shared Frame navbar carries the workspace's sections, so a page has
-- * one navigation (openvibe.design/patterns/single-navbar). On a phone the same sections are the tab bar at the
-- * bottom, and the page reserves its height (openvibe.design/patterns/bottom-tabs).
++ * The page's frame. There is no sidebar and no second tab bar: the Frame owns the navigation — its navbar on a
++ * wide screen, its app bar on a phone (openvibe.design/patterns/single-navbar, /bottom-tabs) — and the page only
++ * reserves the bar's height (openvibe.design/patterns/bottom-tabs).
*/
-- const wrap = (current, content, signedIn = false) => `<div class="c4"><main class="c4-main">${content}</main><nav class="c4-mobile" aria-label="Workspace">${LINKS.map(([label, href, svg]) => `<a href="${href}"${current === href ? ' aria-current="page"' : ''}>${icon(svg)}<span>${label}</span></a>`).join('')}</nav></div>`;
++ const wrap = (content) => `<div class="c4"><main class="c4-main">${content}</main></div>`;
const signIn = (req) => `<section class="c4-card c4-empty"><h2>Sign in to see your work</h2><p>Your tasks, machines and usage belong to your OpenVibe account.</p><a class="c4-button" href="/auth/login?next=${encodeURIComponent(req.originalUrl || '/')}">Sign in</a></section>`;
const sameOrigin = (req) => {
const origin = req.get('origin');
@@@ -284,8 +278,8 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
? `<div class="c4-outcome"><span class="c4-obadge" aria-hidden="true">${icon('<path d="m18 6-12 12M6 6l12 12"/>')}</span><div class="c4-outcome-copy"><b>${row.outcome === 'stopped' ? 'Stopped' : 'The last turn did not finish'}</b><span>${esc(row.outcome_note || row.error || 'Write a message to carry on.')}</span></div></div>` : '';
const transcript = `<section class="c4-card c4-transcript" data-c4-conductor-stream="${esc(`${api}/events?last_event_id=${lastSeq}`)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-c4-conductor="${live ? 'live' : 'idle'}"><div class="c4-card-head"><h2>${live ? 'This turn, live' : 'The last turn'}</h2><span class="c4-muted" data-live-label>${live ? 'Connecting…' : 'Nothing is running'}</span></div><pre class="c4-log" data-task-log aria-live="polite" tabindex="0">${esc(lastLines)}</pre></section>`;
const composer = row.outcome === 'stopped' ? '' : `<section class="c4-card c4-conductor-composer"><form method="post" action="/tasks/${encodeURIComponent(row.id)}/messages" data-c4-conductor-form data-conductor-api="/api/v1/conductor/${encodeURIComponent(row.id)}/messages"><label for="c4-message">${live ? 'Say something: it is the next turn' : 'What next?'}</label><textarea id="c4-message" name="text" rows="3" placeholder="Tell your conductor what to do next, or ask what it is doing" required></textarea><div class="c4-conductor-actions"><label class="c4-file">Attach an image<input type="file" accept="image/*" multiple data-c4-images></label><button class="c4-button" type="submit">Send</button></div><p class="c4-hint" data-c4-message-status role="status">${live ? 'A message waits until the current turn ends; it is not lost.' : ''}</p></form></section>`;
-- const content = `${outcome}<section class="c4-card c4-conductor-head"><div class="c4-task-heading"><div class="c4-task-title-wrap"><h1>Conductor</h1></div><span class="c4-status c4-${esc(row.state)}">${esc(conductorState(row))}</span></div><div class="c4-chips" aria-label="Conductor details"><span class="c4-chip">${esc(row.folder || 'a folder')}</span><span class="c4-chip">${esc(row.model || 'opus')}</span><span class="c4-chip">safety: ${esc(row.safety || 'ask')}</span><span class="c4-chip">${esc(machineLabel || 'your machine')}</span></div><p class="c4-hint">One Claude Code session on your own machine, with your own login. Each message is the next turn of the same conversation; its work is carried out through the Codes tools, on your other agents.</p></section>${approvals}<section class="c4-card c4-turns"><div class="c4-card-head"><h2>Turns</h2><span class="c4-muted">${(view.messages || []).length} so far</span></div>${turns ? `<ol class="c4-turn-list">${turns}</ol>` : '<div class="c4-emptyline">Its first turn is starting.</div>'}</section>${transcript}${composer}`;
-- page(req, res, 'Conductor', wrap('/tasks', content, true));
++ const content = `${outcome}<section class="c4-card c4-conductor-head"><div class="c4-task-heading"><div class="c4-task-title-wrap"><h1>Conductor</h1></div><span class="c4-status c4-${esc(row.state)}">${esc(conductorState(row))}</span></div><div class="c4-chips" aria-label="Conductor details"><span class="c4-chip">${esc(row.folder || 'a folder')}</span><span class="c4-chip">${esc(modelLabel(row.model))}</span><span class="c4-chip">safety: ${esc(row.safety || 'ask')}</span><span class="c4-chip">${esc(machineLabel || 'your machine')}</span></div><p class="c4-hint">One Claude Code session on your own machine, with your own login. Each message is the next turn of the same conversation; its work is carried out through the Codes tools, on your other agents.</p></section>${approvals}<section class="c4-card c4-turns"><div class="c4-card-head"><h2>Turns</h2><span class="c4-muted">${(view.messages || []).length} so far</span></div>${turns ? `<ol class="c4-turn-list">${turns}</ol>` : '<div class="c4-emptyline">Its first turn is starting.</div>'}</section>${transcript}${composer}`;
++ page(req, res, 'Conductor', wrap(content));
}
// ── Home ────────────────────────────────────────────────────────────────────────────────────────────────────
@@@ -309,7 +303,7 @@ ${signedIn ? `<section class="c4-card c4-conductor-cta"><div class="c4-card-head
<section class="c4-card"><div class="c4-card-head"><h2>Recent tasks</h2><a href="/tasks">View all</a></div>${rows.slice(0, 5).map(rowHtml).join('') || '<div class="c4-emptyline">No tasks yet. <a href="/#c4-prompt">Start a task</a></div>'}</section>
${signedIn && !mine.ok && mine.problem ? note(`Your machines could not be read, so the machine picker has only Auto. ${esc(mine.problem.detail || '')}`, 'bad') : ''}
${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h2><div class="c4-grid">${[['Any agent', 'Bring a key or plan. Auto picks a good fit and can hand over when one gets stuck.'], ['Your computer or ours', 'Pair your computer with one command. A hosted sandbox is coming soon.'], ['Proof you can inspect', 'See the changes, tests, screenshots and review before you approve.'], ['A cost you can follow', 'Track task costs and plan limits. Set caps that stop with a wrap-up.']].map(([a, b]) => `<article><h3>${a}</h3><p>${b}</p></article>`).join('')}</div></section>`}`;
-- page(req, res, null, wrap('/', content, signedIn), signedIn ? {} : { index: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });
++ page(req, res, null, wrap(content), signedIn ? {} : { index: true, styles: [showcase.STYLESHEET], jsonLd: homeJsonLd(config) });
});
/**
@@@ -345,11 +339,11 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
];
const taskList = filtered.length ? groups.filter(([, items]) => items.length).map(([label, items]) => `<section class="c4-card"><div class="c4-card-head"><h2>${label}</h2></div>${items.map(rowHtml).join('')}</section>`).join('') : `<div class="c4-card c4-emptyline">${q ? 'No matching tasks.' : 'No tasks yet.'} <a href="${q ? '/tasks' : '/#c4-prompt'}">${q ? 'Clear search' : 'Start a task'}</a></div>`;
const content = `${header('Tasks')}${viewer(req) ? `<form class="c4-search" method="get"><label for="task-search">Search your tasks</label><input id="task-search" name="q" value="${esc(q)}" placeholder="Search by task, project or agent"><button class="c4-button c4-button-light">Search</button></form>${taskList}` : signIn(req)}`;
-- page(req, res, 'Tasks', wrap('/tasks', content, !!viewer(req)));
++ page(req, res, 'Tasks', wrap(content));
});
r.get('/tasks/:id', async (req, res) => {
-- if (!viewer(req)) return page(req, res, 'Task', wrap('/tasks', `${header('Task')}${signIn(req)}`));
++ if (!viewer(req)) return page(req, res, 'Task', wrap(`${header('Task')}${signIn(req)}`));
if (!safeId(req.params.id)) return res.status(404).send('Not found');
const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);
if (!row) return res.status(404).send('Not found');
@@@ -392,33 +386,37 @@ ${signedIn ? '' : `<section class="c4-reasons"><h2>Built for the way you work</h
: `<p class="c4-muted">${live ? 'The proof is collected when the task ends: its tests, its pages, its diff and a second opinion.' : 'This task has no proof: it ended before proof was collected.'}</p>`;
const prPanel = await pullRequestPanel(row);
const content = `<section class="c4-card c4-task-detail" data-task-api="${esc(taskApi)}" data-retry-url="${esc(retry)}" data-switch-url="${esc(switchAgent)}">${outcomeRow(row)}<div class="c4-task-heading"><div class="c4-task-title-wrap"><h1 class="c4-brief" data-brief>${esc(row.prompt || 'Task')}</h1><button class="c4-brief-expand" type="button" hidden>Show the whole brief</button></div>${row.outcome === 'unfinished' || row.error ? '' : stateChip(row.state)}</div>${taskChips(row, machineName)}${progress}<nav class="c4-tabs" aria-label="Task details">${[['live', 'Live'], ['changes', 'Changes'], ['proof', 'Proof'], ['pull-request', 'Pull request']].map(([id, label]) => `<a href="#${id}" role="tab" aria-selected="${id === preferredTab}" data-c4-tab="${id}">${label}</a>`).join('')}</nav><section id="live" class="c4-panel" role="tabpanel"><div id="live-content" class="c4-progress-content" data-c4-stream="${esc(events)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-live="${live ? 'true' : 'false'}"><div class="c4-progress-inner"><p data-live-label>${live ? 'Connecting to the live task…' : 'This task has finished.'}</p><ol class="c4-attempts" data-c4-attempts aria-live="polite">${attemptItems}</ol><pre class="c4-log" data-task-log aria-live="polite" hidden></pre></div></div></section><section id="changes" class="c4-panel" role="tabpanel">${changesPanel(row)}</section><section id="proof" class="c4-panel" role="tabpanel">${proofPanel}</section><section id="pull-request" class="c4-panel" role="tabpanel">${prPanel}</section>${live ? `<div class="c4-actions"><form method="post" action="/run/tasks/${encodeURIComponent(row.id)}/cancel"><button class="c4-button c4-button-light">Stop task</button></form></div>` : ''}</section>`;
-- page(req, res, 'Task', wrap('/tasks', content, true));
++ page(req, res, 'Task', wrap(content));
});
// ── The conductor's pages (C9b): start one, write to it, answer what it asks ────────────────────────────────
r.get('/conductor', async (req, res) => {
-- if (!viewer(req)) return page(req, res, 'Conductor', wrap('/conductor', `${header('Conductor')}${signIn(req)}`));
++ if (!viewer(req)) return page(req, res, 'Conductor', wrap(`${header('Conductor')}${signIn(req)}`));
const settings = await userSettings.get(req.viewer.subject);
const mine = await machinesOf(req, res);
const used = await lastUsedMachines(req);
const machineOptions = orderMachines((mine.machines || []).filter((m) => m.state !== 'revoked'), used)
.map((m) => `<option value="${esc(m.id)}"${settings.default_machine === m.id ? ' selected' : ''}>${esc(m.name || m.node_id || m.id)}</option>`).join('');
++ // The folders this person's machines have worked in, newest first: the choices beside the typed path.
const folders = await db.many(
-- `SELECT folder, max(created_at) AS at FROM run_tasks WHERE subject = $1 AND kind = 'conductor' AND folder IS NOT NULL
-- GROUP BY folder ORDER BY at DESC LIMIT 8`, [req.viewer.subject],
++ `SELECT folder, machine, max(created_at) AS at, max(kind) AS last_kind FROM run_tasks
++ WHERE subject = $1 AND folder IS NOT NULL
++ GROUP BY folder, machine ORDER BY at DESC LIMIT 40`, [req.viewer.subject],
);
++ const machineName = new Map((mine.machines || []).map((m) => [m.id, m.name || m.node_id || m.id]));
++ const folderChoices = folders.map((f) => `<option value="${esc(f.folder)}" data-machine="${esc(f.machine || '')}">${esc(f.folder)}${f.machine ? ` · ${esc(machineName.get(f.machine) || f.machine)}` : ''}</option>`).join('');
const existing = await db.many("SELECT * FROM run_tasks WHERE subject = $1 AND kind = 'conductor' ORDER BY created_at DESC LIMIT 4", [req.viewer.subject]);
-- const list = existing.map((row) => `<a class="c4-task" href="${taskHref(row)}"><span class="c4-status-dot c4-${esc(row.state)}" aria-label="${esc(conductorState(row))}"></span><span class="c4-task-title">${esc(row.folder || 'Conductor')}</span><span class="c4-task-meta">${esc([row.model || 'opus', conductorState(row), age(row.created_at)].join(' · '))}</span></a>`).join('');
++ const list = existing.map((row) => `<a class="c4-task" href="${taskHref(row)}"><span class="c4-status-dot c4-${esc(row.state)}" aria-label="${esc(conductorState(row))}"></span><span class="c4-task-title">${esc(row.folder || 'Conductor')}</span><span class="c4-task-meta">${esc([modelLabel(row.model), conductorState(row), age(row.created_at)].join(' · '))}</span></a>`).join('');
const content = `${header('Conductor', 'Your orchestrator, running here: one Claude Code session on your machine, on your own login, carrying the plan forward through the other agents.')}${flash(req)}
<section class="c4-card c4-composer"><form method="post" action="/conductor">
--<label for="c4-folder">Working folder</label><input id="c4-folder" name="folder" required placeholder="/home/you/project" value="${esc(String(req.query.folder || ''))}" list="c4-folders" autocomplete="off" spellcheck="false"><datalist id="c4-folders">${folders.map((f) => `<option value="${esc(f.folder)}"></option>`).join('')}</datalist>
--<div class="c4-controls"><label>Machine <select name="machine"><option value="">Your default</option>${machineOptions}</select></label><label>Model <input name="model" value="opus" autocomplete="off" spellcheck="false"></label><label>Safety <select name="safety">${[['ask', 'Ask me'], ['project', 'Auto in the project'], ['full', 'Full access']].map(([v, l]) => `<option value="${v}"${settings.safety === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label></div>
++<label for="c4-folder">Working folder</label><div class="c4-folder-row"><input id="c4-folder" name="folder" placeholder="/home/you/project" value="${esc(String(req.query.folder || ''))}" list="c4-folders" autocomplete="off" spellcheck="false"><select name="folder_pick" data-c4-folder-pick aria-label="Folders this machine has worked in"><option value="">A recent folder…</option>${folderChoices}</select></div><datalist id="c4-folders">${folders.map((f) => `<option value="${esc(f.folder)}"></option>`).join('')}</datalist>
++<div class="c4-controls"><label>Machine <select name="machine" data-c4-machine><option value="">Your default</option>${machineOptions}</select></label><label>Model <select name="model" data-c4-model>${MODELS.map(([v, l]) => `<option value="${v}"${(settings.model || 'opus') === v ? ' selected' : ''}>${l}</option>`).join('')}<option value="other">Other…</option></select></label><label class="c4-model-other" data-c4-model-other hidden>Other model <input name="model_other" placeholder="a model name or id" autocomplete="off" spellcheck="false"></label><label>Safety <select name="safety">${[['ask', 'Ask me'], ['project', 'Auto in the project'], ['full', 'Full access']].map(([v, l]) => `<option value="${v}"${settings.safety === v ? ' selected' : ''}>${l}</option>`).join('')}</select></label></div>
<label class="c4-note-field" for="c4-conductor-note">What should it pick up? <small>Optional. It starts from the hand-over note and the plan; say here what you want first.</small></label><textarea id="c4-conductor-note" name="note" rows="2" placeholder="e.g. carry on with the next piece of the plan"></textarea>
<p class="c4-hint">It opens that folder, so it loads that project's memory and AGENTS.md. It runs with your own Claude Code login on that machine, and never with a key of ours.${mine.problem ? ` ${esc(mine.problem.detail || '')}` : ''}</p>
<div class="c4-conductor-actions"><span class="c4-hint">One conductor is enough: it hands the work to the other agents through the Codes tools.</span><button class="c4-button" type="submit">Start your conductor</button></div>
</form></section>
${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors</h2><a href="/tasks">All tasks</a></div>${list}</section>` : ''}`;
-- page(req, res, 'Conductor', wrap('/conductor', content, true));
++ page(req, res, 'Conductor', wrap(content));
});
r.post('/conductor', form, async (req, res) => {
@@@ -426,12 +424,17 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
const b = req.body || {};
try {
++ const folder = String(b.folder || b.folder_pick || '').trim();
++ const picked = String(b.model || 'opus');
++ const model = (picked === 'other' ? String(b.model_other || '') : picked).trim();
++ if (!model) throw Object.assign(new Error('name the model for Other: an alias such as opus, or a model id'), { status: 400 });
++ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(model)) throw Object.assign(new Error('that is not a model name'), { status: 400 });
const project = await conductor.projectFor(req.viewer.token || null, req.viewer.subject);
if (!project) throw Object.assign(new Error(`start one task at ${config.baseUrl}/run first: a conductor runs in your project, on your machine`), { status: 409 });
const made = await conductor.create({
subject: req.viewer.subject, project,
-- folder: String(b.folder || '').trim(), machine: b.machine || null,
-- model: b.model || 'opus', safety: b.safety || 'ask', note: b.note || null,
++ folder, machine: b.machine || null,
++ model, safety: b.safety || 'ask', note: b.note || null,
});
await userSettings.save(req.viewer.subject, { safety: b.safety || 'ask', ...(b.machine ? { default_machine: b.machine } : {}) }).catch(() => {});
return res.redirect(303, `/tasks/${encodeURIComponent(made.task.id)}`);
@@@ -470,11 +473,11 @@ ${list ? `<section class="c4-card"><div class="c4-card-head"><h2>Your conductors
/** The approvals inbox: what the person's conductors are waiting on, from anywhere. */
r.get('/approvals', async (req, res) => {
-- if (!viewer(req)) return page(req, res, 'Approvals', wrap('/approvals', `${header('Approvals')}${signIn(req)}`));
++ if (!viewer(req)) return page(req, res, 'Approvals', wrap(`${header('Approvals')}${signIn(req)}`));
const rows = await approvals.inbox(req.viewer.subject);
const cards = rows.map((a) => `<li class="c4-approval-item">${approvalCard(a, `/approvals/${encodeURIComponent(a.id)}`)}<p class="c4-hint"><a href="/tasks/${encodeURIComponent(a.task_id)}">Open the conductor</a></p></li>`).join('');
const content = `${header('Approvals', 'What your conductors are waiting on.')}${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Waiting for you</h2><span class="c4-muted">${rows.length} decision${rows.length === 1 ? '' : 's'}</span></div>${cards ? `<ul class="c4-approval-list">${cards}</ul>` : '<div class="c4-emptyline">Nothing needs your attention.</div>'}</section>`;
-- page(req, res, 'Approvals', wrap('/approvals', content, true), { notificationsRealtime: true });
++ page(req, res, 'Approvals', wrap(content), { notificationsRealtime: true });
});
r.post('/approvals/:id', form, async (req, res) => {
@@@ -513,7 +516,7 @@ ${rows || plans ? `<ul class="c4-rows">${rows}${plans}</ul>` : `<div class="c4-e
r.get('/agents', async (req, res) => {
const content = `${header('Agents', 'The agents your tasks can run, and the keys behind them.')}${viewer(req) ? `${flash(req)}${await connectionsCard(req)}` : signIn(req)}`;
-- page(req, res, 'Agents', wrap('/agents', content, !!viewer(req)));
++ page(req, res, 'Agents', wrap(content));
});
r.post('/agents/connections', form, async (req, res) => {
if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/agents')}`);
@@@ -578,7 +581,7 @@ ${badCode ? note('a pairing code is 8 letters and digits, like ABCD-1234', 'bad'
${code ? `<div class="c4-pair-command"><p class="c4-kv"><span>On the machine</span><code class="c4-code-inline">sudo openvibe-node pair ${esc(code)} --network https://openvibe.network</code></p><p class="c4-kv"><span>Or with the installer</span><code class="c4-code-inline">curl -fsSL https://openvibe.bot/install | sh -s -- --network https://openvibe.network --pairing <span class="c4-note-inline">pair_… from openvibe.bot</span> --code ${esc(code)} --worker</code></p><p class="c4-hint">The code is the one openvibe.bot gave you for this machine; Codes never mints it. Reload this page once the installer says it is paired.</p></div>` : ''}
</section>`;
const content = `${header('Machines', 'Your own computers, paired with your account.')}${viewer(req) ? `${flash(req)}<section class="c4-card"><div class="c4-card-head"><h2>Your machines</h2></div><p class="c4-sub">${(mine.machines || []).length} paired</p>${mine.ok ? (list ? `<ul class="c4-rows">${list}</ul>` : '<div class="c4-emptyline">No paired computers yet. Pair one below and it shows up here.</div>') : note(`Your machines could not be read. ${esc((mine.problem && mine.problem.detail) || 'Sign in again.')}`, 'bad')}</section>${pairCard}` : signIn(req)}`;
-- page(req, res, 'Machines', wrap('/machines', content, !!viewer(req)));
++ page(req, res, 'Machines', wrap(content));
});
r.post('/machines/:id/revoke', form, async (req, res) => {
if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/machines')}`);
@@@ -602,7 +605,7 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
<p class="c4-hint">Runs are tasks, attempts are the tries inside them, minutes are the time an attempt was running, and tokens are what its session reported.</p>
</section>
<section class="c4-card"><div class="c4-card-head"><h2>Money</h2></div><p class="c4-muted">No prices are shown here. Money for model use belongs to OpenVibe.Billing, whose per-person usage read is not one Codes may make, so Codes shows what it knows — runs, minutes and tokens — and adds no ledger of its own.</p></section>` : signIn(req)}`;
-- page(req, res, 'Usage', wrap('/usage', content, !!viewer(req)));
++ page(req, res, 'Usage', wrap(content));
});
// ── Settings: remembered per person ────────────────────────────────────────────────────────────────────────
@@@ -644,7 +647,7 @@ ${report.days.length ? `<div class="table-wrap" tabindex="0" role="region" aria-
<div class="c4-safety" role="group" aria-label="Safety level">${SAFETY_OPTIONS.map(([value, title, desc]) => `<label class="c4-safety-card"><input type="radio" name="safety" value="${value}"${settings.safety === value ? ' checked' : ''}><span><b>${title}</b><small>${desc}</small></span></label>`).join('')}</div>
<button class="c4-button" type="submit">Save settings</button>
</form></section>` : signIn(req)}`;
-- page(req, res, 'Settings', wrap('/settings', content, !!viewer(req)));
++ page(req, res, 'Settings', wrap(content));
});
r.post('/settings', form, async (req, res) => {
if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent('/settings')}`);
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex c0e60c8..7920ac0 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -91,7 +91,10 @@ function renderPage(o) {
brandName: SITE_NAME,
apiBase: NETWORK_URL,
links,
-- appBar: c4Workspace ? false : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)
++ // Phones: one tab bar, the Frame's own (Shared 4.20). On a workspace page a signed-in person gets the workspace's
++ // sections there — the first four (Home, Tasks, Conductor, Agents) and More, which opens the navbar's drawer with
++ // the rest; the section the person is in is marked. Everyone else keeps the product's four.
++ appBar: c4Workspace && signedIn ? true : [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Start', href: '/start', icon: 'fa-play' }, { label: 'Run', href: '/run', icon: 'fa-microchip' }, { label: 'Agents', href: '/harnesses', icon: 'fa-robot' }],
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
ddiff --git a/test/c4.test.js b/test/c4.test.js
iindex dc30420..91fe2f0 100644
---- a/test/c4.test.js
++++ b/test/c4.test.js
@@@ -12,7 +12,7 @@ const { boot, check, done } = require('./helpers/boot');
const r = await t.get(path);
assert.strictEqual(r.status, 200, `${path} status`);
assert.ok(r.text.includes(label), `${path} content`);
-- assert.ok(r.text.includes('c4-mobile'), `${path} workspace navigation`);
++ assert.ok(r.text.includes('ov-workspace'), `${path} reserves the Frame app bar's height`);
}
const task = await t.get('/tasks/tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV');
assert.strictEqual(task.status, 200);
ddiff --git a/test/c4b.test.js b/test/c4b.test.js
iindex 27d981a..8c579c0 100644
---- a/test/c4b.test.js
++++ b/test/c4b.test.js
@@@ -132,9 +132,9 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
assert.ok(page.text.includes('Online'), 'with its state');
assert.ok(page.text.includes(`/machines/${NODE}/revoke`), 'and a way to revoke it');
assert.ok(page.text.includes('Pair a machine') && page.text.includes('openvibe.bot/machines'), 'the pairing flow is Nodes');
-- assert.ok(page.text.includes('"appBar":false'), 'the workspace keeps the Frame\'s own navbar: no second bar');
++ assert.ok(page.text.includes('"appBar":true') && !page.text.includes('c4-mobile'), 'the workspace gives the Frame app bar its own sections: no second bar');
const coded = await t.get('/machines?code=abcd-2345', { as: ada });
-- assert.ok(coded.text.includes('"appBar":false'), 'a query string does not bring the second bar back');
++ assert.ok(coded.text.includes('"appBar":true'), 'a query string does not bring a second bar back');
assert.ok(coded.text.includes('sudo openvibe-node pair ABCD-2345 --network https://openvibe.network'), 'the code makes the command');
const wrong = await t.get('/machines?code=nope', { as: ada });
assert.ok(wrong.text.includes('a pairing code is 8 letters and digits, like ABCD-1234'), 'a wrong code gets Node&rsquo;s own words');
ddiff --git a/test/c4c.test.js b/test/c4c.test.js
iindex f71ae45..b59f704 100644
---- a/test/c4c.test.js
++++ b/test/c4c.test.js
@@@ -70,15 +70,23 @@ const split = (links) => ({
assert.ok(!top.includes('Machines'), 'and the workspace&rsquo;s sections are not');
});
-- await check('the phone page reserves the tab bar&rsquo;s height, so nothing hides under it', async () => {
++ await check('the phone page reserves the Frame app bar&rsquo;s height, and has no bar of its own', async () => {
const page = await t.get('/settings', { as: ada });
assert.ok(page.text.includes('<body class="ov-workspace">'), 'the workspace page says it carries a tab bar');
++ assert.ok(!page.text.includes('c4-mobile'), 'and it draws no second bar of its own');
const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'c4.css'), 'utf8');
-- assert.ok(/body\.ov-workspace\{[^}]*--c4-tabs-h:calc\(58px \+ env\(safe-area-inset-bottom,0px\)\)/.test(css),
-- 'the tab bar&rsquo;s height is its own height plus the safe area');
-- assert.ok(/body\.ov-workspace\{[^}]*padding-bottom:var\(--c4-tabs-h\)/.test(css),
-- 'and the page reserves it at the bottom');
-- assert.ok(/\.c4-mobile\{[^}]*height:var\(--c4-tabs-h\)/.test(css), 'the bar itself is that tall');
++ assert.ok(/body\.ov-workspace\{[^}]*padding-bottom:var\(--ovnav-appbar-h,calc\(58px \+ env\(safe-area-inset-bottom,0px\)\)\)/.test(css),
++ 'the page reserves the Frame bar&rsquo;s height, its safe area included');
++ });
++
++ await check('the Frame&rsquo;s app bar is the workspace&rsquo;s sections for a signed-in person, and the product&rsquo;s for a visitor', async () => {
++ const signedIn = await t.get('/tasks', { as: ada });
++ assert.strictEqual(navbarOf(signedIn.text).appBar, true,
++ 'the bar takes the navbar&rsquo;s own sections: the first four and More (Shared appbar.js)');
++ const visitor = await t.get('/tasks');
++ const bar = navbarOf(visitor.text).appBar;
++ assert.ok(Array.isArray(bar) && bar.length <= 5, 'a visitor&rsquo;s bar is at most five places');
++ assert.deepStrictEqual(bar.map((l) => l.label), ['Home', 'Start', 'Run', 'Agents'], 'and they are the product&rsquo;s');
});
await check('a card&rsquo;s muted note sits under its heading, not beside it', async () => {
ddiff --git a/test/conductor.test.js b/test/conductor.test.js
iindex 0f1cf32..0d6f28c 100644
---- a/test/conductor.test.js
++++ b/test/conductor.test.js
@@@ -155,6 +155,26 @@ const NODE = 'nod_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
assert.strictEqual(subRow.via, 'conductor');
});
++ await check('the start form: a model list the login offers, and the folders the machine has worked in', async () => {
++ const res = await get('/conductor');
++ assert.strictEqual(res.status, 200);
++ assert.match(res.text, /<select name="model" data-c4-model>/, 'the model is a list');
++ for (const label of ['Opus 5.5 (default)', 'Sonnet 5.5', 'Haiku 5.5', 'Other…']) assert.ok(res.text.includes(label), `${label} is offered`);
++ assert.match(res.text, /data-c4-folder-pick/, 'the recent folders are a choice beside the typed path');
++ assert.ok(res.text.includes(`data-machine="${NODE}"`), 'this conductor&rsquo;s folder is one, on its machine');
++ assert.ok(res.text.includes('/home/ada/openvibe'), 'and it is the folder this person has used');
++ });
++
++ await check('a recent folder and a model the person named start a conductor', async () => {
++ const res = await t.get('/conductor', { as: ada, form: { folder: '', folder_pick: '/home/ada/openvibe', machine: NODE, model: 'other', model_other: 'claude-opus-5-5', safety: 'ask' } });
++ assert.strictEqual(res.status, 303, res.text);
++ const id = decodeURIComponent(String(res.headers.get('location') || '').split('/').pop());
++ const row = await rowOf(id);
++ assert.strictEqual(row.folder, '/home/ada/openvibe', 'the picked folder');
++ assert.strictEqual(row.model, 'claude-opus-5-5', 'the model behind Other…');
++ await post(`/api/v1/conductor/${id}/stop`, {});
++ });
++
await check('the page: the conductor is a conversation, not a diff', async () => {
const res = await get(`/tasks/${taskId}`);
assert.strictEqual(res.status, 200);