openvibers/openvibe.actor Commits

The chat draws with the network's conversation UI (L55 C4) (#257)

OP OpenVibers committed 11 days ago · 9b57d28904 · 7 files, +166 −462

* The chat draws with the network's conversation UI (openvibe-shared/thread, L55 C4)

* Merge remote-tracking branch 'origin/main' into design/chat-on-thread-kit

* openvibe-shared v4.96.0 (was v4.95.0): the conversation UI

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 77ed051..55ec318 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,11 +9,11 @@
"domain": "openvibe.actor",
"contracts": "openvibe-contracts v0.269.0",
"sdk": "openvibe-sdk v0.81.0",
-- "shared": "openvibe-shared v4.95.0",
++ "shared": "openvibe-shared v4.96.0",
"packages": [
"openvibe-contracts v0.269.0",
"openvibe-sdk v0.81.0",
-- "openvibe-shared v4.95.0"
++ "openvibe-shared v4.96.0"
],
"summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.",
"works": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 15a46bd..2de61c4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.95.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.96.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1190,9 +1190,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.95.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.95.0",
-- "integrity": "sha512-jY0ZKFQMvx3NiyKxHkZ950nq7P8CtlzlP4OOnTkJf0nyVVG93qT4JE0udr8bF8Kwm7FrzkLgfaLB1qDrjH22eg==",
++ "version": "4.96.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.96.0",
++ "integrity": "sha512-4+9RNAqu66FTRxYlWndmOVy7ZLFCXnQIJXiTO6n8LeUmX0eoL+y/70hKVbz7XmmX/3khqkehJYAn9Tq8mVmmVQ==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex f16b6e5..e37a3ce 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.269.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.95.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.96.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 804f94b..d460915 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -1,146 +1,24 @@
--/* 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. */
++/* Actor's own parts of its chat (plan L55 C1–C3). The conversation itself (the chats, the turns, the work, the answer, the
++ composer) is the network's: /shared/thread.css (openvibe-shared/thread, L55 C4), linked before this file. 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 { --line: color-mix(in srgb, var(--border, #1f2d47) 80%, transparent); }
--.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, color-mix(in srgb, var(--text-primary, #fff) 5%, transparent)); 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, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); 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;
++/* A citation in an answer: its number opens the evidence on the Details page. */
++.ovt-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-streaming .sw { opacity: 0; transition: opacity .28s ease-out; }
--.ct-answer.is-streaming .sw.on { opacity: 1; }
--.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); }
++.ovt-answer .ev:hover { background: color-mix(in srgb, var(--accent, #f97316) 32%, transparent); 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, color-mix(in srgb, var(--text-primary, #fff) 6%, transparent)); 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)); }
++/* A question waiting for you, a retry, a stopped turn. */
.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. */
++/* An empty chat: what Actor does, and starters from what people really hand agents like it. */
.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; }
@@@ -151,51 +29,23 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
.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); }
++.ct-starters button { grid-template-columns: 22px minmax(0, 1fr); column-gap: 10px; align-items: start; }
++.ct-starters button svg { grid-row: span 2; margin-top: 1px; color: var(--accent, #f97316); }
++.ct-starters span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
++@media (max-width: 560px) { .ct-starters { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .ct-starters button { padding: 10px 11px; grid-template-columns: minmax(0, 1fr); } .ct-starters button svg { grid-row: auto; } .ct-starters span { font-size: 12.5px; } }
--/* 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; }
++/* The composer's own tools: the mode, the live note, a problem line. */
.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-stop { flex: none; display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--line); background: var(--bg-secondary, #0f1729); color: var(--text-primary, #e6edf7); }
--.cc-stop[hidden] { display: none; }
--.cc-stop svg { width: 16px; height: 16px; fill: currentColor; }
--.cc-stop:hover { border-color: var(--error, #ef4444); color: var(--error-text, var(--error, #ef4444)); }
.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 { animation: none !important; }
-- .ct-answer.is-streaming .sw { opacity: 1 !important; transition: none !important; }
-- .cw-live, .cw-tool.is-run b { color: var(--text-primary, #e6edf7); background: none; }
--}
--html[data-ov-motion=reduced] .ct-answer.is-streaming .sw { opacity: 1 !important; transition: none !important; }
/* What the person said while it worked, in its list. */
--.cw-you p { margin: 0; padding: 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--accent, #f97316) 14%, var(--bg-card, #131c2e)); color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; justify-self: start; }
--.cw-you { color: var(--accent, #f97316); }
--.cw-you.is-sending p { opacity: .6; }
++.ovt-w-you p { margin: 0; padding: 4px 10px; border-radius: 12px; background: color-mix(in srgb, var(--accent, #f97316) 14%, var(--bg-card, #131c2e)); color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; justify-self: start; }
++.ovt-w-you { color: var(--accent, #f97316); }
++.ovt-w-you.is-sending p { opacity: .6; }
/* The screen: what Actor sees while it works, with Bot's live view one tap away. */
.ct-screen { margin: 0; display: grid; gap: 6px; }
@@@ -215,12 +65,6 @@ html[data-ov-motion=reduced] .ct-answer.is-streaming .sw { opacity: 1 !important
.ct-watch svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.ct-screen:not(.is-live) .ct-screen-frame { max-width: 420px; }
--/* The starters carry the set's own glyphs. */
--.ct-starters button { grid-template-columns: 22px minmax(0, 1fr); column-gap: 10px; align-items: start; }
--.ct-starters button svg { grid-row: span 2; margin-top: 1px; color: var(--accent, #f97316); }
--.ct-starters span { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
--@media (max-width: 560px) { .ct-starters { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .ct-starters button { padding: 10px 11px; grid-template-columns: minmax(0, 1fr); } .ct-starters button svg { grid-row: auto; } .ct-starters span { font-size: 12.5px; } }
--
/* Attached files: chips in the composer before sending, and above the bubble after. */
.cc-attach { flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; border: 1px solid var(--line); background: none; color: var(--text-secondary, #b4c0d4); }
.cc-attach:hover { color: var(--text-primary, #e6edf7); border-color: var(--accent, #f97316); }
@@@ -234,14 +78,6 @@ html[data-ov-motion=reduced] .ct-answer.is-streaming .sw { opacity: 1 !important
.cc-files span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cc-files button { flex: none; width: 22px; height: 22px; border-radius: 50%; border: 0; background: none; color: var(--text-muted, #7386a3); font-size: 16px; line-height: 1; }
.cc-files button:hover { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 8%, transparent)); color: var(--text-primary, #e6edf7); }
--.ct-you { flex-direction: column; align-items: flex-end; gap: 6px; }
--.ct-attached { list-style: none; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; margin: 0; padding: 0; max-width: min(85%, 620px); }
--.ct-attached img { display: block; max-width: 220px; max-height: 180px; border-radius: 14px; border: 1px solid var(--line); }
--.ct-file { display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 8px; border-radius: 12px; border: 1px solid var(--line); background: var(--bg-card, #131c2e); color: var(--text-primary, #e6edf7); text-decoration: none; }
--.ct-file svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.6; }
--.ct-file span { display: grid; line-height: 1.25; }
--.ct-file b { font-size: 13.5px; font-weight: 600; }
--.ct-file small { font-size: 12px; color: var(--text-muted, #7386a3); }
/* The model chip (plan L55 C3): what your tasks run on, beside the mode. Its menu opens above the composer. */
.cc-model { position: relative; }
@@@ -290,3 +126,7 @@ html[data-ov-motion=reduced] .ct-answer.is-streaming .sw { opacity: 1 !important
.cm-modelid small { color: var(--text-muted, #7386a3); }
.cm-modelid input { min-height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--line); background: transparent; color: inherit; font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.cm-save { justify-self: start; margin-top: 14px; min-height: 42px; padding: 0 22px; border-radius: 999px; border: 0; background: var(--accent, #4f7cff); color: #fff; font: inherit; font-weight: 700; cursor: pointer; }
++
++@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; } }
++@media (prefers-reduced-motion: reduce) { .ct-hello-mark, .ct-live::before { animation: none !important; } }
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex ea59070..86083c4 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -1,12 +1,13 @@
--/* 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;
++/* Actor's chat in the browser (plan L55 C1), on the network's conversation UI (openvibe-shared/thread, ov-thread.js,
++ * L55 C4). The page works without this file. ov-thread.js does what every chat does: the drawer and Find a chat, the
++ * growing composer and Enter to send, starters, Copy, staying at the bottom, and the answer typing in. This file adds
++ * Actor's own parts:
++ * - a message sends without a reload (files ride along), and its turn appears at once;
++ * - 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 finished turn is swapped in;
* - while Actor works, what you write steers that turn (it hears it at its next step) and Stop is its own button;
-- * Enter sends on a keyboard, Shift+Enter is a new line;
* - a turn working on a screen shows what Actor sees, refreshed every two seconds, with Bot's live view one tap away;
-- * - the chats open as a drawer on phones, and the search box filters them;
-- * - Copy on each answer and on each code block.
++ * - the model chip.
* Booted on load and after an in-place page swap (openvibe-shared/boost's ov:boost:load).
*/
(function () {
@@@ -17,67 +18,21 @@
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 form = chat.querySelector('[data-ovt-composer]');
++ var text = chat.querySelector('[data-ovt-text]');
++ var send = chat.querySelector('[data-ovt-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(); });
-- });
++ // ── The composer: ov-thread.js grows it, sends on Enter and fills starters. An input event lets it re-measure after
++ // this file changes the words.
++ function grow() { if (text) text.dispatchEvent(new Event('input')); }
++ if (text) text.addEventListener('input', function () { syncSend(); });
// ── Files: the paperclip opens the picker; each file is a chip until it is sent (at most 4, 6 MB each).
var attachBtn = chat.querySelector('[data-cc-attach]');
var fileInput = chat.querySelector('[data-cc-files]');
@@@ -123,8 +78,8 @@
}));
}
-- 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 stopBtn = chat.querySelector('[data-cc-stop]');
++ function openTurn() { var t = chat.querySelectorAll('.ovt-turn'); var last = t[t.length - 1]; return last && (last.hasAttribute('data-open') || last.getAttribute('data-state') === 'needs_you') ? last : null; }
++ var stopBtn = chat.querySelector('[data-ovt-stop]');
function working() { var t = openTurn(); return t && t.hasAttribute('data-open') ? t : null; }
function syncSend() {
if (!send) return;
@@@ -161,10 +116,10 @@
// Steering: the words join the working turn's list at once, and the run hears them at its next step.
function steer(w) {
var said = text.value.trim();
-- var row = document.createElement('li'); row.className = 'cw-you is-sending';
-- row.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 5.5h14v10H10l-5 4Z"/></svg><p></p>';
++ var row = document.createElement('li'); row.className = 'ovt-w-you is-sending';
++ row.innerHTML = '<svg class="ovt-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20a8 8 0 0 1 16 0"/></svg><p></p>';
row.querySelector('p').textContent = said;
-- var list = w.querySelector('[data-work]');
++ var list = w.querySelector('[data-ovt-work]');
if (list) { var fold = list.closest('details'); if (fold) fold.open = true; list.appendChild(row); }
text.value = ''; grow(); syncSend();
var body = new URLSearchParams({ task: said });
@@@ -194,52 +149,45 @@
.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);
++ var thread = chat.querySelector('[data-ovt-thread]');
++ if (thread && !thread.querySelector(':scope > li')) {
++ var title = chat.querySelector('.ovt-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);
++ var turn = window.OVThread ? window.OVThread.add(res.j.turn) : null;
text.value = ''; grow();
picked = []; drawChips();
setParent(res.j.id);
-- follow(turn);
++ if (turn) 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');
++ var list = chat.querySelector('.ovt-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 empty = list.querySelector('.ovt-empty'); if (empty) empty.remove();
++ chat.querySelectorAll('.ovt-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';
++ if (!ul || !list.querySelector('.ovt-group') || list.querySelector('.ovt-group').textContent !== 'Today') {
++ var g = document.createElement('p'); g.className = 'ovt-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');
++ var li = document.createElement('li'), a = document.createElement('a'), dot = document.createElement('span'), 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);
++ dot.className = 'ovt-dot'; dot.setAttribute('aria-hidden', 'true'); 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]');
++ var line = turn.querySelector('[data-ovt-live]');
if (!line) return;
-- var run = turn.querySelectorAll('.cw-tool.is-run b');
-- var steps = turn.querySelectorAll('.cw-step span, .cw-note p');
++ var run = turn.querySelectorAll('.ovt-w-tool.is-run b');
++ var steps = turn.querySelectorAll('.ovt-w-step span, .ovt-w-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;
@@@ -257,24 +205,21 @@
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]');
++ var list = turn.querySelector('[data-ovt-work]'), fresh = next.querySelector('[data-ovt-work]');
if (list && fresh) {
// Keep a line the person just sent until the server's list has it too.
-- var sending = Array.prototype.slice.call(list.querySelectorAll('.cw-you.is-sending'));
++ var sending = Array.prototype.slice.call(list.querySelectorAll('.ovt-w-you.is-sending'));
list.innerHTML = fresh.innerHTML;
sending.forEach(function (r) { list.appendChild(r); });
-- } else if (fresh) turn.querySelector('.ct-reply').innerHTML = next.querySelector('.ct-reply').innerHTML;
++ } else if (fresh) turn.querySelector('.ovt-reply').innerHTML = next.querySelector('.ovt-reply').innerHTML;
// The screen appears (or its live link changes) when the run opens a desk.
var scr = turn.querySelector('[data-screen]'), scr2 = next.querySelector('[data-screen]');
if (scr && scr2 && (scr.hidden !== scr2.hidden || scr.getAttribute('data-desk') !== scr2.getAttribute('data-desk'))) { scr.replaceWith(scr2); screen(turn); }
liveLine(turn);
} else {
stopScreen(turn);
-- turn.replaceWith(next);
-- var ans = next.querySelector('[data-answer]');
-- if (ans && !reduce && !document.documentElement.hasAttribute('data-ov-motion')) streamIn(ans);
-- decorate(next);
-- var dot = chat.querySelector('.cs-list a[aria-current] .cs-dot'); if (dot) dot.remove();
++ if (window.OVThread) window.OVThread.replace(id, h); else turn.replaceWith(next);
++ var dot = chat.querySelector('.ovt-list a[aria-current] .ovt-dot'); if (dot) dot.remove();
if (note) note.textContent = '';
syncSend();
if (text && fine) text.focus();
@@@ -365,7 +310,7 @@
src.addEventListener('output', function (m) {
try {
var e = JSON.parse(m.data);
-- var line = turn.querySelector('[data-live-line]');
++ var line = turn.querySelector('[data-ovt-live]');
if (line && e.step === 'tool_call' && e.tool) line.textContent = 'Using ' + String(e.tool).replace(/_/g, ' ') + '…';
} catch (err) { /* a malformed event is skipped */ }
soon();
@@@ -374,81 +319,8 @@
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); });
++ chat.querySelectorAll('.ovt-turn[data-open]').forEach(function (t) { follow(t); });
-- // ── The answer streams in (the owner, 2026-11-01: "text streams in, with nice effects"). Actor shows an answer
-- // only once a second model has checked it, so the words arrive together; they are revealed word by word, fast
-- // (about a second for a long answer), the way a chat app types. Code blocks and tables come in whole. Nothing
-- // is hidden from a screen reader: only opacity changes.
-- function streamIn(ans) {
-- var words = [];
-- var walker = document.createTreeWalker(ans, NodeFilter.SHOW_TEXT, { acceptNode: function (n) { return n.parentNode.closest('pre, table, .evs') ? NodeFilter.FILTER_REJECT : (/\S/.test(n.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT); } });
-- var nodes = [];
-- while (walker.nextNode()) nodes.push(walker.currentNode);
-- nodes.forEach(function (n) {
-- var frag = document.createDocumentFragment();
-- n.nodeValue.split(/(\s+)/).forEach(function (part) {
-- if (!part) return;
-- if (/^\s+$/.test(part)) { frag.appendChild(document.createTextNode(part)); return; }
-- var w = document.createElement('span'); w.className = 'sw'; w.textContent = part; frag.appendChild(w); words.push(w);
-- });
-- n.parentNode.replaceChild(frag, n);
-- });
-- var blocks = Array.prototype.slice.call(ans.querySelectorAll('pre, table, .evs, img'));
-- blocks.forEach(function (b) { b.classList.add('sw'); });
-- var all = words.concat(blocks);
-- if (!all.length) return;
-- ans.classList.add('is-streaming');
-- var per = Math.max(2, Math.ceil(words.length / 45)), i = 0; // about 45 frames, whatever the length
-- (function step() {
-- for (var k = 0; k < per && i < words.length; k++, i++) words[i].classList.add('on');
-- blocks.forEach(function (b) { if (!b.classList.contains('on') && (b.compareDocumentPosition(words[Math.max(0, i - 1)]) & Node.DOCUMENT_POSITION_FOLLOWING)) b.classList.add('on'); });
-- if (i < words.length) requestAnimationFrame(step);
-- else { all.forEach(function (w) { w.classList.add('on'); }); setTimeout(function () { ans.classList.remove('is-streaming'); }, 300); }
-- })();
-- }
--
-- // ── 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 ? plainText(ans) : '', 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 () { copy(plainText(pre.querySelector('code') || pre), b); });
-- pre.appendChild(b);
-- });
-- }
-- // What a person means by the answer's text: without the citation chips' numbers ("visits 12" was "visits 1 2")
-- // and without the code blocks' own Copy buttons. innerText keeps the line breaks only for laid-out nodes, so the
-- // trimmed copy is read while it sits, invisible, in the page.
-- function plainText(el) {
-- var c = el.cloneNode(true);
-- c.querySelectorAll('.evs, .ct-copy-code').forEach(function (x) {
-- // "one host [1 2], and" → "one host, and": the space before a chip goes when punctuation follows it.
-- var before = x.previousSibling, after = x.nextSibling;
-- if (before && before.nodeType === 3 && (!after || (after.nodeType === 3 && /^[\s,.;:!?)]/.test(after.nodeValue)))) before.nodeValue = before.nodeValue.replace(/\s+$/, '');
-- x.remove();
-- });
-- c.style.cssText = 'position:fixed;left:-10000px;top:0;width:' + (el.clientWidth || 600) + 'px;opacity:0;pointer-events:none';
-- c.setAttribute('aria-hidden', 'true');
-- document.body.appendChild(c);
-- var t = c.innerText.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
-- c.remove();
-- return t;
-- }
-- 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);
modelChip();
}
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex 8111172..33c50cd 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -39,6 +39,8 @@ const AI_SITE = 'https://ai.openvibe.services';
const allowUrl = (provider) => `${AI_SITE}/you?allow=actor&provider=${encodeURIComponent(provider)}`;
const modelLabel = (m) => (m && m.provider ? `Your ${PROVIDER_NAMES[m.provider] || m.provider} key` : 'Auto');
const glyphs = require('openvibe-shared/glyphs');
++// One conversation UI for the network (plan L55 C4): the sidebar, the turns, the work, the answer and the composer.
++const thread = require('openvibe-shared/thread');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const OPEN = ['queued', 'running', 'verifying'];
@@@ -167,22 +169,21 @@ function createChatPages(ctx, { computers = null } = {}) {
</figure>`;
}
-- /** The work fold's rows, in order: a plan step, a note the agent wrote, or a tool call with what it returned. */
++ /** The work fold's rows (thread.work), in order: a plan step, a note the agent wrote, what the person said while it
++ * worked, or a tool call with what it returned. */
function workRows(lines) {
-- const { toolLabel, msText } = pageKit();
++ const { toolLabel } = 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' && /^You: /.test(l.text)) rows.push(html`<li class="cw-you">${raw(ICON.you)}<p>${clip(l.text.slice(5), 300)}</p></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>`);
++ if (l.t === 'step') rows.push({ kind: 'step', text: l.text });
++ else if (l.t === 'text' && /^You: /.test(l.text)) rows.push({ kind: 'you', text: l.text.slice(5) });
++ else if (l.t === 'text') rows.push({ kind: 'note', text: l.text });
++ else if (l.t === 'note') rows.push({ kind: 'note', text: l.text, level: l.level || 'info' });
++ else if (l.t === 'handoff') rows.push({ kind: 'step', text: `Handed to ${l.to || 'another agent'}${l.reason ? `: ${clip(l.reason, 160)}` : ''}` });
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>`);
++ rows.push({ kind: 'tool', name: toolLabel(l.name), input: l.what || null, output: res ? res.output : null, ok: res ? Boolean(res.ok) : null, running: !res });
}
}
return rows;
@@@ -190,15 +191,7 @@ ${res ? html`<pre class="cw-out">${clip(res.output, 1500)}</pre>` : html`<p clas
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>`;
++ return raw(thread.work({ open, live: LIVE_TEXT[row.state] || 'Working', took: tookOf(row) || null, rows: workRows(lines) }));
}
function answerBlock(row, t) {
@@@ -206,27 +199,35 @@ ${res ? html`<pre class="cw-out">${clip(res.output, 1500)}</pre>` : html`<p clas
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-`);
++ // A citation chip comes in whole when the answer types in, and its number is never copied with the answer.
++ const body = chipify(markdown(result.answer || '', { headingOffset: 2 }), evidence)
++ .replace(/href="#ev-/g, `href="/tasks/${row.id}?view=details#ev-`)
++ .replace(/<(\w+) class="evs/g, '<$1 data-ovt-whole data-ovt-nocopy class="evs');
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 claims = Number((result.checked || {}).claims) || 0;
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>`;
++ return raw(thread.answer({
++ html: `<div class="prose">${body}</div>`,
++ files: artifacts.map((a) => ({ href: a.url, name: a.name, image: /^image\/(svg\+xml|png|jpe?g|gif|webp)$/.test(a.mime) })),
++ sources,
++ meta: [
++ `${MODE_LABEL[row.mode] || row.mode}${result.model ? ` · ${modelName(result.model)}` : ''}`,
++ t.cost ? usd(t.cost.usd) : null,
++ took || null,
++ // Said in words, not a tooltip a phone cannot open.
++ claims ? `${claims} claim${claims === 1 ? '' : 's'} checked` : null,
++ ],
++ actions: [{ label: 'Details', href: `/tasks/${row.id}?view=details`, icon: 'more' }],
++ }));
}
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>`;
++ if (!q) return html`<div class="ovt-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>
++ return html`<div class="ovt-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>`;
}
@@@ -239,16 +240,13 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
'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>
++ return html`<div class="ovt-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>`;
}
-- const bytesText = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : n >= 1024 ? `${Math.round(n / 1024)} KB` : `${n} B`);
-- function filesBlock(row, files) {
-- if (!files || !files.length) return '';
-- return html`<ul class="ct-attached">${files.map((f) => html`<li>${inputs.IMAGE.test(f.mime)
-- ? html`<a href="/chat/file/${row.id}/${f.id}" target="_blank" rel="noopener"><img src="/chat/file/${row.id}/${f.id}" alt="${f.name}" loading="lazy"></a>`
-- : html`<a class="ct-file" href="/chat/file/${row.id}/${f.id}" target="_blank" rel="noopener">${raw(ICON.file)}<span><b>${f.name}</b><small>${bytesText(f.size)}</small></span></a>`}</li>`)}</ul>`;
++ /** The files beside the question (thread.files draws them). */
++ function filesOf(row, files) {
++ return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));
}
async function turnHtml(row, { events = null, files = null } = {}) {
@@@ -262,30 +260,20 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
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 = html`${workBlock(row, evs, true)}${screenBlock(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">${filesBlock(row, attached)}<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>`;
++ // data-task and data-last are Actor's (js/chat.js follows the task's event stream from data-last).
++ return raw(thread.turn({ id: row.id, state: row.state, open, you: { text: row.task, files: filesOf(row, attached) }, reply: String(reply), attrs: { 'data-task': row.id, 'data-last': row.last_seq } }));
}
// ── 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>`;
++ return raw(thread.sidebar({
++ newHref: '/chat',
++ groups: thread.group(chats, (c) => c.last, now()).map((g) => ({
++ name: g.name,
++ items: g.items.map((c) => ({ href: `/chat/${c.id}`, title: c.title, current: c.id === currentId, live: c.open })),
++ })),
++ foot: [{ href: '/console', label: 'Console' }, { href: '/me/agents', label: 'Your agents' }, { href: '/memory', label: 'Memory' }],
++ }));
}
/**
@@@ -305,24 +293,27 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
</div></details>`;
}
++ const ATTACH_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11.5 12.4 19a5 5 0 0 1-7.1-7.1l8-8a3.4 3.4 0 0 1 4.8 4.8l-7.9 7.9a1.7 1.7 0 0 1-2.4-2.4L15 7"/></svg>';
++ const ATTACH_ACCEPT = '.txt,.md,.csv,.tsv,.json,.jsonl,.yaml,.yml,.toml,.xml,.html,.css,.js,.ts,.py,.rb,.go,.rs,.java,.c,.cpp,.cs,.php,.sh,.sql,.log,.srt,.vtt,text/*,image/png,image/jpeg,image/webp,image/gif';
function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', model = null } = {}) {
-- return html`<form class="cc" method="post" action="${working ? `/chat/${working}/steer` : '/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">
--<ul class="cc-files" data-cc-chips hidden></ul>
--<label class="sr-only" for="cc-text">Message Actor</label>
--<textarea id="cc-text" name="task" rows="1" maxlength="20000" required placeholder="${working ? 'Tell Actor something while it works' : parent ? 'Reply to Actor' : 'Ask Actor to do something'}" data-cc-text>${text}</textarea>
--<div class="cc-row">
--<button type="button" class="cc-attach" data-cc-attach aria-label="Attach files" title="Attach text, code, CSV, JSON or images" hidden>${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11.5 12.4 19a5 5 0 0 1-7.1-7.1l8-8a3.4 3.4 0 0 1 4.8 4.8l-7.9 7.9a1.7 1.7 0 0 1-2.4-2.4L15 7"/></svg>')}</button><input type="file" multiple hidden data-cc-files accept=".txt,.md,.csv,.tsv,.json,.jsonl,.yaml,.yml,.toml,.xml,.html,.css,.js,.ts,.py,.rb,.go,.rs,.java,.c,.cpp,.cs,.php,.sh,.sql,.log,.srt,.vtt,text/*,image/png,image/jpeg,image/webp,image/gif">
++ const tools = html`<button type="button" class="cc-attach" data-cc-attach aria-label="Attach files" title="Attach text, code, CSV, JSON or images" hidden>${raw(ATTACH_ICON)}</button><input type="file" multiple hidden data-cc-files accept="${ATTACH_ACCEPT}">
${modelChip(model)}
<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>
--${working ? html`<button class="cc-stop" type="submit" formaction="/tasks/${working}/cancel" formnovalidate aria-label="Stop" data-cc-stop>${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>')}</button>` : html`<button class="cc-stop" type="button" aria-label="Stop" data-cc-stop hidden>${raw('<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>')}</button>`}
--<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>`;
++<span class="cc-note" data-cc-note></span>`;
++ return raw(thread.composer({
++ action: working ? `/chat/${working}/steer` : '/chat',
++ name: 'task',
++ maxlength: 20000,
++ label: 'Message Actor',
++ placeholder: working ? 'Tell Actor something while it works' : parent ? 'Reply to Actor' : 'Ask Actor to do something',
++ value: text,
++ busy,
++ stopAction: working ? `/tasks/${working}/cancel` : '',
++ hidden: { parent },
++ top: String(html`${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}<ul class="cc-files" data-cc-chips hidden></ul>`),
++ tools: String(tools),
++ note: `Actor checks each answer with a second model.${config.allowance ? ` Free: ${config.allowance.tasksPerDay} tasks a day.` : ''}`,
++ }));
}
// What people really hand agents like this (the owner, 2026-11-01: "I hate the current examples… search what
@@@ -352,21 +343,17 @@ ${working ? html`<button class="cc-stop" type="submit" formaction="/tasks/${work
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'],
++ css: ['css/chat.css'], scripts: ['js/chat.js'], styles: ['thread.css'], sharedScripts: ['ov-thread.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>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p>
--<ul class="ct-starters">${STARTERS.map(([icon, k, v]) => html`<li><button type="button" data-starter="${v}">${raw(glyphs.svg(icon, { size: 20 }))}<b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`}
--</div>
--${composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model })}
--</section>
--</div>`,
++<div class="chat" data-chat="${first ? first.id : ''}">${raw(thread.page({
++ sidebar: String(sidebar(chats, first ? first.id : null)),
++ head: thread.head({ title, newHref: first ? '/chat' : null }),
++ turns: turnHtmls.map(String),
++ empty: first ? '' : String(html`<div class="ct-hello"><span class="ct-hello-mark" aria-hidden="true"></span><h2>What should Actor do?</h2><p>It browses, clicks and types for you. Watch it work, steer it, or go do something else.</p>
++<ul class="ct-starters">${STARTERS.map(([icon, k, v]) => html`<li><button type="button" data-ovt-starter="${v}">${raw(glyphs.svg(icon, { size: 20 }))}<b>${k}</b><span>${v}</span></button></li>`)}</ul></div>`),
++ composer: String(composer({ parent: lastTurn ? lastTurn.id : null, mode: lastTurn ? lastTurn.mode : 'balanced', busy, working: lastTurn && OPEN.includes(lastTurn.state) ? lastTurn.id : null, problem, text, model })),
++ label: first ? 'Conversation' : 'New chat',
++ }))}</div>`,
});
}
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex 46c5c29..3a27801 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -34,10 +34,12 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
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, 8);
++ assert.strictEqual((r.text.match(/data-ovt-starter="/g) || []).length, 8, 'the starters fill the kit\'s composer');
['Find a flight', 'Is this true?', 'Best deal', 'Watch for it', 'Plan a trip', 'Make a list'].forEach((k) => assert.ok(r.text.includes(`<b>${k}</b>`), k));
assert.ok(!/Who handles the mail/.test(r.text), 'not the old DNS example (the owner: "people aren\'t gonna use it for that")');
-- assert.match(r.text, /<form class="cc" method="post" action="\/chat" data-composer>/);
++ assert.match(r.text, /<form class="ovt-composer" method="post" action="\/chat" data-ovt-composer>/);
++ assert.match(r.text, /<link rel="stylesheet" href="\/shared\/thread\.css(\?v=[0-9a-f]+)?">/, 'the network\'s conversation UI (L55 C4)');
++ assert.match(r.text, /<script src="\/shared\/ov-thread\.js(\?v=[0-9a-f]+)?" defer><\/script>/);
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');
@@@ -53,10 +55,10 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
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, /<p class="ovt-bubble">Which VPS is cheapest for 2 vCPU and 4 GB\?<\/p>/);
++ assert.match(page.text, /<div class="ovt-answer" data-ovt-answer><div class="prose">[\s\S]*Hetzner CX22/);
++ assert.match(page.text, new RegExp(`<a class="ovt-act" href="/tasks/${first}\\?view=details">`), 'Details is the full task page');
++ assert.match(page.text, /<div class="ovt-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');
});
@@@ -99,8 +101,9 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
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>/);
++ assert.match(j.turn, new RegExp(`^<li class="ovt-turn state-(queued|running|succeeded)" id="t-${j.id}" data-turn="${j.id}"`));
++ assert.match(j.turn, new RegExp(`data-task="${j.id}"`), 'Actor follows the turn by its task');
++ assert.match(j.turn, /<p class="ovt-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);
@@@ -149,12 +152,14 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
assert.match(js, /'\/tasks\/' \+ encodeURIComponent\(id\) \+ '\/look\?at='/, 'the screen refreshes from the last picture');
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.match(js, /function streamIn\(ans\)/, 'the checked answer streams in word by word');
-- assert.match(js, /copy\(ans \? plainText\(ans\) : '', b\)/, 'Copy takes the answer as text…');
-- assert.match(js, /c\.querySelectorAll\('\.evs, \.ct-copy-code'\)/, '…without the citation chips\' numbers or the code blocks\' Copy buttons');
-- assert.match(js, /if \(ans && !reduce && !document\.documentElement\.hasAttribute\('data-ov-motion'\)\) streamIn\(ans\)/, 'not under reduced motion');
-- const css = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'css', 'chat.css'), 'utf8');
-- assert.match(css, /\.ct-answer\.is-streaming \.sw \{ opacity: 0;/, 'only opacity: a screen reader reads it all at once');
++ // The checked answer types in word by word, Copy leaves out the citation numbers, and reduced motion is
++ // respected: the network's conversation UI does it (openvibe-shared/ov-thread.js, L55 C4); Actor swaps the
++ // finished turn in through it and marks its citations whole and not for copying.
++ assert.match(js, /window\.OVThread\.replace\(id, h\)/, 'the finished turn is swapped in by the kit, which types its answer in');
++ assert.match(js, /window\.OVThread\.add\(res\.j\.turn\)/, 'a new turn joins through the kit');
++ const pages = require('fs').readFileSync(require('path').join(__dirname, '..', 'server', 'http', 'chat-pages.js'), 'utf8');
++ assert.ok(pages.includes("'<$1 data-ovt-whole data-ovt-nocopy class=\"evs'"), 'a citation comes in whole and is never copied');
++ assert.ok(!/function streamIn|function plainText|function decorate/.test(js), 'no second copy of what the kit does');
assert.ok(!/innerHTML = [^;]*(text\.value|e\.text|title)/.test(js), 'what a person typed is never set as HTML');
});
@@@ -172,9 +177,9 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [last]);
const page = await t.get(`/chat/${last}`, { as: kim });
await s.db.query("UPDATE tasks SET state = 'succeeded' WHERE id = $1", [last]);
-- assert.match(page.text, new RegExp(`<form class="cc" method="post" action="/chat/${last}/steer" data-composer data-busy>`));
++ assert.match(page.text, new RegExp(`<form class="ovt-composer is-busy" method="post" action="/chat/${last}/steer" data-ovt-composer>`));
assert.match(page.text, /placeholder="Tell Actor something while it works"/);
-- assert.match(page.text, new RegExp(`<button class="cc-stop" type="submit" formaction="/tasks/${last}/cancel" formnovalidate aria-label="Stop" data-cc-stop>`));
++ assert.match(page.text, new RegExp(`<button type="submit" class="ovt-stop" formaction="/tasks/${last}/cancel" formnovalidate data-ovt-stop>`));
assert.match(page.text, /<figure class="ct-screen" data-screen hidden><\/figure>/, 'no picture yet: the screen is there, hidden, for the script to fill');
});
@@@ -186,7 +191,7 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
const r = await t.get('/chat', { as: kim, json: { task: 'Which city leads?', files: [{ name: 'visits.csv', type: 'text/csv', data: Buffer.from(csv).toString('base64') }, { name: 'dot.png', type: 'image/png', data: png }] }, headers: { ...SAME, accept: 'application/json' } });
assert.strictEqual(r.status, 201, r.text.slice(0, 200));
const j = r.json();
-- assert.match(j.turn, /<ul class="ct-attached">/);
++ assert.match(j.turn, /<ul class="ovt-files">/);
assert.match(j.turn, /<b>visits\.csv<\/b>/);
assert.match(j.turn, new RegExp(`<img src="/chat/file/${j.id}/inp_[A-Za-z0-9_-]+" alt="dot\.png"`));
await t.waitFor(j.id);