From ac7a2b9eb068b94af545fe9d1e50a5e9b7d65fe1 Mon Sep 17 00:00:00 2001 From: archievi <13202986+archievi@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:32:09 -0400 Subject: [PATCH 1/2] feat(ui): smooth streaming of agent message tokens Streamed assistant tokens arrive in bursty chunks, so the message text jumped forward unevenly as it rendered. Add a useSmoothText hook that reveals the accumulated text at a steady character rate using requestAnimationFrame, so bursts read as smooth typing. - Text present on mount renders immediately, so history and completed messages never replay a typewriter effect; only growth while mounted is animated. - Snaps instantly when the source is replaced (not a prefix) or when the user prefers reduced motion. - Auto-scroll-to-bottom is preserved: the virtualized list follows the rendered height as the smoothed text grows. Closes #2517 --- .../session-update/AgentMessage.tsx | 6 +- .../primitives/hooks/useSmoothText.test.ts | 122 ++++++++++++++++++ .../ui/src/primitives/hooks/useSmoothText.ts | 102 +++++++++++++++ 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/primitives/hooks/useSmoothText.test.ts create mode 100644 packages/ui/src/primitives/hooks/useSmoothText.ts diff --git a/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx b/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx index 2d59f6960c..e6dcaeb0dc 100644 --- a/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx +++ b/packages/ui/src/features/sessions/components/session-update/AgentMessage.tsx @@ -3,6 +3,7 @@ import { Box, Code, IconButton } from "@radix-ui/themes"; import { memo, useCallback, useMemo, useState } from "react"; import type { Components } from "react-markdown"; import { HighlightedCode } from "../../../../primitives/HighlightedCode"; +import { useSmoothText } from "../../../../primitives/hooks/useSmoothText"; import { Tooltip } from "../../../../primitives/Tooltip"; import { usePendingScrollStore } from "../../../code-editor/pendingScrollStore"; import { MarkdownRenderer } from "../../../editor/components/MarkdownRenderer"; @@ -141,6 +142,9 @@ export const AgentMessage = memo(function AgentMessage({ content, }: AgentMessageProps) { const [copied, setCopied] = useState(false); + // Reveal streamed tokens at a steady rate so bursty chunks read as smooth + // typing. Copy always uses the full `content`, not the partial reveal. + const displayedContent = useSmoothText(content); const handleCopy = useCallback(() => { navigator.clipboard.writeText(content); @@ -151,7 +155,7 @@ export const AgentMessage = memo(function AgentMessage({ return ( diff --git a/packages/ui/src/primitives/hooks/useSmoothText.test.ts b/packages/ui/src/primitives/hooks/useSmoothText.test.ts new file mode 100644 index 0000000000..b872e33bbd --- /dev/null +++ b/packages/ui/src/primitives/hooks/useSmoothText.test.ts @@ -0,0 +1,122 @@ +import { + nextRevealLength, + useSmoothText, +} from "@posthog/ui/primitives/hooks/useSmoothText"; +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("nextRevealLength", () => { + it("returns the target when already caught up", () => { + expect(nextRevealLength(10, 10, 16, 120)).toBe(10); + expect(nextRevealLength(12, 10, 16, 120)).toBe(10); + }); + + it("advances proportionally to elapsed time and rate", () => { + // 120 chars/sec over 100ms = 12 chars. + expect(nextRevealLength(0, 100, 100, 120)).toBe(12); + }); + + it("never overshoots the target", () => { + expect(nextRevealLength(95, 100, 1000, 120)).toBe(100); + }); + + it("always advances at least one char when behind", () => { + // Tiny elapsed time would round to zero; keep forward progress. + expect(nextRevealLength(0, 100, 0, 120)).toBe(1); + }); + + it("snaps when the lag is too large to ease pleasantly", () => { + expect(nextRevealLength(0, 5000, 16, 120)).toBe(5000); + }); +}); + +describe("useSmoothText", () => { + let now: number; + let rafCallbacks: Array<(t: number) => void>; + + beforeEach(() => { + now = 0; + rafCallbacks = []; + vi.stubGlobal( + "requestAnimationFrame", + (cb: (t: number) => void): number => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }, + ); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal("matchMedia", () => ({ matches: false })); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // Advance one animation frame by the given time delta. + const flushFrame = (deltaMs: number) => { + now += deltaMs; + const callbacks = rafCallbacks; + rafCallbacks = []; + act(() => { + for (const cb of callbacks) cb(now); + }); + }; + + it("shows existing text immediately on mount (no replay)", () => { + const { result } = renderHook(() => useSmoothText("already here")); + expect(result.current).toBe("already here"); + }); + + it("reveals appended text gradually instead of all at once", () => { + const { result, rerender } = renderHook( + ({ text }) => useSmoothText(text, 100), + { initialProps: { text: "" } }, + ); + + // A burst of 50 characters arrives at once. + rerender({ text: "x".repeat(50) }); + + // First frame establishes the clock and makes minimal forward progress. + flushFrame(0); + expect(result.current.length).toBe(1); + + // 100ms at 100 chars/sec reveals ~10 more chars, nowhere near all 50. + flushFrame(100); + expect(result.current.length).toBe(11); + expect(result.current.length).toBeLessThan(50); + + // Given enough time it catches up fully. + flushFrame(1000); + expect(result.current).toBe("x".repeat(50)); + }); + + it("snaps when the target is replaced rather than appended", () => { + const { result, rerender } = renderHook( + ({ text }) => useSmoothText(text, 100), + { initialProps: { text: "" } }, + ); + + rerender({ text: "hello world" }); + flushFrame(0); + flushFrame(20); // partway through revealing "hello world" + expect(result.current.length).toBeLessThan("hello world".length); + + // A completely different string is not a prefix -> show it all at once. + rerender({ text: "totally different" }); + expect(result.current).toBe("totally different"); + }); + + it("snaps immediately when reduced motion is preferred", () => { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches: query.includes("reduce"), + })); + + const { result, rerender } = renderHook( + ({ text }) => useSmoothText(text, 100), + { initialProps: { text: "" } }, + ); + + rerender({ text: "x".repeat(50) }); + expect(result.current).toBe("x".repeat(50)); + }); +}); diff --git a/packages/ui/src/primitives/hooks/useSmoothText.ts b/packages/ui/src/primitives/hooks/useSmoothText.ts new file mode 100644 index 0000000000..17ee769c85 --- /dev/null +++ b/packages/ui/src/primitives/hooks/useSmoothText.ts @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; + +// Default reveal rate. Streamed LLM tokens arrive in bursty chunks; revealing +// at a steady character rate makes the text feel like it is being typed rather +// than appearing in jumps. ~120 chars/sec roughly matches a fast token stream +// while staying smooth. See https://upstash.com/blog/smooth-streaming. +const DEFAULT_CHARS_PER_SECOND = 120; + +// Once the displayed text falls this far behind the target we stop easing and +// snap, so a large catch-up (e.g. a long buffered chunk) never feels sluggish. +const MAX_LAG_CHARS = 600; + +/** + * Given the current reveal length, the target length and how much time elapsed + * since the last frame, return the next reveal length. Pure so the easing is + * unit-testable without timers. Never exceeds `target` and never goes backwards + * (callers handle resets when the target is replaced rather than appended). + */ +export function nextRevealLength( + current: number, + target: number, + elapsedMs: number, + charsPerSecond: number, +): number { + if (current >= target) return target; + if (target - current > MAX_LAG_CHARS) return target; + const step = Math.ceil((charsPerSecond * elapsedMs) / 1000); + return Math.min(target, current + Math.max(step, 1)); +} + +function prefersReducedMotion(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +/** + * Smoothly reveals a growing string a few characters per frame instead of + * jumping whenever the source updates. Intended for streamed assistant text: + * tokens are appended to `target` in bursts, and this hook animates the visible + * prefix up to it at a steady rate. + * + * The text the source already had on mount is shown immediately, so re-rendered + * history and completed messages never replay a typewriter effect. Only growth + * that happens while mounted is animated. If the target is replaced (no longer a + * prefix of what we were showing) or motion is reduced, it snaps. + */ +export function useSmoothText( + target: string, + charsPerSecond = DEFAULT_CHARS_PER_SECOND, +): string { + // Start fully revealed: history/completed messages should not animate. + const [revealLength, setRevealLength] = useState(target.length); + const revealRef = useRef(revealLength); + revealRef.current = revealLength; + + // Track the previous target so we can detect replacement vs. append. + const prevTargetRef = useRef(target); + + useEffect(() => { + const prevTarget = prevTargetRef.current; + prevTargetRef.current = target; + + // Replacement (not an extension of what we were revealing) or reduced + // motion: show everything immediately. + if (!target.startsWith(prevTarget) || prefersReducedMotion()) { + setRevealLength(target.length); + return; + } + + if (revealRef.current >= target.length) { + setRevealLength(target.length); + return; + } + + let frame = 0; + let lastTime: number | null = null; + + const tick = (now: number) => { + const elapsed = lastTime === null ? 0 : now - lastTime; + lastTime = now; + const next = nextRevealLength( + revealRef.current, + target.length, + elapsed, + charsPerSecond, + ); + revealRef.current = next; + setRevealLength(next); + if (next < target.length) { + frame = requestAnimationFrame(tick); + } + }; + + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [target, charsPerSecond]); + + return target.slice(0, revealLength); +} From e0b12a1b53f5b456863240b5716c07e052aa81fb Mon Sep 17 00:00:00 2001 From: Ingram Ingram Date: Tue, 16 Jun 2026 14:09:18 -0400 Subject: [PATCH 2/2] test(ui): parameterize nextRevealLength tests with it.each Address Greptile review: collapse the repeated nextRevealLength assertions into a single it.each table, and pull the "caught up past target" case out of the first test into its own row. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../primitives/hooks/useSmoothText.test.ts | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/packages/ui/src/primitives/hooks/useSmoothText.test.ts b/packages/ui/src/primitives/hooks/useSmoothText.test.ts index b872e33bbd..0231c841d6 100644 --- a/packages/ui/src/primitives/hooks/useSmoothText.test.ts +++ b/packages/ui/src/primitives/hooks/useSmoothText.test.ts @@ -6,27 +6,18 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; describe("nextRevealLength", () => { - it("returns the target when already caught up", () => { - expect(nextRevealLength(10, 10, 16, 120)).toBe(10); - expect(nextRevealLength(12, 10, 16, 120)).toBe(10); - }); - - it("advances proportionally to elapsed time and rate", () => { + it.each<[string, number, number, number, number, number]>([ + // label current target elapsedMs rate expected + ["caught up: returns the target", 10, 10, 16, 120, 10], + ["caught up past target: clamps to target", 12, 10, 16, 120, 10], // 120 chars/sec over 100ms = 12 chars. - expect(nextRevealLength(0, 100, 100, 120)).toBe(12); - }); - - it("never overshoots the target", () => { - expect(nextRevealLength(95, 100, 1000, 120)).toBe(100); - }); - - it("always advances at least one char when behind", () => { + ["advances proportionally to elapsed/rate", 0, 100, 100, 120, 12], + ["never overshoots the target", 95, 100, 1000, 120, 100], // Tiny elapsed time would round to zero; keep forward progress. - expect(nextRevealLength(0, 100, 0, 120)).toBe(1); - }); - - it("snaps when the lag is too large to ease pleasantly", () => { - expect(nextRevealLength(0, 5000, 16, 120)).toBe(5000); + ["always advances at least one when behind", 0, 100, 0, 120, 1], + ["snaps when lag is too large to ease", 0, 5000, 16, 120, 5000], + ])("%s", (_label, current, target, elapsedMs, rate, expected) => { + expect(nextRevealLength(current, target, elapsedMs, rate)).toBe(expected); }); });