diff --git a/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx b/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx index f4b4c0c5..fe6659b1 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx +++ b/frontend/src/app/pages/AgentChat/bubbles/StreamingBubble.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react'; import { useStreamingMessage } from '@/shared/state/streamingSlice'; import MessageBubble from './MessageBubble'; import ToolCallBubble from '../tool-bubbles/ToolCallBubble'; +import { useSmoothText } from './useSmoothText'; interface Props { sessionId: string; @@ -13,7 +14,15 @@ interface Props { /** Leaf subscriber for one session's streaming entry; isolates re-renders so AgentChat doesn't churn per character. */ const StreamingBubble: React.FC = ({ sessionId, activeBranchId, turnLabel, onStreamGrew }) => { const streamingMessage = useStreamingMessage(sessionId); - const typedContent = streamingMessage?.content ?? ''; + const rawContent = streamingMessage?.content ?? ''; + // Smooth-reveal the assistant's generated text at a steady cadence so it reads + // like typing instead of bursty network chunks. Provider-agnostic by design: + // every model (Anthropic/OpenAI/Gemini/OpenRouter/custom) funnels through this + // same streaming slice, so smoothing here covers all of them at once. Tool-call + // input is left raw (it's args, not prose). Zero added TTFT (see useSmoothText). + const isTextRole = streamingMessage?.role !== 'tool_call'; + const smoothContent = useSmoothText(rawContent, isTextRole); + const typedContent = isTextRole ? smoothContent : rawContent; // RAF-coalesce so onStreamGrew fires once per frame regardless of token rate. const onGrewRef = useRef(onStreamGrew); onGrewRef.current = onStreamGrew; diff --git a/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts new file mode 100644 index 00000000..b3d603bd --- /dev/null +++ b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Smoothly reveals streamed text at a steady cadence instead of painting bursty + * network chunks as they land. Decouples DISPLAY rate from ARRIVAL rate the way + * claude.ai does, so generated text reads like it's being typed rather than + * dumped in clumps. + * + * Zero dependencies. Zero added TTFT: the first characters reveal on the very next + * animation frame after the first delta (same frame budget as painting it directly). + * The reveal rate is ADAPTIVE — it accelerates as the backlog grows, so display + * never falls meaningfully behind the model and never reads as laggy. The rAF loop + * runs ONLY while there's a backlog to drain and parks itself at zero cost once + * caught up, so it adds no idle-frame churn. + */ + +/** Pure pacing step (exported for testing): chars to reveal this frame. */ +export function smoothStep(shown: number, full: number): number { + if (shown >= full) return full; + const backlog = full - shown; + // Floor of 3 chars/frame (~180 chars/sec at 60fps) for a calm typing feel, + // and drain ~1/4 of any backlog on top of that so bursts catch up fast. The + // /4 keeps mid-stream lag small (a few words at most), so when the live bubble + // hands off to the final message at stream end there's no visible jump. Never + // overshoots `full`. + const step = Math.max(3, Math.ceil(backlog / 4)); + return Math.min(full, shown + step); +} + +export function useSmoothText(target: string, enabled: boolean): string { + const [shownLen, setShownLen] = useState(enabled ? 0 : target.length); + const rafRef = useRef(null); + const targetRef = useRef(target); + targetRef.current = target; + + useEffect(() => { + // Disabled (historical message, or smoothing turned off): show all, stop loop. + if (!enabled) { + if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } + setShownLen(targetRef.current.length); + return; + } + const tick = () => { + rafRef.current = null; + setShownLen((cur) => { + const next = smoothStep(cur, targetRef.current.length); + if (next < targetRef.current.length) rafRef.current = requestAnimationFrame(tick); + return next; + }); + }; + // Start a drain only if we're behind and no loop is already running. + if (rafRef.current == null && shownLen < target.length) { + rafRef.current = requestAnimationFrame(tick); + } + return () => { + if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } + }; + }, [enabled, target.length, shownLen]); + + // Target shrank (new turn / reset / branch switch): re-sync so we don't slice + // past the end of a shorter string. + useEffect(() => { + if (shownLen > target.length) setShownLen(enabled ? 0 : target.length); + }, [target.length, shownLen, enabled]); + + // ZERO added TTFT: on the very first frame content exists (shownLen still 0), + // reveal the floor immediately in-render instead of waiting a frame for the rAF + // tick. Pure derivation, no extra render — so first visible text lands on the + // exact same frame it would have without smoothing. State catches up next frame. + if (!enabled) return target; + const effectiveShown = (shownLen === 0 && target.length > 0) + ? Math.min(3, target.length) + : shownLen; + return target.slice(0, effectiveShown); +}