[eric] ui: stream text reveals at a steady human-typing cadence for every model, zero-dep and zero added ttft (adaptive char pacing decoupled from bursty network chunks)

This commit is contained in:
eric
2026-05-31 23:19:59 -07:00
parent 2ebc395c20
commit 661e6f80e2
2 changed files with 85 additions and 1 deletions
@@ -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<Props> = ({ 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;
@@ -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<number | null>(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);
}