From 77d8f703f85a0036792395b932e817814647c427 Mon Sep 17 00:00:00 2001 From: Abhay Padmanabhan <58100924+abhaypadmanabhan@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:17:29 -0700 Subject: [PATCH 1/5] Chat: a reply that comes in while the chat is open flows in word by word A bot's reply landed whole in one frame. Now each word (a code span, bold run, link, heading, table or code block as one) fades in from a soft blur 200ms, a step after the one before: 50ms apart, closer for a long reply so it's all there in about a second; over 400 words it just appears. Only a reply that arrived while the chat was open, never history; none with Reduce motion. Modelled on Grok Bot's flowing replies. scripts/test-flowing-reply.mjs. Co-Authored-By: Claude Opus 5.5 --- app/globals.css | 17 ++++- components/app/chat-view.tsx | 133 +++++++++++++++++++++++---------- scripts/test-flowing-reply.mjs | 95 +++++++++++++++++++++++ 3 files changed, 204 insertions(+), 41 deletions(-) create mode 100644 scripts/test-flowing-reply.mjs diff --git a/app/globals.css b/app/globals.css index 53dbf7a5..9423c67b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -687,6 +687,20 @@ animation: rise-in 240ms cubic-bezier(0.2, 0.8, 0.2, 1) backwards; } +/* + * A reply that came in while the chat was open, word by word (chat-view.tsx Rich's flow): each word, code span or + * link from a soft blur, a moment after the one before, as Grok Bot's replies do. + */ +@keyframes flow-in { + from { + opacity: 0; + filter: blur(4px); + } +} +.motion-flow-word { + animation: flow-in 200ms ease-out backwards; +} + /* What you just sent: up from the composer, a touch smaller at first. */ @keyframes send-in { from { @@ -860,7 +874,8 @@ .motion-tab-in, .motion-tick, .motion-dot-in, - .motion-typing-dot { + .motion-typing-dot, + .motion-flow-word { animation: none; } } diff --git a/components/app/chat-view.tsx b/components/app/chat-view.tsx index afe3eec6..82c943e1 100644 --- a/components/app/chat-view.tsx +++ b/components/app/chat-view.tsx @@ -84,24 +84,40 @@ const blockerAsk = (state: AppState, b: Blocker) => (state.shared ? OWNER_BLOCKE /** The bot whose text this is, so a file it links opens from its computer (see Inline). */ const LinkedBy = createContext(undefined); +/** + * A reply flowing in (Rich's `flow`): how far apart its words start, in ms; 0 when it's all there at once. Each word + * fades in from a soft blur a moment after the one before (`motion-flow-word`), as Grok Bot's replies do. + */ +const FlowStep = createContext(0); +/** The words in some text, as the flow counts them. */ +const wordsIn = (text: string) => text.split(/\s+/).filter(Boolean).length; +/** A word's (or a link's, a code span's) place in the flow, as the props of the element that shows it. */ +const flowAt = (step: number, at: number) => (step ? { className: "motion-flow-word", style: { animationDelay: `${at * step}ms` } } : { className: "", style: undefined }); /** A link to a file a bot made, not a web page: a path on its computer (/workspace/…) or on this Mac (~/…, /Users/…). */ const isFileLink = (href: string) => /^(\/workspace\/|~\/|\/Users\/|file:\/\/\/)/.test(href); -export function Rich({ text, botId }: { text: string; botId?: string }) { +export function Rich({ text, botId, flow }: { text: string; botId?: string; flow?: boolean }) { // Code blocks (ASCII diagrams, commands) keep their monospace layout; the rest is inline text. const blocks = text.split(/```[a-z0-9-]*\n?([\s\S]*?)```/g); + // Flowing in: 50ms between words, closer for a long reply so it's all there in about a second; a very long one + // just appears. Each block starts where the words before it left off (a code block is one step). + const words = wordsIn(text); + const step = flow && words <= 400 ? Math.min(50, Math.floor(900 / Math.max(1, words))) : 0; + const starts = blocks.reduce((at, b, i) => [...at, i ? at[i - 1] + (i % 2 ? wordsIn(blocks[i - 1]) : 1) : 0], []); return ( - {blocks.map((b, i) => - i % 2 ? ( -
-            {b.replace(/\n$/, "")}
-          
- ) : ( - - ), - )} + + {blocks.map((b, i) => { + if (!(i % 2)) return ; + const f = flowAt(step, starts[i]); + return ( +
+              {b.replace(/\n$/, "")}
+            
+ ); + })} +
); } @@ -111,14 +127,18 @@ const isRule = (l: string) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$ const cells = (l: string) => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()); /** Text between code blocks: Markdown tables become real tables, "# Headings" bold lines, the rest inline text. */ -function Prose({ text }: { text: string }) { +function Prose({ text, start = 0 }: { text: string; /** Its first word's place in a reply flowing in (Rich's flow). */ start?: number }) { + const step = useContext(FlowStep); const lines = text.split("\n"); const out: React.ReactNode[] = []; let buf: string[] = []; + // Where the next part starts in the flow: each one after the words before it. + let at = start; // Blank lines around a table or heading would add a second gap on top of its own spacing. const flush = () => { while (buf.length && !buf[buf.length - 1].trim()) buf.pop(); - if (buf.length) out.push(); + if (buf.length) out.push(); + at += wordsIn(buf.join(" ")); buf = []; }; const skipBlank = () => { @@ -135,30 +155,35 @@ function Prose({ text }: { text: string }) { while (i < lines.length && isRow(lines[i])) rows.push(cells(lines[i++])); i--; skipBlank(); + const table = flowAt(step, at); + at += 1; out.push( - - - - - {head.map((h, j) => ( - - ))} - - - - {rows.map((r, k) => ( - - {r.map((c, j) => ( - + + {/* The table flows in whole: its cells' words don't on their own. */} + +
- -
- -
+ + + {head.map((h, j) => ( + ))} - ))} - -
+ +
+ + + {rows.map((r, k) => ( + + {r.map((c, j) => ( + + + + ))} + + ))} + + +
, ); continue; @@ -166,8 +191,10 @@ function Prose({ text }: { text: string }) { const heading = /^\s*#{1,4}\s+(.*)$/.exec(lines[i]); if (heading) { flush(); + const title = flowAt(step, at); + at += 1; out.push( - + {heading[1]} , ); @@ -181,7 +208,8 @@ function Prose({ text }: { text: string }) { } /** Light inline markdown: **bold** and [links](url). */ -function Inline({ text }: { text: string }) { +function Inline({ text, start = 0 }: { text: string; /** Its first word's place in a reply flowing in (Rich's flow). */ start?: number }) { + const step = useContext(FlowStep); // Links open as a tab on the right; ⌘-click still opens your browser. A page a bot made opens there too. // So does a file it linked: Bops fetches it from the bot's computer (or this Mac) first, as the link itself goes nowhere. const openLink = useContext(OpenLink); @@ -199,20 +227,31 @@ function Inline({ text }: { text: string }) { else openLink(url, label); }; const parts = text.split(/(\*\*[^*]+\*\*|\[[^\]]+\]\([^)\s]+\)|`[^`\n]+`)/g); + // Flowing in: each word its own step, and a code span, a bold run or a link one step, in order. + let at = start; + const next = () => flowAt(step, at++); return ( <> {parts.map((p, i) => { // `code`: monospace, and it wraps like text (moves, ids, commands can be long). const code = /^`([^`\n]+)`$/.exec(p); - if (code) return {code[1]}; + if (code) { + const f = next(); + return {code[1]}; + } const bold = /^\*\*([^*]+)\*\*$/.exec(p); - if (bold) return {bold[1]}; + if (bold) { + const f = next(); + return {bold[1]}; + } const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(p); if (link) { const page = link[2].startsWith("/api/pages/"); + const f = next(); return ( {link[1]} ); } - return {p}; + if (!step) return {p}; + return ( + + {p.split(/(\s+)/).map((w, j) => + /\S/.test(w) ? ( + + {w} + + ) : ( + w + ), + )} + + ); })} {failed && ( @@ -1826,7 +1878,8 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO ) : ( (m.text || !m.images?.length) && (
- + {/* A reply that came in while the chat was open flows in, word by word. */} +
) )} diff --git a/scripts/test-flowing-reply.mjs b/scripts/test-flowing-reply.mjs new file mode 100644 index 00000000..afcd54b9 --- /dev/null +++ b/scripts/test-flowing-reply.mjs @@ -0,0 +1,95 @@ +// Tests for a reply flowing in (components/app/chat-view.tsx Rich's `flow`, the motion-flow-word animation in +// app/globals.css): each word, code span, bold run or link of a reply that came in while the chat was open fades in +// a step after the one before, in reading order across paragraphs, headings and code blocks; a long reply's steps +// are closer so it's all there in about a second; a very long one, and any reply without flow (history), has none. +// Rendered to HTML. Usage: node scripts/test-flowing-reply.mjs +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { registerHooks } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import ts from "typescript"; + +const root = fileURLToPath(new URL("..", import.meta.url)).replace(/\/$/, ""); +registerHooks({ + resolve(specifier, context, next) { + if (specifier.startsWith("@/")) specifier = pathToFileURL(`${root}/${specifier.slice(2)}`).href; + try { + return next(specifier, context); + } catch (e) { + if (/^(\.{1,2}\/|\/|file:)/.test(specifier)) + for (const ext of [".ts", ".tsx"]) + try { + return next(specifier + ext, context); + } catch {} + throw e; + } + }, + load(url, context, next) { + if (url.endsWith(".css")) return { format: "module", source: "export default {};", shortCircuit: true }; + if (!url.endsWith(".tsx")) return next(url, context); + const source = readFileSync(fileURLToPath(url), "utf8"); + const out = ts.transpileModule(source, { compilerOptions: { jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022, verbatimModuleSyntax: false }, fileName: url }); + return { format: "module", source: out.outputText, shortCircuit: true }; + }, +}); +const emitWarning = process.emitWarning.bind(process); +process.emitWarning = (warning, ...rest) => (rest[0]?.code === "MODULE_TYPELESS_PACKAGE_JSON" ? undefined : emitWarning(warning, ...rest)); + +// A window, enough of one (nothing here runs effects: it's rendered to HTML). +globalThis.window = globalThis; +globalThis.location = { origin: "http://localhost:3210" }; +globalThis.addEventListener = () => {}; +globalThis.removeEventListener = () => {}; +globalThis.document = { hidden: false, addEventListener() {}, removeEventListener() {}, getElementById: () => null }; +globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; +globalThis.sessionStorage = globalThis.localStorage; +globalThis.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); + +const { createElement: h } = await import("react"); +const { renderToStaticMarkup } = await import("react-dom/server"); +const { Rich } = await import("../components/app/chat-view.tsx"); + +let passed = 0; +const check = (name, fn) => { + fn(); + passed++; + console.log(`ok - ${name}`); +}; +const render = (text, flow) => renderToStaticMarkup(h(Rich, { text, botId: "b1", flow })); +/** Each flowing element's text and its delay in ms, in the order they're drawn. */ +const flowing = (html) => + [...html.matchAll(/<\w+([^>]*)>([^<]*)/g)].filter((m) => /class="[^"]*motion-flow-word/.test(m[1])).map((m) => [m[2], Number(/animation-delay:(\d+)ms/.exec(m[1])?.[1])]); + +check("each word a step after the one before, 50ms apart for a short reply, the spaces between them kept", () => { + const html = render("Booked for Tuesday at noon.", true); + assert.deepEqual(flowing(html), [["Booked", 0], ["for", 50], ["Tuesday", 100], ["at", 150], ["noon.", 200]]); + assert.match(html.replace(/<[^>]+>/g, ""), /^Booked for Tuesday at noon\.$/, "the same words, read the same"); +}); + +check("in reading order across a paragraph, a heading, a code span, a link and a code block", () => { + const html = render("Two steps.\n\n# Next\nRun `npm ci` then [open it](https://example.com).\n```\nnpm run dev\n```\nDone.", true); + assert.deepEqual( + flowing(html).map(([t]) => t), + ["Two", "steps.", "Next", "Run", "npm ci", "then", "open it", ".", "npm run dev", "Done."], + ); + const delays = flowing(html).map(([, d]) => d); + assert.deepEqual(delays, [...delays].sort((a, b) => a - b), "never a step back"); +}); + +check("a long reply's steps are closer, so it's all there in about a second; a very long one just appears", () => { + const words = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(" "); + const long = flowing(render(words(300), true)); + assert.equal(long.length, 300); + assert.equal(long[1][1], 3, "900ms over 300 words: 3ms apart"); + assert.ok(long.at(-1)[1] <= 900); + assert.equal(flowing(render(words(401), true)).length, 0); +}); + +check("history, and any reply without flow, is drawn as it always was", () => { + const html = render("Booked for Tuesday at **noon**.", false); + assert.equal(flowing(html).length, 0); + assert.ok(!html.includes("motion-flow-word")); + assert.match(html, /^Booked for Tuesday at noon<\/strong>\.$/); +}); + +console.log(`\nflowing reply: ${passed} passed`); From f769e92a4460105198c7aae2a47f4ccd52022bf2 Mon Sep 17 00:00:00 2001 From: Abhay Padmanabhan <58100924+abhaypadmanabhan@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:28:13 -0700 Subject: [PATCH 2/5] Flowing reply: a part never starts before the steps above it Review: a paragraph moved the count on by its words, but Inline gives a code span, bold run or link and the punctuation next to it steps of their own, so "Use `git`, `ls`, and `pwd`." took 8 steps where 5 were kept and the heading after it started early. Both now count with one function on the same split Inline draws from; across code blocks a reply keeps, line by line, at least the steps a block can take. Exact delays in the test. Co-Authored-By: Claude Opus 5.5 --- components/app/chat-view.tsx | 22 ++++++++++++++-------- scripts/test-flowing-reply.mjs | 7 +++++++ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/components/app/chat-view.tsx b/components/app/chat-view.tsx index 82c943e1..36de4a40 100644 --- a/components/app/chat-view.tsx +++ b/components/app/chat-view.tsx @@ -89,8 +89,13 @@ const LinkedBy = createContext(undefined); * fades in from a soft blur a moment after the one before (`motion-flow-word`), as Grok Bot's replies do. */ const FlowStep = createContext(0); -/** The words in some text, as the flow counts them. */ -const wordsIn = (text: string) => text.split(/\s+/).filter(Boolean).length; +/** Inline's light markdown: a **bold** run, a [link](url) or a `code` span, each one piece. */ +const INLINE_MARKS = /(\*\*[^*]+\*\*|\[[^\]]+\]\([^)\s]+\)|`[^`\n]+`)/g; +const inlineMark = (part: string) => /^`[^`\n]+`$/.test(part) || /^\*\*[^*]+\*\*$/.test(part) || /^\[[^\]]+\]\([^)\s]+\)$/.test(part); +/** How many steps Inline gives some text when it flows: one per mark, one per word around them (as it draws them). */ +const flowSteps = (text: string) => text.split(INLINE_MARKS).reduce((n, part) => n + (inlineMark(part) ? 1 : (part.match(/\S+/g)?.length ?? 0)), 0); +/** At least as many steps as Prose gives some text, whatever its tables and headings: counted line by line. */ +const flowStepsAtMost = (text: string) => text.split("\n").reduce((n, line) => n + flowSteps(line), 0); /** A word's (or a link's, a code span's) place in the flow, as the props of the element that shows it. */ const flowAt = (step: number, at: number) => (step ? { className: "motion-flow-word", style: { animationDelay: `${at * step}ms` } } : { className: "", style: undefined }); @@ -101,10 +106,11 @@ export function Rich({ text, botId, flow }: { text: string; botId?: string; flow // Code blocks (ASCII diagrams, commands) keep their monospace layout; the rest is inline text. const blocks = text.split(/```[a-z0-9-]*\n?([\s\S]*?)```/g); // Flowing in: 50ms between words, closer for a long reply so it's all there in about a second; a very long one - // just appears. Each block starts where the words before it left off (a code block is one step). - const words = wordsIn(text); - const step = flow && words <= 400 ? Math.min(50, Math.floor(900 / Math.max(1, words))) : 0; - const starts = blocks.reduce((at, b, i) => [...at, i ? at[i - 1] + (i % 2 ? wordsIn(blocks[i - 1]) : 1) : 0], []); + // just appears. Each block starts after every step the one before it can take (a code block is one step), so + // nothing starts before what's above it. + const steps = blocks.reduce((n, b, i) => n + (i % 2 ? 1 : flowStepsAtMost(b)), 0); + const step = flow && steps <= 400 ? Math.min(50, Math.floor(900 / Math.max(1, steps))) : 0; + const starts = blocks.reduce((at, b, i) => [...at, i ? at[i - 1] + (i % 2 ? flowStepsAtMost(blocks[i - 1]) : 1) : 0], []); return ( @@ -138,7 +144,7 @@ function Prose({ text, start = 0 }: { text: string; /** Its first word's place i const flush = () => { while (buf.length && !buf[buf.length - 1].trim()) buf.pop(); if (buf.length) out.push(); - at += wordsIn(buf.join(" ")); + at += flowSteps(buf.join("\n")); buf = []; }; const skipBlank = () => { @@ -226,7 +232,7 @@ function Inline({ text, start = 0 }: { text: string; /** Its first word's place if (browser || !openLink) window.open(url, "_blank"); else openLink(url, label); }; - const parts = text.split(/(\*\*[^*]+\*\*|\[[^\]]+\]\([^)\s]+\)|`[^`\n]+`)/g); + const parts = text.split(INLINE_MARKS); // Flowing in: each word its own step, and a code span, a bold run or a link one step, in order. let at = start; const next = () => flowAt(step, at++); diff --git a/scripts/test-flowing-reply.mjs b/scripts/test-flowing-reply.mjs index afcd54b9..6264bcaf 100644 --- a/scripts/test-flowing-reply.mjs +++ b/scripts/test-flowing-reply.mjs @@ -76,6 +76,13 @@ check("in reading order across a paragraph, a heading, a code span, a link and a assert.deepEqual(delays, [...delays].sort((a, b) => a - b), "never a step back"); }); +check("never a step back when marks and punctuation sit between words: each part starts after all the steps above it", () => { + assert.deepEqual(flowing(render("Use `git`, `ls`, and `pwd`.\n# Next\nDone.", true)), [ + ["Use", 0], ["git", 50], [",", 100], ["ls", 150], [",", 200], ["and", 250], ["pwd", 300], [".", 350], ["Next", 400], ["Done.", 450], + ]); + assert.deepEqual(flowing(render("See `a` and `b`.\n```\nx\n```\nAfter.", true)).map(([, d]) => d), [0, 50, 100, 150, 200, 250, 300]); +}); + check("a long reply's steps are closer, so it's all there in about a second; a very long one just appears", () => { const words = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(" "); const long = flowing(render(words(300), true)); From d0b0fbfc21fe50e99b90984b17e7d98c5de790bd Mon Sep 17 00:00:00 2001 From: Abhay Padmanabhan <58100924+abhaypadmanabhan@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:33:10 -0700 Subject: [PATCH 3/5] Flowing reply: the 400-word cutoff counts words again Review: with steps counted for positions, the cutoff counted steps too, so a 401-word bold run (one step) still flowed. The cutoff counts words, as it says; the pacing still counts steps, which is what keeps the reveal to about a second. Co-Authored-By: Claude Opus 5.5 --- components/app/chat-view.tsx | 9 +++++---- scripts/test-flowing-reply.mjs | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/components/app/chat-view.tsx b/components/app/chat-view.tsx index 36de4a40..8858ea80 100644 --- a/components/app/chat-view.tsx +++ b/components/app/chat-view.tsx @@ -105,11 +105,12 @@ const isFileLink = (href: string) => /^(\/workspace\/|~\/|\/Users\/|file:\/\/\/) export function Rich({ text, botId, flow }: { text: string; botId?: string; flow?: boolean }) { // Code blocks (ASCII diagrams, commands) keep their monospace layout; the rest is inline text. const blocks = text.split(/```[a-z0-9-]*\n?([\s\S]*?)```/g); - // Flowing in: 50ms between words, closer for a long reply so it's all there in about a second; a very long one - // just appears. Each block starts after every step the one before it can take (a code block is one step), so - // nothing starts before what's above it. + // Flowing in: 50ms between steps, closer when there are many so it's all there in about a second; a reply of + // over 400 words just appears. Each block starts after every step the one before it can take (a code block is + // one step), so nothing starts before what's above it. const steps = blocks.reduce((n, b, i) => n + (i % 2 ? 1 : flowStepsAtMost(b)), 0); - const step = flow && steps <= 400 ? Math.min(50, Math.floor(900 / Math.max(1, steps))) : 0; + const words = text.split(/\s+/).filter(Boolean).length; + const step = flow && words <= 400 ? Math.min(50, Math.floor(900 / Math.max(1, steps))) : 0; const starts = blocks.reduce((at, b, i) => [...at, i ? at[i - 1] + (i % 2 ? flowStepsAtMost(blocks[i - 1]) : 1) : 0], []); return ( diff --git a/scripts/test-flowing-reply.mjs b/scripts/test-flowing-reply.mjs index 6264bcaf..9a4e1f78 100644 --- a/scripts/test-flowing-reply.mjs +++ b/scripts/test-flowing-reply.mjs @@ -90,6 +90,7 @@ check("a long reply's steps are closer, so it's all there in about a second; a v assert.equal(long[1][1], 3, "900ms over 300 words: 3ms apart"); assert.ok(long.at(-1)[1] <= 900); assert.equal(flowing(render(words(401), true)).length, 0); + assert.equal(flowing(render(`**${words(401)}**`, true)).length, 0, "counted in words, however they're marked"); }); check("history, and any reply without flow, is drawn as it always was", () => { From ffcff4a29d498ee724865827086ce5c6cab630d6 Mon Sep 17 00:00:00 2001 From: Abhay Padmanabhan <58100924+abhaypadmanabhan@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:52:23 -0700 Subject: [PATCH 4/5] Revert the word-by-word reply flow Abhay: Grok Bot's chat replies don't flow in word by word (that code is its voice transcript); they arrive whole, with a smooth entrance. The next commit does that instead. Co-Authored-By: Claude Opus 5.5 --- app/globals.css | 17 +--- components/app/chat-view.tsx | 142 ++++++++++----------------------- scripts/test-flowing-reply.mjs | 103 ------------------------ 3 files changed, 42 insertions(+), 220 deletions(-) delete mode 100644 scripts/test-flowing-reply.mjs diff --git a/app/globals.css b/app/globals.css index 9423c67b..53dbf7a5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -687,20 +687,6 @@ animation: rise-in 240ms cubic-bezier(0.2, 0.8, 0.2, 1) backwards; } -/* - * A reply that came in while the chat was open, word by word (chat-view.tsx Rich's flow): each word, code span or - * link from a soft blur, a moment after the one before, as Grok Bot's replies do. - */ -@keyframes flow-in { - from { - opacity: 0; - filter: blur(4px); - } -} -.motion-flow-word { - animation: flow-in 200ms ease-out backwards; -} - /* What you just sent: up from the composer, a touch smaller at first. */ @keyframes send-in { from { @@ -874,8 +860,7 @@ .motion-tab-in, .motion-tick, .motion-dot-in, - .motion-typing-dot, - .motion-flow-word { + .motion-typing-dot { animation: none; } } diff --git a/components/app/chat-view.tsx b/components/app/chat-view.tsx index 8858ea80..afe3eec6 100644 --- a/components/app/chat-view.tsx +++ b/components/app/chat-view.tsx @@ -84,47 +84,24 @@ const blockerAsk = (state: AppState, b: Blocker) => (state.shared ? OWNER_BLOCKE /** The bot whose text this is, so a file it links opens from its computer (see Inline). */ const LinkedBy = createContext(undefined); -/** - * A reply flowing in (Rich's `flow`): how far apart its words start, in ms; 0 when it's all there at once. Each word - * fades in from a soft blur a moment after the one before (`motion-flow-word`), as Grok Bot's replies do. - */ -const FlowStep = createContext(0); -/** Inline's light markdown: a **bold** run, a [link](url) or a `code` span, each one piece. */ -const INLINE_MARKS = /(\*\*[^*]+\*\*|\[[^\]]+\]\([^)\s]+\)|`[^`\n]+`)/g; -const inlineMark = (part: string) => /^`[^`\n]+`$/.test(part) || /^\*\*[^*]+\*\*$/.test(part) || /^\[[^\]]+\]\([^)\s]+\)$/.test(part); -/** How many steps Inline gives some text when it flows: one per mark, one per word around them (as it draws them). */ -const flowSteps = (text: string) => text.split(INLINE_MARKS).reduce((n, part) => n + (inlineMark(part) ? 1 : (part.match(/\S+/g)?.length ?? 0)), 0); -/** At least as many steps as Prose gives some text, whatever its tables and headings: counted line by line. */ -const flowStepsAtMost = (text: string) => text.split("\n").reduce((n, line) => n + flowSteps(line), 0); -/** A word's (or a link's, a code span's) place in the flow, as the props of the element that shows it. */ -const flowAt = (step: number, at: number) => (step ? { className: "motion-flow-word", style: { animationDelay: `${at * step}ms` } } : { className: "", style: undefined }); /** A link to a file a bot made, not a web page: a path on its computer (/workspace/…) or on this Mac (~/…, /Users/…). */ const isFileLink = (href: string) => /^(\/workspace\/|~\/|\/Users\/|file:\/\/\/)/.test(href); -export function Rich({ text, botId, flow }: { text: string; botId?: string; flow?: boolean }) { +export function Rich({ text, botId }: { text: string; botId?: string }) { // Code blocks (ASCII diagrams, commands) keep their monospace layout; the rest is inline text. const blocks = text.split(/```[a-z0-9-]*\n?([\s\S]*?)```/g); - // Flowing in: 50ms between steps, closer when there are many so it's all there in about a second; a reply of - // over 400 words just appears. Each block starts after every step the one before it can take (a code block is - // one step), so nothing starts before what's above it. - const steps = blocks.reduce((n, b, i) => n + (i % 2 ? 1 : flowStepsAtMost(b)), 0); - const words = text.split(/\s+/).filter(Boolean).length; - const step = flow && words <= 400 ? Math.min(50, Math.floor(900 / Math.max(1, steps))) : 0; - const starts = blocks.reduce((at, b, i) => [...at, i ? at[i - 1] + (i % 2 ? flowStepsAtMost(blocks[i - 1]) : 1) : 0], []); return ( - - {blocks.map((b, i) => { - if (!(i % 2)) return ; - const f = flowAt(step, starts[i]); - return ( -
-              {b.replace(/\n$/, "")}
-            
- ); - })} -
+ {blocks.map((b, i) => + i % 2 ? ( +
+            {b.replace(/\n$/, "")}
+          
+ ) : ( + + ), + )}
); } @@ -134,18 +111,14 @@ const isRule = (l: string) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$ const cells = (l: string) => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim()); /** Text between code blocks: Markdown tables become real tables, "# Headings" bold lines, the rest inline text. */ -function Prose({ text, start = 0 }: { text: string; /** Its first word's place in a reply flowing in (Rich's flow). */ start?: number }) { - const step = useContext(FlowStep); +function Prose({ text }: { text: string }) { const lines = text.split("\n"); const out: React.ReactNode[] = []; let buf: string[] = []; - // Where the next part starts in the flow: each one after the words before it. - let at = start; // Blank lines around a table or heading would add a second gap on top of its own spacing. const flush = () => { while (buf.length && !buf[buf.length - 1].trim()) buf.pop(); - if (buf.length) out.push(); - at += flowSteps(buf.join("\n")); + if (buf.length) out.push(); buf = []; }; const skipBlank = () => { @@ -162,35 +135,30 @@ function Prose({ text, start = 0 }: { text: string; /** Its first word's place i while (i < lines.length && isRow(lines[i])) rows.push(cells(lines[i++])); i--; skipBlank(); - const table = flowAt(step, at); - at += 1; out.push( - - {/* The table flows in whole: its cells' words don't on their own. */} - - - - - {head.map((h, j) => ( - + +
- -
+ + + {head.map((h, j) => ( + + ))} + + + + {rows.map((r, k) => ( + + {r.map((c, j) => ( + ))} - - - {rows.map((r, k) => ( - - {r.map((c, j) => ( - - ))} - - ))} - -
+ +
+ +
- -
-
+ ))} + +
, ); continue; @@ -198,10 +166,8 @@ function Prose({ text, start = 0 }: { text: string; /** Its first word's place i const heading = /^\s*#{1,4}\s+(.*)$/.exec(lines[i]); if (heading) { flush(); - const title = flowAt(step, at); - at += 1; out.push( - + {heading[1]} , ); @@ -215,8 +181,7 @@ function Prose({ text, start = 0 }: { text: string; /** Its first word's place i } /** Light inline markdown: **bold** and [links](url). */ -function Inline({ text, start = 0 }: { text: string; /** Its first word's place in a reply flowing in (Rich's flow). */ start?: number }) { - const step = useContext(FlowStep); +function Inline({ text }: { text: string }) { // Links open as a tab on the right; ⌘-click still opens your browser. A page a bot made opens there too. // So does a file it linked: Bops fetches it from the bot's computer (or this Mac) first, as the link itself goes nowhere. const openLink = useContext(OpenLink); @@ -233,32 +198,21 @@ function Inline({ text, start = 0 }: { text: string; /** Its first word's place if (browser || !openLink) window.open(url, "_blank"); else openLink(url, label); }; - const parts = text.split(INLINE_MARKS); - // Flowing in: each word its own step, and a code span, a bold run or a link one step, in order. - let at = start; - const next = () => flowAt(step, at++); + const parts = text.split(/(\*\*[^*]+\*\*|\[[^\]]+\]\([^)\s]+\)|`[^`\n]+`)/g); return ( <> {parts.map((p, i) => { // `code`: monospace, and it wraps like text (moves, ids, commands can be long). const code = /^`([^`\n]+)`$/.exec(p); - if (code) { - const f = next(); - return {code[1]}; - } + if (code) return {code[1]}; const bold = /^\*\*([^*]+)\*\*$/.exec(p); - if (bold) { - const f = next(); - return {bold[1]}; - } + if (bold) return {bold[1]}; const link = /^\[([^\]]+)\]\(([^)\s]+)\)$/.exec(p); if (link) { const page = link[2].startsWith("/api/pages/"); - const f = next(); return ( {link[1]} ); } - if (!step) return {p}; - return ( - - {p.split(/(\s+)/).map((w, j) => - /\S/.test(w) ? ( - - {w} - - ) : ( - w - ), - )} - - ); + return {p}; })} {failed && ( @@ -1885,8 +1826,7 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO ) : ( (m.text || !m.images?.length) && (
- {/* A reply that came in while the chat was open flows in, word by word. */} - +
) )} diff --git a/scripts/test-flowing-reply.mjs b/scripts/test-flowing-reply.mjs deleted file mode 100644 index 9a4e1f78..00000000 --- a/scripts/test-flowing-reply.mjs +++ /dev/null @@ -1,103 +0,0 @@ -// Tests for a reply flowing in (components/app/chat-view.tsx Rich's `flow`, the motion-flow-word animation in -// app/globals.css): each word, code span, bold run or link of a reply that came in while the chat was open fades in -// a step after the one before, in reading order across paragraphs, headings and code blocks; a long reply's steps -// are closer so it's all there in about a second; a very long one, and any reply without flow (history), has none. -// Rendered to HTML. Usage: node scripts/test-flowing-reply.mjs -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { registerHooks } from "node:module"; -import { fileURLToPath, pathToFileURL } from "node:url"; -import ts from "typescript"; - -const root = fileURLToPath(new URL("..", import.meta.url)).replace(/\/$/, ""); -registerHooks({ - resolve(specifier, context, next) { - if (specifier.startsWith("@/")) specifier = pathToFileURL(`${root}/${specifier.slice(2)}`).href; - try { - return next(specifier, context); - } catch (e) { - if (/^(\.{1,2}\/|\/|file:)/.test(specifier)) - for (const ext of [".ts", ".tsx"]) - try { - return next(specifier + ext, context); - } catch {} - throw e; - } - }, - load(url, context, next) { - if (url.endsWith(".css")) return { format: "module", source: "export default {};", shortCircuit: true }; - if (!url.endsWith(".tsx")) return next(url, context); - const source = readFileSync(fileURLToPath(url), "utf8"); - const out = ts.transpileModule(source, { compilerOptions: { jsx: ts.JsxEmit.ReactJSX, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022, verbatimModuleSyntax: false }, fileName: url }); - return { format: "module", source: out.outputText, shortCircuit: true }; - }, -}); -const emitWarning = process.emitWarning.bind(process); -process.emitWarning = (warning, ...rest) => (rest[0]?.code === "MODULE_TYPELESS_PACKAGE_JSON" ? undefined : emitWarning(warning, ...rest)); - -// A window, enough of one (nothing here runs effects: it's rendered to HTML). -globalThis.window = globalThis; -globalThis.location = { origin: "http://localhost:3210" }; -globalThis.addEventListener = () => {}; -globalThis.removeEventListener = () => {}; -globalThis.document = { hidden: false, addEventListener() {}, removeEventListener() {}, getElementById: () => null }; -globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} }; -globalThis.sessionStorage = globalThis.localStorage; -globalThis.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }); - -const { createElement: h } = await import("react"); -const { renderToStaticMarkup } = await import("react-dom/server"); -const { Rich } = await import("../components/app/chat-view.tsx"); - -let passed = 0; -const check = (name, fn) => { - fn(); - passed++; - console.log(`ok - ${name}`); -}; -const render = (text, flow) => renderToStaticMarkup(h(Rich, { text, botId: "b1", flow })); -/** Each flowing element's text and its delay in ms, in the order they're drawn. */ -const flowing = (html) => - [...html.matchAll(/<\w+([^>]*)>([^<]*)/g)].filter((m) => /class="[^"]*motion-flow-word/.test(m[1])).map((m) => [m[2], Number(/animation-delay:(\d+)ms/.exec(m[1])?.[1])]); - -check("each word a step after the one before, 50ms apart for a short reply, the spaces between them kept", () => { - const html = render("Booked for Tuesday at noon.", true); - assert.deepEqual(flowing(html), [["Booked", 0], ["for", 50], ["Tuesday", 100], ["at", 150], ["noon.", 200]]); - assert.match(html.replace(/<[^>]+>/g, ""), /^Booked for Tuesday at noon\.$/, "the same words, read the same"); -}); - -check("in reading order across a paragraph, a heading, a code span, a link and a code block", () => { - const html = render("Two steps.\n\n# Next\nRun `npm ci` then [open it](https://example.com).\n```\nnpm run dev\n```\nDone.", true); - assert.deepEqual( - flowing(html).map(([t]) => t), - ["Two", "steps.", "Next", "Run", "npm ci", "then", "open it", ".", "npm run dev", "Done."], - ); - const delays = flowing(html).map(([, d]) => d); - assert.deepEqual(delays, [...delays].sort((a, b) => a - b), "never a step back"); -}); - -check("never a step back when marks and punctuation sit between words: each part starts after all the steps above it", () => { - assert.deepEqual(flowing(render("Use `git`, `ls`, and `pwd`.\n# Next\nDone.", true)), [ - ["Use", 0], ["git", 50], [",", 100], ["ls", 150], [",", 200], ["and", 250], ["pwd", 300], [".", 350], ["Next", 400], ["Done.", 450], - ]); - assert.deepEqual(flowing(render("See `a` and `b`.\n```\nx\n```\nAfter.", true)).map(([, d]) => d), [0, 50, 100, 150, 200, 250, 300]); -}); - -check("a long reply's steps are closer, so it's all there in about a second; a very long one just appears", () => { - const words = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(" "); - const long = flowing(render(words(300), true)); - assert.equal(long.length, 300); - assert.equal(long[1][1], 3, "900ms over 300 words: 3ms apart"); - assert.ok(long.at(-1)[1] <= 900); - assert.equal(flowing(render(words(401), true)).length, 0); - assert.equal(flowing(render(`**${words(401)}**`, true)).length, 0, "counted in words, however they're marked"); -}); - -check("history, and any reply without flow, is drawn as it always was", () => { - const html = render("Booked for Tuesday at **noon**.", false); - assert.equal(flowing(html).length, 0); - assert.ok(!html.includes("motion-flow-word")); - assert.match(html, /^Booked for Tuesday at noon<\/strong>\.$/); -}); - -console.log(`\nflowing reply: ${passed} passed`); From 75d8cd03d245261c25fd29d0e420f3f64104afd8 Mon Sep 17 00:00:00 2001 From: Abhay Padmanabhan <58100924+abhaypadmanabhan@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:54:06 -0700 Subject: [PATCH 5/5] Chat: a bot's reply arrives whole, with a smooth entrance A reply that came in while the chat was open rose 6px and landed in one frame. It now comes in all at once, up from 94% and a 4px blur, opaque by halfway and settling fast: 320ms on cubic-bezier(.23, 1, .32, 1), the values of Grok Bot's message rows. The chat's own smooth scroll to the newest message carries the rest up. Only a bot's reply that arrived while the chat was open; yours, history and everything else as before; none with Reduce motion. Co-Authored-By: Claude Opus 5.5 --- app/globals.css | 20 ++++++++++++++++++++ components/app/chat-view.tsx | 7 ++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/globals.css b/app/globals.css index 53dbf7a5..ac45a87e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -687,6 +687,25 @@ animation: rise-in 240ms cubic-bezier(0.2, 0.8, 0.2, 1) backwards; } +/* + * A bot's reply that came in while the chat was open: all of it at once, up from a touch smaller and a soft blur, + * settling fast (Grok Bot's message rows: 12px, 94%, opaque by halfway, cubic-bezier(.23, 1, .32, 1)). + */ +@keyframes reply-in { + from { + opacity: 0; + filter: blur(4px); + transform: translateY(12px) scale(0.94); + } + 55% { + opacity: 1; + } +} +.motion-reply-in { + transform-origin: left bottom; + animation: reply-in 320ms cubic-bezier(0.23, 1, 0.32, 1) backwards; +} + /* What you just sent: up from the composer, a touch smaller at first. */ @keyframes send-in { from { @@ -850,6 +869,7 @@ @media (prefers-reduced-motion: reduce) { .motion-rise-in, + .motion-reply-in, .motion-send-in, .motion-new-item, .motion-menu-in, diff --git a/components/app/chat-view.tsx b/components/app/chat-view.tsx index afe3eec6..683eb459 100644 --- a/components/app/chat-view.tsx +++ b/components/app/chat-view.tsx @@ -1364,7 +1364,12 @@ function MessageRow({ m, state, showSender, continued, sessionById, chipIds, onO // Decided once, as the row mounts: a poll or a new tapback draws it again but never replays it. // Yours carries on its pending copy's motion (a negative delay starts it partway, or past the end). const [enter] = useState(() => - !arrival ? undefined : arrival.kind === "send" ? { className: "motion-send-in", style: arrival.since ? { animationDelay: `${arrival.since - Date.now()}ms` } : undefined } : { className: "motion-rise-in", style: undefined }, + !arrival + ? undefined + : arrival.kind === "send" + ? { className: "motion-send-in", style: arrival.since ? { animationDelay: `${arrival.since - Date.now()}ms` } : undefined } + : // A bot's reply arrives whole, with an entrance of its own; anything else that comes in rises in. + { className: m.role === "bot" ? "motion-reply-in" : "motion-rise-in", style: undefined }, ); const enterClass = enter?.className ?? ""; // What was already here when the row mounted sits still: its tapbacks.