openvibers/openvibe.actor Commits

Actor is a chat: your conversations on the left, each answer under its question, the work folded (#224)

OP OpenVibers committed 12 days ago · 0ff61aead5 · 12 files, +1017 −16

The owner (2026-11-01): the task page was 'so long… so confusing… so many words'; Actor needs 'a whole conversation
system, like Gemini or ChatGPT… your conversations on the left… way cleaner' (plan L55 C1).

/chat is Actor's app for a signed-in person. A conversation is a chain of follow-ups; the sidebar is the person's chains
by last activity (Today, Yesterday, This week, Earlier), a drawer on phones, with a search box. Each turn is the question
as a bubble and the reply: the work folded ('Worked for 12 s · 3 tools': the plan, its notes, 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. Details is the full task page (/tasks/:id?view=details): saved steps, forks, compare, Bench, evidence, trace.

The composer sits at the bottom above the app bar: it grows as you type, has the mode as a chip, and Send turns into
Stop while Actor works. With JavaScript a message sends without a reload, the turn follows the task's event stream with
a live line ('Using MX lookup…'), and the answer fades in when it lands; without it, every part is a plain form.

A browser opening /tasks/:id lands in its chat; new tasks and follow-ups open there; Chat replaces Tasks in the app bar.
A chat keeps its last six turns in mind (was three). The page scrolls as a whole: no inner box traps a thumb.

Changes

ddiff --git a/public/css/chat.css b/public/css/chat.css
nnew file mode 100644
iindex 0000000..cb2fdbd
---- /dev/null
++++ b/public/css/chat.css
@@@ -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; }
ddiff --git a/public/js/chat.js b/public/js/chat.js
nnew file mode 100644
iindex 0000000..1f4c8d3
---- /dev/null
++++ b/public/js/chat.js
@@@ -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);
++})();
ddiff --git a/server/app.js b/server/app.js
iindex be6f795..cd110b1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 are
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
nnew file mode 100644
iindex 0000000..1842c98
---- /dev/null
++++ b/server/http/chat-pages.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a4104f4..4179e94 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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 };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex bf10512..713bbb4 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -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',
ddiff --git a/server/tasks/store.js b/server/tasks/store.js
iindex 502a251..bf7c700 100644
---- a/server/tasks/store.js
++++ b/server/tasks/store.js
@@@ -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++) {
ddiff --git a/test/brain-engine.test.js b/test/brain-engine.test.js
iindex 7eb1c8d..a6838cd 100644
---- a/test/brain-engine.test.js
++++ b/test/brain-engine.test.js
@@@ -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 () => {
ddiff --git a/test/chat-shots.js b/test/chat-shots.js
nnew file mode 100644
iindex 0000000..e75713c
---- /dev/null
++++ b/test/chat-shots.js
@@@ -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); });
ddiff --git a/test/chat.test.js b/test/chat.test.js
nnew file mode 100644
iindex 0000000..df2caa2
---- /dev/null
++++ b/test/chat.test.js
@@@ -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); });
ddiff --git a/test/durable-runs.test.js b/test/durable-runs.test.js
iindex 0c6394d..1380d70 100644
---- a/test/durable-runs.test.js
++++ b/test/durable-runs.test.js
@@@ -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 (;;) {
ddiff --git a/test/task-follow-ups.test.js b/test/task-follow-ups.test.js
iindex eec910f..946ccc6 100644
---- a/test/task-follow-ups.test.js
++++ b/test/task-follow-ups.test.js
@@@ -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');
});