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}}
+
+ );
+}