openvibers/openvibe.codes Commits
The chat is the network's conversation UI: agents by name, hand-overs, approvals and the panel (#248)
Changes
@@@ -94,7 +94,7 @@ More: [docs/cli.md](docs/cli.md).- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).--- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.95.0** (pinned tag tarballs).++- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.99.0** (pinned tag tarballs).No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).@@@ -9,11 +9,11 @@"domain": "openvibe.codes","contracts": "openvibe-contracts v0.261.0","sdk": "openvibe-sdk v0.81.0",-- "shared": "openvibe-shared v4.95.0",++ "shared": "openvibe-shared v4.99.0","packages": ["openvibe-contracts v0.261.0","openvibe-sdk v0.81.0",-- "openvibe-shared v4.95.0"++ "openvibe-shared v4.99.0"],"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.2) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.","works": [@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.95.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0","pg": "^8.23.0"},"bin": {@@@ -1193,9 +1193,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.95.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.95.0",-- "integrity": "sha512-jY0ZKFQMvx3NiyKxHkZ950nq7P8CtlzlP4OOnTkJf0nyVVG93qT4JE0udr8bF8Kwm7FrzkLgfaLB1qDrjH22eg==",++ "version": "4.99.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",++ "integrity": "sha512-i8iioHF2nBboP5RHAoPKCVn6pYjfOqgDhkX4k8KIGLKqt5ItClwKL8S1giWJ7hFZc2XkuSsRHEC05nArMHqtyg==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -32,7 +32,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.95.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0","pg": "^8.23.0"},"engines": {@@@ -8,7 +8,7 @@.c4-card{background:var(--c4-surface);border:1px solid var(--c4-line);border-radius:13px;padding:20px;margin:0 0 16px;box-shadow:0 1px 2px #1b29340a}.c4-card h2{font-size:16px;letter-spacing:-.02em;margin:0}.c4-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}--.c4-card-head a,.c4-card a:not(.c4-button){color:var(--c4-accent);font-size:13px}++.c4-card-head a:not(.c4-button),.c4-card a:not(.c4-button){color:var(--c4-accent);font-size:13px}.c4-sub{margin:6px 0 12px;color:var(--c4-muted);font-size:13px;line-height:1.45}.c4-composer{border-color:#d5e4df;background:linear-gradient(140deg,#fff,#f8fcfa)}.c4-composer form>label{display:block;font-size:15px;font-weight:650;margin-bottom:12px}@@@ -1,95 +1,35 @@--/* Codes as a chat app (plan L31 CH1): the rail of conversations, the conversation and its composer, and what the work-- produced at the side. Every colour comes from the person's theme (the Frame's tokens), so a conversation looks like-- the rest of their OpenVibe; the agents' own colours only tint their faces. One column of reading width in the-- middle (760px), as in the chat apps people know. */--.ch{--ch-bg:var(--bg-primary,#0f1419);--ch-rail:var(--bg-secondary,#141a21);--ch-card:var(--bg-card,#171e26);--ch-hover:var(--bg-hover,rgba(127,140,160,.12));--ch-ink:var(--text-primary,#e8edf3);--ch-soft:var(--text-secondary,#b4bfcc);--ch-muted:var(--text-muted,#8793a3);--ch-line:var(--border,rgba(127,140,160,.22));--ch-accent:var(--accent,#7c6cff);--ch-on-accent:var(--on-accent,#fff);--ch-ok:var(--success,#22c55e);--ch-bad:var(--danger,#ef4444);--ch-warn:var(--warning,#f59e0b);--ch-col:760px;-- display:grid;grid-template-columns:272px minmax(0,1fr);height:calc(100dvh - 52px);min-height:420px;background:var(--ch-bg);color:var(--ch-ink);font-size:15px}--.ch.has-side{grid-template-columns:272px minmax(0,1fr) 300px}++/* Codes' own parts of the chat app (plan L31 CH1). The conversation UI is the network's: openvibe-shared thread.css++ draws the rail (kit.sidebar), the turns, the agents' faces, the hand-overs, the approvals, the panel ("What it did")++ and the composer, in the person's theme. Here: the state in the head, the result card, the composer's pickers and++ lines, a new chat's greeting, its three ways to start and the ideas rail, and the workspace's other pages. */++.ch{--ch-rail:var(--bg-secondary,#141a21);--ch-card:var(--bg-card,#171e26);--ch-hover:var(--bg-hover,rgba(127,140,160,.12));--ch-ink:var(--text-primary,#e8edf3);--ch-soft:var(--text-secondary,#b4bfcc);--ch-muted:var(--text-muted,#8793a3);--ch-line:var(--border,rgba(127,140,160,.22));--ch-accent:var(--accent,#7c6cff);--ch-on-accent:var(--on-accent,#fff);--ch-ok:var(--success,#22c55e);--ch-bad:var(--danger,#ef4444);--ch-warn:var(--warning,#f59e0b);++ font-size:15px}.ch-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}.ch-i{width:18px;height:18px;flex:none}/* The page is the app: no footer under it, no phone tab bar over it (the rail is the navigation here). */body:has(.ch) #ov-footer,body:has(.ch) .openvibe-footer{display:none}body.ov-workspace:has(.ch){padding-bottom:0}main.page:has(> .ch){max-width:none;margin:0;padding:0}++@media (max-width:760px){body:has(.ch) .ovnav-appbar{display:none!important}body:has(.ch){--ovnav-appbar-h:0px}}--/* ── The rail ── */--.ch-group{margin:14px 8px 4px;font-size:11.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--ch-muted)}----/* Faces: an agent's initials in its own colour; the conductor's is its sign. */--.ch-av{display:inline-grid;place-items:center;width:24px;height:24px;flex:none;border-radius:50%;font-size:10.5px;font-weight:800;letter-spacing:-.02em;color:var(--av,#64748b);background:color-mix(in srgb,var(--av,#64748b) 18%,transparent)}--.ch-av-conductor{color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 16%,transparent)}--.ch-av-conductor .ch-i{width:14px;height:14px}--/* State dots: they breathe while something works; nothing spins. */--.ch-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--ch-muted)}--.ch-dot.is-busy{background:var(--ch-accent);animation:ch-breathe 1.6s ease-in-out infinite}--.ch-dot.is-needs{background:var(--ch-warn)}--.ch-dot.is-bad{background:var(--ch-bad)}--@keyframes ch-breathe{0%,100%{opacity:.45;scale:.8}50%{opacity:1;scale:1.1}}----/* ── The conversation ── */--.ch-main{display:flex;flex-direction:column;min-width:0;min-height:0;position:relative}--.ch-head{display:flex;align-items:center;gap:10px;min-height:52px;padding:6px 16px;border-bottom:1px solid var(--ch-line)}--.ch-title{flex:1;min-width:0;margin:0;font-size:15px;font-weight:650;letter-spacing:-.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}--.ch-state{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12.5px;font-weight:600;color:var(--ch-soft);background:var(--ch-hover);white-space:nowrap}++/* The state in the head: it breathes while something works; nothing spins. */++.ch-state{display:inline-flex;align-items:center;gap:6px;flex:none;padding:3px 10px;border-radius:999px;font-size:12.5px;font-weight:600;color:var(--ch-soft);background:var(--ch-hover);white-space:nowrap}.ch-state.is-ok{color:var(--ch-ok);background:color-mix(in srgb,var(--ch-ok) 12%,transparent)}.ch-state.is-bad{color:var(--ch-bad);background:color-mix(in srgb,var(--ch-bad) 12%,transparent)}.ch-state.is-busy{color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 12%,transparent)}++.ch-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--ch-muted)}++.ch-dot.is-busy{background:var(--ch-accent);animation:ch-breathe 1.6s ease-in-out infinite}++@keyframes ch-breathe{0%,100%{opacity:.45}50%{opacity:1}}.ch-icon-btn{display:inline-grid;place-items:center;width:36px;height:36px;flex:none;border:0;border-radius:10px;background:transparent;color:var(--ch-soft);cursor:pointer}.ch-icon-btn:hover{background:var(--ch-hover);color:var(--ch-ink)}--.ch-side-open{display:none}--/* The rail is the network's sidebar (openvibe-shared kit.sidebar): the grid's first column on a computer, a drawer on-- a phone (kit.css). Its avatars are Codes' own. */--@media (min-width:761px){.ch > .ovk-sidebar{position:relative;top:auto;height:auto;min-height:0}}--.ch .ovk-sidebar-item .ch-av{width:24px;height:24px;font-size:11px}--.ch-head .ovk-sidebar-open{color:var(--ch-ink)}--.ch-thread{flex:1;min-height:0;overflow-y:auto;padding:28px max(20px,calc((100% - var(--ch-col)) / 2)) 24px;display:flex;flex-direction:column;gap:22px;scroll-behavior:smooth}--.ch-turn{display:flex;flex-direction:column;gap:8px;min-width:0}--.ch-you{align-items:flex-end}--.ch-bubble{max-width:min(82%,620px);padding:11px 15px;border-radius:20px 20px 6px 20px;background:color-mix(in srgb,var(--ch-accent) 15%,var(--ch-card));white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.55}--.ch-files{display:flex;flex-wrap:wrap;gap:6px;margin:0;justify-content:flex-end}--.ch-who{display:flex;align-items:center;gap:8px;font-size:13.5px}--.ch-who span:not(.ch-av){color:var(--ch-muted);font-size:12.5px}--.ch-md{line-height:1.68;overflow-wrap:anywhere}--.ch-md>:first-child{margin-top:0}.ch-md>:last-child{margin-bottom:0}--.ch-md p{margin:0 0 .8em}--.ch-md pre{overflow-x:auto;padding:12px 14px;border-radius:12px;background:var(--ch-rail);border:1px solid var(--ch-line);font-size:13px;line-height:1.5}--.ch-md code{font-size:.9em;padding:.1em .35em;border-radius:6px;background:var(--ch-hover)}--.ch-md pre code{padding:0;background:none}--.ch-md a{color:var(--ch-accent)}--.ch-md h1,.ch-md h2,.ch-md h3{font-size:1.02em;margin:1.1em 0 .4em}--.ch-md table{border-collapse:collapse;display:block;overflow-x:auto}--.ch-md td,.ch-md th{border:1px solid var(--ch-line);padding:5px 9px}--.ch-quiet{margin:0;color:var(--ch-muted);font-size:14px}.ch-error{margin:0;padding:10px 14px;border-radius:12px;color:var(--ch-bad);background:color-mix(in srgb,var(--ch-bad) 10%,transparent);font-size:14px}----/* The steps an agent took: one quiet fold per turn, each step with the sign of what it did. */--.ch-steps{align-self:flex-start;max-width:100%}--.ch-steps summary{display:inline-flex;align-items:center;gap:6px;padding:4px 10px 4px 12px;border-radius:999px;background:var(--ch-hover);color:var(--ch-soft);font-size:12.5px;font-weight:600;cursor:pointer;list-style:none}--.ch-steps summary::-webkit-details-marker{display:none}++.ch-muted{color:var(--ch-muted);font-size:13px}.ch-caret{width:14px;height:14px;transition:rotate .2s}details[open]>summary .ch-caret{rotate:180deg}--.ch-steps ol{list-style:none;margin:8px 0 0;padding:4px 0 0 12px;border-left:2px solid var(--ch-line);display:grid;gap:2px;max-height:min(46vh,420px);overflow-y:auto}--.ch-step{display:flex;align-items:center;gap:8px;min-width:0;padding:3px 6px;border-radius:8px;font-size:13px;color:var(--ch-soft)}--.ch-step .ch-i{width:15px;height:15px;color:var(--ch-muted)}--.ch-step-n{font-weight:600;color:var(--ch-ink);white-space:nowrap}--.ch-step-w{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ch-muted);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}--.ch-step.is-bad .ch-i,.ch-step.is-bad .ch-step-n{color:var(--ch-bad)}--.ch-step.is-new{animation:ch-in .35s ease-out}--@keyframes ch-in{from{opacity:0;translate:0 4px}}--/* Working: three dots hopping in turn, and the step it is on. */--.ch-working{display:flex;align-items:center;gap:10px;color:var(--ch-muted);font-size:13.5px;min-width:0}--.ch-working span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}--.ch-dots{display:inline-flex;gap:4px}--.ch-dots i{width:6px;height:6px;border-radius:50%;background:var(--ch-accent);animation:ch-hop 1s ease-in-out infinite}--.ch-dots i:nth-child(2){animation-delay:.15s}.ch-dots i:nth-child(3){animation-delay:.3s}--@keyframes ch-hop{0%,80%,100%{translate:0 0;opacity:.45}40%{translate:0 -4px;opacity:1}}--/* A hand-over, a wait: a quiet line across the thread. */--.ch-divider{display:flex;align-items:center;gap:10px;color:var(--ch-muted);font-size:12.5px;font-weight:600}--.ch-divider::before,.ch-divider::after{content:'';flex:1;height:1px;background:var(--ch-line)}--.ch-divider .ch-i{width:15px;height:15px}--/* How it ended. */--.ch-result{display:grid;gap:2px;padding:6px;border:1px solid var(--ch-line);border-radius:16px;background:var(--ch-card)}--.ch-res-line,.ch-res-fold summary{display:flex;align-items:center;gap:9px;margin:0;padding:9px 10px;border-radius:11px;font-size:14px}++++/* How it ended: a card in the last reply (the kit's), with the files, the proof and the pull request. */++.ch-result{display:grid;gap:2px;margin:-6px}++.ch-res-line,.ch-res-fold summary{display:flex;align-items:center;gap:9px;margin:0;padding:9px 10px;border-radius:11px;font-size:14px;min-width:0}.ch-res-line.is-bad{color:var(--ch-bad)}.ch-res-fold summary{cursor:pointer;list-style:none}.ch-res-fold summary::-webkit-details-marker{display:none}@@@ -112,29 +52,14 @@ details[open]>summary .ch-caret{rotate:180deg}.ch-diff{margin:6px 0 0}.ch-diff summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--ch-soft)}.ch-diff pre{max-height:50vh;overflow:auto;margin:6px 0 0;padding:10px 12px;border-radius:10px;background:var(--ch-rail);font-size:12px;line-height:1.5}--.ch-cap{font-size:12px;font-weight:650;color:var(--ch-muted)}.ch-add{color:var(--ch-ok);font-variant-numeric:tabular-nums;font-size:12.5px}.ch-del{color:var(--ch-bad);font-variant-numeric:tabular-nums;font-size:12.5px}--.ch-muted{color:var(--ch-muted);font-size:13px}--/* What waits for a yes. */--.ch-approval{display:grid;gap:10px;padding:14px;border-radius:16px;border:1px solid color-mix(in srgb,var(--ch-warn) 45%,var(--ch-line));background:color-mix(in srgb,var(--ch-warn) 7%,var(--ch-card))}--.ch-approval-h{display:flex;align-items:center;gap:8px;margin:0;font-size:14px}--.ch-approval code{display:block;overflow-x:auto;padding:8px 10px;border-radius:10px;background:var(--ch-rail);font-size:12.5px;white-space:pre}--.ch-approval-a{display:flex;gap:8px}--.ch-btn{padding:8px 16px;border:0;border-radius:999px;background:var(--ch-accent);color:var(--ch-on-accent);font:inherit;font-size:14px;font-weight:650;cursor:pointer}--.ch-btn-quiet{background:var(--ch-hover);color:var(--ch-ink)}++++/* The composer's pickers and chips (in the kit composer's tools). */.ch-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;border:1px solid var(--ch-line);background:var(--ch-card);color:var(--ch-soft);font:inherit;font-size:13px;text-decoration:none;cursor:pointer}.ch-chip:hover{color:var(--ch-ink);border-color:color-mix(in srgb,var(--ch-accent) 40%,var(--ch-line))}.ch-chip .ch-i{width:15px;height:15px}.ch-chip-static{cursor:default;padding:4px 10px 4px 4px}--.ch-chip-static .ch-av{width:22px;height:22px}----/* ── The composer ── */--.ch-composer{position:relative;margin:0 max(16px,calc((100% - var(--ch-col)) / 2)) 14px;padding:10px 10px 8px 16px;border:1px solid var(--ch-line);border-radius:22px;background:var(--ch-card);box-shadow:0 8px 28px rgba(0,0,0,.14)}--.ch-composer:focus-within{border-color:color-mix(in srgb,var(--ch-accent) 55%,var(--ch-line))}--.ch-composer textarea{display:block;width:100%;box-sizing:border-box;min-height:28px;max-height:40vh;padding:6px 0 8px;border:0;outline:0;resize:none;background:transparent;color:var(--ch-ink);font:inherit;font-size:15.5px;line-height:1.5}--.ch-composer textarea::placeholder{color:var(--ch-muted)}--.ch-bar{display:flex;align-items:center;gap:8px}--.ch-picks{flex:1;min-width:0;display:flex;align-items:center;gap:6px;flex-wrap:wrap}++.ch-chip-static .ovt-avatar{width:22px;height:22px;margin:0;border-radius:7px;font-size:10px}.ch-pick{position:relative;display:inline-flex;align-items:center;gap:6px;max-width:100%;height:32px;padding:0 10px;border-radius:999px;border:1px solid var(--ch-line);color:var(--ch-soft);font-size:13px}.ch-pick:hover{background:var(--ch-hover);color:var(--ch-ink)}.ch-pick select{appearance:none;-webkit-appearance:none;border:0;background:transparent;color:inherit;font:inherit;font-weight:600;padding:0 16px 0 0;max-width:200px;cursor:pointer}@@@ -147,32 +72,21 @@ details[open]>summary .ch-caret{rotate:180deg}.ch-pick-file{cursor:pointer;padding:0 9px}.ch-pick-file input{position:absolute;inset:0;opacity:0;cursor:pointer}.ch-pick-file.has-files{color:var(--ch-accent);border-color:color-mix(in srgb,var(--ch-accent) 50%,var(--ch-line))}--.ch-send{display:inline-grid;place-items:center;width:36px;height:36px;flex:none;border:0;border-radius:50%;background:var(--ch-accent);color:var(--ch-on-accent);cursor:pointer;transition:opacity .2s,scale .15s}--.ch-send .ch-i{width:18px;height:18px;stroke-width:2.2}--.ch-send:active{scale:.92}--.ch-composer:has(textarea:placeholder-shown) .ch-send{opacity:.45}--.ch-send[disabled]{opacity:.45;cursor:default}--.ch-stop{width:36px;height:36px;border-radius:50%;background:var(--ch-hover);color:var(--ch-ink)}--.ch-stop .ch-i rect{fill:currentColor}--.ch-stop:hover{background:color-mix(in srgb,var(--ch-bad) 18%,transparent);color:var(--ch-bad)}--.ch-note{margin:4px 2px 0;min-height:1em;color:var(--ch-muted);font-size:12px}--.ch-note.is-bad{color:var(--ch-bad)}--.ch-composer.is-sending textarea{opacity:.6}++.ch .ovt-c-note.is-bad{color:var(--ch-bad)}++.ch .ovt-composer.is-sending textarea{opacity:.6}--/* A new chat: the greeting and the composer meet in the middle, as in the chat apps people know. */--.ch.is-empty .ch-main{justify-content:center}--.ch.is-empty .ch-head{position:absolute;inset:0 0 auto;border-bottom:0}--.ch.is-empty .ch-head .ch-title{opacity:0}--.ch.is-empty .ch-thread{flex:none;overflow:visible;padding-bottom:18px}++/* A new chat: the greeting, then the composer (the kit's start page puts them together in the middle). */.ch-hello{display:grid;justify-items:center;gap:10px;text-align:center}.ch-hello h1{margin:6px 0 0;font-size:clamp(24px,3.2vw,32px);letter-spacing:-.03em;line-height:1.15;text-wrap:balance}.ch-hello p{margin:0;max-width:52ch;color:var(--ch-soft);line-height:1.55}++.ch .ovt.is-start .ovt-head .ovt-title{opacity:0}++.ch .ovt.is-start .ovt-head{border-bottom:0;background:none;backdrop-filter:none;-webkit-backdrop-filter:none}/* What you are making: three cards that belong to the composer's form. Each says where its work runs. */.ch-modes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;width:100%;margin:14px 0 0;padding:0;border:0;min-width:0}.ch-mode{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr);align-content:start;gap:2px 11px;padding:12px 14px;border:1px solid var(--ch-line);border-radius:16px;background:var(--ch-card);text-align:left;cursor:pointer;transition:border-color .15s,background-color .15s}.ch-mode:hover{border-color:color-mix(in srgb,var(--ch-accent) 35%,var(--ch-line))}.ch-mode input{position:absolute;inset:0;margin:0;opacity:0;cursor:pointer}--.ch-mode-i{grid-row:span 2;display:grid;place-items:center;width:34px;height:34px;border-radius:11px;color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 12%,transparent)}++.ch-mode-i{grid-row:span 2;display:grid;place-items:center;width:34px;height:34px;border-radius:11px;color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 14%,transparent)}.ch-mode-i .ch-i{width:18px;height:18px}.ch-mode b{font-size:14.5px;letter-spacing:-.01em;color:var(--ch-ink)}.ch-mode small{font-size:12.5px;line-height:1.4;color:var(--ch-muted)}@@@ -200,10 +114,10 @@ details[open]>summary .ch-caret{rotate:180deg}.ch-where-tail{color:var(--ch-muted)}.ch-on{display:inline-flex;align-items:center;gap:4px;font-style:normal;font-weight:600;color:var(--ch-ok)}.ch-on::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor}--/* The ideas rail: it drifts sideways and stops under a pointer, the keyboard or its own pause. Two copies make the-- loop seamless; the second is hidden from assistive tech and the keyboard. Calm motion (either preference) makes-- it a still row that scrolls by hand. */--.ch-spark{display:flex;align-items:center;gap:4px;margin:0 max(16px,calc((100% - var(--ch-col)) / 2)) 12px}++/* The ideas rail, under the composer: it drifts sideways and stops under a pointer, the keyboard or its own pause.++ Two copies make the loop seamless; the second is hidden from assistive tech and the keyboard. Calm motion (either++ preference) makes it a still row that scrolls by hand. */++.ch-spark{display:flex;align-items:center;gap:4px;max-width:780px;margin:12px auto 0}.ch-spark-view{flex:1;min-width:0;overflow:hidden;-webkit-mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent);mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent)}.ch-spark-track{display:flex;width:max-content;animation:ch-drift var(--ch-drift,80s) linear infinite}.ch-spark-list{display:flex;gap:8px;margin:0;padding:2px 8px 2px 0;list-style:none}@@@ -216,51 +130,21 @@ details[open]>summary .ch-caret{rotate:180deg}.ch-spark-pause .ch-i{width:15px;height:15px}.ch-spark .ch-i-play,.ch-spark.is-paused .ch-i-pause{display:none}.ch-spark.is-paused .ch-i-play{display:block}--@media (prefers-reduced-motion:reduce){.ch-spark-track{animation:none}.ch-spark-view{overflow-x:auto;scrollbar-width:none}.ch-spark-list[aria-hidden]{display:none}.ch-spark-pause{display:none}}++@media (prefers-reduced-motion:reduce){.ch-spark-track{animation:none}.ch-spark-view{overflow-x:auto;scrollbar-width:none}.ch-spark-list[aria-hidden]{display:none}.ch-spark-pause{display:none}.ch-dot.is-busy{animation:none}}html[data-ov-motion=reduced] .ch-spark-track{animation:none}html[data-ov-motion=reduced] .ch-spark-view{overflow-x:auto;scrollbar-width:none}html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=reduced] .ch-spark-pause{display:none}--/* The workspace's other pages (Agents, Machines, Usage, Settings…) in the conversation's place: they scroll there and-- keep their own heading, so the bar above them is only the phone's way to the rail. */--.ch-page{flex:1;min-height:0;overflow-y:auto}--.ch-page .c4{max-width:none;padding:0 max(20px,calc((100% - 980px) / 2)) 44px}--.ch.is-page .ch-head{display:none}--@media (max-width:760px){.ch.is-page .ch-head{display:flex;border-bottom:0;min-height:44px}.ch-page .c4{padding:0 14px 32px}.ch-page .c4-main{padding-top:6px}}----/* ── The side: what it did ── */--.ch-side{min-height:0;overflow-y:auto;padding:10px 14px 18px;border-left:1px solid var(--ch-line);background:var(--ch-rail)}--.ch-side-head{display:flex;align-items:center;justify-content:space-between;min-height:40px;font-size:14px}--.ch-side-head .ch-icon-btn{display:none}--.ch-side .ch-group{margin-left:0}--.ch-people,.ch-facts{list-style:none;margin:0;padding:0;display:grid;gap:8px}--.ch-people li{display:flex;align-items:center;gap:10px;font-size:13.5px}--.ch-people small{display:block;color:var(--ch-muted);font-size:12px}--.ch-facts li{display:flex;align-items:flex-start;gap:9px;font-size:13.5px;min-width:0}--.ch-facts li span{min-width:0;overflow-wrap:anywhere}--.ch-facts .ch-i{width:16px;height:16px;color:var(--ch-muted);margin-top:2px}--.ch-facts a{color:var(--ch-accent)}++/* The workspace's other pages (Agents, Machines, Usage, Settings…) in the conversation's place: they keep their own++ heading, so the head above them is only the phone's way to the rail. */++.ch .ovt-page .c4{max-width:none;padding:0 0 44px}++@media (min-width:761px){.ch .ovt.is-page .ovt-head{display:none}}++@media (max-width:760px){.ch .ovt.is-page .ovt-head{min-height:44px}.ch .ovt-page .c4{padding:0 0 32px}.ch .ovt-page .c4-main{padding-top:6px}}--/* Narrower: the side becomes a sheet from the right. */--@media (max-width:1180px){-- .ch.has-side{grid-template-columns:272px minmax(0,1fr)}-- .ch-side-open{display:inline-grid}-- .ch-side{position:fixed;z-index:10060;top:0;right:0;bottom:0;width:min(340px,88vw);translate:100% 0;transition:translate .25s ease;box-shadow:-12px 0 32px rgba(0,0,0,.25)}-- .ch.side-open .ch-side{translate:0 0}-- .ch-side-head .ch-icon-btn{display:inline-grid}--}--/* A phone: the conversation owns the screen; the rail is a drawer, the composer sits on the safe area. */++/* A phone: the three cards stack, the repository box takes the row. */@media (max-width:760px){-- .ch,.ch.has-side{grid-template-columns:minmax(0,1fr);height:calc(100dvh - 52px)}-- body:has(.ch) .ovnav-appbar{display:none!important}-- body:has(.ch){--ovnav-appbar-h:0px}-- .ch-head{padding:4px 8px}-- .ch-thread{padding:18px 14px 16px}-- .ch-bubble{max-width:88%}-- .ch-composer{margin:0 8px calc(8px + env(safe-area-inset-bottom,0px));border-radius:20px}.ch-pick-repo{flex:1 1 100%}.ch-pick-repo input{width:auto;flex:1;min-width:0}-- .ch.is-empty .ch-thread{padding-top:52px}.ch-hello{gap:8px}.ch-hello h1{font-size:23px}.ch-hello p{font-size:14px}@@@ -269,7 +153,4 @@ html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=red.ch-mode-i{width:32px;height:32px;border-radius:10px;align-self:center}.ch-mode b{font-size:14px}.ch-mode small{font-size:12px;line-height:1.35}-- .ch-spark{margin:0 8px 10px}}--.ch-scrim{position:fixed;inset:0;z-index:10050;background:rgba(0,0,0,.4)}--@media (prefers-reduced-motion:reduce){.ch-dot.is-busy,.ch-dots i,.ch-step.is-new{animation:none}.ch-side{transition:none}.ch-thread{scroll-behavior:auto}}@@@ -1,19 +1,18 @@'use strict';--// The chat app's conveniences (plan L31 CH1). The page works without this script; with it:--// - the rail and the side open and close on narrow screens (Escape and the scrim close them);--// - the composer grows with what is typed, Enter sends (Shift+Enter is a new line), an idea fills it;--// - the rail's search filters the conversations as you type;--// - a running turn streams into the thread: each step appears as the agent takes it, and the page renders the--// finished turn when the attempt ends;--// - the conductor's images go up with the message through the JSON API.++// Codes' own parts of the chat app (plan L31 CH1). The conversation UI is the network's (openvibe-shared/thread, plan++// L55 C4): ov-thread.js grows the composer, sends on Enter, filters the rail, opens the panel on a narrow screen, adds++// Copy and keeps the thread at the bottom; kit-web.js opens the rail on a phone. The page works without either. Here:++// - a new chat's three ways to start, the ideas rail, the projects picked from the words and the repository search;++// - the conductor's images go up with the message through the JSON API;++// - a running turn streams into the thread (OVThread.tool / toolDone / live), and the page renders the finished turn++// when the attempt ends.//// Codes moves between pages with openvibe-shared/boost, which swaps the page in place without running this file// again: each page is set up on its first load and on every ov:boost:load, and a live stream is closed when the page--// moves away (the owner, 2026-10-31: the rail's button did nothing on a phone after a move to Machines).++// moves away.(() => {const $ = (sel, root = document) => root.querySelector(sel);-- let page = null; // { app, es, setOpen } for the page on screen-- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && page) page.setOpen('side-open', false); });++ let page = null; // { app, es } for the page on screendocument.addEventListener('ov:boost:before', () => { if (page && page.es) page.es.close(); page = null; });document.addEventListener('ov:boost:load', init);if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();@@@ -21,7 +20,8 @@// ── A new chat: the three ways to start, and the ideas rail ──// The cards are radio buttons of the composer's form (CSS shows each one's pickers); here a card also swaps the// placeholder and the rail's ideas, and an idea fills the box and picks the card it belongs to.-- function startPage(app, form, text, grow) {++ function startPage(app, form, text) {++ const grow = () => text.dispatchEvent(new Event('input', { bubbles: true }));const rail = $('[data-ch-ideas]', app);const track = rail && $('[data-ch-ideas-track]', rail);const kind = $('[data-ch-kind]', form);@@@ -125,51 +125,15 @@const app = document.querySelector('[data-chat]');if (!app || app.dataset.chReady) return;app.dataset.chReady = '1';-- page = { app, es: null, setOpen: () => {} };++ page = { app, es: null };-- // ── The side on narrow screens (the rail is the network's sidebar: kit-web.js opens and closes it) ──-- const scrim = $('[data-ch-scrim]', app);-- const setOpen = (cls, on) => {-- app.classList.toggle(cls, on);-- if (scrim) scrim.hidden = !app.classList.contains('side-open');-- if (on) { const first = $('.ch-side button', app); if (first) first.focus({ preventScroll: true }); }-- };-- app.addEventListener('click', (e) => {-- const t = e.target.closest('[data-ch-side-open],[data-ch-side-close],[data-ch-scrim]');-- if (!t) return;-- setOpen('side-open', t.matches('[data-ch-side-open]'));-- });-- page.setOpen = setOpen;---- // ── The rail's search: filter as you type (Enter still searches every task) ──-- const find = $('.ovk-sidebar-find input', app);-- if (find) {-- find.addEventListener('input', () => {-- const q = find.value.trim().toLowerCase();-- app.querySelectorAll('.ovk-sidebar-sec').forEach((sec) => {-- let shown = false;-- sec.querySelectorAll('.ovk-sidebar-item').forEach((a) => { a.hidden = !!q && !a.textContent.toLowerCase().includes(q); if (!a.hidden) shown = true; });-- sec.hidden = !!q && !shown;-- });-- });-- }---- // ── The composer ──-- const form = $('[data-ch-composer]', app);-- const text = form && $('[data-ch-text]', form);-- const status = form && $('[data-ch-status]', form);++ // ── The composer (the kit's: it grows, Enter sends, Send waits for words) ──++ const form = $('[data-ovt-composer]', app);++ const text = form && $('[data-ovt-text]', form);++ const status = form && $('[data-ovt-note]', form);const say = (msg, bad) => { if (status) { status.textContent = msg; status.classList.toggle('is-bad', !!bad); } };if (text) {-- const grow = () => { text.style.height = 'auto'; text.style.height = `${Math.min(text.scrollHeight, window.innerHeight * 0.4)}px`; };-- text.addEventListener('input', grow);-- text.addEventListener('keydown', (e) => {-- if (e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229) return;-- // On a phone the keyboard's return is a new line; the send button sends.-- if (matchMedia('(pointer: coarse)').matches && !e.metaKey && !e.ctrlKey) return;-- e.preventDefault();-- if (text.value.trim()) form.requestSubmit();-- });-- startPage(app, form, text, grow);++ startPage(app, form, text);// The repository: a recent one is a tap away, and typing searches every repository the person can reach.const repo = $('[data-ch-repo]', form);app.querySelectorAll('[data-ch-saved-repo]').forEach((b) => b.addEventListener('click', () => { if (repo) { repo.value = b.dataset.chSavedRepo; text.focus(); } }));@@@ -195,8 +159,7 @@}// The keyboard hint only where Enter sends (a phone keyboard's return is a new line).if (status && !status.textContent && !matchMedia('(pointer: coarse)').matches) status.textContent = 'Enter sends · Shift+Enter for a new line';-- if (app.classList.contains('is-empty')) text.focus({ preventScroll: true });-- grow();++ if ($('.ovt.is-start', app)) text.focus({ preventScroll: true });}if (form) {const images = $('[data-ch-images]', form);@@@ -210,7 +173,7 @@if (submitter && submitter.formAction && submitter.formAction !== form.action) return; // Stop: a plain postconst files = images ? Array.from(images.files || []).slice(0, 4) : [];form.classList.add('is-sending');-- form.querySelectorAll('button').forEach((b) => { b.disabled = b.classList.contains('ch-send'); });++ form.querySelectorAll('button').forEach((b) => { b.disabled = b.matches('[data-ovt-send]'); });if (!files.length) { say('Sending…'); return; } // words alone: the plain form poste.preventDefault();say(files.length === 1 ? 'Sending the image…' : `Sending ${files.length} images…`);@@@ -229,13 +192,12 @@});}-- // ── The thread: start at the latest turn, and stream a running one ──-- const thread = $('#ch-thread');-- if (thread && !app.classList.contains('is-empty')) thread.scrollTop = thread.scrollHeight;-- const stream = thread && thread.dataset.chStream;-- const live = thread && thread.querySelector('.ch-agent.is-live');-- if (!stream || !live || typeof EventSource === 'undefined') return;-- const near = () => thread.scrollHeight - thread.scrollTop - thread.clientHeight < 160;++ // ── A running turn streams in: each tool as the agent takes it, and the finished turn when the attempt ends ──++ const stream = app.dataset.chStream;++ const live = $('[data-ovt-thread] > .ovt-turn[data-open]', app);++ const OV = window.OVThread;++ if (!stream || !live || !OV || typeof EventSource === 'undefined') return;++ const id = live.dataset.turn;const kindOf = (name) => {const n = String(name || '').toLowerCase();if (/^(read|view|cat|ls|glob|list|open)|read_file|list_dir/.test(n)) return 'read';@@@ -246,26 +208,6 @@if (/(task|agent|delegate|subagent)/.test(n)) return 'sub';return 'tool';};-- // The step's sign: the server drew one of each kind already; reuse its icon when there is one.-- const iconFor = (kind) => {-- const drawn = thread.querySelector(`.ch-step[data-kind="${kind}"] svg`);-- if (drawn) return drawn.cloneNode(true);-- const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');-- s.setAttribute('class', 'ch-i'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '1.8');-- const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); c.setAttribute('cx', '12'); c.setAttribute('cy', '12'); c.setAttribute('r', '3');-- s.appendChild(c); return s;-- };-- let steps = live.querySelector('.ch-steps');-- const list = () => {-- if (!steps) {-- steps = document.createElement('details'); steps.className = 'ch-steps'; steps.open = true;-- const sum = document.createElement('summary'); sum.textContent = 'Working'; steps.append(sum, document.createElement('ol'));-- live.querySelector('.ch-who').after(steps);-- }-- return steps.querySelector('ol');-- };-- const count = () => { const n = list().children.length; const sum = steps.querySelector('summary'); if (sum && sum.firstChild && sum.firstChild.nodeType === 3) sum.firstChild.textContent = `Working · ${n} step${n === 1 ? '' : 's'}`; };-- const working = live.querySelector('[data-ch-working-text]');const attemptN = Number(live.dataset.attempt || 0);const es = page.es = new EventSource(stream);let reloading = false;@@@ -274,19 +216,14 @@let d; try { d = JSON.parse(ev.data); } catch { return; }if (attemptN && d.attempt_n && Number(d.attempt_n) !== attemptN) return;const b = d.body && typeof d.body === 'object' ? d.body : { t: d.kind, text: String(d.body || '') };-- const stick = near();if (b.t === 'tool') {-- const li = document.createElement('li'); li.className = 'ch-step is-new'; li.dataset.kind = kindOf(b.name);-- const n = document.createElement('span'); n.className = 'ch-step-n'; n.textContent = b.name || 'tool';-- const w = document.createElement('span'); w.className = 'ch-step-w'; w.textContent = b.what || '';-- li.append(iconFor(li.dataset.kind), n, w); list().appendChild(li); count();-- if (working) working.textContent = `${b.name || 'Working'} ${b.what || ''}`.trim();-- } else if (b.t === 'tool_result' && b.ok === false) {-- const last = list().lastElementChild; if (last) last.classList.add('is-bad');-- } else if (b.t === 'text' && b.text && working) {-- working.textContent = String(b.text).split('\n').find(Boolean) || working.textContent;++ OV.tool(id, { name: b.name || 'tool', input: b.what || '', icon: kindOf(b.name), key: b.id });++ OV.live(id, `${b.name || 'Working'} ${b.what || ''}`.trim());++ } else if (b.t === 'tool_result') {++ OV.toolDone(id, b.ok !== false, b.id);++ } else if (b.t === 'text' && b.text) {++ OV.live(id, String(b.text).split('\n').find(Boolean) || 'Working');} else if (b.t === 'done' || b.t === 'end' || d.kind === 'end') reload();-- if (stick) thread.scrollTop = thread.scrollHeight;});es.addEventListener('attempt', (ev) => {let d; try { d = JSON.parse(ev.data); } catch { return; }@@@ -22,6 +22,7 @@ const { asyncRouter } = require('./router');const { sessionLogOf } = require('../domain/run-tasks');const models = require('../data/models');const kit = require('openvibe-shared/kit');++const thread = require('openvibe-shared/thread');const start = require('./start');const LIVE = ['queued', 'placed', 'running'];@@@ -79,7 +80,8 @@ const agentOf = (id, names) => {const name = (names && names.get(id)) || (known && known[0]) || (id ? String(id) : 'Auto');return { name, initials: known ? known[1] : name.slice(0, 1).toUpperCase(), color: known ? known[2] : '#64748b' };};--const avatar = (a) => `<span class="ch-av" style="--av:${esc(a.color)}" aria-hidden="true">${esc(a.initials)}</span>`;++// The face is the conversation kit's (thread.face): the same in the rail, the thread and the panel.++const avatar = (a) => thread.face(a);// A tool call's kind, by the names the agents give their tools.function stepKind(name) {@@@ -158,7 +160,7 @@ function createChatRoutes(ctx) {const item = (row) => {const [, tone] = stateOf(row);const dot = waitingIds.has(row.id) ? 'needs' : tone === 'busy' ? 'busy' : tone === 'bad' ? 'bad' : null;-- const mark = row.kind === 'conductor' ? `<span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span>` : avatar(agentOf(row.agent, names));++ const mark = row.kind === 'conductor' ? thread.face({ name: 'Conductor', mark: ico('conductor'), color: 'var(--accent)' }) : avatar(agentOf(row.agent, names));return { href: `/tasks/${encodeURIComponent(row.id)}`, label: titleOf(row), mark, meta: dot ? '' : ago(row.updated_at || row.created_at), here: row.id === currentId, dot };};const pinned = rows.filter((x) => x.kind === 'conductor' && x.outcome !== 'stopped').slice(0, 2);@@@ -191,17 +193,21 @@ function createChatRoutes(ctx) {});}-- // ── The thread ───────────────────────────────────────────────────────────────────────────────────────────────-- const you = (text, extra = '', caption = '') => `<div class="ch-turn ch-you">${caption ? `<span class="ch-cap">${esc(caption)}</span>` : ''}<div class="ch-bubble">${esc(text)}</div>${extra}</div>`;-- const divider = (text, icon = 'handover') => `<div class="ch-divider" role="note">${ico(icon)}<span>${esc(text)}</span></div>`;-- function stepsHtml(steps, ms, open = false) {-- if (!steps.length) return '';-- const list = steps.map((s) => `<li class="ch-step${s.ok === false ? ' is-bad' : ''}" data-kind="${s.kind}">${ico(s.kind)}<span class="ch-step-n">${esc(s.name)}</span><span class="ch-step-w">${esc(s.what || '')}</span></li>`).join('');-- const label = `Worked${ms ? ` ${minutes(ms)}` : ''} · ${steps.length} step${steps.length === 1 ? '' : 's'}`;-- return `<details class="ch-steps"${open ? ' open' : ''}><summary>${label}${ico('chevron', 'ch-caret')}</summary><ol>${list}</ol></details>`;++ // ── The thread (openvibe-shared/thread, plan L55 C4: one conversation UI for the network) ─────────────────────++ // A conversation is a list of turns: what the person said, and the agent's reply under its face and name. A++ // hand-over to another agent, or a wait, is a line across the thread. How a task ended is a card in its last reply.++ const STEP_ICON = { read: 'read', find: 'find', edit: 'edit', run: 'run', web: 'web', sub: 'sub', tool: 'tool' };++ const errorRow = (msg) => thread.turn({ avatar: false, reply: thread.card({ tone: 'bad', text: msg }), attrs: { role: 'alert' } });++ /** The steps an agent took, as the kit's work rows: what it said along the way is a note, each tool a row. */++ function workHtml(steps, ms, { live = false, liveText = '' } = {}) {++ if (!steps.length && !live) return '';++ const rows = steps.map((s, i) => (s.said++ ? { kind: 'note', text: s.what }++ : { kind: 'tool', name: s.name, input: s.what || '', icon: STEP_ICON[s.kind] || 'tool', ok: s.ok == null ? undefined : s.ok, running: live && s.ok == null && i === steps.length - 1 ? true : false }));++ return thread.work({ open: live, live: liveText || 'Starting', took: ms ? minutes(ms) : null, rows });}-- /** One agent's turn from its session lines: what it said, the steps it took, how it ended. */-- function agentTurn(a, lines, { live = false, attempt = null } = {}) {++ /** One agent's reply from its session lines: the steps it took, then what it said last (its answer). */++ function agentReply(a, lines, { live = false, attempt = null } = {}) {const steps = [];const said = [];let done = null;@@@ -219,12 +225,13 @@ function createChatRoutes(ctx) {// The last thing it said is its answer; what it said along the way folds into the steps.const answer = (done && done.text) || said[said.length - 1] || '';const along = said.slice(0, answer && said[said.length - 1] === answer ? -1 : undefined);-- const alongSteps = along.map((text) => ({ name: 'Said', what: firstLine(text, 140), kind: 'sub', ok: null }));++ const all = [...along.map((text) => ({ said: true, what: firstLine(text, 140) })), ...steps];const ran = attempt ? [attempt.model, attempt.effort ? effortWord(attempt.effort) : null].filter(Boolean).join(' · ') : '';-- const who = `<div class="ch-who">${avatar(a)}<b>${esc(a.name)}</b>${ran ? `<span>${esc(ran)}</span>` : ''}</div>`;-- const body = answer ? `<div class="ch-md">${markdown(answer)}</div>` : live ? '' : `<p class="ch-quiet">${esc(attempt && attempt.end_why ? attempt.end_why : 'It ended without a word.')}</p>`;-- const working = live ? `<div class="ch-working" data-ch-working><span class="ch-dots" aria-hidden="true"><i></i><i></i><i></i></span><span data-ch-working-text>${steps.length ? esc(`${steps[steps.length - 1].name} ${steps[steps.length - 1].what || ''}`.trim()) : 'Starting…'}</span></div>` : '';-- return `<div class="ch-turn ch-agent${live ? ' is-live' : ''}"${attempt ? ` data-attempt="${Number(attempt.n)}"` : ''}>${who}${stepsHtml([...alongSteps, ...steps], ms, live)}${body}${working}</div>`;++ const lastStep = steps[steps.length - 1];++ const liveText = lastStep ? `${lastStep.name} ${lastStep.what || ''}`.trim() : '';++ const body = answer ? thread.answer({ html: markdown(answer) })++ : live ? '' : `<p class="ovt-muted">${esc(attempt && attempt.end_why ? attempt.end_why : 'It ended without a word.')}</p>`;++ return { who: { ...a, sub: ran }, reply: workHtml(all, ms, { live, liveText }) + body };}async function taskThread(row) {const names = agentNames();@@@ -233,24 +240,30 @@ function createChatRoutes(ctx) {if (!lines.length) lines = (sessionLogOf(row) || []).map((body, i) => ({ seq: i + 1, at: null, kind: body && body.t, body, attempt_n: 1 }));const byAttempt = new Map();for (const l of lines) { const n = Number(l.attempt_n || 1); if (!byAttempt.has(n)) byAttempt.set(n, []); byAttempt.get(n).push(l); }-- const out = [you(row.prompt || '')];const live = LIVE.includes(row.state);++ // Each attempt is a turn; the brief (or a note to carry on) is its person's side, a hand-over a line before it.++ const parts = [];++ let mine = { text: row.prompt || '', caption: 'The brief' };let prev = null;for (const a of attempts) {const n = Number(a.n);if (prev) {const note = /^carried on by the owner: /.exec(a.why || '') ? String(a.why).replace(/^carried on by the owner: /, '') : null;-- if (note) out.push(you(note));-- if (a.agent && prev.agent && a.agent !== prev.agent) out.push(divider(`${agentOf(a.agent, names).name} took over${prev.end_why ? `: ${agentOf(prev.agent, names).name} ${prev.end_why}` : ''}`));-- else if (a.why && !note) out.push(divider(String(a.why), 'handover'));++ if (note) mine = { text: note };++ else if (a.agent && prev.agent && a.agent !== prev.agent) parts.push({ divider: `${agentOf(a.agent, names).name} took over${prev.end_why ? `: ${agentOf(prev.agent, names).name} ${prev.end_why}` : ''}` });++ else if (a.why) parts.push({ divider: String(a.why) });}const isLive = live && n === Number(attempts[attempts.length - 1].n);-- out.push(agentTurn(agentOf(a.agent || row.agent, names), byAttempt.get(n) || [], { live: isLive, attempt: a }));++ parts.push({ n, open: isLive, you: mine, ...agentReply(agentOf(a.agent || row.agent, names), byAttempt.get(n) || [], { live: isLive, attempt: a }) });++ mine = null;prev = a;}-- if (!attempts.length) out.push(agentTurn(agentOf(row.agent, names), lines, { live }));-- if (!live) out.push(await resultCard(row));-- return out.join('');++ if (!attempts.length) parts.push({ n: 1, open: live, you: mine, ...agentReply(agentOf(row.agent, names), lines, { live }) });++ // How it ended goes in the last reply.++ const lastTurn = parts.filter((p) => !p.divider).pop();++ if (!live && lastTurn) lastTurn.reply += await resultCard(row);++ return parts.map((p) => (p.divider ? thread.divider({ text: p.divider })++ : thread.turn({ id: `a${p.n}`, open: p.open, state: p.open ? 'running' : null, you: p.you || {}, who: p.who, reply: p.reply, attrs: { 'data-attempt': p.n } })));}async function conductorThread(row, view) {const out = [];@@@ -258,20 +271,33 @@ function createChatRoutes(ctx) {const a = agentOf('claude-code', names);const live = LIVE.includes(row.state);const msgs = view.messages || [];++ const who = { ...a, sub: row.model || '' };for (const m of msgs) {-- out.push(you(m.n === 1 ? (m.prompt || 'Pick up the plan') : m.prompt, (m.attachments || []).length ? `<p class="ch-files">${m.attachments.map((f, i) => `<a class="ch-chip" href="/api/v1/tasks/${encodeURIComponent(row.id)}/messages/${encodeURIComponent(m.id)}/attachments/${i}">${ico('attach')}${esc(f.name)}</a>`).join('')}</p>` : '', m.n === 1 ? 'Hand-over brief' : ''));++ const files = (m.attachments || []).map((f, i) => ({ href: `/api/v1/tasks/${encodeURIComponent(row.id)}/messages/${encodeURIComponent(m.id)}/attachments/${i}`, name: f.name, image: /^image\//.test(String(f.mime || '')) }));++ const mine = { text: m.n === 1 ? (m.prompt || 'Pick up the plan') : m.prompt, caption: m.n === 1 ? 'Hand-over brief' : '', files };++ const id = `m${Number(m.n) || out.length + 1}`;if (m.state === 'running' || (live && m === msgs[msgs.length - 1] && m.state !== 'done')) {const lines = await db.many('SELECT seq, at, kind, body FROM codes_session_lines WHERE task_id = $1 ORDER BY seq DESC LIMIT 300', [row.id]);-- out.push(agentTurn(a, lines.reverse(), { live: true, attempt: { model: row.model } }));++ const r = agentReply(a, lines.reverse(), { live: true, attempt: { model: row.model } });++ out.push(thread.turn({ id, open: true, state: 'running', you: mine, who: r.who, reply: r.reply }));} else if (m.result && m.result.summary) {-- out.push(`<div class="ch-turn ch-agent"><div class="ch-who">${avatar(a)}<b>${esc(a.name)}</b>${row.model ? `<span>${esc(row.model)}</span>` : ''}</div><div class="ch-md">${markdown(m.result.summary)}</div></div>`);-- } else if (m.error) out.push(`<div class="ch-turn ch-agent"><div class="ch-who">${avatar(a)}<b>${esc(a.name)}</b></div><p class="ch-quiet">${esc(m.error)}</p></div>`);-- else if (m.state === 'queued') out.push(divider('Waiting for the turn before it to end', 'conductor'));++ out.push(thread.turn({ id, you: mine, who, reply: thread.answer({ html: markdown(m.result.summary) }) }));++ } else if (m.error) {++ out.push(thread.turn({ id, you: mine, who, reply: `<p class="ovt-muted">${esc(m.error)}</p>` }));++ } else if (m.state === 'queued') {++ out.push(thread.turn({ id, you: mine, reply: null }));++ out.push(thread.divider({ text: 'Waiting for the turn before it to end', icon: 'wait' }));++ } else out.push(thread.turn({ id, you: mine, reply: null }));}-- for (const ap of view.approvals || []) out.push(approvalCard(row, ap));-- return out.join('');++ // What waits for a yes, as a reply of its own at the end of the thread.++ const asks = (view.approvals || []).map((ap) => approvalCard(row, ap)).join('');++ if (asks) out.push(thread.turn({ id: 'asks', who, reply: asks }));++ return out;}-- const approvalCard = (row, ap) => `<form class="ch-approval" method="post" action="/tasks/${encodeURIComponent(row.id)}/approvals/${encodeURIComponent(ap.id)}"><p class="ch-approval-h">${ico('proof')}<b>${esc(ap.tool || 'A tool')}</b> wants to run</p><code tabindex="0">${esc(ap.summary || '')}</code><div class="ch-approval-a"><button class="ch-btn" name="decision" value="allow">Allow</button><button class="ch-btn ch-btn-quiet" name="decision" value="deny">Deny</button></div></form>`;++ const approvalCard = (row, ap) => thread.card({++ tone: 'wait', icon: 'shield', title: ap.tool || 'A tool', text: 'wants to run', code: ap.summary || '',++ form: { action: `/tasks/${encodeURIComponent(row.id)}/approvals/${encodeURIComponent(ap.id)}`, buttons: [{ name: 'decision', value: 'allow', label: 'Allow' }, { name: 'decision', value: 'deny', label: 'Deny', quiet: true }] },++ });/** How the task ended, as one card: the files and the diff, the proof item by item, and the pull request or why none. */async function resultCard(row) {@@@ -306,7 +332,7 @@ function createChatRoutes(ctx) {: push.pushed ? 'The branch was pushed, but no pull request was asked for.' : 'This task did not ask for a pull request.';parts.push(`<p class="ch-res-line ch-muted">${ico('pr')}<span>${esc(why)}${push.branch_url ? ` <a href="${esc(push.branch_url)}" rel="noopener noreferrer">${esc(push.branch || 'the branch')}</a>` : ''}</span></p>`);}-- return `<div class="ch-result">${parts.join('')}</div>`;++ return thread.card({ tone: tone === 'bad' ? 'bad' : 'info', html: `<div class="ch-result">${parts.join('')}</div>` });}// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────@@@ -324,54 +350,51 @@ function createChatRoutes(ctx) {function effortPicker(selected = '', cls = '') {return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort>${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;}-- function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', hidden = '', note = '', id = '', where = '' }) {-- return `<form class="ch-composer"${id ? ` id="${esc(id)}"` : ''} method="post" action="${esc(action)}"${api ? ` data-ch-api="${esc(api)}"` : ''} data-ch-composer>${hidden}--<label class="ch-sr" for="ch-text">${esc(placeholder)}</label>--<textarea id="ch-text" name="text" rows="1" placeholder="${esc(placeholder)}" required data-ch-text></textarea>--<div class="ch-bar"><div class="ch-picks">${pickers}</div>${live && stopAction ? `<button class="ch-icon-btn ch-stop" formaction="${esc(stopAction)}" formnovalidate aria-label="Stop" title="Stop">${ico('stop')}</button>` : ''}<button class="ch-send" type="submit" aria-label="Send" title="Send">${ico('send')}</button></div>--${where}${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p class="ch-note" data-ch-status role="status"></p>'}--</form>`;++ /** The kit's composer with Codes' pickers in its tools, where the work runs under the bar, and its JSON route. */++ function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', note = '', id = null, where = '' }) {++ return thread.composer({++ action, id, placeholder, label: placeholder, tools: pickers, foot: where, note,++ attrs: api ? { 'data-ch-api': api } : {},++ busy: live, stopAction: live && stopAction ? stopAction : null, maxlength: 16000,++ });}// ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────-- async function frame(req, res, { currentId = null, title, head = '', thread = '', composerHtml = '', side = '', streamUrl = '', empty = false, page = null, here = '', css = [], scripts = [] }) {++ /** The frame every signed-in page shares: the rail, the conversation (or a page) and, for a task, the panel. */++ async function frame(req, res, { currentId = null, title, heading = null, state = '', turns = [], empty = '', composerHtml = '', panel = '', streamUrl = '', start = false, page = null, here = '', css = [], scripts = [] }) {const rows = await conversations(req.viewer.subject);const waiting = approvals ? await approvals.inbox(req.viewer.subject).catch(() => []) : [];const waitingIds = new Set(waiting.map((a) => a.task_id));-- const body = `<div class="ch${side ? ' has-side' : ''}${empty ? ' is-empty' : ''}${page != null ? ' is-page' : ''}" data-chat>--${railHtml(rows, currentId, waitingIds, here)}--<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>--<div class="ch-scrim" data-ch-scrim hidden></div>--<section class="ch-main" aria-label="Conversation">--<header class="ch-head">${kit.sidebarButton({ id: 'ch-rail', label: 'Your conversations' })}${head}${side ? `<button class="ch-icon-btn ch-side-open" type="button" data-ch-side-open aria-label="What it did" aria-controls="ch-side">${ico('side')}</button>` : ''}</header>--${page != null ? `<div class="ch-page">${page}</div>` : `<div class="ch-thread" id="ch-thread"${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${thread}</div>--${composerHtml}`}--</section>--${side}--</div>`;-- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css'], sharedScripts: ['kit-web.js'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });++ const view = thread.page({++ sidebar: railHtml(rows, currentId, waitingIds, here),++ head: thread.head({ title: heading == null ? '' : heading, sidebar: 'ch-rail', sidebarLabel: 'Your conversations', actions: state, panel: panel ? 'ch-side' : null, panelLabel: 'What it did' }),++ turns, empty, composer: composerHtml, panel, main: page, center: start, label: page != null ? title : 'Conversation',++ cls: page != null ? 'is-page' : '',++ });++ const body = `<div class="ch" data-chat${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${view}</div>`;++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css'], sharedScripts: ['kit-web.js', 'ov-thread.js'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });}-- /** The side: who worked on it, and where the work is. */++ /** The panel: who worked on it, and where the work is. */async function sideHtml(row) {const names = agentNames();const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];const result = asObject(row.result) || {};const repo = repoLabel(row.repo);-- const facts = [-- row.repo ? [ico('repo'), `<a href="${esc(row.repo)}" rel="noopener noreferrer">${esc(repo || 'The repository')}</a>`] : null,-- row.folder ? [ico('folder'), `<code>${esc(row.folder)}</code>`] : null,-- Number.isFinite(Number(result.cost_usd)) && Number(result.cost_usd) > 0 ? [ico('usage'), `about $${Number(result.cost_usd).toFixed(2)} of model use`] : null,++ const where = [++ row.repo ? { iconHtml: ico('repo'), html: `<a href="${esc(row.repo)}" rel="noopener noreferrer">${esc(repo || 'The repository')}</a>` } : null,++ row.folder ? { iconHtml: ico('folder'), html: `<code>${esc(row.folder)}</code>` } : null,++ Number.isFinite(Number(result.cost_usd)) && Number(result.cost_usd) > 0 ? { iconHtml: ico('usage'), html: `about $${Number(result.cost_usd).toFixed(2)} of model use` } : null,].filter(Boolean);-- const people = attempts.map((a) => { const g = agentOf(a.agent, names); return `<li>${avatar(g)}<span><b>${esc(g.name)}</b><small>${esc([a.model, tasks.attempts.wordsOf && a.reason ? tasks.attempts.wordsOf(a.reason) : a.state].filter(Boolean).join(' · '))}</small></span></li>`; }).join('');-- return `<aside class="ch-side" id="ch-side" aria-label="What it did"><div class="ch-side-head"><b>What it did</b><button class="ch-icon-btn" type="button" data-ch-side-close aria-label="Close">${ico('close')}</button></div>--${people ? `<section><p class="ch-group">Agents</p><ul class="ch-people">${people}</ul></section>` : ''}--${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${facts.map(([i, t]) => `<li>${i}<span>${t}</span></li>`).join('')}</ul></section>` : ''}--</aside>`;++ const people = attempts.map((a) => { const g = agentOf(a.agent, names); return { who: g, title: g.name, sub: [a.model, tasks.attempts.wordsOf && a.reason ? tasks.attempts.wordsOf(a.reason) : a.state].filter(Boolean).join(' · ') }; });++ return thread.panel({ id: 'ch-side', title: 'What it did', sections: [++ people.length ? { title: 'Agents', rows: people } : null,++ where.length ? { title: 'Where', rows: where } : null,++ ] });}ctx.chatShell = (req, res, { title, body, css = [], scripts = [], here = '' }) => frame(req, res, {-- title, page: body, here, css, scripts, head: '',++ title, page: body, here, css, scripts,});/** The person's machines, for the line that says where the work runs: never more than 1.5 s, never fatal. */@@@ -442,9 +465,9 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${fconst rail = (list) => `<ul class="ch-spark-list">${list.map((i) => card(i, false)).join('')}</ul><ul class="ch-spark-list" aria-hidden="true">${list.map((i) => card(i, true)).join('')}</ul>`;const ideas = `<section class="ch-spark" aria-label="Ideas to start from" data-ch-ideas data-ch-ideasets="${esc(JSON.stringify(sets))}"><div class="ch-spark-view"><div class="ch-spark-track" data-ch-ideas-track>${rail(sets[mode])}</div></div><template data-ch-idea-icons>${['web', 'game', 'app', 'ov', 'spark'].map((n) => ico(n).replace('<svg ', `<svg data-icon="${n}" `)).join('')}</template><button class="ch-icon-btn ch-spark-pause" type="button" data-ch-ideas-pause aria-pressed="false" aria-label="Pause the ideas" title="Pause the ideas">${ico('pause', 'ch-i-pause')}${ico('play', 'ch-i-play')}</button></section>`;-- const thread = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend. Pick what it is, and Codes sends it to the right place.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;++ const hello = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend. Pick what it is, and Codes sends it to the right place.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;return frame(req, res, {-- title: 'New chat', empty: true, head: '<h1 class="ch-title">New chat</h1>', thread,++ title: 'New chat', heading: 'New chat', start: true, empty: hello,composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'Describe the change, question or fix', pickers, where }) + ideas,});});@@@ -527,15 +550,16 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${fconst [word, tone] = stateOf(row);const live = LIVE.includes(row.state);const error = String(req.query.error || '').slice(0, 200);-- const head = `<h1 class="ch-title">${esc(titleOf(row))}</h1><span class="ch-state is-${tone}" data-ch-state>${tone === 'busy' ? '<span class="ch-dot is-busy"></span>' : ''}${esc(word)}</span>`;++ const state = `<span class="ch-state is-${tone}" data-ch-state>${tone === 'busy' ? '<span class="ch-dot is-busy"></span>' : ''}${esc(word)}</span>`;++ const errorTurns = error ? [errorRow(error)] : [];if (row.kind === 'conductor') {const view = await conductor.view(row);const last = await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]);const model = String(row.model || 'opus');const pickers = `<span class="ch-chip ch-chip-static" title="The conductor runs Claude Code on your machine">${avatar(agentOf('claude-code'))}${esc(model)}</span><label class="ch-pick ch-pick-file" title="Attach an image">${ico('attach')}<span class="ch-sr">Attach an image</span><input type="file" accept="image/*" multiple data-ch-images></label>`;return frame(req, res, {-- currentId: row.id, title: 'Conductor', head, side: await sideHtml(row),-- thread: (error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : '') + await conductorThread(row, view),++ currentId: row.id, title: 'Conductor', heading: titleOf(row), state, panel: await sideHtml(row),++ turns: [...errorTurns, ...await conductorThread(row, view)],streamUrl: `/api/v1/tasks/${encodeURIComponent(row.id)}/events?last_event_id=${last && last.seq != null ? Number(last.seq) : 0}`,composerHtml: row.outcome === 'stopped' ? '' : composer({ action: `/tasks/${encodeURIComponent(row.id)}/messages`, api: `/api/v1/conductor/${encodeURIComponent(row.id)}/messages`, placeholder: live ? 'Say something: it is the next turn' : 'What next?', pickers, note: live ? 'A message waits until the current turn ends; it is not lost.' : '' }),});@@@ -545,8 +569,8 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${fconst lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` }) + effortPicker('');return frame(req, res, {-- currentId: row.id, title: titleOf(row), head, side: await sideHtml(row),-- thread: (error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : '') + await taskThread(row),++ currentId: row.id, title: titleOf(row), heading: titleOf(row), state, panel: await sideHtml(row),++ turns: [...errorTurns, ...await taskThread(row)],streamUrl: live ? `/api/v1/tasks/${encodeURIComponent(row.id)}/events?last_event_id=${lastSeq}` : '',composerHtml: row.outcome === 'stopped' ? '' : composer({action: `/tasks/${encodeURIComponent(row.id)}/continue`, api: `/api/v1/tasks/${encodeURIComponent(row.id)}/continue`, live,@@@ -47,13 +47,13 @@ const { boot, check, done } = require('./helpers/boot');assert.ok(!home.text.includes('Built for the way you work'));assert.ok(!home.text.includes('Recently shipped on'));assert.ok(home.text.includes('What should we build') && home.text.includes('owner/name or any Git URL') && home.text.includes('name="mode"'), 'the greeting, the three ways to start and the repository');-- assert.ok(home.text.includes('action="/chat"') && home.text.includes('data-ch-composer'), 'one composer that starts the conversation');++ assert.ok(home.text.includes('action="/chat"') && home.text.includes('data-ovt-composer'), 'one composer that starts the conversation');const id = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV';await t.ctx.store.db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, error, created_at, updated_at)VALUES ($1,$2,'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW','https://github.com/OpenVibers/OpenVibe.Codes','main','Fix the full brief and keep the workspace readable on a phone','codex','failed','{"cost":0.42}'::jsonb,'The machine stopped.',$3,$3)`, [id, ada.subject, Date.now()]);const task = await t.get(`/tasks/${id}`, { as: ada });assert.strictEqual(task.status, 200);-- assert.ok(task.text.includes('class="ch-bubble">Fix the full brief and keep the workspace readable on a phone'), 'the brief is the first message');++ assert.ok(task.text.includes('class="ovt-bubble">Fix the full brief and keep the workspace readable on a phone'), 'the brief is the first message');assert.ok(task.text.includes("Didn't finish") || task.text.includes('Didn\'t finish'), 'the state is said in words');assert.ok(task.text.includes('Same agent (Codex)'), 'the composer carries on with the same agent, or another one');assert.ok(task.text.includes(`action="/tasks/${id}/continue"`), 'the next message is the next turn');@@@ -1,43 +1,53 @@'use strict';--// public/js/chat.js after a page move (the owner, 2026-10-31, a phone: "I can't open… the menu" on Machines). Codes--// moves between pages with openvibe-shared/boost, which swaps <main> without running the page's scripts again: a move--// from Agents to Machines (the same scripts) left the new page with no handlers. The rail is now the network's sidebar,--// which kit-web.js opens on the document (openvibe-shared test/kit.test.js); chat.js still owns the side panel and the--// rail's search, and sets them up on each page: its first load and every ov:boost:load.++// The chat app after a page move (the owner, 2026-10-31, a phone: "I can't open… the menu" on Machines). Codes moves++// between pages with openvibe-shared/boost, which swaps <main> without running the page's scripts again. The rail is++// the network's sidebar (kit-web.js opens it on the document); the panel ("What it did") and the rail's search are the++// conversation kit's (ov-thread.js, plan L55 C4); chat.js keeps Codes' own parts. Each sets itself up on a page's first++// load and on every ov:boost:load.const assert = require('assert');const fs = require('fs');const path = require('path');const { parseHTML } = require('linkedom');++const thread = require('openvibe-shared/thread');++const kit = require('openvibe-shared/kit');const { check, done } = require('./helpers/boot');--const SRC = fs.readFileSync(path.join(__dirname, '../public/js/chat.js'), 'utf8');--const PAGE = (title) => `<div class="ch has-side" data-chat><aside class="ovk-sidebar" id="ch-rail" data-ovk-sidebar>--<form class="ovk-sidebar-find"><input></form><div class="ovk-sidebar-secs"><section class="ovk-sidebar-sec"><h2 class="ovk-sidebar-h">Today</h2>--<a class="ovk-sidebar-item" href="/tasks/1"><span class="ovk-sidebar-t">Fit the home page</span></a><a class="ovk-sidebar-item" href="/tasks/2"><span class="ovk-sidebar-t">Fix the fleet card</span></a></section>--<section class="ovk-sidebar-sec"><h2 class="ovk-sidebar-h">Earlier</h2><a class="ovk-sidebar-item" href="/tasks/3"><span class="ovk-sidebar-t">Importer</span></a></section></div></aside>--<div class="ch-scrim" data-ch-scrim hidden></div>--<section class="ch-main"><header class="ch-head"><button type="button" data-ch-side-open>side</button></header><div class="ch-page"><h1>${title}</h1></div></section>--<aside class="ch-side" id="ch-side"><button type="button" data-ch-side-close>close</button></aside></div>`;++const CHAT = fs.readFileSync(path.join(__dirname, '../public/js/chat.js'), 'utf8');++const KIT = fs.readFileSync(path.join(path.dirname(require.resolve('openvibe-shared/package.json')), 'ov-thread.js'), 'utf8');++const rail = kit.sidebar({ id: 'ch-rail', label: 'Codes', find: { action: '/tasks', name: 'q', placeholder: 'Search conversations' }, sections: [++ { title: 'Today', items: [{ href: '/tasks/1', label: 'Fit the home page' }, { href: '/tasks/2', label: 'Fix the fleet card' }] },++ { title: 'Earlier', items: [{ href: '/tasks/3', label: 'Importer' }] },++] });++const PAGE = (title) => `<div class="ch" data-chat>${thread.page({++ sidebar: rail,++ head: thread.head({ title, sidebar: 'ch-rail', panel: 'ch-side', panelLabel: 'What it did' }),++ turns: [thread.turn({ id: 'a1', you: { text: 'Fit the home page' }, reply: '<p>Done</p>' })],++ panel: thread.panel({ id: 'ch-side', title: 'What it did' }),++ composer: thread.composer({ action: '/tasks/1/continue' }),++})}</div>`;(async () => {-- await check('the side panel and the rail’s search work on a page that boost swapped in, not only on the first', async () => {++ await check('the panel, the rail’s search and the composer work on a page that boost swapped in, not only on the first', async () => {const { window, document, CustomEvent, Event } = parseHTML(`<!doctype html><html><body><main id="main">${PAGE('Agents')}</main></body></html>`);window.EventSource = undefined;-- new Function('window', 'document', 'location', 'setTimeout', SRC)(window, document, { reload() {} }, setTimeout);++ window.matchMedia = () => ({ matches: false });++ const run = (src) => new Function('window', 'document', 'location', 'setTimeout', 'matchMedia', 'navigator', src)(window, document, { reload() {}, hash: '' }, setTimeout, window.matchMedia, {});++ run(KIT);++ run(CHAT);const tap = (sel) => document.querySelector(sel).dispatchEvent(new Event('click', { bubbles: true }));-- tap('[data-ch-side-open]');-- assert.ok(document.querySelector('[data-chat]').classList.contains('side-open'), 'the first page');++ tap('[data-ovt-panel-open]');++ assert.ok(document.querySelector('[data-ovt]').classList.contains('is-panel'), 'the first page');// A boost move: the old <main> goes, the new one comes in, then ov:boost:load.document.dispatchEvent(new CustomEvent('ov:boost:before'));document.getElementById('main').innerHTML = PAGE('Machines');document.dispatchEvent(new CustomEvent('ov:boost:load'));-- const app = document.querySelector('[data-chat]');-- tap('[data-ch-side-open]');-- assert.ok(app.classList.contains('side-open') && document.querySelector('[data-ch-scrim]').hidden === false, 'the swapped-in page, with its scrim');++ const box = document.querySelector('[data-ovt]');++ tap('[data-ovt-panel-open]');++ assert.ok(box.classList.contains('is-panel') && document.querySelector('[data-ovt-panel-scrim]').hidden === false, 'the swapped-in page, with its scrim');const esc = new Event('keydown'); esc.key = 'Escape'; document.dispatchEvent(esc);-- assert.ok(!app.classList.contains('side-open'), 'Escape closes the side of the page on screen');++ assert.ok(!box.classList.contains('is-panel'), 'Escape closes the panel of the page on screen');document.dispatchEvent(new CustomEvent('ov:boost:load'));-- assert.strictEqual(app.dataset.chReady, '1', 'a second load event does not set it up twice');++ assert.strictEqual(document.querySelector('[data-chat]').dataset.chReady, '1', 'a second load event does not set it up twice');// The rail's search filters the conversations as you type, and a section with none left hides.const input = document.querySelector('.ovk-sidebar-find input');input.value = 'fleet'; input.dispatchEvent(new Event('input'));@@@ -45,6 +55,8 @@ const PAGE = (title) => `<div class="ch has-side" data-chat><aside class="ovk-siassert.deepStrictEqual(shown, ['Fix the fleet card']);const secs = [...document.querySelectorAll('.ovk-sidebar-sec')].map((x) => x.hidden);assert.deepStrictEqual(secs, [false, true], 'Earlier has nothing left, so it hides');++ // Codes' own: the keyboard hint in the composer's status line.++ assert.strictEqual(document.querySelector('[data-ovt-note]').textContent, 'Enter sends · Shift+Enter for a new line', 'the hint where Enter sends');});done();})().catch((e) => { console.error(e); process.exit(1); });@@@ -4,6 +4,7 @@// conversations only; the thread shows the brief, each agent's turn with its steps, a hand-over as a divider and a// carry-on as their next message; a new chat starts a task from the composer; the step kinds name what an agent did.const assert = require('assert');++const thread = require('openvibe-shared/thread');const { boot, check, done } = require('./helpers/boot');const { stepKind } = require('../server/http/chat');@@@ -44,13 +45,15 @@ const { stepKind } = require('../server/http/chat');const page = await t.get(`/tasks/${MINE}`, { as: ada });assert.strictEqual(page.status, 200);const text = page.text;-- const brief = text.indexOf('class="ch-bubble">Fit the home page');++ const brief = text.indexOf('class="ovt-bubble">Fit the home page');const handover = text.indexOf('Claude Code took over');const answer = text.indexOf('<strong>scrolls sideways</strong>');-- const next = text.indexOf('class="ch-bubble">Keep the titles on one line.');++ const next = text.indexOf('class="ovt-bubble">Keep the titles on one line.');const last = text.indexOf('Titles stay on one line.');assert.ok(brief > -1 && handover > brief && answer > handover && next > answer && last > next, 'in the order it happened');-- assert.ok(text.includes('data-kind="read"') && text.includes('data-kind="edit"'), 'each step carries its kind');++ assert.ok(text.includes(thread.icon('read')) && text.includes(thread.icon('edit')), 'each step carries the sign of its kind');++ assert.ok(text.includes('<li class="ovt-divider">') && /<p class="ovt-who"><b>Claude Code<\/b>/.test(text), 'the hand-over is a line, and each reply says which agent');++ assert.ok(text.includes('data-ovt-panel-open aria-controls="ch-side"') && text.includes('<aside class="ovt-panel" id="ch-side"'), 'what it did is the panel');assert.ok(text.includes('1 file changed') && text.includes('<span class="ch-add">+3</span>'), 'how it ended: the files');assert.ok(text.includes(`action="/tasks/${MINE}/continue"`), 'the composer carries it on');assert.ok(text.includes('aria-current="page"'), 'the rail marks the open conversation');@@@ -74,7 +77,8 @@ const { stepKind } = require('../server/http/chat');const home = await t.get('/', { as: ada });const h = home.text;['new', 'openvibe', 'code'].forEach((v) => assert.match(h, new RegExp(`<input type="radio" name="mode" value="${v}" form="ch-start"`), `the ${v} card belongs to the form`));-- assert.ok(h.includes('<form class="ch-composer" id="ch-start"'), 'the composer is the form the cards name');++ assert.ok(h.includes('<form class="ovt-composer" id="ch-start"'), 'the composer is the form the cards name');++ assert.ok(h.includes('<div class="ovt is-start" data-ovt>'), 'a start page: the greeting and the composer together');assert.ok(!/name="repo"[^>]*required/.test(h), 'the repository is not required: something new needs none');assert.match(h, /<select name="project"[^>]*>[\s\S]*?<option value="OpenVibers\/OpenVibe\.Live"[^>]*>Live · openvibe\.live<\/option>/, 'OpenVibe projects are a list');assert.match(h, /<option value="OpenVibers\/OpenVibe\.Shared"/, 'the shared libraries too');
@@ -94,7 +94,7 @@ More: [docs/cli.md](docs/cli.md).
@@ -94,7 +94,7 @@ More: [docs/cli.md](docs/cli.md).
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
-- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.95.0** (pinned tag tarballs).
+- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.99.0** (pinned tag tarballs).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
@@ -9,11 +9,11 @@
@@ -9,11 +9,11 @@
"domain": "openvibe.codes",
"domain": "openvibe.codes",
"contracts": "openvibe-contracts v0.261.0",
"contracts": "openvibe-contracts v0.261.0",
"sdk": "openvibe-sdk v0.81.0",
"sdk": "openvibe-sdk v0.81.0",
- "shared": "openvibe-shared v4.95.0",
+ "shared": "openvibe-shared v4.99.0",
"packages": [
"packages": [
"openvibe-contracts v0.261.0",
"openvibe-contracts v0.261.0",
"openvibe-sdk v0.81.0",
"openvibe-sdk v0.81.0",
- "openvibe-shared v4.95.0"
+ "openvibe-shared v4.99.0"
],
],
"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.2) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.5.2) runs a coding task on Claude Code, Claude Code on another provider, Codex, OpenCode, Command Code, MiniMax Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"works": [
"works": [
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.95.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"bin": {
"bin": {
@@ -1193,9 +1193,9 @@
@@ -1193,9 +1193,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.95.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.95.0",
- "integrity": "sha512-jY0ZKFQMvx3NiyKxHkZ950nq7P8CtlzlP4OOnTkJf0nyVVG93qT4JE0udr8bF8Kwm7FrzkLgfaLB1qDrjH22eg==",
+ "version": "4.99.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",
+ "integrity": "sha512-i8iioHF2nBboP5RHAoPKCVn6pYjfOqgDhkX4k8KIGLKqt5ItClwKL8S1giWJ7hFZc2XkuSsRHEC05nArMHqtyg==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.261.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.95.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.99.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
.c4-card{background:var(--c4-surface);border:1px solid var(--c4-line);border-radius:13px;padding:20px;margin:0 0 16px;box-shadow:0 1px 2px #1b29340a}
.c4-card{background:var(--c4-surface);border:1px solid var(--c4-line);border-radius:13px;padding:20px;margin:0 0 16px;box-shadow:0 1px 2px #1b29340a}
.c4-card h2{font-size:16px;letter-spacing:-.02em;margin:0}
.c4-card h2{font-size:16px;letter-spacing:-.02em;margin:0}
.c4-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}
.c4-card-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}
-.c4-card-head a,.c4-card a:not(.c4-button){color:var(--c4-accent);font-size:13px}
+.c4-card-head a:not(.c4-button),.c4-card a:not(.c4-button){color:var(--c4-accent);font-size:13px}
.c4-sub{margin:6px 0 12px;color:var(--c4-muted);font-size:13px;line-height:1.45}
.c4-sub{margin:6px 0 12px;color:var(--c4-muted);font-size:13px;line-height:1.45}
.c4-composer{border-color:#d5e4df;background:linear-gradient(140deg,#fff,#f8fcfa)}
.c4-composer{border-color:#d5e4df;background:linear-gradient(140deg,#fff,#f8fcfa)}
.c4-composer form>label{display:block;font-size:15px;font-weight:650;margin-bottom:12px}
.c4-composer form>label{display:block;font-size:15px;font-weight:650;margin-bottom:12px}
@@ -1,95 +1,35 @@
@@ -1,95 +1,35 @@
-/* Codes as a chat app (plan L31 CH1): the rail of conversations, the conversation and its composer, and what the work
- produced at the side. Every colour comes from the person's theme (the Frame's tokens), so a conversation looks like
- the rest of their OpenVibe; the agents' own colours only tint their faces. One column of reading width in the
- middle (760px), as in the chat apps people know. */
-.ch{--ch-bg:var(--bg-primary,#0f1419);--ch-rail:var(--bg-secondary,#141a21);--ch-card:var(--bg-card,#171e26);--ch-hover:var(--bg-hover,rgba(127,140,160,.12));--ch-ink:var(--text-primary,#e8edf3);--ch-soft:var(--text-secondary,#b4bfcc);--ch-muted:var(--text-muted,#8793a3);--ch-line:var(--border,rgba(127,140,160,.22));--ch-accent:var(--accent,#7c6cff);--ch-on-accent:var(--on-accent,#fff);--ch-ok:var(--success,#22c55e);--ch-bad:var(--danger,#ef4444);--ch-warn:var(--warning,#f59e0b);--ch-col:760px;
- display:grid;grid-template-columns:272px minmax(0,1fr);height:calc(100dvh - 52px);min-height:420px;background:var(--ch-bg);color:var(--ch-ink);font-size:15px}
-.ch.has-side{grid-template-columns:272px minmax(0,1fr) 300px}
+/* Codes' own parts of the chat app (plan L31 CH1). The conversation UI is the network's: openvibe-shared thread.css
+ draws the rail (kit.sidebar), the turns, the agents' faces, the hand-overs, the approvals, the panel ("What it did")
+ and the composer, in the person's theme. Here: the state in the head, the result card, the composer's pickers and
+ lines, a new chat's greeting, its three ways to start and the ideas rail, and the workspace's other pages. */
+.ch{--ch-rail:var(--bg-secondary,#141a21);--ch-card:var(--bg-card,#171e26);--ch-hover:var(--bg-hover,rgba(127,140,160,.12));--ch-ink:var(--text-primary,#e8edf3);--ch-soft:var(--text-secondary,#b4bfcc);--ch-muted:var(--text-muted,#8793a3);--ch-line:var(--border,rgba(127,140,160,.22));--ch-accent:var(--accent,#7c6cff);--ch-on-accent:var(--on-accent,#fff);--ch-ok:var(--success,#22c55e);--ch-bad:var(--danger,#ef4444);--ch-warn:var(--warning,#f59e0b);
+ font-size:15px}
.ch-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.ch-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.ch-i{width:18px;height:18px;flex:none}
.ch-i{width:18px;height:18px;flex:none}
/* The page is the app: no footer under it, no phone tab bar over it (the rail is the navigation here). */
/* The page is the app: no footer under it, no phone tab bar over it (the rail is the navigation here). */
body:has(.ch) #ov-footer,body:has(.ch) .openvibe-footer{display:none}
body:has(.ch) #ov-footer,body:has(.ch) .openvibe-footer{display:none}
body.ov-workspace:has(.ch){padding-bottom:0}
body.ov-workspace:has(.ch){padding-bottom:0}
main.page:has(> .ch){max-width:none;margin:0;padding:0}
main.page:has(> .ch){max-width:none;margin:0;padding:0}
+@media (max-width:760px){body:has(.ch) .ovnav-appbar{display:none!important}body:has(.ch){--ovnav-appbar-h:0px}}
-/* ── The rail ── */
-.ch-group{margin:14px 8px 4px;font-size:11.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--ch-muted)}
-
-/* Faces: an agent's initials in its own colour; the conductor's is its sign. */
-.ch-av{display:inline-grid;place-items:center;width:24px;height:24px;flex:none;border-radius:50%;font-size:10.5px;font-weight:800;letter-spacing:-.02em;color:var(--av,#64748b);background:color-mix(in srgb,var(--av,#64748b) 18%,transparent)}
-.ch-av-conductor{color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 16%,transparent)}
-.ch-av-conductor .ch-i{width:14px;height:14px}
-/* State dots: they breathe while something works; nothing spins. */
-.ch-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--ch-muted)}
-.ch-dot.is-busy{background:var(--ch-accent);animation:ch-breathe 1.6s ease-in-out infinite}
-.ch-dot.is-needs{background:var(--ch-warn)}
-.ch-dot.is-bad{background:var(--ch-bad)}
-@keyframes ch-breathe{0%,100%{opacity:.45;scale:.8}50%{opacity:1;scale:1.1}}
-
-/* ── The conversation ── */
-.ch-main{display:flex;flex-direction:column;min-width:0;min-height:0;position:relative}
-.ch-head{display:flex;align-items:center;gap:10px;min-height:52px;padding:6px 16px;border-bottom:1px solid var(--ch-line)}
-.ch-title{flex:1;min-width:0;margin:0;font-size:15px;font-weight:650;letter-spacing:-.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.ch-state{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:999px;font-size:12.5px;font-weight:600;color:var(--ch-soft);background:var(--ch-hover);white-space:nowrap}
+/* The state in the head: it breathes while something works; nothing spins. */
+.ch-state{display:inline-flex;align-items:center;gap:6px;flex:none;padding:3px 10px;border-radius:999px;font-size:12.5px;font-weight:600;color:var(--ch-soft);background:var(--ch-hover);white-space:nowrap}
.ch-state.is-ok{color:var(--ch-ok);background:color-mix(in srgb,var(--ch-ok) 12%,transparent)}
.ch-state.is-ok{color:var(--ch-ok);background:color-mix(in srgb,var(--ch-ok) 12%,transparent)}
.ch-state.is-bad{color:var(--ch-bad);background:color-mix(in srgb,var(--ch-bad) 12%,transparent)}
.ch-state.is-bad{color:var(--ch-bad);background:color-mix(in srgb,var(--ch-bad) 12%,transparent)}
.ch-state.is-busy{color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 12%,transparent)}
.ch-state.is-busy{color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 12%,transparent)}
+.ch-dot{width:8px;height:8px;border-radius:50%;flex:none;background:var(--ch-muted)}
+.ch-dot.is-busy{background:var(--ch-accent);animation:ch-breathe 1.6s ease-in-out infinite}
+@keyframes ch-breathe{0%,100%{opacity:.45}50%{opacity:1}}
.ch-icon-btn{display:inline-grid;place-items:center;width:36px;height:36px;flex:none;border:0;border-radius:10px;background:transparent;color:var(--ch-soft);cursor:pointer}
.ch-icon-btn{display:inline-grid;place-items:center;width:36px;height:36px;flex:none;border:0;border-radius:10px;background:transparent;color:var(--ch-soft);cursor:pointer}
.ch-icon-btn:hover{background:var(--ch-hover);color:var(--ch-ink)}
.ch-icon-btn:hover{background:var(--ch-hover);color:var(--ch-ink)}
-.ch-side-open{display:none}
-/* The rail is the network's sidebar (openvibe-shared kit.sidebar): the grid's first column on a computer, a drawer on
- a phone (kit.css). Its avatars are Codes' own. */
-@media (min-width:761px){.ch > .ovk-sidebar{position:relative;top:auto;height:auto;min-height:0}}
-.ch .ovk-sidebar-item .ch-av{width:24px;height:24px;font-size:11px}
-.ch-head .ovk-sidebar-open{color:var(--ch-ink)}
-.ch-thread{flex:1;min-height:0;overflow-y:auto;padding:28px max(20px,calc((100% - var(--ch-col)) / 2)) 24px;display:flex;flex-direction:column;gap:22px;scroll-behavior:smooth}
-.ch-turn{display:flex;flex-direction:column;gap:8px;min-width:0}
-.ch-you{align-items:flex-end}
-.ch-bubble{max-width:min(82%,620px);padding:11px 15px;border-radius:20px 20px 6px 20px;background:color-mix(in srgb,var(--ch-accent) 15%,var(--ch-card));white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.55}
-.ch-files{display:flex;flex-wrap:wrap;gap:6px;margin:0;justify-content:flex-end}
-.ch-who{display:flex;align-items:center;gap:8px;font-size:13.5px}
-.ch-who span:not(.ch-av){color:var(--ch-muted);font-size:12.5px}
-.ch-md{line-height:1.68;overflow-wrap:anywhere}
-.ch-md>:first-child{margin-top:0}.ch-md>:last-child{margin-bottom:0}
-.ch-md p{margin:0 0 .8em}
-.ch-md pre{overflow-x:auto;padding:12px 14px;border-radius:12px;background:var(--ch-rail);border:1px solid var(--ch-line);font-size:13px;line-height:1.5}
-.ch-md code{font-size:.9em;padding:.1em .35em;border-radius:6px;background:var(--ch-hover)}
-.ch-md pre code{padding:0;background:none}
-.ch-md a{color:var(--ch-accent)}
-.ch-md h1,.ch-md h2,.ch-md h3{font-size:1.02em;margin:1.1em 0 .4em}
-.ch-md table{border-collapse:collapse;display:block;overflow-x:auto}
-.ch-md td,.ch-md th{border:1px solid var(--ch-line);padding:5px 9px}
-.ch-quiet{margin:0;color:var(--ch-muted);font-size:14px}
.ch-error{margin:0;padding:10px 14px;border-radius:12px;color:var(--ch-bad);background:color-mix(in srgb,var(--ch-bad) 10%,transparent);font-size:14px}
.ch-error{margin:0;padding:10px 14px;border-radius:12px;color:var(--ch-bad);background:color-mix(in srgb,var(--ch-bad) 10%,transparent);font-size:14px}
-
-/* The steps an agent took: one quiet fold per turn, each step with the sign of what it did. */
-.ch-steps{align-self:flex-start;max-width:100%}
-.ch-steps summary{display:inline-flex;align-items:center;gap:6px;padding:4px 10px 4px 12px;border-radius:999px;background:var(--ch-hover);color:var(--ch-soft);font-size:12.5px;font-weight:600;cursor:pointer;list-style:none}
-.ch-steps summary::-webkit-details-marker{display:none}
+.ch-muted{color:var(--ch-muted);font-size:13px}
.ch-caret{width:14px;height:14px;transition:rotate .2s}
.ch-caret{width:14px;height:14px;transition:rotate .2s}
details[open]>summary .ch-caret{rotate:180deg}
details[open]>summary .ch-caret{rotate:180deg}
-.ch-steps ol{list-style:none;margin:8px 0 0;padding:4px 0 0 12px;border-left:2px solid var(--ch-line);display:grid;gap:2px;max-height:min(46vh,420px);overflow-y:auto}
-.ch-step{display:flex;align-items:center;gap:8px;min-width:0;padding:3px 6px;border-radius:8px;font-size:13px;color:var(--ch-soft)}
-.ch-step .ch-i{width:15px;height:15px;color:var(--ch-muted)}
-.ch-step-n{font-weight:600;color:var(--ch-ink);white-space:nowrap}
-.ch-step-w{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ch-muted);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
-.ch-step.is-bad .ch-i,.ch-step.is-bad .ch-step-n{color:var(--ch-bad)}
-.ch-step.is-new{animation:ch-in .35s ease-out}
-@keyframes ch-in{from{opacity:0;translate:0 4px}}
-/* Working: three dots hopping in turn, and the step it is on. */
-.ch-working{display:flex;align-items:center;gap:10px;color:var(--ch-muted);font-size:13.5px;min-width:0}
-.ch-working span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
-.ch-dots{display:inline-flex;gap:4px}
-.ch-dots i{width:6px;height:6px;border-radius:50%;background:var(--ch-accent);animation:ch-hop 1s ease-in-out infinite}
-.ch-dots i:nth-child(2){animation-delay:.15s}.ch-dots i:nth-child(3){animation-delay:.3s}
-@keyframes ch-hop{0%,80%,100%{translate:0 0;opacity:.45}40%{translate:0 -4px;opacity:1}}
-/* A hand-over, a wait: a quiet line across the thread. */
-.ch-divider{display:flex;align-items:center;gap:10px;color:var(--ch-muted);font-size:12.5px;font-weight:600}
-.ch-divider::before,.ch-divider::after{content:'';flex:1;height:1px;background:var(--ch-line)}
-.ch-divider .ch-i{width:15px;height:15px}
-/* How it ended. */
-.ch-result{display:grid;gap:2px;padding:6px;border:1px solid var(--ch-line);border-radius:16px;background:var(--ch-card)}
-.ch-res-line,.ch-res-fold summary{display:flex;align-items:center;gap:9px;margin:0;padding:9px 10px;border-radius:11px;font-size:14px}
+
+/* How it ended: a card in the last reply (the kit's), with the files, the proof and the pull request. */
+.ch-result{display:grid;gap:2px;margin:-6px}
+.ch-res-line,.ch-res-fold summary{display:flex;align-items:center;gap:9px;margin:0;padding:9px 10px;border-radius:11px;font-size:14px;min-width:0}
.ch-res-line.is-bad{color:var(--ch-bad)}
.ch-res-line.is-bad{color:var(--ch-bad)}
.ch-res-fold summary{cursor:pointer;list-style:none}
.ch-res-fold summary{cursor:pointer;list-style:none}
.ch-res-fold summary::-webkit-details-marker{display:none}
.ch-res-fold summary::-webkit-details-marker{display:none}
@@ -112,29 +52,14 @@ details[open]>summary .ch-caret{rotate:180deg}
@@ -112,29 +52,14 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-diff{margin:6px 0 0}
.ch-diff{margin:6px 0 0}
.ch-diff summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--ch-soft)}
.ch-diff summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--ch-soft)}
.ch-diff pre{max-height:50vh;overflow:auto;margin:6px 0 0;padding:10px 12px;border-radius:10px;background:var(--ch-rail);font-size:12px;line-height:1.5}
.ch-diff pre{max-height:50vh;overflow:auto;margin:6px 0 0;padding:10px 12px;border-radius:10px;background:var(--ch-rail);font-size:12px;line-height:1.5}
-.ch-cap{font-size:12px;font-weight:650;color:var(--ch-muted)}
.ch-add{color:var(--ch-ok);font-variant-numeric:tabular-nums;font-size:12.5px}.ch-del{color:var(--ch-bad);font-variant-numeric:tabular-nums;font-size:12.5px}
.ch-add{color:var(--ch-ok);font-variant-numeric:tabular-nums;font-size:12.5px}.ch-del{color:var(--ch-bad);font-variant-numeric:tabular-nums;font-size:12.5px}
-.ch-muted{color:var(--ch-muted);font-size:13px}
-/* What waits for a yes. */
-.ch-approval{display:grid;gap:10px;padding:14px;border-radius:16px;border:1px solid color-mix(in srgb,var(--ch-warn) 45%,var(--ch-line));background:color-mix(in srgb,var(--ch-warn) 7%,var(--ch-card))}
-.ch-approval-h{display:flex;align-items:center;gap:8px;margin:0;font-size:14px}
-.ch-approval code{display:block;overflow-x:auto;padding:8px 10px;border-radius:10px;background:var(--ch-rail);font-size:12.5px;white-space:pre}
-.ch-approval-a{display:flex;gap:8px}
-.ch-btn{padding:8px 16px;border:0;border-radius:999px;background:var(--ch-accent);color:var(--ch-on-accent);font:inherit;font-size:14px;font-weight:650;cursor:pointer}
-.ch-btn-quiet{background:var(--ch-hover);color:var(--ch-ink)}
+
+/* The composer's pickers and chips (in the kit composer's tools). */
.ch-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;border:1px solid var(--ch-line);background:var(--ch-card);color:var(--ch-soft);font:inherit;font-size:13px;text-decoration:none;cursor:pointer}
.ch-chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:999px;border:1px solid var(--ch-line);background:var(--ch-card);color:var(--ch-soft);font:inherit;font-size:13px;text-decoration:none;cursor:pointer}
.ch-chip:hover{color:var(--ch-ink);border-color:color-mix(in srgb,var(--ch-accent) 40%,var(--ch-line))}
.ch-chip:hover{color:var(--ch-ink);border-color:color-mix(in srgb,var(--ch-accent) 40%,var(--ch-line))}
.ch-chip .ch-i{width:15px;height:15px}
.ch-chip .ch-i{width:15px;height:15px}
.ch-chip-static{cursor:default;padding:4px 10px 4px 4px}
.ch-chip-static{cursor:default;padding:4px 10px 4px 4px}
-.ch-chip-static .ch-av{width:22px;height:22px}
-
-/* ── The composer ── */
-.ch-composer{position:relative;margin:0 max(16px,calc((100% - var(--ch-col)) / 2)) 14px;padding:10px 10px 8px 16px;border:1px solid var(--ch-line);border-radius:22px;background:var(--ch-card);box-shadow:0 8px 28px rgba(0,0,0,.14)}
-.ch-composer:focus-within{border-color:color-mix(in srgb,var(--ch-accent) 55%,var(--ch-line))}
-.ch-composer textarea{display:block;width:100%;box-sizing:border-box;min-height:28px;max-height:40vh;padding:6px 0 8px;border:0;outline:0;resize:none;background:transparent;color:var(--ch-ink);font:inherit;font-size:15.5px;line-height:1.5}
-.ch-composer textarea::placeholder{color:var(--ch-muted)}
-.ch-bar{display:flex;align-items:center;gap:8px}
-.ch-picks{flex:1;min-width:0;display:flex;align-items:center;gap:6px;flex-wrap:wrap}
+.ch-chip-static .ovt-avatar{width:22px;height:22px;margin:0;border-radius:7px;font-size:10px}
.ch-pick{position:relative;display:inline-flex;align-items:center;gap:6px;max-width:100%;height:32px;padding:0 10px;border-radius:999px;border:1px solid var(--ch-line);color:var(--ch-soft);font-size:13px}
.ch-pick{position:relative;display:inline-flex;align-items:center;gap:6px;max-width:100%;height:32px;padding:0 10px;border-radius:999px;border:1px solid var(--ch-line);color:var(--ch-soft);font-size:13px}
.ch-pick:hover{background:var(--ch-hover);color:var(--ch-ink)}
.ch-pick:hover{background:var(--ch-hover);color:var(--ch-ink)}
.ch-pick select{appearance:none;-webkit-appearance:none;border:0;background:transparent;color:inherit;font:inherit;font-weight:600;padding:0 16px 0 0;max-width:200px;cursor:pointer}
.ch-pick select{appearance:none;-webkit-appearance:none;border:0;background:transparent;color:inherit;font:inherit;font-weight:600;padding:0 16px 0 0;max-width:200px;cursor:pointer}
@@ -147,32 +72,21 @@ details[open]>summary .ch-caret{rotate:180deg}
@@ -147,32 +72,21 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-pick-file{cursor:pointer;padding:0 9px}
.ch-pick-file{cursor:pointer;padding:0 9px}
.ch-pick-file input{position:absolute;inset:0;opacity:0;cursor:pointer}
.ch-pick-file input{position:absolute;inset:0;opacity:0;cursor:pointer}
.ch-pick-file.has-files{color:var(--ch-accent);border-color:color-mix(in srgb,var(--ch-accent) 50%,var(--ch-line))}
.ch-pick-file.has-files{color:var(--ch-accent);border-color:color-mix(in srgb,var(--ch-accent) 50%,var(--ch-line))}
-.ch-send{display:inline-grid;place-items:center;width:36px;height:36px;flex:none;border:0;border-radius:50%;background:var(--ch-accent);color:var(--ch-on-accent);cursor:pointer;transition:opacity .2s,scale .15s}
-.ch-send .ch-i{width:18px;height:18px;stroke-width:2.2}
-.ch-send:active{scale:.92}
-.ch-composer:has(textarea:placeholder-shown) .ch-send{opacity:.45}
-.ch-send[disabled]{opacity:.45;cursor:default}
-.ch-stop{width:36px;height:36px;border-radius:50%;background:var(--ch-hover);color:var(--ch-ink)}
-.ch-stop .ch-i rect{fill:currentColor}
-.ch-stop:hover{background:color-mix(in srgb,var(--ch-bad) 18%,transparent);color:var(--ch-bad)}
-.ch-note{margin:4px 2px 0;min-height:1em;color:var(--ch-muted);font-size:12px}
-.ch-note.is-bad{color:var(--ch-bad)}
-.ch-composer.is-sending textarea{opacity:.6}
+.ch .ovt-c-note.is-bad{color:var(--ch-bad)}
+.ch .ovt-composer.is-sending textarea{opacity:.6}
-/* A new chat: the greeting and the composer meet in the middle, as in the chat apps people know. */
-.ch.is-empty .ch-main{justify-content:center}
-.ch.is-empty .ch-head{position:absolute;inset:0 0 auto;border-bottom:0}
-.ch.is-empty .ch-head .ch-title{opacity:0}
-.ch.is-empty .ch-thread{flex:none;overflow:visible;padding-bottom:18px}
+/* A new chat: the greeting, then the composer (the kit's start page puts them together in the middle). */
.ch-hello{display:grid;justify-items:center;gap:10px;text-align:center}
.ch-hello{display:grid;justify-items:center;gap:10px;text-align:center}
.ch-hello h1{margin:6px 0 0;font-size:clamp(24px,3.2vw,32px);letter-spacing:-.03em;line-height:1.15;text-wrap:balance}
.ch-hello h1{margin:6px 0 0;font-size:clamp(24px,3.2vw,32px);letter-spacing:-.03em;line-height:1.15;text-wrap:balance}
.ch-hello p{margin:0;max-width:52ch;color:var(--ch-soft);line-height:1.55}
.ch-hello p{margin:0;max-width:52ch;color:var(--ch-soft);line-height:1.55}
+.ch .ovt.is-start .ovt-head .ovt-title{opacity:0}
+.ch .ovt.is-start .ovt-head{border-bottom:0;background:none;backdrop-filter:none;-webkit-backdrop-filter:none}
/* What you are making: three cards that belong to the composer's form. Each says where its work runs. */
/* What you are making: three cards that belong to the composer's form. Each says where its work runs. */
.ch-modes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;width:100%;margin:14px 0 0;padding:0;border:0;min-width:0}
.ch-modes{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;width:100%;margin:14px 0 0;padding:0;border:0;min-width:0}
.ch-mode{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr);align-content:start;gap:2px 11px;padding:12px 14px;border:1px solid var(--ch-line);border-radius:16px;background:var(--ch-card);text-align:left;cursor:pointer;transition:border-color .15s,background-color .15s}
.ch-mode{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr);align-content:start;gap:2px 11px;padding:12px 14px;border:1px solid var(--ch-line);border-radius:16px;background:var(--ch-card);text-align:left;cursor:pointer;transition:border-color .15s,background-color .15s}
.ch-mode:hover{border-color:color-mix(in srgb,var(--ch-accent) 35%,var(--ch-line))}
.ch-mode:hover{border-color:color-mix(in srgb,var(--ch-accent) 35%,var(--ch-line))}
.ch-mode input{position:absolute;inset:0;margin:0;opacity:0;cursor:pointer}
.ch-mode input{position:absolute;inset:0;margin:0;opacity:0;cursor:pointer}
-.ch-mode-i{grid-row:span 2;display:grid;place-items:center;width:34px;height:34px;border-radius:11px;color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 12%,transparent)}
+.ch-mode-i{grid-row:span 2;display:grid;place-items:center;width:34px;height:34px;border-radius:11px;color:var(--ch-accent);background:color-mix(in srgb,var(--ch-accent) 14%,transparent)}
.ch-mode-i .ch-i{width:18px;height:18px}
.ch-mode-i .ch-i{width:18px;height:18px}
.ch-mode b{font-size:14.5px;letter-spacing:-.01em;color:var(--ch-ink)}
.ch-mode b{font-size:14.5px;letter-spacing:-.01em;color:var(--ch-ink)}
.ch-mode small{font-size:12.5px;line-height:1.4;color:var(--ch-muted)}
.ch-mode small{font-size:12.5px;line-height:1.4;color:var(--ch-muted)}
@@ -200,10 +114,10 @@ details[open]>summary .ch-caret{rotate:180deg}
@@ -200,10 +114,10 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-where-tail{color:var(--ch-muted)}
.ch-where-tail{color:var(--ch-muted)}
.ch-on{display:inline-flex;align-items:center;gap:4px;font-style:normal;font-weight:600;color:var(--ch-ok)}
.ch-on{display:inline-flex;align-items:center;gap:4px;font-style:normal;font-weight:600;color:var(--ch-ok)}
.ch-on::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor}
.ch-on::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor}
-/* The ideas rail: it drifts sideways and stops under a pointer, the keyboard or its own pause. Two copies make the
- loop seamless; the second is hidden from assistive tech and the keyboard. Calm motion (either preference) makes
- it a still row that scrolls by hand. */
-.ch-spark{display:flex;align-items:center;gap:4px;margin:0 max(16px,calc((100% - var(--ch-col)) / 2)) 12px}
+/* The ideas rail, under the composer: it drifts sideways and stops under a pointer, the keyboard or its own pause.
+ Two copies make the loop seamless; the second is hidden from assistive tech and the keyboard. Calm motion (either
+ preference) makes it a still row that scrolls by hand. */
+.ch-spark{display:flex;align-items:center;gap:4px;max-width:780px;margin:12px auto 0}
.ch-spark-view{flex:1;min-width:0;overflow:hidden;-webkit-mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent);mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent)}
.ch-spark-view{flex:1;min-width:0;overflow:hidden;-webkit-mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent);mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent)}
.ch-spark-track{display:flex;width:max-content;animation:ch-drift var(--ch-drift,80s) linear infinite}
.ch-spark-track{display:flex;width:max-content;animation:ch-drift var(--ch-drift,80s) linear infinite}
.ch-spark-list{display:flex;gap:8px;margin:0;padding:2px 8px 2px 0;list-style:none}
.ch-spark-list{display:flex;gap:8px;margin:0;padding:2px 8px 2px 0;list-style:none}
@@ -216,51 +130,21 @@ details[open]>summary .ch-caret{rotate:180deg}
@@ -216,51 +130,21 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-spark-pause .ch-i{width:15px;height:15px}
.ch-spark-pause .ch-i{width:15px;height:15px}
.ch-spark .ch-i-play,.ch-spark.is-paused .ch-i-pause{display:none}
.ch-spark .ch-i-play,.ch-spark.is-paused .ch-i-pause{display:none}
.ch-spark.is-paused .ch-i-play{display:block}
.ch-spark.is-paused .ch-i-play{display:block}
-@media (prefers-reduced-motion:reduce){.ch-spark-track{animation:none}.ch-spark-view{overflow-x:auto;scrollbar-width:none}.ch-spark-list[aria-hidden]{display:none}.ch-spark-pause{display:none}}
+@media (prefers-reduced-motion:reduce){.ch-spark-track{animation:none}.ch-spark-view{overflow-x:auto;scrollbar-width:none}.ch-spark-list[aria-hidden]{display:none}.ch-spark-pause{display:none}.ch-dot.is-busy{animation:none}}
html[data-ov-motion=reduced] .ch-spark-track{animation:none}
html[data-ov-motion=reduced] .ch-spark-track{animation:none}
html[data-ov-motion=reduced] .ch-spark-view{overflow-x:auto;scrollbar-width:none}
html[data-ov-motion=reduced] .ch-spark-view{overflow-x:auto;scrollbar-width:none}
html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=reduced] .ch-spark-pause{display:none}
html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=reduced] .ch-spark-pause{display:none}
-/* The workspace's other pages (Agents, Machines, Usage, Settings…) in the conversation's place: they scroll there and
- keep their own heading, so the bar above them is only the phone's way to the rail. */
-.ch-page{flex:1;min-height:0;overflow-y:auto}
-.ch-page .c4{max-width:none;padding:0 max(20px,calc((100% - 980px) / 2)) 44px}
-.ch.is-page .ch-head{display:none}
-@media (max-width:760px){.ch.is-page .ch-head{display:flex;border-bottom:0;min-height:44px}.ch-page .c4{padding:0 14px 32px}.ch-page .c4-main{padding-top:6px}}
-
-/* ── The side: what it did ── */
-.ch-side{min-height:0;overflow-y:auto;padding:10px 14px 18px;border-left:1px solid var(--ch-line);background:var(--ch-rail)}
-.ch-side-head{display:flex;align-items:center;justify-content:space-between;min-height:40px;font-size:14px}
-.ch-side-head .ch-icon-btn{display:none}
-.ch-side .ch-group{margin-left:0}
-.ch-people,.ch-facts{list-style:none;margin:0;padding:0;display:grid;gap:8px}
-.ch-people li{display:flex;align-items:center;gap:10px;font-size:13.5px}
-.ch-people small{display:block;color:var(--ch-muted);font-size:12px}
-.ch-facts li{display:flex;align-items:flex-start;gap:9px;font-size:13.5px;min-width:0}
-.ch-facts li span{min-width:0;overflow-wrap:anywhere}
-.ch-facts .ch-i{width:16px;height:16px;color:var(--ch-muted);margin-top:2px}
-.ch-facts a{color:var(--ch-accent)}
+/* The workspace's other pages (Agents, Machines, Usage, Settings…) in the conversation's place: they keep their own
+ heading, so the head above them is only the phone's way to the rail. */
+.ch .ovt-page .c4{max-width:none;padding:0 0 44px}
+@media (min-width:761px){.ch .ovt.is-page .ovt-head{display:none}}
+@media (max-width:760px){.ch .ovt.is-page .ovt-head{min-height:44px}.ch .ovt-page .c4{padding:0 0 32px}.ch .ovt-page .c4-main{padding-top:6px}}
-/* Narrower: the side becomes a sheet from the right. */
-@media (max-width:1180px){
- .ch.has-side{grid-template-columns:272px minmax(0,1fr)}
- .ch-side-open{display:inline-grid}
- .ch-side{position:fixed;z-index:10060;top:0;right:0;bottom:0;width:min(340px,88vw);translate:100% 0;transition:translate .25s ease;box-shadow:-12px 0 32px rgba(0,0,0,.25)}
- .ch.side-open .ch-side{translate:0 0}
- .ch-side-head .ch-icon-btn{display:inline-grid}
-}
-/* A phone: the conversation owns the screen; the rail is a drawer, the composer sits on the safe area. */
+/* A phone: the three cards stack, the repository box takes the row. */
@media (max-width:760px){
@media (max-width:760px){
- .ch,.ch.has-side{grid-template-columns:minmax(0,1fr);height:calc(100dvh - 52px)}
- body:has(.ch) .ovnav-appbar{display:none!important}
- body:has(.ch){--ovnav-appbar-h:0px}
- .ch-head{padding:4px 8px}
- .ch-thread{padding:18px 14px 16px}
- .ch-bubble{max-width:88%}
- .ch-composer{margin:0 8px calc(8px + env(safe-area-inset-bottom,0px));border-radius:20px}
.ch-pick-repo{flex:1 1 100%}
.ch-pick-repo{flex:1 1 100%}
.ch-pick-repo input{width:auto;flex:1;min-width:0}
.ch-pick-repo input{width:auto;flex:1;min-width:0}
- .ch.is-empty .ch-thread{padding-top:52px}
.ch-hello{gap:8px}
.ch-hello{gap:8px}
.ch-hello h1{font-size:23px}
.ch-hello h1{font-size:23px}
.ch-hello p{font-size:14px}
.ch-hello p{font-size:14px}
@@ -269,7 +153,4 @@ html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=red
@@ -269,7 +153,4 @@ html[data-ov-motion=reduced] .ch-spark-list[aria-hidden],html[data-ov-motion=red
.ch-mode-i{width:32px;height:32px;border-radius:10px;align-self:center}
.ch-mode-i{width:32px;height:32px;border-radius:10px;align-self:center}
.ch-mode b{font-size:14px}
.ch-mode b{font-size:14px}
.ch-mode small{font-size:12px;line-height:1.35}
.ch-mode small{font-size:12px;line-height:1.35}
- .ch-spark{margin:0 8px 10px}
}
}
-.ch-scrim{position:fixed;inset:0;z-index:10050;background:rgba(0,0,0,.4)}
-@media (prefers-reduced-motion:reduce){.ch-dot.is-busy,.ch-dots i,.ch-step.is-new{animation:none}.ch-side{transition:none}.ch-thread{scroll-behavior:auto}}
@@ -1,19 +1,18 @@
@@ -1,19 +1,18 @@
'use strict';
'use strict';
-// The chat app's conveniences (plan L31 CH1). The page works without this script; with it:
-// - the rail and the side open and close on narrow screens (Escape and the scrim close them);
-// - the composer grows with what is typed, Enter sends (Shift+Enter is a new line), an idea fills it;
-// - the rail's search filters the conversations as you type;
-// - a running turn streams into the thread: each step appears as the agent takes it, and the page renders the
-// finished turn when the attempt ends;
-// - the conductor's images go up with the message through the JSON API.
+// Codes' own parts of the chat app (plan L31 CH1). The conversation UI is the network's (openvibe-shared/thread, plan
+// L55 C4): ov-thread.js grows the composer, sends on Enter, filters the rail, opens the panel on a narrow screen, adds
+// Copy and keeps the thread at the bottom; kit-web.js opens the rail on a phone. The page works without either. Here:
+// - a new chat's three ways to start, the ideas rail, the projects picked from the words and the repository search;
+// - the conductor's images go up with the message through the JSON API;
+// - a running turn streams into the thread (OVThread.tool / toolDone / live), and the page renders the finished turn
+// when the attempt ends.
//
//
// Codes moves between pages with openvibe-shared/boost, which swaps the page in place without running this file
// Codes moves between pages with openvibe-shared/boost, which swaps the page in place without running this file
// again: each page is set up on its first load and on every ov:boost:load, and a live stream is closed when the page
// again: each page is set up on its first load and on every ov:boost:load, and a live stream is closed when the page
-// moves away (the owner, 2026-10-31: the rail's button did nothing on a phone after a move to Machines).
+// moves away.
(() => {
(() => {
const $ = (sel, root = document) => root.querySelector(sel);
const $ = (sel, root = document) => root.querySelector(sel);
- let page = null; // { app, es, setOpen } for the page on screen
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && page) page.setOpen('side-open', false); });
+ let page = null; // { app, es } for the page on screen
document.addEventListener('ov:boost:before', () => { if (page && page.es) page.es.close(); page = null; });
document.addEventListener('ov:boost:before', () => { if (page && page.es) page.es.close(); page = null; });
document.addEventListener('ov:boost:load', init);
document.addEventListener('ov:boost:load', init);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
@@ -21,7 +20,8 @@
@@ -21,7 +20,8 @@
// ── A new chat: the three ways to start, and the ideas rail ──
// ── A new chat: the three ways to start, and the ideas rail ──
// The cards are radio buttons of the composer's form (CSS shows each one's pickers); here a card also swaps the
// The cards are radio buttons of the composer's form (CSS shows each one's pickers); here a card also swaps the
// placeholder and the rail's ideas, and an idea fills the box and picks the card it belongs to.
// placeholder and the rail's ideas, and an idea fills the box and picks the card it belongs to.
- function startPage(app, form, text, grow) {
+ function startPage(app, form, text) {
+ const grow = () => text.dispatchEvent(new Event('input', { bubbles: true }));
const rail = $('[data-ch-ideas]', app);
const rail = $('[data-ch-ideas]', app);
const track = rail && $('[data-ch-ideas-track]', rail);
const track = rail && $('[data-ch-ideas-track]', rail);
const kind = $('[data-ch-kind]', form);
const kind = $('[data-ch-kind]', form);
@@ -125,51 +125,15 @@
@@ -125,51 +125,15 @@
const app = document.querySelector('[data-chat]');
const app = document.querySelector('[data-chat]');
if (!app || app.dataset.chReady) return;
if (!app || app.dataset.chReady) return;
app.dataset.chReady = '1';
app.dataset.chReady = '1';
- page = { app, es: null, setOpen: () => {} };
+ page = { app, es: null };
- // ── The side on narrow screens (the rail is the network's sidebar: kit-web.js opens and closes it) ──
- const scrim = $('[data-ch-scrim]', app);
- const setOpen = (cls, on) => {
- app.classList.toggle(cls, on);
- if (scrim) scrim.hidden = !app.classList.contains('side-open');
- if (on) { const first = $('.ch-side button', app); if (first) first.focus({ preventScroll: true }); }
- };
- app.addEventListener('click', (e) => {
- const t = e.target.closest('[data-ch-side-open],[data-ch-side-close],[data-ch-scrim]');
- if (!t) return;
- setOpen('side-open', t.matches('[data-ch-side-open]'));
- });
- page.setOpen = setOpen;
-
- // ── The rail's search: filter as you type (Enter still searches every task) ──
- const find = $('.ovk-sidebar-find input', app);
- if (find) {
- find.addEventListener('input', () => {
- const q = find.value.trim().toLowerCase();
- app.querySelectorAll('.ovk-sidebar-sec').forEach((sec) => {
- let shown = false;
- sec.querySelectorAll('.ovk-sidebar-item').forEach((a) => { a.hidden = !!q && !a.textContent.toLowerCase().includes(q); if (!a.hidden) shown = true; });
- sec.hidden = !!q && !shown;
- });
- });
- }
-
- // ── The composer ──
- const form = $('[data-ch-composer]', app);
- const text = form && $('[data-ch-text]', form);
- const status = form && $('[data-ch-status]', form);
+ // ── The composer (the kit's: it grows, Enter sends, Send waits for words) ──
+ const form = $('[data-ovt-composer]', app);
+ const text = form && $('[data-ovt-text]', form);
+ const status = form && $('[data-ovt-note]', form);
const say = (msg, bad) => { if (status) { status.textContent = msg; status.classList.toggle('is-bad', !!bad); } };
const say = (msg, bad) => { if (status) { status.textContent = msg; status.classList.toggle('is-bad', !!bad); } };
if (text) {
if (text) {
- const grow = () => { text.style.height = 'auto'; text.style.height = `${Math.min(text.scrollHeight, window.innerHeight * 0.4)}px`; };
- text.addEventListener('input', grow);
- text.addEventListener('keydown', (e) => {
- if (e.key !== 'Enter' || e.shiftKey || e.isComposing || e.keyCode === 229) return;
- // On a phone the keyboard's return is a new line; the send button sends.
- if (matchMedia('(pointer: coarse)').matches && !e.metaKey && !e.ctrlKey) return;
- e.preventDefault();
- if (text.value.trim()) form.requestSubmit();
- });
- startPage(app, form, text, grow);
+ startPage(app, form, text);
// The repository: a recent one is a tap away, and typing searches every repository the person can reach.
// The repository: a recent one is a tap away, and typing searches every repository the person can reach.
const repo = $('[data-ch-repo]', form);
const repo = $('[data-ch-repo]', form);
app.querySelectorAll('[data-ch-saved-repo]').forEach((b) => b.addEventListener('click', () => { if (repo) { repo.value = b.dataset.chSavedRepo; text.focus(); } }));
app.querySelectorAll('[data-ch-saved-repo]').forEach((b) => b.addEventListener('click', () => { if (repo) { repo.value = b.dataset.chSavedRepo; text.focus(); } }));
@@ -195,8 +159,7 @@
@@ -195,8 +159,7 @@
}
}
// The keyboard hint only where Enter sends (a phone keyboard's return is a new line).
// The keyboard hint only where Enter sends (a phone keyboard's return is a new line).
if (status && !status.textContent && !matchMedia('(pointer: coarse)').matches) status.textContent = 'Enter sends · Shift+Enter for a new line';
if (status && !status.textContent && !matchMedia('(pointer: coarse)').matches) status.textContent = 'Enter sends · Shift+Enter for a new line';
- if (app.classList.contains('is-empty')) text.focus({ preventScroll: true });
- grow();
+ if ($('.ovt.is-start', app)) text.focus({ preventScroll: true });
}
}
if (form) {
if (form) {
const images = $('[data-ch-images]', form);
const images = $('[data-ch-images]', form);
@@ -210,7 +173,7 @@
@@ -210,7 +173,7 @@
if (submitter && submitter.formAction && submitter.formAction !== form.action) return; // Stop: a plain post
if (submitter && submitter.formAction && submitter.formAction !== form.action) return; // Stop: a plain post
const files = images ? Array.from(images.files || []).slice(0, 4) : [];
const files = images ? Array.from(images.files || []).slice(0, 4) : [];
form.classList.add('is-sending');
form.classList.add('is-sending');
- form.querySelectorAll('button').forEach((b) => { b.disabled = b.classList.contains('ch-send'); });
+ form.querySelectorAll('button').forEach((b) => { b.disabled = b.matches('[data-ovt-send]'); });
if (!files.length) { say('Sending…'); return; } // words alone: the plain form post
if (!files.length) { say('Sending…'); return; } // words alone: the plain form post
e.preventDefault();
e.preventDefault();
say(files.length === 1 ? 'Sending the image…' : `Sending ${files.length} images…`);
say(files.length === 1 ? 'Sending the image…' : `Sending ${files.length} images…`);
@@ -229,13 +192,12 @@
@@ -229,13 +192,12 @@
});
});
}
}
- // ── The thread: start at the latest turn, and stream a running one ──
- const thread = $('#ch-thread');
- if (thread && !app.classList.contains('is-empty')) thread.scrollTop = thread.scrollHeight;
- const stream = thread && thread.dataset.chStream;
- const live = thread && thread.querySelector('.ch-agent.is-live');
- if (!stream || !live || typeof EventSource === 'undefined') return;
- const near = () => thread.scrollHeight - thread.scrollTop - thread.clientHeight < 160;
+ // ── A running turn streams in: each tool as the agent takes it, and the finished turn when the attempt ends ──
+ const stream = app.dataset.chStream;
+ const live = $('[data-ovt-thread] > .ovt-turn[data-open]', app);
+ const OV = window.OVThread;
+ if (!stream || !live || !OV || typeof EventSource === 'undefined') return;
+ const id = live.dataset.turn;
const kindOf = (name) => {
const kindOf = (name) => {
const n = String(name || '').toLowerCase();
const n = String(name || '').toLowerCase();
if (/^(read|view|cat|ls|glob|list|open)|read_file|list_dir/.test(n)) return 'read';
if (/^(read|view|cat|ls|glob|list|open)|read_file|list_dir/.test(n)) return 'read';
@@ -246,26 +208,6 @@
@@ -246,26 +208,6 @@
if (/(task|agent|delegate|subagent)/.test(n)) return 'sub';
if (/(task|agent|delegate|subagent)/.test(n)) return 'sub';
return 'tool';
return 'tool';
};
};
- // The step's sign: the server drew one of each kind already; reuse its icon when there is one.
- const iconFor = (kind) => {
- const drawn = thread.querySelector(`.ch-step[data-kind="${kind}"] svg`);
- if (drawn) return drawn.cloneNode(true);
- const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- s.setAttribute('class', 'ch-i'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '1.8');
- const c = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); c.setAttribute('cx', '12'); c.setAttribute('cy', '12'); c.setAttribute('r', '3');
- s.appendChild(c); return s;
- };
- let steps = live.querySelector('.ch-steps');
- const list = () => {
- if (!steps) {
- steps = document.createElement('details'); steps.className = 'ch-steps'; steps.open = true;
- const sum = document.createElement('summary'); sum.textContent = 'Working'; steps.append(sum, document.createElement('ol'));
- live.querySelector('.ch-who').after(steps);
- }
- return steps.querySelector('ol');
- };
- const count = () => { const n = list().children.length; const sum = steps.querySelector('summary'); if (sum && sum.firstChild && sum.firstChild.nodeType === 3) sum.firstChild.textContent = `Working · ${n} step${n === 1 ? '' : 's'}`; };
- const working = live.querySelector('[data-ch-working-text]');
const attemptN = Number(live.dataset.attempt || 0);
const attemptN = Number(live.dataset.attempt || 0);
const es = page.es = new EventSource(stream);
const es = page.es = new EventSource(stream);
let reloading = false;
let reloading = false;
@@ -274,19 +216,14 @@
@@ -274,19 +216,14 @@
let d; try { d = JSON.parse(ev.data); } catch { return; }
let d; try { d = JSON.parse(ev.data); } catch { return; }
if (attemptN && d.attempt_n && Number(d.attempt_n) !== attemptN) return;
if (attemptN && d.attempt_n && Number(d.attempt_n) !== attemptN) return;
const b = d.body && typeof d.body === 'object' ? d.body : { t: d.kind, text: String(d.body || '') };
const b = d.body && typeof d.body === 'object' ? d.body : { t: d.kind, text: String(d.body || '') };
- const stick = near();
if (b.t === 'tool') {
if (b.t === 'tool') {
- const li = document.createElement('li'); li.className = 'ch-step is-new'; li.dataset.kind = kindOf(b.name);
- const n = document.createElement('span'); n.className = 'ch-step-n'; n.textContent = b.name || 'tool';
- const w = document.createElement('span'); w.className = 'ch-step-w'; w.textContent = b.what || '';
- li.append(iconFor(li.dataset.kind), n, w); list().appendChild(li); count();
- if (working) working.textContent = `${b.name || 'Working'} ${b.what || ''}`.trim();
- } else if (b.t === 'tool_result' && b.ok === false) {
- const last = list().lastElementChild; if (last) last.classList.add('is-bad');
- } else if (b.t === 'text' && b.text && working) {
- working.textContent = String(b.text).split('\n').find(Boolean) || working.textContent;
+ OV.tool(id, { name: b.name || 'tool', input: b.what || '', icon: kindOf(b.name), key: b.id });
+ OV.live(id, `${b.name || 'Working'} ${b.what || ''}`.trim());
+ } else if (b.t === 'tool_result') {
+ OV.toolDone(id, b.ok !== false, b.id);
+ } else if (b.t === 'text' && b.text) {
+ OV.live(id, String(b.text).split('\n').find(Boolean) || 'Working');
} else if (b.t === 'done' || b.t === 'end' || d.kind === 'end') reload();
} else if (b.t === 'done' || b.t === 'end' || d.kind === 'end') reload();
- if (stick) thread.scrollTop = thread.scrollHeight;
});
});
es.addEventListener('attempt', (ev) => {
es.addEventListener('attempt', (ev) => {
let d; try { d = JSON.parse(ev.data); } catch { return; }
let d; try { d = JSON.parse(ev.data); } catch { return; }
@@ -22,6 +22,7 @@ const { asyncRouter } = require('./router');
@@ -22,6 +22,7 @@ const { asyncRouter } = require('./router');
const { sessionLogOf } = require('../domain/run-tasks');
const { sessionLogOf } = require('../domain/run-tasks');
const models = require('../data/models');
const models = require('../data/models');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
+const thread = require('openvibe-shared/thread');
const start = require('./start');
const start = require('./start');
const LIVE = ['queued', 'placed', 'running'];
const LIVE = ['queued', 'placed', 'running'];
@@ -79,7 +80,8 @@ const agentOf = (id, names) => {
@@ -79,7 +80,8 @@ const agentOf = (id, names) => {
const name = (names && names.get(id)) || (known && known[0]) || (id ? String(id) : 'Auto');
const name = (names && names.get(id)) || (known && known[0]) || (id ? String(id) : 'Auto');
return { name, initials: known ? known[1] : name.slice(0, 1).toUpperCase(), color: known ? known[2] : '#64748b' };
return { name, initials: known ? known[1] : name.slice(0, 1).toUpperCase(), color: known ? known[2] : '#64748b' };
};
};
-const avatar = (a) => `<span class="ch-av" style="--av:${esc(a.color)}" aria-hidden="true">${esc(a.initials)}</span>`;
+// The face is the conversation kit's (thread.face): the same in the rail, the thread and the panel.
+const avatar = (a) => thread.face(a);
// A tool call's kind, by the names the agents give their tools.
// A tool call's kind, by the names the agents give their tools.
function stepKind(name) {
function stepKind(name) {
@@ -158,7 +160,7 @@ function createChatRoutes(ctx) {
@@ -158,7 +160,7 @@ function createChatRoutes(ctx) {
const item = (row) => {
const item = (row) => {
const [, tone] = stateOf(row);
const [, tone] = stateOf(row);
const dot = waitingIds.has(row.id) ? 'needs' : tone === 'busy' ? 'busy' : tone === 'bad' ? 'bad' : null;
const dot = waitingIds.has(row.id) ? 'needs' : tone === 'busy' ? 'busy' : tone === 'bad' ? 'bad' : null;
- const mark = row.kind === 'conductor' ? `<span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span>` : avatar(agentOf(row.agent, names));
+ const mark = row.kind === 'conductor' ? thread.face({ name: 'Conductor', mark: ico('conductor'), color: 'var(--accent)' }) : avatar(agentOf(row.agent, names));
return { href: `/tasks/${encodeURIComponent(row.id)}`, label: titleOf(row), mark, meta: dot ? '' : ago(row.updated_at || row.created_at), here: row.id === currentId, dot };
return { href: `/tasks/${encodeURIComponent(row.id)}`, label: titleOf(row), mark, meta: dot ? '' : ago(row.updated_at || row.created_at), here: row.id === currentId, dot };
};
};
const pinned = rows.filter((x) => x.kind === 'conductor' && x.outcome !== 'stopped').slice(0, 2);
const pinned = rows.filter((x) => x.kind === 'conductor' && x.outcome !== 'stopped').slice(0, 2);
@@ -191,17 +193,21 @@ function createChatRoutes(ctx) {
@@ -191,17 +193,21 @@ function createChatRoutes(ctx) {
});
});
}
}
- // ── The thread ───────────────────────────────────────────────────────────────────────────────────────────────
- const you = (text, extra = '', caption = '') => `<div class="ch-turn ch-you">${caption ? `<span class="ch-cap">${esc(caption)}</span>` : ''}<div class="ch-bubble">${esc(text)}</div>${extra}</div>`;
- const divider = (text, icon = 'handover') => `<div class="ch-divider" role="note">${ico(icon)}<span>${esc(text)}</span></div>`;
- function stepsHtml(steps, ms, open = false) {
- if (!steps.length) return '';
- const list = steps.map((s) => `<li class="ch-step${s.ok === false ? ' is-bad' : ''}" data-kind="${s.kind}">${ico(s.kind)}<span class="ch-step-n">${esc(s.name)}</span><span class="ch-step-w">${esc(s.what || '')}</span></li>`).join('');
- const label = `Worked${ms ? ` ${minutes(ms)}` : ''} · ${steps.length} step${steps.length === 1 ? '' : 's'}`;
- return `<details class="ch-steps"${open ? ' open' : ''}><summary>${label}${ico('chevron', 'ch-caret')}</summary><ol>${list}</ol></details>`;
+ // ── The thread (openvibe-shared/thread, plan L55 C4: one conversation UI for the network) ─────────────────────
+ // A conversation is a list of turns: what the person said, and the agent's reply under its face and name. A
+ // hand-over to another agent, or a wait, is a line across the thread. How a task ended is a card in its last reply.
+ const STEP_ICON = { read: 'read', find: 'find', edit: 'edit', run: 'run', web: 'web', sub: 'sub', tool: 'tool' };
+ const errorRow = (msg) => thread.turn({ avatar: false, reply: thread.card({ tone: 'bad', text: msg }), attrs: { role: 'alert' } });
+ /** The steps an agent took, as the kit's work rows: what it said along the way is a note, each tool a row. */
+ function workHtml(steps, ms, { live = false, liveText = '' } = {}) {
+ if (!steps.length && !live) return '';
+ const rows = steps.map((s, i) => (s.said
+ ? { kind: 'note', text: s.what }
+ : { kind: 'tool', name: s.name, input: s.what || '', icon: STEP_ICON[s.kind] || 'tool', ok: s.ok == null ? undefined : s.ok, running: live && s.ok == null && i === steps.length - 1 ? true : false }));
+ return thread.work({ open: live, live: liveText || 'Starting', took: ms ? minutes(ms) : null, rows });
}
}
- /** One agent's turn from its session lines: what it said, the steps it took, how it ended. */
- function agentTurn(a, lines, { live = false, attempt = null } = {}) {
+ /** One agent's reply from its session lines: the steps it took, then what it said last (its answer). */
+ function agentReply(a, lines, { live = false, attempt = null } = {}) {
const steps = [];
const steps = [];
const said = [];
const said = [];
let done = null;
let done = null;
@@ -219,12 +225,13 @@ function createChatRoutes(ctx) {
@@ -219,12 +225,13 @@ function createChatRoutes(ctx) {
// The last thing it said is its answer; what it said along the way folds into the steps.
// The last thing it said is its answer; what it said along the way folds into the steps.
const answer = (done && done.text) || said[said.length - 1] || '';
const answer = (done && done.text) || said[said.length - 1] || '';
const along = said.slice(0, answer && said[said.length - 1] === answer ? -1 : undefined);
const along = said.slice(0, answer && said[said.length - 1] === answer ? -1 : undefined);
- const alongSteps = along.map((text) => ({ name: 'Said', what: firstLine(text, 140), kind: 'sub', ok: null }));
+ const all = [...along.map((text) => ({ said: true, what: firstLine(text, 140) })), ...steps];
const ran = attempt ? [attempt.model, attempt.effort ? effortWord(attempt.effort) : null].filter(Boolean).join(' · ') : '';
const ran = attempt ? [attempt.model, attempt.effort ? effortWord(attempt.effort) : null].filter(Boolean).join(' · ') : '';
- const who = `<div class="ch-who">${avatar(a)}<b>${esc(a.name)}</b>${ran ? `<span>${esc(ran)}</span>` : ''}</div>`;
- const body = answer ? `<div class="ch-md">${markdown(answer)}</div>` : live ? '' : `<p class="ch-quiet">${esc(attempt && attempt.end_why ? attempt.end_why : 'It ended without a word.')}</p>`;
- const working = live ? `<div class="ch-working" data-ch-working><span class="ch-dots" aria-hidden="true"><i></i><i></i><i></i></span><span data-ch-working-text>${steps.length ? esc(`${steps[steps.length - 1].name} ${steps[steps.length - 1].what || ''}`.trim()) : 'Starting…'}</span></div>` : '';
- return `<div class="ch-turn ch-agent${live ? ' is-live' : ''}"${attempt ? ` data-attempt="${Number(attempt.n)}"` : ''}>${who}${stepsHtml([...alongSteps, ...steps], ms, live)}${body}${working}</div>`;
+ const lastStep = steps[steps.length - 1];
+ const liveText = lastStep ? `${lastStep.name} ${lastStep.what || ''}`.trim() : '';
+ const body = answer ? thread.answer({ html: markdown(answer) })
+ : live ? '' : `<p class="ovt-muted">${esc(attempt && attempt.end_why ? attempt.end_why : 'It ended without a word.')}</p>`;
+ return { who: { ...a, sub: ran }, reply: workHtml(all, ms, { live, liveText }) + body };
}
}
async function taskThread(row) {
async function taskThread(row) {
const names = agentNames();
const names = agentNames();
@@ -233,24 +240,30 @@ function createChatRoutes(ctx) {
@@ -233,24 +240,30 @@ function createChatRoutes(ctx) {
if (!lines.length) lines = (sessionLogOf(row) || []).map((body, i) => ({ seq: i + 1, at: null, kind: body && body.t, body, attempt_n: 1 }));
if (!lines.length) lines = (sessionLogOf(row) || []).map((body, i) => ({ seq: i + 1, at: null, kind: body && body.t, body, attempt_n: 1 }));
const byAttempt = new Map();
const byAttempt = new Map();
for (const l of lines) { const n = Number(l.attempt_n || 1); if (!byAttempt.has(n)) byAttempt.set(n, []); byAttempt.get(n).push(l); }
for (const l of lines) { const n = Number(l.attempt_n || 1); if (!byAttempt.has(n)) byAttempt.set(n, []); byAttempt.get(n).push(l); }
- const out = [you(row.prompt || '')];
const live = LIVE.includes(row.state);
const live = LIVE.includes(row.state);
+ // Each attempt is a turn; the brief (or a note to carry on) is its person's side, a hand-over a line before it.
+ const parts = [];
+ let mine = { text: row.prompt || '', caption: 'The brief' };
let prev = null;
let prev = null;
for (const a of attempts) {
for (const a of attempts) {
const n = Number(a.n);
const n = Number(a.n);
if (prev) {
if (prev) {
const note = /^carried on by the owner: /.exec(a.why || '') ? String(a.why).replace(/^carried on by the owner: /, '') : null;
const note = /^carried on by the owner: /.exec(a.why || '') ? String(a.why).replace(/^carried on by the owner: /, '') : null;
- if (note) out.push(you(note));
- if (a.agent && prev.agent && a.agent !== prev.agent) out.push(divider(`${agentOf(a.agent, names).name} took over${prev.end_why ? `: ${agentOf(prev.agent, names).name} ${prev.end_why}` : ''}`));
- else if (a.why && !note) out.push(divider(String(a.why), 'handover'));
+ if (note) mine = { text: note };
+ else if (a.agent && prev.agent && a.agent !== prev.agent) parts.push({ divider: `${agentOf(a.agent, names).name} took over${prev.end_why ? `: ${agentOf(prev.agent, names).name} ${prev.end_why}` : ''}` });
+ else if (a.why) parts.push({ divider: String(a.why) });
}
}
const isLive = live && n === Number(attempts[attempts.length - 1].n);
const isLive = live && n === Number(attempts[attempts.length - 1].n);
- out.push(agentTurn(agentOf(a.agent || row.agent, names), byAttempt.get(n) || [], { live: isLive, attempt: a }));
+ parts.push({ n, open: isLive, you: mine, ...agentReply(agentOf(a.agent || row.agent, names), byAttempt.get(n) || [], { live: isLive, attempt: a }) });
+ mine = null;
prev = a;
prev = a;
}
}
- if (!attempts.length) out.push(agentTurn(agentOf(row.agent, names), lines, { live }));
- if (!live) out.push(await resultCard(row));
- return out.join('');
+ if (!attempts.length) parts.push({ n: 1, open: live, you: mine, ...agentReply(agentOf(row.agent, names), lines, { live }) });
+ // How it ended goes in the last reply.
+ const lastTurn = parts.filter((p) => !p.divider).pop();
+ if (!live && lastTurn) lastTurn.reply += await resultCard(row);
+ return parts.map((p) => (p.divider ? thread.divider({ text: p.divider })
+ : thread.turn({ id: `a${p.n}`, open: p.open, state: p.open ? 'running' : null, you: p.you || {}, who: p.who, reply: p.reply, attrs: { 'data-attempt': p.n } })));
}
}
async function conductorThread(row, view) {
async function conductorThread(row, view) {
const out = [];
const out = [];
@@ -258,20 +271,33 @@ function createChatRoutes(ctx) {
@@ -258,20 +271,33 @@ function createChatRoutes(ctx) {
const a = agentOf('claude-code', names);
const a = agentOf('claude-code', names);
const live = LIVE.includes(row.state);
const live = LIVE.includes(row.state);
const msgs = view.messages || [];
const msgs = view.messages || [];
+ const who = { ...a, sub: row.model || '' };
for (const m of msgs) {
for (const m of msgs) {
- out.push(you(m.n === 1 ? (m.prompt || 'Pick up the plan') : m.prompt, (m.attachments || []).length ? `<p class="ch-files">${m.attachments.map((f, i) => `<a class="ch-chip" href="/api/v1/tasks/${encodeURIComponent(row.id)}/messages/${encodeURIComponent(m.id)}/attachments/${i}">${ico('attach')}${esc(f.name)}</a>`).join('')}</p>` : '', m.n === 1 ? 'Hand-over brief' : ''));
+ const files = (m.attachments || []).map((f, i) => ({ href: `/api/v1/tasks/${encodeURIComponent(row.id)}/messages/${encodeURIComponent(m.id)}/attachments/${i}`, name: f.name, image: /^image\//.test(String(f.mime || '')) }));
+ const mine = { text: m.n === 1 ? (m.prompt || 'Pick up the plan') : m.prompt, caption: m.n === 1 ? 'Hand-over brief' : '', files };
+ const id = `m${Number(m.n) || out.length + 1}`;
if (m.state === 'running' || (live && m === msgs[msgs.length - 1] && m.state !== 'done')) {
if (m.state === 'running' || (live && m === msgs[msgs.length - 1] && m.state !== 'done')) {
const lines = await db.many('SELECT seq, at, kind, body FROM codes_session_lines WHERE task_id = $1 ORDER BY seq DESC LIMIT 300', [row.id]);
const lines = await db.many('SELECT seq, at, kind, body FROM codes_session_lines WHERE task_id = $1 ORDER BY seq DESC LIMIT 300', [row.id]);
- out.push(agentTurn(a, lines.reverse(), { live: true, attempt: { model: row.model } }));
+ const r = agentReply(a, lines.reverse(), { live: true, attempt: { model: row.model } });
+ out.push(thread.turn({ id, open: true, state: 'running', you: mine, who: r.who, reply: r.reply }));
} else if (m.result && m.result.summary) {
} else if (m.result && m.result.summary) {
- out.push(`<div class="ch-turn ch-agent"><div class="ch-who">${avatar(a)}<b>${esc(a.name)}</b>${row.model ? `<span>${esc(row.model)}</span>` : ''}</div><div class="ch-md">${markdown(m.result.summary)}</div></div>`);
- } else if (m.error) out.push(`<div class="ch-turn ch-agent"><div class="ch-who">${avatar(a)}<b>${esc(a.name)}</b></div><p class="ch-quiet">${esc(m.error)}</p></div>`);
- else if (m.state === 'queued') out.push(divider('Waiting for the turn before it to end', 'conductor'));
+ out.push(thread.turn({ id, you: mine, who, reply: thread.answer({ html: markdown(m.result.summary) }) }));
+ } else if (m.error) {
+ out.push(thread.turn({ id, you: mine, who, reply: `<p class="ovt-muted">${esc(m.error)}</p>` }));
+ } else if (m.state === 'queued') {
+ out.push(thread.turn({ id, you: mine, reply: null }));
+ out.push(thread.divider({ text: 'Waiting for the turn before it to end', icon: 'wait' }));
+ } else out.push(thread.turn({ id, you: mine, reply: null }));
}
}
- for (const ap of view.approvals || []) out.push(approvalCard(row, ap));
- return out.join('');
+ // What waits for a yes, as a reply of its own at the end of the thread.
+ const asks = (view.approvals || []).map((ap) => approvalCard(row, ap)).join('');
+ if (asks) out.push(thread.turn({ id: 'asks', who, reply: asks }));
+ return out;
}
}
- const approvalCard = (row, ap) => `<form class="ch-approval" method="post" action="/tasks/${encodeURIComponent(row.id)}/approvals/${encodeURIComponent(ap.id)}"><p class="ch-approval-h">${ico('proof')}<b>${esc(ap.tool || 'A tool')}</b> wants to run</p><code tabindex="0">${esc(ap.summary || '')}</code><div class="ch-approval-a"><button class="ch-btn" name="decision" value="allow">Allow</button><button class="ch-btn ch-btn-quiet" name="decision" value="deny">Deny</button></div></form>`;
+ const approvalCard = (row, ap) => thread.card({
+ tone: 'wait', icon: 'shield', title: ap.tool || 'A tool', text: 'wants to run', code: ap.summary || '',
+ form: { action: `/tasks/${encodeURIComponent(row.id)}/approvals/${encodeURIComponent(ap.id)}`, buttons: [{ name: 'decision', value: 'allow', label: 'Allow' }, { name: 'decision', value: 'deny', label: 'Deny', quiet: true }] },
+ });
/** How the task ended, as one card: the files and the diff, the proof item by item, and the pull request or why none. */
/** How the task ended, as one card: the files and the diff, the proof item by item, and the pull request or why none. */
async function resultCard(row) {
async function resultCard(row) {
@@ -306,7 +332,7 @@ function createChatRoutes(ctx) {
@@ -306,7 +332,7 @@ function createChatRoutes(ctx) {
: push.pushed ? 'The branch was pushed, but no pull request was asked for.' : 'This task did not ask for a pull request.';
: push.pushed ? 'The branch was pushed, but no pull request was asked for.' : 'This task did not ask for a pull request.';
parts.push(`<p class="ch-res-line ch-muted">${ico('pr')}<span>${esc(why)}${push.branch_url ? ` <a href="${esc(push.branch_url)}" rel="noopener noreferrer">${esc(push.branch || 'the branch')}</a>` : ''}</span></p>`);
parts.push(`<p class="ch-res-line ch-muted">${ico('pr')}<span>${esc(why)}${push.branch_url ? ` <a href="${esc(push.branch_url)}" rel="noopener noreferrer">${esc(push.branch || 'the branch')}</a>` : ''}</span></p>`);
}
}
- return `<div class="ch-result">${parts.join('')}</div>`;
+ return thread.card({ tone: tone === 'bad' ? 'bad' : 'info', html: `<div class="ch-result">${parts.join('')}</div>` });
}
}
// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────
// ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -324,54 +350,51 @@ function createChatRoutes(ctx) {
@@ -324,54 +350,51 @@ function createChatRoutes(ctx) {
function effortPicker(selected = '', cls = '') {
function effortPicker(selected = '', cls = '') {
return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort>${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort>${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
}
}
- function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', hidden = '', note = '', id = '', where = '' }) {
- return `<form class="ch-composer"${id ? ` id="${esc(id)}"` : ''} method="post" action="${esc(action)}"${api ? ` data-ch-api="${esc(api)}"` : ''} data-ch-composer>${hidden}
-<label class="ch-sr" for="ch-text">${esc(placeholder)}</label>
-<textarea id="ch-text" name="text" rows="1" placeholder="${esc(placeholder)}" required data-ch-text></textarea>
-<div class="ch-bar"><div class="ch-picks">${pickers}</div>${live && stopAction ? `<button class="ch-icon-btn ch-stop" formaction="${esc(stopAction)}" formnovalidate aria-label="Stop" title="Stop">${ico('stop')}</button>` : ''}<button class="ch-send" type="submit" aria-label="Send" title="Send">${ico('send')}</button></div>
-${where}${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p class="ch-note" data-ch-status role="status"></p>'}
-</form>`;
+ /** The kit's composer with Codes' pickers in its tools, where the work runs under the bar, and its JSON route. */
+ function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', note = '', id = null, where = '' }) {
+ return thread.composer({
+ action, id, placeholder, label: placeholder, tools: pickers, foot: where, note,
+ attrs: api ? { 'data-ch-api': api } : {},
+ busy: live, stopAction: live && stopAction ? stopAction : null, maxlength: 16000,
+ });
}
}
// ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────
// ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────
- async function frame(req, res, { currentId = null, title, head = '', thread = '', composerHtml = '', side = '', streamUrl = '', empty = false, page = null, here = '', css = [], scripts = [] }) {
+ /** The frame every signed-in page shares: the rail, the conversation (or a page) and, for a task, the panel. */
+ async function frame(req, res, { currentId = null, title, heading = null, state = '', turns = [], empty = '', composerHtml = '', panel = '', streamUrl = '', start = false, page = null, here = '', css = [], scripts = [] }) {
const rows = await conversations(req.viewer.subject);
const rows = await conversations(req.viewer.subject);
const waiting = approvals ? await approvals.inbox(req.viewer.subject).catch(() => []) : [];
const waiting = approvals ? await approvals.inbox(req.viewer.subject).catch(() => []) : [];
const waitingIds = new Set(waiting.map((a) => a.task_id));
const waitingIds = new Set(waiting.map((a) => a.task_id));
- const body = `<div class="ch${side ? ' has-side' : ''}${empty ? ' is-empty' : ''}${page != null ? ' is-page' : ''}" data-chat>
-${railHtml(rows, currentId, waitingIds, here)}
-<div class="ovk-sidebar-scrim" data-ovk-sidebar-scrim hidden></div>
-<div class="ch-scrim" data-ch-scrim hidden></div>
-<section class="ch-main" aria-label="Conversation">
-<header class="ch-head">${kit.sidebarButton({ id: 'ch-rail', label: 'Your conversations' })}${head}${side ? `<button class="ch-icon-btn ch-side-open" type="button" data-ch-side-open aria-label="What it did" aria-controls="ch-side">${ico('side')}</button>` : ''}</header>
-${page != null ? `<div class="ch-page">${page}</div>` : `<div class="ch-thread" id="ch-thread"${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${thread}</div>
-${composerHtml}`}
-</section>
-${side}
-</div>`;
- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css'], sharedScripts: ['kit-web.js'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });
+ const view = thread.page({
+ sidebar: railHtml(rows, currentId, waitingIds, here),
+ head: thread.head({ title: heading == null ? '' : heading, sidebar: 'ch-rail', sidebarLabel: 'Your conversations', actions: state, panel: panel ? 'ch-side' : null, panelLabel: 'What it did' }),
+ turns, empty, composer: composerHtml, panel, main: page, center: start, label: page != null ? title : 'Conversation',
+ cls: page != null ? 'is-page' : '',
+ });
+ const body = `<div class="ch" data-chat${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${view}</div>`;
+ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css'], sharedScripts: ['kit-web.js', 'ov-thread.js'], scripts: [...scripts, 'js/chat.js'], body: raw(body) });
}
}
- /** The side: who worked on it, and where the work is. */
+ /** The panel: who worked on it, and where the work is. */
async function sideHtml(row) {
async function sideHtml(row) {
const names = agentNames();
const names = agentNames();
const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];
const attempts = tasks?.attempts ? await tasks.attempts.list(row.id) : [];
const result = asObject(row.result) || {};
const result = asObject(row.result) || {};
const repo = repoLabel(row.repo);
const repo = repoLabel(row.repo);
- const facts = [
- row.repo ? [ico('repo'), `<a href="${esc(row.repo)}" rel="noopener noreferrer">${esc(repo || 'The repository')}</a>`] : null,
- row.folder ? [ico('folder'), `<code>${esc(row.folder)}</code>`] : null,
- Number.isFinite(Number(result.cost_usd)) && Number(result.cost_usd) > 0 ? [ico('usage'), `about $${Number(result.cost_usd).toFixed(2)} of model use`] : null,
+ const where = [
+ row.repo ? { iconHtml: ico('repo'), html: `<a href="${esc(row.repo)}" rel="noopener noreferrer">${esc(repo || 'The repository')}</a>` } : null,
+ row.folder ? { iconHtml: ico('folder'), html: `<code>${esc(row.folder)}</code>` } : null,
+ Number.isFinite(Number(result.cost_usd)) && Number(result.cost_usd) > 0 ? { iconHtml: ico('usage'), html: `about $${Number(result.cost_usd).toFixed(2)} of model use` } : null,
].filter(Boolean);
].filter(Boolean);
- const people = attempts.map((a) => { const g = agentOf(a.agent, names); return `<li>${avatar(g)}<span><b>${esc(g.name)}</b><small>${esc([a.model, tasks.attempts.wordsOf && a.reason ? tasks.attempts.wordsOf(a.reason) : a.state].filter(Boolean).join(' · '))}</small></span></li>`; }).join('');
- return `<aside class="ch-side" id="ch-side" aria-label="What it did"><div class="ch-side-head"><b>What it did</b><button class="ch-icon-btn" type="button" data-ch-side-close aria-label="Close">${ico('close')}</button></div>
-${people ? `<section><p class="ch-group">Agents</p><ul class="ch-people">${people}</ul></section>` : ''}
-${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${facts.map(([i, t]) => `<li>${i}<span>${t}</span></li>`).join('')}</ul></section>` : ''}
-</aside>`;
+ const people = attempts.map((a) => { const g = agentOf(a.agent, names); return { who: g, title: g.name, sub: [a.model, tasks.attempts.wordsOf && a.reason ? tasks.attempts.wordsOf(a.reason) : a.state].filter(Boolean).join(' · ') }; });
+ return thread.panel({ id: 'ch-side', title: 'What it did', sections: [
+ people.length ? { title: 'Agents', rows: people } : null,
+ where.length ? { title: 'Where', rows: where } : null,
+ ] });
}
}
ctx.chatShell = (req, res, { title, body, css = [], scripts = [], here = '' }) => frame(req, res, {
ctx.chatShell = (req, res, { title, body, css = [], scripts = [], here = '' }) => frame(req, res, {
- title, page: body, here, css, scripts, head: '',
+ title, page: body, here, css, scripts,
});
});
/** The person's machines, for the line that says where the work runs: never more than 1.5 s, never fatal. */
/** The person's machines, for the line that says where the work runs: never more than 1.5 s, never fatal. */
@@ -442,9 +465,9 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
@@ -442,9 +465,9 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
const rail = (list) => `<ul class="ch-spark-list">${list.map((i) => card(i, false)).join('')}</ul><ul class="ch-spark-list" aria-hidden="true">${list.map((i) => card(i, true)).join('')}</ul>`;
const rail = (list) => `<ul class="ch-spark-list">${list.map((i) => card(i, false)).join('')}</ul><ul class="ch-spark-list" aria-hidden="true">${list.map((i) => card(i, true)).join('')}</ul>`;
const ideas = `<section class="ch-spark" aria-label="Ideas to start from" data-ch-ideas data-ch-ideasets="${esc(JSON.stringify(sets))}"><div class="ch-spark-view"><div class="ch-spark-track" data-ch-ideas-track>${rail(sets[mode])}</div></div><template data-ch-idea-icons>${['web', 'game', 'app', 'ov', 'spark'].map((n) => ico(n).replace('<svg ', `<svg data-icon="${n}" `)).join('')}</template><button class="ch-icon-btn ch-spark-pause" type="button" data-ch-ideas-pause aria-pressed="false" aria-label="Pause the ideas" title="Pause the ideas">${ico('pause', 'ch-i-pause')}${ico('play', 'ch-i-play')}</button></section>`;
const ideas = `<section class="ch-spark" aria-label="Ideas to start from" data-ch-ideas data-ch-ideasets="${esc(JSON.stringify(sets))}"><div class="ch-spark-view"><div class="ch-spark-track" data-ch-ideas-track>${rail(sets[mode])}</div></div><template data-ch-idea-icons>${['web', 'game', 'app', 'ov', 'spark'].map((n) => ico(n).replace('<svg ', `<svg data-icon="${n}" `)).join('')}</template><button class="ch-icon-btn ch-spark-pause" type="button" data-ch-ideas-pause aria-pressed="false" aria-label="Pause the ideas" title="Pause the ideas">${ico('pause', 'ch-i-pause')}${ico('play', 'ch-i-play')}</button></section>`;
- const thread = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend. Pick what it is, and Codes sends it to the right place.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;
+ const hello = `<div class="ch-hello"><span class="ov-mark" data-size="56" aria-hidden="true"></span><h1>${name ? `What should we build, ${esc(name)}?` : 'What should we build?'}</h1><p>Say it the way you would to a friend. Pick what it is, and Codes sends it to the right place.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;
return frame(req, res, {
return frame(req, res, {
- title: 'New chat', empty: true, head: '<h1 class="ch-title">New chat</h1>', thread,
+ title: 'New chat', heading: 'New chat', start: true, empty: hello,
composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'Describe the change, question or fix', pickers, where }) + ideas,
composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'Describe the change, question or fix', pickers, where }) + ideas,
});
});
});
});
@@ -527,15 +550,16 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
@@ -527,15 +550,16 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
const [word, tone] = stateOf(row);
const [word, tone] = stateOf(row);
const live = LIVE.includes(row.state);
const live = LIVE.includes(row.state);
const error = String(req.query.error || '').slice(0, 200);
const error = String(req.query.error || '').slice(0, 200);
- const head = `<h1 class="ch-title">${esc(titleOf(row))}</h1><span class="ch-state is-${tone}" data-ch-state>${tone === 'busy' ? '<span class="ch-dot is-busy"></span>' : ''}${esc(word)}</span>`;
+ const state = `<span class="ch-state is-${tone}" data-ch-state>${tone === 'busy' ? '<span class="ch-dot is-busy"></span>' : ''}${esc(word)}</span>`;
+ const errorTurns = error ? [errorRow(error)] : [];
if (row.kind === 'conductor') {
if (row.kind === 'conductor') {
const view = await conductor.view(row);
const view = await conductor.view(row);
const last = await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]);
const last = await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]);
const model = String(row.model || 'opus');
const model = String(row.model || 'opus');
const pickers = `<span class="ch-chip ch-chip-static" title="The conductor runs Claude Code on your machine">${avatar(agentOf('claude-code'))}${esc(model)}</span><label class="ch-pick ch-pick-file" title="Attach an image">${ico('attach')}<span class="ch-sr">Attach an image</span><input type="file" accept="image/*" multiple data-ch-images></label>`;
const pickers = `<span class="ch-chip ch-chip-static" title="The conductor runs Claude Code on your machine">${avatar(agentOf('claude-code'))}${esc(model)}</span><label class="ch-pick ch-pick-file" title="Attach an image">${ico('attach')}<span class="ch-sr">Attach an image</span><input type="file" accept="image/*" multiple data-ch-images></label>`;
return frame(req, res, {
return frame(req, res, {
- currentId: row.id, title: 'Conductor', head, side: await sideHtml(row),
- thread: (error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : '') + await conductorThread(row, view),
+ currentId: row.id, title: 'Conductor', heading: titleOf(row), state, panel: await sideHtml(row),
+ turns: [...errorTurns, ...await conductorThread(row, view)],
streamUrl: `/api/v1/tasks/${encodeURIComponent(row.id)}/events?last_event_id=${last && last.seq != null ? Number(last.seq) : 0}`,
streamUrl: `/api/v1/tasks/${encodeURIComponent(row.id)}/events?last_event_id=${last && last.seq != null ? Number(last.seq) : 0}`,
composerHtml: row.outcome === 'stopped' ? '' : composer({ action: `/tasks/${encodeURIComponent(row.id)}/messages`, api: `/api/v1/conductor/${encodeURIComponent(row.id)}/messages`, placeholder: live ? 'Say something: it is the next turn' : 'What next?', pickers, note: live ? 'A message waits until the current turn ends; it is not lost.' : '' }),
composerHtml: row.outcome === 'stopped' ? '' : composer({ action: `/tasks/${encodeURIComponent(row.id)}/messages`, api: `/api/v1/conductor/${encodeURIComponent(row.id)}/messages`, placeholder: live ? 'Say something: it is the next turn' : 'What next?', pickers, note: live ? 'A message waits until the current turn ends; it is not lost.' : '' }),
});
});
@@ -545,8 +569,8 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
@@ -545,8 +569,8 @@ ${facts.length ? `<section><p class="ch-group">Where</p><ul class="ch-facts">${f
const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;
const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;
const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` }) + effortPicker('');
const pickers = whoPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` }) + effortPicker('');
return frame(req, res, {
return frame(req, res, {
- currentId: row.id, title: titleOf(row), head, side: await sideHtml(row),
- thread: (error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : '') + await taskThread(row),
+ currentId: row.id, title: titleOf(row), heading: titleOf(row), state, panel: await sideHtml(row),
+ turns: [...errorTurns, ...await taskThread(row)],
streamUrl: live ? `/api/v1/tasks/${encodeURIComponent(row.id)}/events?last_event_id=${lastSeq}` : '',
streamUrl: live ? `/api/v1/tasks/${encodeURIComponent(row.id)}/events?last_event_id=${lastSeq}` : '',
composerHtml: row.outcome === 'stopped' ? '' : composer({
composerHtml: row.outcome === 'stopped' ? '' : composer({
action: `/tasks/${encodeURIComponent(row.id)}/continue`, api: `/api/v1/tasks/${encodeURIComponent(row.id)}/continue`, live,
action: `/tasks/${encodeURIComponent(row.id)}/continue`, api: `/api/v1/tasks/${encodeURIComponent(row.id)}/continue`, live,
@@ -47,13 +47,13 @@ const { boot, check, done } = require('./helpers/boot');
@@ -47,13 +47,13 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok(!home.text.includes('Built for the way you work'));
assert.ok(!home.text.includes('Built for the way you work'));
assert.ok(!home.text.includes('Recently shipped on'));
assert.ok(!home.text.includes('Recently shipped on'));
assert.ok(home.text.includes('What should we build') && home.text.includes('owner/name or any Git URL') && home.text.includes('name="mode"'), 'the greeting, the three ways to start and the repository');
assert.ok(home.text.includes('What should we build') && home.text.includes('owner/name or any Git URL') && home.text.includes('name="mode"'), 'the greeting, the three ways to start and the repository');
- assert.ok(home.text.includes('action="/chat"') && home.text.includes('data-ch-composer'), 'one composer that starts the conversation');
+ assert.ok(home.text.includes('action="/chat"') && home.text.includes('data-ovt-composer'), 'one composer that starts the conversation');
const id = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV';
const id = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAV';
await t.ctx.store.db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, error, created_at, updated_at)
await t.ctx.store.db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, error, created_at, updated_at)
VALUES ($1,$2,'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW','https://github.com/OpenVibers/OpenVibe.Codes','main','Fix the full brief and keep the workspace readable on a phone','codex','failed','{"cost":0.42}'::jsonb,'The machine stopped.',$3,$3)`, [id, ada.subject, Date.now()]);
VALUES ($1,$2,'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW','https://github.com/OpenVibers/OpenVibe.Codes','main','Fix the full brief and keep the workspace readable on a phone','codex','failed','{"cost":0.42}'::jsonb,'The machine stopped.',$3,$3)`, [id, ada.subject, Date.now()]);
const task = await t.get(`/tasks/${id}`, { as: ada });
const task = await t.get(`/tasks/${id}`, { as: ada });
assert.strictEqual(task.status, 200);
assert.strictEqual(task.status, 200);
- assert.ok(task.text.includes('class="ch-bubble">Fix the full brief and keep the workspace readable on a phone'), 'the brief is the first message');
+ assert.ok(task.text.includes('class="ovt-bubble">Fix the full brief and keep the workspace readable on a phone'), 'the brief is the first message');
assert.ok(task.text.includes("Didn't finish") || task.text.includes('Didn\'t finish'), 'the state is said in words');
assert.ok(task.text.includes("Didn't finish") || task.text.includes('Didn\'t finish'), 'the state is said in words');
assert.ok(task.text.includes('Same agent (Codex)'), 'the composer carries on with the same agent, or another one');
assert.ok(task.text.includes('Same agent (Codex)'), 'the composer carries on with the same agent, or another one');
assert.ok(task.text.includes(`action="/tasks/${id}/continue"`), 'the next message is the next turn');
assert.ok(task.text.includes(`action="/tasks/${id}/continue"`), 'the next message is the next turn');
@@ -1,43 +1,53 @@
@@ -1,43 +1,53 @@
'use strict';
'use strict';
-// public/js/chat.js after a page move (the owner, 2026-10-31, a phone: "I can't open… the menu" on Machines). Codes
-// moves between pages with openvibe-shared/boost, which swaps <main> without running the page's scripts again: a move
-// from Agents to Machines (the same scripts) left the new page with no handlers. The rail is now the network's sidebar,
-// which kit-web.js opens on the document (openvibe-shared test/kit.test.js); chat.js still owns the side panel and the
-// rail's search, and sets them up on each page: its first load and every ov:boost:load.
+// The chat app after a page move (the owner, 2026-10-31, a phone: "I can't open… the menu" on Machines). Codes moves
+// between pages with openvibe-shared/boost, which swaps <main> without running the page's scripts again. The rail is
+// the network's sidebar (kit-web.js opens it on the document); the panel ("What it did") and the rail's search are the
+// conversation kit's (ov-thread.js, plan L55 C4); chat.js keeps Codes' own parts. Each sets itself up on a page's first
+// load and on every ov:boost:load.
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const { parseHTML } = require('linkedom');
const { parseHTML } = require('linkedom');
+const thread = require('openvibe-shared/thread');
+const kit = require('openvibe-shared/kit');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
-const SRC = fs.readFileSync(path.join(__dirname, '../public/js/chat.js'), 'utf8');
-const PAGE = (title) => `<div class="ch has-side" data-chat><aside class="ovk-sidebar" id="ch-rail" data-ovk-sidebar>
-<form class="ovk-sidebar-find"><input></form><div class="ovk-sidebar-secs"><section class="ovk-sidebar-sec"><h2 class="ovk-sidebar-h">Today</h2>
-<a class="ovk-sidebar-item" href="/tasks/1"><span class="ovk-sidebar-t">Fit the home page</span></a><a class="ovk-sidebar-item" href="/tasks/2"><span class="ovk-sidebar-t">Fix the fleet card</span></a></section>
-<section class="ovk-sidebar-sec"><h2 class="ovk-sidebar-h">Earlier</h2><a class="ovk-sidebar-item" href="/tasks/3"><span class="ovk-sidebar-t">Importer</span></a></section></div></aside>
-<div class="ch-scrim" data-ch-scrim hidden></div>
-<section class="ch-main"><header class="ch-head"><button type="button" data-ch-side-open>side</button></header><div class="ch-page"><h1>${title}</h1></div></section>
-<aside class="ch-side" id="ch-side"><button type="button" data-ch-side-close>close</button></aside></div>`;
+const CHAT = fs.readFileSync(path.join(__dirname, '../public/js/chat.js'), 'utf8');
+const KIT = fs.readFileSync(path.join(path.dirname(require.resolve('openvibe-shared/package.json')), 'ov-thread.js'), 'utf8');
+const rail = kit.sidebar({ id: 'ch-rail', label: 'Codes', find: { action: '/tasks', name: 'q', placeholder: 'Search conversations' }, sections: [
+ { title: 'Today', items: [{ href: '/tasks/1', label: 'Fit the home page' }, { href: '/tasks/2', label: 'Fix the fleet card' }] },
+ { title: 'Earlier', items: [{ href: '/tasks/3', label: 'Importer' }] },
+] });
+const PAGE = (title) => `<div class="ch" data-chat>${thread.page({
+ sidebar: rail,
+ head: thread.head({ title, sidebar: 'ch-rail', panel: 'ch-side', panelLabel: 'What it did' }),
+ turns: [thread.turn({ id: 'a1', you: { text: 'Fit the home page' }, reply: '<p>Done</p>' })],
+ panel: thread.panel({ id: 'ch-side', title: 'What it did' }),
+ composer: thread.composer({ action: '/tasks/1/continue' }),
+})}</div>`;
(async () => {
(async () => {
- await check('the side panel and the rail’s search work on a page that boost swapped in, not only on the first', async () => {
+ await check('the panel, the rail’s search and the composer work on a page that boost swapped in, not only on the first', async () => {
const { window, document, CustomEvent, Event } = parseHTML(`<!doctype html><html><body><main id="main">${PAGE('Agents')}</main></body></html>`);
const { window, document, CustomEvent, Event } = parseHTML(`<!doctype html><html><body><main id="main">${PAGE('Agents')}</main></body></html>`);
window.EventSource = undefined;
window.EventSource = undefined;
- new Function('window', 'document', 'location', 'setTimeout', SRC)(window, document, { reload() {} }, setTimeout);
+ window.matchMedia = () => ({ matches: false });
+ const run = (src) => new Function('window', 'document', 'location', 'setTimeout', 'matchMedia', 'navigator', src)(window, document, { reload() {}, hash: '' }, setTimeout, window.matchMedia, {});
+ run(KIT);
+ run(CHAT);
const tap = (sel) => document.querySelector(sel).dispatchEvent(new Event('click', { bubbles: true }));
const tap = (sel) => document.querySelector(sel).dispatchEvent(new Event('click', { bubbles: true }));
- tap('[data-ch-side-open]');
- assert.ok(document.querySelector('[data-chat]').classList.contains('side-open'), 'the first page');
+ tap('[data-ovt-panel-open]');
+ assert.ok(document.querySelector('[data-ovt]').classList.contains('is-panel'), 'the first page');
// A boost move: the old <main> goes, the new one comes in, then ov:boost:load.
// A boost move: the old <main> goes, the new one comes in, then ov:boost:load.
document.dispatchEvent(new CustomEvent('ov:boost:before'));
document.dispatchEvent(new CustomEvent('ov:boost:before'));
document.getElementById('main').innerHTML = PAGE('Machines');
document.getElementById('main').innerHTML = PAGE('Machines');
document.dispatchEvent(new CustomEvent('ov:boost:load'));
document.dispatchEvent(new CustomEvent('ov:boost:load'));
- const app = document.querySelector('[data-chat]');
- tap('[data-ch-side-open]');
- assert.ok(app.classList.contains('side-open') && document.querySelector('[data-ch-scrim]').hidden === false, 'the swapped-in page, with its scrim');
+ const box = document.querySelector('[data-ovt]');
+ tap('[data-ovt-panel-open]');
+ assert.ok(box.classList.contains('is-panel') && document.querySelector('[data-ovt-panel-scrim]').hidden === false, 'the swapped-in page, with its scrim');
const esc = new Event('keydown'); esc.key = 'Escape'; document.dispatchEvent(esc);
const esc = new Event('keydown'); esc.key = 'Escape'; document.dispatchEvent(esc);
- assert.ok(!app.classList.contains('side-open'), 'Escape closes the side of the page on screen');
+ assert.ok(!box.classList.contains('is-panel'), 'Escape closes the panel of the page on screen');
document.dispatchEvent(new CustomEvent('ov:boost:load'));
document.dispatchEvent(new CustomEvent('ov:boost:load'));
- assert.strictEqual(app.dataset.chReady, '1', 'a second load event does not set it up twice');
+ assert.strictEqual(document.querySelector('[data-chat]').dataset.chReady, '1', 'a second load event does not set it up twice');
// The rail's search filters the conversations as you type, and a section with none left hides.
// The rail's search filters the conversations as you type, and a section with none left hides.
const input = document.querySelector('.ovk-sidebar-find input');
const input = document.querySelector('.ovk-sidebar-find input');
input.value = 'fleet'; input.dispatchEvent(new Event('input'));
input.value = 'fleet'; input.dispatchEvent(new Event('input'));
@@ -45,6 +55,8 @@ const PAGE = (title) => `<div class="ch has-side" data-chat><aside class="ovk-si
@@ -45,6 +55,8 @@ const PAGE = (title) => `<div class="ch has-side" data-chat><aside class="ovk-si
assert.deepStrictEqual(shown, ['Fix the fleet card']);
assert.deepStrictEqual(shown, ['Fix the fleet card']);
const secs = [...document.querySelectorAll('.ovk-sidebar-sec')].map((x) => x.hidden);
const secs = [...document.querySelectorAll('.ovk-sidebar-sec')].map((x) => x.hidden);
assert.deepStrictEqual(secs, [false, true], 'Earlier has nothing left, so it hides');
assert.deepStrictEqual(secs, [false, true], 'Earlier has nothing left, so it hides');
+ // Codes' own: the keyboard hint in the composer's status line.
+ assert.strictEqual(document.querySelector('[data-ovt-note]').textContent, 'Enter sends · Shift+Enter for a new line', 'the hint where Enter sends');
});
});
done();
done();
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,6 +4,7 @@
@@ -4,6 +4,7 @@
// conversations only; the thread shows the brief, each agent's turn with its steps, a hand-over as a divider and a
// conversations only; the thread shows the brief, each agent's turn with its steps, a hand-over as a divider and a
// carry-on as their next message; a new chat starts a task from the composer; the step kinds name what an agent did.
// carry-on as their next message; a new chat starts a task from the composer; the step kinds name what an agent did.
const assert = require('assert');
const assert = require('assert');
+const thread = require('openvibe-shared/thread');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const { stepKind } = require('../server/http/chat');
const { stepKind } = require('../server/http/chat');
@@ -44,13 +45,15 @@ const { stepKind } = require('../server/http/chat');
@@ -44,13 +45,15 @@ const { stepKind } = require('../server/http/chat');
const page = await t.get(`/tasks/${MINE}`, { as: ada });
const page = await t.get(`/tasks/${MINE}`, { as: ada });
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
const text = page.text;
const text = page.text;
- const brief = text.indexOf('class="ch-bubble">Fit the home page');
+ const brief = text.indexOf('class="ovt-bubble">Fit the home page');
const handover = text.indexOf('Claude Code took over');
const handover = text.indexOf('Claude Code took over');
const answer = text.indexOf('<strong>scrolls sideways</strong>');
const answer = text.indexOf('<strong>scrolls sideways</strong>');
- const next = text.indexOf('class="ch-bubble">Keep the titles on one line.');
+ const next = text.indexOf('class="ovt-bubble">Keep the titles on one line.');
const last = text.indexOf('Titles stay on one line.');
const last = text.indexOf('Titles stay on one line.');
assert.ok(brief > -1 && handover > brief && answer > handover && next > answer && last > next, 'in the order it happened');
assert.ok(brief > -1 && handover > brief && answer > handover && next > answer && last > next, 'in the order it happened');
- assert.ok(text.includes('data-kind="read"') && text.includes('data-kind="edit"'), 'each step carries its kind');
+ assert.ok(text.includes(thread.icon('read')) && text.includes(thread.icon('edit')), 'each step carries the sign of its kind');
+ assert.ok(text.includes('<li class="ovt-divider">') && /<p class="ovt-who"><b>Claude Code<\/b>/.test(text), 'the hand-over is a line, and each reply says which agent');
+ assert.ok(text.includes('data-ovt-panel-open aria-controls="ch-side"') && text.includes('<aside class="ovt-panel" id="ch-side"'), 'what it did is the panel');
assert.ok(text.includes('1 file changed') && text.includes('<span class="ch-add">+3</span>'), 'how it ended: the files');
assert.ok(text.includes('1 file changed') && text.includes('<span class="ch-add">+3</span>'), 'how it ended: the files');
assert.ok(text.includes(`action="/tasks/${MINE}/continue"`), 'the composer carries it on');
assert.ok(text.includes(`action="/tasks/${MINE}/continue"`), 'the composer carries it on');
assert.ok(text.includes('aria-current="page"'), 'the rail marks the open conversation');
assert.ok(text.includes('aria-current="page"'), 'the rail marks the open conversation');
@@ -74,7 +77,8 @@ const { stepKind } = require('../server/http/chat');
@@ -74,7 +77,8 @@ const { stepKind } = require('../server/http/chat');
const home = await t.get('/', { as: ada });
const home = await t.get('/', { as: ada });
const h = home.text;
const h = home.text;
['new', 'openvibe', 'code'].forEach((v) => assert.match(h, new RegExp(`<input type="radio" name="mode" value="${v}" form="ch-start"`), `the ${v} card belongs to the form`));
['new', 'openvibe', 'code'].forEach((v) => assert.match(h, new RegExp(`<input type="radio" name="mode" value="${v}" form="ch-start"`), `the ${v} card belongs to the form`));
- assert.ok(h.includes('<form class="ch-composer" id="ch-start"'), 'the composer is the form the cards name');
+ assert.ok(h.includes('<form class="ovt-composer" id="ch-start"'), 'the composer is the form the cards name');
+ assert.ok(h.includes('<div class="ovt is-start" data-ovt>'), 'a start page: the greeting and the composer together');
assert.ok(!/name="repo"[^>]*required/.test(h), 'the repository is not required: something new needs none');
assert.ok(!/name="repo"[^>]*required/.test(h), 'the repository is not required: something new needs none');
assert.match(h, /<select name="project"[^>]*>[\s\S]*?<option value="OpenVibers\/OpenVibe\.Live"[^>]*>Live · openvibe\.live<\/option>/, 'OpenVibe projects are a list');
assert.match(h, /<select name="project"[^>]*>[\s\S]*?<option value="OpenVibers\/OpenVibe\.Live"[^>]*>Live · openvibe\.live<\/option>/, 'OpenVibe projects are a list');
assert.match(h, /<option value="OpenVibers\/OpenVibe\.Shared"/, 'the shared libraries too');
assert.match(h, /<option value="OpenVibers\/OpenVibe\.Shared"/, 'the shared libraries too');