🚣‍♀️ feat: Smooth Streaming Text Fade-In (#14757)

* 🚣‍♀️ feat: Smooth Streaming Text Fade-In

Adds a native FlowToken-style fade-in for streamed message text, with no
changes to the streaming data path: a per-block rehype plugin wraps newly
arrived words in one-shot CSS fade spans, using document-order character
offsets so already-visible text never re-animates, even when markdown
re-parsing restructures the tree. Words still inside their animation
window replay identical props so in-flight fades are never cut short.

- New Content/animate.tsx: word splitting (Intl.Segmenter for CJK),
  offset-based new-text classification, rehype plugin factory, and an
  AnimatedText component for plain-text reasoning content
- MarkdownBlocks: per-block plugin instance appended to the cached
  rehype plugins only while animating; block memoization untouched
  (bench asserts identical code-block render counts)
- Animation gated on isSubmitting && isLatestMessage and dropped at
  stream end, so settled messages render without wrapper spans
- Skips code, pre, math/KaTeX, artifacts, citations, and MCP UI subtrees
- Smooth streaming toggle (default on) under Settings → Chat → Messages;
  animation disabled under prefers-reduced-motion
- Extends the streaming bench with a fade variant (+9% render time in
  jsdom, 62/62 code-block renders) and adds unit tests for the plugin,
  classification, and AnimatedText

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

* 🔧 chore: Fix Import Order in Markdown and Settings Registry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

*  test: Cover Kana-Only CJK Word Segmentation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

* 🛡️ fix: Harden Streaming Fade for Resume, Concurrency, and Reduced Motion

Addresses Codex review on the smooth streaming fade:

- Hydrated/resumed content becomes the animation baseline: a renderer whose
  first run already exceeds FADE_HYDRATION_THRESHOLD (reconnected stream,
  conversation switch) no longer re-fades the entire accumulated response
- Classification is now transactional under React 18 concurrency: runs are
  staged during render and published via commit() from a layout effect, so
  abandoned renders (interruption, StrictMode double-render) leave no trace
- prefers-reduced-motion now disables the rehype transform and AnimatedText
  in the render gate, not just the CSS animation, so reduced-motion users
  skip the span-wrapping work entirely

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

* 🌏 fix: Refine Fade Hydration Signal, Spaceless Scripts, Collapsed Reasoning

Second Codex review round on the smooth streaming fade:

- Hydration is now an explicit signal instead of a per-block length guess:
  Markdown captures whether substantial content already exists at the render
  where its animate gate flips on, and passes it to each block's plugin. New
  blocks mounting later in the stream always animate regardless of size, and
  resumed content becomes the baseline regardless of block sizes
- Word segmentation now covers all spaceless scripts (Thai, Lao, Tibetan,
  Myanmar, Khmer) in addition to CJK/Hangul, so continuously streamed text in
  those scripts keeps fading instead of freezing after the first window
- Reasoning text no longer runs the word transform while the thinking panel
  is collapsed; expanding mid-stream starts from a hydrated baseline

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

* 🖱️ feat: Hide Streaming Cursors While Smooth Fade Is Active

The word fade itself signals streaming, so the trailing block cursor
(result-streaming) and the pulsing thinking dot (result-thinking) are
now suppressed whenever the smooth streaming fade is enabled. Both
return when the setting is off or the device prefers reduced motion.
Extracts the shared gate into a useSmoothStreaming hook consumed by
Markdown, Reasoning, TextPart, DisplayMessage, EmptyText, and the
legacy loading fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

*  test: Pin Cursor Assertions to Fade-Off State

The pulsing thinking cursor now only renders while the smooth streaming
fade is off, so the tests asserting it set the toggle off first (at file
level where earlier renders would cache the atom's first read).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

* 💫 feat: Restore Start-of-Run Dot With Fade-Matched Treatment

The trailing streaming cursor stays hidden while the fade is active (the
fade itself signals text arriving), but the pre-first-token dot returns:
nothing else tells the user the run started before any text exists.

Restyled to match the word fade rather than the classic size throb — it
fades in on the same 250ms ease-out curve, then breathes on opacity, so
"run starting" and "text arriving" read as one system. Applied only when
the fade is active; with the setting off or reduced motion preferred the
dot keeps its original pulseSize behavior, so the cursor assertions in
the existing suites hold unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DrUMn8aEKtMY2QRZhpheJB

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Danny Avila 2026-08-12 13:21:52 -04:00 committed by GitHub
parent ee8c0abe2d
commit ae24461146
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 844 additions and 26 deletions

View file

@ -1,9 +1,12 @@
import React, { memo, useMemo } from 'react';
import React, { memo, useMemo, useRef, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { getRemarkPlugins, getRehypePlugins, getMarkdownComponents } from './markdownConfig';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import MarkdownErrorBoundary from './MarkdownErrorBoundary';
import { FADE_HYDRATION_THRESHOLD } from './animate';
import { useMessageContext } from '~/Providers';
import MarkdownBlocks from './MarkdownBlocks';
import { preprocessLaTeX } from '~/utils';
import { preprocessLaTeX, cn } from '~/utils';
import store from '~/store';
type TContentProps = {
@ -12,9 +15,30 @@ type TContentProps = {
};
const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TContentProps) {
const { isSubmitting = false } = useMessageContext();
const smoothStreaming = useSmoothStreaming();
const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing);
const isInitializing = content === '';
const animate = smoothStreaming && isLatestMessage && isSubmitting;
// Hydration signal for the fade: substantial content already present at the
// render where `animate` flips on means resumed/switched-to/follow-up
// content, which becomes the fade baseline instead of re-animating. The flag
// is cleared after that render commits, so blocks mounting later in the same
// stream (new paragraphs, however large) always animate.
const prevAnimateRef = useRef(false);
const hydratedRef = useRef(false);
if (animate && !prevAnimateRef.current) {
hydratedRef.current = content.length > FADE_HYDRATION_THRESHOLD;
}
prevAnimateRef.current = animate;
useEffect(() => {
if (animate) {
hydratedRef.current = false;
}
}, [animate]);
const currentContent = useMemo(() => {
if (isInitializing) {
return '';
@ -26,7 +50,12 @@ const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TCont
return (
<div className="absolute">
<p className="relative">
<span className={isLatestMessage ? 'result-thinking' : ''} />
<span
className={cn(
isLatestMessage && 'result-thinking',
isLatestMessage && smoothStreaming && 'result-thinking-fade',
)}
/>
</p>
</div>
);
@ -39,6 +68,8 @@ const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TCont
remarkPlugins={getRemarkPlugins()}
rehypePlugins={getRehypePlugins()}
components={getMarkdownComponents()}
animate={animate}
hydrated={hydratedRef.current}
/>
</MarkdownErrorBoundary>
);

View file

@ -3,7 +3,7 @@ import { RecoilRoot } from 'recoil';
import ReactMarkdown from 'react-markdown';
import { render } from '@testing-library/react';
import { getRemarkPlugins, getRehypePlugins, getMarkdownComponents } from './markdownConfig';
import { ArtifactProvider, CodeBlockProvider } from '~/Providers';
import { ArtifactProvider, CodeBlockProvider, MessageContext } from '~/Providers';
import CodeBlock from '~/components/Messages/Content/CodeBlock';
import Markdown from './Markdown';
@ -87,6 +87,19 @@ const NewMarkdown = ({ content }: { content: string }) => (
<Markdown content={content} isLatestMessage={true} />
);
const streamingContext = {
messageId: 'bench',
isExpanded: false,
isSubmitting: true,
isLatestMessage: true,
};
const FadeMarkdown = ({ content }: { content: string }) => (
<MessageContext.Provider value={streamingContext}>
<Markdown content={content} isLatestMessage={true} />
</MessageContext.Provider>
);
const measure = (
Component: React.ComponentType<{ content: string }>,
prefixes: string[],
@ -122,19 +135,24 @@ describe('Markdown streaming benchmark (OLD whole-message vs NEW per-block)', ()
// Warm up module/highlight caches so the first measured run isn't skewed.
measure(OldMarkdown, prefixes);
measure(NewMarkdown, prefixes);
measure(FadeMarkdown, prefixes);
const old: Array<{ totalMs: number; codeBlockRenders: number }> = [];
const neu: Array<{ totalMs: number; codeBlockRenders: number }> = [];
const fade: Array<{ totalMs: number; codeBlockRenders: number }> = [];
for (let i = 0; i < iterations; i += 1) {
old.push(measure(OldMarkdown, prefixes));
neu.push(measure(NewMarkdown, prefixes));
fade.push(measure(FadeMarkdown, prefixes));
}
const minMs = (rs: Array<{ totalMs: number }>) => Math.min(...rs.map((r) => r.totalMs));
const oldMs = minMs(old);
const newMs = minMs(neu);
const fadeMs = minMs(fade);
const oldRenders = old[0].codeBlockRenders;
const newRenders = neu[0].codeBlockRenders;
const fadeRenders = fade[0].codeBlockRenders;
console.log(
[
@ -145,17 +163,23 @@ describe('Markdown streaming benchmark (OLD whole-message vs NEW per-block)', ()
`code-block renders over the stream (structural, noise-free):`,
` OLD (whole-message): ${oldRenders}`,
` NEW (per-block) : ${newRenders}`,
` NEW + fade : ${fadeRenders}`,
` reduction : ${(100 * (1 - newRenders / oldRenders)).toFixed(1)}%`,
'',
`total render time (min of ${iterations}, summed Profiler actualDuration; jsdom):`,
` OLD: ${oldMs.toFixed(1)} ms`,
` NEW: ${newMs.toFixed(1)} ms`,
` speedup: ${(oldMs / newMs).toFixed(2)}x`,
` OLD : ${oldMs.toFixed(1)} ms`,
` NEW : ${newMs.toFixed(1)} ms`,
` NEW + fade: ${fadeMs.toFixed(1)} ms (${(100 * (fadeMs / newMs - 1)).toFixed(1)}% vs NEW)`,
` speedup vs OLD: ${(oldMs / newMs).toFixed(2)}x (fade: ${(oldMs / fadeMs).toFixed(2)}x)`,
'=============================================================',
'',
].join('\n'),
);
// The fade plugin must not disturb block memoization: code blocks render
// exactly as often as without it.
expect(fadeRenders).toBe(newRenders);
// Sanity: the per-block renderer must not render code blocks MORE than the
// whole-message renderer. The real win is asserted separately below.
expect(newRenders).toBeLessThanOrEqual(oldRenders);

View file

@ -1,14 +1,17 @@
import React, { memo, useMemo } from 'react';
import React, { memo, useMemo, useLayoutEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import type { PluggableList } from 'unified';
import type { ElementType } from 'react';
import { ArtifactProvider, CodeBlockProvider } from '~/Providers';
import { splitMarkdownIntoBlocks } from './splitMarkdown';
import { createFadePlugin } from './animate';
type SharedProps = {
remarkPlugins: PluggableList;
rehypePlugins: PluggableList;
components: { [nodeType: string]: ElementType };
animate?: boolean;
hydrated?: boolean;
};
type MarkdownBlockProps = SharedProps & {
@ -34,7 +37,28 @@ const MarkdownBlock = memo(
remarkPlugins,
rehypePlugins,
components,
animate = false,
hydrated = false,
}: MarkdownBlockProps) {
// One fade-plugin instance per block: its closure tracks this block's
// character offsets so only newly streamed words animate. When `animate`
// flips off at stream end, the plain plugin array renders the settled
// block without wrapper spans. Classification is staged during render and
// published after React commits, so abandoned renders leave no trace.
// `hydrated` only matters at plugin creation (its first run), so it is
// deliberately absent from the memo comparator and the useMemo deps.
const fade = useMemo(
() => (animate ? createFadePlugin(hydrated) : null),
// eslint-disable-next-line react-hooks/exhaustive-deps
[animate],
);
const blockRehypePlugins = useMemo(
() => (fade == null ? rehypePlugins : [...rehypePlugins, fade.plugin]),
[fade, rehypePlugins],
);
useLayoutEffect(() => {
fade?.commit();
});
return (
<ArtifactProvider baseIndex={artifactBaseIndex}>
<CodeBlockProvider baseIndex={codeBaseIndex} mermaidBaseIndex={mermaidBaseIndex}>
@ -42,7 +66,7 @@ const MarkdownBlock = memo(
/** @ts-ignore */
remarkPlugins={remarkPlugins}
/** @ts-ignore */
rehypePlugins={rehypePlugins}
rehypePlugins={blockRehypePlugins}
components={components}
>
{content}
@ -55,7 +79,8 @@ const MarkdownBlock = memo(
prev.content === next.content &&
prev.codeBaseIndex === next.codeBaseIndex &&
prev.artifactBaseIndex === next.artifactBaseIndex &&
prev.mermaidBaseIndex === next.mermaidBaseIndex,
prev.mermaidBaseIndex === next.mermaidBaseIndex &&
prev.animate === next.animate,
);
MarkdownBlock.displayName = 'MarkdownBlock';
@ -75,6 +100,8 @@ const MarkdownBlocks = memo(function MarkdownBlocks({
remarkPlugins,
rehypePlugins,
components,
animate,
hydrated,
}: MarkdownBlocksProps) {
const blocks = useMemo(() => {
let codeBaseIndex = 0;
@ -106,6 +133,8 @@ const MarkdownBlocks = memo(function MarkdownBlocks({
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
animate={animate}
hydrated={hydrated}
/>
))}
</>

View file

@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil';
import { Alert, DelayedRender } from '@librechat/client';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageContentProps, TDisplayProps } from '~/common';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import Error from '~/components/Messages/Content/Error';
import { useMessageContext } from '~/Providers';
import MarkdownLite from './MarkdownLite';
@ -26,17 +27,21 @@ const parseThinkingContent = (text: string) => {
};
};
const LoadingFallback = () => (
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
<div className="markdown prose dark:prose-invert light w-full break-words">
<div className="absolute">
<p className="submitting relative">
<span className="result-thinking" />
</p>
const LoadingFallback = () => {
const smoothStreaming = useSmoothStreaming();
return (
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
<div className="markdown prose dark:prose-invert light w-full break-words">
<div className="absolute">
<p className="submitting relative">
<span className={cn('result-thinking', smoothStreaming && 'result-thinking-fade')} />
</p>
</div>
</div>
</div>
</div>
);
);
};
const ErrorBox = ({
children,
@ -94,10 +99,13 @@ export const ErrorMessage = ({
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => {
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const smoothStreaming = useSmoothStreaming();
// The word fade itself indicates streaming, so the trailing block cursor
// only shows when the fade is unavailable (setting off or reduced motion).
const showCursorState = useMemo(
() => showCursor === true && isSubmitting,
[showCursor, isSubmitting],
() => showCursor === true && isSubmitting && !(smoothStreaming && !isCreatedByUser),
[showCursor, isSubmitting, smoothStreaming, isCreatedByUser],
);
const content = useMemo(() => {

View file

@ -1,13 +1,17 @@
import { memo } from 'react';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import { cn } from '~/utils';
/** Streaming cursor placeholder — no bottom margin to match Container's structure and prevent CLS */
const EmptyTextPart = memo(() => {
const smoothStreaming = useSmoothStreaming();
return (
<div className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
<div className="markdown prose dark:prose-invert light w-full break-words">
<div className="absolute">
<p className="submitting relative">
<span className="result-thinking" />
<span className={cn('result-thinking', smoothStreaming && 'result-thinking-fade')} />
</p>
</div>
</div>

View file

@ -3,6 +3,7 @@ import { useAtomValue } from 'jotai';
import { ContentTypes } from 'librechat-data-provider';
import type { MouseEvent, FocusEvent } from 'react';
import { ThinkingContent, ThinkingButton, FloatingThinkingBar } from './Thinking';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import { useLocalize, useExpandCollapse } from '~/hooks';
import { showThinkingAtom } from '~/store/showThinking';
import { useMessageContext } from '~/Providers';
@ -39,6 +40,7 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
const contentId = useId();
const localize = useLocalize();
const showThinking = useAtomValue(showThinkingAtom);
const smoothStreaming = useSmoothStreaming();
const [isExpanded, setIsExpanded] = useState(showThinking);
const [isBarVisible, setIsBarVisible] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
@ -118,7 +120,11 @@ const Reasoning = memo(({ reasoning, isLast }: ReasoningProps) => {
style={expandStyle}
>
<div className="relative overflow-hidden" ref={expandRef}>
<ThinkingContent>{reasoningText}</ThinkingContent>
<ThinkingContent
animate={smoothStreaming && effectiveIsSubmitting && isLast && isExpanded}
>
{reasoningText}
</ThinkingContent>
<FloatingThinkingBar
isVisible={isBarVisible && isExpanded}
isExpanded={isExpanded}

View file

@ -1,6 +1,7 @@
import { memo, useMemo, ReactElement } from 'react';
import { useRecoilValue } from 'recoil';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import useSmoothStreaming from '~/hooks/Messages/useSmoothStreaming';
import Markdown from '~/components/Chat/Messages/Content/Markdown';
import { useMessageContext } from '~/Providers';
import { cn } from '~/utils';
@ -20,7 +21,13 @@ type ContentType =
const TextPart = memo(function TextPart({ text, isCreatedByUser, showCursor }: TextPartProps) {
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]);
const smoothStreaming = useSmoothStreaming();
// The word fade itself indicates streaming, so the trailing block cursor
// only shows when the fade is unavailable (setting off or reduced motion).
const showCursorState = useMemo(
() => showCursor && isSubmitting && !(smoothStreaming && !isCreatedByUser),
[showCursor, isSubmitting, smoothStreaming, isCreatedByUser],
);
const content: ContentType = useMemo(() => {
if (!isCreatedByUser) {

View file

@ -6,6 +6,7 @@ import type { FocusEvent, FC } from 'react';
import { useLocalize, useExpandCollapse } from '~/hooks';
import { showThinkingAtom } from '~/store/showThinking';
import { fontSizeAtom } from '~/store/fontSize';
import { AnimatedText } from '../animate';
import { cn } from '~/utils';
/**
@ -14,12 +15,15 @@ import { cn } from '~/utils';
*/
export const ThinkingContent: FC<{
children: React.ReactNode;
}> = memo(({ children }) => {
animate?: boolean;
}> = memo(({ children, animate = false }) => {
const fontSize = useAtomValue(fontSizeAtom);
const content =
animate && typeof children === 'string' ? <AnimatedText text={children} /> : children;
return (
<div className="relative rounded-lg border border-border-light bg-surface-secondary p-3 pb-8 text-text-secondary">
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{children}</p>
<p className={cn('whitespace-pre-wrap leading-[26px]', fontSize)}>{content}</p>
</div>
);
});

View file

@ -0,0 +1,264 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import { render } from '@testing-library/react';
import type { FadePlugin } from '../animate';
import {
splitWords,
beginRun,
endRun,
classifyValue,
createFadeState,
createFadePlugin,
AnimatedText,
FADE_DURATION_MS,
FADE_STAGGER_MS,
FADE_STAGGER_MAX_MS,
FADE_HYDRATION_THRESHOLD,
} from '../animate';
let nowSpy: jest.SpyInstance<number, []>;
let mockedNow = 0;
const setTime = (value: number) => {
mockedNow = value;
};
beforeEach(() => {
mockedNow = 0;
nowSpy = jest.spyOn(performance, 'now').mockImplementation(() => mockedNow);
});
afterEach(() => {
nowSpy.mockRestore();
});
describe('splitWords', () => {
it('splits into word parts with trailing whitespace and round-trips exactly', () => {
const value = ' Hello world,\nthis is streamed ';
const parts = splitWords(value);
expect(parts.join('')).toBe(value);
expect(parts[0]).toBe(' ');
expect(parts[1]).toBe('Hello ');
expect(parts[2]).toBe('world,\n');
});
it('splits CJK runs into smaller segments', () => {
const parts = splitWords('これは日本語のテストです');
expect(parts.join('')).toBe('これは日本語のテストです');
expect(parts.length).toBeGreaterThan(1);
});
it('segments kana-only Japanese text without any Han characters', () => {
const hiragana = splitWords('こんにちはせかい');
expect(hiragana.join('')).toBe('こんにちはせかい');
expect(hiragana.length).toBeGreaterThan(1);
const katakana = splitWords('カタカナテキスト');
expect(katakana.join('')).toBe('カタカナテキスト');
expect(katakana.length).toBeGreaterThan(1);
});
it('segments spaceless Southeast Asian scripts', () => {
const thai = splitWords('สวัสดีครับผมชื่อจอห์นและนี่คือการทดสอบ');
expect(thai.join('')).toBe('สวัสดีครับผมชื่อจอห์นและนี่คือการทดสอบ');
expect(thai.length).toBeGreaterThan(1);
const burmese = splitWords('မင်္ဂလာပါကမ္ဘာကြီး');
expect(burmese.join('')).toBe('မင်္ဂလာပါကမ္ဘာကြီး');
expect(burmese.length).toBeGreaterThan(1);
});
});
describe('classifyValue', () => {
it('animates every word on the first run with capped stagger', () => {
const state = createFadeState();
const run = beginRun(state);
const words = 'a b c d e f g h i j k l m'.split(' ').join(' ');
const segments = classifyValue(run, words).filter((segment) => /\S/.test(segment.value));
endRun(run);
expect(segments.every((segment) => segment.animated)).toBe(true);
expect(segments[0].delay).toBe(0);
expect(segments[1].delay).toBe(FADE_STAGGER_MS);
const maxDelay = Math.max(...segments.map((segment) => segment.delay));
expect(maxDelay).toBe(FADE_STAGGER_MAX_MS);
});
it('does not animate whitespace parts', () => {
const state = createFadeState();
const run = beginRun(state);
const segments = classifyValue(run, ' hello ');
endRun(run);
const whitespace = segments.filter((segment) => !/\S/.test(segment.value));
expect(whitespace.length).toBeGreaterThan(0);
expect(whitespace.every((segment) => !segment.animated)).toBe(true);
});
it('only animates newly appended words on later runs', () => {
const state = createFadeState();
const first = beginRun(state);
classifyValue(first, 'hello world ');
endRun(first);
setTime(FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1);
const second = beginRun(state);
const segments = classifyValue(second, 'hello world and more');
endRun(second);
const byValue = new Map(segments.map((segment) => [segment.value.trim(), segment]));
expect(byValue.get('hello')?.animated).toBe(false);
expect(byValue.get('world')?.animated).toBe(false);
expect(byValue.get('and')?.animated).toBe(true);
expect(byValue.get('more')?.animated).toBe(true);
});
it('replays identical animation props for words still inside their window', () => {
const state = createFadeState();
const first = beginRun(state);
const firstSegments = classifyValue(first, 'hello world');
endRun(first);
const worldDelay = firstSegments.find((s) => s.value === 'world')?.delay;
setTime(FADE_DURATION_MS / 2);
const second = beginRun(state);
const segments = classifyValue(second, 'hello world again');
endRun(second);
const world = segments.find((segment) => segment.value.trim() === 'world');
expect(world?.animated).toBe(true);
expect(world?.delay).toBe(worldDelay);
});
it('keeps animating a word that grows at the stream head', () => {
const state = createFadeState();
const first = beginRun(state);
classifyValue(first, 'hel');
endRun(first);
setTime(FADE_DURATION_MS / 2);
const second = beginRun(state);
const segments = classifyValue(second, 'hello');
endRun(second);
expect(segments[0].animated).toBe(true);
expect(segments[0].delay).toBe(0);
});
});
describe('createFadePlugin', () => {
const renderMarkdown = (fade: FadePlugin, content: string) => (
/** @ts-ignore */
<ReactMarkdown rehypePlugins={[fade.plugin]}>{content}</ReactMarkdown>
);
it('wraps words in fade spans on first render, including inline formatting', () => {
const fade = createFadePlugin();
const { container } = render(renderMarkdown(fade, 'Hello **bold** world'));
const spans = container.querySelectorAll('span[data-lc-fade]');
expect(spans.length).toBe(3);
expect(container.textContent).toBe('Hello bold world');
});
it('does not wrap text inside code blocks or inline code', () => {
const fade = createFadePlugin();
const { container } = render(
renderMarkdown(fade, 'text `inline code` more\n\n```\nconst x = 1;\n```'),
);
expect(container.querySelector('code span[data-lc-fade]')).toBeNull();
expect(container.querySelector('pre span[data-lc-fade]')).toBeNull();
expect(container.querySelectorAll('p span[data-lc-fade]').length).toBe(2);
});
it('only animates appended words across streamed re-renders', () => {
const fade = createFadePlugin();
const { container, rerender } = render(renderMarkdown(fade, 'Hello world'));
fade.commit();
setTime(FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1);
rerender(renderMarkdown(fade, 'Hello world and more text'));
const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) =>
span.textContent?.trim(),
);
expect(animated).toEqual(['and', 'more', 'text']);
const bare = Array.from(container.querySelectorAll('p > span:not([data-lc-fade])')).map(
(span) => span.textContent?.trim(),
);
expect(bare).toEqual(['Hello', 'world']);
});
it('does not re-animate words when markdown restructures around them', () => {
const fade = createFadePlugin();
const { container, rerender } = render(renderMarkdown(fade, 'Result is done and'));
fade.commit();
setTime(FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1);
rerender(renderMarkdown(fade, 'Result is done and **final**'));
const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) =>
span.textContent?.trim(),
);
expect(animated).toEqual(['final']);
});
it('treats the first render of a hydrated plugin as baseline without animating', () => {
const fade = createFadePlugin(true);
const hydrated = `word${' word'.repeat(Math.ceil(FADE_HYDRATION_THRESHOLD / 5) + 4)}`;
const { container, rerender } = render(renderMarkdown(fade, hydrated));
expect(container.querySelectorAll('span[data-lc-fade]').length).toBe(0);
fade.commit();
rerender(renderMarkdown(fade, `${hydrated} appended tail`));
const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) =>
span.textContent?.trim(),
);
expect(animated).toEqual(['appended', 'tail']);
});
it('animates a large first render on a non-hydrated plugin (new block in one chunk)', () => {
const fade = createFadePlugin();
const large = `word${' word'.repeat(Math.ceil(FADE_HYDRATION_THRESHOLD / 5) + 4)}`;
const { container } = render(renderMarkdown(fade, large));
expect(container.querySelectorAll('span[data-lc-fade]').length).toBeGreaterThan(0);
});
it('re-classifies identically when a render is never committed', () => {
const fade = createFadePlugin();
const { container, rerender } = render(renderMarkdown(fade, 'Hello world'));
setTime(FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1);
rerender(renderMarkdown(fade, 'Hello world'));
const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) =>
span.textContent?.trim(),
);
expect(animated).toEqual(['Hello', 'world']);
});
});
describe('AnimatedText', () => {
it('renders new words in fade spans and settles old words to plain text', () => {
const { container, rerender } = render(<AnimatedText text="thinking about" />);
expect(container.querySelectorAll('span[data-lc-fade]').length).toBe(2);
setTime(FADE_DURATION_MS + FADE_STAGGER_MAX_MS + 1);
rerender(<AnimatedText text="thinking about the answer" />);
const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) =>
span.textContent?.trim(),
);
expect(animated).toEqual(['the', 'answer']);
expect(container.textContent).toBe('thinking about the answer');
});
it('treats large hydrated text as baseline without animating', () => {
const hydrated = `word${' word'.repeat(Math.ceil(FADE_HYDRATION_THRESHOLD / 5) + 4)}`;
const { container, rerender } = render(<AnimatedText text={hydrated} />);
expect(container.querySelectorAll('span[data-lc-fade]').length).toBe(0);
rerender(<AnimatedText text={`${hydrated} appended tail`} />);
const animated = Array.from(container.querySelectorAll('span[data-lc-fade]')).map((span) =>
span.textContent?.trim(),
);
expect(animated).toEqual(['appended', 'tail']);
});
});

View file

@ -0,0 +1,349 @@
import { memo, useRef, Fragment, useLayoutEffect } from 'react';
import type { Root, Element, ElementContent } from 'hast';
import type { CSSProperties } from 'react';
import type { Plugin } from 'unified';
/** Must match the animation duration on `[data-lc-fade]` in style.css */
export const FADE_DURATION_MS = 250;
export const FADE_STAGGER_MS = 25;
export const FADE_STAGGER_MAX_MS = 250;
/**
* Text already longer than this when animation starts is hydrated/resumed
* content (reconnected stream, conversation switch, follow-up turn), not a
* fresh delta that content becomes the baseline instead of re-fading. The
* markdown path evaluates this against the whole message when its `animate`
* gate flips on; `AnimatedText` evaluates it against its own first text.
*/
export const FADE_HYDRATION_THRESHOLD = 120;
type FadeEntry = { at: number; delay: number };
type PendingRun = {
prevCount: number;
additions: Map<number, FadeEntry>;
now: number;
};
export type FadeState = {
/** Total characters classified during the last committed run; parts below this offset are not new */
prevCount: number;
/** Parts still mid-animation, keyed by start offset, so re-renders replay identical props */
active: Map<number, FadeEntry>;
/** True until the first run commits; used for the hydration baseline decision */
firstRun: boolean;
/** Staged result of the latest render, published to the fields above on commit */
pending: PendingRun | null;
};
type FadeRun = {
state: FadeState;
now: number;
count: number;
newIndex: number;
/** Baseline mode: classify everything as already seen (hydrated first run) */
suppress: boolean;
additions: Map<number, FadeEntry>;
};
export type FadeSegment = {
start: number;
value: string;
animated: boolean;
delay: number;
};
const WORD_REGEX = /\S+\s*/g;
const NON_WHITESPACE_REGEX = /\S/;
/**
* Scripts written without word-delimiting spaces: Thai, Lao, Myanmar, Khmer,
* Tibetan, CJK ideographs/kana, Hangul, and CJK compatibility ideographs.
* Whitespace splitting yields one ever-growing part for these, which would
* stop fading once its window expires, so they go through Intl.Segmenter.
*/
const SPACELESS_REGEX =
/[\u0E00-\u0EFF\u0F00-\u0FFF\u1000-\u109F\u1780-\u17FF\u2E80-\u9FFF\uAC00-\uD7AF\uF900-\uFAFF]/;
let wordSegmenter: Intl.Segmenter | null | undefined;
function getWordSegmenter(): Intl.Segmenter | null {
if (wordSegmenter === undefined) {
wordSegmenter =
typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
? new Intl.Segmenter(undefined, { granularity: 'word' })
: null;
}
return wordSegmenter;
}
function pushSegmentedParts(parts: string[], token: string): void {
const segmenter = getWordSegmenter();
if (segmenter == null) {
parts.push(token);
return;
}
const trailing = /\s+$/.exec(token);
const word = trailing == null ? token : token.slice(0, trailing.index);
for (const segment of segmenter.segment(word)) {
parts.push(segment.segment);
}
if (trailing != null) {
parts.push(trailing[0]);
}
}
/**
* Splits text into word parts, each a non-whitespace run plus its trailing
* whitespace, with whitespace-only runs kept as separate parts. Scripts
* without word-delimiting spaces (CJK) are further split via Intl.Segmenter.
* Concatenating the result always reproduces the input exactly.
*/
export function splitWords(value: string): string[] {
const parts: string[] = [];
WORD_REGEX.lastIndex = 0;
let index = 0;
let match: RegExpExecArray | null;
while ((match = WORD_REGEX.exec(value)) !== null) {
if (match.index > index) {
parts.push(value.slice(index, match.index));
}
const token = match[0];
if (SPACELESS_REGEX.test(token)) {
pushSegmentedParts(parts, token);
} else {
parts.push(token);
}
index = match.index + token.length;
}
if (index < value.length) {
parts.push(value.slice(index));
}
return parts;
}
export function createFadeState(): FadeState {
return { prevCount: 0, active: new Map(), firstRun: true, pending: null };
}
export function beginRun(state: FadeState, suppress = false): FadeRun {
return {
state,
now: performance.now(),
count: 0,
newIndex: 0,
suppress,
additions: new Map(),
};
}
/**
* Stages the run's result on the state without publishing it. Classification
* never mutates committed state during render, so a render that React
* abandons (concurrent interruption, StrictMode double-render) leaves no
* trace; only {@link commitRun} called after the render commits publishes.
*/
export function stageRun(run: FadeRun): void {
run.state.pending = { prevCount: run.count, additions: run.additions, now: run.now };
}
/** Publishes the staged run: baseline offset, new animations, pruned entries. */
export function commitRun(state: FadeState): void {
const pending = state.pending;
if (pending == null) {
return;
}
state.pending = null;
state.firstRun = false;
state.prevCount = pending.prevCount;
for (const [start, entry] of pending.additions) {
state.active.set(start, entry);
}
for (const [start, entry] of state.active) {
if (pending.now - entry.at >= entry.delay + FADE_DURATION_MS) {
state.active.delete(start);
}
}
}
/** Stages and immediately commits — for callers without a commit phase. */
export function endRun(run: FadeRun): void {
stageRun(run);
commitRun(run.state);
}
/**
* Classifies one text value into fade segments, advancing the run's
* document-order character offset. A part is animated when it starts past the
* last committed run's total offset (newly streamed) or when it is still
* within its animation window from an earlier run in which case it replays
* identical animation props so React leaves the in-flight CSS animation
* untouched. Committed state is only read here; new animations are recorded
* on the run and published by {@link commitRun}.
*/
export function classifyValue(run: FadeRun, value: string): FadeSegment[] {
const { state, now } = run;
const segments: FadeSegment[] = [];
for (const part of splitWords(value)) {
const start = run.count;
run.count += part.length;
if (run.suppress || !NON_WHITESPACE_REGEX.test(part)) {
segments.push({ start, value: part, animated: false, delay: 0 });
continue;
}
if (start >= state.prevCount) {
const staged = run.additions.get(start);
const delay = staged?.delay ?? Math.min(run.newIndex * FADE_STAGGER_MS, FADE_STAGGER_MAX_MS);
run.newIndex += 1;
run.additions.set(start, staged ?? { at: now, delay });
segments.push({ start, value: part, animated: true, delay });
continue;
}
const entry = state.active.get(start);
if (entry != null && now - entry.at < entry.delay + FADE_DURATION_MS) {
segments.push({ start, value: part, animated: true, delay: entry.delay });
continue;
}
segments.push({ start, value: part, animated: false, delay: 0 });
}
return segments;
}
const SKIP_TAGS = new Set([
'code',
'pre',
'svg',
'math',
'annotation',
'script',
'style',
'artifact',
'citation',
'composite-citation',
'highlighted-text',
'mcp-ui-resource',
'mcp-ui-carousel',
]);
function isSkippedElement(node: Element): boolean {
if (SKIP_TAGS.has(node.tagName)) {
return true;
}
const className = node.properties?.className;
if (Array.isArray(className)) {
return className.some((name) => typeof name === 'string' && name.startsWith('katex'));
}
return typeof className === 'string' && className.startsWith('katex');
}
function toContent(segment: FadeSegment): ElementContent {
if (!NON_WHITESPACE_REGEX.test(segment.value)) {
return { type: 'text', value: segment.value };
}
const properties: Element['properties'] = {};
if (segment.animated) {
properties.dataLcFade = '';
if (segment.delay > 0) {
properties.style = `--lc-delay:${segment.delay}ms`;
}
}
return {
type: 'element',
tagName: 'span',
properties,
children: [{ type: 'text', value: segment.value }],
};
}
function transformElement(run: FadeRun, element: Element): void {
const next: ElementContent[] = [];
for (const child of element.children) {
if (child.type === 'text') {
for (const segment of classifyValue(run, child.value)) {
next.push(toContent(segment));
}
continue;
}
if (child.type === 'element' && !isSkippedElement(child)) {
transformElement(run, child);
}
next.push(child);
}
element.children = next;
}
export type FadePlugin = {
plugin: Plugin<[], Root>;
/** Publish the latest render's staged classification; call after React commits. */
commit: () => void;
};
/**
* Creates a per-renderer rehype plugin that wraps newly streamed words in
* one-shot CSS fade spans (`[data-lc-fade]`). New-text detection uses
* document-order character offsets held in the factory closure, so text that
* was already visible in a previous render mounts as a bare span and never
* re-animates, even when markdown re-parsing restructures the tree. Pass
* `hydrated: true` for renderers created while previously accumulated content
* is already showing (resumed stream, conversation switch, follow-up turn):
* their first run becomes the baseline without animating. Classification is
* staged during render and must be published via `commit()` after React
* commits (a layout effect), so abandoned renders leave no trace. Create one
* instance per streaming renderer and drop it (plain plugin array) once the
* stream ends so the settled message renders without wrapper spans.
*/
export function createFadePlugin(hydrated = false): FadePlugin {
const state = createFadeState();
const plugin: Plugin<[], Root> = function rehypeFade() {
return (tree: Root) => {
const run = beginRun(state, hydrated && state.firstRun);
for (const child of tree.children) {
if (child.type === 'element' && !isSkippedElement(child)) {
transformElement(run, child);
}
}
stageRun(run);
};
};
return { plugin, commit: () => commitRun(state) };
}
const DELAY_VAR = '--lc-delay';
/**
* Plain-text counterpart of the rehype plugin for non-markdown streamed text
* (reasoning). Renders words in fade spans keyed by character offset; only
* render this while the text is actively streaming and render the raw string
* once settled.
*/
export const AnimatedText = memo(function AnimatedText({ text }: { text: string }) {
const stateRef = useRef<FadeState | null>(null);
if (stateRef.current == null) {
stateRef.current = createFadeState();
}
const state = stateRef.current;
const suppress = state.firstRun && text.length > FADE_HYDRATION_THRESHOLD;
const run = beginRun(state, suppress);
const segments = classifyValue(run, text);
stageRun(run);
useLayoutEffect(() => {
commitRun(state);
});
return (
<>
{segments.map((segment) => {
if (!segment.animated) {
return <Fragment key={segment.start}>{segment.value}</Fragment>;
}
const style =
segment.delay > 0 ? ({ [DELAY_VAR]: `${segment.delay}ms` } as CSSProperties) : undefined;
return (
<span key={segment.start} data-lc-fade="" style={style}>
{segment.value}
</span>
);
})}
</>
);
});
AnimatedText.displayName = 'AnimatedText';

View file

@ -30,6 +30,7 @@ import DeleteAccount from '../SettingsTabs/Account/DeleteAccount';
import { ForkSettings } from '../SettingsTabs/Chat/ForkSettings';
import ChatDirection from '../SettingsTabs/Chat/ChatDirection';
import { DeleteCache } from '../SettingsTabs/Data/DeleteCache';
import { smoothStreamingAtom } from '~/store/smoothStreaming';
import { RevokeKeys } from '../SettingsTabs/Data/RevokeKeys';
import { ClearChats } from '../SettingsTabs/Data/ClearChats';
import { TokenCredits, AutoRefill } from './BillingControls';
@ -275,6 +276,19 @@ export const registry: SettingEntry[] = [
switchId: 'showThinking',
}),
},
{
id: 'smoothStreaming',
tab: CHAT,
section: 'messages',
labelKey: 'com_nav_smooth_streaming',
keywords: ['smooth', 'streaming', 'fade', 'animation', 'animate'],
Component: toggleControl({
stateAtom: smoothStreamingAtom,
localizationKey: 'com_nav_smooth_streaming',
switchId: 'smoothStreaming',
hoverCardText: 'com_nav_info_smooth_streaming',
}),
},
{
id: 'autoExpandTools',
tab: CHAT,

View file

@ -19,3 +19,4 @@ export { default as useMessageHelpers } from './useMessageHelpers';
export { default as useCopyToClipboard } from './useCopyToClipboard';
export { default as useContentMetadata } from './useContentMetadata';
export { default as useMessageScrolling } from './useMessageScrolling';
export { default as useSmoothStreaming } from './useSmoothStreaming';

View file

@ -0,0 +1,16 @@
import { useAtomValue } from 'jotai';
import { useMediaQuery } from '@librechat/client';
import { smoothStreamingAtom } from '~/store/smoothStreaming';
/**
* Whether the smooth streaming fade is enabled for this client: the user
* setting is on and the device does not prefer reduced motion. Streaming
* renderers combine this with per-message state (latest message, submitting)
* to decide whether to animate; the trailing block cursor is hidden while the
* fade is enabled since the fade itself indicates streaming.
*/
export default function useSmoothStreaming(): boolean {
const smoothStreaming = useAtomValue(smoothStreamingAtom);
const reducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
return smoothStreaming && !reducedMotion;
}

View file

@ -533,6 +533,7 @@
"com_nav_info_save_badges_state": "When enabled, the state of the chat badges will be saved. This means that if you create a new chat, the badges will remain in the same state as the previous chat. If you disable this option, the badges will reset to their default state every time you create a new chat",
"com_nav_info_save_draft": "When enabled, the text and attachments you enter in the chat form will be automatically saved locally as drafts. These drafts will be available even if you reload the page or switch to a different conversation. Drafts are stored locally on your device and are deleted once the message is sent.",
"com_nav_info_show_thinking": "When enabled, the chat will display the thinking dropdowns open by default, allowing you to view the AI's reasoning in real-time. When disabled, the thinking dropdowns will remain closed by default for a cleaner and more streamlined interface",
"com_nav_info_smooth_streaming": "When enabled, newly streamed words fade in smoothly for the latest response. This is purely visual — it does not delay token delivery — and is disabled automatically when your device prefers reduced motion.",
"com_nav_info_stateful_sessions": "When enabled, this agent's code executions reuse one persistent sandbox workspace per conversation: files, installed packages, and working state usually carry over between runs. The workspace may occasionally reset, so anything important should be saved under /mnt/data. Requires Code Interpreter and the app-level stateful sessions capability.",
"com_nav_info_user_name_display": "When enabled, the username of the sender will be shown above each message you send. When disabled, you will only see \"You\" above your messages.",
"com_nav_keep_screen_awake": "Keep screen awake during response generation",
@ -624,6 +625,7 @@
"com_nav_show_thinking": "Open Thinking Dropdowns by Default",
"com_nav_slash_command": "/-Command",
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
"com_nav_smooth_streaming": "Smooth streaming text (fade in new words)",
"com_nav_speech_to_text": "Speech to Text",
"com_nav_stop_generating": "Stop generating",
"com_nav_text_to_speech": "Text to Speech",

View file

@ -0,0 +1,13 @@
import { createStorageAtom } from './jotai-utils';
const DEFAULT_SMOOTH_STREAMING = true;
/**
* Controls whether newly streamed message text fades in smoothly. Purely
* visual: token delivery and state updates are unaffected, and the CSS
* animation is disabled under `prefers-reduced-motion`.
*/
export const smoothStreamingAtom = createStorageAtom<boolean>(
'smoothStreaming',
DEFAULT_SMOOTH_STREAMING,
);

View file

@ -1908,6 +1908,52 @@ html {
display: none !important;
}
/* Smooth streaming: one-shot fade-in on newly streamed words.
Duration must match FADE_DURATION_MS in Chat/Messages/Content/animate.tsx */
@keyframes lc-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
[data-lc-fade] {
animation: lc-fade-in 250ms ease-out both;
animation-delay: var(--lc-delay, 0ms);
}
@media (prefers-reduced-motion: reduce) {
[data-lc-fade] {
animation: none;
}
}
/* Pre-first-token dot, restyled to match the word fade: it fades in on the
same curve rather than popping, then breathes on opacity instead of the
classic size throb, so "run starting" and "text arriving" read as one
system. Only applied while the fade is active; without it the dot keeps
its original pulseSize behavior. */
@keyframes lc-dot-breathe {
0%,
100% {
opacity: 0.35;
transform: translateZ(0) scale(0.85);
}
50% {
opacity: 1;
transform: translateZ(0) scale(1);
}
}
.submitting .result-thinking.result-thinking-fade:empty:last-child:after {
animation:
lc-fade-in 250ms ease-out both,
lc-dot-breathe 1.6s ease-in-out 250ms infinite;
will-change: opacity, transform;
}
.webkit-dark-styles,
.webkit-dark-styles:focus {
background-clip: content-box;