diff --git a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/AppMarkdownStreaming.stories.tsx b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/AppMarkdownStreaming.stories.tsx index 43fe4a1b4..54debd808 100644 --- a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/AppMarkdownStreaming.stories.tsx +++ b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/AppMarkdownStreaming.stories.tsx @@ -496,3 +496,249 @@ export const ParagraphToListTransition: Story = { ); } }; + +// Test case for broken up inline code streaming +const brokenInlineCodeTokens = [ + { + token: 'This paragraph contains several inline code examples. Here is a complete ', + delayMs: 300 + }, + { + token: '`complete_code_block`', + delayMs: 200 + }, + { + token: ' HOLD ', + delayMs: 1000 + }, + { + token: " that arrives in one token. Now let's test broken inline code: ", + delayMs: 300 + }, + { + token: '`br', + delayMs: 400 + }, + { + token: 'oken', + delayMs: 2000 + }, + { + token: '_code', + delayMs: 250 + }, + { + token: '_block', + delayMs: 260 + }, + { + token: '`', + delayMs: 650 + }, + { + token: " where the backticks and content are split. Here's another example with ", + delayMs: 300 + }, + { + token: '`', + delayMs: 500 + }, + { + token: 'useState', + delayMs: 200 + }, + { + token: '`', + delayMs: 400 + }, + { + token: ' and ', + delayMs: 150 + }, + { + token: '`', + delayMs: 300 + }, + { + token: 'useEffect', + delayMs: 200 + }, + { + token: '`', + delayMs: 300 + }, + { + token: ' hooks. Sometimes the content itself is split like ', + delayMs: 300 + }, + { + token: '`', + delayMs: 200 + }, + { + token: 'customer', + delayMs: 150 + }, + { + token: '_all', + delayMs: 150 + }, + { + token: '_time', + delayMs: 150 + }, + { + token: '_clv', + delayMs: 150 + }, + { + token: '`', + delayMs: 200 + }, + { + token: ' and we need to wait for all parts to complete the inline code block.\n\n', + delayMs: 400 + }, + { + token: 'Another paragraph with more examples: ', + delayMs: 200 + }, + { + token: '`', + delayMs: 300 + }, + { + token: 'const', + delayMs: 150 + }, + { + token: ' result', + delayMs: 150 + }, + { + token: ' =', + delayMs: 100 + }, + { + token: ' await', + delayMs: 150 + }, + { + token: ' fetch', + delayMs: 150 + }, + { + token: '()', + delayMs: 100 + }, + { + token: '`', + delayMs: 300 + }, + { + token: ' shows how complex code can be streamed. Mixed with normal ', + delayMs: 300 + }, + { + token: '`simple`', + delayMs: 150 + }, + { + token: ' inline code that arrives complete.\n\n', + delayMs: 200 + }, + { + token: 'Final test case: ', + delayMs: 200 + }, + { + token: '`', + delayMs: 400 + }, + { + token: 'database', + delayMs: 200 + }, + { + token: '.query', + delayMs: 150 + }, + { + token: '("SELECT', + delayMs: 150 + }, + { + token: ' * FROM', + delayMs: 150 + }, + { + token: ' customers', + delayMs: 150 + }, + { + token: ' WHERE', + delayMs: 150 + }, + { + token: ' active', + delayMs: 150 + }, + { + token: ' = true")', + delayMs: 200 + }, + { + token: '`', + delayMs: 300 + }, + { + token: ' demonstrates SQL code streaming.', + delayMs: 200 + }, + { + token: 'HOLD', + delayMs: 50000 + } +]; + +export const InlineCodeStreaming: Story = { + render: () => { + const { isStreamFinished, output } = useStreamTokenArray(brokenInlineCodeTokens); + + return ( +
+
Testing Broken Inline Code Streaming
+
+ Watch how inline code renders when backticks and content are split across multiple stream + tokens +
+
+
+

Animated Output

+
+ +
+
+
+

Raw Streaming Content

+
+
{output}
+
+
+ Stream Status: {isStreamFinished ? '✅ Complete' : '⏳ Streaming...'} +
+
+ Current length: {output.length} characters +
+
+
+
+ ); + } +}; diff --git a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/LLMAnimatedMarkdown.tsx b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/LLMAnimatedMarkdown.tsx index f79023718..4a995255f 100644 --- a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/LLMAnimatedMarkdown.tsx +++ b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/LLMAnimatedMarkdown.tsx @@ -1,32 +1,51 @@ import type { BlockMatch } from '@llm-ui/react'; import AnimatedMarkdown from '../../typography/AnimatedMarkdown/AnimatedMarkdown'; -import React from 'react'; +import React, { useMemo } from 'react'; import { useAppMarkdownStreaming } from './AppMarkdownStreaming'; +import { findPartialInlineCode } from './inlineCodeHelpers'; type LLMAnimatedMarkdownProps = { blockMatch: BlockMatch; }; -export const LLMAnimatedMarkdown: React.FC = ({ blockMatch }) => { - const markdown = blockMatch.output; - const { - animation, - stripFormatting, - isThrottleStreamingFinished, - animationDuration, - animationTimingFunction - } = useAppMarkdownStreaming(); +export const LLMAnimatedMarkdown: React.FC = React.memo( + ({ blockMatch }) => { + const markdown = blockMatch.llmOutput; + const { + animation, + stripFormatting, - return ( - - ); -}; + animationDuration, + isStreamFinished, + animationTimingFunction + } = useAppMarkdownStreaming(); + + const optimizedContent = useMemo(() => { + const partialMatch = findPartialInlineCode(markdown); + + // If there's a partial inline code match, only show content up to the start of the partial match + // This naturally excludes the offending backtick without affecting complete inline code blocks + if (partialMatch) { + return markdown.slice(0, partialMatch.startIndex); + } + + // No partial match, show all content as-is + return markdown; + }, [markdown]); + + return ( + + ); + } +); + +LLMAnimatedMarkdown.displayName = 'LLMAnimatedMarkdown'; export default LLMAnimatedMarkdown; diff --git a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/inlineCodeHelpers.test.ts b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/inlineCodeHelpers.test.ts new file mode 100644 index 000000000..8ad863c68 --- /dev/null +++ b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/inlineCodeHelpers.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { findPartialInlineCode } from './inlineCodeHelpers'; + +describe('findPartialInlineCode', () => { + it('should find partial inline code (unclosed backtick)', () => { + // Test case: Text with an unclosed backtick at the end + const text = 'This is some text with `unclosed code'; + const result = findPartialInlineCode(text); + + expect(result).not.toBeNull(); + expect(result).toEqual({ + startIndex: 23, // Position of the backtick + endIndex: 37, // End of the partial code + outputRaw: '`unclosed code' + }); + }); + + it('should return null for complete inline code', () => { + // Test case: Text with properly closed inline code + const text = 'This is some text with `complete code` here'; + const result = findPartialInlineCode(text); + + // Should return null because the code block is complete (properly closed) + expect(result).toBeNull(); + }); + + it('should return null when no inline code is present', () => { + // Test case: Text with no backticks at all + const text = 'This is just plain text without any code blocks'; + const result = findPartialInlineCode(text); + + // Should return null because there are no backticks + expect(result).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/inlineCodeHelpers.ts b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/inlineCodeHelpers.ts new file mode 100644 index 000000000..b981522b0 --- /dev/null +++ b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/inlineCodeHelpers.ts @@ -0,0 +1,55 @@ +// Helper functions for inline code detection in streaming markdown + +import type { LLMOutputMatcher } from '@llm-ui/react'; + +/** + * Finds complete inline code blocks (text between single backticks) + * @returns A function that finds complete inline code in the input + */ +export const findCompleteInlineCode = (): LLMOutputMatcher => { + return (input: string) => { + // Match inline code that is NOT part of a code block (```). + // Look for single backticks that are not preceded or followed by additional backticks + const regex = /(? { return useLLMOutput({ llmOutput: content, - fallbackBlock: { - component: LLMAnimatedMarkdown, - lookBack: markdownLookBack() - }, - blocks: [ - { - component: CodeComponentStreaming, - findCompleteMatch: findCompleteCodeBlock(), - findPartialMatch: findPartialCodeBlock(), - lookBack: codeBlockLookBack() - } - ], + fallbackBlock, + blocks, isStreamFinished, throttle }); diff --git a/apps/web/src/components/ui/typography/AnimatedMarkdown/AnimatedMarkdown.tsx b/apps/web/src/components/ui/typography/AnimatedMarkdown/AnimatedMarkdown.tsx index e11ce56ad..e41ddfdd8 100644 --- a/apps/web/src/components/ui/typography/AnimatedMarkdown/AnimatedMarkdown.tsx +++ b/apps/web/src/components/ui/typography/AnimatedMarkdown/AnimatedMarkdown.tsx @@ -8,6 +8,7 @@ import { MarkdownAnimation } from './animation-helpers'; import styles from './AnimatedMarkdown.module.css'; import { cn } from '@/lib/classMerge'; import './animations.css'; +import { useMount } from '../../../../hooks'; export interface AnimatedMarkdownProps { className?: string; @@ -28,13 +29,8 @@ const AnimatedMarkdown: React.FC = ({ animationTimingFunction = 'ease-in-out', isStreamFinished = true, stripFormatting = false, - className }) => { - const optimizedContent = useMemo(() => { - return content; - }, [content]); - const { components } = useMarkdownComponents({ animation, animationDuration, @@ -50,7 +46,7 @@ const AnimatedMarkdown: React.FC = ({ // remarkPlugins are used to extend or modify the Markdown parsing behavior. // Here, remarkGfm enables GitHub Flavored Markdown features (like tables, strikethrough, task lists). remarkPlugins={remarkPlugins}> - {optimizedContent} + {content} ); diff --git a/apps/web/src/components/ui/typography/AnimatedMarkdown/MarkdownComponent.tsx b/apps/web/src/components/ui/typography/AnimatedMarkdown/MarkdownComponent.tsx index 9ce2bc7bf..ced49fe63 100644 --- a/apps/web/src/components/ui/typography/AnimatedMarkdown/MarkdownComponent.tsx +++ b/apps/web/src/components/ui/typography/AnimatedMarkdown/MarkdownComponent.tsx @@ -5,6 +5,7 @@ import { cva } from 'class-variance-authority'; import { StreamingMessageCode } from '../../streaming/StreamingMessageCode'; import { cn } from '@/lib/classMerge'; import { useAnimationContext, AnimationProvider } from './AnimationContext'; +import { useMount } from '../../../../hooks'; type MarkdownComponentProps = { children: React.ReactNode; diff --git a/apps/web/src/components/ui/typography/AnimatedMarkdown/index.ts b/apps/web/src/components/ui/typography/AnimatedMarkdown/index.ts index fa3fa1a0b..c0930f466 100644 --- a/apps/web/src/components/ui/typography/AnimatedMarkdown/index.ts +++ b/apps/web/src/components/ui/typography/AnimatedMarkdown/index.ts @@ -1,2 +1 @@ export { default as AnimatedMarkdown } from './AnimatedMarkdown'; -export { default as LLMAnimatedMarkdown } from '../AppMarkdownStreaming/LLMAnimatedMarkdown'; diff --git a/apps/web/src/components/ui/typography/AnimatedMarkdown/useMarkdownComponents.tsx b/apps/web/src/components/ui/typography/AnimatedMarkdown/useMarkdownComponents.tsx index 99a8a4852..c35aa07e9 100644 --- a/apps/web/src/components/ui/typography/AnimatedMarkdown/useMarkdownComponents.tsx +++ b/apps/web/src/components/ui/typography/AnimatedMarkdown/useMarkdownComponents.tsx @@ -23,6 +23,7 @@ import { BreakComponent, CodeComponent } from './MarkdownComponent'; +import { useMount, useWhyDidYouUpdate } from '../../../../hooks'; interface UseMarkdownComponentsProps { stripFormatting?: boolean; @@ -139,8 +140,6 @@ export const useMarkdownComponents = ({ ), - // Components WITHOUT animations - // Components WITH animations (updated) ul: ({ children, className, style }) => ( {children}