openvibers/openvibe.codes Commits

The chat is the network's conversation UI: agents by name, hand-overs, approvals and the panel (#248)

OP OpenVibers committed 11 days ago · f9fc3010ac · 11 files, +217 −359

* The chat is the network's conversation UI (openvibe-shared/thread): agents by name, hand-overs, approvals and the panel from the kit

* openvibe-shared v4.99.0 (was v4.95.0): the conversation kit's parts for Codes

Changes

ddiff --git a/README.md b/README.md
iindex 8c1dbb7..8ae3434 100644
---- a/README.md
++++ b/README.md
@@@ -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).
ddiff --git a/STATUS.json b/STATUS.json
iindex 8ebdbff..57af901 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 25760dc..337a27a 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 7564758..b71acea 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/public/css/c4.css b/public/css/c4.css
iindex 63986f3..234c67b 100644
---- a/public/css/c4.css
++++ b/public/css/c4.css
@@@ -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}
ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 32a4d7c..b3197f6 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -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}}
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex a7f248a..9cb6cee 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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 screen
document.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 post
const 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 post
e.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; }
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex 0ab6182..5696b56 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -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">${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 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">${f
const [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">${f
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('');
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,
ddiff --git a/test/c4.test.js b/test/c4.test.js
iindex 9cdebfa..a1b07c2 100644
---- a/test/c4.test.js
++++ b/test/c4.test.js
@@@ -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&#39;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');
ddiff --git a/test/chat-boost.test.js b/test/chat-boost.test.js
iindex 5d655b4..8b3831c 100644
---- a/test/chat-boost.test.js
++++ b/test/chat-boost.test.js
@@@ -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&rsquo;s search work on a page that boost swapped in, not only on the first', async () => {
++ await check('the panel, the rail&rsquo;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-si
assert.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); });
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex 80424de..f612fa4 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -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');