This commit is contained in:
Zacharias-Brohn
2026-01-14 20:56:59 +01:00
parent cadab7aaef
commit 461ccf4fee
4 changed files with 93 additions and 23 deletions
@@ -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;
}
+13 -1
View File
@@ -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 (
<div className={classes.markdown}>
<Text size="sm" style={{ lineHeight: 1.6 }}>
<StreamingText content={content} isStreaming={isStreaming} />
</Text>
</div>
);
}
return (
<div className={classes.markdown}>
<ReactMarkdown
@@ -75,7 +88,6 @@ export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessag
>
{content}
</ReactMarkdown>
{isStreaming && <span className={classes.streamingCursor} />}
</div>
);
}
+17
View File
@@ -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;
}
}
+63
View File
@@ -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<NodeJS.Timeout | null>(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 (
<span className={classes.streamingText}>
<span>{committedText}</span>
{newText && <span className={classes.fadeIn}>{newText}</span>}
</span>
);
}