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