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)

OP OpenVibers committed 13 days ago · b9bd9708ff · 10 files, +970 −28

For a signed-in person, / and /tasks/:id are one app. The rail holds their conversations (the conductor pinned, the
rest by when they last moved, a breathing dot while one works or needs them); the thread shows the brief, each agent's
turn with a fold of its steps (each with the sign of what it did), a hand-over as a divider, a carry-on as the person's
next message, and how it ended (the files and the diff, the proof item by item, the pull request or why none). The
composer starts a chat or carries one on with the same agent or another; the conductor's takes images. A running turn
streams into the thread. The side lists the agents and where the work is. Phones get the rail as a drawer and the
conversation owns the screen. Everything works without JavaScript.

Changes

ddiff --git a/public/css/chat.css b/public/css/chat.css
nnew file mode 100644
iindex 0000000..cc1d5a4
---- /dev/null
++++ b/public/css/chat.css
@@@ -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}}
ddiff --git a/public/js/chat.js b/public/js/chat.js
nnew file mode 100644
iindex 0000000..380beef
---- /dev/null
++++ b/public/js/chat.js
@@@ -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);
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 202c55e..9b29064 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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);
ddiff --git a/server/http/chat.js b/server/http/chat.js
nnew file mode 100644
iindex 0000000..39f312a
---- /dev/null
++++ b/server/http/chat.js
@@@ -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 };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 62ca115..11f2341 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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
ddiff --git a/test/c4.test.js b/test/c4.test.js
iindex 91fe2f0..aef151d 100644
---- a/test/c4.test.js
++++ b/test/c4.test.js
@@@ -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&rsquo;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&#39;t finish") || task.text.includes('Didn\'t finish'), 'the state is said in words');
++ assert.ok(task.text.includes('Same agent (Codex)'), 'the composer carries on with the same agent, or another one');
++ assert.ok(task.text.includes(`action="/tasks/${id}/continue"`), 'the next message is the next turn');
++ 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();
ddiff --git a/test/c4b.test.js b/test/c4b.test.js
iindex 8c579c0..ff0afdb 100644
---- a/test/c4b.test.js
++++ b/test/c4b.test.js
@@@ -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');
ddiff --git a/test/c4c.test.js b/test/c4c.test.js
iindex 809f78f..c499cd2 100644
---- a/test/c4c.test.js
++++ b/test/c4c.test.js
@@@ -89,12 +89,12 @@ const split = (links) => ({
assert.deepStrictEqual(bar.map((l) => l.label), ['Home', 'Start', 'Run', 'Agents'], 'and they are the product&rsquo;s');
});
-- await check('a card&rsquo;s muted note sits under its heading, not beside it', async () => {
++ await check('a signed-in person&rsquo;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&rsquo;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();
ddiff --git a/test/chat.test.js b/test/chat.test.js
nnew file mode 100644
iindex 0000000..849deca
---- /dev/null
++++ b/test/chat.test.js
@@@ -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&rsquo;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&rsquo;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&rsquo;s conversation does not open');
++ });
++
++ await check('the thread: the brief, each agent&rsquo;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&rsquo;s own task');
++ });
++
++ await check('a visitor who is not signed in gets the product&rsquo;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); });
ddiff --git a/test/conductor.test.js b/test/conductor.test.js
iindex a5e484e..00caf23 100644
---- a/test/conductor.test.js
++++ b/test/conductor.test.js
@@@ -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');
});