openvibers/openvibe.codes Commits
Codes is a chat app: conversations on the left, the conversation in the middle, what it did at the side (L31 CH1) (#202)
Changes
nnew file mode 100644@@@ -0,0 +1,225 @@++/* 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}++.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}++++/* ── The rail ── */++.ch-rail{display:flex;flex-direction:column;min-height:0;background:var(--ch-rail);border-right:1px solid var(--ch-line)}++.ch-rail-top{display:flex;gap:6px;padding:12px 10px 6px}++.ch-new{flex:1;display:flex;align-items:center;gap:9px;padding:9px 12px;border-radius:12px;color:var(--ch-ink);text-decoration:none;font-weight:600;font-size:14px;border:1px solid var(--ch-line);background:var(--ch-card)}++.ch-new:hover{border-color:color-mix(in srgb,var(--ch-accent) 45%,var(--ch-line))}++.ch-find{position:relative;margin:4px 10px 6px}++.ch-find .ch-i{position:absolute;left:10px;top:50%;translate:0 -50%;width:15px;height:15px;color:var(--ch-muted)}++.ch-find input{width:100%;box-sizing:border-box;padding:8px 10px 8px 32px;border-radius:10px;border:1px solid transparent;background:var(--ch-hover);color:var(--ch-ink);font:inherit;font-size:13.5px}++.ch-find input:focus{outline:none;border-color:color-mix(in srgb,var(--ch-accent) 55%,transparent)}++.ch-list{flex:1;min-height:0;overflow-y:auto;padding:2px 8px 10px;scrollbar-width:thin}++.ch-group{margin:14px 8px 4px;font-size:11.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--ch-muted)}++.ch-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:10px;color:var(--ch-soft);text-decoration:none;font-size:14px;line-height:1.3;min-height:36px}++.ch-item:hover{background:var(--ch-hover);color:var(--ch-ink)}++.ch-item.is-here{background:var(--ch-hover);color:var(--ch-ink);font-weight:600}++.ch-item-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}++.ch-item-m{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ch-muted);font-weight:500}++.ch-item-cta{color:var(--ch-accent);font-weight:600}++.ch-empty-rail{margin:16px 8px;color:var(--ch-muted);font-size:13px}++.ch-rail-foot{display:grid;grid-template-columns:repeat(5,1fr);gap:2px;padding:8px;border-top:1px solid var(--ch-line)}++.ch-rail-foot a{display:grid;justify-items:center;gap:3px;padding:7px 2px;border-radius:10px;color:var(--ch-muted);text-decoration:none;font-size:10.5px;font-weight:600}++.ch-rail-foot a:hover{background:var(--ch-hover);color:var(--ch-ink)}++++/* 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}++.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-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-rail-open,.ch-side-open,.ch-icon-btn.ch-rail-close{display:none}++.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-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}++.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}++.ch-res-fold summary:hover{background:var(--ch-hover)}++.ch-res-fold summary .ch-caret{margin-left:auto}++.ch-res-fold ul{list-style:none;margin:0 0 6px;padding:0 10px 4px 37px;display:grid;gap:4px;font-size:13px}++.ch-res-fold li{display:flex;align-items:center;gap:8px;min-width:0}++.ch-res-fold li code{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}++.ch-res-fold li .ch-i{width:15px;height:15px}++.ch-res-fold li.is-ok .ch-i{color:var(--ch-ok)}.ch-res-fold li.is-bad .ch-i{color:var(--ch-bad)}++.ch-res-fold li span{color:var(--ch-muted)}++.ch-res-line a{color:var(--ch-accent);font-weight:600}++.ch-res-line.ch-muted{font-size:13.5px}++.ch-proof li{flex-wrap:wrap}++.ch-proof em{font-style:normal;font-size:12px;font-weight:650;color:var(--ch-muted)}++.ch-proof li.is-ok em{color:var(--ch-ok)}.ch-proof li.is-bad em{color:var(--ch-bad)}++.ch-proof li>span{flex-basis:100%;padding-left:23px}++.ch-shots{list-style:none;margin:2px 0 0;padding:0 0 0 23px;flex-basis:100%;display:grid;gap:2px;font-size:12.5px;color:var(--ch-muted)}++.ch-shots li.is-bad{color:var(--ch-bad)}++.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)}++.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-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}++.ch-pick select:focus{outline:none}++.ch-pick:focus-within{border-color:color-mix(in srgb,var(--ch-accent) 55%,var(--ch-line))}++.ch-pick .ch-caret{position:absolute;right:8px;pointer-events:none}++.ch-pick .ch-i:not(.ch-caret){width:15px;height:15px}++.ch-pick-repo input{width:min(260px,52vw);border:0;background:transparent;color:inherit;font:inherit;font-size:13px}++.ch-pick-repo input:focus{outline:none}++.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}++++/* 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}++.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-ideas{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:10px}++.ch-recent{margin-top:2px}++.ch-recent .ch-chip{font-size:12.5px;padding:4px 10px;color:var(--ch-muted)}++++/* ── 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)}++++/* 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. */++@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-rail{position:fixed;z-index:10060;top:0;left:0;bottom:0;width:min(320px,86vw);translate:-100% 0;transition:translate .25s ease;box-shadow:12px 0 32px rgba(0,0,0,.25)}++ .ch.rail-open .ch-rail{translate:0 0}++ .ch-rail-open,.ch-icon-btn.ch-rail-close{display:inline-grid}++ .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 input{width:46vw}++ .ch.is-empty .ch-thread{padding-top:56px}++}++.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-rail,.ch-side{transition:none}.ch-thread{scroll-behavior:auto}}nnew file mode 100644@@@ -0,0 +1,185 @@++'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.++(() => {++ const app = document.querySelector('[data-chat]');++ if (!app) return;++ const $ = (sel, root = document) => root.querySelector(sel);++++ // ── The rail and the side on narrow screens ──++ const scrim = $('[data-ch-scrim]', app);++ const setOpen = (cls, on) => {++ app.classList.toggle(cls, on);++ const any = app.classList.contains('rail-open') || app.classList.contains('side-open');++ if (scrim) scrim.hidden = !any;++ if (on) { const first = $(cls === 'rail-open' ? '.ch-rail a, .ch-rail input' : '.ch-side button', app); if (first) first.focus({ preventScroll: true }); }++ };++ app.addEventListener('click', (e) => {++ const t = e.target.closest('[data-ch-rail-open],[data-ch-rail-close],[data-ch-side-open],[data-ch-side-close],[data-ch-scrim]');++ if (!t) return;++ if (t.matches('[data-ch-rail-open]')) setOpen('rail-open', true);++ else if (t.matches('[data-ch-side-open]')) setOpen('side-open', true);++ else { setOpen('rail-open', false); setOpen('side-open', false); }++ });++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { setOpen('rail-open', false); setOpen('side-open', false); } });++++ // ── The rail's search: filter as you type (Enter still searches every task) ──++ const find = $('[data-ch-find]', app);++ if (find) {++ find.addEventListener('input', () => {++ const q = find.value.trim().toLowerCase();++ app.querySelectorAll('.ch-list .ch-item').forEach((a) => { a.hidden = !!q && !a.textContent.toLowerCase().includes(q); });++ app.querySelectorAll('.ch-list .ch-group').forEach((g) => {++ let n = g.nextElementSibling, shown = false;++ while (n && !n.classList.contains('ch-group')) { if (n.classList.contains('ch-item') && !n.hidden) shown = true; n = n.nextElementSibling; }++ g.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);++ 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();++ });++ app.querySelectorAll('[data-ch-idea]').forEach((b) => b.addEventListener('click', () => { text.value = b.dataset.chIdea; grow(); text.focus(); }));++ // 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(); } }));++ if (repo && repo.dataset.chRepoUrl) {++ const list = document.getElementById(repo.getAttribute('list'));++ let timer = 0, seq = 0;++ repo.addEventListener('input', () => {++ clearTimeout(timer);++ timer = setTimeout(async () => {++ const q = repo.value.trim();++ if (!list || q.length < 2 || /^https?:\/\//i.test(q)) return;++ const mine = ++seq;++ try {++ const res = await fetch(`${repo.dataset.chRepoUrl}?q=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });++ if (!res.ok || mine !== seq) return;++ const doc = await res.json();++ if (mine !== seq) return;++ list.textContent = '';++ (doc.repos || []).forEach((r) => { const o = document.createElement('option'); o.value = r.url; o.label = r.full_name + (r.recent ? ' · used here' : ''); list.appendChild(o); });++ } catch { /* the typed URL still works */ }++ }, 250);++ });++ }++ // 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 (form) {++ const images = $('[data-ch-images]', form);++ if (images) images.addEventListener('change', () => {++ const n = (images.files || []).length;++ images.closest('.ch-pick-file').classList.toggle('has-files', n > 0);++ say(n ? `${n} image${n === 1 ? '' : 's'} will go with your message` : '');++ });++ form.addEventListener('submit', async (e) => {++ const submitter = e.submitter;++ 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'); });++ 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…`);++ const asData = (file) => new Promise((resolve) => { const r = new FileReader(); r.onload = () => resolve({ name: file.name, mime: file.type, data: String(r.result || '') }); r.onerror = () => resolve(null); r.readAsDataURL(file); });++ try {++ const attachments = (await Promise.all(files.map(asData))).filter(Boolean);++ const res = await fetch(form.dataset.chApi, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ text: text.value, attachments }) });++ const doc = await res.json().catch(() => ({}));++ if (!res.ok) throw new Error(doc.detail || `the message was refused (${res.status})`);++ location.reload();++ } catch (err) {++ say(String((err && err.message) || 'the message could not be sent'), true);++ form.classList.remove('is-sending');++ form.querySelectorAll('button').forEach((b) => { b.disabled = false; });++ }++ });++ }++++ // ── 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;++ const kindOf = (name) => {++ const n = String(name || '').toLowerCase();++ if (/^(read|view|cat|ls|glob|list|open)|read_file|list_dir/.test(n)) return 'read';++ if (/(grep|search|find|rg)/.test(n) && !/web/.test(n)) return 'find';++ if (/(edit|write|patch|apply|create|replace|multiedit|str_replace)/.test(n)) return 'edit';++ if (/(bash|shell|exec|run|command|terminal)/.test(n)) return 'run';++ if (/(web|fetch|browse|http|url)/.test(n)) return 'web';++ 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 = new EventSource(stream);++ let reloading = false;++ const reload = () => { if (reloading) return; reloading = true; es.close(); setTimeout(() => location.reload(), 500); };++ es.addEventListener('line', (ev) => {++ 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;++ } 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; }++ // The stream replays every attempt on connect: only the live one ending, or a newer one starting, is news.++ if ((d.type === 'attempt_ended' && attemptN && Number(d.attempt_n) === attemptN) || (d.type === 'attempt_started' && attemptN && Number(d.attempt_n) > attemptN)) reload();++ });++ es.addEventListener('end', reload);++})();@@@ -247,6 +247,8 @@ async function createApp(opts = {}) {if (req.path !== '/.well-known/agent.json' && !twin) return next();return agentLayer(req, res, next);});++ // The chat app (L31 CH1) answers a signed-in person's / and /tasks/:id; everyone else falls through to C4.++ app.use(require('./http/chat').createChatRoutes(ctx));// C4 workspace pages sit before the legacy marketing pages and share the existing owner-scoped task store.app.use(require('./http/c4').createC4Routes(ctx));app.use(pageRoutes);nnew file mode 100644@@@ -0,0 +1,449 @@++'use strict';++++/**++ * Codes as a chat app (plan L31, CH1; the owner, 2026-10-30: "the conversation system in ChatGPT or Claude Code:++ * sessions on the left, jump between them, each has its own conversation… you change the agent in the corner").++ *++ * For a signed-in person, `/` and `/tasks/:id` are one app: the rail of conversations on the left (the conductor++ * pinned, the rest by when they last moved), the conversation in the middle with the composer under it, and what the++ * work produced at the side (who worked on it, the changes, the proof, the pull request). Everything is rendered++ * here and works without JavaScript (every action is a form post); public/js/chat.js streams a running turn into the++ * thread, grows the composer and opens the rail and the side on a phone.++ *++ * A conversation is a run_tasks row: a coding task (its first message is the brief, each "carry on" is a turn, a++ * hand-over to another agent is a divider) or the conductor (each message is a turn of one Claude Code session).++ * A visitor who is not signed in falls through to the product's own home (server/http/c4.js).++ */++const express = require('express');++const { send } = require('../render/layout');++const { raw, esc } = require('../render/html');++const { markdown } = require('../render/markdown');++const { asyncRouter } = require('./router');++const { sessionLogOf } = require('../domain/run-tasks');++++const LIVE = ['queued', 'placed', 'running'];++const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));++++// Icons: one stroke family (24 grid, 1.8 stroke, round), drawn here so the page needs no icon font.++const P = {++ new: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>',++ search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',++ conductor: '<circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M7.7 7.8 10.6 16M16.3 7.8 13.4 16M8.5 6h7"/>',++ agents: '<rect x="4" y="7" width="16" height="12" rx="4"/><path d="M12 3v4M9 12h.01M15 12h.01M9.5 16h5"/>',++ machines: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>',++ memory: '<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 3 3h0a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3Z"/><path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-3 3h0a3 3 0 0 1-3-3"/>',++ usage: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',++ settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',++ send: '<path d="M12 19V5M5 12l7-7 7 7"/>',++ stop: '<rect x="7" y="7" width="10" height="10" rx="2"/>',++ rail: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="M9 4v16"/>',++ side: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="M15 4v16"/>',++ read: '<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>',++ edit: '<path d="M4 20h4L19 9l-4-4L4 16Z"/><path d="m13.5 6.5 4 4"/>',++ run: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/>',++ web: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>',++ find: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',++ tool: '<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2.4-.6-.6-2.4Z"/>',++ sub: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18"/>',++ handover: '<path d="M4 8h13l-3-3M20 16H7l3 3"/>',++ ok: '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',++ bad: '<circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/>',++ files: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"/><path d="M14 3v5h5M10 13h4M12 11v4M10 18h4"/>',++ proof: '<path d="M12 3 5 6v6c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6Z"/><path d="m9 12 2 2 4-4"/>',++ pr: '<circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M6 8.5v7M18 15.5V9a3 3 0 0 0-3-3h-4m0 0 2-2m-2 2 2 2"/>',++ attach: '<path d="m21 11-8.6 8.6a5 5 0 0 1-7-7L14 4a3.3 3.3 0 0 1 4.7 4.7L10 17.4a1.7 1.7 0 0 1-2.4-2.4L15.5 7"/>',++ folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',++ repo: '<path d="M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3Z"/><path d="M5 17a3 3 0 0 1 3-3h11"/>',++ chevron: '<path d="m6 9 6 6 6-6"/>',++ close: '<path d="m6 6 12 12M18 6 6 18"/>',++};++const ico = (name, cls = '') => `<svg class="ch-i${cls ? ` ${cls}` : ''}" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${P[name] || P.tool}</svg>`;++++// Each agent's face: its initials in its own colour, so a hand-over in the thread is visible at a glance.++const AGENTS = {++ 'claude-code': ['Claude Code', 'C', '#d97757'], codex: ['Codex', 'Cx', '#10a37f'], opencode: ['OpenCode', 'O', '#7c6cff'],++ 'command-code': ['Command Code', 'Cc', '#f59e0b'], aider: ['Aider', 'A', '#14b8a6'], deepseek: ['DeepSeek', 'D', '#4f7cff'],++ minimax: ['MiniMax', 'M', '#ec4899'], openai: ['Your model', 'Y', '#64748b'],++};++const agentOf = (id, names) => {++ const known = AGENTS[String(id || '').split(':')[0]];++ 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>`;++++// A tool call's kind, by the names the agents give their tools.++function stepKind(name) {++ const n = String(name || '').toLowerCase();++ if (/^(read|view|cat|ls|glob|list|open)|read_file|list_dir/.test(n)) return 'read';++ if (/(grep|search|find|rg)/.test(n) && !/web/.test(n)) return 'find';++ if (/(edit|write|patch|apply|create|replace|multiedit|str_replace)/.test(n)) return 'edit';++ if (/(bash|shell|exec|run|command|terminal)/.test(n)) return 'run';++ if (/(web|fetch|browse|http|url)/.test(n)) return 'web';++ if (/(task|agent|delegate|subagent)/.test(n)) return 'sub';++ return 'tool';++}++++const when = (value) => {++ // run_tasks keeps epoch milliseconds (bigint, read as a string); the attempts and lines keep timestamps.++ const ms = value instanceof Date ? value.getTime() : /^\d{10,}$/.test(String(value)) ? Number(value) : Date.parse(String(value || ''));++ return Number.isFinite(ms) ? ms : 0;++};++const ago = (value) => {++ const ms = when(value);++ if (!ms) return '';++ const mins = Math.max(0, Math.floor((Date.now() - ms) / 60000));++ return mins < 1 ? 'now' : mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.floor(mins / 60)}h` : `${Math.floor(mins / 1440)}d`;++};++const minutes = (ms) => (ms < 60000 ? `${Math.max(1, Math.round(ms / 1000))} s` : `${Math.round(ms / 60000)} min`);++const firstLine = (s, n = 64) => {++ const line = String(s || '').split('\n').map((l) => l.replace(/^#+\s*/, '').trim()).find(Boolean) || '';++ return line.length > n ? `${line.slice(0, n - 1)}…` : line;++};++const folderName = (f) => String(f || '').replace(/\/+$/, '').split('/').pop() || 'a folder';++const titleOf = (row) => (row.kind === 'conductor' ? `Conductor · ${folderName(row.folder)}` : (row.title || firstLine(row.prompt) || 'New task'));++const stateOf = (row) => {++ if (LIVE.includes(row.state)) return row.state === 'queued' ? ['Getting ready', 'busy'] : ['Working', 'busy'];++ if (row.outcome === 'stopped' || row.state === 'cancelled') return ['Stopped', 'idle'];++ if (row.outcome === 'unfinished' || row.state === 'failed' || row.error) return ["Didn't finish", 'bad'];++ if (row.kind === 'conductor') return ['Waiting for you', 'ok'];++ return ['Done', 'ok'];++};++const asObject = (v) => {++ if (v && typeof v === 'object' && !Array.isArray(v)) return v;++ if (typeof v === 'string') { try { const p = JSON.parse(v); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; } }++ return null;++};++const repoLabel = (repo) => { try { return new URL(String(repo)).pathname.split('/').filter(Boolean).slice(-2).join('/').replace(/\.git$/i, ''); } catch { return ''; } };++++function createChatRoutes(ctx) {++ const r = asyncRouter();++ const { config, store, harnesses, tasks, conductor, approvals, userSettings, github } = ctx;++ const db = store.db;++ const form = express.urlencoded({ extended: false, limit: '32kb' });++ const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const sameOrigin = (req) => {++ const origin = req.get('origin');++ if (!origin) return true;++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }++ };++ const agentNames = () => new Map((harnesses?.list?.() || []).map((a) => [a.id, a.name]));++++ // ── The rail ─────────────────────────────────────────────────────────────────────────────────────────────────++ async function conversations(subject) {++ return db.many(++ `SELECT id, kind, state, outcome, error, prompt, folder, agent, model, repo, created_at, updated_at, finished_at++ FROM run_tasks WHERE subject = $1 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 120`, [subject],++ );++ }++ function railHtml(rows, currentId, waitingIds) {++ const now = new Date();++ const day0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();++ const bucket = (row) => {++ const t = when(row.updated_at || row.created_at);++ return t >= day0 ? 'Today' : t >= day0 - 86400000 ? 'Yesterday' : t >= day0 - 6 * 86400000 ? 'This week' : 'Earlier';++ };++ const names = agentNames();++ const item = (row) => {++ const [word, tone] = stateOf(row);++ const needs = waitingIds.has(row.id);++ const dot = needs ? '<span class="ch-dot is-needs" title="Needs you"></span>' : tone === 'busy' ? `<span class="ch-dot is-busy" title="${esc(word)}"></span>` : tone === 'bad' ? `<span class="ch-dot is-bad" title="${esc(word)}"></span>` : '';++ const face = row.kind === 'conductor' ? `<span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span>` : avatar(agentOf(row.agent, names));++ return `<a class="ch-item${row.id === currentId ? ' is-here' : ''}" href="/tasks/${encodeURIComponent(row.id)}"${row.id === currentId ? ' aria-current="page"' : ''}>${face}<span class="ch-item-t">${esc(titleOf(row))}</span><span class="ch-item-m">${dot}${esc(ago(row.updated_at || row.created_at))}</span></a>`;++ };++ const pinned = rows.filter((x) => x.kind === 'conductor' && x.outcome !== 'stopped').slice(0, 2);++ const rest = rows.filter((x) => !pinned.includes(x));++ const groups = [];++ for (const row of rest) {++ const b = bucket(row);++ if (!groups.length || groups[groups.length - 1][0] !== b) groups.push([b, []]);++ groups[groups.length - 1][1].push(row);++ }++ const foot = [['Agents', '/agents', 'agents'], ['Machines', '/machines', 'machines'], ['Memory', '/memory', 'memory'], ['Usage', '/usage', 'usage'], ['Settings', '/settings', 'settings']]++ .map(([label, href, i]) => `<a href="${href}" title="${label}">${ico(i)}<span>${label}</span></a>`).join('');++ return `<aside class="ch-rail" id="ch-rail" aria-label="Your conversations">++<div class="ch-rail-top"><a class="ch-new" href="/">${ico('new')}<span>New chat</span></a><button class="ch-icon-btn ch-rail-close" type="button" data-ch-rail-close aria-label="Close the list">${ico('close')}</button></div>++<form class="ch-find" role="search" method="get" action="/tasks"><label class="ch-sr" for="ch-find">Search your conversations</label>${ico('search')}<input id="ch-find" name="q" placeholder="Search" autocomplete="off" data-ch-find></form>++<nav class="ch-list" aria-label="Conversations">++${pinned.length ? `<p class="ch-group">Pinned</p>${pinned.map(item).join('')}` : `<a class="ch-item ch-item-cta" href="/conductor"><span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span><span class="ch-item-t">Start your conductor</span></a>`}++${groups.map(([label, list]) => `<p class="ch-group">${label}</p>${list.map(item).join('')}`).join('')}++${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</p>'}++</nav>++<nav class="ch-rail-foot" aria-label="Codes">${foot}</nav>++</aside>`;++ }++++ // ── 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>`;++ }++ /** 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 } = {}) {++ const steps = [];++ const said = [];++ let done = null;++ for (const l of lines) {++ const b = asObject(l.body) || { t: 'text', text: String(l.body || '') };++ const t = b.t || l.kind;++ if (t === 'tool') steps.push({ id: b.id, name: b.name || 'tool', what: b.what || '', kind: stepKind(b.name), ok: null });++ else if (t === 'tool_result') { const s = steps.slice().reverse().find((x) => x.id && x.id === b.id) || steps[steps.length - 1]; if (s) s.ok = b.ok !== false; }++ else if (t === 'text' && b.text) said.push(String(b.text));++ else if (t === 'done') done = b;++ else if (t === 'note' && b.text) said.push(`> ${String(b.text)}`);++ }++ const first = lines[0] && when(lines[0].at), last = lines.length && when(lines[lines.length - 1].at);++ const ms = first && last ? last - first : 0;++ // 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 who = `<div class="ch-who">${avatar(a)}<b>${esc(a.name)}</b>${attempt && attempt.model ? `<span>${esc(attempt.model)}</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>`;++ }++ async function taskThread(row) {++ const names = agentNames();++ const attempts = tasks?.attempts ? (await tasks.attempts.list(row.id)).map((a) => ({ ...a, ...(tasks.attempts.present ? tasks.attempts.present(a) : {}) })) : [];++ let lines = await db.many('SELECT seq, at, kind, body, attempt_n FROM codes_session_lines WHERE task_id = $1 ORDER BY seq ASC LIMIT 4000', [row.id]);++ 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);++ 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 (a.kind === 'manual' && note) out.push(you(note));++ else 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) out.push(divider(String(a.why), 'handover'));++ }++ 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 }));++ prev = a;++ }++ if (!attempts.length) out.push(agentTurn(agentOf(row.agent, names), lines, { live }));++ if (!live) out.push(await resultCard(row));++ return out.join('');++ }++ async function conductorThread(row, view) {++ const out = [];++ const names = agentNames();++ const a = agentOf('claude-code', names);++ const live = LIVE.includes(row.state);++ const msgs = view.messages || [];++ 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' : ''));++ 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 } }));++ } 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'));++ }++ for (const ap of view.approvals || []) out.push(approvalCard(row, ap));++ return out.join('');++ }++ 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>`;++++ /** 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) {++ const result = asObject(row.result) || {};++ const files = Array.isArray(result.files) ? result.files.filter((f) => f && f.path) : [];++ const patch = typeof result.patch === 'string' ? result.patch : '';++ const push = asObject(result.push) || {};++ const proof = tasks?.proof ? tasks.proof.present(await tasks.proof.list(row.id)) : [];++ const [word, tone] = stateOf(row);++ const fold = (icon, head, body) => `<details class="ch-res-fold"><summary>${ico(icon)}${head}${ico('chevron', 'ch-caret')}</summary>${body}</details>`;++ const parts = [];++ if (tone === 'bad') parts.push(`<p class="ch-res-line is-bad">${ico('bad')}<span><b>${esc(word)}</b> ${esc(row.outcome_note || row.error || '')}</span></p>`);++ if (files.length || patch) {++ const added = files.reduce((n, f) => n + (Number(f.added) || 0), 0), removed = files.reduce((n, f) => n + (Number(f.removed) || 0), 0);++ parts.push(fold('files', `<b>${files.length} file${files.length === 1 ? '' : 's'} changed</b><span class="ch-add">+${added}</span><span class="ch-del">−${removed}</span>`,++ `<ul>${files.map((f) => `<li><code>${esc(f.path)}</code><span class="ch-add">+${esc(f.added ?? '·')}</span><span class="ch-del">−${esc(f.removed ?? '·')}</span></li>`).join('')}</ul>${patch ? `<details class="ch-diff"><summary>The diff${result.patch_truncated ? ' (cut to fit)' : ` · ${patch.split('\n').length} lines`}</summary><pre tabindex="0">${esc(patch)}</pre></details>` : ''}`));++ }++ if (proof.length) {++ const passed = proof.filter((p) => p.ok && !p.skipped).length;++ const item = (p) => {++ const state = p.skipped ? 'Skipped' : p.ok ? 'Passed' : 'Failed';++ const detail = asObject(p.detail) || {};++ const shots = p.kind === 'screenshots' && Array.isArray(detail.shots)++ ? `<ul class="ch-shots">${detail.shots.map((x) => `<li class="${x.ok === false ? 'is-bad' : ''}">${esc(`${x.route || '/'} at ${Number(x.width) || 0} px`)}${x.summary ? `: ${esc(x.summary)}` : ''}</li>`).join('')}</ul>` : '';++ return `<li class="${p.skipped ? '' : p.ok ? 'is-ok' : 'is-bad'}">${ico(p.skipped ? 'tool' : p.ok ? 'ok' : 'bad')}<b>${esc(p.label || p.kind)}</b><em>${state}</em><span>${esc(p.summary || '')}</span>${shots}</li>`;++ };++ parts.push(fold('proof', `<b>Proof</b><span class="ch-muted">${passed} of ${proof.length} passed</span>`, `<ul class="ch-proof">${proof.map(item).join('')}</ul>`));++ } else parts.push(`<p class="ch-res-line ch-muted">${ico('proof')}<span>This task has no proof: it ended before proof was collected.</span></p>`);++ if (push.pull_request) parts.push(`<p class="ch-res-line">${ico('pr')}<a href="${esc(push.pull_request)}" rel="noopener noreferrer">The pull request</a>${push.branch ? `<span class="ch-muted">${esc(push.branch)}</span>` : ''}</p>`);++ else {++ const why = push.pull_request_error ? `The pull request was not opened: ${push.pull_request_error}`++ : 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>`;++ }++++ // ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────++ function agentPicker(selected, { label = 'Agent', auto = 'Auto' } = {}) {++ const list = harnesses?.list?.() || [];++ return `<label class="ch-pick" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span><select name="agent" data-ch-agent><option value="">${esc(auto)}</option>${list.map((a) => `<option value="${esc(a.id)}"${selected === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;++ }++ function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', hidden = '', note = '' }) {++ return `<form class="ch-composer" 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>++${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p class="ch-note" data-ch-status role="status"></p>'}++</form>`;++ }++++ // ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────++ async function frame(req, res, { currentId = null, title, head = '', thread = '', composerHtml = '', side = '', streamUrl = '', empty = false }) {++ 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' : ''}" data-chat>++${railHtml(rows, currentId, waitingIds)}++<div class="ch-scrim" data-ch-scrim hidden></div>++<section class="ch-main" aria-label="Conversation">++<header class="ch-head"><button class="ch-icon-btn ch-rail-open" type="button" data-ch-rail-open aria-label="Your conversations" aria-controls="ch-rail">${ico('rail')}</button>${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>++<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/chat.css'], scripts: ['js/chat.js'], body: raw(body) });++ }++++ /** The side: 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,++ ].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>`;++ }++++ // A new conversation: the greeting, the repository and the agent, and the composer.++ r.get('/', async (req, res, next) => {++ if (!viewer(req)) return next();++ const settings = await userSettings.get(req.viewer.subject).catch(() => ({}));++ const repos = github ? await github.recent(req.viewer.subject, 6).catch(() => []) : [];++ const repo = String(req.query.repo || (repos[0] && repos[0].url) || '');++ const error = String(req.query.error || '').slice(0, 200);++ const name = String(req.viewer.displayName || req.viewer.username || '').split(' ')[0];++ const ideas = ['Fix the failing tests', 'Explain how this project is laid out', 'Make the home page faster on phones', 'Find and fix one bug'];++ const pickers = `<label class="ch-pick ch-pick-repo" title="Repository"><span class="ch-sr">Repository</span>${ico('repo')}<input name="repo" type="url" value="${esc(repo)}" placeholder="Repository URL" list="ch-repos" autocomplete="url" required data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>${agentPicker(settings.default_agent || '')}`;++ 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 colleague. Codes picks the agent, runs it on your machine, and shows you what changed.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}<div class="ch-ideas" aria-label="Ideas">${ideas.map((t) => `<button class="ch-chip" type="button" data-ch-idea="${esc(t)}">${esc(t)}</button>`).join('')}</div>${repos.length > 1 ? `<div class="ch-ideas ch-recent" aria-label="Your recent repositories">${repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('')}</div>` : ''}</div>`;++ return frame(req, res, {++ title: 'New chat', empty: true, head: '<h1 class="ch-title">New chat</h1>', thread,++ composerHtml: composer({ action: '/chat', placeholder: 'Describe the change, question or fix', pickers }),++ });++ });++++ /** Start a conversation: the brief becomes a task on the person's project, and the page is its conversation. */++ r.post('/chat', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2F');++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ const b = req.body || {};++ const back = (msg) => res.redirect(303, `/?error=${encodeURIComponent(msg)}${b.repo ? `&repo=${encodeURIComponent(String(b.repo).slice(0, 300))}` : ''}`);++ const text = String(b.text || '').trim().slice(0, 16000);++ if (!text) return back('Write what you want done first.');++ const agent = String(b.agent || '').trim();++ if (agent && !(harnesses?.list?.() || []).some((a) => a.id === agent)) return back('Choose an agent from the list.');++ let args;++ try {++ const { parseArgs } = require('../../harness/job');++ args = parseArgs(JSON.stringify({ repo: String(b.repo || '').trim(), prompt: text, agent: agent || null, permission: 'edit' }));++ } catch (e) { return back(`Check the repository: ${e.message}.`); }++ let project = null;++ try { project = (await tasks.latestProject?.(req.viewer.subject)) || await conductor.projectFor(req.viewer.token || null, req.viewer.subject); } catch { project = null; }++ if (!project) return back('Codes could not find or make a project for you on OpenVibe.Services: sign in again and retry.');++ if (agent) await userSettings.save(req.viewer.subject, { default_agent: agent }).catch(() => {});++ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args });++ return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);++ });++++ // One conversation.++ r.get('/tasks/:id', async (req, res, next) => {++ if (!viewer(req)) return next();++ if (!safeId(req.params.id)) return next();++ const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);++ if (!row) return next();++ 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>`;++ 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),++ 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.' : '' }),++ });++ }++ // The stream starts after the last line this page already shows.++ const seqRow = live ? await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]) : null;++ const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;++ const pickers = agentPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` });++ 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),++ 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,++ stopAction: `/tasks/${encodeURIComponent(row.id)}/stop`,++ placeholder: live ? 'Wait for this turn, or stop it' : 'Ask for a change, or tell it what to do next', pickers,++ }),++ });++ });++++ /** The next turn of a coding task: the same agent carries on with the note, or another one takes over. */++ r.post('/tasks/:id/continue', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ if (!safeId(req.params.id)) return res.status(404).send('Not found');++ const row = await db.maybe("SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND kind <> 'conductor'", [req.params.id, req.viewer.subject]);++ if (!row) return res.status(404).send('Not found');++ const b = req.body || {};++ const agent = String(b.agent || '').trim() || null;++ if (agent && !(harnesses?.list?.() || []).some((a) => a.id === agent)) return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent('Choose an agent from the list.')}`);++ const out = await tasks.continueTask(row, { note: String(b.text || '').slice(0, 4000), agent });++ return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}${out.error ? `?error=${encodeURIComponent(out.error)}` : ''}`);++ });++++ /** Stop the running turn. */++ r.post('/tasks/:id/stop', form, async (req, res) => {++ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');++ if (!safeId(req.params.id)) return res.status(404).send('Not found');++ const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);++ if (!row) return res.status(404).send('Not found');++ const out = await tasks.stopTask(row);++ return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}${out.error ? `?error=${encodeURIComponent(out.error)}` : ''}`);++ });++++ return r;++}++++module.exports = { createChatRoutes, stepKind, titleOf, stateOf };@@@ -79,7 +79,10 @@ function renderPage(o) {// the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the// product's links, because the sections hold the visitor's own work and a visitor has none yet.const here = (href) => pathOnly === href || (href !== '/' && pathOnly.startsWith(`${href}/`));-- const links = (signedIn && c4Workspace)++ // A conversation (a signed-in /, /tasks/:id) is the chat app: its rail is the navigation, so the bar carries none.++ const chatPage = signedIn && (pathOnly === '/' || /^\/tasks\/tsk_[0-9A-Z]{26}$/.test(pathOnly));++ const links = chatPage ? []++ : (signedIn && c4Workspace)? [...WORKSPACE_NAV.map((l) => ({ ...l, active: here(l.href) })),// The catalogue of harnesses is the product's Agents page; the workspace's own Agents page is above, so the@@@ -29,30 +29,24 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(hidden.status, 404);});-- await check('signed-in workspace and task details use the reviewed layout', async () => {++ await check('a signed-in person’s home and tasks are the chat app (plan L31)', async () => {const home = await t.get('/', { as: ada });assert.ok(!home.text.includes('Built for the way you work'));assert.ok(!home.text.includes('Recently shipped on'));-- assert.ok(!home.text.includes('OPENVIBE.CODES'));-- assert.ok(!home.text.includes('Your coding workspace'));-- assert.ok(home.text.includes('Paste a repository URL'));++ assert.ok(home.text.includes('What should we build') && home.text.includes('Repository URL'), 'the greeting and the repository');++ assert.ok(home.text.includes('action="/chat"') && home.text.includes('data-ch-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('Show the whole brief'));-- assert.ok(task.text.includes('aria-selected="true" data-c4-tab="live"'));-- assert.ok(task.text.includes('Retry') && task.text.includes('Switch') && task.text.includes('Fine'));-- assert.ok(task.text.includes('data-task-api="/api/v1/tasks/' + id + '"'));-- assert.ok(task.text.includes('data-c4-stream="/api/v1/tasks/' + id + '/events"'));-- assert.ok(!task.text.includes('Continue or switch agent in Run'));-- assert.ok(!task.text.includes('>Stop task</button>'));-- assert.ok(!task.text.includes('<pre class="c4-log" aria-live="polite"></pre>'));-- assert.ok(task.text.includes('>OpenVibe.Codes</a>'));-- assert.ok(!task.text.includes('>https://github.com/'));-- assert.ok(!task.text.includes('<details open id="live"'));-- assert.ok(!task.text.includes('<details id="changes"'));++ 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("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');++ assert.ok(task.text.includes('>OpenVibers/OpenVibe.Codes</a>'), 'the repository by its name');++ assert.ok(!task.text.includes('>https://github.com/'), 'never a bare URL');++ assert.ok(!task.text.includes('data-ch-stream'), 'a finished task does not stream');});} finally { await t.close(); }done();@@@ -39,9 +39,10 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';const codes = home.text.indexOf('https://github.com/OpenVibers/OpenVibe.Codes');assert.ok(shared > -1 && codes > -1, 'both repositories are offered');assert.ok(shared < codes, 'the last one used comes first');-- assert.ok(home.text.includes('<option value="' + NODE + '">Desk'), 'the machine picker offers the paired machine');-- assert.ok(home.text.includes('data-c4-repo-search'), 'the repository field searches');-- assert.ok(home.text.includes('data-c4-saved-repo="https://github.com/OpenVibers/OpenVibe.Shared"'), 'and the saved ones are a pill away');++ assert.ok(home.text.includes('data-ch-repo-url="/api/v1/me/repos"'), 'the repository field searches');++ assert.ok(home.text.includes('data-ch-saved-repo="https://github.com/OpenVibers/OpenVibe.Shared"'), 'and the recent ones are a tap away');++ // A task cannot be pinned to a machine yet (Run's pin is a Bot device): the default machine is chosen in Settings.++ assert.ok(!home.text.includes('<option value="' + NODE + '">Desk'), 'the chat offers no machine it could not honour');});await check('the machine picker is in the order of use, on Home and on Settings', async () => {@@@ -49,7 +50,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';await db.exec(`INSERT INTO codes_attempts (id, task_id, n, agent, machine, state, started_at, finished_at, created_at)VALUES ('att_01ARZ3NDEKTSV4RRFFQ69G5FAC',$1,2,'codex',$2,'done', now() - interval '2 hours', now() - interval '2 hours', now() - interval '2 hours')`, [TASK2, NODE]);await t.ctx.userSettings.save(ada.subject, { default_machine: OTHER });-- for (const page of ['/', '/settings']) {++ for (const page of ['/settings']) {const shown = await t.get(page, { as: ada });assert.strictEqual(shown.status, 200);const desk = shown.text.indexOf(`<option value="${NODE}">Desk`);@@@ -84,10 +85,10 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';assert.strictEqual(page.status, 200, page.text.slice(0, 200));// Changes: the files first, then the diff folded out.assert.ok(page.text.includes('server/http/c4.js') && page.text.includes('public/css/c4.css'), 'the changed files are listed');-- assert.ok(page.text.includes('<span class="c4-add">+42</span>') || page.text.includes('c4-add'), 'with their counts');++ assert.ok(page.text.includes('<span class="ch-add">+42</span>'), 'with their counts');assert.ok(page.text.includes('The diff') && page.text.includes('+the change'), 'and the diff itself');// Proof: one calm row per item, each with its own state.-- assert.ok(page.text.includes('data-proof-kind') || page.text.includes('c4-row-label">tests'), 'a proof row per item');++ assert.ok(page.text.includes('<b>tests</b>') && page.text.includes('<b>screenshots</b>') && page.text.includes('<b>review</b>'), 'a proof row per item');assert.ok(page.text.includes('Passed') && page.text.includes('Skipped') && page.text.includes('Failed'), 'each row carries its state');assert.ok(page.text.includes('390 px') && page.text.includes('1440 px'), 'the shots are listed by width and route');assert.ok(page.text.includes('the page is 1500px wide'), 'and what the shot found');@@@ -89,12 +89,12 @@ const split = (links) => ({assert.deepStrictEqual(bar.map((l) => l.label), ['Home', 'Start', 'Run', 'Agents'], 'and they are the product’s');});-- await check('a card’s muted note sits under its heading, not beside it', async () => {++ await check('a signed-in person’s home is a new chat, not a page of cards (plan L31)', async () => {const page = await t.get('/', { as: ada });assert.strictEqual(page.status, 200);-- assert.ok(page.text.includes('<h2>Needs you</h2></div><p class="c4-sub">Approvals and questions</p>'),-- 'the note is the card’s subtitle');-- assert.ok(!page.text.includes('<h2>Needs you</h2><span'), 'and not a second title beside the heading');++ assert.ok(page.text.includes('What should we build') && page.text.includes('action="/chat"'), 'the greeting and the composer');++ assert.ok(!page.text.includes('class="c4-card'), 'no cards');++ assert.ok(page.text.includes('aria-label="Your conversations"'), 'the rail of conversations is the navigation');});} finally { await t.close(); }done();nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++++// Codes as a chat app (plan L31 CH1): a signed-in person's / and /tasks/:id are one app. The rail holds their own++// 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 { boot, check, done } = require('./helpers/boot');++const { stepKind } = require('../server/http/chat');++++(async () => {++ const t = await boot();++ const ada = t.network.addUser('chat-ada');++ const bo = t.network.addUser('chat-bo');++ const db = t.ctx.store.db;++ const now = Date.now();++ const P = 'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW';++ const MINE = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FB1', THEIRS = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FB2';++ try {++ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, created_at, updated_at) VALUES++ ($1, $2, $5, 'https://github.com/OpenVibers/OpenVibe.Live', 'main', 'Fit the home page to a 360 px phone', 'claude-code', 'succeeded', '{"files":[{"path":"public/css/home.css","added":3,"removed":1}]}'::jsonb, $6, $6),++ ($3, $4, $5, 'https://github.com/OpenVibers/OpenVibe.Meme', 'main', 'Someone else entirely', 'codex', 'succeeded', '{}'::jsonb, $6, $6)`,++ [MINE, ada.subject, THEIRS, bo.subject, P, now - 60_000]);++ await db.exec(`INSERT INTO codes_attempts (id, task_id, n, agent, model, state, reason, why, kind, created_at) VALUES++ ('att_01ARZ3NDEKTSV4RRFFQ69G5FC1', $1, 1, 'codex', 'gpt-5.5', 'failed', 'limit', 'the router picked Codex', 'first', now() - interval '50 minutes'),++ ('att_01ARZ3NDEKTSV4RRFFQ69G5FC2', $1, 2, 'claude-code', 'opus', 'done', null, 'Codex hit its limit', 'switch', now() - interval '40 minutes'),++ ('att_01ARZ3NDEKTSV4RRFFQ69G5FC3', $1, 3, 'claude-code', 'opus', 'done', null, 'carried on by the owner: Keep the titles on one line.', 'manual', now() - interval '30 minutes')`, [MINE]);++ await db.exec(`INSERT INTO codes_session_lines (task_id, seq, at, kind, body, attempt_n) VALUES++ ($1, 1, now() - interval '49 minutes', 'tool', '{"t":"tool","name":"Read","what":"public/css/home.css"}', 1),++ ($1, 2, now() - interval '39 minutes', 'tool', '{"t":"tool","name":"Edit","what":"public/css/home.css"}', 2),++ ($1, 3, now() - interval '38 minutes', 'done', '{"t":"done","ok":true,"text":"The clips rail now **scrolls sideways**."}', 2),++ ($1, 4, now() - interval '29 minutes', 'done', '{"t":"done","ok":true,"text":"Titles stay on one line."}', 3)`, [MINE]);++++ await check('the rail holds the person’s own conversations, and only theirs', async () => {++ const page = await t.get('/', { as: ada });++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('Fit the home page to a 360 px phone'), 'their conversation is in the rail');++ assert.ok(!page.text.includes('Someone else entirely'), 'another person’s is not');++ assert.ok(page.text.includes('<p class="ch-group">Today</p>'), 'grouped by when it last moved');++ const theirs = await t.get(`/tasks/${THEIRS}`, { as: ada });++ assert.notStrictEqual(theirs.status, 200, 'another person’s conversation does not open');++ });++++ await check('the thread: the brief, each agent’s turn, a hand-over and the next message', async () => {++ 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 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 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('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');++ });++++ await check('a new chat starts a task from the composer, and an empty one is refused in words', async () => {++ const empty = await t.get('/chat', { as: ada, form: { text: ' ', repo: 'https://github.com/OpenVibers/OpenVibe.Live' } });++ assert.strictEqual(empty.status, 303);++ assert.match(empty.headers.get('location'), /^\/\?error=Write%20what%20you%20want%20done%20first/);++ const made = await t.get('/chat', { as: ada, form: { text: 'Make the footer quieter', repo: 'https://github.com/OpenVibers/OpenVibe.Live', agent: '' } });++ assert.strictEqual(made.status, 303, made.text.slice(0, 200));++ const at = made.headers.get('location');++ assert.match(at, /^\/tasks\/tsk_[0-9A-Z]{26}$/, `opens the new conversation (${at})`);++ const row = await db.maybe('SELECT prompt, subject FROM run_tasks WHERE id = $1', [at.split('/').pop()]);++ assert.ok(row && row.prompt === 'Make the footer quieter' && row.subject === ada.subject, 'the brief is the person’s own task');++ });++++ await check('a visitor who is not signed in gets the product’s home, not the app', async () => {++ const page = await t.get('/');++ assert.strictEqual(page.status, 200);++ assert.ok(!page.text.includes('data-chat'), 'no chat app');++ });++++ await check('the step kinds name what an agent did, whatever the agent calls its tools', async () => {++ const kinds = { Read: 'read', read_file: 'read', Glob: 'read', Grep: 'find', Edit: 'edit', apply_patch: 'edit', Write: 'edit', Bash: 'run', shell: 'run', WebFetch: 'web', WebSearch: 'web', Task: 'sub', mcp__codes__fleet: 'tool' };++ for (const [name, kind] of Object.entries(kinds)) assert.strictEqual(stepKind(name), kind, name);++ });++ } finally { await t.close(); }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -216,7 +216,7 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';const res = await get(`/tasks/${taskId}`);assert.strictEqual(res.status, 200);assert.match(res.text, /Hand-over brief/, 'the first turn is labelled');-- assert.match(res.text, /data-c4-conductor-stream/, 'the transcript streams over the C2 events');++ assert.match(res.text, /data-ch-stream="\/api\/v1\/tasks\/[^"]+\/events/, 'the conversation streams over the C2 events');assert.match(res.text, /What next\?|Say something/, 'the box for the next turn');});
new file mode 100644
new file mode 100644
@@ -0,0 +1,225 @@
@@ -0,0 +1,225 @@
+/* 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}
+.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}
+
+/* ── The rail ── */
+.ch-rail{display:flex;flex-direction:column;min-height:0;background:var(--ch-rail);border-right:1px solid var(--ch-line)}
+.ch-rail-top{display:flex;gap:6px;padding:12px 10px 6px}
+.ch-new{flex:1;display:flex;align-items:center;gap:9px;padding:9px 12px;border-radius:12px;color:var(--ch-ink);text-decoration:none;font-weight:600;font-size:14px;border:1px solid var(--ch-line);background:var(--ch-card)}
+.ch-new:hover{border-color:color-mix(in srgb,var(--ch-accent) 45%,var(--ch-line))}
+.ch-find{position:relative;margin:4px 10px 6px}
+.ch-find .ch-i{position:absolute;left:10px;top:50%;translate:0 -50%;width:15px;height:15px;color:var(--ch-muted)}
+.ch-find input{width:100%;box-sizing:border-box;padding:8px 10px 8px 32px;border-radius:10px;border:1px solid transparent;background:var(--ch-hover);color:var(--ch-ink);font:inherit;font-size:13.5px}
+.ch-find input:focus{outline:none;border-color:color-mix(in srgb,var(--ch-accent) 55%,transparent)}
+.ch-list{flex:1;min-height:0;overflow-y:auto;padding:2px 8px 10px;scrollbar-width:thin}
+.ch-group{margin:14px 8px 4px;font-size:11.5px;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--ch-muted)}
+.ch-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:10px;color:var(--ch-soft);text-decoration:none;font-size:14px;line-height:1.3;min-height:36px}
+.ch-item:hover{background:var(--ch-hover);color:var(--ch-ink)}
+.ch-item.is-here{background:var(--ch-hover);color:var(--ch-ink);font-weight:600}
+.ch-item-t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.ch-item-m{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ch-muted);font-weight:500}
+.ch-item-cta{color:var(--ch-accent);font-weight:600}
+.ch-empty-rail{margin:16px 8px;color:var(--ch-muted);font-size:13px}
+.ch-rail-foot{display:grid;grid-template-columns:repeat(5,1fr);gap:2px;padding:8px;border-top:1px solid var(--ch-line)}
+.ch-rail-foot a{display:grid;justify-items:center;gap:3px;padding:7px 2px;border-radius:10px;color:var(--ch-muted);text-decoration:none;font-size:10.5px;font-weight:600}
+.ch-rail-foot a:hover{background:var(--ch-hover);color:var(--ch-ink)}
+
+/* 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}
+.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-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-rail-open,.ch-side-open,.ch-icon-btn.ch-rail-close{display:none}
+.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-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}
+.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}
+.ch-res-fold summary:hover{background:var(--ch-hover)}
+.ch-res-fold summary .ch-caret{margin-left:auto}
+.ch-res-fold ul{list-style:none;margin:0 0 6px;padding:0 10px 4px 37px;display:grid;gap:4px;font-size:13px}
+.ch-res-fold li{display:flex;align-items:center;gap:8px;min-width:0}
+.ch-res-fold li code{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.ch-res-fold li .ch-i{width:15px;height:15px}
+.ch-res-fold li.is-ok .ch-i{color:var(--ch-ok)}.ch-res-fold li.is-bad .ch-i{color:var(--ch-bad)}
+.ch-res-fold li span{color:var(--ch-muted)}
+.ch-res-line a{color:var(--ch-accent);font-weight:600}
+.ch-res-line.ch-muted{font-size:13.5px}
+.ch-proof li{flex-wrap:wrap}
+.ch-proof em{font-style:normal;font-size:12px;font-weight:650;color:var(--ch-muted)}
+.ch-proof li.is-ok em{color:var(--ch-ok)}.ch-proof li.is-bad em{color:var(--ch-bad)}
+.ch-proof li>span{flex-basis:100%;padding-left:23px}
+.ch-shots{list-style:none;margin:2px 0 0;padding:0 0 0 23px;flex-basis:100%;display:grid;gap:2px;font-size:12.5px;color:var(--ch-muted)}
+.ch-shots li.is-bad{color:var(--ch-bad)}
+.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)}
+.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-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}
+.ch-pick select:focus{outline:none}
+.ch-pick:focus-within{border-color:color-mix(in srgb,var(--ch-accent) 55%,var(--ch-line))}
+.ch-pick .ch-caret{position:absolute;right:8px;pointer-events:none}
+.ch-pick .ch-i:not(.ch-caret){width:15px;height:15px}
+.ch-pick-repo input{width:min(260px,52vw);border:0;background:transparent;color:inherit;font:inherit;font-size:13px}
+.ch-pick-repo input:focus{outline:none}
+.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}
+
+/* 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}
+.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-ideas{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin-top:10px}
+.ch-recent{margin-top:2px}
+.ch-recent .ch-chip{font-size:12.5px;padding:4px 10px;color:var(--ch-muted)}
+
+/* ── 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)}
+
+/* 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. */
+@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-rail{position:fixed;z-index:10060;top:0;left:0;bottom:0;width:min(320px,86vw);translate:-100% 0;transition:translate .25s ease;box-shadow:12px 0 32px rgba(0,0,0,.25)}
+ .ch.rail-open .ch-rail{translate:0 0}
+ .ch-rail-open,.ch-icon-btn.ch-rail-close{display:inline-grid}
+ .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 input{width:46vw}
+ .ch.is-empty .ch-thread{padding-top:56px}
+}
+.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-rail,.ch-side{transition:none}.ch-thread{scroll-behavior:auto}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
+'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.
+(() => {
+ const app = document.querySelector('[data-chat]');
+ if (!app) return;
+ const $ = (sel, root = document) => root.querySelector(sel);
+
+ // ── The rail and the side on narrow screens ──
+ const scrim = $('[data-ch-scrim]', app);
+ const setOpen = (cls, on) => {
+ app.classList.toggle(cls, on);
+ const any = app.classList.contains('rail-open') || app.classList.contains('side-open');
+ if (scrim) scrim.hidden = !any;
+ if (on) { const first = $(cls === 'rail-open' ? '.ch-rail a, .ch-rail input' : '.ch-side button', app); if (first) first.focus({ preventScroll: true }); }
+ };
+ app.addEventListener('click', (e) => {
+ const t = e.target.closest('[data-ch-rail-open],[data-ch-rail-close],[data-ch-side-open],[data-ch-side-close],[data-ch-scrim]');
+ if (!t) return;
+ if (t.matches('[data-ch-rail-open]')) setOpen('rail-open', true);
+ else if (t.matches('[data-ch-side-open]')) setOpen('side-open', true);
+ else { setOpen('rail-open', false); setOpen('side-open', false); }
+ });
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { setOpen('rail-open', false); setOpen('side-open', false); } });
+
+ // ── The rail's search: filter as you type (Enter still searches every task) ──
+ const find = $('[data-ch-find]', app);
+ if (find) {
+ find.addEventListener('input', () => {
+ const q = find.value.trim().toLowerCase();
+ app.querySelectorAll('.ch-list .ch-item').forEach((a) => { a.hidden = !!q && !a.textContent.toLowerCase().includes(q); });
+ app.querySelectorAll('.ch-list .ch-group').forEach((g) => {
+ let n = g.nextElementSibling, shown = false;
+ while (n && !n.classList.contains('ch-group')) { if (n.classList.contains('ch-item') && !n.hidden) shown = true; n = n.nextElementSibling; }
+ g.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);
+ 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();
+ });
+ app.querySelectorAll('[data-ch-idea]').forEach((b) => b.addEventListener('click', () => { text.value = b.dataset.chIdea; grow(); text.focus(); }));
+ // 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(); } }));
+ if (repo && repo.dataset.chRepoUrl) {
+ const list = document.getElementById(repo.getAttribute('list'));
+ let timer = 0, seq = 0;
+ repo.addEventListener('input', () => {
+ clearTimeout(timer);
+ timer = setTimeout(async () => {
+ const q = repo.value.trim();
+ if (!list || q.length < 2 || /^https?:\/\//i.test(q)) return;
+ const mine = ++seq;
+ try {
+ const res = await fetch(`${repo.dataset.chRepoUrl}?q=${encodeURIComponent(q)}`, { headers: { Accept: 'application/json' }, credentials: 'same-origin' });
+ if (!res.ok || mine !== seq) return;
+ const doc = await res.json();
+ if (mine !== seq) return;
+ list.textContent = '';
+ (doc.repos || []).forEach((r) => { const o = document.createElement('option'); o.value = r.url; o.label = r.full_name + (r.recent ? ' · used here' : ''); list.appendChild(o); });
+ } catch { /* the typed URL still works */ }
+ }, 250);
+ });
+ }
+ // 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 (form) {
+ const images = $('[data-ch-images]', form);
+ if (images) images.addEventListener('change', () => {
+ const n = (images.files || []).length;
+ images.closest('.ch-pick-file').classList.toggle('has-files', n > 0);
+ say(n ? `${n} image${n === 1 ? '' : 's'} will go with your message` : '');
+ });
+ form.addEventListener('submit', async (e) => {
+ const submitter = e.submitter;
+ 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'); });
+ 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…`);
+ const asData = (file) => new Promise((resolve) => { const r = new FileReader(); r.onload = () => resolve({ name: file.name, mime: file.type, data: String(r.result || '') }); r.onerror = () => resolve(null); r.readAsDataURL(file); });
+ try {
+ const attachments = (await Promise.all(files.map(asData))).filter(Boolean);
+ const res = await fetch(form.dataset.chApi, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ text: text.value, attachments }) });
+ const doc = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(doc.detail || `the message was refused (${res.status})`);
+ location.reload();
+ } catch (err) {
+ say(String((err && err.message) || 'the message could not be sent'), true);
+ form.classList.remove('is-sending');
+ form.querySelectorAll('button').forEach((b) => { b.disabled = false; });
+ }
+ });
+ }
+
+ // ── 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;
+ const kindOf = (name) => {
+ const n = String(name || '').toLowerCase();
+ if (/^(read|view|cat|ls|glob|list|open)|read_file|list_dir/.test(n)) return 'read';
+ if (/(grep|search|find|rg)/.test(n) && !/web/.test(n)) return 'find';
+ if (/(edit|write|patch|apply|create|replace|multiedit|str_replace)/.test(n)) return 'edit';
+ if (/(bash|shell|exec|run|command|terminal)/.test(n)) return 'run';
+ if (/(web|fetch|browse|http|url)/.test(n)) return 'web';
+ 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 = new EventSource(stream);
+ let reloading = false;
+ const reload = () => { if (reloading) return; reloading = true; es.close(); setTimeout(() => location.reload(), 500); };
+ es.addEventListener('line', (ev) => {
+ 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;
+ } 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; }
+ // The stream replays every attempt on connect: only the live one ending, or a newer one starting, is news.
+ if ((d.type === 'attempt_ended' && attemptN && Number(d.attempt_n) === attemptN) || (d.type === 'attempt_started' && attemptN && Number(d.attempt_n) > attemptN)) reload();
+ });
+ es.addEventListener('end', reload);
+})();
@@ -247,6 +247,8 @@ async function createApp(opts = {}) {
@@ -247,6 +247,8 @@ async function createApp(opts = {}) {
if (req.path !== '/.well-known/agent.json' && !twin) return next();
if (req.path !== '/.well-known/agent.json' && !twin) return next();
return agentLayer(req, res, next);
return agentLayer(req, res, next);
});
});
+ // The chat app (L31 CH1) answers a signed-in person's / and /tasks/:id; everyone else falls through to C4.
+ app.use(require('./http/chat').createChatRoutes(ctx));
// C4 workspace pages sit before the legacy marketing pages and share the existing owner-scoped task store.
// C4 workspace pages sit before the legacy marketing pages and share the existing owner-scoped task store.
app.use(require('./http/c4').createC4Routes(ctx));
app.use(require('./http/c4').createC4Routes(ctx));
app.use(pageRoutes);
app.use(pageRoutes);
new file mode 100644
new file mode 100644
@@ -0,0 +1,449 @@
@@ -0,0 +1,449 @@
+'use strict';
+
+/**
+ * Codes as a chat app (plan L31, CH1; the owner, 2026-10-30: "the conversation system in ChatGPT or Claude Code:
+ * sessions on the left, jump between them, each has its own conversation… you change the agent in the corner").
+ *
+ * For a signed-in person, `/` and `/tasks/:id` are one app: the rail of conversations on the left (the conductor
+ * pinned, the rest by when they last moved), the conversation in the middle with the composer under it, and what the
+ * work produced at the side (who worked on it, the changes, the proof, the pull request). Everything is rendered
+ * here and works without JavaScript (every action is a form post); public/js/chat.js streams a running turn into the
+ * thread, grows the composer and opens the rail and the side on a phone.
+ *
+ * A conversation is a run_tasks row: a coding task (its first message is the brief, each "carry on" is a turn, a
+ * hand-over to another agent is a divider) or the conductor (each message is a turn of one Claude Code session).
+ * A visitor who is not signed in falls through to the product's own home (server/http/c4.js).
+ */
+const express = require('express');
+const { send } = require('../render/layout');
+const { raw, esc } = require('../render/html');
+const { markdown } = require('../render/markdown');
+const { asyncRouter } = require('./router');
+const { sessionLogOf } = require('../domain/run-tasks');
+
+const LIVE = ['queued', 'placed', 'running'];
+const safeId = (s) => /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(s || ''));
+
+// Icons: one stroke family (24 grid, 1.8 stroke, round), drawn here so the page needs no icon font.
+const P = {
+ new: '<path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
+ search: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
+ conductor: '<circle cx="6" cy="6" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M7.7 7.8 10.6 16M16.3 7.8 13.4 16M8.5 6h7"/>',
+ agents: '<rect x="4" y="7" width="16" height="12" rx="4"/><path d="M12 3v4M9 12h.01M15 12h.01M9.5 16h5"/>',
+ machines: '<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>',
+ memory: '<path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-2 5 3 3 0 0 0 2 5 3 3 0 0 0 3 3h0a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3Z"/><path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-3 3h0a3 3 0 0 1-3-3"/>',
+ usage: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
+ settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
+ send: '<path d="M12 19V5M5 12l7-7 7 7"/>',
+ stop: '<rect x="7" y="7" width="10" height="10" rx="2"/>',
+ rail: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="M9 4v16"/>',
+ side: '<rect x="3" y="4" width="18" height="16" rx="3"/><path d="M15 4v16"/>',
+ read: '<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>',
+ edit: '<path d="M4 20h4L19 9l-4-4L4 16Z"/><path d="m13.5 6.5 4 4"/>',
+ run: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/>',
+ web: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>',
+ find: '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
+ tool: '<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2.4-.6-.6-2.4Z"/>',
+ sub: '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M18 6l-2.5 2.5M8.5 15.5 6 18"/>',
+ handover: '<path d="M4 8h13l-3-3M20 16H7l3 3"/>',
+ ok: '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
+ bad: '<circle cx="12" cy="12" r="9"/><path d="m9 9 6 6M15 9l-6 6"/>',
+ files: '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z"/><path d="M14 3v5h5M10 13h4M12 11v4M10 18h4"/>',
+ proof: '<path d="M12 3 5 6v6c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6Z"/><path d="m9 12 2 2 4-4"/>',
+ pr: '<circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M6 8.5v7M18 15.5V9a3 3 0 0 0-3-3h-4m0 0 2-2m-2 2 2 2"/>',
+ attach: '<path d="m21 11-8.6 8.6a5 5 0 0 1-7-7L14 4a3.3 3.3 0 0 1 4.7 4.7L10 17.4a1.7 1.7 0 0 1-2.4-2.4L15.5 7"/>',
+ folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
+ repo: '<path d="M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3Z"/><path d="M5 17a3 3 0 0 1 3-3h11"/>',
+ chevron: '<path d="m6 9 6 6 6-6"/>',
+ close: '<path d="m6 6 12 12M18 6 6 18"/>',
+};
+const ico = (name, cls = '') => `<svg class="ch-i${cls ? ` ${cls}` : ''}" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">${P[name] || P.tool}</svg>`;
+
+// Each agent's face: its initials in its own colour, so a hand-over in the thread is visible at a glance.
+const AGENTS = {
+ 'claude-code': ['Claude Code', 'C', '#d97757'], codex: ['Codex', 'Cx', '#10a37f'], opencode: ['OpenCode', 'O', '#7c6cff'],
+ 'command-code': ['Command Code', 'Cc', '#f59e0b'], aider: ['Aider', 'A', '#14b8a6'], deepseek: ['DeepSeek', 'D', '#4f7cff'],
+ minimax: ['MiniMax', 'M', '#ec4899'], openai: ['Your model', 'Y', '#64748b'],
+};
+const agentOf = (id, names) => {
+ const known = AGENTS[String(id || '').split(':')[0]];
+ 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>`;
+
+// A tool call's kind, by the names the agents give their tools.
+function stepKind(name) {
+ const n = String(name || '').toLowerCase();
+ if (/^(read|view|cat|ls|glob|list|open)|read_file|list_dir/.test(n)) return 'read';
+ if (/(grep|search|find|rg)/.test(n) && !/web/.test(n)) return 'find';
+ if (/(edit|write|patch|apply|create|replace|multiedit|str_replace)/.test(n)) return 'edit';
+ if (/(bash|shell|exec|run|command|terminal)/.test(n)) return 'run';
+ if (/(web|fetch|browse|http|url)/.test(n)) return 'web';
+ if (/(task|agent|delegate|subagent)/.test(n)) return 'sub';
+ return 'tool';
+}
+
+const when = (value) => {
+ // run_tasks keeps epoch milliseconds (bigint, read as a string); the attempts and lines keep timestamps.
+ const ms = value instanceof Date ? value.getTime() : /^\d{10,}$/.test(String(value)) ? Number(value) : Date.parse(String(value || ''));
+ return Number.isFinite(ms) ? ms : 0;
+};
+const ago = (value) => {
+ const ms = when(value);
+ if (!ms) return '';
+ const mins = Math.max(0, Math.floor((Date.now() - ms) / 60000));
+ return mins < 1 ? 'now' : mins < 60 ? `${mins}m` : mins < 1440 ? `${Math.floor(mins / 60)}h` : `${Math.floor(mins / 1440)}d`;
+};
+const minutes = (ms) => (ms < 60000 ? `${Math.max(1, Math.round(ms / 1000))} s` : `${Math.round(ms / 60000)} min`);
+const firstLine = (s, n = 64) => {
+ const line = String(s || '').split('\n').map((l) => l.replace(/^#+\s*/, '').trim()).find(Boolean) || '';
+ return line.length > n ? `${line.slice(0, n - 1)}…` : line;
+};
+const folderName = (f) => String(f || '').replace(/\/+$/, '').split('/').pop() || 'a folder';
+const titleOf = (row) => (row.kind === 'conductor' ? `Conductor · ${folderName(row.folder)}` : (row.title || firstLine(row.prompt) || 'New task'));
+const stateOf = (row) => {
+ if (LIVE.includes(row.state)) return row.state === 'queued' ? ['Getting ready', 'busy'] : ['Working', 'busy'];
+ if (row.outcome === 'stopped' || row.state === 'cancelled') return ['Stopped', 'idle'];
+ if (row.outcome === 'unfinished' || row.state === 'failed' || row.error) return ["Didn't finish", 'bad'];
+ if (row.kind === 'conductor') return ['Waiting for you', 'ok'];
+ return ['Done', 'ok'];
+};
+const asObject = (v) => {
+ if (v && typeof v === 'object' && !Array.isArray(v)) return v;
+ if (typeof v === 'string') { try { const p = JSON.parse(v); return p && typeof p === 'object' && !Array.isArray(p) ? p : null; } catch { return null; } }
+ return null;
+};
+const repoLabel = (repo) => { try { return new URL(String(repo)).pathname.split('/').filter(Boolean).slice(-2).join('/').replace(/\.git$/i, ''); } catch { return ''; } };
+
+function createChatRoutes(ctx) {
+ const r = asyncRouter();
+ const { config, store, harnesses, tasks, conductor, approvals, userSettings, github } = ctx;
+ const db = store.db;
+ const form = express.urlencoded({ extended: false, limit: '32kb' });
+ const viewer = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const sameOrigin = (req) => {
+ const origin = req.get('origin');
+ if (!origin) return true;
+ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
+ };
+ const agentNames = () => new Map((harnesses?.list?.() || []).map((a) => [a.id, a.name]));
+
+ // ── The rail ─────────────────────────────────────────────────────────────────────────────────────────────────
+ async function conversations(subject) {
+ return db.many(
+ `SELECT id, kind, state, outcome, error, prompt, folder, agent, model, repo, created_at, updated_at, finished_at
+ FROM run_tasks WHERE subject = $1 ORDER BY COALESCE(updated_at, created_at) DESC LIMIT 120`, [subject],
+ );
+ }
+ function railHtml(rows, currentId, waitingIds) {
+ const now = new Date();
+ const day0 = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
+ const bucket = (row) => {
+ const t = when(row.updated_at || row.created_at);
+ return t >= day0 ? 'Today' : t >= day0 - 86400000 ? 'Yesterday' : t >= day0 - 6 * 86400000 ? 'This week' : 'Earlier';
+ };
+ const names = agentNames();
+ const item = (row) => {
+ const [word, tone] = stateOf(row);
+ const needs = waitingIds.has(row.id);
+ const dot = needs ? '<span class="ch-dot is-needs" title="Needs you"></span>' : tone === 'busy' ? `<span class="ch-dot is-busy" title="${esc(word)}"></span>` : tone === 'bad' ? `<span class="ch-dot is-bad" title="${esc(word)}"></span>` : '';
+ const face = row.kind === 'conductor' ? `<span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span>` : avatar(agentOf(row.agent, names));
+ return `<a class="ch-item${row.id === currentId ? ' is-here' : ''}" href="/tasks/${encodeURIComponent(row.id)}"${row.id === currentId ? ' aria-current="page"' : ''}>${face}<span class="ch-item-t">${esc(titleOf(row))}</span><span class="ch-item-m">${dot}${esc(ago(row.updated_at || row.created_at))}</span></a>`;
+ };
+ const pinned = rows.filter((x) => x.kind === 'conductor' && x.outcome !== 'stopped').slice(0, 2);
+ const rest = rows.filter((x) => !pinned.includes(x));
+ const groups = [];
+ for (const row of rest) {
+ const b = bucket(row);
+ if (!groups.length || groups[groups.length - 1][0] !== b) groups.push([b, []]);
+ groups[groups.length - 1][1].push(row);
+ }
+ const foot = [['Agents', '/agents', 'agents'], ['Machines', '/machines', 'machines'], ['Memory', '/memory', 'memory'], ['Usage', '/usage', 'usage'], ['Settings', '/settings', 'settings']]
+ .map(([label, href, i]) => `<a href="${href}" title="${label}">${ico(i)}<span>${label}</span></a>`).join('');
+ return `<aside class="ch-rail" id="ch-rail" aria-label="Your conversations">
+<div class="ch-rail-top"><a class="ch-new" href="/">${ico('new')}<span>New chat</span></a><button class="ch-icon-btn ch-rail-close" type="button" data-ch-rail-close aria-label="Close the list">${ico('close')}</button></div>
+<form class="ch-find" role="search" method="get" action="/tasks"><label class="ch-sr" for="ch-find">Search your conversations</label>${ico('search')}<input id="ch-find" name="q" placeholder="Search" autocomplete="off" data-ch-find></form>
+<nav class="ch-list" aria-label="Conversations">
+${pinned.length ? `<p class="ch-group">Pinned</p>${pinned.map(item).join('')}` : `<a class="ch-item ch-item-cta" href="/conductor"><span class="ch-av ch-av-conductor" aria-hidden="true">${ico('conductor')}</span><span class="ch-item-t">Start your conductor</span></a>`}
+${groups.map(([label, list]) => `<p class="ch-group">${label}</p>${list.map(item).join('')}`).join('')}
+${rows.length ? '' : '<p class="ch-empty-rail">Your conversations appear here.</p>'}
+</nav>
+<nav class="ch-rail-foot" aria-label="Codes">${foot}</nav>
+</aside>`;
+ }
+
+ // ── 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>`;
+ }
+ /** 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 } = {}) {
+ const steps = [];
+ const said = [];
+ let done = null;
+ for (const l of lines) {
+ const b = asObject(l.body) || { t: 'text', text: String(l.body || '') };
+ const t = b.t || l.kind;
+ if (t === 'tool') steps.push({ id: b.id, name: b.name || 'tool', what: b.what || '', kind: stepKind(b.name), ok: null });
+ else if (t === 'tool_result') { const s = steps.slice().reverse().find((x) => x.id && x.id === b.id) || steps[steps.length - 1]; if (s) s.ok = b.ok !== false; }
+ else if (t === 'text' && b.text) said.push(String(b.text));
+ else if (t === 'done') done = b;
+ else if (t === 'note' && b.text) said.push(`> ${String(b.text)}`);
+ }
+ const first = lines[0] && when(lines[0].at), last = lines.length && when(lines[lines.length - 1].at);
+ const ms = first && last ? last - first : 0;
+ // 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 who = `<div class="ch-who">${avatar(a)}<b>${esc(a.name)}</b>${attempt && attempt.model ? `<span>${esc(attempt.model)}</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>`;
+ }
+ async function taskThread(row) {
+ const names = agentNames();
+ const attempts = tasks?.attempts ? (await tasks.attempts.list(row.id)).map((a) => ({ ...a, ...(tasks.attempts.present ? tasks.attempts.present(a) : {}) })) : [];
+ let lines = await db.many('SELECT seq, at, kind, body, attempt_n FROM codes_session_lines WHERE task_id = $1 ORDER BY seq ASC LIMIT 4000', [row.id]);
+ 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);
+ 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 (a.kind === 'manual' && note) out.push(you(note));
+ else 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) out.push(divider(String(a.why), 'handover'));
+ }
+ 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 }));
+ prev = a;
+ }
+ if (!attempts.length) out.push(agentTurn(agentOf(row.agent, names), lines, { live }));
+ if (!live) out.push(await resultCard(row));
+ return out.join('');
+ }
+ async function conductorThread(row, view) {
+ const out = [];
+ const names = agentNames();
+ const a = agentOf('claude-code', names);
+ const live = LIVE.includes(row.state);
+ const msgs = view.messages || [];
+ 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' : ''));
+ 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 } }));
+ } 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'));
+ }
+ for (const ap of view.approvals || []) out.push(approvalCard(row, ap));
+ return out.join('');
+ }
+ 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>`;
+
+ /** 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) {
+ const result = asObject(row.result) || {};
+ const files = Array.isArray(result.files) ? result.files.filter((f) => f && f.path) : [];
+ const patch = typeof result.patch === 'string' ? result.patch : '';
+ const push = asObject(result.push) || {};
+ const proof = tasks?.proof ? tasks.proof.present(await tasks.proof.list(row.id)) : [];
+ const [word, tone] = stateOf(row);
+ const fold = (icon, head, body) => `<details class="ch-res-fold"><summary>${ico(icon)}${head}${ico('chevron', 'ch-caret')}</summary>${body}</details>`;
+ const parts = [];
+ if (tone === 'bad') parts.push(`<p class="ch-res-line is-bad">${ico('bad')}<span><b>${esc(word)}</b> ${esc(row.outcome_note || row.error || '')}</span></p>`);
+ if (files.length || patch) {
+ const added = files.reduce((n, f) => n + (Number(f.added) || 0), 0), removed = files.reduce((n, f) => n + (Number(f.removed) || 0), 0);
+ parts.push(fold('files', `<b>${files.length} file${files.length === 1 ? '' : 's'} changed</b><span class="ch-add">+${added}</span><span class="ch-del">−${removed}</span>`,
+ `<ul>${files.map((f) => `<li><code>${esc(f.path)}</code><span class="ch-add">+${esc(f.added ?? '·')}</span><span class="ch-del">−${esc(f.removed ?? '·')}</span></li>`).join('')}</ul>${patch ? `<details class="ch-diff"><summary>The diff${result.patch_truncated ? ' (cut to fit)' : ` · ${patch.split('\n').length} lines`}</summary><pre tabindex="0">${esc(patch)}</pre></details>` : ''}`));
+ }
+ if (proof.length) {
+ const passed = proof.filter((p) => p.ok && !p.skipped).length;
+ const item = (p) => {
+ const state = p.skipped ? 'Skipped' : p.ok ? 'Passed' : 'Failed';
+ const detail = asObject(p.detail) || {};
+ const shots = p.kind === 'screenshots' && Array.isArray(detail.shots)
+ ? `<ul class="ch-shots">${detail.shots.map((x) => `<li class="${x.ok === false ? 'is-bad' : ''}">${esc(`${x.route || '/'} at ${Number(x.width) || 0} px`)}${x.summary ? `: ${esc(x.summary)}` : ''}</li>`).join('')}</ul>` : '';
+ return `<li class="${p.skipped ? '' : p.ok ? 'is-ok' : 'is-bad'}">${ico(p.skipped ? 'tool' : p.ok ? 'ok' : 'bad')}<b>${esc(p.label || p.kind)}</b><em>${state}</em><span>${esc(p.summary || '')}</span>${shots}</li>`;
+ };
+ parts.push(fold('proof', `<b>Proof</b><span class="ch-muted">${passed} of ${proof.length} passed</span>`, `<ul class="ch-proof">${proof.map(item).join('')}</ul>`));
+ } else parts.push(`<p class="ch-res-line ch-muted">${ico('proof')}<span>This task has no proof: it ended before proof was collected.</span></p>`);
+ if (push.pull_request) parts.push(`<p class="ch-res-line">${ico('pr')}<a href="${esc(push.pull_request)}" rel="noopener noreferrer">The pull request</a>${push.branch ? `<span class="ch-muted">${esc(push.branch)}</span>` : ''}</p>`);
+ else {
+ const why = push.pull_request_error ? `The pull request was not opened: ${push.pull_request_error}`
+ : 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>`;
+ }
+
+ // ── The composer ─────────────────────────────────────────────────────────────────────────────────────────────
+ function agentPicker(selected, { label = 'Agent', auto = 'Auto' } = {}) {
+ const list = harnesses?.list?.() || [];
+ return `<label class="ch-pick" title="${esc(label)}"><span class="ch-sr">${esc(label)}</span><select name="agent" data-ch-agent><option value="">${esc(auto)}</option>${list.map((a) => `<option value="${esc(a.id)}"${selected === a.id ? ' selected' : ''}>${esc(a.name)}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
+ }
+ function composer({ action, api = '', placeholder, pickers = '', live = false, stopAction = '', hidden = '', note = '' }) {
+ return `<form class="ch-composer" 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>
+${note ? `<p class="ch-note" data-ch-status role="status">${note}</p>` : '<p class="ch-note" data-ch-status role="status"></p>'}
+</form>`;
+ }
+
+ // ── Pages ────────────────────────────────────────────────────────────────────────────────────────────────────
+ async function frame(req, res, { currentId = null, title, head = '', thread = '', composerHtml = '', side = '', streamUrl = '', empty = false }) {
+ 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' : ''}" data-chat>
+${railHtml(rows, currentId, waitingIds)}
+<div class="ch-scrim" data-ch-scrim hidden></div>
+<section class="ch-main" aria-label="Conversation">
+<header class="ch-head"><button class="ch-icon-btn ch-rail-open" type="button" data-ch-rail-open aria-label="Your conversations" aria-controls="ch-rail">${ico('rail')}</button>${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>
+<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/chat.css'], scripts: ['js/chat.js'], body: raw(body) });
+ }
+
+ /** The side: 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,
+ ].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>`;
+ }
+
+ // A new conversation: the greeting, the repository and the agent, and the composer.
+ r.get('/', async (req, res, next) => {
+ if (!viewer(req)) return next();
+ const settings = await userSettings.get(req.viewer.subject).catch(() => ({}));
+ const repos = github ? await github.recent(req.viewer.subject, 6).catch(() => []) : [];
+ const repo = String(req.query.repo || (repos[0] && repos[0].url) || '');
+ const error = String(req.query.error || '').slice(0, 200);
+ const name = String(req.viewer.displayName || req.viewer.username || '').split(' ')[0];
+ const ideas = ['Fix the failing tests', 'Explain how this project is laid out', 'Make the home page faster on phones', 'Find and fix one bug'];
+ const pickers = `<label class="ch-pick ch-pick-repo" title="Repository"><span class="ch-sr">Repository</span>${ico('repo')}<input name="repo" type="url" value="${esc(repo)}" placeholder="Repository URL" list="ch-repos" autocomplete="url" required data-ch-repo data-ch-repo-url="/api/v1/me/repos"><datalist id="ch-repos">${repos.map((x) => `<option value="${esc(x.url)}">${esc(x.full_name)}</option>`).join('')}</datalist></label>${agentPicker(settings.default_agent || '')}`;
+ 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 colleague. Codes picks the agent, runs it on your machine, and shows you what changed.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}<div class="ch-ideas" aria-label="Ideas">${ideas.map((t) => `<button class="ch-chip" type="button" data-ch-idea="${esc(t)}">${esc(t)}</button>`).join('')}</div>${repos.length > 1 ? `<div class="ch-ideas ch-recent" aria-label="Your recent repositories">${repos.map((x) => `<button class="ch-chip" type="button" data-ch-saved-repo="${esc(x.url)}">${ico('repo')}${esc(x.full_name)}</button>`).join('')}</div>` : ''}</div>`;
+ return frame(req, res, {
+ title: 'New chat', empty: true, head: '<h1 class="ch-title">New chat</h1>', thread,
+ composerHtml: composer({ action: '/chat', placeholder: 'Describe the change, question or fix', pickers }),
+ });
+ });
+
+ /** Start a conversation: the brief becomes a task on the person's project, and the page is its conversation. */
+ r.post('/chat', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, '/auth/login?next=%2F');
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ const b = req.body || {};
+ const back = (msg) => res.redirect(303, `/?error=${encodeURIComponent(msg)}${b.repo ? `&repo=${encodeURIComponent(String(b.repo).slice(0, 300))}` : ''}`);
+ const text = String(b.text || '').trim().slice(0, 16000);
+ if (!text) return back('Write what you want done first.');
+ const agent = String(b.agent || '').trim();
+ if (agent && !(harnesses?.list?.() || []).some((a) => a.id === agent)) return back('Choose an agent from the list.');
+ let args;
+ try {
+ const { parseArgs } = require('../../harness/job');
+ args = parseArgs(JSON.stringify({ repo: String(b.repo || '').trim(), prompt: text, agent: agent || null, permission: 'edit' }));
+ } catch (e) { return back(`Check the repository: ${e.message}.`); }
+ let project = null;
+ try { project = (await tasks.latestProject?.(req.viewer.subject)) || await conductor.projectFor(req.viewer.token || null, req.viewer.subject); } catch { project = null; }
+ if (!project) return back('Codes could not find or make a project for you on OpenVibe.Services: sign in again and retry.');
+ if (agent) await userSettings.save(req.viewer.subject, { default_agent: agent }).catch(() => {});
+ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args });
+ return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);
+ });
+
+ // One conversation.
+ r.get('/tasks/:id', async (req, res, next) => {
+ if (!viewer(req)) return next();
+ if (!safeId(req.params.id)) return next();
+ const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);
+ if (!row) return next();
+ 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>`;
+ 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),
+ 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.' : '' }),
+ });
+ }
+ // The stream starts after the last line this page already shows.
+ const seqRow = live ? await db.maybe('SELECT max(seq) AS seq FROM codes_session_lines WHERE task_id = $1', [row.id]) : null;
+ const lastSeq = seqRow && seqRow.seq != null ? Number(seqRow.seq) : 0;
+ const pickers = agentPicker('', { label: 'Who carries on', auto: `Same agent (${agentOf(row.agent, agentNames()).name})` });
+ 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),
+ 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,
+ stopAction: `/tasks/${encodeURIComponent(row.id)}/stop`,
+ placeholder: live ? 'Wait for this turn, or stop it' : 'Ask for a change, or tell it what to do next', pickers,
+ }),
+ });
+ });
+
+ /** The next turn of a coding task: the same agent carries on with the note, or another one takes over. */
+ r.post('/tasks/:id/continue', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ if (!safeId(req.params.id)) return res.status(404).send('Not found');
+ const row = await db.maybe("SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND kind <> 'conductor'", [req.params.id, req.viewer.subject]);
+ if (!row) return res.status(404).send('Not found');
+ const b = req.body || {};
+ const agent = String(b.agent || '').trim() || null;
+ if (agent && !(harnesses?.list?.() || []).some((a) => a.id === agent)) return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}?error=${encodeURIComponent('Choose an agent from the list.')}`);
+ const out = await tasks.continueTask(row, { note: String(b.text || '').slice(0, 4000), agent });
+ return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}${out.error ? `?error=${encodeURIComponent(out.error)}` : ''}`);
+ });
+
+ /** Stop the running turn. */
+ r.post('/tasks/:id/stop', form, async (req, res) => {
+ if (!viewer(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
+ if (!safeId(req.params.id)) return res.status(404).send('Not found');
+ const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2', [req.params.id, req.viewer.subject]);
+ if (!row) return res.status(404).send('Not found');
+ const out = await tasks.stopTask(row);
+ return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}${out.error ? `?error=${encodeURIComponent(out.error)}` : ''}`);
+ });
+
+ return r;
+}
+
+module.exports = { createChatRoutes, stepKind, titleOf, stateOf };
@@ -79,7 +79,10 @@ function renderPage(o) {
@@ -79,7 +79,10 @@ function renderPage(o) {
// the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the
// the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the
// product's links, because the sections hold the visitor's own work and a visitor has none yet.
// product's links, because the sections hold the visitor's own work and a visitor has none yet.
const here = (href) => pathOnly === href || (href !== '/' && pathOnly.startsWith(`${href}/`));
const here = (href) => pathOnly === href || (href !== '/' && pathOnly.startsWith(`${href}/`));
- const links = (signedIn && c4Workspace)
+ // A conversation (a signed-in /, /tasks/:id) is the chat app: its rail is the navigation, so the bar carries none.
+ const chatPage = signedIn && (pathOnly === '/' || /^\/tasks\/tsk_[0-9A-Z]{26}$/.test(pathOnly));
+ const links = chatPage ? []
+ : (signedIn && c4Workspace)
? [
? [
...WORKSPACE_NAV.map((l) => ({ ...l, active: here(l.href) })),
...WORKSPACE_NAV.map((l) => ({ ...l, active: here(l.href) })),
// The catalogue of harnesses is the product's Agents page; the workspace's own Agents page is above, so the
// The catalogue of harnesses is the product's Agents page; the workspace's own Agents page is above, so the
@@ -29,30 +29,24 @@ const { boot, check, done } = require('./helpers/boot');
@@ -29,30 +29,24 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(hidden.status, 404);
assert.strictEqual(hidden.status, 404);
});
});
- await check('signed-in workspace and task details use the reviewed layout', async () => {
+ await check('a signed-in person’s home and tasks are the chat app (plan L31)', async () => {
const home = await t.get('/', { as: ada });
const home = await t.get('/', { as: ada });
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('OPENVIBE.CODES'));
- assert.ok(!home.text.includes('Your coding workspace'));
- assert.ok(home.text.includes('Paste a repository URL'));
+ assert.ok(home.text.includes('What should we build') && home.text.includes('Repository URL'), 'the greeting and the repository');
+ assert.ok(home.text.includes('action="/chat"') && home.text.includes('data-ch-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('Show the whole brief'));
- assert.ok(task.text.includes('aria-selected="true" data-c4-tab="live"'));
- assert.ok(task.text.includes('Retry') && task.text.includes('Switch') && task.text.includes('Fine'));
- assert.ok(task.text.includes('data-task-api="/api/v1/tasks/' + id + '"'));
- assert.ok(task.text.includes('data-c4-stream="/api/v1/tasks/' + id + '/events"'));
- assert.ok(!task.text.includes('Continue or switch agent in Run'));
- assert.ok(!task.text.includes('>Stop task</button>'));
- assert.ok(!task.text.includes('<pre class="c4-log" aria-live="polite"></pre>'));
- assert.ok(task.text.includes('>OpenVibe.Codes</a>'));
- assert.ok(!task.text.includes('>https://github.com/'));
- assert.ok(!task.text.includes('<details open id="live"'));
- assert.ok(!task.text.includes('<details id="changes"'));
+ 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("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');
+ assert.ok(task.text.includes('>OpenVibers/OpenVibe.Codes</a>'), 'the repository by its name');
+ assert.ok(!task.text.includes('>https://github.com/'), 'never a bare URL');
+ assert.ok(!task.text.includes('data-ch-stream'), 'a finished task does not stream');
});
});
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
@@ -39,9 +39,10 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -39,9 +39,10 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
const codes = home.text.indexOf('https://github.com/OpenVibers/OpenVibe.Codes');
const codes = home.text.indexOf('https://github.com/OpenVibers/OpenVibe.Codes');
assert.ok(shared > -1 && codes > -1, 'both repositories are offered');
assert.ok(shared > -1 && codes > -1, 'both repositories are offered');
assert.ok(shared < codes, 'the last one used comes first');
assert.ok(shared < codes, 'the last one used comes first');
- assert.ok(home.text.includes('<option value="' + NODE + '">Desk'), 'the machine picker offers the paired machine');
- assert.ok(home.text.includes('data-c4-repo-search'), 'the repository field searches');
- assert.ok(home.text.includes('data-c4-saved-repo="https://github.com/OpenVibers/OpenVibe.Shared"'), 'and the saved ones are a pill away');
+ assert.ok(home.text.includes('data-ch-repo-url="/api/v1/me/repos"'), 'the repository field searches');
+ assert.ok(home.text.includes('data-ch-saved-repo="https://github.com/OpenVibers/OpenVibe.Shared"'), 'and the recent ones are a tap away');
+ // A task cannot be pinned to a machine yet (Run's pin is a Bot device): the default machine is chosen in Settings.
+ assert.ok(!home.text.includes('<option value="' + NODE + '">Desk'), 'the chat offers no machine it could not honour');
});
});
await check('the machine picker is in the order of use, on Home and on Settings', async () => {
await check('the machine picker is in the order of use, on Home and on Settings', async () => {
@@ -49,7 +50,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -49,7 +50,7 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
await db.exec(`INSERT INTO codes_attempts (id, task_id, n, agent, machine, state, started_at, finished_at, created_at)
await db.exec(`INSERT INTO codes_attempts (id, task_id, n, agent, machine, state, started_at, finished_at, created_at)
VALUES ('att_01ARZ3NDEKTSV4RRFFQ69G5FAC',$1,2,'codex',$2,'done', now() - interval '2 hours', now() - interval '2 hours', now() - interval '2 hours')`, [TASK2, NODE]);
VALUES ('att_01ARZ3NDEKTSV4RRFFQ69G5FAC',$1,2,'codex',$2,'done', now() - interval '2 hours', now() - interval '2 hours', now() - interval '2 hours')`, [TASK2, NODE]);
await t.ctx.userSettings.save(ada.subject, { default_machine: OTHER });
await t.ctx.userSettings.save(ada.subject, { default_machine: OTHER });
- for (const page of ['/', '/settings']) {
+ for (const page of ['/settings']) {
const shown = await t.get(page, { as: ada });
const shown = await t.get(page, { as: ada });
assert.strictEqual(shown.status, 200);
assert.strictEqual(shown.status, 200);
const desk = shown.text.indexOf(`<option value="${NODE}">Desk`);
const desk = shown.text.indexOf(`<option value="${NODE}">Desk`);
@@ -84,10 +85,10 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
@@ -84,10 +85,10 @@ const TASK3 = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FAX';
assert.strictEqual(page.status, 200, page.text.slice(0, 200));
assert.strictEqual(page.status, 200, page.text.slice(0, 200));
// Changes: the files first, then the diff folded out.
// Changes: the files first, then the diff folded out.
assert.ok(page.text.includes('server/http/c4.js') && page.text.includes('public/css/c4.css'), 'the changed files are listed');
assert.ok(page.text.includes('server/http/c4.js') && page.text.includes('public/css/c4.css'), 'the changed files are listed');
- assert.ok(page.text.includes('<span class="c4-add">+42</span>') || page.text.includes('c4-add'), 'with their counts');
+ assert.ok(page.text.includes('<span class="ch-add">+42</span>'), 'with their counts');
assert.ok(page.text.includes('The diff') && page.text.includes('+the change'), 'and the diff itself');
assert.ok(page.text.includes('The diff') && page.text.includes('+the change'), 'and the diff itself');
// Proof: one calm row per item, each with its own state.
// Proof: one calm row per item, each with its own state.
- assert.ok(page.text.includes('data-proof-kind') || page.text.includes('c4-row-label">tests'), 'a proof row per item');
+ assert.ok(page.text.includes('<b>tests</b>') && page.text.includes('<b>screenshots</b>') && page.text.includes('<b>review</b>'), 'a proof row per item');
assert.ok(page.text.includes('Passed') && page.text.includes('Skipped') && page.text.includes('Failed'), 'each row carries its state');
assert.ok(page.text.includes('Passed') && page.text.includes('Skipped') && page.text.includes('Failed'), 'each row carries its state');
assert.ok(page.text.includes('390 px') && page.text.includes('1440 px'), 'the shots are listed by width and route');
assert.ok(page.text.includes('390 px') && page.text.includes('1440 px'), 'the shots are listed by width and route');
assert.ok(page.text.includes('the page is 1500px wide'), 'and what the shot found');
assert.ok(page.text.includes('the page is 1500px wide'), 'and what the shot found');
@@ -89,12 +89,12 @@ const split = (links) => ({
@@ -89,12 +89,12 @@ const split = (links) => ({
assert.deepStrictEqual(bar.map((l) => l.label), ['Home', 'Start', 'Run', 'Agents'], 'and they are the product’s');
assert.deepStrictEqual(bar.map((l) => l.label), ['Home', 'Start', 'Run', 'Agents'], 'and they are the product’s');
});
});
- await check('a card’s muted note sits under its heading, not beside it', async () => {
+ await check('a signed-in person’s home is a new chat, not a page of cards (plan L31)', async () => {
const page = await t.get('/', { as: ada });
const page = await t.get('/', { as: ada });
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
- assert.ok(page.text.includes('<h2>Needs you</h2></div><p class="c4-sub">Approvals and questions</p>'),
- 'the note is the card’s subtitle');
- assert.ok(!page.text.includes('<h2>Needs you</h2><span'), 'and not a second title beside the heading');
+ assert.ok(page.text.includes('What should we build') && page.text.includes('action="/chat"'), 'the greeting and the composer');
+ assert.ok(!page.text.includes('class="c4-card'), 'no cards');
+ assert.ok(page.text.includes('aria-label="Your conversations"'), 'the rail of conversations is the navigation');
});
});
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+
+// Codes as a chat app (plan L31 CH1): a signed-in person's / and /tasks/:id are one app. The rail holds their own
+// 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 { boot, check, done } = require('./helpers/boot');
+const { stepKind } = require('../server/http/chat');
+
+(async () => {
+ const t = await boot();
+ const ada = t.network.addUser('chat-ada');
+ const bo = t.network.addUser('chat-bo');
+ const db = t.ctx.store.db;
+ const now = Date.now();
+ const P = 'prj_01ARZ3NDEKTSV4RRFFQ69G5FAW';
+ const MINE = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FB1', THEIRS = 'tsk_01ARZ3NDEKTSV4RRFFQ69G5FB2';
+ try {
+ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, state, result, created_at, updated_at) VALUES
+ ($1, $2, $5, 'https://github.com/OpenVibers/OpenVibe.Live', 'main', 'Fit the home page to a 360 px phone', 'claude-code', 'succeeded', '{"files":[{"path":"public/css/home.css","added":3,"removed":1}]}'::jsonb, $6, $6),
+ ($3, $4, $5, 'https://github.com/OpenVibers/OpenVibe.Meme', 'main', 'Someone else entirely', 'codex', 'succeeded', '{}'::jsonb, $6, $6)`,
+ [MINE, ada.subject, THEIRS, bo.subject, P, now - 60_000]);
+ await db.exec(`INSERT INTO codes_attempts (id, task_id, n, agent, model, state, reason, why, kind, created_at) VALUES
+ ('att_01ARZ3NDEKTSV4RRFFQ69G5FC1', $1, 1, 'codex', 'gpt-5.5', 'failed', 'limit', 'the router picked Codex', 'first', now() - interval '50 minutes'),
+ ('att_01ARZ3NDEKTSV4RRFFQ69G5FC2', $1, 2, 'claude-code', 'opus', 'done', null, 'Codex hit its limit', 'switch', now() - interval '40 minutes'),
+ ('att_01ARZ3NDEKTSV4RRFFQ69G5FC3', $1, 3, 'claude-code', 'opus', 'done', null, 'carried on by the owner: Keep the titles on one line.', 'manual', now() - interval '30 minutes')`, [MINE]);
+ await db.exec(`INSERT INTO codes_session_lines (task_id, seq, at, kind, body, attempt_n) VALUES
+ ($1, 1, now() - interval '49 minutes', 'tool', '{"t":"tool","name":"Read","what":"public/css/home.css"}', 1),
+ ($1, 2, now() - interval '39 minutes', 'tool', '{"t":"tool","name":"Edit","what":"public/css/home.css"}', 2),
+ ($1, 3, now() - interval '38 minutes', 'done', '{"t":"done","ok":true,"text":"The clips rail now **scrolls sideways**."}', 2),
+ ($1, 4, now() - interval '29 minutes', 'done', '{"t":"done","ok":true,"text":"Titles stay on one line."}', 3)`, [MINE]);
+
+ await check('the rail holds the person’s own conversations, and only theirs', async () => {
+ const page = await t.get('/', { as: ada });
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('Fit the home page to a 360 px phone'), 'their conversation is in the rail');
+ assert.ok(!page.text.includes('Someone else entirely'), 'another person’s is not');
+ assert.ok(page.text.includes('<p class="ch-group">Today</p>'), 'grouped by when it last moved');
+ const theirs = await t.get(`/tasks/${THEIRS}`, { as: ada });
+ assert.notStrictEqual(theirs.status, 200, 'another person’s conversation does not open');
+ });
+
+ await check('the thread: the brief, each agent’s turn, a hand-over and the next message', async () => {
+ 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 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 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('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');
+ });
+
+ await check('a new chat starts a task from the composer, and an empty one is refused in words', async () => {
+ const empty = await t.get('/chat', { as: ada, form: { text: ' ', repo: 'https://github.com/OpenVibers/OpenVibe.Live' } });
+ assert.strictEqual(empty.status, 303);
+ assert.match(empty.headers.get('location'), /^\/\?error=Write%20what%20you%20want%20done%20first/);
+ const made = await t.get('/chat', { as: ada, form: { text: 'Make the footer quieter', repo: 'https://github.com/OpenVibers/OpenVibe.Live', agent: '' } });
+ assert.strictEqual(made.status, 303, made.text.slice(0, 200));
+ const at = made.headers.get('location');
+ assert.match(at, /^\/tasks\/tsk_[0-9A-Z]{26}$/, `opens the new conversation (${at})`);
+ const row = await db.maybe('SELECT prompt, subject FROM run_tasks WHERE id = $1', [at.split('/').pop()]);
+ assert.ok(row && row.prompt === 'Make the footer quieter' && row.subject === ada.subject, 'the brief is the person’s own task');
+ });
+
+ await check('a visitor who is not signed in gets the product’s home, not the app', async () => {
+ const page = await t.get('/');
+ assert.strictEqual(page.status, 200);
+ assert.ok(!page.text.includes('data-chat'), 'no chat app');
+ });
+
+ await check('the step kinds name what an agent did, whatever the agent calls its tools', async () => {
+ const kinds = { Read: 'read', read_file: 'read', Glob: 'read', Grep: 'find', Edit: 'edit', apply_patch: 'edit', Write: 'edit', Bash: 'run', shell: 'run', WebFetch: 'web', WebSearch: 'web', Task: 'sub', mcp__codes__fleet: 'tool' };
+ for (const [name, kind] of Object.entries(kinds)) assert.strictEqual(stepKind(name), kind, name);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -216,7 +216,7 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
@@ -216,7 +216,7 @@ const DEVICE = 'dev_01J8Z3Q4R5S6T7V8W9X0Y1Z2N1';
const res = await get(`/tasks/${taskId}`);
const res = await get(`/tasks/${taskId}`);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.status, 200);
assert.match(res.text, /Hand-over brief/, 'the first turn is labelled');
assert.match(res.text, /Hand-over brief/, 'the first turn is labelled');
- assert.match(res.text, /data-c4-conductor-stream/, 'the transcript streams over the C2 events');
+ assert.match(res.text, /data-ch-stream="\/api\/v1\/tasks\/[^"]+\/events/, 'the conversation streams over the C2 events');
assert.match(res.text, /What next\?|Say something/, 'the box for the next turn');
assert.match(res.text, /What next\?|Say something/, 'the box for the next turn');
});
});