⚡️(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:
@@ -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"
|
||||
|
||||
+171
@@ -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();
|
||||
});
|
||||
});
|
||||
+536
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user