diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e88029..6c7dd40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to - ✨(front) allow pasting an attachment from clipboard +### Changed + +- ⚡️(front) optimize streaming markdown rendering performance + ### Fixed - 💚(docker) vendor mime.types file instead of fetching from Apache SVN diff --git a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx index 8c23fb6..e1c4869 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx @@ -1,18 +1,13 @@ -import { Message, SourceUIPart, ToolInvocationUIPart } from '@ai-sdk/ui-utils'; +import { Message, SourceUIPart } from '@ai-sdk/ui-utils'; import { Modal, ModalSize } from '@openfun/cunningham-react'; -import rehypeShikiFromHighlighter from '@shikijs/rehype/core'; import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you import { useRouter } from 'next/router'; -import { use, useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ChangeEvent, FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { MarkdownHooks } from 'react-markdown'; -import rehypeKatex from 'rehype-katex'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Box, Icon, Loader, Text } from '@/components'; +import { Box, Loader, Text } from '@/components'; import { useUploadFile } from '@/features/attachments/hooks/useUploadFile'; import { useChat } from '@/features/chat/api/useChat'; import { getConversation } from '@/features/chat/api/useConversation'; @@ -21,13 +16,9 @@ import { LLMModel, useLLMConfiguration, } from '@/features/chat/api/useLLMConfiguration'; -import { AttachmentList } from '@/features/chat/components/AttachmentList'; import { ChatError } from '@/features/chat/components/ChatError'; -import { CodeBlock } from '@/features/chat/components/CodeBlock'; -import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons'; import { InputChat } from '@/features/chat/components/InputChat'; -import { SourceItemList } from '@/features/chat/components/SourceItemList'; -import { ToolInvocationItem } from '@/features/chat/components/ToolInvocationItem'; +import { MessageItem } from '@/features/chat/components/MessageItem'; import { useClipboard } from '@/hook'; import { useResponsiveStore } from '@/stores'; @@ -35,9 +26,6 @@ import { useSourceMetadataCache } from '../hooks'; import { useChatPreferencesStore } from '../stores/useChatPreferencesStore'; import { usePendingChatStore } from '../stores/usePendingChatStore'; import { useScrollStore } from '../stores/useScrollStore'; -import { getHighlighter } from '../utils/shiki'; - -const highlighterPromise = getHighlighter(); // Define Attachment type locally (mirroring backend structure) export interface Attachment { @@ -54,7 +42,7 @@ export const Chat = ({ const { t } = useTranslation(); const copyToClipboard = useClipboard(); const { isMobile } = useResponsiveStore(); - const highlighter = use(highlighterPromise); + const streamProtocol = 'data'; // or 'text' const { @@ -292,21 +280,21 @@ export const Chat = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages]); - const openSources = (messageId: string) => { - if (isSourceOpen === messageId) { - setIsSourceOpen(null); - return; - } - const message = messages.find((msg) => msg.id === messageId); - if (message?.parts) { - const sourceParts = message.parts.filter( - (part): part is SourceUIPart => part.type === 'source', - ); - if (sourceParts.length > 0) { - setIsSourceOpen(messageId); - } - } - }; + const openSources = useCallback((messageId: string) => { + // Source-parts guard is handled at the call site (MessageItem only shows the button when sourceParts.length > 0), + // so we just toggle it here. + setIsSourceOpen((prev) => (prev === messageId ? null : messageId)); + }, []); + + // Memoize the last assistant message index to avoid recalculating in render + const lastAssistantMessageIndex = useMemo(() => { + return messages.findLastIndex((msg) => msg.role === 'assistant'); + }, [messages]); + + // Memoize whether this is the first conversation (2 or fewer messages) + const isFirstConversationMessage = useMemo(() => { + return messages.length <= 2; + }, [messages.length]); // Calculer la hauteur pour le message de streaming const calculateStreamingHeight = useCallback(() => { @@ -645,319 +633,26 @@ export const Chat = ({ > {messages.length > 0 && ( - {messages.map((message, index) => { - const isLastMessage = index === messages.length - 1; - const isLastAssistantMessageInConversation = - message.role === 'assistant' && - index === - messages.findLastIndex((msg) => msg.role === 'assistant'); - const isFirstConversationMessage = messages.length <= 2; - const shouldApplyStreamingHeight = - isLastAssistantMessageInConversation && - isLastMessage && - streamingMessageHeight && - !isFirstConversationMessage; - const isCurrentlyStreaming = - isLastAssistantMessageInConversation && - (status === 'streaming' || status === 'submitted'); - - return ( - - - {message.experimental_attachments && - message.experimental_attachments.length > 0 && ( - - - - )} - - {/* Message content */} - {message.content && ( - -

- {message.role === 'user' - ? t('You said: ') - : t('Assistant IA replied: ')} -

- {message.role === 'user' ? ( - - {message.content} - - ) : ( - ( - - ), - a: ({ children, ...props }) => ( - - {children} - - ), - // eslint-disable-next-line @typescript-eslint/no-unused-vars - pre: ({ node, children, ...props }) => ( - {children} - ), - }} - > - {message.content} - - )} -
- )} - - - {isCurrentlyStreaming && - isLastAssistantMessageInConversation && - status === 'streaming' && - message.parts?.some( - (part) => - part.type === 'tool-invocation' && - part.toolInvocation.toolName !== - 'document_parsing', - ) && ( - - - - {(() => { - const toolInvocation = message.parts?.find( - (part) => - part.type === 'tool-invocation' && - part.toolInvocation.toolName !== - 'document_parsing', - ); - if ( - toolInvocation?.type === - 'tool-invocation' && - toolInvocation.toolInvocation.toolName === - 'summarize' - ) { - return t('Summarizing...'); - } - return t('Search...'); - })()} - - - )} - {message.parts - ?.filter((part) => part.type === 'tool-invocation') - .map( - (part: ToolInvocationUIPart, partIndex: number) => - part.type === 'tool-invocation' && - isCurrentlyStreaming && - isLastAssistantMessageInConversation ? ( - - ) : null, - )} - - {message.role === 'assistant' && - !( - isLastAssistantMessageInConversation && - status === 'streaming' - ) && ( - - - copyToClipboard(message.content)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - copyToClipboard(message.content); - } - }} - role="button" - tabIndex={0} - > - - {!isMobile && ( - - {t('Copy')} - - )} - - {message.parts?.some( - (part) => part.type === 'source', - ) && - (() => { - const sourceCount = - message.parts?.filter( - (part) => part.type === 'source', - ).length || 0; - return ( - openSources(message.id)} - onKeyDown={(e) => { - if ( - e.key === 'Enter' || - e.key === ' ' - ) { - e.preventDefault(); - openSources(message.id); - } - }} - role="button" - tabIndex={0} - > - - - {t('Show')} {sourceCount}{' '} - {sourceCount !== 1 - ? t('sources') - : t('source')} - - - ); - })()} - - - {/* We should display the button, but disabled if no trace linked */} - {conversationId && - message.id && - message.id.startsWith('trace-') && ( - - )} - - - )} - {message.parts && - isSourceOpen === message.id && - (() => { - const sourceParts = message.parts.filter( - (part): part is SourceUIPart => - part.type === 'source', - ); - return ( - - - - ); - })()} -
-
-
- ); - })} + {messages.map((message, index) => ( + + ))}
)} {(status !== 'ready' && status !== 'streaming' && status !== 'error') || diff --git a/src/frontend/apps/conversations/src/features/chat/components/MessageBlock.tsx b/src/frontend/apps/conversations/src/features/chat/components/MessageBlock.tsx new file mode 100644 index 0000000..60ab249 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/components/MessageBlock.tsx @@ -0,0 +1,89 @@ +// Memoized components for a single completed markdown blocks - only re-renders when content changes +import rehypeShikiFromHighlighter from '@shikijs/rehype/core'; +import React, { use } from 'react'; +import { Components, MarkdownHooks } from 'react-markdown'; +import rehypeKatex from 'rehype-katex'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; + +import { Text } from '@/components'; +import { CodeBlock } from '@/features/chat/components/CodeBlock'; + +// Memoized markdown plugins - created once at module level +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const REMARK_PLUGINS: any[] = [remarkGfm, remarkMath]; +// // eslint-disable-next-line @typescript-eslint/no-explicit-any + +import { getHighlighter } from '../utils/shiki'; + +const highlighterPromise = getHighlighter(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rehypePluginsPromise: Promise = highlighterPromise.then( + (highlighter) => [ + rehypeKatex, + [ + rehypeShikiFromHighlighter, + highlighter, + { + theme: 'github-dark-dimmed', + fallbackLanguage: 'plaintext', + }, + ], + ], +); + +// Memoized markdown components - created once at module level +const MARKDOWN_COMPONENTS: Components = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + p: ({ node, ...props }) => ( + + ), + a: ({ children, ...props }) => ( + + {children} + + ), + + pre: ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + node, + children, + ...props + }) => {children}, +}; + +export const CompletedMarkdownBlock = React.memo( + ({ content }: { content: string }) => { + const rehypePlugins = use(rehypePluginsPromise); + return ( + + {content} + + ); + }, + (prev, next) => prev.content === next.content, +); + +CompletedMarkdownBlock.displayName = 'CompletedMarkdownBlock'; + +export const RawTextBlock = ({ content }: { content: string }) => ( + + {content} + +); diff --git a/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx b/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx new file mode 100644 index 0000000..6cc6327 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/components/MessageItem.tsx @@ -0,0 +1,541 @@ +import { Message, SourceUIPart, ToolInvocationUIPart } from '@ai-sdk/ui-utils'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Icon, Loader, Text } from '@/components'; +import { AttachmentList } from '@/features/chat/components/AttachmentList'; +import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons'; +import { + CompletedMarkdownBlock, + RawTextBlock, +} from '@/features/chat/components/MessageBlock'; +import { SourceItemList } from '@/features/chat/components/SourceItemList'; +import { ToolInvocationItem } from '@/features/chat/components/ToolInvocationItem'; + +// Memoized blocks list to prevent parent re-renders from causing block remounts +const BlocksList = React.memo( + ({ blocks, pending }: { blocks: string[]; pending: string }) => ( +
+ {/* key={index} is safe here: blocks are append-only during streaming + and a completed block's content never changes once finalized. */} + {blocks.map((block, index) => ( + + ))} + {pending && } +
+ ), + (prev, next) => { + const lengthChanged = prev.blocks.length !== next.blocks.length; + const pendingChanged = prev.pending !== next.pending; + + let blocksChanged = false; + for (let i = 0; i < Math.min(prev.blocks.length, next.blocks.length); i++) { + if (prev.blocks[i] !== next.blocks[i]) { + blocksChanged = true; + } + } + + if (lengthChanged || pendingChanged || blocksChanged) { + return false; // needs re-render + } + return true; + }, +); +BlocksList.displayName = 'BlocksList'; + +export interface StreamingContent { + completedBlocks: string[]; + pending: string; +} + +/** + * Splits content into blocks by double newlines, respecting code fences. + * Code fences may contain double newlines, so we merge blocks until fences are balanced. + */ +export const splitIntoBlocks = (content: string): string[] => { + if (!content) { + return []; + } + + const rawBlocks = content.split('\n\n'); + const blocks: string[] = []; + let currentBlock = ''; + let fenceCount = 0; + + for (const rawBlock of rawBlocks) { + const fences = (rawBlock.match(/```/g) || []).length; + + currentBlock = currentBlock ? currentBlock + '\n\n' + rawBlock : rawBlock; + fenceCount += fences; + + // Balanced fences = complete block + if (fenceCount % 2 === 0) { + if (currentBlock.trim()) { + blocks.push(currentBlock); + } + currentBlock = ''; + fenceCount = 0; + } + } + + if (currentBlock.trim()) { + blocks.push(currentBlock); + } + + return blocks; +}; + +/** + * Splits streaming content into completed blocks (safe and ready to render as markdown) + * + a pending content (still being streamed, rendered as raw text). + * + * A block is considered completed when followed by a double newline. + * Each block is returned separately to enable independent memoization. + * NB: it respects code fences (``` ... ```) that may contain double newlines. + */ +export const splitStreamingContent = (content: string): StreamingContent => { + if (!content) { + return { completedBlocks: [], pending: '' }; + } + + // Find all code fence positions + // Note: this counts all ``` occurrences including those inside inline code spans. + // In practice this is unlikely to cause issues since inline code rarely contains ```. + const fenceRegex = /```/g; + const fences: number[] = []; + let match; + while ((match = fenceRegex.exec(content)) !== null) { + fences.push(match.index); + } + + // Check if we're inside an unclosed code fence + const isInsideCodeFence = fences.length % 2 === 1; + + let completedContent: string; + let pendingContent: string; + + if (isInsideCodeFence) { + // Find the last opening fence + const lastFenceStart = fences[fences.length - 1]; + // Everything before the unclosed fence is potentially complete + const beforeFence = content.slice(0, lastFenceStart); + const fenceAndAfter = content.slice(lastFenceStart); + + // Find the last complete block boundary before the fence + const lastDoubleNewline = beforeFence.lastIndexOf('\n\n'); + if (lastDoubleNewline !== -1) { + completedContent = beforeFence.slice(0, lastDoubleNewline); + pendingContent = beforeFence.slice(lastDoubleNewline) + fenceAndAfter; + } else { + // No complete blocks before fence + return { completedBlocks: [], pending: content }; + } + } else { + // Not inside a code fence - find the last double newline as block boundary + const lastDoubleNewline = content.lastIndexOf('\n\n'); + if (lastDoubleNewline === -1) { + // No double newline yet - everything is pending + return { completedBlocks: [], pending: content }; + } + + // Content up to the last \n\n is complete + completedContent = content.slice(0, lastDoubleNewline); + // Content after the last \n\n is pending (may be empty if content ends with \n\n) + pendingContent = content.slice(lastDoubleNewline + 2); + } + + const completedBlocks = splitIntoBlocks(completedContent); + return { completedBlocks, pending: pendingContent }; +}; + +interface SourceMetadata { + title: string | null; + favicon: string | null; + loading: boolean; + error: boolean; +} + +export interface MessageItemProps { + message: Message; + isLastMessage: boolean; + isLastAssistantMessage: boolean; + isFirstConversationMessage: boolean; + streamingMessageHeight: number | null; + status: 'submitted' | 'streaming' | 'ready' | 'error'; + conversationId: string | undefined; + isSourceOpen: string | null; + isMobile: boolean; + onCopyToClipboard: (content: string) => void; + onOpenSources: (messageId: string) => void; + getMetadata: (url: string) => SourceMetadata | undefined; +} + +const MessageItemComponent: React.FC = ({ + message, + isLastMessage, + isLastAssistantMessage, + isFirstConversationMessage, + streamingMessageHeight, + status, + conversationId, + isSourceOpen, + isMobile, + onCopyToClipboard, + onOpenSources, + getMetadata, +}) => { + const { t } = useTranslation(); + + const shouldApplyStreamingHeight = + isLastAssistantMessage && + isLastMessage && + streamingMessageHeight && + !isFirstConversationMessage; + + const isCurrentlyStreaming = + isLastAssistantMessage && + (status === 'streaming' || status === 'submitted'); + + const sourceParts = React.useMemo(() => { + if (!message.parts) { + return []; + } + return message.parts.filter( + (part): part is SourceUIPart => part.type === 'source', + ); + }, [message.parts]); + + const toolInvocationParts = React.useMemo(() => { + if (!message.parts) { + return []; + } + return message.parts.filter( + (part): part is ToolInvocationUIPart => part.type === 'tool-invocation', + ); + }, [message.parts]); + + const hasNonDocumentParsingTool = React.useMemo(() => { + return toolInvocationParts.some( + (part) => part.toolInvocation.toolName !== 'document_parsing', + ); + }, [toolInvocationParts]); + + const activeToolInvocation = React.useMemo(() => { + const tool = toolInvocationParts.find( + (part) => part.toolInvocation.toolName !== 'document_parsing', + ); + return tool?.toolInvocation; + }, [toolInvocationParts]); + + // Memoize the streaming content split to avoid recreating components in JSX + const { completedBlocks, pending } = React.useMemo(() => { + // When not streaming, everything is completed as a single block array + if (!isCurrentlyStreaming) { + return { + completedBlocks: splitIntoBlocks(message.content), + pending: '', + }; + } + return splitStreamingContent(message.content); + }, [isCurrentlyStreaming, message.content]); + + const handleCopy = React.useCallback(() => { + onCopyToClipboard(message.content); + }, [onCopyToClipboard, message.content]); + + const handleCopyKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onCopyToClipboard(message.content); + } + }, + [onCopyToClipboard, message.content], + ); + + const handleOpenSources = React.useCallback(() => { + onOpenSources(message.id); + }, [onOpenSources, message.id]); + + const handleOpenSourcesKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpenSources(message.id); + } + }, + [onOpenSources, message.id], + ); + + return ( + + + {message.experimental_attachments && + message.experimental_attachments.length > 0 && ( + + + + )} + + {/* Message content */} + {message.content && ( + +

+ {message.role === 'user' + ? t('You said: ') + : t('Assistant IA replied: ')} +

+ {message.role === 'user' ? ( + + {message.content} + + ) : ( + // Render completed blocks as markdown, pending block as plain text + + )} +
+ )} + + + {isCurrentlyStreaming && + isLastAssistantMessage && + status === 'streaming' && + hasNonDocumentParsingTool && ( + + + + {activeToolInvocation?.toolName === 'summarize' + ? t('Summarizing...') + : t('Search...')} + + + )} + {toolInvocationParts.map((part, partIndex) => + isCurrentlyStreaming && isLastAssistantMessage ? ( + + ) : null, + )} + + + {message.role === 'assistant' && + !(isLastAssistantMessage && status === 'streaming') && ( + + + + + {!isMobile && ( + + {t('Copy')} + + )} + + {sourceParts.length > 0 && ( + + + + {t('Show')} {sourceParts.length}{' '} + {sourceParts.length !== 1 ? t('sources') : t('source')} + + + )} + + + {conversationId && + message.id && + message.id.startsWith('trace-') && ( + + )} + + + )} + + {isSourceOpen === message.id && sourceParts.length > 0 && ( + + + + )} +
+
+
+ ); +}; + +MessageItemComponent.displayName = 'MessageItem'; + +// Custom comparison function for React.memo +// Only re-render when props that affect rendering change +const arePropsEqual = ( + prevProps: MessageItemProps, + nextProps: MessageItemProps, +): boolean => { + // Always re-render if message content changed + if (prevProps.message.id !== nextProps.message.id) { + return false; + } + if (prevProps.message.content !== nextProps.message.content) { + return false; + } + if (prevProps.message.role !== nextProps.message.role) { + return false; + } + + // Check parts changes (for streaming tool invocations and sources) + const prevPartsLength = prevProps.message.parts?.length ?? 0; + const nextPartsLength = nextProps.message.parts?.length ?? 0; + if (prevPartsLength !== nextPartsLength) { + return false; + } + + // Check attachments + const prevAttachmentsLength = + prevProps.message.experimental_attachments?.length ?? 0; + const nextAttachmentsLength = + nextProps.message.experimental_attachments?.length ?? 0; + if (prevAttachmentsLength !== nextAttachmentsLength) { + return false; + } + + // Check rendering flags + if (prevProps.isLastMessage !== nextProps.isLastMessage) { + return false; + } + if (prevProps.isLastAssistantMessage !== nextProps.isLastAssistantMessage) { + return false; + } + if ( + prevProps.isFirstConversationMessage !== + nextProps.isFirstConversationMessage + ) { + return false; + } + if (prevProps.streamingMessageHeight !== nextProps.streamingMessageHeight) { + return false; + } + if (prevProps.status !== nextProps.status) { + return false; + } + if (prevProps.isSourceOpen !== nextProps.isSourceOpen) { + return false; + } + if (prevProps.isMobile !== nextProps.isMobile) { + return false; + } + if (prevProps.conversationId !== nextProps.conversationId) { + return false; + } + + return true; +}; + +export const MessageItem = React.memo(MessageItemComponent, arePropsEqual); diff --git a/src/frontend/apps/conversations/src/features/chat/components/SourceItemList.tsx b/src/frontend/apps/conversations/src/features/chat/components/SourceItemList.tsx index 4e411d3..cd728e3 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/SourceItemList.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/SourceItemList.tsx @@ -23,7 +23,6 @@ const SourceItemListComponent: React.FC = ({ if (parts.length === 0) { return null; } - return ( ({ + MarkdownHooks: ({ children }: { children: string }) => { + // Simple mock that renders markdown-like content + // This tests the component integration, not the markdown parsing itself + return
{children}
; + }, +})); + +// Mock rehype/remark plugins +jest.mock('@shikijs/rehype/core', () => () => {}); +jest.mock('../../utils/shiki', () => ({ + getHighlighter: () => Promise.resolve({}), +})); +jest.mock('rehype-katex', () => () => {}); +jest.mock('remark-gfm', () => () => {}); +jest.mock('remark-math', () => () => {}); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const renderWithProviders = (ui: React.ReactNode) => { + return render( + + {ui} + , + ); +}; + +describe('CompletedMarkdownBlock', () => { + it('renders content through MarkdownHooks', async () => { + await act(async () => { + renderWithProviders(); + }); + + expect(screen.getByText('Hello world')).toBeInTheDocument(); + }); + + it('passes content to markdown renderer', async () => { + const content = '# Header Some **bold** text'; + await act(async () => { + renderWithProviders(); + }); + + expect(screen.getByTestId('markdown-content')).toHaveTextContent(content); + }); + + it('does not re-render when content is the same (memoization)', async () => { + let rerender: ReturnType['rerender']; + await act(async () => { + ({ rerender } = renderWithProviders( + , + )); + }); + + const firstRender = screen.getByTestId('markdown-content'); + + rerender!( + + + + + , + ); + + const secondRender = screen.getByTestId('markdown-content'); + + // The DOM element should be the same instance (not recreated) + expect(firstRender).toBe(secondRender); + }); + + it('re-renders when content changes', async () => { + let rerender: ReturnType['rerender']; + await act(async () => { + ({ rerender } = renderWithProviders( + , + )); + }); + + expect(screen.getByText('Original content')).toBeInTheDocument(); + + rerender!( + + + + + , + ); + + expect(screen.queryByText('Original content')).not.toBeInTheDocument(); + expect(screen.getByText('New content')).toBeInTheDocument(); + }); + + it('handles empty content', async () => { + let container: HTMLElement; + await act(async () => { + ({ container } = renderWithProviders( + , + )); + }); + + expect(container!).toBeInTheDocument(); + expect(screen.getByTestId('markdown-content')).toBeInTheDocument(); + }); + + it('handles content with special characters', async () => { + await act(async () => { + renderWithProviders( + & " '`} />, + ); + }); + + expect(screen.getByText(/Special chars:/)).toBeInTheDocument(); + }); +}); + +describe('RawTextBlock', () => { + it('renders plain text content', () => { + renderWithProviders(); + + expect(screen.getByText('Raw text content')).toBeInTheDocument(); + }); + + it('preserves whitespace and newlines', () => { + renderWithProviders( + , + ); + + const textElement = screen.getByText(/Line 1/); + expect(textElement).toHaveStyle('white-space: pre-wrap'); + }); + + it('renders as a div element', () => { + renderWithProviders(); + + const element = screen.getByText('Test content'); + // Text component renders with as="div", check it's in the DOM + expect(element).toBeInTheDocument(); + }); + + it('does not parse markdown syntax', () => { + renderWithProviders(); + + // Should render the raw markdown syntax, not parsed + expect(screen.getByText('**not bold** *not italic*')).toBeInTheDocument(); + }); + + it('handles empty content', () => { + const { container } = renderWithProviders(); + + expect(container).toBeInTheDocument(); + }); + + it('handles content with code fence markers', () => { + renderWithProviders( + , + ); + + expect(screen.getByText(/```python/)).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx new file mode 100644 index 0000000..5ed218a --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/components/__tests__/MessageItem.test.tsx @@ -0,0 +1,536 @@ +/* eslint-disable testing-library/no-unnecessary-act, @typescript-eslint/require-await */ +import { CunninghamProvider } from '@openfun/cunningham-react'; +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Suspense } from 'react'; + +import { + MessageItem, + splitIntoBlocks, + splitStreamingContent, +} from '../MessageItem'; + +// Mock react-markdown (ESM module) +jest.mock('react-markdown', () => ({ + MarkdownHooks: ({ children }: { children: string }) => ( +
{children}
+ ), +})); + +jest.mock('@shikijs/rehype/core', () => () => {}); +jest.mock('../../utils/shiki', () => ({ + getHighlighter: () => Promise.resolve({}), +})); +jest.mock('rehype-katex', () => () => {}); +jest.mock('remark-gfm', () => () => {}); +jest.mock('remark-math', () => () => {}); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +// Mock child components +jest.mock('../AttachmentList', () => ({ + AttachmentList: () =>
, +})); + +jest.mock('../FeedbackButtons', () => ({ + FeedbackButtons: () =>
, +})); + +jest.mock('../SourceItemList', () => ({ + SourceItemList: () =>
, +})); + +jest.mock('../ToolInvocationItem', () => ({ + ToolInvocationItem: () =>
, +})); + +describe('splitIntoBlocks', () => { + describe('basic splitting', () => { + it('returns empty array for empty content', () => { + expect(splitIntoBlocks('')).toEqual([]); + }); + + it('returns empty array for null/undefined content', () => { + expect(splitIntoBlocks(null as unknown as string)).toEqual([]); + expect(splitIntoBlocks(undefined as unknown as string)).toEqual([]); + }); + + it('returns single block for content without double newlines', () => { + expect(splitIntoBlocks('Hello world')).toEqual(['Hello world']); + }); + + it('splits content by double newlines', () => { + expect(splitIntoBlocks('Block 1\n\nBlock 2')).toEqual([ + 'Block 1', + 'Block 2', + ]); + }); + + it('splits multiple blocks', () => { + expect(splitIntoBlocks('Block 1\n\nBlock 2\n\nBlock 3')).toEqual([ + 'Block 1', + 'Block 2', + 'Block 3', + ]); + }); + + it('ignores single newlines', () => { + expect(splitIntoBlocks('Line 1\nLine 2\n\nBlock 2')).toEqual([ + 'Line 1\nLine 2', + 'Block 2', + ]); + }); + + it('filters out empty blocks', () => { + expect(splitIntoBlocks('Block 1\n\n\n\nBlock 2')).toEqual([ + 'Block 1', + 'Block 2', + ]); + }); + + it('filters out whitespace-only blocks', () => { + expect(splitIntoBlocks('Block 1\n\n \n\nBlock 2')).toEqual([ + 'Block 1', + 'Block 2', + ]); + }); + }); + + describe('code fence handling', () => { + it('keeps code block with internal double newlines as single block', () => { + const content = '```python\nline1\n\nline2\n```'; + expect(splitIntoBlocks(content)).toEqual([content]); + }); + + it('keeps code block intact when followed by other content', () => { + const content = '```python\ncode\n```\n\nText after'; + expect(splitIntoBlocks(content)).toEqual([ + '```python\ncode\n```', + 'Text after', + ]); + }); + + it('keeps code block intact when preceded by other content', () => { + const content = 'Text before\n\n```python\ncode\n```'; + expect(splitIntoBlocks(content)).toEqual([ + 'Text before', + '```python\ncode\n```', + ]); + }); + + it('handles multiple code blocks', () => { + const content = '```js\ncode1\n```\n\nText\n\n```python\ncode2\n```'; + expect(splitIntoBlocks(content)).toEqual([ + '```js\ncode1\n```', + 'Text', + '```python\ncode2\n```', + ]); + }); + + it('handles code block with multiple double newlines inside', () => { + const content = '```\nline1\n\nline2\n\nline3\n```'; + expect(splitIntoBlocks(content)).toEqual([content]); + }); + + it('handles nested backticks inside code block', () => { + const content = '```markdown\nSome `inline` code\n\nMore text\n```'; + expect(splitIntoBlocks(content)).toEqual([content]); + }); + + it('handles unclosed code fence', () => { + const content = 'Text\n\n```python\ncode without closing'; + const result = splitIntoBlocks(content); + // The unclosed fence should be kept with the text before it + expect(result).toEqual(['Text', '```python\ncode without closing']); + }); + }); + + describe('edge cases', () => { + it('handles content ending with double newline', () => { + expect(splitIntoBlocks('Block 1\n\nBlock 2\n\n')).toEqual([ + 'Block 1', + 'Block 2', + ]); + }); + + it('handles content starting with double newline', () => { + expect(splitIntoBlocks('\n\nBlock 1\n\nBlock 2')).toEqual([ + 'Block 1', + 'Block 2', + ]); + }); + + it('handles markdown headers', () => { + expect(splitIntoBlocks('# Header\n\nParagraph')).toEqual([ + '# Header', + 'Paragraph', + ]); + }); + + it('handles markdown lists', () => { + const content = '- Item 1\n- Item 2\n\nParagraph'; + expect(splitIntoBlocks(content)).toEqual([ + '- Item 1\n- Item 2', + 'Paragraph', + ]); + }); + }); +}); + +describe('splitStreamingContent', () => { + describe('basic splitting', () => { + it('returns empty blocks and empty pending for empty content', () => { + expect(splitStreamingContent('')).toEqual({ + completedBlocks: [], + pending: '', + }); + }); + + it('returns all content as pending when no double newline', () => { + expect(splitStreamingContent('Partial content')).toEqual({ + completedBlocks: [], + pending: 'Partial content', + }); + }); + + it('splits completed and pending content', () => { + expect(splitStreamingContent('Block 1\n\nPending')).toEqual({ + completedBlocks: ['Block 1'], + pending: 'Pending', + }); + }); + + it('handles multiple completed blocks with pending', () => { + expect(splitStreamingContent('Block 1\n\nBlock 2\n\nPending')).toEqual({ + completedBlocks: ['Block 1', 'Block 2'], + pending: 'Pending', + }); + }); + }); + + describe('content ending with double newline (bug fix)', () => { + it('keeps blocks when content ends with double newline', () => { + // This was the bug: content ending with \n\n would return empty blocks + const result = splitStreamingContent('Block 1\n\nBlock 2\n\n'); + expect(result.completedBlocks).toEqual(['Block 1', 'Block 2']); + expect(result.pending).toBe(''); + }); + + it('keeps single block when content ends with double newline', () => { + const result = splitStreamingContent('Block 1\n\n'); + expect(result.completedBlocks).toEqual(['Block 1']); + expect(result.pending).toBe(''); + }); + + it('handles multiple trailing double newlines', () => { + // 'Block 1\n\n\n\n' - lastIndexOf('\n\n') finds the last pair + // completedContent = 'Block 1\n\n', pending = '' + const result = splitStreamingContent('Block 1\n\n\n\n'); + expect(result.completedBlocks).toEqual(['Block 1']); + expect(result.pending).toBe(''); + }); + }); + + describe('code fence handling', () => { + it('treats unclosed code fence as pending', () => { + const result = splitStreamingContent('Text\n\n```python\ncode'); + expect(result.completedBlocks).toEqual(['Text']); + expect(result.pending).toBe('\n\n```python\ncode'); + }); + + it('handles closed code fence as completed', () => { + const result = splitStreamingContent('```python\ncode\n```\n\nPending'); + expect(result.completedBlocks).toEqual(['```python\ncode\n```']); + expect(result.pending).toBe('Pending'); + }); + + it('handles unclosed fence with no content before', () => { + const result = splitStreamingContent('```python\ncode'); + expect(result.completedBlocks).toEqual([]); + expect(result.pending).toBe('```python\ncode'); + }); + + it('handles unclosed fence with double newline inside', () => { + const result = splitStreamingContent('Text\n\n```python\nline1\n\nline2'); + expect(result.completedBlocks).toEqual(['Text']); + expect(result.pending).toContain('```python'); + }); + + it('handles multiple code blocks', () => { + const result = splitStreamingContent( + '```js\ncode1\n```\n\n```python\ncode2\n```\n\nPending', + ); + expect(result.completedBlocks).toEqual([ + '```js\ncode1\n```', + '```python\ncode2\n```', + ]); + expect(result.pending).toBe('Pending'); + }); + }); + + describe('streaming simulation', () => { + it('maintains block stability as content grows', () => { + // Simulate streaming: content grows over time + const stream1 = 'Hello'; + const stream2 = 'Hello world'; + const stream3 = 'Hello world\n\n'; + const stream4 = 'Hello world\n\nSecond'; + const stream5 = 'Hello world\n\nSecond block\n\n'; + const stream6 = 'Hello world\n\nSecond block\n\nThird'; + + // Initially all pending + expect(splitStreamingContent(stream1).completedBlocks).toEqual([]); + expect(splitStreamingContent(stream2).completedBlocks).toEqual([]); + + // After first \n\n, first block is complete + const result3 = splitStreamingContent(stream3); + expect(result3.completedBlocks).toEqual(['Hello world']); + expect(result3.pending).toBe(''); + + // New content arrives + const result4 = splitStreamingContent(stream4); + expect(result4.completedBlocks).toEqual(['Hello world']); + expect(result4.pending).toBe('Second'); + + // Second block completes + const result5 = splitStreamingContent(stream5); + expect(result5.completedBlocks).toEqual(['Hello world', 'Second block']); + expect(result5.pending).toBe(''); + + // More content + const result6 = splitStreamingContent(stream6); + expect(result6.completedBlocks).toEqual(['Hello world', 'Second block']); + expect(result6.pending).toBe('Third'); + }); + + it('block content remains stable during streaming', () => { + // The key test: first block content should not change as more content arrives + const stream1 = 'First block\n\nSecond'; + const stream2 = 'First block\n\nSecond block\n\nThird'; + + const result1 = splitStreamingContent(stream1); + const result2 = splitStreamingContent(stream2); + + // First block should be identical + expect(result1.completedBlocks[0]).toBe(result2.completedBlocks[0]); + expect(result1.completedBlocks[0]).toBe('First block'); + }); + }); + + describe('edge cases', () => { + it('handles only whitespace', () => { + expect(splitStreamingContent(' ')).toEqual({ + completedBlocks: [], + pending: ' ', + }); + }); + + it('handles only newlines', () => { + expect(splitStreamingContent('\n\n')).toEqual({ + completedBlocks: [], + pending: '', + }); + }); + + it('handles special characters', () => { + const result = splitStreamingContent('Special: < > & "\n\nMore'); + expect(result.completedBlocks).toEqual(['Special: < > & "']); + expect(result.pending).toBe('More'); + }); + }); +}); + +describe('MessageItem', () => { + const defaultProps = { + message: { + id: 'msg-1', + role: 'assistant' as const, + content: 'Hello world', + }, + isLastMessage: false, + isLastAssistantMessage: false, + isFirstConversationMessage: false, + streamingMessageHeight: null, + status: 'ready' as const, + conversationId: 'conv-1', + isSourceOpen: null, + isMobile: false, + onCopyToClipboard: jest.fn(), + onOpenSources: jest.fn(), + getMetadata: jest.fn(), + }; + + const renderWithProviders = (ui: React.ReactNode) => { + return render( + + {ui} + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('rendering', () => { + it('renders assistant message content', async () => { + await act(async () => { + renderWithProviders(); + }); + + expect( + screen.getByTestId('assistant-message-content'), + ).toBeInTheDocument(); + }); + + it('renders user message content', async () => { + await act(async () => { + renderWithProviders( + , + ); + }); + + expect(screen.getByText('Hello world')).toBeInTheDocument(); + }); + + it('renders message with data-message-id attribute', async () => { + await act(async () => { + renderWithProviders(); + }); + + expect(screen.getByTestId('msg-1')).toBeInTheDocument(); + }); + + it('renders copy button for non-streaming assistant messages', async () => { + await act(async () => { + renderWithProviders(); + }); + + expect(screen.getByText('Copy')).toBeInTheDocument(); + }); + + it('does not render copy button while streaming', async () => { + await act(async () => { + renderWithProviders( + , + ); + }); + + expect(screen.queryByText('Copy')).not.toBeInTheDocument(); + }); + + it('hides copy text on mobile', async () => { + await act(async () => { + renderWithProviders(); + }); + + expect(screen.queryByText('Copy')).not.toBeInTheDocument(); + }); + }); + + describe('interactions', () => { + it('calls onCopyToClipboard when copy button is clicked', async () => { + const user = userEvent.setup(); + const onCopyToClipboard = jest.fn(); + + await act(async () => { + renderWithProviders( + , + ); + }); + + await user.click(screen.getByText('Copy')); + + expect(onCopyToClipboard).toHaveBeenCalledWith('Hello world'); + }); + }); + + describe('streaming state', () => { + it('uses splitStreamingContent when streaming', async () => { + // When streaming, content should be split into blocks + pending + await act(async () => { + renderWithProviders( + , + ); + }); + + // The markdown content should contain "Block 1" but not necessarily "Pending" + // since pending is rendered as raw text + expect( + screen.getByTestId('assistant-message-content'), + ).toBeInTheDocument(); + }); + }); + + describe('attachments', () => { + it('renders AttachmentList when message has attachments', async () => { + const messageWithAttachments = { + ...defaultProps.message, + experimental_attachments: [ + { url: 'https://example.com/file.pdf', name: 'file.pdf' }, + ], + }; + + await act(async () => { + renderWithProviders( + , + ); + }); + + expect(screen.getByTestId('attachment-list')).toBeInTheDocument(); + }); + + it('does not render AttachmentList when no attachments', async () => { + await act(async () => { + renderWithProviders(); + }); + + expect(screen.queryByTestId('attachment-list')).not.toBeInTheDocument(); + }); + }); + + describe('feedback buttons', () => { + it('renders FeedbackButtons for trace messages', async () => { + await act(async () => { + renderWithProviders( + , + ); + }); + + expect(screen.getByTestId('feedback-buttons')).toBeInTheDocument(); + }); + + it('does not render FeedbackButtons for non-trace messages', async () => { + await act(async () => { + renderWithProviders(); + }); + + expect(screen.queryByTestId('feedback-buttons')).not.toBeInTheDocument(); + }); + }); +});