diff --git a/components/Chat/MarkdownMessage.module.css b/components/Chat/MarkdownMessage.module.css index 7286410..b642127 100644 --- a/components/Chat/MarkdownMessage.module.css +++ b/components/Chat/MarkdownMessage.module.css @@ -7,28 +7,6 @@ margin-bottom: 0; } -/* Streaming cursor that blinks at the end */ -.streamingCursor { - display: inline-block; - width: 0.5em; - height: 1em; - background-color: currentColor; - margin-left: 2px; - animation: blink 1s step-end infinite; - vertical-align: text-bottom; - opacity: 0.7; -} - -@keyframes blink { - 0%, - 100% { - opacity: 0.7; - } - 50% { - opacity: 0; - } -} - .preWrapper { margin: 0.5em 0; } diff --git a/components/Chat/MarkdownMessage.tsx b/components/Chat/MarkdownMessage.tsx index 6a563e8..7a86f5b 100644 --- a/components/Chat/MarkdownMessage.tsx +++ b/components/Chat/MarkdownMessage.tsx @@ -1,5 +1,6 @@ import ReactMarkdown from 'react-markdown'; import { Anchor, Blockquote, Code, List, Text, Title } from '@mantine/core'; +import { StreamingText } from './StreamingText'; import classes from './MarkdownMessage.module.css'; interface MarkdownMessageProps { @@ -8,6 +9,18 @@ interface MarkdownMessageProps { } export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) { + // While streaming, render plain text with fade-in animation + // Once done, render full markdown + if (isStreaming) { + return ( +
+ + + +
+ ); + } + return (
{content} - {isStreaming && }
); } diff --git a/components/Chat/StreamingText.module.css b/components/Chat/StreamingText.module.css new file mode 100644 index 0000000..b43b77c --- /dev/null +++ b/components/Chat/StreamingText.module.css @@ -0,0 +1,17 @@ +.streamingText { + white-space: pre-wrap; + word-wrap: break-word; +} + +.fadeIn { + animation: textFadeIn 0.3s ease-out forwards; +} + +@keyframes textFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/components/Chat/StreamingText.tsx b/components/Chat/StreamingText.tsx new file mode 100644 index 0000000..2efa4a5 --- /dev/null +++ b/components/Chat/StreamingText.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import classes from './StreamingText.module.css'; + +interface StreamingTextProps { + content: string; + isStreaming: boolean; +} + +/** + * Component that renders text with a smooth fade-in animation for new chunks. + * Splits content into "committed" (already visible) and "new" (fading in) portions. + */ +export function StreamingText({ content, isStreaming }: StreamingTextProps) { + const [committedLength, setCommittedLength] = useState(0); + const animationTimeoutRef = useRef(null); + + // When content grows, schedule the new content to become "committed" after animation + useEffect(() => { + if (content.length > committedLength) { + // Clear any pending timeout + if (animationTimeoutRef.current) { + clearTimeout(animationTimeoutRef.current); + } + + // After the fade-in animation completes, commit the new content + animationTimeoutRef.current = setTimeout(() => { + setCommittedLength(content.length); + }, 300); // Match CSS animation duration + } + + return () => { + if (animationTimeoutRef.current) { + clearTimeout(animationTimeoutRef.current); + } + }; + }, [content, committedLength]); + + // When streaming stops, immediately commit all content + useEffect(() => { + if (!isStreaming) { + setCommittedLength(content.length); + } + }, [isStreaming, content.length]); + + // Reset when content is cleared (new message) + useEffect(() => { + if (content.length === 0) { + setCommittedLength(0); + } + }, [content]); + + const committedText = content.slice(0, committedLength); + const newText = content.slice(committedLength); + + return ( + + {committedText} + {newText && {newText}} + + ); +}