️(front) optimize streaming markdown rendering with block splitting

Split streaming content into independent memoized blocks to avoid
re-rendering all markdown/katex/syntax-highlighting on each update.
Add some tests for split functions and components.

Signed-off-by: Laurent Paoletti <lp@providenz.fr>
This commit is contained in:
Laurent Paoletti
2026-01-21 16:59:18 +01:00
parent 9d16460777
commit af6facf4a3
7 changed files with 1381 additions and 346 deletions
+4
View File
@@ -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
@@ -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 && (
<Box>
{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 (
<Box
key={message.id}
data-message-id={message.id}
$css={`
display: flex;
width: 100%;
margin: auto;
margin-bottom: ${isLastAssistantMessageInConversation ? '30px' : '0px'};
color: var(--c--theme--colors--greyscale-850);
padding-left: 12px;
padding-right: 12px;
max-width: 750px;
text-align: left;
overflow-wrap: anywhere;
flex-direction: ${message.role === 'user' ? 'row-reverse' : 'row'};
`}
>
<Box
$display="block"
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
>
{message.experimental_attachments &&
message.experimental_attachments.length > 0 && (
<Box>
<AttachmentList
attachments={message.experimental_attachments}
isReadOnly={true}
/>
</Box>
)}
<Box
className={`chatMessage ${message.role === 'user' ? 'chatMessage--user' : 'chatMessage--assistant'}`}
style={
shouldApplyStreamingHeight
? { minHeight: `${streamingMessageHeight}px` }
: undefined
}
>
{/* Message content */}
{message.content && (
<Box
className="mainContent-chat"
data-testid={
message.role === 'assistant'
? 'assistant-message-content'
: undefined
}
$padding={{ all: 'xxs' }}
>
<p className="sr-only">
{message.role === 'user'
? t('You said: ')
: t('Assistant IA replied: ')}
</p>
{message.role === 'user' ? (
<Text
as="p"
$css="white-space: pre-wrap; display: block;"
$theme="greyscale"
$variation="850"
>
{message.content}
</Text>
) : (
<MarkdownHooks
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[
rehypeKatex,
[
rehypeShikiFromHighlighter,
highlighter,
{
theme: 'github-dark-dimmed',
fallbackLanguage: 'plaintext',
},
],
]}
components={{
// Custom components for Markdown rendering
// eslint-disable-next-line @typescript-eslint/no-unused-vars
p: ({ node, ...props }) => (
<Text
as="p"
$css="display: block"
$theme="greyscale"
$variation="850"
{...props}
/>
),
a: ({ children, ...props }) => (
<a target="_blank" {...props}>
{children}
</a>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
pre: ({ node, children, ...props }) => (
<CodeBlock {...props}>{children}</CodeBlock>
),
}}
>
{message.content}
</MarkdownHooks>
)}
</Box>
)}
<Box $direction="column" $gap="2">
{isCurrentlyStreaming &&
isLastAssistantMessageInConversation &&
status === 'streaming' &&
message.parts?.some(
(part) =>
part.type === 'tool-invocation' &&
part.toolInvocation.toolName !==
'document_parsing',
) && (
<Box
$direction="row"
$align="center"
$gap="6px"
$width="100%"
$maxWidth="750px"
$margin={{
all: 'auto',
top: 'base',
bottom: 'md',
}}
>
<Loader />
<Text $variation="600" $size="md">
{(() => {
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...');
})()}
</Text>
</Box>
)}
{message.parts
?.filter((part) => part.type === 'tool-invocation')
.map(
(part: ToolInvocationUIPart, partIndex: number) =>
part.type === 'tool-invocation' &&
isCurrentlyStreaming &&
isLastAssistantMessageInConversation ? (
<ToolInvocationItem
key={`tool-invocation-${partIndex}`}
toolInvocation={part.toolInvocation}
status={status}
hideSearchLoader={true}
/>
) : null,
)}
</Box>
{message.role === 'assistant' &&
!(
isLastAssistantMessageInConversation &&
status === 'streaming'
) && (
<Box
$css="font-size: 12px;"
$direction="row"
$align="center"
className="clr-content-semantic-neutral-secondary"
$justify="space-between"
$gap="6px"
$margin={{ top: 'base' }}
>
<Box $direction="row" $gap="4px">
<Box
$theme="neutral"
$variation="secondary"
$direction="row"
$align="center"
$gap="4px"
className="c__button c__button--brand c__button--brand--tertiary c__button--nano clr-content-semantic-neutral-secondary"
onClick={() => copyToClipboard(message.content)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
copyToClipboard(message.content);
}
}}
role="button"
tabIndex={0}
>
<Icon
iconName="content_copy"
$variation="550"
$size="16px"
/>
{!isMobile && (
<Text $theme="neutral" $variation="secondary">
{t('Copy')}
</Text>
)}
</Box>
{message.parts?.some(
(part) => part.type === 'source',
) &&
(() => {
const sourceCount =
message.parts?.filter(
(part) => part.type === 'source',
).length || 0;
return (
<Box
$direction="row"
$align="center"
$gap="4px"
className={`c__button c__button--brand c__button--brand--tertiary c__button--nano ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
onClick={() => openSources(message.id)}
onKeyDown={(e) => {
if (
e.key === 'Enter' ||
e.key === ' '
) {
e.preventDefault();
openSources(message.id);
}
}}
role="button"
tabIndex={0}
>
<Icon
iconName="book"
$theme="greyscale"
$variation="550"
$size="16px"
className="action-chat-button-icon"
/>
<Text
$theme="greyscale"
$variation="550"
$weight="500"
$size="12px"
>
{t('Show')} {sourceCount}{' '}
{sourceCount !== 1
? t('sources')
: t('source')}
</Text>
</Box>
);
})()}
</Box>
<Box $direction="row" $gap="4px">
{/* We should display the button, but disabled if no trace linked */}
{conversationId &&
message.id &&
message.id.startsWith('trace-') && (
<FeedbackButtons
conversationId={conversationId}
messageId={message.id}
/>
)}
</Box>
</Box>
)}
{message.parts &&
isSourceOpen === message.id &&
(() => {
const sourceParts = message.parts.filter(
(part): part is SourceUIPart =>
part.type === 'source',
);
return (
<Box
$css={`
animation: fade-in 0.2s ease-out;
`}
>
<SourceItemList
parts={sourceParts}
getMetadata={getMetadata}
/>
</Box>
);
})()}
</Box>
</Box>
</Box>
);
})}
{messages.map((message, index) => (
<MessageItem
key={message.id}
message={message}
isLastMessage={index === messages.length - 1}
isLastAssistantMessage={
message.role === 'assistant' &&
index === lastAssistantMessageIndex
}
isFirstConversationMessage={isFirstConversationMessage}
streamingMessageHeight={streamingMessageHeight}
status={status}
conversationId={conversationId}
isSourceOpen={isSourceOpen}
isMobile={isMobile}
onCopyToClipboard={copyToClipboard}
onOpenSources={openSources}
getMetadata={getMetadata}
/>
))}
</Box>
)}
{(status !== 'ready' && status !== 'streaming' && status !== 'error') ||
@@ -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<any[]> = 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 }) => (
<Text
as="p"
$css="display: block"
$theme="greyscale"
$variation="850"
{...props}
/>
),
a: ({ children, ...props }) => (
<a target="_blank" {...props}>
{children}
</a>
),
pre: ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
node,
children,
...props
}) => <CodeBlock {...props}>{children}</CodeBlock>,
};
export const CompletedMarkdownBlock = React.memo(
({ content }: { content: string }) => {
const rehypePlugins = use(rehypePluginsPromise);
return (
<MarkdownHooks
remarkPlugins={REMARK_PLUGINS}
rehypePlugins={rehypePlugins}
components={MARKDOWN_COMPONENTS}
>
{content}
</MarkdownHooks>
);
},
(prev, next) => prev.content === next.content,
);
CompletedMarkdownBlock.displayName = 'CompletedMarkdownBlock';
export const RawTextBlock = ({ content }: { content: string }) => (
<Text
as="div"
$css="white-space: pre-wrap; display: block;"
$theme="greyscale"
$variation="850"
>
{content}
</Text>
);
@@ -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 }) => (
<div>
{/* 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) => (
<CompletedMarkdownBlock key={index} content={block} />
))}
{pending && <RawTextBlock content={pending} />}
</div>
),
(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<MessageItemProps> = ({
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 (
<Box
data-message-id={message.id}
data-testid={message.id}
$css={`
display: flex;
width: 100%;
margin: auto;
margin-bottom: ${isLastAssistantMessage ? '30px' : '0px'};
color: var(--c--theme--colors--greyscale-850);
padding-left: 12px;
padding-right: 12px;
max-width: 750px;
text-align: left;
overflow-wrap: anywhere;
flex-direction: ${message.role === 'user' ? 'row-reverse' : 'row'};
`}
>
<Box
$display="block"
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
>
{message.experimental_attachments &&
message.experimental_attachments.length > 0 && (
<Box>
<AttachmentList
attachments={message.experimental_attachments}
isReadOnly={true}
/>
</Box>
)}
<Box
$radius="8px"
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
$maxWidth="100%"
$padding={`${message.role === 'user' ? '12px' : '0'}`}
$margin={{ vertical: 'base' }}
$background={`${message.role === 'user' ? '#EEF1F4' : 'white'}`}
$css={`
display: inline-block;
float: right;
${shouldApplyStreamingHeight ? `min-height: ${streamingMessageHeight}px;` : ''}`}
>
{/* Message content */}
{message.content && (
<Box
className="mainContent-chat"
data-testid={
message.role === 'assistant'
? 'assistant-message-content'
: undefined
}
$padding={{ all: 'xxs' }}
>
<p className="sr-only">
{message.role === 'user'
? t('You said: ')
: t('Assistant IA replied: ')}
</p>
{message.role === 'user' ? (
<Text
as="p"
$css="white-space: pre-wrap; display: block;"
$theme="greyscale"
$variation="850"
>
{message.content}
</Text>
) : (
// Render completed blocks as markdown, pending block as plain text
<BlocksList blocks={completedBlocks} pending={pending} />
)}
</Box>
)}
<Box $direction="column" $gap="2">
{isCurrentlyStreaming &&
isLastAssistantMessage &&
status === 'streaming' &&
hasNonDocumentParsingTool && (
<Box
$direction="row"
$align="center"
$gap="6px"
$width="100%"
$maxWidth="750px"
$margin={{
all: 'auto',
top: 'base',
bottom: 'md',
}}
>
<Loader />
<Text $variation="600" $size="md">
{activeToolInvocation?.toolName === 'summarize'
? t('Summarizing...')
: t('Search...')}
</Text>
</Box>
)}
{toolInvocationParts.map((part, partIndex) =>
isCurrentlyStreaming && isLastAssistantMessage ? (
<ToolInvocationItem
key={`tool-invocation-${partIndex}`}
toolInvocation={part.toolInvocation}
status={status}
hideSearchLoader={true}
/>
) : null,
)}
</Box>
{message.role === 'assistant' &&
!(isLastAssistantMessage && status === 'streaming') && (
<Box
$css="color: #222631; font-size: 12px;"
$direction="row"
$align="center"
$justify="space-between"
$gap="6px"
$margin={{ top: 'base' }}
>
<Box $direction="row" $gap="4px">
<Box
$direction="row"
$align="center"
$gap="4px"
className="c__button--neutral action-chat-button"
onClick={handleCopy}
onKeyDown={handleCopyKeyDown}
role="button"
tabIndex={0}
>
<Icon
iconName="content_copy"
$theme="greyscale"
$variation="550"
$size="16px"
className="action-chat-button-icon"
/>
{!isMobile && (
<Text $theme="greyscale" $variation="550">
{t('Copy')}
</Text>
)}
</Box>
{sourceParts.length > 0 && (
<Box
$direction="row"
$align="center"
$gap="4px"
className={`c__button--neutral action-chat-button ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
onClick={handleOpenSources}
onKeyDown={handleOpenSourcesKeyDown}
role="button"
tabIndex={0}
>
<Icon
iconName="book"
$theme="greyscale"
$variation="550"
$size="16px"
className="action-chat-button-icon"
/>
<Text
$theme="greyscale"
$variation="550"
$weight="500"
$size="12px"
>
{t('Show')} {sourceParts.length}{' '}
{sourceParts.length !== 1 ? t('sources') : t('source')}
</Text>
</Box>
)}
</Box>
<Box $direction="row" $gap="4px">
{conversationId &&
message.id &&
message.id.startsWith('trace-') && (
<FeedbackButtons
conversationId={conversationId}
messageId={message.id}
/>
)}
</Box>
</Box>
)}
{isSourceOpen === message.id && sourceParts.length > 0 && (
<Box
$css={`
animation: fade-in 0.2s ease-out;
`}
>
<SourceItemList parts={sourceParts} getMetadata={getMetadata} />
</Box>
)}
</Box>
</Box>
</Box>
);
};
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);
@@ -23,7 +23,6 @@ const SourceItemListComponent: React.FC<SourceItemListProps> = ({
if (parts.length === 0) {
return null;
}
return (
<Box
$direction="column"
@@ -0,0 +1,171 @@
/* 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 { Suspense } from 'react';
import { CompletedMarkdownBlock, RawTextBlock } from '../MessageBlock';
// Mock react-markdown (ESM module not compatible with Jest)
jest.mock('react-markdown', () => ({
MarkdownHooks: ({ children }: { children: string }) => {
// Simple mock that renders markdown-like content
// This tests the component integration, not the markdown parsing itself
return <div data-testid="markdown-content">{children}</div>;
},
}));
// 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(
<CunninghamProvider>
<Suspense fallback={null}>{ui}</Suspense>
</CunninghamProvider>,
);
};
describe('CompletedMarkdownBlock', () => {
it('renders content through MarkdownHooks', async () => {
await act(async () => {
renderWithProviders(<CompletedMarkdownBlock content="Hello world" />);
});
expect(screen.getByText('Hello world')).toBeInTheDocument();
});
it('passes content to markdown renderer', async () => {
const content = '# Header Some **bold** text';
await act(async () => {
renderWithProviders(<CompletedMarkdownBlock content={content} />);
});
expect(screen.getByTestId('markdown-content')).toHaveTextContent(content);
});
it('does not re-render when content is the same (memoization)', async () => {
let rerender: ReturnType<typeof render>['rerender'];
await act(async () => {
({ rerender } = renderWithProviders(
<CompletedMarkdownBlock content="Same content" />,
));
});
const firstRender = screen.getByTestId('markdown-content');
rerender!(
<CunninghamProvider>
<Suspense fallback={null}>
<CompletedMarkdownBlock content="Same content" />
</Suspense>
</CunninghamProvider>,
);
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<typeof render>['rerender'];
await act(async () => {
({ rerender } = renderWithProviders(
<CompletedMarkdownBlock content="Original content" />,
));
});
expect(screen.getByText('Original content')).toBeInTheDocument();
rerender!(
<CunninghamProvider>
<Suspense fallback={null}>
<CompletedMarkdownBlock content="New content" />
</Suspense>
</CunninghamProvider>,
);
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(
<CompletedMarkdownBlock content="" />,
));
});
expect(container!).toBeInTheDocument();
expect(screen.getByTestId('markdown-content')).toBeInTheDocument();
});
it('handles content with special characters', async () => {
await act(async () => {
renderWithProviders(
<CompletedMarkdownBlock content={`Special chars: < > & " '`} />,
);
});
expect(screen.getByText(/Special chars:/)).toBeInTheDocument();
});
});
describe('RawTextBlock', () => {
it('renders plain text content', () => {
renderWithProviders(<RawTextBlock content="Raw text content" />);
expect(screen.getByText('Raw text content')).toBeInTheDocument();
});
it('preserves whitespace and newlines', () => {
renderWithProviders(
<RawTextBlock content="Line 1\n Indented line\nLine 3" />,
);
const textElement = screen.getByText(/Line 1/);
expect(textElement).toHaveStyle('white-space: pre-wrap');
});
it('renders as a div element', () => {
renderWithProviders(<RawTextBlock content="Test content" />);
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(<RawTextBlock content="**not bold** *not italic*" />);
// Should render the raw markdown syntax, not parsed
expect(screen.getByText('**not bold** *not italic*')).toBeInTheDocument();
});
it('handles empty content', () => {
const { container } = renderWithProviders(<RawTextBlock content="" />);
expect(container).toBeInTheDocument();
});
it('handles content with code fence markers', () => {
renderWithProviders(
<RawTextBlock content="```python\npsquares = [x**2 for x in range(10)]" />,
);
expect(screen.getByText(/```python/)).toBeInTheDocument();
});
});
@@ -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 }) => (
<div data-testid="markdown-content">{children}</div>
),
}));
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: () => <div data-testid="attachment-list" />,
}));
jest.mock('../FeedbackButtons', () => ({
FeedbackButtons: () => <div data-testid="feedback-buttons" />,
}));
jest.mock('../SourceItemList', () => ({
SourceItemList: () => <div data-testid="source-item-list" />,
}));
jest.mock('../ToolInvocationItem', () => ({
ToolInvocationItem: () => <div data-testid="tool-invocation-item" />,
}));
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(
<CunninghamProvider>
<Suspense fallback={null}>{ui}</Suspense>
</CunninghamProvider>,
);
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('rendering', () => {
it('renders assistant message content', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} />);
});
expect(
screen.getByTestId('assistant-message-content'),
).toBeInTheDocument();
});
it('renders user message content', async () => {
await act(async () => {
renderWithProviders(
<MessageItem
{...defaultProps}
message={{ ...defaultProps.message, role: 'user' }}
/>,
);
});
expect(screen.getByText('Hello world')).toBeInTheDocument();
});
it('renders message with data-message-id attribute', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} />);
});
expect(screen.getByTestId('msg-1')).toBeInTheDocument();
});
it('renders copy button for non-streaming assistant messages', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} />);
});
expect(screen.getByText('Copy')).toBeInTheDocument();
});
it('does not render copy button while streaming', async () => {
await act(async () => {
renderWithProviders(
<MessageItem
{...defaultProps}
status="streaming"
isLastAssistantMessage={true}
/>,
);
});
expect(screen.queryByText('Copy')).not.toBeInTheDocument();
});
it('hides copy text on mobile', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} isMobile={true} />);
});
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(
<MessageItem
{...defaultProps}
onCopyToClipboard={onCopyToClipboard}
/>,
);
});
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(
<MessageItem
{...defaultProps}
message={{
...defaultProps.message,
content: 'Block 1\n\nPending content',
}}
status="streaming"
isLastAssistantMessage={true}
/>,
);
});
// 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(
<MessageItem {...defaultProps} message={messageWithAttachments} />,
);
});
expect(screen.getByTestId('attachment-list')).toBeInTheDocument();
});
it('does not render AttachmentList when no attachments', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} />);
});
expect(screen.queryByTestId('attachment-list')).not.toBeInTheDocument();
});
});
describe('feedback buttons', () => {
it('renders FeedbackButtons for trace messages', async () => {
await act(async () => {
renderWithProviders(
<MessageItem
{...defaultProps}
message={{ ...defaultProps.message, id: 'trace-123' }}
/>,
);
});
expect(screen.getByTestId('feedback-buttons')).toBeInTheDocument();
});
it('does not render FeedbackButtons for non-trace messages', async () => {
await act(async () => {
renderWithProviders(<MessageItem {...defaultProps} />);
});
expect(screen.queryByTestId('feedback-buttons')).not.toBeInTheDocument();
});
});
});