openvibers/openvibe.actor Commits
Actor is a chat: your conversations on the left, each answer under its question, the work folded (#224)
Changes
nnew file mode 100644@@@ -0,0 +1,189 @@++/* Actor's chat (plan L55 C1). The page scrolls as a whole (no inner scroll box to trap a thumb), the composer sits at++ the bottom of the screen above the app bar, the chats are a column on wide screens and a drawer on phones. Theme++ tokens only, so every OpenVibe look applies. */++.page.page-app { max-width: none; padding: 0; min-height: 0; }++body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app) .ovf { display: none; }++.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++++.chat { --chat-top: 56px; --rail: 272px; --line: color-mix(in srgb, var(--border, #1f2d47) 80%, transparent);++ display: grid; grid-template-columns: minmax(0, 1fr); min-height: calc(100dvh - var(--chat-top)); }++@media (min-width: 900px) { .chat { grid-template-columns: var(--rail) minmax(0, 1fr); } }++++/* ── The chats ─────────────────────────────────────────────────────────────── */++.cs { display: flex; flex-direction: column; gap: 10px; padding: 12px 10px 14px; background: var(--bg-secondary, #0f1729); border-right: 1px solid var(--line); }++@media (min-width: 900px) { .cs { position: sticky; top: var(--chat-top); height: calc(100dvh - var(--chat-top)); overflow-y: auto; overscroll-behavior: contain; } }++@media (max-width: 899px) {++ .cs { position: fixed; z-index: 60; inset: 0 auto 0 0; width: min(86vw, 320px); padding-top: calc(12px + env(safe-area-inset-top, 0px)); padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));++ overflow-y: auto; overscroll-behavior: contain; transform: translateX(-104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); box-shadow: 0 0 40px rgba(0, 0, 0, .45); visibility: hidden; }++ .chat.is-drawer .cs { transform: none; visibility: visible; }++ .cs-scrim { position: fixed; inset: 0; z-index: 59; background: rgba(0, 0, 0, .5); }++ .cs-close { display: grid !important; }++}++.cs-scrim[hidden] { display: none; }++.cs-head { display: flex; align-items: center; gap: 8px; }++.cs-new { flex: 1; display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line);++ background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font-weight: 650; text-decoration: none; }++.cs-new:hover { border-color: var(--accent, #f97316); }++.cs-new svg, .cs-close svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }++.cs-close { display: none; place-items: center; width: 42px; height: 42px; border-radius: 12px; border: 1px solid var(--line); background: none; color: var(--text-secondary, #96a7c2); }++.cs-find input { width: 100%; min-height: 38px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; font-size: 14px; }++.cs-list { flex: 1; min-height: 0; }++.cs-list ul { list-style: none; margin: 0 0 6px; padding: 0; }++.cs-group { margin: 12px 8px 4px; font-size: 11.5px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.cs-list a { display: flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 10px; border-radius: 10px; color: var(--text-secondary, #b4c0d4); text-decoration: none; font-size: 14.5px; }++.cs-list a span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.cs-list a:hover { background: var(--bg-hover, rgba(255, 255, 255, .05)); color: var(--text-primary, #e6edf7); }++.cs-list a[aria-current=page] { background: color-mix(in srgb, var(--accent, #f97316) 16%, transparent); color: var(--text-primary, #e6edf7); font-weight: 600; }++.cs-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--accent, #f97316); animation: ct-breathe 1.6s ease-in-out infinite; }++.cs-empty { margin: 14px 10px; font-size: 14px; color: var(--text-muted, #7386a3); }++.cs-foot { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0; padding: 10px 8px 0; border-top: 1px solid var(--line); font-size: 13px; }++.cs-foot a { color: var(--text-secondary, #96a7c2); text-decoration: none; }++.cs-foot a:hover { color: var(--text-primary, #e6edf7); }++++/* ── The conversation ──────────────────────────────────────────────────────── */++.ct { display: flex; flex-direction: column; min-width: 0; min-height: calc(100dvh - var(--chat-top) - var(--ovnav-appbar-h, 0px)); }++.ct-head { position: sticky; top: var(--chat-top); z-index: 20; display: flex; align-items: center; gap: 8px; min-height: 52px; padding: 6px max(12px, env(safe-area-inset-right, 0px)) 6px max(12px, env(safe-area-inset-left, 0px));++ background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 88%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid transparent; }++.ct-head.is-stuck { border-bottom-color: var(--line); }++.ct-title { flex: 1; min-width: 0; margin: 0; font-size: 16px; font-weight: 650; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ct-menu, .ct-new { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; border: 0; background: none; color: var(--text-secondary, #b4c0d4); }++.ct-menu:hover, .ct-new:hover { background: var(--bg-hover, rgba(255, 255, 255, .06)); color: var(--text-primary, #e6edf7); }++.ct-menu svg, .ct-new svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }++@media (min-width: 900px) { .ct-menu { display: none; } }++.ct-scroll { flex: 1; padding: 8px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px)); }++.ct-thread { list-style: none; max-width: 780px; margin: 0 auto; padding: 0; display: grid; gap: 30px; }++++.ct-turn { display: grid; gap: 14px; scroll-margin-top: calc(var(--chat-top) + 60px); }++.ct-you { display: flex; justify-content: flex-end; }++.ct-bubble { max-width: min(85%, 620px); margin: 0; padding: 10px 15px; border-radius: 20px 20px 6px 20px; white-space: pre-wrap; overflow-wrap: anywhere;++ background: color-mix(in srgb, var(--accent, #f97316) 14%, var(--bg-card, #131c2e)); color: var(--text-primary, #e6edf7); line-height: 1.5; }++.ct-turn.is-new .ct-bubble { animation: ct-rise .32s cubic-bezier(.2, .8, .2, 1) both; }++.ct-bot { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 12px; align-items: start; }++.ct-avatar { width: 28px; height: 28px; margin-top: 2px; border-radius: 9px; background: radial-gradient(circle at 30% 30%, color-mix(in srgb, var(--accent, #f97316) 70%, #fff), var(--accent, #f97316) 55%, color-mix(in srgb, var(--accent, #f97316) 40%, #6d28d9));++ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #f97316) 35%, transparent); }++.ct-turn[data-open] .ct-avatar { animation: ct-breathe 1.6s ease-in-out infinite; }++.ct-reply { min-width: 0; display: grid; gap: 10px; }++++/* The work, folded: "Worked for 12 s · 3 tools". Open while the turn works, so its live line shows. */++.cw { border-radius: 14px; }++.cw > summary { list-style: none; display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: 32px; padding: 0 10px 0 2px; border-radius: 10px; cursor: pointer;++ color: var(--text-secondary, #96a7c2); font-size: 14px; }++.cw > summary::-webkit-details-marker { display: none; }++.cw > summary:hover { color: var(--text-primary, #e6edf7); }++.cw-chev { flex: none; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: transform .2s; }++.cw[open] .cw-chev { transform: rotate(180deg); }++.cw-live { background: linear-gradient(90deg, var(--text-secondary, #96a7c2) 20%, var(--text-primary, #e6edf7) 40%, var(--text-secondary, #96a7c2) 60%) 0 0 / 300% 100%;++ -webkit-background-clip: text; background-clip: text; color: transparent; animation: ct-shimmer 2.2s linear infinite; font-weight: 550; }++.cw-list { list-style: none; margin: 4px 0 0; padding: 2px 0 2px 12px; border-left: 2px solid var(--line); display: grid; gap: 6px; }++.cw-list > li { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 8px; align-items: start; font-size: 14px; color: var(--text-secondary, #a8b3c4); animation: ct-rise .25s ease both; }++.cw-list svg { width: 16px; height: 16px; margin-top: 3px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }++.cw-step span { font-weight: 600; color: var(--text-primary, #e6edf7); }++.cw-note p { margin: 0; line-height: 1.5; overflow-wrap: anywhere; }++.cw-note.is-warning, .cw-note.is-error { color: var(--warning-text, var(--warning, #f59e0b)); }++.cw-tool { grid-template-columns: minmax(0, 1fr) !important; }++.cw-tool details > summary { list-style: none; display: grid; grid-template-columns: 18px auto minmax(0, 1fr) auto; gap: 8px; align-items: center; min-height: 30px; cursor: pointer; }++.cw-tool details > summary::-webkit-details-marker { display: none; }++.cw-tool svg { margin-top: 0; }++.cw-tool b { font-weight: 600; color: var(--text-primary, #e6edf7); white-space: nowrap; }++.cw-in { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; color: var(--text-muted, #7386a3); }++.cw-st { display: inline-flex; align-items: center; gap: 4px; font-size: 12.5px; color: var(--text-muted, #7386a3); white-space: nowrap; }++.cw-tool.is-ok .cw-st svg { stroke: var(--success, #22c55e); }++.cw-tool.is-bad .cw-st svg { stroke: var(--error, #ef4444); }++.cw-tool.is-run b { background: linear-gradient(90deg, var(--text-secondary, #96a7c2) 20%, var(--text-primary, #e6edf7) 40%, var(--text-secondary, #96a7c2) 60%) 0 0 / 300% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: ct-shimmer 2.2s linear infinite; }++.cw-out { margin: 6px 0 4px 26px; max-height: 260px; overflow: auto; font-size: 12.5px; white-space: pre-wrap; overflow-wrap: anywhere; }++++/* The answer. */++.ct-answer { max-width: none; font-size: 16px; line-height: 1.68; color: var(--text-primary, #e6edf7); }++.ct-answer > :first-child { margin-top: 0; }++.ct-answer > :last-child { margin-bottom: 0; }++.ct-answer p, .ct-answer ul, .ct-answer ol { margin: 0 0 .8em; }++.ct-answer li + li { margin-top: .25em; }++.ct-answer h2, .ct-answer h3, .ct-answer h4 { margin: 1.2em 0 .45em; line-height: 1.3; }++.ct-answer h2 { font-size: 1.2rem; } .ct-answer h3 { font-size: 1.06rem; } .ct-answer h4 { font-size: 1rem; }++.ct-answer pre { position: relative; margin: .6em 0 1em; border-radius: 12px; }++.ct-answer table { display: block; max-width: 100%; overflow-x: auto; margin: .6em 0 1em; }++.ct-answer blockquote { margin: .6em 0; padding: 2px 0 2px 14px; border-left: 3px solid var(--line); color: var(--text-secondary, #a8b3c4); }++.ct-answer .ev { display: inline-grid; place-items: center; min-width: 1.4em; height: 1.4em; padding: 0 .3em; margin: 0 .08em; border-radius: 6px; font-size: .72em; font-weight: 700; vertical-align: .18em;++ background: color-mix(in srgb, var(--accent, #f97316) 16%, transparent); color: var(--text-secondary, #b4c0d4); text-decoration: none; }++.ct-answer .ev:hover { background: color-mix(in srgb, var(--accent, #f97316) 32%, transparent); color: var(--text-primary, #e6edf7); }++.ct-answer.is-reveal > * { animation: ct-reveal .5s cubic-bezier(.2, .8, .2, 1) both; }++.ct-copy-code { position: absolute; top: 6px; right: 6px; padding: 3px 9px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-secondary, #96a7c2); font-size: 12px; opacity: .85; }++.ct-copy-code:hover { opacity: 1; color: var(--text-primary, #e6edf7); }++++.ct-files { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }++.ct-files a { display: flex; align-items: center; gap: 8px; max-width: 280px; padding: 6px 10px 6px 6px; border-radius: 12px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 14px; }++.ct-files img { width: 120px; height: 80px; object-fit: cover; border-radius: 8px; }++.ct-files svg { width: 28px; height: 28px; fill: none; stroke: currentColor; stroke-width: 1.6; }++.ct-files span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.ct-sources { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; }++.ct-sources a { padding: 2px 10px; border-radius: 999px; border: 1px solid var(--line); color: var(--text-secondary, #a8b3c4); font-size: 13px; text-decoration: none; }++.ct-sources a:hover { border-color: var(--accent, #f97316); color: var(--text-primary, #e6edf7); }++++.ct-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 13px; color: var(--text-muted, #7386a3); }++.ct-meta > span:not(.ct-acts) + span:not(.ct-acts)::before { content: ""; display: inline-block; width: 3px; height: 3px; margin: 0 10px 2px -6px; border-radius: 50%; background: currentColor; opacity: .6; }++.ct-checked { display: inline-flex; align-items: center; gap: 3px; color: var(--success-text, var(--success, #22c55e)); }++.ct-checked svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }++.ct-acts { display: inline-flex; gap: 2px; margin-left: auto; }++.ct-act { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 0 9px; border-radius: 9px; border: 0; background: none; color: var(--text-secondary, #96a7c2); font-size: 13px; text-decoration: none; }++.ct-act:hover { background: var(--bg-hover, rgba(255, 255, 255, .06)); color: var(--text-primary, #e6edf7); }++.ct-act svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }++.ct-act.is-done { color: var(--success-text, var(--success, #22c55e)); }++++.ct-card { padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); }++.ct-card p { margin: 0 0 6px; }++.ct-card.is-wait { border-color: color-mix(in srgb, var(--accent, #f97316) 55%, var(--line)); }++.ct-card.is-bad { border-color: color-mix(in srgb, var(--error, #ef4444) 45%, var(--line)); }++.ct-opts, .ct-own, .ct-retry { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }++.ct-own input { flex: 1 1 200px; min-height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg-primary, #0a0f1c); color: inherit; font: inherit; }++.ct-opt { min-height: 40px; padding: 0 14px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); font-weight: 600; }++.ct-opt:hover { border-color: var(--accent, #f97316); }++.ct-stopped { margin: 0; color: var(--text-muted, #7386a3); font-size: 14px; }++++/* A new chat: the mark, one line, and four things to try. */++.ct-hello { max-width: 720px; margin: 6vh auto 0; text-align: center; }++.ct-hello-mark { display: inline-block; width: 52px; height: 52px; border-radius: 16px; background: radial-gradient(circle at 30% 30%, color-mix(in srgb, var(--accent, #f97316) 70%, #fff), var(--accent, #f97316) 55%, color-mix(in srgb, var(--accent, #f97316) 40%, #6d28d9));++ box-shadow: 0 10px 40px color-mix(in srgb, var(--accent, #f97316) 30%, transparent); animation: ct-breathe 3.2s ease-in-out infinite; }++.ct-hello h2 { margin: 14px 0 6px; font-size: 1.6rem; }++.ct-hello > p { margin: 0 auto 20px; max-width: 46ch; color: var(--text-secondary, #a8b3c4); }++.ct-starters { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; text-align: left; }++.ct-starters button { display: grid; gap: 3px; width: 100%; height: 100%; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-align: left; }++.ct-starters button:hover { border-color: var(--accent, #f97316); }++.ct-starters b { font-size: 14px; }++.ct-starters span { font-size: 13.5px; line-height: 1.45; color: var(--text-secondary, #a8b3c4); }++@media (max-width: 560px) { .ct-starters { grid-template-columns: minmax(0, 1fr); } }++++/* The composer: at the bottom of the screen, above the app bar. */++.cc { position: sticky; bottom: var(--ovnav-appbar-h, 0px); z-index: 15; padding: 10px max(12px, env(safe-area-inset-right, 0px)) calc(8px + env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px));++ background: linear-gradient(to top, var(--bg-primary, #0a0f1c) 70%, transparent); }++html.ov-has-appbar .cc { padding-bottom: 8px; }++.cc-box { max-width: 780px; margin: 0 auto; padding: 8px 8px 8px 14px; border-radius: 24px; border: 1px solid var(--line); background: var(--bg-card, #131c2e);++ box-shadow: 0 8px 30px rgba(0, 0, 0, .25); transition: border-color .2s, box-shadow .2s; }++.cc-box:focus-within { border-color: color-mix(in srgb, var(--accent, #f97316) 60%, var(--line)); box-shadow: 0 8px 30px rgba(0, 0, 0, .25), 0 0 0 3px color-mix(in srgb, var(--accent, #f97316) 18%, transparent); }++.cc-box textarea { display: block; width: 100%; min-height: 28px; max-height: 40vh; padding: 6px 2px; border: 0; outline: 0; resize: none; background: none; color: var(--text-primary, #e6edf7);++ font: inherit; font-size: 16px; line-height: 1.5; }++.cc-box textarea:focus-visible { outline: 0; }++.cc-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }++.cc-mode select { max-width: 150px; min-height: 34px; padding: 0 26px 0 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: var(--text-secondary, #b4c0d4);++ font: inherit; font-size: 13px; appearance: none; -webkit-appearance: none; cursor: pointer;++ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2396a7c2' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='M7 10l5 5 5-5'/%3E%3C/svg%3E");++ background-repeat: no-repeat; background-position: right 8px center; background-size: 14px; text-overflow: ellipsis; }++.cc-note { flex: 1; min-width: 0; font-size: 12.5px; color: var(--text-muted, #7386a3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.cc-send { flex: none; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; border: 0; background: var(--accent, #f97316); color: var(--on-accent, #fff); transition: transform .15s, opacity .2s, background .2s; }++.cc-send svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }++.cc-send:active { transform: scale(.92); }++.cc-send:disabled { opacity: .45; cursor: default; }++.cc-send.is-stop { background: var(--text-primary, #e6edf7); color: var(--bg-primary, #0a0f1c); }++.cc-problem { max-width: 780px; margin: 0 auto 8px; padding: 8px 12px; border-radius: 12px; background: color-mix(in srgb, var(--warning, #f59e0b) 14%, var(--bg-card, #131c2e)); font-size: 14px; }++.cc-fine { max-width: 780px; margin: 6px auto 0; text-align: center; font-size: 12px; color: var(--text-muted, #7386a3); }++@media (max-width: 560px) { .cc-fine { display: none; } }++++@keyframes ct-breathe { 0%, 100% { opacity: .55; transform: scale(.94); } 50% { opacity: 1; transform: scale(1); } }++@keyframes ct-shimmer { from { background-position: 100% 0; } to { background-position: -50% 0; } }++@keyframes ct-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }++@keyframes ct-reveal { from { opacity: 0; transform: translateY(4px); filter: blur(3px); } to { opacity: 1; transform: none; filter: none; } }++@media (prefers-reduced-motion: reduce) {++ .cs, .cw-chev, .cc-box, .cc-send { transition: none; }++ .cs-dot, .ct-avatar, .cw-live, .cw-tool.is-run b, .ct-hello-mark, .ct-turn.is-new .ct-bubble, .cw-list > li, .ct-answer.is-reveal > * { animation: none !important; }++ .cw-live, .cw-tool.is-run b { color: var(--text-primary, #e6edf7); background: none; }++}++html[data-ov-motion=reduced] .ct-answer.is-reveal > * { animation: none !important; }nnew file mode 100644@@@ -0,0 +1,256 @@++/* Actor's chat in the browser (plan L55 C1). The page works without this file; with it:++ * - a message sends without a reload, its turn appears at once, and the thread stays at the bottom;++ * - a turn that is working follows its event stream: the live line says what it is doing now, the work list fills in++ * (the server renders it: GET /chat/turn/:id), and the answer fades in when it is done;++ * - Send turns into Stop while Actor works; Enter sends on a keyboard, Shift+Enter is a new line;++ * - the chats open as a drawer on phones, and the search box filters them;++ * - Copy on each answer and on each code block.++ * Booted on load and after an in-place page swap (openvibe-shared/boost's ov:boost:load).++ */++(function () {++ 'use strict';++ var streams = [];++++ function start() {++ var chat = document.querySelector('.chat');++ if (!chat || chat.dataset.ready) return;++ chat.dataset.ready = '1';++ var form = chat.querySelector('[data-composer]');++ var text = chat.querySelector('[data-cc-text]');++ var send = chat.querySelector('[data-cc-send]');++ var note = chat.querySelector('[data-cc-note]');++ var head = chat.querySelector('.ct-head');++ var fine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;++ var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;++++ // ── Where the navbar ends: the sticky header and the chats column sit under it, and follow it when it steps away.++ var nav = document.querySelector('.openvibe-navbar') || document.getElementById('navbar-mount');++ var topWas = -1, ticking = false;++ function measure() {++ ticking = false;++ var b = nav ? Math.max(0, Math.round(nav.getBoundingClientRect().bottom)) : 0;++ if (b !== topWas) { topWas = b; chat.style.setProperty('--chat-top', b + 'px'); }++ if (head) head.classList.toggle('is-stuck', window.scrollY > 8);++ }++ function onScroll() { if (!ticking) { ticking = true; requestAnimationFrame(measure); } }++ measure();++ window.addEventListener('scroll', onScroll, { passive: true });++ window.addEventListener('resize', onScroll);++ setTimeout(measure, 400);++++ // ── The thread stays at the bottom while you are there.++ function nearBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 220; }++ function toBottom(smooth) { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: smooth && !reduce ? 'smooth' : 'auto' }); }++ if (!location.hash && chat.querySelector('[data-thread]')) toBottom(false);++++ // ── The chats: a drawer on phones, filtered by the search box.++ var opener = chat.querySelector('[data-cs-open]');++ var scrim = chat.querySelector('.cs-scrim');++ var closeBtn = chat.querySelector('.cs-close');++ function drawer(on) {++ chat.classList.toggle('is-drawer', on);++ if (scrim) scrim.hidden = !on;++ if (closeBtn) closeBtn.hidden = !on;++ if (opener) opener.setAttribute('aria-expanded', on ? 'true' : 'false');++ if (on) { var f = chat.querySelector('[data-cs-find]'); if (f && fine) f.focus(); }++ }++ if (opener) opener.addEventListener('click', function () { drawer(true); });++ chat.querySelectorAll('[data-cs-close]').forEach(function (el) { el.addEventListener('click', function () { drawer(false); }); });++ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && chat.classList.contains('is-drawer')) drawer(false); });++ var find = chat.querySelector('[data-cs-find]');++ if (find) find.addEventListener('input', function () {++ var q = find.value.trim().toLowerCase();++ chat.querySelectorAll('.cs-list li').forEach(function (li) { var a = li.querySelector('a'); li.hidden = !!q && (!a || (a.getAttribute('data-title') || '').indexOf(q) === -1); });++ chat.querySelectorAll('.cs-group').forEach(function (g) { var ul = g.nextElementSibling; g.hidden = !!ul && !ul.querySelector('li:not([hidden])'); });++ });++++ // ── The composer.++ function grow() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(text.scrollHeight, window.innerHeight * 0.4) + 'px'; }++ if (text) {++ text.addEventListener('input', function () { grow(); syncSend(); });++ text.addEventListener('keydown', function (e) {++ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && fine) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }++ });++ grow();++ }++ chat.querySelectorAll('[data-starter]').forEach(function (b) {++ b.addEventListener('click', function () { text.value = b.getAttribute('data-starter'); grow(); syncSend(); text.focus(); });++ });++ function openTurn() { var t = chat.querySelectorAll('.ct-turn'); var last = t[t.length - 1]; return last && (last.hasAttribute('data-open') || last.getAttribute('data-state') === 'needs_you') ? last : null; }++ var STOP = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor" stroke="none"/></svg>';++ var SEND = send ? send.innerHTML : '';++ function syncSend() {++ if (!send) return;++ var busy = openTurn();++ send.classList.toggle('is-stop', !!busy && busy.hasAttribute('data-open'));++ send.innerHTML = busy && busy.hasAttribute('data-open') ? STOP : SEND;++ send.setAttribute('aria-label', busy && busy.hasAttribute('data-open') ? 'Stop' : 'Send');++ send.disabled = !(busy && busy.hasAttribute('data-open')) && !(text && text.value.trim());++ }++ syncSend();++++ function problem(msg) {++ var p = form.querySelector('.cc-problem');++ if (!msg) { if (p) p.remove(); return; }++ if (!p) { p = document.createElement('p'); p.className = 'cc-problem'; p.setAttribute('role', 'alert'); form.insertBefore(p, form.firstChild); }++ p.textContent = msg;++ }++ function setParent(id) {++ var input = form.querySelector('input[name=parent]');++ if (!input) { input = document.createElement('input'); input.type = 'hidden'; input.name = 'parent'; form.appendChild(input); }++ input.value = id;++ if (text) text.placeholder = 'Reply to Actor';++ }++++ if (form) form.addEventListener('submit', function (e) {++ var busy = openTurn();++ if (busy && busy.hasAttribute('data-open')) { // Stop++ e.preventDefault();++ send.disabled = true;++ if (note) note.textContent = 'Stopping…';++ fetch('/tasks/' + encodeURIComponent(busy.getAttribute('data-task')) + '/cancel', { method: 'POST', credentials: 'same-origin', redirect: 'manual' }).catch(function () {});++ return;++ }++ if (!window.fetch || !text || !text.value.trim()) return;++ e.preventDefault();++ problem(null);++ send.disabled = true;++ var body = new URLSearchParams(new FormData(form));++ fetch('/chat', { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString() })++ .then(function (r) { return r.json().catch(function () { return { error: 'That could not be sent. Try again.' }; }).then(function (j) { return { ok: r.ok, j: j }; }); })++ .then(function (res) {++ if (!res.ok || !res.j.turn) { problem(res.j.error || 'That could not be sent. Try again.'); syncSend(); return; }++ var thread = chat.querySelector('[data-thread]');++ if (!thread) {++ var hello = chat.querySelector('.ct-hello');++ thread = document.createElement('ol'); thread.className = 'ct-thread'; thread.setAttribute('data-thread', '');++ if (hello) hello.replaceWith(thread); else chat.querySelector('[data-scroll]').appendChild(thread);++ var title = chat.querySelector('.ct-title'); if (title) title.textContent = text.value.trim().slice(0, 60);++ try { history.pushState({}, '', res.j.url); } catch (err) { /* */ }++ addChat(res.j.id, text.value.trim());++ }++ var box = document.createElement('div'); box.innerHTML = res.j.turn;++ var turn = box.firstElementChild;++ turn.classList.add('is-new');++ thread.appendChild(turn);++ text.value = ''; grow();++ setParent(res.j.id);++ follow(turn);++ syncSend();++ toBottom(true);++ })++ .catch(function () { problem('No connection. Your message is still in the box.'); syncSend(); });++ });++++ // A new chat joins the top of the list.++ function addChat(id, title) {++ var list = chat.querySelector('.cs-list');++ if (!list) return;++ var empty = list.querySelector('.cs-empty'); if (empty) empty.remove();++ chat.querySelectorAll('.cs-list a[aria-current]').forEach(function (a) { a.removeAttribute('aria-current'); });++ var ul = list.querySelector('ul');++ if (!ul || !list.querySelector('.cs-group') || list.querySelector('.cs-group').textContent !== 'Today') {++ var g = document.createElement('p'); g.className = 'cs-group'; g.textContent = 'Today';++ ul = document.createElement('ul'); list.insertBefore(ul, list.firstChild); list.insertBefore(g, ul);++ }++ var li = document.createElement('li'), a = document.createElement('a'), dot = document.createElement('i'), span = document.createElement('span');++ a.href = '/chat/' + id; a.setAttribute('aria-current', 'page'); a.setAttribute('data-title', title.toLowerCase());++ dot.className = 'cs-dot'; span.textContent = title.slice(0, 80);++ a.appendChild(dot); a.appendChild(span); li.appendChild(a); ul.insertBefore(li, ul.firstChild);++ }++++ // ── A turn at work: its stream, its live line, and the answer when it lands.++ function liveLine(turn) {++ var line = turn.querySelector('[data-live-line]');++ if (!line) return;++ var run = turn.querySelectorAll('.cw-tool.is-run b');++ var steps = turn.querySelectorAll('.cw-step span, .cw-note p');++ var txt = run.length ? 'Using ' + run[run.length - 1].textContent + '…'++ : steps.length ? steps[steps.length - 1].textContent : '';++ if (txt) line.textContent = txt.length > 90 ? txt.slice(0, 89) + '…' : txt;++ }++ function refresh(turn, done) {++ var id = turn.getAttribute('data-task');++ return fetch('/chat/turn/' + encodeURIComponent(id), { credentials: 'same-origin' })++ .then(function (r) { return r.ok ? r.text() : null; })++ .then(function (h) {++ if (!h) return;++ var stay = nearBottom();++ var box = document.createElement('div'); box.innerHTML = h;++ var next = box.firstElementChild;++ if (!next) return;++ var finished = !next.hasAttribute('data-open');++ if (!finished) {++ // Only the work list changes while it works: the fold keeps its open or closed state.++ var list = turn.querySelector('[data-work]'), fresh = next.querySelector('[data-work]');++ if (list && fresh) list.innerHTML = fresh.innerHTML;++ else if (fresh) turn.querySelector('.ct-reply').innerHTML = next.querySelector('.ct-reply').innerHTML;++ liveLine(turn);++ } else {++ turn.replaceWith(next);++ var ans = next.querySelector('[data-answer]');++ if (ans && !reduce) { ans.classList.add('is-reveal'); Array.prototype.forEach.call(ans.children, function (c, i) { c.style.animationDelay = Math.min(i, 14) * 55 + 'ms'; }); }++ decorate(next);++ var dot = chat.querySelector('.cs-list a[aria-current] .cs-dot'); if (dot) dot.remove();++ if (note) note.textContent = '';++ syncSend();++ if (text && fine) text.focus();++ }++ if (stay) toBottom(true);++ if (done && finished) done();++ })++ .catch(function () {});++ }++ function follow(turn) {++ if (!window.EventSource || !turn || !turn.hasAttribute('data-open') || turn.dataset.following) return;++ turn.dataset.following = '1';++ liveLine(turn);++ var id = turn.getAttribute('data-task');++ var src = new EventSource('/api/v1/tasks/' + encodeURIComponent(id) + '/events?after=' + encodeURIComponent(turn.getAttribute('data-last') || '0'));++ streams.push(src);++ var timer = 0;++ function soon() { if (timer) return; timer = setTimeout(function () { timer = 0; refresh(turn); }, 450); }++ src.addEventListener('output', function (m) {++ try {++ var e = JSON.parse(m.data);++ var line = turn.querySelector('[data-live-line]');++ if (line && e.step === 'tool_call' && e.tool) line.textContent = 'Using ' + String(e.tool).replace(/_/g, ' ') + '…';++ } catch (err) { /* a malformed event is skipped */ }++ soon();++ });++ src.addEventListener('state', soon);++ src.addEventListener('end', function () { src.close(); clearTimeout(timer); timer = 0; refresh(turn); });++ src.onerror = function () { if (src.readyState === 2) setTimeout(function () { refresh(turn); }, 3000); };++ }++ chat.querySelectorAll('.ct-turn[data-open]').forEach(function (t) { follow(t); });++++ // ── Copy: an answer, and each code block in it.++ function decorate(rootEl) {++ (rootEl || chat).querySelectorAll('[data-copy]').forEach(function (b) {++ if (b.dataset.wired) return; b.dataset.wired = '1'; b.hidden = false;++ b.addEventListener('click', function () {++ var ans = b.closest('.ct-reply').querySelector('[data-answer]');++ copy(ans ? ans.innerText : '', b);++ });++ });++ (rootEl || chat).querySelectorAll('.ct-answer pre').forEach(function (pre) {++ if (pre.querySelector('.ct-copy-code')) return;++ var b = document.createElement('button'); b.type = 'button'; b.className = 'ct-copy-code'; b.textContent = 'Copy';++ b.addEventListener('click', function () { var code = pre.querySelector('code') || pre; copy(code.innerText, b); });++ pre.appendChild(b);++ });++ }++ function copy(str, btn) {++ var label = btn.querySelector('span') || btn;++ var was = label.textContent;++ function ok() { label.textContent = 'Copied'; btn.classList.add('is-done'); setTimeout(function () { label.textContent = was; btn.classList.remove('is-done'); }, 1600); }++ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(str).then(ok, function () {});++ }++ decorate(chat);++ }++++ function stop() { streams.forEach(function (s) { try { s.close(); } catch (e) { /* */ } }); streams = []; }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();++ document.addEventListener('ov:boost:load', start);++ window.addEventListener('pagehide', stop);++})();@@@ -51,6 +51,7 @@ const { createPrincipal } = require('./http/principal');const { createApi } = require('./http/api');const resourceIndex = require('./registry/resource-index');const { createPageRoutes } = require('./http/pages');++const { createChatPages } = require('./http/chat-pages');const { createAgentPages } = require('./http/agent-pages');const { createConsolePages } = require('./http/console-pages');const { createMemoryPages } = require('./http/memory-pages');@@@ -241,6 +242,7 @@ async function createApp(opts = {}) {app.use(createMemoryPages(ctx));app.use(createConnectionsPages(ctx));app.use(createConsolePages(ctx, opts.bot || {}));++ app.use(createChatPages(ctx));const pageRoutes = createPageRoutes(ctx);// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public// pages. The pages themselves keep their own routes — a signed-in visitor's account bar and task box arennew file mode 100644@@@ -0,0 +1,348 @@++'use strict';++++/**++ * The chat (plan L55 C1; the owner, 2026-11-01: "it needs a whole conversation system… like Gemini or ChatGPT… your++ * conversations on the left… way cleaner, so many words"). Actor's app for a signed-in person.++ *++ * - A conversation is a chain of tasks: each follow-up's parent_id is the turn before it (a fork starts a chain of its++ * own). The sidebar is the person's chains, newest activity first, grouped Today / Yesterday / This week / Earlier.++ * - A turn is the question as a bubble and Actor's reply under it: the work folded ("Worked for 12 s · 3 tools", the++ * plan, its notes and each tool call with what it returned), the answer, its files and sources, and one quiet line++ * (mode · model · cost · time · checked) with Copy and Details.++ * - Everything else a task has (saved steps and forks, compare, the Bench offer, the evidence list with Challenge, the++ * trace, webhooks, the API line) is that turn's Details: /tasks/:id?view=details, the full task page.++ *++ * Server-rendered and complete without JavaScript: the composer is a plain form (POST /chat) and a turn still working++ * refreshes the page (<noscript>). public/js/chat.js adds the live work line from the task's event stream, swaps the++ * finished turn in (GET /chat/turn/:id), sends without a reload and keeps the thread at the bottom.++ */++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++const { asyncRouter } = require('./router');++const { html, raw } = require('../render/html');++const { send } = require('../render/layout');++const { markdown } = require('../render/markdown');++const store = require('../tasks/store');++const { createTask } = require('../tasks/create');++const { MODES } = require('../agents/router');++const catalog = require('../agents/catalog');++const taskEvents = require('../../public/js/task-events');++const { sameOrigin } = require('./principal');++++const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++const OPEN = ['queued', 'running', 'verifying'];++const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };++const MODE_HINT = { cheapest: 'the lowest price that can do it', balanced: 'good and cheap', best: 'the strongest agent', fastest: 'the quickest answer', private: 'open model, nothing leaves OpenVibe' };++const LIVE_TEXT = { queued: 'Starting', running: 'Working', verifying: 'Checking the answer' };++const SIDEBAR_ROWS = 400; // the person's newest tasks the sidebar groups into chats++const CHAIN_MAX = 60; // turns one conversation shows++const EVENTS_MAX = 600; // events per turn the work fold reads++++const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };++const isFollow = (row) => row && row.parent_id && row.fork_of_step == null;++++/** The chats in a person's newest tasks: [{ id (the first turn), title, last (ISO), turns, open }], newest first. */++function chatsOf(rows) {++ const byId = new Map(rows.map((r) => [r.id, r]));++ const rootOf = (row) => {++ let cur = row;++ for (let i = 0; i < CHAIN_MAX && isFollow(cur) && byId.has(cur.parent_id); i++) cur = byId.get(cur.parent_id);++ return cur;++ };++ const chats = new Map();++ for (const row of rows) {++ const root = rootOf(row);++ const c = chats.get(root.id) || { id: root.id, title: clip(root.task, 80), last: root.created_at, turns: 0, open: false };++ c.turns += 1;++ if (String(row.created_at) > String(c.last)) c.last = row.created_at;++ if (OPEN.includes(row.state) || row.state === 'needs_you') c.open = true;++ chats.set(root.id, c);++ }++ return [...chats.values()].sort((a, b) => (String(b.last) > String(a.last) ? 1 : String(b.last) < String(a.last) ? -1 : 0));++}++++/** Today / Yesterday / This week / Earlier, by UTC day, for the sidebar. */++function groupOf(iso, now) {++ const day = (ms) => Math.floor(ms / 86_400_000);++ const d = day(now) - day(Date.parse(iso) || now);++ return d <= 0 ? 'Today' : d === 1 ? 'Yesterday' : d < 7 ? 'This week' : 'Earlier';++}++++/** "Worked for 35 s", from the task's own times. */++function tookOf(row) {++ const ms = row.finished_at && row.created_at ? Date.parse(row.finished_at) - Date.parse(row.created_at) : NaN;++ if (!Number.isFinite(ms) || ms < 1000) return '';++ const s = Math.round(ms / 1000);++ return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ''}`;++}++++function createChatPages(ctx) {++ const { config, s, engine } = ctx;++ const r = asyncRouter();++ // Helpers from the task page, read when first used (pages.js is the bigger module; no load-order coupling).++ const pageKit = () => require('./pages');++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const requesterOf = (req) => `user:${req.viewer.subject}`;++ const now = () => (typeof s.now === 'function' ? s.now() : Date.now());++++ async function chatsFor(requester) {++ const rows = await s.db.many(++ 'SELECT id, parent_id, fork_of_step, task, state, created_at FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT $2',++ [requester, SIDEBAR_ROWS],++ );++ return chatsOf(rows);++ }++++ /** The turns of the chat through `row`: its follow-up chain up to the first turn, then on through the newest++ * follow-up of each turn. */++ async function chainOf(row) {++ const up = [];++ let cur = row;++ for (let i = 0; i < CHAIN_MAX && isFollow(cur); i++) {++ const p = await store.getTask(s, cur.parent_id);++ if (!p || p.requester !== row.requester) break;++ up.unshift(p);++ cur = p;++ }++ const down = [];++ cur = row;++ for (let i = 0; i < CHAIN_MAX - up.length - 1; i++) {++ const next = await s.db.maybe(++ 'SELECT * FROM tasks WHERE parent_id = $1 AND requester = $2 AND fork_of_step IS NULL ORDER BY id DESC LIMIT 1',++ [cur.id, row.requester],++ );++ if (!next) break;++ down.push(next);++ cur = next;++ }++ return [...up, row, ...down];++ }++++ // ── One turn ───────────────────────────────────────────────────────────────────────────────────────────────────++ const ICON = {++ tool: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.1L4 16.7V20h3.3l5.3-5.3a4 4 0 0 0 5.1-5.4l-2.6 2.6-2.4-.6-.6-2.4Z"/></svg>',++ note: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3a6 6 0 0 0-3.5 10.9V16h7v-2.1A6 6 0 0 0 12 3ZM9.5 19.5h5"/></svg>',++ step: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>',++ ok: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>',++ bad: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7 7 17"/></svg>',++ copy: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg>',++ more: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10"/></svg>',++ file: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3h7l5 5v13H7Z"/><path d="M14 3v5h5"/></svg>',++ };++++ /** The work fold's rows, in order: a plan step, a note the agent wrote, or a tool call with what it returned. */++ function workRows(lines) {++ const { toolLabel, msText } = pageKit();++ const results = new Map(lines.filter((l) => l.t === 'tool_result').map((l) => [l.id, l]));++ const rows = [];++ for (const l of lines) {++ if (l.t === 'step') rows.push(html`<li class="cw-step">${raw(ICON.step)}<span>${l.text}</span></li>`);++ else if (l.t === 'text') rows.push(html`<li class="cw-note">${raw(ICON.note)}<p>${clip(l.text, 700)}</p></li>`);++ else if (l.t === 'note') rows.push(html`<li class="cw-note is-${l.level || 'info'}">${raw(ICON.note)}<p>${clip(l.text, 400)}</p></li>`);++ else if (l.t === 'handoff') rows.push(html`<li class="cw-step">${raw(ICON.step)}<span>Handed to ${l.to || 'another agent'}${l.reason ? `: ${clip(l.reason, 160)}` : ''}</span></li>`);++ else if (l.t === 'tool') {++ const res = results.get(l.id);++ const state = !res ? 'is-run' : res.ok ? 'is-ok' : 'is-bad';++ rows.push(html`<li class="cw-tool ${state}"><details><summary>${raw(ICON.tool)}<b>${toolLabel(l.name)}</b>${l.what ? html`<span class="cw-in">${clip(l.what, 90)}</span>` : ''}<span class="cw-st">${res ? raw(res.ok ? ICON.ok : ICON.bad) : ''}${res && res.duration_ms ? msText(res.duration_ms) : ''}</span></summary>++${res ? html`<pre class="cw-out">${clip(res.output, 1500)}</pre>` : html`<p class="cw-out muted">Running…</p>`}</details></li>`);++ }++ }++ return rows;++ }++++ function workBlock(row, events, open) {++ const lines = taskEvents.lines(events.slice(-EVENTS_MAX), { final: !open, state: row.state });++ const rows = workRows(lines);++ const tools = lines.filter((l) => l.t === 'tool').length;++ const took = tookOf(row);++ const head = open++ ? html`<span class="cw-live" data-live-line>${LIVE_TEXT[row.state] || 'Working'}…</span>`++ : html`<span>${took ? `Worked for ${took}` : 'Worked'}${tools ? ` · ${tools} tool${tools === 1 ? '' : 's'}` : ''}</span>`;++ if (!open && !rows.length) return '';++ return html`<details class="cw"${open ? raw(' data-open') : ''}><summary>${head}<svg class="cw-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 10l4 4 4-4"/></svg></summary>++<ol class="cw-list" data-work>${rows}</ol></details>`;++ }++++ function answerBlock(row, t) {++ const { chipify, modelName, usd } = pageKit();++ const result = t.result || {};++ const evidence = Array.isArray(result.evidence) ? result.evidence.filter((e) => e && /^E\d{1,3}$/.test(String(e.id))) : [];++ // Citations open the evidence on the Details page (the chat keeps one line per answer, not the evidence list).++ const body = chipify(markdown(result.answer || '', { headingOffset: 2 }), evidence).replace(/href="#ev-/g, `href="/tasks/${row.id}?view=details#ev-`);++ const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];++ const sources = Array.isArray(result.sources) ? result.sources.slice(0, 8) : [];++ const checked = result.checked || {};++ const claims = Number(checked.claims) || 0;++ const agent = catalog.byId(result.agent);++ const took = tookOf(row);++ const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return String(u).slice(0, 40); } };++ return html`<div class="ct-answer prose" data-answer>${raw(body)}</div>++${artifacts.length ? html`<ul class="ct-files">${artifacts.map((a) => html`<li><a href="${a.url}" rel="nofollow noopener">${/^image\/(svg\+xml|png|jpe?g|gif|webp)$/.test(a.mime) ? html`<img src="${a.url}" alt="${a.name}" loading="lazy">` : raw(ICON.file)}<span>${a.name}</span></a></li>`)}</ul>` : ''}++${sources.length ? html`<p class="ct-sources">${sources.map((u) => html`<a href="${u}" rel="nofollow ugc noopener" target="_blank">${host(u)}</a>`)}</p>` : ''}++<div class="ct-meta"><span title="${agent ? agent.name : result.agent || ''}">${MODE_LABEL[row.mode] || row.mode}${result.model ? ` · ${modelName(result.model)}` : ''}</span>${t.cost ? html`<span>${usd(t.cost.usd)}</span>` : ''}${took ? html`<span>${took}</span>` : ''}${claims ? html`<span class="ct-checked" title="${claims} claim${claims === 1 ? '' : 's'} checked by a second model">${raw(ICON.ok)}Checked</span>` : ''}++<span class="ct-acts"><button type="button" class="ct-act" data-copy hidden>${raw(ICON.copy)}<span>Copy</span></button><a class="ct-act" href="/tasks/${row.id}?view=details">${raw(ICON.more)}<span>Details</span></a></span></div>`;++ }++++ function questionBlock(row, events) {++ const q = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'question');++ if (!q) return html`<div class="ct-card is-wait"><p><b>Actor is waiting for you.</b></p><p><a href="/tasks/${row.id}?view=details#question">Open the question</a></p></div>`;++ const options = Array.isArray(q.options) ? q.options : [];++ const acting = q.question_kind === 'acting';++ return html`<div class="ct-card is-wait"><p><b>${acting ? 'Actor was stopped mid-action' : 'One question first'}</b></p><p>${String(q.text || '')}</p>++<form method="post" action="/tasks/${row.id}/answer" class="ct-opts">${options.map((o) => html`<button class="ct-opt" type="submit" name="answer" value="${o}">${o}</button>`)}</form>++${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class="ct-own"><input name="answer" type="text" maxlength="2000" required placeholder="Or answer in your own words" aria-label="Your answer"><button class="ct-opt" type="submit">Answer</button></form>`}</div>`;++ }++++ function failedBlock(row, t) {++ const err = t.error || {};++ const why = {++ 'actor.agents.exhausted': 'No answer passed its check, so nothing unchecked was shown.',++ 'actor.check.unavailable': 'The answer could not be checked, so it was not shown.',++ 'actor.task.timeout': 'It ran too long and was stopped.',++ 'actor.task.interrupted': 'Actor restarted while this was running. Nothing more was charged.',++ }[err.code] || err.detail || 'It stopped without saying why.';++ return html`<div class="ct-card is-bad"><p><b>Actor couldn't finish this.</b> ${why}</p>++<form method="post" action="/chat" class="ct-retry"><input type="hidden" name="task" value="${row.task}"><input type="hidden" name="mode" value="best">${isFollow(row) ? html`<input type="hidden" name="parent" value="${row.parent_id}">` : ''}<button class="ct-opt" type="submit">Try again in Best mode</button><a class="ct-act" href="/tasks/${row.id}?view=details">Details</a></form></div>`;++ }++++ async function turnHtml(row, { events = null } = {}) {++ const t = store.toWire(row, { baseUrl: config.baseUrl });++ const evs = events || await store.eventsAfter(s, row.id, 0);++ const open = OPEN.includes(row.state);++ let reply;++ if (row.state === 'succeeded') reply = html`${workBlock(row, evs, false)}${answerBlock(row, t)}`;++ else if (row.state === 'needs_you') reply = html`${workBlock(row, evs, false)}${questionBlock(row, evs)}`;++ else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t)}`;++ else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;++ else reply = workBlock(row, evs, true);++ return html`<li class="ct-turn state-${row.state}" id="t-${row.id}" data-task="${row.id}" data-state="${row.state}" data-last="${row.last_seq}"${open ? raw(' data-open') : ''}>++<div class="ct-you"><p class="ct-bubble">${row.task}</p></div>++<div class="ct-bot"><span class="ct-avatar" aria-hidden="true"></span><div class="ct-reply">${reply}</div></div>++</li>`;++ }++++ // ── The page ───────────────────────────────────────────────────────────────────────────────────────────────────++ function sidebar(chats, currentId) {++ const groups = [];++ let last = null;++ const t = now();++ for (const c of chats) {++ const g = groupOf(c.last, t);++ if (g !== last) { groups.push({ name: g, items: [] }); last = g; }++ groups[groups.length - 1].items.push(c);++ }++ return html`<aside class="cs" id="chats" aria-label="Your chats">++<div class="cs-head"><a class="cs-new" href="/chat">${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>')}New chat</a>++<button type="button" class="cs-close" data-cs-close aria-label="Close the chats" hidden>${raw(ICON.bad)}</button></div>++<label class="cs-find"><span class="sr-only">Find a chat</span><input type="search" placeholder="Find a chat" data-cs-find autocomplete="off"></label>++<nav class="cs-list">${groups.length ? groups.map((g) => html`<p class="cs-group">${g.name}</p><ul>${g.items.map((c) => html`<li><a href="/chat/${c.id}"${c.id === currentId ? raw(' aria-current="page"') : ''} data-title="${c.title.toLowerCase()}">${c.open ? html`<i class="cs-dot" title="Working"></i>` : ''}<span>${c.title}</span></a></li>`)}</ul>`)++ : html`<p class="cs-empty">Your chats show here.</p>`}</nav>++<p class="cs-foot"><a href="/console">Console</a><a href="/me/agents">Your agents</a><a href="/memory">Memory</a></p>++</aside>`;++ }++++ function composer({ parent = null, mode = 'balanced', busy = false, problem = null, text = '' } = {}) {++ return html`<form class="cc" method="post" action="/chat" data-composer${busy ? raw(' data-busy') : ''}>++${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}++${parent ? html`<input type="hidden" name="parent" value="${parent}">` : ''}++<div class="cc-box">++<label class="sr-only" for="cc-text">Message Actor</label>++<textarea id="cc-text" name="task" rows="1" maxlength="20000" required placeholder="${parent ? 'Reply to Actor' : 'Ask Actor to do something'}" data-cc-text>${text}</textarea>++<div class="cc-row">++<label class="cc-mode" title="How Actor picks the agent"><span class="sr-only">Mode</span><select name="mode" data-cc-mode>${MODES.map((m) => html`<option value="${m}" title="${MODE_HINT[m] || ''}"${m === mode ? raw(' selected') : ''}>${MODE_LABEL[m] || m}</option>`)}</select></label>++<span class="cc-note" data-cc-note></span>++<button class="cc-send" type="submit" aria-label="Send" data-cc-send>${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M6 11l6-6 6 6"/></svg>')}</button>++</div></div>++<p class="cc-fine">Actor checks each answer with a second model. ${config.allowance ? `Free: ${config.allowance.tasksPerDay} tasks a day.` : ''}</p>++</form>`;++ }++++ const STARTERS = [++ ['Check a domain', 'Who handles the mail for openvibe.network, and is DMARC set up?'],++ ['Read a page', 'Read https://openvibe.live and tell me in three lines what it is.'],++ ['Work something out', 'What is 18% of 2,450, and what is that per month over a year?'],++ ['Plan', 'Plan a 20-minute stream: an opening, three segments and a close.'],++ ];++++ async function render(req, res, { row = null, problem = null, text = '', status = 200 } = {}) {++ const requester = requesterOf(req);++ const [chats, turns] = await Promise.all([chatsFor(requester), row ? chainOf(row) : []]);++ const first = turns[0] || null;++ const lastTurn = turns[turns.length - 1] || null;++ const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));++ const turnHtmls = await Promise.all(turns.map((t) => turnHtml(t)));++ const title = first ? clip(first.task, 60) : 'New chat';++ return send(res, status, {++ viewer: req.viewer, config, path: req.originalUrl, title, app: true, noReferrer: true,++ css: ['css/chat.css'], scripts: ['js/chat.js'],++ body: html`${busy ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}++<div class="chat" data-chat="${first ? first.id : ''}">++${sidebar(chats, first ? first.id : null)}++<div class="cs-scrim" data-cs-close hidden></div>++<section class="ct" aria-label="${first ? 'Conversation' : 'New chat'}">++<header class="ct-head"><button type="button" class="ct-menu" data-cs-open aria-controls="chats" aria-expanded="false">${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>')}<span class="sr-only">Your chats</span></button>++<h1 class="ct-title">${title}</h1>${first ? html`<a class="ct-new" href="/chat" aria-label="New chat">${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>')}</a>` : ''}</header>++<div class="ct-scroll" data-scroll>++${turns.length ? html`<ol class="ct-thread" data-thread>${turnHtmls}</ol>` : html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>Ask in plain words. Actor picks the agent and the tools, checks the answer, and shows what it cost.</p>++<ul class="ct-starters">${STARTERS.map(([k, v]) => html`<li><button type="button" data-starter="${v}"><b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`}++</div>++${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, problem, text })}++</section>++</div>`,++ });++ }++++ const loginTo = (req) => `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;++++ r.get('/chat', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req)) return res.redirect(303, loginTo(req));++ const text = typeof req.query.ask === 'string' ? req.query.ask.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').slice(0, 2000) : '';++ return render(req, res, { text });++ });++++ r.get('/chat/turn/:id', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return res.status(404).end();++ const row = await store.getTask(s, req.params.id);++ if (!row || row.requester !== requesterOf(req)) return res.status(404).end();++ res.type('html').send(require('../render/html').render(await turnHtml(row)));++ });++++ r.get('/chat/:id', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (!signedIn(req)) return res.redirect(303, loginTo(req));++ const row = TASK_ID.test(req.params.id) ? await store.getTask(s, req.params.id) : null;++ if (!row || row.requester !== requesterOf(req)) return render(req, res, { status: 404, problem: 'That chat is not here: it may have expired (tasks are kept 30 days) or belong to someone else.' });++ return render(req, res, { row });++ });++++ const PROBLEM = {++ 'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',++ 'actor.budget.day_spent': 'Today\'s budget is spent. It resets at midnight UTC.',++ 'actor.capacity.spent': 'Actor has used today\'s free capacity across everyone. It resets at midnight UTC.',++ };++ r.post('/chat', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const wantsJson = String(req.get('accept') || '').includes('application/json');++ const fail = (status, detail, row = null, text = '') => (wantsJson ? res.status(status).json({ error: detail }) : render(req, res, { row, problem: detail, text, status }));++ if (!signedIn(req)) return wantsJson ? res.status(401).json({ error: 'Sign in first.' }) : res.redirect(303, '/auth/login?next=%2Fchat');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A message must be sent from openvibe.actor itself.');++ const text = String((req.body && req.body.task) || '').trim().slice(0, 20000);++ const mode = MODES.includes(req.body && req.body.mode) ? req.body.mode : 'balanced';++ const parentId = req.body && TASK_ID.test(String(req.body.parent || '')) ? String(req.body.parent) : null;++ const parent = parentId ? await store.getTask(s, parentId) : null;++ if (parentId && (!parent || parent.requester !== requesterOf(req))) return fail(404, 'That chat is not here any more. Start a new one.');++ if (!text) return fail(422, 'Write a message first.', parent);++ if (parent && (OPEN.includes(parent.state) || parent.state === 'needs_you')) return fail(409, 'Actor is still working on the last message. Wait for it, or stop it.', parent, text);++ const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode }, parentId: parent ? parent.id : null });++ if (out.code) return fail(out.status === 503 || out.status === 429 ? out.status : 422, PROBLEM[out.code] || out.detail || 'That could not start. Try again in a moment.', parent, text);++ const row = await store.getTask(s, out.task.id);++ if (wantsJson) return res.status(201).json({ id: row.id, chat: parent ? undefined : row.id, url: `/chat/${row.id}`, turn: require('../render/html').render(await turnHtml(row, { events: [] })) });++ return res.redirect(303, `/chat/${row.id}#t-${row.id}`);++ });++++ return r;++}++++module.exports = { createChatPages, chatsOf, groupOf };@@@ -119,7 +119,7 @@ const msText = (n) => {// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".const BRANDS = { deepseek: 'DeepSeek', gpt: 'GPT', openai: 'OpenAI', minimax: 'MiniMax', claude: 'Claude', gemini: 'Gemini', qwen: 'Qwen', glm: 'GLM', kimi: 'Kimi' };function modelName(id) {-- const m = String(id || '').split(':').pop();++ const m = String(id || '').split(':').pop().replace(/-\d{4}-\d{2}-\d{2}$/, '');if (!m) return 'another model';if (/^gpt-/i.test(m)) return `GPT-${m.slice(4)}`;return m.split('-').map((w) => BRANDS[w.toLowerCase()] || (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');@@@ -587,7 +587,7 @@ ${raw(foldBlock({const mode = MODES.includes(req.body && req.body.mode) ? req.body.mode : 'balanced';const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode } });if (out.code) return home(req, res, { status: out.status === 503 || out.status === 429 ? out.status : 422, form: { text, mode, problem: out.detail } });-- return res.redirect(303, `/tasks/${out.task.id}`);++ return res.redirect(303, `/chat/${out.task.id}`);});// A follow-up (Actor v2, stage V2-5b): a new task of the same person's that continues an answered one, with its@@@ -609,7 +609,7 @@ ${raw(foldBlock({if (!text) return res.redirect(303, `/tasks/${parent.id}#follow-up`);const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode: parent.mode }, parentId: parent.id });if (out.code) return res.redirect(303, `/tasks/${parent.id}?follow=${encodeURIComponent(out.code)}#follow-up`);-- return res.redirect(303, `/tasks/${out.task.id}`);++ return res.redirect(303, `/chat/${out.task.id}#t-${out.task.id}`);});// ── Durable runs (stage V2-9): fork from a saved step, and compare two runs ──────@@@ -995,6 +995,9 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(exist});r.get('/tasks/:id', async (req, res) => {++ if (req.get('sec-fetch-mode') === 'navigate' && req.query.view !== 'details' && TASK_ID.test(req.params.id) && signedIn(req)) {++ return res.redirect(303, `/chat/${req.params.id}#t-${req.params.id}`);++ }const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;if (!row || row.requester !== requesterOf(req)) {return page(req, res, { title: 'Task not found', body: html`<h1>Task not found</h1><p>${signedIn(req) ? 'There is no task here that belongs to you.' : html`<a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in</a> to see your task.`}</p>` }, 404);@@@ -1454,4 +1457,4 @@ ${table(['Code', 'Meaning'], [return r;}--module.exports = { createPageRoutes, MODE_TEXT };++module.exports = { createPageRoutes, MODE_TEXT, chipify, toolLabel, inputText, modelName, msText, usd };@@@ -25,6 +25,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold into// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.const NAV = [++ { label: 'Chat', href: '/chat', icon: 'fa-comments' },{ label: 'Console', href: '/console', icon: 'fa-gauge-high' },{ label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' },{ label: 'Agents', href: '/agents', icon: 'fa-robot' },@@@ -60,7 +61,7 @@ function renderPage(o) {service: 'actor',apiBase: NETWORK_URL,links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),-- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Console', href: '/console', icon: 'fa-gauge-high' }, { label: 'Tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Agents', href: '/agents', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)++ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Console', href: '/console', icon: 'fa-gauge-high' }, { label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Agents', href: '/agents', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)history: { type: 'page', title: o.title || SITE_NAME },silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,sessionUrl: '/auth/me',@@@ -70,7 +71,7 @@ function renderPage(o) {};// This site's own account links live in the shared navbar's account menu (the page's account// bar below is only for visitors without JavaScript).-- if (signedIn) nav.menu = { before: [{ label: 'Console', href: '/console', icon: 'fa-gauge' }, { label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }, { label: 'Memory', href: '/memory', icon: 'fa-brain' }] };++ if (signedIn) nav.menu = { before: [{ label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Console', href: '/console', icon: 'fa-gauge' }, { label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }, { label: 'Memory', href: '/memory', icon: 'fa-brain' }] };const footer = { service: 'actor', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };const account = signedIn? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`@@@ -109,8 +110,8 @@ ${render(scripts)}body: `<a class="skip" href="#main">Skip to content</a><div id="navbar-mount"></div><noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>--<main id="main" class="page">--${render(crumbs)}++<main id="main" class="page${o.app ? ' page-app' : ''}">++${o.app ? '' : render(crumbs)}${render(o.body)}${o.path === '/' ? foldBlock({id: 'shipped',@@@ -38,7 +38,7 @@ const getTask = (s, id) => s.db.maybe('SELECT * FROM tasks WHERE id = $1', [id])* question and its answer, only the same person's, only answered ones. An answer's [E#] citations belonged to its own* run, so they are taken out: the new run cites only what it fetches itself. Each answer is clipped to `clip` chars.*/--async function followContext(s, row, { depth = 3, clip = 3000 } = {}) {++async function followContext(s, row, { depth = 6, clip = 2500 } = {}) {const out = [];let at = row && row.parent_id;for (let i = 0; at && i < depth; i++) {@@@ -11,7 +11,7 @@ const { boot, check, done } = require('./helpers/boot');const store = require('../server/tasks/store');const taskEvents = require('../public/js/task-events');--const taskIdOf = (res) => String(res.headers.get('location') || '').split('/').filter(Boolean).pop();++const taskIdOf = (res) => String(res.headers.get('location') || '').split('#')[0].split('/').filter(Boolean).pop(); // /tasks/<id> or the chat's /chat/<id>#t-<id>const SAME = { origin: 'https://openvibe.actor' };(async () => {nnew file mode 100644@@@ -0,0 +1,42 @@++'use strict';++// A signed-in Actor with a three-turn chat, for looking at /chat in a browser (not run by test/run.js: only *.test.js++// are). Usage: node test/chat-shots.js [port] → prints the address; the proxy signs every request in as kim.++const http = require('http');++const { boot } = require('./helpers/boot');++++(async () => {++ const port = Number(process.argv[2]) || 0;++ const t = await boot();++ const kim = t.network.addUser('kim');++ const SAME = { 'sec-fetch-site': 'same-origin' };++ t.providers.state.cls = 'lookup';++ const one = (await t.get('/api/v1/tasks', { as: kim, json: { task: 'Who handles the mail for openvibe.network, and is DMARC set up?' }, headers: SAME })).json().id;++ await t.waitFor(one);++ const two = (await t.get(`/tasks/${one}/follow-up`, { as: kim, form: { task: 'And what about openvibe.live?' }, headers: SAME }));++ if (!/\/chat\//.test(two.headers.get('location') || '')) throw new Error('follow-up: ' + two.status + ' ' + two.headers.get('location'));++ const twoId = /\/chat\/(tsk_[0-9A-Z]+)/.exec(two.headers.get('location') || '')[1];++ await t.waitFor(twoId);++ t.providers.reset();++ t.providers.state.cls = 'research';++ t.providers.state.checkOk = false;++ const third = await t.get('/chat', { as: kim, form: { task: 'Summarise the news about OpenVibe this week', parent: twoId }, headers: SAME });++ const bad = (/\/chat\/(tsk_[0-9A-Z]+)/.exec(third.headers.get('location') || '') || [])[1];++ if (!bad) throw new Error('chat post: ' + third.status + ' ' + third.headers.get('location'));++ await t.waitFor(bad);++ t.providers.reset();++ t.providers.state.cls = 'lookup';++ const cookie = t.signIn(kim);++ const proxy = http.createServer((req, res) => {++ const headers = { ...req.headers, cookie: [cookie, req.headers.cookie].filter(Boolean).join('; '), host: new URL(t.base).host, origin: t.base };++ const up = http.request(t.base + req.url, { method: req.method, headers }, (r) => {++ const h = { ...r.headers };++ if (h.location && h.location.startsWith(t.base)) h.location = h.location.slice(t.base.length);++ res.writeHead(r.statusCode, h);++ r.pipe(res);++ });++ up.on('error', () => { res.writeHead(502); res.end(); });++ req.pipe(up);++ });++ await new Promise((r) => proxy.listen(port, '127.0.0.1', r));++ process.stdout.write(`chat ready http://127.0.0.1:${proxy.address().port}/chat/${one} first=${one} failed=${bad}\n`);++})().catch((e) => { process.stderr.write(String(e && e.stack)); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,159 @@++'use strict';++// The chat (plan L55 C1; the owner, 2026-11-01: "a whole conversation system, like Gemini or ChatGPT… your conversations++// on the left… way cleaner"): a conversation is a chain of follow-ups, the sidebar is the person's chains, each turn is++// a bubble and a reply with the work folded and one quiet line; everything else is the turn's Details page. Only the++// person's own chats, only from Actor itself, and a browser opening a task lands in its chat.++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { chatsOf, groupOf } = require('../server/http/chat-pages');++++const SAME = { 'sec-fetch-site': 'same-origin' };++const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const bo = t.network.addUser('bo');++ t.config.loop.selfReflections = 0;++ const s = t.ctx.s;++ const say = async (text, o = {}) => {++ t.providers.state.script = [{ content: o.answer || 'Hetzner CX22 at €4.51 a month [E1].' }];++ const r = await t.get('/chat', { as: o.as || kim, form: { task: text, ...(o.parent ? { parent: o.parent } : {}), ...(o.mode ? { mode: o.mode } : {}) }, headers: o.headers || SAME });++ return r;++ };++ const idOf = (r) => { const m = LOC.exec(r.headers.get('location') || ''); assert.ok(m, `redirect to the chat, got ${r.status} ${r.headers.get('location')}`); return m[1]; };++++ try {++ await check('signed out, /chat asks you to sign in first', async () => {++ const r = await t.get('/chat');++ assert.strictEqual(r.status, 303);++ assert.match(r.headers.get('location'), /^\/auth\/login\?next=%2Fchat/);++ });++++ await check('a new chat: one question, four starters, the composer, and an empty list of chats', async () => {++ const r = await t.get('/chat', { as: kim });++ assert.strictEqual(r.status, 200);++ assert.match(r.text, /<h2>What should Actor do\?<\/h2>/);++ assert.strictEqual((r.text.match(/data-starter="/g) || []).length, 4);++ assert.match(r.text, /<form class="cc" method="post" action="\/chat" data-composer>/);++ assert.match(r.text, /Your chats show here\./);++ assert.match(r.text, /<main id="main" class="page page-app">/, 'the app layout: the whole width, no crumbs');++ assert.ok(!/name="parent"/.test(r.text), 'a new chat follows nothing');++ assert.match(r.text, /<link rel="stylesheet" href="\/css\/chat\.css\?v=[0-9a-f]{10}">/);++ });++++ let first;++ let second;++ await check('sending starts a task and opens its chat: the bubble, the answer, the quiet line, Details', async () => {++ const r = await say('Which VPS is cheapest for 2 vCPU and 4 GB?');++ assert.strictEqual(r.status, 303);++ first = idOf(r);++ await t.waitFor(first);++ const page = await t.get(`/chat/${first}`, { as: kim });++ assert.strictEqual(page.status, 200);++ assert.match(page.text, /<p class="ct-bubble">Which VPS is cheapest for 2 vCPU and 4 GB\?<\/p>/);++ assert.match(page.text, /<div class="ct-answer prose" data-answer>[\s\S]*Hetzner CX22/);++ assert.match(page.text, new RegExp(`<a class="ct-act" href="/tasks/${first}\\?view=details">`), 'Details is the full task page');++ assert.match(page.text, /<div class="ct-meta"><span[^>]*>Balanced/);++ assert.match(page.text, new RegExp(`<input type="hidden" name="parent" value="${first}">`), 'the composer replies to the last turn');++ assert.match(page.text, new RegExp(`<a href="/chat/${first}" aria-current="page"`), 'the chat is in the list, marked as the one open');++ });++++ await check('a reply continues the chat: both turns, in order, one chat in the list, with the turn before in mind', async () => {++ t.providers.state.sent = [];++ const r = await say('And for 8 GB?', { parent: first, answer: 'Hetzner CX32 at €7.59 [E1].' });++ second = idOf(r);++ await t.waitFor(second);++ const row = await s.db.maybe('SELECT parent_id FROM tasks WHERE id = $1', [second]);++ assert.strictEqual(row.parent_id, first);++ for (const at of [first, second]) {++ const page = await t.get(`/chat/${at}`, { as: kim });++ const a = page.text.indexOf(`id="t-${first}"`), b = page.text.indexOf(`id="t-${second}"`);++ assert.ok(a > 0 && b > a, `the chat through ${at} shows both turns, oldest first`);++ assert.strictEqual((page.text.match(/<a href="\/chat\//g) || []).length, 1, 'one chat in the list');++ assert.match(page.text, new RegExp(`<a href="/chat/${first}" aria-current="page"`), 'the list names a chat by its first turn');++ }++ });++++ await check('the list: newest activity first, grouped by day; a fork starts a chat of its own', async () => {++ const rows = [++ { id: 'tsk_A', parent_id: null, fork_of_step: null, task: 'first', state: 'succeeded', created_at: '2026-11-01T08:00:00.000Z' },++ { id: 'tsk_B', parent_id: 'tsk_A', fork_of_step: null, task: 'reply', state: 'running', created_at: '2026-11-01T09:00:00.000Z' },++ { id: 'tsk_C', parent_id: null, fork_of_step: null, task: 'older', state: 'succeeded', created_at: '2026-10-20T09:00:00.000Z' },++ { id: 'tsk_D', parent_id: 'tsk_A', fork_of_step: 2, task: 'a fork', state: 'succeeded', created_at: '2026-11-01T07:00:00.000Z' },++ ];++ const chats = chatsOf(rows);++ assert.deepStrictEqual(chats.map((c) => c.id), ['tsk_A', 'tsk_D', 'tsk_C']);++ assert.strictEqual(chats[0].turns, 2);++ assert.strictEqual(chats[0].open, true, 'a working turn marks its chat');++ const now = Date.parse('2026-11-01T12:00:00Z');++ assert.deepStrictEqual(['2026-11-01T01:00:00Z', '2026-10-31T23:00:00Z', '2026-10-28T10:00:00Z', '2026-10-01T10:00:00Z'].map((iso) => groupOf(iso, now)),++ ['Today', 'Yesterday', 'This week', 'Earlier']);++ });++++ await check('sending from a script gets the new turn back to draw at once', async () => {++ t.providers.state.script = [{ content: 'Netcup at €5.26 [E1].' }];++ const r = await t.get('/chat', { as: kim, form: { task: 'And Netcup?', parent: second }, headers: { ...SAME, accept: 'application/json' } });++ assert.strictEqual(r.status, 201);++ const j = r.json();++ assert.match(j.id, /^tsk_/);++ assert.match(j.turn, new RegExp(`^<li class="ct-turn state-(queued|running|succeeded)" id="t-${j.id}"`));++ assert.match(j.turn, /<p class="ct-bubble">And Netcup\?<\/p>/);++ await t.waitFor(j.id);++ const frag = await t.get(`/chat/turn/${j.id}`, { as: kim });++ assert.strictEqual(frag.status, 200);++ assert.match(frag.text, /data-state="succeeded"/);++ assert.match(frag.text, /Netcup at/);++ assert.strictEqual((await t.get(`/chat/turn/${j.id}`, { as: bo })).status, 404, 'another person: nothing');++ });++++ await check('only your own chats, only from Actor itself, and a message needs words', async () => {++ const theirs = await say('mine now', { as: bo, parent: first });++ assert.strictEqual(theirs.status, 404);++ assert.match(theirs.text, /That chat is not here any more/);++ const page = await t.get(`/chat/${first}`, { as: bo });++ assert.strictEqual(page.status, 404);++ assert.ok(!page.text.includes('Which VPS'), 'nothing of kim\'s chat');++ const cross = await t.get('/chat', { as: kim, form: { task: 'hi' }, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(cross.status, 403);++ const empty = await t.get('/chat', { as: kim, form: { task: ' ' }, headers: SAME });++ assert.strictEqual(empty.status, 422);++ assert.match(empty.text, /Write a message first\./);++ });++++ await check('a browser opening a task lands in its chat; Details and scripts still get the task page', async () => {++ // fetch() sets Sec-Fetch-Mode itself (cors), so a navigation is sent the way a browser sends it.++ const navigate = (p) => new Promise((resolve, reject) => {++ const req = require('http').get(t.base + p, { headers: { cookie: t.signIn(kim), 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none' } }, (res) => {++ let body = ''; res.on('data', (c) => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location, text: body }));++ });++ req.on('error', reject);++ });++ const nav = await navigate(`/tasks/${first}`);++ assert.strictEqual(nav.status, 303);++ assert.strictEqual(nav.location, `/chat/${first}#t-${first}`);++ const details = await navigate(`/tasks/${first}?view=details`);++ assert.strictEqual(details.status, 200);++ assert.match(details.text, /<article class="task state-succeeded"/);++ assert.strictEqual((await t.get(`/tasks/${first}`, { as: kim })).status, 200, 'not a navigation: the page itself');++ });++++ await check('in the browser (public/js/chat.js): the live stream, the turn fragment, Stop, Enter on a keyboard only', async () => {++ const js = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');++ assert.match(js, /new EventSource\('\/api\/v1\/tasks\/' \+ encodeURIComponent\(id\) \+ '\/events\?after='/, 'follows the task\'s own event stream');++ assert.match(js, /fetch\('\/chat\/turn\/' \+ encodeURIComponent\(id\)/, 'the server draws each turn; the page swaps it in');++ assert.match(js, /'\/tasks\/' \+ encodeURIComponent\(busy\.getAttribute\('data-task'\)\) \+ '\/cancel'/, 'Stop cancels the working turn');++ assert.match(js, /\(hover: hover\) and \(pointer: fine\)/, 'Enter sends only with a keyboard and mouse; a phone\'s Enter is a new line');++ assert.match(js, /prefers-reduced-motion: reduce/);++ assert.ok(!/innerHTML = [^;]*(text\.value|e\.text|title)/.test(js), 'what a person typed is never set as HTML');++ });++++ await check('the app bar and the menu lead to Chat', async () => {++ const r = await t.get('/chat', { as: kim });++ assert.match(r.text, /"label":"Chat","href":"\/chat"/);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -26,7 +26,7 @@ const KEY = crypto.randomBytes(32).toString('hex');const SAME = { 'sec-fetch-site': 'same-origin' };const SELF = 'user:usr_01JZ0000000000000000000AAA';const quiet = { log() {}, info() {}, warn() {}, error() {} };--const taskIdOf = (res) => String(res.headers.get('location') || '').split('/').filter(Boolean).pop();++const taskIdOf = (res) => String(res.headers.get('location') || '').split('#')[0].split('/').filter(Boolean).pop(); // /tasks/<id> or the chat's /chat/<id>#t-<id>const waitFor = async (fn, ms = 10_000) => {const until = Date.now() + ms;for (;;) {@@@ -6,7 +6,8 @@ const assert = require('assert');const { boot, check, done } = require('./helpers/boot');const SAME = { 'sec-fetch-site': 'same-origin' };--const ID = /\/tasks\/(tsk_[0-9A-HJKMNP-TV-Z]{26})$/;++// A follow-up opens in the chat (plan L55 C1): /chat/<id>#t-<id>.++const ID = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;(async () => {const t = await boot();@@@ -68,10 +69,10 @@ const ID = /\/tasks\/(tsk_[0-9A-HJKMNP-TV-Z]{26})$/;assert.strictEqual(Number(await s.db.value('SELECT count(*) FROM tasks')), before, 'none of those made a task');});-- await check('the conversation reaches back three answers at most, and never into another person\'s task', async () => {++ await check('the conversation reaches back six answers at most (a chat, L55), and never into another person\'s task', async () => {const { followContext } = require('../server/tasks/store');const ids = [first];-- for (let i = 0; i < 4; i++) {++ for (let i = 0; i < 7; i++) {t.providers.state.script = [{ content: `Answer ${i + 2}.` }];const r = await follow(ids[ids.length - 1], `Question ${i + 2}?`);const id = ID.exec(r.headers.get('location'))[1];@@@ -80,8 +81,8 @@ const ID = /\/tasks\/(tsk_[0-9A-HJKMNP-TV-Z]{26})$/;}const last = await s.db.maybe('SELECT * FROM tasks WHERE id = $1', [ids[ids.length - 1]]);const ctx = await followContext(s, last);-- assert.deepStrictEqual(ctx.map((c) => c.task), ['Question 2?', 'Question 3?', 'Question 4?'], 'the three before it, oldest first');-- await s.db.query('UPDATE tasks SET requester = $2 WHERE id = $1', [ids[3], `user:${bo.subject}`]);++ assert.deepStrictEqual(ctx.map((c) => c.task), ['Question 2?', 'Question 3?', 'Question 4?', 'Question 5?', 'Question 6?', 'Question 7?'], 'the six before it, oldest first');++ await s.db.query('UPDATE tasks SET requester = $2 WHERE id = $1', [ids[ids.length - 2], `user:${bo.subject}`]);assert.deepStrictEqual((await followContext(s, last)).map((c) => c.task), [], 'a parent that is not the same person\'s ends the thread');});
new file mode 100644
new file mode 100644
@@ -0,0 +1,189 @@
@@ -0,0 +1,189 @@
+/* Actor's chat (plan L55 C1). The page scrolls as a whole (no inner scroll box to trap a thumb), the composer sits at
+ the bottom of the screen above the app bar, the chats are a column on wide screens and a drawer on phones. Theme
+ tokens only, so every OpenVibe look applies. */
+.page.page-app { max-width: none; padding: 0; min-height: 0; }
+body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app) .ovf { display: none; }
+.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+
+.chat { --chat-top: 56px; --rail: 272px; --line: color-mix(in srgb, var(--border, #1f2d47) 80%, transparent);
+ display: grid; grid-template-columns: minmax(0, 1fr); min-height: calc(100dvh - var(--chat-top)); }
+@media (min-width: 900px) { .chat { grid-template-columns: var(--rail) minmax(0, 1fr); } }
+
+/* ── The chats ─────────────────────────────────────────────────────────────── */
+.cs { display: flex; flex-direction: column; gap: 10px; padding: 12px 10px 14px; background: var(--bg-secondary, #0f1729); border-right: 1px solid var(--line); }
+@media (min-width: 900px) { .cs { position: sticky; top: var(--chat-top); height: calc(100dvh - var(--chat-top)); overflow-y: auto; overscroll-behavior: contain; } }
+@media (max-width: 899px) {
+ .cs { position: fixed; z-index: 60; inset: 0 auto 0 0; width: min(86vw, 320px); padding-top: calc(12px + env(safe-area-inset-top, 0px)); padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
+ overflow-y: auto; overscroll-behavior: contain; transform: translateX(-104%); transition: transform .26s cubic-bezier(.2, .8, .2, 1); box-shadow: 0 0 40px rgba(0, 0, 0, .45); visibility: hidden; }
+ .chat.is-drawer .cs { transform: none; visibility: visible; }
+ .cs-scrim { position: fixed; inset: 0; z-index: 59; background: rgba(0, 0, 0, .5); }
+ .cs-close { display: grid !important; }
+}
+.cs-scrim[hidden] { display: none; }
+.cs-head { display: flex; align-items: center; gap: 8px; }
+.cs-new { flex: 1; display: flex; align-items: center; gap: 8px; min-height: 42px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line);
+ background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); font-weight: 650; text-decoration: none; }
+.cs-new:hover { border-color: var(--accent, #f97316); }
+.cs-new svg, .cs-close svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
+.cs-close { display: none; place-items: center; width: 42px; height: 42px; border-radius: 12px; border: 1px solid var(--line); background: none; color: var(--text-secondary, #96a7c2); }
+.cs-find input { width: 100%; min-height: 38px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font: inherit; font-size: 14px; }
+.cs-list { flex: 1; min-height: 0; }
+.cs-list ul { list-style: none; margin: 0 0 6px; padding: 0; }
+.cs-group { margin: 12px 8px 4px; font-size: 11.5px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.cs-list a { display: flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 10px; border-radius: 10px; color: var(--text-secondary, #b4c0d4); text-decoration: none; font-size: 14.5px; }
+.cs-list a span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cs-list a:hover { background: var(--bg-hover, rgba(255, 255, 255, .05)); color: var(--text-primary, #e6edf7); }
+.cs-list a[aria-current=page] { background: color-mix(in srgb, var(--accent, #f97316) 16%, transparent); color: var(--text-primary, #e6edf7); font-weight: 600; }
+.cs-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--accent, #f97316); animation: ct-breathe 1.6s ease-in-out infinite; }
+.cs-empty { margin: 14px 10px; font-size: 14px; color: var(--text-muted, #7386a3); }
+.cs-foot { display: flex; flex-wrap: wrap; gap: 4px 14px; margin: 0; padding: 10px 8px 0; border-top: 1px solid var(--line); font-size: 13px; }
+.cs-foot a { color: var(--text-secondary, #96a7c2); text-decoration: none; }
+.cs-foot a:hover { color: var(--text-primary, #e6edf7); }
+
+/* ── The conversation ──────────────────────────────────────────────────────── */
+.ct { display: flex; flex-direction: column; min-width: 0; min-height: calc(100dvh - var(--chat-top) - var(--ovnav-appbar-h, 0px)); }
+.ct-head { position: sticky; top: var(--chat-top); z-index: 20; display: flex; align-items: center; gap: 8px; min-height: 52px; padding: 6px max(12px, env(safe-area-inset-right, 0px)) 6px max(12px, env(safe-area-inset-left, 0px));
+ background: color-mix(in srgb, var(--bg-primary, #0a0f1c) 88%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border-bottom: 1px solid transparent; }
+.ct-head.is-stuck { border-bottom-color: var(--line); }
+.ct-title { flex: 1; min-width: 0; margin: 0; font-size: 16px; font-weight: 650; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ct-menu, .ct-new { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; border: 0; background: none; color: var(--text-secondary, #b4c0d4); }
+.ct-menu:hover, .ct-new:hover { background: var(--bg-hover, rgba(255, 255, 255, .06)); color: var(--text-primary, #e6edf7); }
+.ct-menu svg, .ct-new svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
+@media (min-width: 900px) { .ct-menu { display: none; } }
+.ct-scroll { flex: 1; padding: 8px max(16px, env(safe-area-inset-right, 0px)) 16px max(16px, env(safe-area-inset-left, 0px)); }
+.ct-thread { list-style: none; max-width: 780px; margin: 0 auto; padding: 0; display: grid; gap: 30px; }
+
+.ct-turn { display: grid; gap: 14px; scroll-margin-top: calc(var(--chat-top) + 60px); }
+.ct-you { display: flex; justify-content: flex-end; }
+.ct-bubble { max-width: min(85%, 620px); margin: 0; padding: 10px 15px; border-radius: 20px 20px 6px 20px; white-space: pre-wrap; overflow-wrap: anywhere;
+ background: color-mix(in srgb, var(--accent, #f97316) 14%, var(--bg-card, #131c2e)); color: var(--text-primary, #e6edf7); line-height: 1.5; }
+.ct-turn.is-new .ct-bubble { animation: ct-rise .32s cubic-bezier(.2, .8, .2, 1) both; }
+.ct-bot { display: grid; grid-template-columns: 28px minmax(0, 1fr); gap: 12px; align-items: start; }
+.ct-avatar { width: 28px; height: 28px; margin-top: 2px; border-radius: 9px; background: radial-gradient(circle at 30% 30%, color-mix(in srgb, var(--accent, #f97316) 70%, #fff), var(--accent, #f97316) 55%, color-mix(in srgb, var(--accent, #f97316) 40%, #6d28d9));
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #f97316) 35%, transparent); }
+.ct-turn[data-open] .ct-avatar { animation: ct-breathe 1.6s ease-in-out infinite; }
+.ct-reply { min-width: 0; display: grid; gap: 10px; }
+
+/* The work, folded: "Worked for 12 s · 3 tools". Open while the turn works, so its live line shows. */
+.cw { border-radius: 14px; }
+.cw > summary { list-style: none; display: inline-flex; align-items: center; gap: 6px; max-width: 100%; min-height: 32px; padding: 0 10px 0 2px; border-radius: 10px; cursor: pointer;
+ color: var(--text-secondary, #96a7c2); font-size: 14px; }
+.cw > summary::-webkit-details-marker { display: none; }
+.cw > summary:hover { color: var(--text-primary, #e6edf7); }
+.cw-chev { flex: none; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; transition: transform .2s; }
+.cw[open] .cw-chev { transform: rotate(180deg); }
+.cw-live { background: linear-gradient(90deg, var(--text-secondary, #96a7c2) 20%, var(--text-primary, #e6edf7) 40%, var(--text-secondary, #96a7c2) 60%) 0 0 / 300% 100%;
+ -webkit-background-clip: text; background-clip: text; color: transparent; animation: ct-shimmer 2.2s linear infinite; font-weight: 550; }
+.cw-list { list-style: none; margin: 4px 0 0; padding: 2px 0 2px 12px; border-left: 2px solid var(--line); display: grid; gap: 6px; }
+.cw-list > li { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 8px; align-items: start; font-size: 14px; color: var(--text-secondary, #a8b3c4); animation: ct-rise .25s ease both; }
+.cw-list svg { width: 16px; height: 16px; margin-top: 3px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
+.cw-step span { font-weight: 600; color: var(--text-primary, #e6edf7); }
+.cw-note p { margin: 0; line-height: 1.5; overflow-wrap: anywhere; }
+.cw-note.is-warning, .cw-note.is-error { color: var(--warning-text, var(--warning, #f59e0b)); }
+.cw-tool { grid-template-columns: minmax(0, 1fr) !important; }
+.cw-tool details > summary { list-style: none; display: grid; grid-template-columns: 18px auto minmax(0, 1fr) auto; gap: 8px; align-items: center; min-height: 30px; cursor: pointer; }
+.cw-tool details > summary::-webkit-details-marker { display: none; }
+.cw-tool svg { margin-top: 0; }
+.cw-tool b { font-weight: 600; color: var(--text-primary, #e6edf7); white-space: nowrap; }
+.cw-in { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; color: var(--text-muted, #7386a3); }
+.cw-st { display: inline-flex; align-items: center; gap: 4px; font-size: 12.5px; color: var(--text-muted, #7386a3); white-space: nowrap; }
+.cw-tool.is-ok .cw-st svg { stroke: var(--success, #22c55e); }
+.cw-tool.is-bad .cw-st svg { stroke: var(--error, #ef4444); }
+.cw-tool.is-run b { background: linear-gradient(90deg, var(--text-secondary, #96a7c2) 20%, var(--text-primary, #e6edf7) 40%, var(--text-secondary, #96a7c2) 60%) 0 0 / 300% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; animation: ct-shimmer 2.2s linear infinite; }
+.cw-out { margin: 6px 0 4px 26px; max-height: 260px; overflow: auto; font-size: 12.5px; white-space: pre-wrap; overflow-wrap: anywhere; }
+
+/* The answer. */
+.ct-answer { max-width: none; font-size: 16px; line-height: 1.68; color: var(--text-primary, #e6edf7); }
+.ct-answer > :first-child { margin-top: 0; }
+.ct-answer > :last-child { margin-bottom: 0; }
+.ct-answer p, .ct-answer ul, .ct-answer ol { margin: 0 0 .8em; }
+.ct-answer li + li { margin-top: .25em; }
+.ct-answer h2, .ct-answer h3, .ct-answer h4 { margin: 1.2em 0 .45em; line-height: 1.3; }
+.ct-answer h2 { font-size: 1.2rem; } .ct-answer h3 { font-size: 1.06rem; } .ct-answer h4 { font-size: 1rem; }
+.ct-answer pre { position: relative; margin: .6em 0 1em; border-radius: 12px; }
+.ct-answer table { display: block; max-width: 100%; overflow-x: auto; margin: .6em 0 1em; }
+.ct-answer blockquote { margin: .6em 0; padding: 2px 0 2px 14px; border-left: 3px solid var(--line); color: var(--text-secondary, #a8b3c4); }
+.ct-answer .ev { display: inline-grid; place-items: center; min-width: 1.4em; height: 1.4em; padding: 0 .3em; margin: 0 .08em; border-radius: 6px; font-size: .72em; font-weight: 700; vertical-align: .18em;
+ background: color-mix(in srgb, var(--accent, #f97316) 16%, transparent); color: var(--text-secondary, #b4c0d4); text-decoration: none; }
+.ct-answer .ev:hover { background: color-mix(in srgb, var(--accent, #f97316) 32%, transparent); color: var(--text-primary, #e6edf7); }
+.ct-answer.is-reveal > * { animation: ct-reveal .5s cubic-bezier(.2, .8, .2, 1) both; }
+.ct-copy-code { position: absolute; top: 6px; right: 6px; padding: 3px 9px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-secondary, #96a7c2); font-size: 12px; opacity: .85; }
+.ct-copy-code:hover { opacity: 1; color: var(--text-primary, #e6edf7); }
+
+.ct-files { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
+.ct-files a { display: flex; align-items: center; gap: 8px; max-width: 280px; padding: 6px 10px 6px 6px; border-radius: 12px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 14px; }
+.ct-files img { width: 120px; height: 80px; object-fit: cover; border-radius: 8px; }
+.ct-files svg { width: 28px; height: 28px; fill: none; stroke: currentColor; stroke-width: 1.6; }
+.ct-files span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ct-sources { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; }
+.ct-sources a { padding: 2px 10px; border-radius: 999px; border: 1px solid var(--line); color: var(--text-secondary, #a8b3c4); font-size: 13px; text-decoration: none; }
+.ct-sources a:hover { border-color: var(--accent, #f97316); color: var(--text-primary, #e6edf7); }
+
+.ct-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; font-size: 13px; color: var(--text-muted, #7386a3); }
+.ct-meta > span:not(.ct-acts) + span:not(.ct-acts)::before { content: ""; display: inline-block; width: 3px; height: 3px; margin: 0 10px 2px -6px; border-radius: 50%; background: currentColor; opacity: .6; }
+.ct-checked { display: inline-flex; align-items: center; gap: 3px; color: var(--success-text, var(--success, #22c55e)); }
+.ct-checked svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
+.ct-acts { display: inline-flex; gap: 2px; margin-left: auto; }
+.ct-act { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 0 9px; border-radius: 9px; border: 0; background: none; color: var(--text-secondary, #96a7c2); font-size: 13px; text-decoration: none; }
+.ct-act:hover { background: var(--bg-hover, rgba(255, 255, 255, .06)); color: var(--text-primary, #e6edf7); }
+.ct-act svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
+.ct-act.is-done { color: var(--success-text, var(--success, #22c55e)); }
+
+.ct-card { padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); }
+.ct-card p { margin: 0 0 6px; }
+.ct-card.is-wait { border-color: color-mix(in srgb, var(--accent, #f97316) 55%, var(--line)); }
+.ct-card.is-bad { border-color: color-mix(in srgb, var(--error, #ef4444) 45%, var(--line)); }
+.ct-opts, .ct-own, .ct-retry { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
+.ct-own input { flex: 1 1 200px; min-height: 40px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg-primary, #0a0f1c); color: inherit; font: inherit; }
+.ct-opt { min-height: 40px; padding: 0 14px; border-radius: 10px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); font-weight: 600; }
+.ct-opt:hover { border-color: var(--accent, #f97316); }
+.ct-stopped { margin: 0; color: var(--text-muted, #7386a3); font-size: 14px; }
+
+/* A new chat: the mark, one line, and four things to try. */
+.ct-hello { max-width: 720px; margin: 6vh auto 0; text-align: center; }
+.ct-hello-mark { display: inline-block; width: 52px; height: 52px; border-radius: 16px; background: radial-gradient(circle at 30% 30%, color-mix(in srgb, var(--accent, #f97316) 70%, #fff), var(--accent, #f97316) 55%, color-mix(in srgb, var(--accent, #f97316) 40%, #6d28d9));
+ box-shadow: 0 10px 40px color-mix(in srgb, var(--accent, #f97316) 30%, transparent); animation: ct-breathe 3.2s ease-in-out infinite; }
+.ct-hello h2 { margin: 14px 0 6px; font-size: 1.6rem; }
+.ct-hello > p { margin: 0 auto 20px; max-width: 46ch; color: var(--text-secondary, #a8b3c4); }
+.ct-starters { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; text-align: left; }
+.ct-starters button { display: grid; gap: 3px; width: 100%; height: 100%; padding: 12px 14px; border-radius: 14px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-align: left; }
+.ct-starters button:hover { border-color: var(--accent, #f97316); }
+.ct-starters b { font-size: 14px; }
+.ct-starters span { font-size: 13.5px; line-height: 1.45; color: var(--text-secondary, #a8b3c4); }
+@media (max-width: 560px) { .ct-starters { grid-template-columns: minmax(0, 1fr); } }
+
+/* The composer: at the bottom of the screen, above the app bar. */
+.cc { position: sticky; bottom: var(--ovnav-appbar-h, 0px); z-index: 15; padding: 10px max(12px, env(safe-area-inset-right, 0px)) calc(8px + env(safe-area-inset-bottom, 0px)) max(12px, env(safe-area-inset-left, 0px));
+ background: linear-gradient(to top, var(--bg-primary, #0a0f1c) 70%, transparent); }
+html.ov-has-appbar .cc { padding-bottom: 8px; }
+.cc-box { max-width: 780px; margin: 0 auto; padding: 8px 8px 8px 14px; border-radius: 24px; border: 1px solid var(--line); background: var(--bg-card, #131c2e);
+ box-shadow: 0 8px 30px rgba(0, 0, 0, .25); transition: border-color .2s, box-shadow .2s; }
+.cc-box:focus-within { border-color: color-mix(in srgb, var(--accent, #f97316) 60%, var(--line)); box-shadow: 0 8px 30px rgba(0, 0, 0, .25), 0 0 0 3px color-mix(in srgb, var(--accent, #f97316) 18%, transparent); }
+.cc-box textarea { display: block; width: 100%; min-height: 28px; max-height: 40vh; padding: 6px 2px; border: 0; outline: 0; resize: none; background: none; color: var(--text-primary, #e6edf7);
+ font: inherit; font-size: 16px; line-height: 1.5; }
+.cc-box textarea:focus-visible { outline: 0; }
+.cc-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
+.cc-mode select { max-width: 150px; min-height: 34px; padding: 0 26px 0 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: var(--text-secondary, #b4c0d4);
+ font: inherit; font-size: 13px; appearance: none; -webkit-appearance: none; cursor: pointer;
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2396a7c2' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='M7 10l5 5 5-5'/%3E%3C/svg%3E");
+ background-repeat: no-repeat; background-position: right 8px center; background-size: 14px; text-overflow: ellipsis; }
+.cc-note { flex: 1; min-width: 0; font-size: 12.5px; color: var(--text-muted, #7386a3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cc-send { flex: none; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; border: 0; background: var(--accent, #f97316); color: var(--on-accent, #fff); transition: transform .15s, opacity .2s, background .2s; }
+.cc-send svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
+.cc-send:active { transform: scale(.92); }
+.cc-send:disabled { opacity: .45; cursor: default; }
+.cc-send.is-stop { background: var(--text-primary, #e6edf7); color: var(--bg-primary, #0a0f1c); }
+.cc-problem { max-width: 780px; margin: 0 auto 8px; padding: 8px 12px; border-radius: 12px; background: color-mix(in srgb, var(--warning, #f59e0b) 14%, var(--bg-card, #131c2e)); font-size: 14px; }
+.cc-fine { max-width: 780px; margin: 6px auto 0; text-align: center; font-size: 12px; color: var(--text-muted, #7386a3); }
+@media (max-width: 560px) { .cc-fine { display: none; } }
+
+@keyframes ct-breathe { 0%, 100% { opacity: .55; transform: scale(.94); } 50% { opacity: 1; transform: scale(1); } }
+@keyframes ct-shimmer { from { background-position: 100% 0; } to { background-position: -50% 0; } }
+@keyframes ct-rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
+@keyframes ct-reveal { from { opacity: 0; transform: translateY(4px); filter: blur(3px); } to { opacity: 1; transform: none; filter: none; } }
+@media (prefers-reduced-motion: reduce) {
+ .cs, .cw-chev, .cc-box, .cc-send { transition: none; }
+ .cs-dot, .ct-avatar, .cw-live, .cw-tool.is-run b, .ct-hello-mark, .ct-turn.is-new .ct-bubble, .cw-list > li, .ct-answer.is-reveal > * { animation: none !important; }
+ .cw-live, .cw-tool.is-run b { color: var(--text-primary, #e6edf7); background: none; }
+}
+html[data-ov-motion=reduced] .ct-answer.is-reveal > * { animation: none !important; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,256 @@
@@ -0,0 +1,256 @@
+/* Actor's chat in the browser (plan L55 C1). The page works without this file; with it:
+ * - a message sends without a reload, its turn appears at once, and the thread stays at the bottom;
+ * - a turn that is working follows its event stream: the live line says what it is doing now, the work list fills in
+ * (the server renders it: GET /chat/turn/:id), and the answer fades in when it is done;
+ * - Send turns into Stop while Actor works; Enter sends on a keyboard, Shift+Enter is a new line;
+ * - the chats open as a drawer on phones, and the search box filters them;
+ * - Copy on each answer and on each code block.
+ * Booted on load and after an in-place page swap (openvibe-shared/boost's ov:boost:load).
+ */
+(function () {
+ 'use strict';
+ var streams = [];
+
+ function start() {
+ var chat = document.querySelector('.chat');
+ if (!chat || chat.dataset.ready) return;
+ chat.dataset.ready = '1';
+ var form = chat.querySelector('[data-composer]');
+ var text = chat.querySelector('[data-cc-text]');
+ var send = chat.querySelector('[data-cc-send]');
+ var note = chat.querySelector('[data-cc-note]');
+ var head = chat.querySelector('.ct-head');
+ var fine = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)').matches;
+ var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ // ── Where the navbar ends: the sticky header and the chats column sit under it, and follow it when it steps away.
+ var nav = document.querySelector('.openvibe-navbar') || document.getElementById('navbar-mount');
+ var topWas = -1, ticking = false;
+ function measure() {
+ ticking = false;
+ var b = nav ? Math.max(0, Math.round(nav.getBoundingClientRect().bottom)) : 0;
+ if (b !== topWas) { topWas = b; chat.style.setProperty('--chat-top', b + 'px'); }
+ if (head) head.classList.toggle('is-stuck', window.scrollY > 8);
+ }
+ function onScroll() { if (!ticking) { ticking = true; requestAnimationFrame(measure); } }
+ measure();
+ window.addEventListener('scroll', onScroll, { passive: true });
+ window.addEventListener('resize', onScroll);
+ setTimeout(measure, 400);
+
+ // ── The thread stays at the bottom while you are there.
+ function nearBottom() { return window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 220; }
+ function toBottom(smooth) { window.scrollTo({ top: document.documentElement.scrollHeight, behavior: smooth && !reduce ? 'smooth' : 'auto' }); }
+ if (!location.hash && chat.querySelector('[data-thread]')) toBottom(false);
+
+ // ── The chats: a drawer on phones, filtered by the search box.
+ var opener = chat.querySelector('[data-cs-open]');
+ var scrim = chat.querySelector('.cs-scrim');
+ var closeBtn = chat.querySelector('.cs-close');
+ function drawer(on) {
+ chat.classList.toggle('is-drawer', on);
+ if (scrim) scrim.hidden = !on;
+ if (closeBtn) closeBtn.hidden = !on;
+ if (opener) opener.setAttribute('aria-expanded', on ? 'true' : 'false');
+ if (on) { var f = chat.querySelector('[data-cs-find]'); if (f && fine) f.focus(); }
+ }
+ if (opener) opener.addEventListener('click', function () { drawer(true); });
+ chat.querySelectorAll('[data-cs-close]').forEach(function (el) { el.addEventListener('click', function () { drawer(false); }); });
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && chat.classList.contains('is-drawer')) drawer(false); });
+ var find = chat.querySelector('[data-cs-find]');
+ if (find) find.addEventListener('input', function () {
+ var q = find.value.trim().toLowerCase();
+ chat.querySelectorAll('.cs-list li').forEach(function (li) { var a = li.querySelector('a'); li.hidden = !!q && (!a || (a.getAttribute('data-title') || '').indexOf(q) === -1); });
+ chat.querySelectorAll('.cs-group').forEach(function (g) { var ul = g.nextElementSibling; g.hidden = !!ul && !ul.querySelector('li:not([hidden])'); });
+ });
+
+ // ── The composer.
+ function grow() { if (!text) return; text.style.height = 'auto'; text.style.height = Math.min(text.scrollHeight, window.innerHeight * 0.4) + 'px'; }
+ if (text) {
+ text.addEventListener('input', function () { grow(); syncSend(); });
+ text.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && fine) { e.preventDefault(); if (form.requestSubmit) form.requestSubmit(); else form.submit(); }
+ });
+ grow();
+ }
+ chat.querySelectorAll('[data-starter]').forEach(function (b) {
+ b.addEventListener('click', function () { text.value = b.getAttribute('data-starter'); grow(); syncSend(); text.focus(); });
+ });
+ function openTurn() { var t = chat.querySelectorAll('.ct-turn'); var last = t[t.length - 1]; return last && (last.hasAttribute('data-open') || last.getAttribute('data-state') === 'needs_you') ? last : null; }
+ var STOP = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor" stroke="none"/></svg>';
+ var SEND = send ? send.innerHTML : '';
+ function syncSend() {
+ if (!send) return;
+ var busy = openTurn();
+ send.classList.toggle('is-stop', !!busy && busy.hasAttribute('data-open'));
+ send.innerHTML = busy && busy.hasAttribute('data-open') ? STOP : SEND;
+ send.setAttribute('aria-label', busy && busy.hasAttribute('data-open') ? 'Stop' : 'Send');
+ send.disabled = !(busy && busy.hasAttribute('data-open')) && !(text && text.value.trim());
+ }
+ syncSend();
+
+ function problem(msg) {
+ var p = form.querySelector('.cc-problem');
+ if (!msg) { if (p) p.remove(); return; }
+ if (!p) { p = document.createElement('p'); p.className = 'cc-problem'; p.setAttribute('role', 'alert'); form.insertBefore(p, form.firstChild); }
+ p.textContent = msg;
+ }
+ function setParent(id) {
+ var input = form.querySelector('input[name=parent]');
+ if (!input) { input = document.createElement('input'); input.type = 'hidden'; input.name = 'parent'; form.appendChild(input); }
+ input.value = id;
+ if (text) text.placeholder = 'Reply to Actor';
+ }
+
+ if (form) form.addEventListener('submit', function (e) {
+ var busy = openTurn();
+ if (busy && busy.hasAttribute('data-open')) { // Stop
+ e.preventDefault();
+ send.disabled = true;
+ if (note) note.textContent = 'Stopping…';
+ fetch('/tasks/' + encodeURIComponent(busy.getAttribute('data-task')) + '/cancel', { method: 'POST', credentials: 'same-origin', redirect: 'manual' }).catch(function () {});
+ return;
+ }
+ if (!window.fetch || !text || !text.value.trim()) return;
+ e.preventDefault();
+ problem(null);
+ send.disabled = true;
+ var body = new URLSearchParams(new FormData(form));
+ fetch('/chat', { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body: body.toString() })
+ .then(function (r) { return r.json().catch(function () { return { error: 'That could not be sent. Try again.' }; }).then(function (j) { return { ok: r.ok, j: j }; }); })
+ .then(function (res) {
+ if (!res.ok || !res.j.turn) { problem(res.j.error || 'That could not be sent. Try again.'); syncSend(); return; }
+ var thread = chat.querySelector('[data-thread]');
+ if (!thread) {
+ var hello = chat.querySelector('.ct-hello');
+ thread = document.createElement('ol'); thread.className = 'ct-thread'; thread.setAttribute('data-thread', '');
+ if (hello) hello.replaceWith(thread); else chat.querySelector('[data-scroll]').appendChild(thread);
+ var title = chat.querySelector('.ct-title'); if (title) title.textContent = text.value.trim().slice(0, 60);
+ try { history.pushState({}, '', res.j.url); } catch (err) { /* */ }
+ addChat(res.j.id, text.value.trim());
+ }
+ var box = document.createElement('div'); box.innerHTML = res.j.turn;
+ var turn = box.firstElementChild;
+ turn.classList.add('is-new');
+ thread.appendChild(turn);
+ text.value = ''; grow();
+ setParent(res.j.id);
+ follow(turn);
+ syncSend();
+ toBottom(true);
+ })
+ .catch(function () { problem('No connection. Your message is still in the box.'); syncSend(); });
+ });
+
+ // A new chat joins the top of the list.
+ function addChat(id, title) {
+ var list = chat.querySelector('.cs-list');
+ if (!list) return;
+ var empty = list.querySelector('.cs-empty'); if (empty) empty.remove();
+ chat.querySelectorAll('.cs-list a[aria-current]').forEach(function (a) { a.removeAttribute('aria-current'); });
+ var ul = list.querySelector('ul');
+ if (!ul || !list.querySelector('.cs-group') || list.querySelector('.cs-group').textContent !== 'Today') {
+ var g = document.createElement('p'); g.className = 'cs-group'; g.textContent = 'Today';
+ ul = document.createElement('ul'); list.insertBefore(ul, list.firstChild); list.insertBefore(g, ul);
+ }
+ var li = document.createElement('li'), a = document.createElement('a'), dot = document.createElement('i'), span = document.createElement('span');
+ a.href = '/chat/' + id; a.setAttribute('aria-current', 'page'); a.setAttribute('data-title', title.toLowerCase());
+ dot.className = 'cs-dot'; span.textContent = title.slice(0, 80);
+ a.appendChild(dot); a.appendChild(span); li.appendChild(a); ul.insertBefore(li, ul.firstChild);
+ }
+
+ // ── A turn at work: its stream, its live line, and the answer when it lands.
+ function liveLine(turn) {
+ var line = turn.querySelector('[data-live-line]');
+ if (!line) return;
+ var run = turn.querySelectorAll('.cw-tool.is-run b');
+ var steps = turn.querySelectorAll('.cw-step span, .cw-note p');
+ var txt = run.length ? 'Using ' + run[run.length - 1].textContent + '…'
+ : steps.length ? steps[steps.length - 1].textContent : '';
+ if (txt) line.textContent = txt.length > 90 ? txt.slice(0, 89) + '…' : txt;
+ }
+ function refresh(turn, done) {
+ var id = turn.getAttribute('data-task');
+ return fetch('/chat/turn/' + encodeURIComponent(id), { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.text() : null; })
+ .then(function (h) {
+ if (!h) return;
+ var stay = nearBottom();
+ var box = document.createElement('div'); box.innerHTML = h;
+ var next = box.firstElementChild;
+ if (!next) return;
+ var finished = !next.hasAttribute('data-open');
+ if (!finished) {
+ // Only the work list changes while it works: the fold keeps its open or closed state.
+ var list = turn.querySelector('[data-work]'), fresh = next.querySelector('[data-work]');
+ if (list && fresh) list.innerHTML = fresh.innerHTML;
+ else if (fresh) turn.querySelector('.ct-reply').innerHTML = next.querySelector('.ct-reply').innerHTML;
+ liveLine(turn);
+ } else {
+ turn.replaceWith(next);
+ var ans = next.querySelector('[data-answer]');
+ if (ans && !reduce) { ans.classList.add('is-reveal'); Array.prototype.forEach.call(ans.children, function (c, i) { c.style.animationDelay = Math.min(i, 14) * 55 + 'ms'; }); }
+ decorate(next);
+ var dot = chat.querySelector('.cs-list a[aria-current] .cs-dot'); if (dot) dot.remove();
+ if (note) note.textContent = '';
+ syncSend();
+ if (text && fine) text.focus();
+ }
+ if (stay) toBottom(true);
+ if (done && finished) done();
+ })
+ .catch(function () {});
+ }
+ function follow(turn) {
+ if (!window.EventSource || !turn || !turn.hasAttribute('data-open') || turn.dataset.following) return;
+ turn.dataset.following = '1';
+ liveLine(turn);
+ var id = turn.getAttribute('data-task');
+ var src = new EventSource('/api/v1/tasks/' + encodeURIComponent(id) + '/events?after=' + encodeURIComponent(turn.getAttribute('data-last') || '0'));
+ streams.push(src);
+ var timer = 0;
+ function soon() { if (timer) return; timer = setTimeout(function () { timer = 0; refresh(turn); }, 450); }
+ src.addEventListener('output', function (m) {
+ try {
+ var e = JSON.parse(m.data);
+ var line = turn.querySelector('[data-live-line]');
+ if (line && e.step === 'tool_call' && e.tool) line.textContent = 'Using ' + String(e.tool).replace(/_/g, ' ') + '…';
+ } catch (err) { /* a malformed event is skipped */ }
+ soon();
+ });
+ src.addEventListener('state', soon);
+ src.addEventListener('end', function () { src.close(); clearTimeout(timer); timer = 0; refresh(turn); });
+ src.onerror = function () { if (src.readyState === 2) setTimeout(function () { refresh(turn); }, 3000); };
+ }
+ chat.querySelectorAll('.ct-turn[data-open]').forEach(function (t) { follow(t); });
+
+ // ── Copy: an answer, and each code block in it.
+ function decorate(rootEl) {
+ (rootEl || chat).querySelectorAll('[data-copy]').forEach(function (b) {
+ if (b.dataset.wired) return; b.dataset.wired = '1'; b.hidden = false;
+ b.addEventListener('click', function () {
+ var ans = b.closest('.ct-reply').querySelector('[data-answer]');
+ copy(ans ? ans.innerText : '', b);
+ });
+ });
+ (rootEl || chat).querySelectorAll('.ct-answer pre').forEach(function (pre) {
+ if (pre.querySelector('.ct-copy-code')) return;
+ var b = document.createElement('button'); b.type = 'button'; b.className = 'ct-copy-code'; b.textContent = 'Copy';
+ b.addEventListener('click', function () { var code = pre.querySelector('code') || pre; copy(code.innerText, b); });
+ pre.appendChild(b);
+ });
+ }
+ function copy(str, btn) {
+ var label = btn.querySelector('span') || btn;
+ var was = label.textContent;
+ function ok() { label.textContent = 'Copied'; btn.classList.add('is-done'); setTimeout(function () { label.textContent = was; btn.classList.remove('is-done'); }, 1600); }
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(str).then(ok, function () {});
+ }
+ decorate(chat);
+ }
+
+ function stop() { streams.forEach(function (s) { try { s.close(); } catch (e) { /* */ } }); streams = []; }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
+ document.addEventListener('ov:boost:load', start);
+ window.addEventListener('pagehide', stop);
+})();
@@ -51,6 +51,7 @@ const { createPrincipal } = require('./http/principal');
@@ -51,6 +51,7 @@ const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const resourceIndex = require('./registry/resource-index');
const resourceIndex = require('./registry/resource-index');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
+const { createChatPages } = require('./http/chat-pages');
const { createAgentPages } = require('./http/agent-pages');
const { createAgentPages } = require('./http/agent-pages');
const { createConsolePages } = require('./http/console-pages');
const { createConsolePages } = require('./http/console-pages');
const { createMemoryPages } = require('./http/memory-pages');
const { createMemoryPages } = require('./http/memory-pages');
@@ -241,6 +242,7 @@ async function createApp(opts = {}) {
@@ -241,6 +242,7 @@ async function createApp(opts = {}) {
app.use(createMemoryPages(ctx));
app.use(createMemoryPages(ctx));
app.use(createConnectionsPages(ctx));
app.use(createConnectionsPages(ctx));
app.use(createConsolePages(ctx, opts.bot || {}));
app.use(createConsolePages(ctx, opts.bot || {}));
+ app.use(createChatPages(ctx));
const pageRoutes = createPageRoutes(ctx);
const pageRoutes = createPageRoutes(ctx);
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public
// pages. The pages themselves keep their own routes — a signed-in visitor's account bar and task box are
// pages. The pages themselves keep their own routes — a signed-in visitor's account bar and task box are
new file mode 100644
new file mode 100644
@@ -0,0 +1,348 @@
@@ -0,0 +1,348 @@
+'use strict';
+
+/**
+ * The chat (plan L55 C1; the owner, 2026-11-01: "it needs a whole conversation system… like Gemini or ChatGPT… your
+ * conversations on the left… way cleaner, so many words"). Actor's app for a signed-in person.
+ *
+ * - A conversation is a chain of tasks: each follow-up's parent_id is the turn before it (a fork starts a chain of its
+ * own). The sidebar is the person's chains, newest activity first, grouped Today / Yesterday / This week / Earlier.
+ * - A turn is the question as a bubble and Actor's reply under it: the work folded ("Worked for 12 s · 3 tools", the
+ * plan, its notes and each tool call with what it returned), the answer, its files and sources, and one quiet line
+ * (mode · model · cost · time · checked) with Copy and Details.
+ * - Everything else a task has (saved steps and forks, compare, the Bench offer, the evidence list with Challenge, the
+ * trace, webhooks, the API line) is that turn's Details: /tasks/:id?view=details, the full task page.
+ *
+ * Server-rendered and complete without JavaScript: the composer is a plain form (POST /chat) and a turn still working
+ * refreshes the page (<noscript>). public/js/chat.js adds the live work line from the task's event stream, swaps the
+ * finished turn in (GET /chat/turn/:id), sends without a reload and keeps the thread at the bottom.
+ */
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+const { asyncRouter } = require('./router');
+const { html, raw } = require('../render/html');
+const { send } = require('../render/layout');
+const { markdown } = require('../render/markdown');
+const store = require('../tasks/store');
+const { createTask } = require('../tasks/create');
+const { MODES } = require('../agents/router');
+const catalog = require('../agents/catalog');
+const taskEvents = require('../../public/js/task-events');
+const { sameOrigin } = require('./principal');
+
+const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const OPEN = ['queued', 'running', 'verifying'];
+const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };
+const MODE_HINT = { cheapest: 'the lowest price that can do it', balanced: 'good and cheap', best: 'the strongest agent', fastest: 'the quickest answer', private: 'open model, nothing leaves OpenVibe' };
+const LIVE_TEXT = { queued: 'Starting', running: 'Working', verifying: 'Checking the answer' };
+const SIDEBAR_ROWS = 400; // the person's newest tasks the sidebar groups into chats
+const CHAIN_MAX = 60; // turns one conversation shows
+const EVENTS_MAX = 600; // events per turn the work fold reads
+
+const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
+const isFollow = (row) => row && row.parent_id && row.fork_of_step == null;
+
+/** The chats in a person's newest tasks: [{ id (the first turn), title, last (ISO), turns, open }], newest first. */
+function chatsOf(rows) {
+ const byId = new Map(rows.map((r) => [r.id, r]));
+ const rootOf = (row) => {
+ let cur = row;
+ for (let i = 0; i < CHAIN_MAX && isFollow(cur) && byId.has(cur.parent_id); i++) cur = byId.get(cur.parent_id);
+ return cur;
+ };
+ const chats = new Map();
+ for (const row of rows) {
+ const root = rootOf(row);
+ const c = chats.get(root.id) || { id: root.id, title: clip(root.task, 80), last: root.created_at, turns: 0, open: false };
+ c.turns += 1;
+ if (String(row.created_at) > String(c.last)) c.last = row.created_at;
+ if (OPEN.includes(row.state) || row.state === 'needs_you') c.open = true;
+ chats.set(root.id, c);
+ }
+ return [...chats.values()].sort((a, b) => (String(b.last) > String(a.last) ? 1 : String(b.last) < String(a.last) ? -1 : 0));
+}
+
+/** Today / Yesterday / This week / Earlier, by UTC day, for the sidebar. */
+function groupOf(iso, now) {
+ const day = (ms) => Math.floor(ms / 86_400_000);
+ const d = day(now) - day(Date.parse(iso) || now);
+ return d <= 0 ? 'Today' : d === 1 ? 'Yesterday' : d < 7 ? 'This week' : 'Earlier';
+}
+
+/** "Worked for 35 s", from the task's own times. */
+function tookOf(row) {
+ const ms = row.finished_at && row.created_at ? Date.parse(row.finished_at) - Date.parse(row.created_at) : NaN;
+ if (!Number.isFinite(ms) || ms < 1000) return '';
+ const s = Math.round(ms / 1000);
+ return s < 60 ? `${s} s` : `${Math.floor(s / 60)} min${s % 60 ? ` ${s % 60} s` : ''}`;
+}
+
+function createChatPages(ctx) {
+ const { config, s, engine } = ctx;
+ const r = asyncRouter();
+ // Helpers from the task page, read when first used (pages.js is the bigger module; no load-order coupling).
+ const pageKit = () => require('./pages');
+ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const requesterOf = (req) => `user:${req.viewer.subject}`;
+ const now = () => (typeof s.now === 'function' ? s.now() : Date.now());
+
+ async function chatsFor(requester) {
+ const rows = await s.db.many(
+ 'SELECT id, parent_id, fork_of_step, task, state, created_at FROM tasks WHERE requester = $1 ORDER BY id DESC LIMIT $2',
+ [requester, SIDEBAR_ROWS],
+ );
+ return chatsOf(rows);
+ }
+
+ /** The turns of the chat through `row`: its follow-up chain up to the first turn, then on through the newest
+ * follow-up of each turn. */
+ async function chainOf(row) {
+ const up = [];
+ let cur = row;
+ for (let i = 0; i < CHAIN_MAX && isFollow(cur); i++) {
+ const p = await store.getTask(s, cur.parent_id);
+ if (!p || p.requester !== row.requester) break;
+ up.unshift(p);
+ cur = p;
+ }
+ const down = [];
+ cur = row;
+ for (let i = 0; i < CHAIN_MAX - up.length - 1; i++) {
+ const next = await s.db.maybe(
+ 'SELECT * FROM tasks WHERE parent_id = $1 AND requester = $2 AND fork_of_step IS NULL ORDER BY id DESC LIMIT 1',
+ [cur.id, row.requester],
+ );
+ if (!next) break;
+ down.push(next);
+ cur = next;
+ }
+ return [...up, row, ...down];
+ }
+
+ // ── One turn ───────────────────────────────────────────────────────────────────────────────────────────────────
+ const ICON = {
+ tool: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.1L4 16.7V20h3.3l5.3-5.3a4 4 0 0 0 5.1-5.4l-2.6 2.6-2.4-.6-.6-2.4Z"/></svg>',
+ note: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3a6 6 0 0 0-3.5 10.9V16h7v-2.1A6 6 0 0 0 12 3ZM9.5 19.5h5"/></svg>',
+ step: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>',
+ ok: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>',
+ bad: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 7l10 10M17 7 7 17"/></svg>',
+ copy: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg>',
+ more: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10"/></svg>',
+ file: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 3h7l5 5v13H7Z"/><path d="M14 3v5h5"/></svg>',
+ };
+
+ /** The work fold's rows, in order: a plan step, a note the agent wrote, or a tool call with what it returned. */
+ function workRows(lines) {
+ const { toolLabel, msText } = pageKit();
+ const results = new Map(lines.filter((l) => l.t === 'tool_result').map((l) => [l.id, l]));
+ const rows = [];
+ for (const l of lines) {
+ if (l.t === 'step') rows.push(html`<li class="cw-step">${raw(ICON.step)}<span>${l.text}</span></li>`);
+ else if (l.t === 'text') rows.push(html`<li class="cw-note">${raw(ICON.note)}<p>${clip(l.text, 700)}</p></li>`);
+ else if (l.t === 'note') rows.push(html`<li class="cw-note is-${l.level || 'info'}">${raw(ICON.note)}<p>${clip(l.text, 400)}</p></li>`);
+ else if (l.t === 'handoff') rows.push(html`<li class="cw-step">${raw(ICON.step)}<span>Handed to ${l.to || 'another agent'}${l.reason ? `: ${clip(l.reason, 160)}` : ''}</span></li>`);
+ else if (l.t === 'tool') {
+ const res = results.get(l.id);
+ const state = !res ? 'is-run' : res.ok ? 'is-ok' : 'is-bad';
+ rows.push(html`<li class="cw-tool ${state}"><details><summary>${raw(ICON.tool)}<b>${toolLabel(l.name)}</b>${l.what ? html`<span class="cw-in">${clip(l.what, 90)}</span>` : ''}<span class="cw-st">${res ? raw(res.ok ? ICON.ok : ICON.bad) : ''}${res && res.duration_ms ? msText(res.duration_ms) : ''}</span></summary>
+${res ? html`<pre class="cw-out">${clip(res.output, 1500)}</pre>` : html`<p class="cw-out muted">Running…</p>`}</details></li>`);
+ }
+ }
+ return rows;
+ }
+
+ function workBlock(row, events, open) {
+ const lines = taskEvents.lines(events.slice(-EVENTS_MAX), { final: !open, state: row.state });
+ const rows = workRows(lines);
+ const tools = lines.filter((l) => l.t === 'tool').length;
+ const took = tookOf(row);
+ const head = open
+ ? html`<span class="cw-live" data-live-line>${LIVE_TEXT[row.state] || 'Working'}…</span>`
+ : html`<span>${took ? `Worked for ${took}` : 'Worked'}${tools ? ` · ${tools} tool${tools === 1 ? '' : 's'}` : ''}</span>`;
+ if (!open && !rows.length) return '';
+ return html`<details class="cw"${open ? raw(' data-open') : ''}><summary>${head}<svg class="cw-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 10l4 4 4-4"/></svg></summary>
+<ol class="cw-list" data-work>${rows}</ol></details>`;
+ }
+
+ function answerBlock(row, t) {
+ const { chipify, modelName, usd } = pageKit();
+ const result = t.result || {};
+ const evidence = Array.isArray(result.evidence) ? result.evidence.filter((e) => e && /^E\d{1,3}$/.test(String(e.id))) : [];
+ // Citations open the evidence on the Details page (the chat keeps one line per answer, not the evidence list).
+ const body = chipify(markdown(result.answer || '', { headingOffset: 2 }), evidence).replace(/href="#ev-/g, `href="/tasks/${row.id}?view=details#ev-`);
+ const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];
+ const sources = Array.isArray(result.sources) ? result.sources.slice(0, 8) : [];
+ const checked = result.checked || {};
+ const claims = Number(checked.claims) || 0;
+ const agent = catalog.byId(result.agent);
+ const took = tookOf(row);
+ const host = (u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return String(u).slice(0, 40); } };
+ return html`<div class="ct-answer prose" data-answer>${raw(body)}</div>
+${artifacts.length ? html`<ul class="ct-files">${artifacts.map((a) => html`<li><a href="${a.url}" rel="nofollow noopener">${/^image\/(svg\+xml|png|jpe?g|gif|webp)$/.test(a.mime) ? html`<img src="${a.url}" alt="${a.name}" loading="lazy">` : raw(ICON.file)}<span>${a.name}</span></a></li>`)}</ul>` : ''}
+${sources.length ? html`<p class="ct-sources">${sources.map((u) => html`<a href="${u}" rel="nofollow ugc noopener" target="_blank">${host(u)}</a>`)}</p>` : ''}
+<div class="ct-meta"><span title="${agent ? agent.name : result.agent || ''}">${MODE_LABEL[row.mode] || row.mode}${result.model ? ` · ${modelName(result.model)}` : ''}</span>${t.cost ? html`<span>${usd(t.cost.usd)}</span>` : ''}${took ? html`<span>${took}</span>` : ''}${claims ? html`<span class="ct-checked" title="${claims} claim${claims === 1 ? '' : 's'} checked by a second model">${raw(ICON.ok)}Checked</span>` : ''}
+<span class="ct-acts"><button type="button" class="ct-act" data-copy hidden>${raw(ICON.copy)}<span>Copy</span></button><a class="ct-act" href="/tasks/${row.id}?view=details">${raw(ICON.more)}<span>Details</span></a></span></div>`;
+ }
+
+ function questionBlock(row, events) {
+ const q = [...events].reverse().find((e) => e.kind === 'output' && e.step === 'question');
+ if (!q) return html`<div class="ct-card is-wait"><p><b>Actor is waiting for you.</b></p><p><a href="/tasks/${row.id}?view=details#question">Open the question</a></p></div>`;
+ const options = Array.isArray(q.options) ? q.options : [];
+ const acting = q.question_kind === 'acting';
+ return html`<div class="ct-card is-wait"><p><b>${acting ? 'Actor was stopped mid-action' : 'One question first'}</b></p><p>${String(q.text || '')}</p>
+<form method="post" action="/tasks/${row.id}/answer" class="ct-opts">${options.map((o) => html`<button class="ct-opt" type="submit" name="answer" value="${o}">${o}</button>`)}</form>
+${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class="ct-own"><input name="answer" type="text" maxlength="2000" required placeholder="Or answer in your own words" aria-label="Your answer"><button class="ct-opt" type="submit">Answer</button></form>`}</div>`;
+ }
+
+ function failedBlock(row, t) {
+ const err = t.error || {};
+ const why = {
+ 'actor.agents.exhausted': 'No answer passed its check, so nothing unchecked was shown.',
+ 'actor.check.unavailable': 'The answer could not be checked, so it was not shown.',
+ 'actor.task.timeout': 'It ran too long and was stopped.',
+ 'actor.task.interrupted': 'Actor restarted while this was running. Nothing more was charged.',
+ }[err.code] || err.detail || 'It stopped without saying why.';
+ return html`<div class="ct-card is-bad"><p><b>Actor couldn't finish this.</b> ${why}</p>
+<form method="post" action="/chat" class="ct-retry"><input type="hidden" name="task" value="${row.task}"><input type="hidden" name="mode" value="best">${isFollow(row) ? html`<input type="hidden" name="parent" value="${row.parent_id}">` : ''}<button class="ct-opt" type="submit">Try again in Best mode</button><a class="ct-act" href="/tasks/${row.id}?view=details">Details</a></form></div>`;
+ }
+
+ async function turnHtml(row, { events = null } = {}) {
+ const t = store.toWire(row, { baseUrl: config.baseUrl });
+ const evs = events || await store.eventsAfter(s, row.id, 0);
+ const open = OPEN.includes(row.state);
+ let reply;
+ if (row.state === 'succeeded') reply = html`${workBlock(row, evs, false)}${answerBlock(row, t)}`;
+ else if (row.state === 'needs_you') reply = html`${workBlock(row, evs, false)}${questionBlock(row, evs)}`;
+ else if (row.state === 'failed') reply = html`${workBlock(row, evs, false)}${failedBlock(row, t)}`;
+ else if (row.state === 'cancelled') reply = html`${workBlock(row, evs, false)}<p class="ct-stopped">Stopped. What it spent before stopping stays spent.</p>`;
+ else reply = workBlock(row, evs, true);
+ return html`<li class="ct-turn state-${row.state}" id="t-${row.id}" data-task="${row.id}" data-state="${row.state}" data-last="${row.last_seq}"${open ? raw(' data-open') : ''}>
+<div class="ct-you"><p class="ct-bubble">${row.task}</p></div>
+<div class="ct-bot"><span class="ct-avatar" aria-hidden="true"></span><div class="ct-reply">${reply}</div></div>
+</li>`;
+ }
+
+ // ── The page ───────────────────────────────────────────────────────────────────────────────────────────────────
+ function sidebar(chats, currentId) {
+ const groups = [];
+ let last = null;
+ const t = now();
+ for (const c of chats) {
+ const g = groupOf(c.last, t);
+ if (g !== last) { groups.push({ name: g, items: [] }); last = g; }
+ groups[groups.length - 1].items.push(c);
+ }
+ return html`<aside class="cs" id="chats" aria-label="Your chats">
+<div class="cs-head"><a class="cs-new" href="/chat">${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>')}New chat</a>
+<button type="button" class="cs-close" data-cs-close aria-label="Close the chats" hidden>${raw(ICON.bad)}</button></div>
+<label class="cs-find"><span class="sr-only">Find a chat</span><input type="search" placeholder="Find a chat" data-cs-find autocomplete="off"></label>
+<nav class="cs-list">${groups.length ? groups.map((g) => html`<p class="cs-group">${g.name}</p><ul>${g.items.map((c) => html`<li><a href="/chat/${c.id}"${c.id === currentId ? raw(' aria-current="page"') : ''} data-title="${c.title.toLowerCase()}">${c.open ? html`<i class="cs-dot" title="Working"></i>` : ''}<span>${c.title}</span></a></li>`)}</ul>`)
+ : html`<p class="cs-empty">Your chats show here.</p>`}</nav>
+<p class="cs-foot"><a href="/console">Console</a><a href="/me/agents">Your agents</a><a href="/memory">Memory</a></p>
+</aside>`;
+ }
+
+ function composer({ parent = null, mode = 'balanced', busy = false, problem = null, text = '' } = {}) {
+ return html`<form class="cc" method="post" action="/chat" data-composer${busy ? raw(' data-busy') : ''}>
+${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}
+${parent ? html`<input type="hidden" name="parent" value="${parent}">` : ''}
+<div class="cc-box">
+<label class="sr-only" for="cc-text">Message Actor</label>
+<textarea id="cc-text" name="task" rows="1" maxlength="20000" required placeholder="${parent ? 'Reply to Actor' : 'Ask Actor to do something'}" data-cc-text>${text}</textarea>
+<div class="cc-row">
+<label class="cc-mode" title="How Actor picks the agent"><span class="sr-only">Mode</span><select name="mode" data-cc-mode>${MODES.map((m) => html`<option value="${m}" title="${MODE_HINT[m] || ''}"${m === mode ? raw(' selected') : ''}>${MODE_LABEL[m] || m}</option>`)}</select></label>
+<span class="cc-note" data-cc-note></span>
+<button class="cc-send" type="submit" aria-label="Send" data-cc-send>${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M6 11l6-6 6 6"/></svg>')}</button>
+</div></div>
+<p class="cc-fine">Actor checks each answer with a second model. ${config.allowance ? `Free: ${config.allowance.tasksPerDay} tasks a day.` : ''}</p>
+</form>`;
+ }
+
+ const STARTERS = [
+ ['Check a domain', 'Who handles the mail for openvibe.network, and is DMARC set up?'],
+ ['Read a page', 'Read https://openvibe.live and tell me in three lines what it is.'],
+ ['Work something out', 'What is 18% of 2,450, and what is that per month over a year?'],
+ ['Plan', 'Plan a 20-minute stream: an opening, three segments and a close.'],
+ ];
+
+ async function render(req, res, { row = null, problem = null, text = '', status = 200 } = {}) {
+ const requester = requesterOf(req);
+ const [chats, turns] = await Promise.all([chatsFor(requester), row ? chainOf(row) : []]);
+ const first = turns[0] || null;
+ const lastTurn = turns[turns.length - 1] || null;
+ const busy = Boolean(lastTurn && (OPEN.includes(lastTurn.state) || lastTurn.state === 'needs_you'));
+ const turnHtmls = await Promise.all(turns.map((t) => turnHtml(t)));
+ const title = first ? clip(first.task, 60) : 'New chat';
+ return send(res, status, {
+ viewer: req.viewer, config, path: req.originalUrl, title, app: true, noReferrer: true,
+ css: ['css/chat.css'], scripts: ['js/chat.js'],
+ body: html`${busy ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}
+<div class="chat" data-chat="${first ? first.id : ''}">
+${sidebar(chats, first ? first.id : null)}
+<div class="cs-scrim" data-cs-close hidden></div>
+<section class="ct" aria-label="${first ? 'Conversation' : 'New chat'}">
+<header class="ct-head"><button type="button" class="ct-menu" data-cs-open aria-controls="chats" aria-expanded="false">${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"/></svg>')}<span class="sr-only">Your chats</span></button>
+<h1 class="ct-title">${title}</h1>${first ? html`<a class="ct-new" href="/chat" aria-label="New chat">${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>')}</a>` : ''}</header>
+<div class="ct-scroll" data-scroll>
+${turns.length ? html`<ol class="ct-thread" data-thread>${turnHtmls}</ol>` : html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>Ask in plain words. Actor picks the agent and the tools, checks the answer, and shows what it cost.</p>
+<ul class="ct-starters">${STARTERS.map(([k, v]) => html`<li><button type="button" data-starter="${v}"><b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`}
+</div>
+${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, problem, text })}
+</section>
+</div>`,
+ });
+ }
+
+ const loginTo = (req) => `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;
+
+ r.get('/chat', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req)) return res.redirect(303, loginTo(req));
+ const text = typeof req.query.ask === 'string' ? req.query.ask.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').slice(0, 2000) : '';
+ return render(req, res, { text });
+ });
+
+ r.get('/chat/turn/:id', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req) || !TASK_ID.test(req.params.id)) return res.status(404).end();
+ const row = await store.getTask(s, req.params.id);
+ if (!row || row.requester !== requesterOf(req)) return res.status(404).end();
+ res.type('html').send(require('../render/html').render(await turnHtml(row)));
+ });
+
+ r.get('/chat/:id', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (!signedIn(req)) return res.redirect(303, loginTo(req));
+ const row = TASK_ID.test(req.params.id) ? await store.getTask(s, req.params.id) : null;
+ if (!row || row.requester !== requesterOf(req)) return render(req, res, { status: 404, problem: 'That chat is not here: it may have expired (tasks are kept 30 days) or belong to someone else.' });
+ return render(req, res, { row });
+ });
+
+ const PROBLEM = {
+ 'actor.allowance.exhausted': 'You have used today\'s free tasks. They come back at midnight UTC.',
+ 'actor.budget.day_spent': 'Today\'s budget is spent. It resets at midnight UTC.',
+ 'actor.capacity.spent': 'Actor has used today\'s free capacity across everyone. It resets at midnight UTC.',
+ };
+ r.post('/chat', express.urlencoded({ extended: false, limit: '64kb' }), async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const wantsJson = String(req.get('accept') || '').includes('application/json');
+ const fail = (status, detail, row = null, text = '') => (wantsJson ? res.status(status).json({ error: detail }) : render(req, res, { row, problem: detail, text, status }));
+ if (!signedIn(req)) return wantsJson ? res.status(401).json({ error: 'Sign in first.' }) : res.redirect(303, '/auth/login?next=%2Fchat');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('A message must be sent from openvibe.actor itself.');
+ const text = String((req.body && req.body.task) || '').trim().slice(0, 20000);
+ const mode = MODES.includes(req.body && req.body.mode) ? req.body.mode : 'balanced';
+ const parentId = req.body && TASK_ID.test(String(req.body.parent || '')) ? String(req.body.parent) : null;
+ const parent = parentId ? await store.getTask(s, parentId) : null;
+ if (parentId && (!parent || parent.requester !== requesterOf(req))) return fail(404, 'That chat is not here any more. Start a new one.');
+ if (!text) return fail(422, 'Write a message first.', parent);
+ if (parent && (OPEN.includes(parent.state) || parent.state === 'needs_you')) return fail(409, 'Actor is still working on the last message. Wait for it, or stop it.', parent, text);
+ const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode }, parentId: parent ? parent.id : null });
+ if (out.code) return fail(out.status === 503 || out.status === 429 ? out.status : 422, PROBLEM[out.code] || out.detail || 'That could not start. Try again in a moment.', parent, text);
+ const row = await store.getTask(s, out.task.id);
+ if (wantsJson) return res.status(201).json({ id: row.id, chat: parent ? undefined : row.id, url: `/chat/${row.id}`, turn: require('../render/html').render(await turnHtml(row, { events: [] })) });
+ return res.redirect(303, `/chat/${row.id}#t-${row.id}`);
+ });
+
+ return r;
+}
+
+module.exports = { createChatPages, chatsOf, groupOf };
@@ -119,7 +119,7 @@ const msText = (n) => {
@@ -119,7 +119,7 @@ const msText = (n) => {
// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".
// A model id as people say it: "deepseek:deepseek-flash" → "DeepSeek Flash", "openai:gpt-5.1" → "GPT-5.1".
const BRANDS = { deepseek: 'DeepSeek', gpt: 'GPT', openai: 'OpenAI', minimax: 'MiniMax', claude: 'Claude', gemini: 'Gemini', qwen: 'Qwen', glm: 'GLM', kimi: 'Kimi' };
const BRANDS = { deepseek: 'DeepSeek', gpt: 'GPT', openai: 'OpenAI', minimax: 'MiniMax', claude: 'Claude', gemini: 'Gemini', qwen: 'Qwen', glm: 'GLM', kimi: 'Kimi' };
function modelName(id) {
function modelName(id) {
- const m = String(id || '').split(':').pop();
+ const m = String(id || '').split(':').pop().replace(/-\d{4}-\d{2}-\d{2}$/, '');
if (!m) return 'another model';
if (!m) return 'another model';
if (/^gpt-/i.test(m)) return `GPT-${m.slice(4)}`;
if (/^gpt-/i.test(m)) return `GPT-${m.slice(4)}`;
return m.split('-').map((w) => BRANDS[w.toLowerCase()] || (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
return m.split('-').map((w) => BRANDS[w.toLowerCase()] || (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
@@ -587,7 +587,7 @@ ${raw(foldBlock({
@@ -587,7 +587,7 @@ ${raw(foldBlock({
const mode = MODES.includes(req.body && req.body.mode) ? req.body.mode : 'balanced';
const mode = MODES.includes(req.body && req.body.mode) ? req.body.mode : 'balanced';
const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode } });
const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode } });
if (out.code) return home(req, res, { status: out.status === 503 || out.status === 429 ? out.status : 422, form: { text, mode, problem: out.detail } });
if (out.code) return home(req, res, { status: out.status === 503 || out.status === 429 ? out.status : 422, form: { text, mode, problem: out.detail } });
- return res.redirect(303, `/tasks/${out.task.id}`);
+ return res.redirect(303, `/chat/${out.task.id}`);
});
});
// A follow-up (Actor v2, stage V2-5b): a new task of the same person's that continues an answered one, with its
// A follow-up (Actor v2, stage V2-5b): a new task of the same person's that continues an answered one, with its
@@ -609,7 +609,7 @@ ${raw(foldBlock({
@@ -609,7 +609,7 @@ ${raw(foldBlock({
if (!text) return res.redirect(303, `/tasks/${parent.id}#follow-up`);
if (!text) return res.redirect(303, `/tasks/${parent.id}#follow-up`);
const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode: parent.mode }, parentId: parent.id });
const out = await createTask({ s, config, engine, principal: { requester: requesterOf(req), project: null }, body: { task: text, mode: parent.mode }, parentId: parent.id });
if (out.code) return res.redirect(303, `/tasks/${parent.id}?follow=${encodeURIComponent(out.code)}#follow-up`);
if (out.code) return res.redirect(303, `/tasks/${parent.id}?follow=${encodeURIComponent(out.code)}#follow-up`);
- return res.redirect(303, `/tasks/${out.task.id}`);
+ return res.redirect(303, `/chat/${out.task.id}#t-${out.task.id}`);
});
});
// ── Durable runs (stage V2-9): fork from a saved step, and compare two runs ──────
// ── Durable runs (stage V2-9): fork from a saved step, and compare two runs ──────
@@ -995,6 +995,9 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(exist
@@ -995,6 +995,9 @@ ${existing ? html`<div class="notice" role="status">Already offered ${time(exist
});
});
r.get('/tasks/:id', async (req, res) => {
r.get('/tasks/:id', async (req, res) => {
+ if (req.get('sec-fetch-mode') === 'navigate' && req.query.view !== 'details' && TASK_ID.test(req.params.id) && signedIn(req)) {
+ return res.redirect(303, `/chat/${req.params.id}#t-${req.params.id}`);
+ }
const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;
const row = TASK_ID.test(req.params.id) && signedIn(req) ? await store.getTask(s, req.params.id) : null;
if (!row || row.requester !== requesterOf(req)) {
if (!row || row.requester !== requesterOf(req)) {
return page(req, res, { title: 'Task not found', body: html`<h1>Task not found</h1><p>${signedIn(req) ? 'There is no task here that belongs to you.' : html`<a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in</a> to see your task.`}</p>` }, 404);
return page(req, res, { title: 'Task not found', body: html`<h1>Task not found</h1><p>${signedIn(req) ? 'There is no task here that belongs to you.' : html`<a href="/auth/login?next=${encodeURIComponent(req.originalUrl)}">Sign in</a> to see your task.`}</p>` }, 404);
@@ -1454,4 +1457,4 @@ ${table(['Code', 'Meaning'], [
@@ -1454,4 +1457,4 @@ ${table(['Code', 'Meaning'], [
return r;
return r;
}
}
-module.exports = { createPageRoutes, MODE_TEXT };
+module.exports = { createPageRoutes, MODE_TEXT, chipify, toolLabel, inputText, modelName, msText, usd };
@@ -25,6 +25,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -25,6 +25,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold into
// The product's own places: the main ones carry an icon (phones get them as the bottom tab bar), the rest fold into
// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.
// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.
const NAV = [
const NAV = [
+ { label: 'Chat', href: '/chat', icon: 'fa-comments' },
{ label: 'Console', href: '/console', icon: 'fa-gauge-high' },
{ label: 'Console', href: '/console', icon: 'fa-gauge-high' },
{ label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' },
{ label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' },
{ label: 'Agents', href: '/agents', icon: 'fa-robot' },
{ label: 'Agents', href: '/agents', icon: 'fa-robot' },
@@ -60,7 +61,7 @@ function renderPage(o) {
@@ -60,7 +61,7 @@ function renderPage(o) {
service: 'actor',
service: 'actor',
apiBase: NETWORK_URL,
apiBase: NETWORK_URL,
links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
links: NAV.map((l) => ({ label: l.label, href: l.href, ...(l.icon ? { icon: l.icon } : {}), ...(l.more ? { more: true } : {}), active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
- appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Console', href: '/console', icon: 'fa-gauge-high' }, { label: 'Tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Agents', href: '/agents', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)
+ appBar: [{ label: 'Home', href: '/', icon: 'fa-house' }, { label: 'Console', href: '/console', icon: 'fa-gauge-high' }, { label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Agents', href: '/agents', icon: 'fa-robot' }], // phones: the bottom tab bar (Shared 4.20)
history: { type: 'page', title: o.title || SITE_NAME },
history: { type: 'page', title: o.title || SITE_NAME },
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
sessionUrl: '/auth/me',
sessionUrl: '/auth/me',
@@ -70,7 +71,7 @@ function renderPage(o) {
@@ -70,7 +71,7 @@ function renderPage(o) {
};
};
// This site's own account links live in the shared navbar's account menu (the page's account
// This site's own account links live in the shared navbar's account menu (the page's account
// bar below is only for visitors without JavaScript).
// bar below is only for visitors without JavaScript).
- if (signedIn) nav.menu = { before: [{ label: 'Console', href: '/console', icon: 'fa-gauge' }, { label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }, { label: 'Memory', href: '/memory', icon: 'fa-brain' }] };
+ if (signedIn) nav.menu = { before: [{ label: 'Chat', href: '/chat', icon: 'fa-comments' }, { label: 'Console', href: '/console', icon: 'fa-gauge' }, { label: 'Your tasks', href: '/tasks', icon: 'fa-list-check' }, { label: 'Your agents', href: '/me/agents', icon: 'fa-robot' }, { label: 'Memory', href: '/memory', icon: 'fa-brain' }] };
const footer = { service: 'actor', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
const footer = { service: 'actor', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME, updates: '/updates' };
const account = signedIn
const account = signedIn
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
@@ -109,8 +110,8 @@ ${render(scripts)}
@@ -109,8 +110,8 @@ ${render(scripts)}
body: `<a class="skip" href="#main">Skip to content</a>
body: `<a class="skip" href="#main">Skip to content</a>
<div id="navbar-mount"></div>
<div id="navbar-mount"></div>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
<noscript><div class="account-bar" role="navigation" aria-label="Account">${render(account)}</div></noscript>
-<main id="main" class="page">
-${render(crumbs)}
+<main id="main" class="page${o.app ? ' page-app' : ''}">
+${o.app ? '' : render(crumbs)}
${render(o.body)}
${render(o.body)}
${o.path === '/' ? foldBlock({
${o.path === '/' ? foldBlock({
id: 'shipped',
id: 'shipped',
@@ -38,7 +38,7 @@ const getTask = (s, id) => s.db.maybe('SELECT * FROM tasks WHERE id = $1', [id])
@@ -38,7 +38,7 @@ const getTask = (s, id) => s.db.maybe('SELECT * FROM tasks WHERE id = $1', [id])
* question and its answer, only the same person's, only answered ones. An answer's [E#] citations belonged to its own
* question and its answer, only the same person's, only answered ones. An answer's [E#] citations belonged to its own
* run, so they are taken out: the new run cites only what it fetches itself. Each answer is clipped to `clip` chars.
* run, so they are taken out: the new run cites only what it fetches itself. Each answer is clipped to `clip` chars.
*/
*/
-async function followContext(s, row, { depth = 3, clip = 3000 } = {}) {
+async function followContext(s, row, { depth = 6, clip = 2500 } = {}) {
const out = [];
const out = [];
let at = row && row.parent_id;
let at = row && row.parent_id;
for (let i = 0; at && i < depth; i++) {
for (let i = 0; at && i < depth; i++) {
@@ -11,7 +11,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -11,7 +11,7 @@ const { boot, check, done } = require('./helpers/boot');
const store = require('../server/tasks/store');
const store = require('../server/tasks/store');
const taskEvents = require('../public/js/task-events');
const taskEvents = require('../public/js/task-events');
-const taskIdOf = (res) => String(res.headers.get('location') || '').split('/').filter(Boolean).pop();
+const taskIdOf = (res) => String(res.headers.get('location') || '').split('#')[0].split('/').filter(Boolean).pop(); // /tasks/<id> or the chat's /chat/<id>#t-<id>
const SAME = { origin: 'https://openvibe.actor' };
const SAME = { origin: 'https://openvibe.actor' };
(async () => {
(async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+'use strict';
+// A signed-in Actor with a three-turn chat, for looking at /chat in a browser (not run by test/run.js: only *.test.js
+// are). Usage: node test/chat-shots.js [port] → prints the address; the proxy signs every request in as kim.
+const http = require('http');
+const { boot } = require('./helpers/boot');
+
+(async () => {
+ const port = Number(process.argv[2]) || 0;
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const SAME = { 'sec-fetch-site': 'same-origin' };
+ t.providers.state.cls = 'lookup';
+ const one = (await t.get('/api/v1/tasks', { as: kim, json: { task: 'Who handles the mail for openvibe.network, and is DMARC set up?' }, headers: SAME })).json().id;
+ await t.waitFor(one);
+ const two = (await t.get(`/tasks/${one}/follow-up`, { as: kim, form: { task: 'And what about openvibe.live?' }, headers: SAME }));
+ if (!/\/chat\//.test(two.headers.get('location') || '')) throw new Error('follow-up: ' + two.status + ' ' + two.headers.get('location'));
+ const twoId = /\/chat\/(tsk_[0-9A-Z]+)/.exec(two.headers.get('location') || '')[1];
+ await t.waitFor(twoId);
+ t.providers.reset();
+ t.providers.state.cls = 'research';
+ t.providers.state.checkOk = false;
+ const third = await t.get('/chat', { as: kim, form: { task: 'Summarise the news about OpenVibe this week', parent: twoId }, headers: SAME });
+ const bad = (/\/chat\/(tsk_[0-9A-Z]+)/.exec(third.headers.get('location') || '') || [])[1];
+ if (!bad) throw new Error('chat post: ' + third.status + ' ' + third.headers.get('location'));
+ await t.waitFor(bad);
+ t.providers.reset();
+ t.providers.state.cls = 'lookup';
+ const cookie = t.signIn(kim);
+ const proxy = http.createServer((req, res) => {
+ const headers = { ...req.headers, cookie: [cookie, req.headers.cookie].filter(Boolean).join('; '), host: new URL(t.base).host, origin: t.base };
+ const up = http.request(t.base + req.url, { method: req.method, headers }, (r) => {
+ const h = { ...r.headers };
+ if (h.location && h.location.startsWith(t.base)) h.location = h.location.slice(t.base.length);
+ res.writeHead(r.statusCode, h);
+ r.pipe(res);
+ });
+ up.on('error', () => { res.writeHead(502); res.end(); });
+ req.pipe(up);
+ });
+ await new Promise((r) => proxy.listen(port, '127.0.0.1', r));
+ process.stdout.write(`chat ready http://127.0.0.1:${proxy.address().port}/chat/${one} first=${one} failed=${bad}\n`);
+})().catch((e) => { process.stderr.write(String(e && e.stack)); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+'use strict';
+// The chat (plan L55 C1; the owner, 2026-11-01: "a whole conversation system, like Gemini or ChatGPT… your conversations
+// on the left… way cleaner"): a conversation is a chain of follow-ups, the sidebar is the person's chains, each turn is
+// a bubble and a reply with the work folded and one quiet line; everything else is the turn's Details page. Only the
+// person's own chats, only from Actor itself, and a browser opening a task lands in its chat.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { chatsOf, groupOf } = require('../server/http/chat-pages');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const bo = t.network.addUser('bo');
+ t.config.loop.selfReflections = 0;
+ const s = t.ctx.s;
+ const say = async (text, o = {}) => {
+ t.providers.state.script = [{ content: o.answer || 'Hetzner CX22 at €4.51 a month [E1].' }];
+ const r = await t.get('/chat', { as: o.as || kim, form: { task: text, ...(o.parent ? { parent: o.parent } : {}), ...(o.mode ? { mode: o.mode } : {}) }, headers: o.headers || SAME });
+ return r;
+ };
+ const idOf = (r) => { const m = LOC.exec(r.headers.get('location') || ''); assert.ok(m, `redirect to the chat, got ${r.status} ${r.headers.get('location')}`); return m[1]; };
+
+ try {
+ await check('signed out, /chat asks you to sign in first', async () => {
+ const r = await t.get('/chat');
+ assert.strictEqual(r.status, 303);
+ assert.match(r.headers.get('location'), /^\/auth\/login\?next=%2Fchat/);
+ });
+
+ await check('a new chat: one question, four starters, the composer, and an empty list of chats', async () => {
+ const r = await t.get('/chat', { as: kim });
+ assert.strictEqual(r.status, 200);
+ assert.match(r.text, /<h2>What should Actor do\?<\/h2>/);
+ assert.strictEqual((r.text.match(/data-starter="/g) || []).length, 4);
+ assert.match(r.text, /<form class="cc" method="post" action="\/chat" data-composer>/);
+ assert.match(r.text, /Your chats show here\./);
+ assert.match(r.text, /<main id="main" class="page page-app">/, 'the app layout: the whole width, no crumbs');
+ assert.ok(!/name="parent"/.test(r.text), 'a new chat follows nothing');
+ assert.match(r.text, /<link rel="stylesheet" href="\/css\/chat\.css\?v=[0-9a-f]{10}">/);
+ });
+
+ let first;
+ let second;
+ await check('sending starts a task and opens its chat: the bubble, the answer, the quiet line, Details', async () => {
+ const r = await say('Which VPS is cheapest for 2 vCPU and 4 GB?');
+ assert.strictEqual(r.status, 303);
+ first = idOf(r);
+ await t.waitFor(first);
+ const page = await t.get(`/chat/${first}`, { as: kim });
+ assert.strictEqual(page.status, 200);
+ assert.match(page.text, /<p class="ct-bubble">Which VPS is cheapest for 2 vCPU and 4 GB\?<\/p>/);
+ assert.match(page.text, /<div class="ct-answer prose" data-answer>[\s\S]*Hetzner CX22/);
+ assert.match(page.text, new RegExp(`<a class="ct-act" href="/tasks/${first}\\?view=details">`), 'Details is the full task page');
+ assert.match(page.text, /<div class="ct-meta"><span[^>]*>Balanced/);
+ assert.match(page.text, new RegExp(`<input type="hidden" name="parent" value="${first}">`), 'the composer replies to the last turn');
+ assert.match(page.text, new RegExp(`<a href="/chat/${first}" aria-current="page"`), 'the chat is in the list, marked as the one open');
+ });
+
+ await check('a reply continues the chat: both turns, in order, one chat in the list, with the turn before in mind', async () => {
+ t.providers.state.sent = [];
+ const r = await say('And for 8 GB?', { parent: first, answer: 'Hetzner CX32 at €7.59 [E1].' });
+ second = idOf(r);
+ await t.waitFor(second);
+ const row = await s.db.maybe('SELECT parent_id FROM tasks WHERE id = $1', [second]);
+ assert.strictEqual(row.parent_id, first);
+ for (const at of [first, second]) {
+ const page = await t.get(`/chat/${at}`, { as: kim });
+ const a = page.text.indexOf(`id="t-${first}"`), b = page.text.indexOf(`id="t-${second}"`);
+ assert.ok(a > 0 && b > a, `the chat through ${at} shows both turns, oldest first`);
+ assert.strictEqual((page.text.match(/<a href="\/chat\//g) || []).length, 1, 'one chat in the list');
+ assert.match(page.text, new RegExp(`<a href="/chat/${first}" aria-current="page"`), 'the list names a chat by its first turn');
+ }
+ });
+
+ await check('the list: newest activity first, grouped by day; a fork starts a chat of its own', async () => {
+ const rows = [
+ { id: 'tsk_A', parent_id: null, fork_of_step: null, task: 'first', state: 'succeeded', created_at: '2026-11-01T08:00:00.000Z' },
+ { id: 'tsk_B', parent_id: 'tsk_A', fork_of_step: null, task: 'reply', state: 'running', created_at: '2026-11-01T09:00:00.000Z' },
+ { id: 'tsk_C', parent_id: null, fork_of_step: null, task: 'older', state: 'succeeded', created_at: '2026-10-20T09:00:00.000Z' },
+ { id: 'tsk_D', parent_id: 'tsk_A', fork_of_step: 2, task: 'a fork', state: 'succeeded', created_at: '2026-11-01T07:00:00.000Z' },
+ ];
+ const chats = chatsOf(rows);
+ assert.deepStrictEqual(chats.map((c) => c.id), ['tsk_A', 'tsk_D', 'tsk_C']);
+ assert.strictEqual(chats[0].turns, 2);
+ assert.strictEqual(chats[0].open, true, 'a working turn marks its chat');
+ const now = Date.parse('2026-11-01T12:00:00Z');
+ assert.deepStrictEqual(['2026-11-01T01:00:00Z', '2026-10-31T23:00:00Z', '2026-10-28T10:00:00Z', '2026-10-01T10:00:00Z'].map((iso) => groupOf(iso, now)),
+ ['Today', 'Yesterday', 'This week', 'Earlier']);
+ });
+
+ await check('sending from a script gets the new turn back to draw at once', async () => {
+ t.providers.state.script = [{ content: 'Netcup at €5.26 [E1].' }];
+ const r = await t.get('/chat', { as: kim, form: { task: 'And Netcup?', parent: second }, headers: { ...SAME, accept: 'application/json' } });
+ assert.strictEqual(r.status, 201);
+ const j = r.json();
+ assert.match(j.id, /^tsk_/);
+ assert.match(j.turn, new RegExp(`^<li class="ct-turn state-(queued|running|succeeded)" id="t-${j.id}"`));
+ assert.match(j.turn, /<p class="ct-bubble">And Netcup\?<\/p>/);
+ await t.waitFor(j.id);
+ const frag = await t.get(`/chat/turn/${j.id}`, { as: kim });
+ assert.strictEqual(frag.status, 200);
+ assert.match(frag.text, /data-state="succeeded"/);
+ assert.match(frag.text, /Netcup at/);
+ assert.strictEqual((await t.get(`/chat/turn/${j.id}`, { as: bo })).status, 404, 'another person: nothing');
+ });
+
+ await check('only your own chats, only from Actor itself, and a message needs words', async () => {
+ const theirs = await say('mine now', { as: bo, parent: first });
+ assert.strictEqual(theirs.status, 404);
+ assert.match(theirs.text, /That chat is not here any more/);
+ const page = await t.get(`/chat/${first}`, { as: bo });
+ assert.strictEqual(page.status, 404);
+ assert.ok(!page.text.includes('Which VPS'), 'nothing of kim\'s chat');
+ const cross = await t.get('/chat', { as: kim, form: { task: 'hi' }, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(cross.status, 403);
+ const empty = await t.get('/chat', { as: kim, form: { task: ' ' }, headers: SAME });
+ assert.strictEqual(empty.status, 422);
+ assert.match(empty.text, /Write a message first\./);
+ });
+
+ await check('a browser opening a task lands in its chat; Details and scripts still get the task page', async () => {
+ // fetch() sets Sec-Fetch-Mode itself (cors), so a navigation is sent the way a browser sends it.
+ const navigate = (p) => new Promise((resolve, reject) => {
+ const req = require('http').get(t.base + p, { headers: { cookie: t.signIn(kim), 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none' } }, (res) => {
+ let body = ''; res.on('data', (c) => { body += c; }); res.on('end', () => resolve({ status: res.statusCode, location: res.headers.location, text: body }));
+ });
+ req.on('error', reject);
+ });
+ const nav = await navigate(`/tasks/${first}`);
+ assert.strictEqual(nav.status, 303);
+ assert.strictEqual(nav.location, `/chat/${first}#t-${first}`);
+ const details = await navigate(`/tasks/${first}?view=details`);
+ assert.strictEqual(details.status, 200);
+ assert.match(details.text, /<article class="task state-succeeded"/);
+ assert.strictEqual((await t.get(`/tasks/${first}`, { as: kim })).status, 200, 'not a navigation: the page itself');
+ });
+
+ await check('in the browser (public/js/chat.js): the live stream, the turn fragment, Stop, Enter on a keyboard only', async () => {
+ const js = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'js', 'chat.js'), 'utf8');
+ assert.match(js, /new EventSource\('\/api\/v1\/tasks\/' \+ encodeURIComponent\(id\) \+ '\/events\?after='/, 'follows the task\'s own event stream');
+ assert.match(js, /fetch\('\/chat\/turn\/' \+ encodeURIComponent\(id\)/, 'the server draws each turn; the page swaps it in');
+ assert.match(js, /'\/tasks\/' \+ encodeURIComponent\(busy\.getAttribute\('data-task'\)\) \+ '\/cancel'/, 'Stop cancels the working turn');
+ assert.match(js, /\(hover: hover\) and \(pointer: fine\)/, 'Enter sends only with a keyboard and mouse; a phone\'s Enter is a new line');
+ assert.match(js, /prefers-reduced-motion: reduce/);
+ assert.ok(!/innerHTML = [^;]*(text\.value|e\.text|title)/.test(js), 'what a person typed is never set as HTML');
+ });
+
+ await check('the app bar and the menu lead to Chat', async () => {
+ const r = await t.get('/chat', { as: kim });
+ assert.match(r.text, /"label":"Chat","href":"\/chat"/);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -26,7 +26,7 @@ const KEY = crypto.randomBytes(32).toString('hex');
@@ -26,7 +26,7 @@ const KEY = crypto.randomBytes(32).toString('hex');
const SAME = { 'sec-fetch-site': 'same-origin' };
const SAME = { 'sec-fetch-site': 'same-origin' };
const SELF = 'user:usr_01JZ0000000000000000000AAA';
const SELF = 'user:usr_01JZ0000000000000000000AAA';
const quiet = { log() {}, info() {}, warn() {}, error() {} };
const quiet = { log() {}, info() {}, warn() {}, error() {} };
-const taskIdOf = (res) => String(res.headers.get('location') || '').split('/').filter(Boolean).pop();
+const taskIdOf = (res) => String(res.headers.get('location') || '').split('#')[0].split('/').filter(Boolean).pop(); // /tasks/<id> or the chat's /chat/<id>#t-<id>
const waitFor = async (fn, ms = 10_000) => {
const waitFor = async (fn, ms = 10_000) => {
const until = Date.now() + ms;
const until = Date.now() + ms;
for (;;) {
for (;;) {
@@ -6,7 +6,8 @@ const assert = require('assert');
@@ -6,7 +6,8 @@ const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
const SAME = { 'sec-fetch-site': 'same-origin' };
const SAME = { 'sec-fetch-site': 'same-origin' };
-const ID = /\/tasks\/(tsk_[0-9A-HJKMNP-TV-Z]{26})$/;
+// A follow-up opens in the chat (plan L55 C1): /chat/<id>#t-<id>.
+const ID = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
(async () => {
(async () => {
const t = await boot();
const t = await boot();
@@ -68,10 +69,10 @@ const ID = /\/tasks\/(tsk_[0-9A-HJKMNP-TV-Z]{26})$/;
@@ -68,10 +69,10 @@ const ID = /\/tasks\/(tsk_[0-9A-HJKMNP-TV-Z]{26})$/;
assert.strictEqual(Number(await s.db.value('SELECT count(*) FROM tasks')), before, 'none of those made a task');
assert.strictEqual(Number(await s.db.value('SELECT count(*) FROM tasks')), before, 'none of those made a task');
});
});
- await check('the conversation reaches back three answers at most, and never into another person\'s task', async () => {
+ await check('the conversation reaches back six answers at most (a chat, L55), and never into another person\'s task', async () => {
const { followContext } = require('../server/tasks/store');
const { followContext } = require('../server/tasks/store');
const ids = [first];
const ids = [first];
- for (let i = 0; i < 4; i++) {
+ for (let i = 0; i < 7; i++) {
t.providers.state.script = [{ content: `Answer ${i + 2}.` }];
t.providers.state.script = [{ content: `Answer ${i + 2}.` }];
const r = await follow(ids[ids.length - 1], `Question ${i + 2}?`);
const r = await follow(ids[ids.length - 1], `Question ${i + 2}?`);
const id = ID.exec(r.headers.get('location'))[1];
const id = ID.exec(r.headers.get('location'))[1];
@@ -80,8 +81,8 @@ const ID = /\/tasks\/(tsk_[0-9A-HJKMNP-TV-Z]{26})$/;
@@ -80,8 +81,8 @@ const ID = /\/tasks\/(tsk_[0-9A-HJKMNP-TV-Z]{26})$/;
}
}
const last = await s.db.maybe('SELECT * FROM tasks WHERE id = $1', [ids[ids.length - 1]]);
const last = await s.db.maybe('SELECT * FROM tasks WHERE id = $1', [ids[ids.length - 1]]);
const ctx = await followContext(s, last);
const ctx = await followContext(s, last);
- assert.deepStrictEqual(ctx.map((c) => c.task), ['Question 2?', 'Question 3?', 'Question 4?'], 'the three before it, oldest first');
- await s.db.query('UPDATE tasks SET requester = $2 WHERE id = $1', [ids[3], `user:${bo.subject}`]);
+ assert.deepStrictEqual(ctx.map((c) => c.task), ['Question 2?', 'Question 3?', 'Question 4?', 'Question 5?', 'Question 6?', 'Question 7?'], 'the six before it, oldest first');
+ await s.db.query('UPDATE tasks SET requester = $2 WHERE id = $1', [ids[ids.length - 2], `user:${bo.subject}`]);
assert.deepStrictEqual((await followContext(s, last)).map((c) => c.task), [], 'a parent that is not the same person\'s ends the thread');
assert.deepStrictEqual((await followContext(s, last)).map((c) => c.task), [], 'a parent that is not the same person\'s ends the thread');
});
});