This commit is contained in:
Zacharias-Brohn
2026-01-14 20:58:57 +01:00
parent 461ccf4fee
commit ac2f9d6fcd
+36 -36
View File
@@ -8,56 +8,56 @@ interface StreamingTextProps {
isStreaming: boolean; isStreaming: boolean;
} }
interface TextChunk {
id: number;
text: string;
}
/** /**
* Component that renders text with a smooth fade-in animation for new chunks. * Component that renders text with a smooth fade-in animation for new chunks.
* Splits content into "committed" (already visible) and "new" (fading in) portions. * Each chunk gets its own span element with a fade-in animation.
*/ */
export function StreamingText({ content, isStreaming }: StreamingTextProps) { export function StreamingText({ content, isStreaming }: StreamingTextProps) {
const [committedLength, setCommittedLength] = useState(0); const [chunks, setChunks] = useState<TextChunk[]>([]);
const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null); const prevLengthRef = useRef(0);
const chunkIdRef = useRef(0);
// When content grows, schedule the new content to become "committed" after animation
useEffect(() => { useEffect(() => {
if (content.length > committedLength) { const prevLength = prevLengthRef.current;
// Clear any pending timeout const currentLength = content.length;
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}
// After the fade-in animation completes, commit the new content if (currentLength > prevLength) {
animationTimeoutRef.current = setTimeout(() => { // New content arrived - create a new chunk for it
setCommittedLength(content.length); const newText = content.slice(prevLength);
}, 300); // Match CSS animation duration const newChunk: TextChunk = {
} id: chunkIdRef.current++,
text: newText,
return () => {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current);
}
}; };
}, [content, committedLength]); setChunks((prev) => [...prev, newChunk]);
prevLengthRef.current = currentLength;
// When streaming stops, immediately commit all content } else if (currentLength < prevLength) {
useEffect(() => { // Content was reset (new message)
if (!isStreaming) { setChunks([]);
setCommittedLength(content.length); prevLengthRef.current = 0;
} chunkIdRef.current = 0;
}, [isStreaming, content.length]);
// Reset when content is cleared (new message)
useEffect(() => {
if (content.length === 0) {
setCommittedLength(0);
} }
}, [content]); }, [content]);
const committedText = content.slice(0, committedLength); // When streaming stops, consolidate all chunks into one (removes animations)
const newText = content.slice(committedLength); useEffect(() => {
if (!isStreaming && content.length > 0) {
setChunks([{ id: 0, text: content }]);
prevLengthRef.current = content.length;
}
}, [isStreaming, content]);
return ( return (
<span className={classes.streamingText}> <span className={classes.streamingText}>
<span>{committedText}</span> {chunks.map((chunk) => (
{newText && <span className={classes.fadeIn}>{newText}</span>} <span key={chunk.id} className={isStreaming ? classes.fadeIn : undefined}>
{chunk.text}
</span>
))}
</span> </span>
); );
} }