From a72f66d18887ed166f921f52c7c10baacded9b6f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Jul 2025 16:09:22 +0000 Subject: [PATCH] Enhance markdown streaming with word-by-word animation and improved content handling Co-authored-by: natemkelley --- .../AppMarkdown/AppMarkdown.stories.tsx | 153 +++++++----- .../AppMarkdown/AppMarkdownCommon.tsx | 232 ++++++++++++++++-- 2 files changed, 305 insertions(+), 80 deletions(-) diff --git a/apps/web/src/components/ui/typography/AppMarkdown/AppMarkdown.stories.tsx b/apps/web/src/components/ui/typography/AppMarkdown/AppMarkdown.stories.tsx index ead4da9cd..101395297 100644 --- a/apps/web/src/components/ui/typography/AppMarkdown/AppMarkdown.stories.tsx +++ b/apps/web/src/components/ui/typography/AppMarkdown/AppMarkdown.stories.tsx @@ -368,97 +368,138 @@ The user wants to save the existing "Monthly Sales by Sales Rep" chart into a da // Streaming content simulation const StreamingMarkdown = () => { - const [streamedContent, setStreamedContent] = useState('# Streaming Markdown Demo\n\n'); + const [streamedContent, setStreamedContent] = useState(''); const [isStreaming, setIsStreaming] = useState(false); - const fullContent = `# Streaming Markdown Demo + const fullContent = `# Streaming Word-by-Word Demo -## Introduction +This is a **comprehensive test** of the streaming animation system. Each word should fade in smoothly as it appears, creating a natural reading experience. -This demonstrates how markdown content can be streamed and animated as it appears. Each new word, sentence, or paragraph will fade in smoothly as it's added to the content. +## Paragraph Streaming -## Streaming Features +Here we test how words get added to existing paragraphs. This sentence will demonstrate **bold text** and *italic text* being streamed word by word. The animation should be smooth and natural. -**Real-time updates**: Content appears progressively as it's streamed from a server or API. +## Mixed Content Test -**Smooth animations**: Each new piece of content fades in with appropriate timing. +### Subheading Animation -**Paragraph continuity**: When words are added to existing paragraphs, they animate in seamlessly. +This section tests streaming with **multiple formatting** types: -### Code Examples +- First list item with **bold content** +- Second item with *italic styling* +- Third item with ~~strikethrough~~ text +- Fourth item with \`inline code\` blocks -Streaming can include complex formatting like: +### Complex Paragraph -\`\`\`javascript -function streamContent(data) { - // Process streaming data - const chunks = data.split(' '); - chunks.forEach((chunk, index) => { - setTimeout(() => { - appendToStream(chunk); - }, index * 100); - }); -} -\`\`\` +This paragraph contains **bold text**, *italic text*, ~~strikethrough~~, and \`inline code\`. Each word should animate in individually while preserving the markdown formatting. The streaming should feel natural and not overwhelming. -### Lists and Other Elements +## Blockquote Test -- First item appears -- Second item fades in -- Third item follows smoothly -- Complex **formatting** works too -- Including *italic* text -- And ~~strikethrough~~ text +> This blockquote will stream in word by word, testing how the animation works with different markdown elements. Each word should have its own fade-in animation. -### Tables Stream Too +## Final Notes -| Column 1 | Column 2 | Column 3 | -|----------|----------|----------| -| Row 1 | Data | Values | -| Row 2 | More | Content | -| Row 3 | Final | Row | - -> Even blockquotes can be streamed progressively, maintaining proper formatting and animation timing throughout the entire process. - -## Conclusion - -This streaming approach provides a natural, engaging way to display content as it's generated or received, perfect for AI responses, live updates, or progressive content loading.`; +The streaming should work seamlessly across all markdown elements, providing a smooth and engaging user experience for real-time content updates.`; const startStreaming = () => { setIsStreaming(true); - setStreamedContent('# Streaming Markdown Demo\n\n'); + setStreamedContent(''); - const words = fullContent.split(' '); - let currentContent = '# Streaming Markdown Demo\n\n'; + // Split content into words while preserving markdown structure + const words = fullContent.split(/(\s+)/); // Split but keep whitespace + let currentIndex = 0; - words.slice(4).forEach((word, index) => { // Skip the first few words that are already there - setTimeout(() => { - currentContent += word + ' '; - setStreamedContent(currentContent); - - if (index === words.length - 5) { // Last word - setIsStreaming(false); - } - }, index * 80); // 80ms delay between words - }); + const streamNextWord = () => { + if (currentIndex < words.length) { + setStreamedContent((prev: string) => prev + words[currentIndex]); + currentIndex++; + + // Variable timing - faster for whitespace, slower for words + const isWhitespace = words[currentIndex - 1]?.trim() === ''; + const delay = isWhitespace ? 10 : 120; // 10ms for spaces, 120ms for words + + setTimeout(streamNextWord, delay); + } else { + setIsStreaming(false); + } + }; + + streamNextWord(); + }; + + const startFastStreaming = () => { + setIsStreaming(true); + setStreamedContent(''); + + const words = fullContent.split(/(\s+)/); + let currentIndex = 0; + + const streamNextWord = () => { + if (currentIndex < words.length) { + setStreamedContent((prev: string) => prev + words[currentIndex]); + currentIndex++; + + const isWhitespace = words[currentIndex - 1]?.trim() === ''; + const delay = isWhitespace ? 5 : 50; // Faster streaming + + setTimeout(streamNextWord, delay); + } else { + setIsStreaming(false); + } + }; + + streamNextWord(); + }; + + const startParagraphStreaming = () => { + setIsStreaming(true); + setStreamedContent('# Paragraph Test\n\nThis is the beginning of a sentence'); + + const additionalWords = [ + ' that', ' will', ' continue', ' to', ' grow', ' word', ' by', ' word', + ' with', ' **bold**', ' and', ' *italic*', ' formatting', ' included.', + ' Each', ' new', ' word', ' should', ' animate', ' in', ' smoothly.' + ]; + + additionalWords.forEach((word, index) => { + setTimeout(() => { + setStreamedContent((prev: string) => prev + word); + if (index === additionalWords.length - 1) { + setIsStreaming(false); + } + }, index * 150); + }); }; const resetContent = () => { - setStreamedContent('# Streaming Markdown Demo\n\n'); + setStreamedContent(''); setIsStreaming(false); }; return (
-
+
+ +
+
+

Full Demo: Complete streaming test with all markdown elements

+

Fast Stream: Same content but faster timing

+

Paragraph Test: Tests word-by-word addition to existing paragraphs

+
+
{ + const [renderedContent, setRenderedContent] = useState(newContent); + const previousContentRef = useRef(''); + const animationKeyRef = useRef(0); + + useEffect(() => { + if (!isStreaming || !showLoader) { + setRenderedContent(newContent); + previousContentRef.current = newContent; + return; + } + + const prevContent = previousContentRef.current; + + // If new content is longer than previous, we have new content to animate + if (newContent.length > prevContent.length && newContent.startsWith(prevContent)) { + const newPart = newContent.slice(prevContent.length); + const existingPart = prevContent; + + // Split new content into words for individual animation + const newWords = newPart.split(/(\s+)/); // Split but keep whitespace + + const animatedNewContent = newWords.map((word, index) => { + if (word.trim() === '') return word; // Return whitespace as-is + + animationKeyRef.current += 1; + return ( + + {word} + + ); + }); + + setRenderedContent( + <> + {existingPart} + {animatedNewContent} + + ); + } else { + // If content changed in other ways, just render it normally + setRenderedContent(newContent); + } + + previousContentRef.current = newContent; + }, [newContent, isStreaming, showLoader]); + + return renderedContent; +}; + export interface ExtraPropsExtra extends ExtraProps { numberOfLineMarkdown: number; isStreaming?: boolean; @@ -42,24 +101,27 @@ export const CustomParagraph: React.FC< isStreaming?: boolean; } & ExtraPropsExtra > = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => { + // Convert children to string for diffing + const textContent = typeof children === 'string' ? children : + Array.isArray(children) ? children.join('') : + children?.toString() || ''; - if (Array.isArray(children)) { - return ( -

- {children} -

- ); - } + const streamedContent = useStreamingContent(textContent, isStreaming, showLoader); - //weird bug where all web components are rendered as p - //web components are objects - if (typeof children === 'object') { + // For non-text children (like other React elements), pass through normally + if (typeof children === 'object' && !Array.isArray(children) && children !== null) { return <>{children}; } + const getBaseAnimationClass = () => { + if (!showLoader) return ''; + // Only apply base animation if not streaming (to avoid double animation) + return !isStreaming ? 'fade-in duration-700' : ''; + }; + return ( -

- {children} +

+ {isStreaming && showLoader ? streamedContent : children}

); }; @@ -91,14 +153,26 @@ export const CustomHeading: React.FC< > = ({ level, children, markdown, stripFormatting = false, showLoader, isStreaming = false, ...rest }) => { const HeadingTag = `h${level}` as keyof JSX.IntrinsicElements; + // Convert children to string for diffing + const textContent = typeof children === 'string' ? children : + Array.isArray(children) ? children.join('') : + children?.toString() || ''; + + const streamedContent = useStreamingContent(textContent, isStreaming, showLoader); + + const getBaseAnimationClass = () => { + if (!showLoader) return ''; + return !isStreaming ? 'fade-in duration-700' : ''; + }; + return ( - {children} + {isStreaming && showLoader ? streamedContent : children} ); }; @@ -149,9 +223,22 @@ export const CustomListItem: React.FC< isStreaming?: boolean; } & ExtraPropsExtra > = ({ children, showLoader, isStreaming = false }) => { + // Convert children to string for diffing if it's simple text + const textContent = typeof children === 'string' ? children : + (Array.isArray(children) && children.every(child => typeof child === 'string')) ? + children.join('') : null; + + const streamedContent = textContent ? + useStreamingContent(textContent, isStreaming, showLoader) : null; + + const getBaseAnimationClass = () => { + if (!showLoader) return ''; + return !isStreaming ? 'fade-in duration-700' : ''; + }; + return ( -
  • - {children} +
  • + {isStreaming && showLoader && streamedContent ? streamedContent : children}
  • ); }; @@ -194,8 +281,21 @@ export const CustomSpan: React.FC< isStreaming?: boolean; } & ExtraPropsExtra > = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => { + const textContent = typeof children === 'string' ? children : + Array.isArray(children) ? children.join('') : + children?.toString() || ''; + + const streamedContent = useStreamingContent(textContent, isStreaming, showLoader); + + const getBaseAnimationClass = () => { + if (!showLoader) return ''; + return !isStreaming ? 'fade-in duration-700' : ''; + }; + return ( - {children} + + {isStreaming && showLoader ? streamedContent : children} + ); }; @@ -207,9 +307,20 @@ export const CustomStrong: React.FC< isStreaming?: boolean; } & ExtraPropsExtra > = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => { + const textContent = typeof children === 'string' ? children : + Array.isArray(children) ? children.join('') : + children?.toString() || ''; + + const streamedContent = useStreamingContent(textContent, isStreaming, showLoader); + + const getBaseAnimationClass = () => { + if (!showLoader) return ''; + return !isStreaming ? 'fade-in duration-700' : ''; + }; + return ( - - {children} + + {isStreaming && showLoader ? streamedContent : children} ); }; @@ -222,8 +333,21 @@ export const CustomEm: React.FC< isStreaming?: boolean; } & ExtraPropsExtra > = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => { + const textContent = typeof children === 'string' ? children : + Array.isArray(children) ? children.join('') : + children?.toString() || ''; + + const streamedContent = useStreamingContent(textContent, isStreaming, showLoader); + + const getBaseAnimationClass = () => { + if (!showLoader) return ''; + return !isStreaming ? 'fade-in duration-700' : ''; + }; + return ( - {children} + + {isStreaming && showLoader ? streamedContent : children} + ); }; @@ -235,7 +359,22 @@ export const CustomItalic: React.FC< isStreaming?: boolean; } & ExtraPropsExtra > = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => { - return {children}; + const textContent = typeof children === 'string' ? children : + Array.isArray(children) ? children.join('') : + children?.toString() || ''; + + const streamedContent = useStreamingContent(textContent, isStreaming, showLoader); + + const getBaseAnimationClass = () => { + if (!showLoader) return ''; + return !isStreaming ? 'fade-in duration-700' : ''; + }; + + return ( + + {isStreaming && showLoader ? streamedContent : children} + + ); }; export const CustomUnderline: React.FC< @@ -246,7 +385,22 @@ export const CustomUnderline: React.FC< isStreaming?: boolean; } & ExtraPropsExtra > = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => { - return {children}; + const textContent = typeof children === 'string' ? children : + Array.isArray(children) ? children.join('') : + children?.toString() || ''; + + const streamedContent = useStreamingContent(textContent, isStreaming, showLoader); + + const getBaseAnimationClass = () => { + if (!showLoader) return ''; + return !isStreaming ? 'fade-in duration-700' : ''; + }; + + return ( + + {isStreaming && showLoader ? streamedContent : children} + + ); }; export const CustomStrikethrough: React.FC< @@ -257,7 +411,22 @@ export const CustomStrikethrough: React.FC< isStreaming?: boolean; } & ExtraPropsExtra > = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => { - return {children}; + const textContent = typeof children === 'string' ? children : + Array.isArray(children) ? children.join('') : + children?.toString() || ''; + + const streamedContent = useStreamingContent(textContent, isStreaming, showLoader); + + const getBaseAnimationClass = () => { + if (!showLoader) return ''; + return !isStreaming ? 'fade-in duration-700' : ''; + }; + + return ( + + {isStreaming && showLoader ? streamedContent : children} + + ); }; export const CustomLink: React.FC< @@ -268,5 +437,20 @@ export const CustomLink: React.FC< isStreaming?: boolean; } & ExtraPropsExtra > = ({ children, markdown, showLoader, isStreaming = false, ...rest }) => { - return {children}; + const textContent = typeof children === 'string' ? children : + Array.isArray(children) ? children.join('') : + children?.toString() || ''; + + const streamedContent = useStreamingContent(textContent, isStreaming, showLoader); + + const getBaseAnimationClass = () => { + if (!showLoader) return ''; + return !isStreaming ? 'fade-in duration-700' : ''; + }; + + return ( + + {isStreaming && showLoader ? streamedContent : children} + + ); };