diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0017a29..f0333d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,7 +15,7 @@ and this project adheres to
## [0.0.11] - 2026-01-16
### Changed
-
+- 🐛(front) optimize chat
- 📦️(front) update react
- ✨(chat) Generate and edit conversation title
diff --git a/src/frontend/apps/conversations/src/components/Loader.tsx b/src/frontend/apps/conversations/src/components/Loader.tsx
index 609d0d0..b3ba0c3 100644
--- a/src/frontend/apps/conversations/src/components/Loader.tsx
+++ b/src/frontend/apps/conversations/src/components/Loader.tsx
@@ -1,8 +1,9 @@
import dynamic from 'next/dynamic';
-const Lottie = dynamic(() => import('lottie-react'), { ssr: false });
import searchingAnimation from '@/assets/lotties/searching';
+const Lottie = dynamic(() => import('lottie-react'), { ssr: false });
+
export function Loader() {
return (
diff --git a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx
index a620c48..2198a11 100644
--- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx
+++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx
@@ -227,6 +227,10 @@ export const Chat = ({
const stopGeneration = async () => {
stopChat();
+ if (!conversationId) {
+ return;
+ }
+
const response = await fetchAPI(`chats/${conversationId}/stop-streaming/`, {
method: 'POST',
});
diff --git a/src/frontend/apps/conversations/src/features/chat/components/ChatMessage.tsx b/src/frontend/apps/conversations/src/features/chat/components/ChatMessage.tsx
new file mode 100644
index 0000000..c9c463f
--- /dev/null
+++ b/src/frontend/apps/conversations/src/features/chat/components/ChatMessage.tsx
@@ -0,0 +1,386 @@
+import {
+ Message,
+ ReasoningUIPart,
+ SourceUIPart,
+ ToolInvocationUIPart,
+} from '@ai-sdk/ui-utils';
+import 'katex/dist/katex.min.css';
+import { memo, useDeferredValue } from 'react';
+import { useTranslation } from 'react-i18next';
+import { MarkdownHooks } from 'react-markdown';
+import rehypeKatex from 'rehype-katex';
+import rehypePrettyCode from 'rehype-pretty-code';
+import remarkGfm from 'remark-gfm';
+import remarkMath from 'remark-math';
+
+import { Box, Icon, Text } from '@/components';
+import { useClipboard } from '@/hook';
+import { useResponsiveStore } from '@/stores';
+
+import { AttachmentList } from './AttachmentList';
+import { CodeBlock } from './CodeBlock';
+import { FeedbackButtons } from './FeedbackButtons';
+import { SourceItemList } from './SourceItemList';
+import { ToolInvocationItem } from './ToolInvocationItem';
+
+// Mémoriser les plugins Markdown en dehors du composant pour éviter les recréations
+const remarkPlugins = [remarkGfm, remarkMath];
+const rehypePlugins = [
+ [
+ rehypePrettyCode,
+ {
+ theme: 'github-dark-dimmed',
+ },
+ ],
+ rehypeKatex,
+];
+
+// Composants Markdown mémorisés
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const markdownComponents: any = {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
+ p: ({ node, ...props }: any) => (
+
+ ),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ a: ({ children, ...props }: any) => (
+
+ {children}
+
+ ),
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
+ pre: ({ node, children, ...props }: any) => (
+
{children}
+ ),
+};
+
+// Composant Markdown mémorisé pour éviter les recalculs inutiles
+const MemoizedMarkdown = memo(function MemoizedMarkdown({
+ content,
+}: {
+ content: string;
+}) {
+ return (
+
+ {content}
+
+ );
+});
+
+interface ChatMessageProps {
+ message: Message;
+ isLastAssistantMessageInConversation: boolean;
+ shouldApplyStreamingHeight: boolean;
+ streamingMessageHeight: number | null;
+ isCurrentlyStreaming: boolean;
+ status: 'idle' | 'streaming' | 'submitted' | 'ready' | 'error';
+ isSourceOpen: string | null;
+ conversationId: string | undefined;
+ onOpenSources: (messageId: string) => void;
+ getMetadata: (url: string) =>
+ | {
+ title: string | null;
+ favicon: string | null;
+ loading: boolean;
+ error: boolean;
+ }
+ | undefined;
+}
+
+export const ChatMessage = memo(function ChatMessage({
+ message,
+ isLastAssistantMessageInConversation,
+ shouldApplyStreamingHeight,
+ streamingMessageHeight,
+ isCurrentlyStreaming,
+ status,
+ isSourceOpen,
+ conversationId,
+ onOpenSources,
+ getMetadata,
+}: ChatMessageProps) {
+ const { t } = useTranslation();
+ const copyToClipboard = useClipboard();
+ const { isMobile } = useResponsiveStore();
+
+ const deferredContent = useDeferredValue(message.content);
+
+ const contentToRender =
+ message.role === 'assistant' ? deferredContent : message.content;
+
+ return (
+
+
+ {message.experimental_attachments &&
+ message.experimental_attachments.length > 0 && (
+
+
+
+ )}
+
+ {message.content && (
+
+
+ {message.role === 'user'
+ ? t('You said: ')
+ : t('Assistant IA replied: ')}
+
+ {message.role === 'user' ? (
+
+ {message.content}
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {isCurrentlyStreaming &&
+ isLastAssistantMessageInConversation &&
+ status === 'streaming' &&
+ message.parts?.some(
+ (part) =>
+ part.type === 'tool-invocation' &&
+ part.toolInvocation.toolName !== 'document_parsing',
+ ) && (
+
+
+ {(() => {
+ const toolInvocation = message.parts?.find(
+ (part) =>
+ part.type === 'tool-invocation' &&
+ part.toolInvocation.toolName !== 'document_parsing',
+ );
+ if (
+ toolInvocation?.type === 'tool-invocation' &&
+ toolInvocation.toolInvocation.toolName === 'summarize'
+ ) {
+ return t('Summarizing...');
+ }
+ return t('Search...');
+ })()}
+
+
+ )}
+ {message.parts
+ ?.filter(
+ (part) =>
+ part.type === 'reasoning' || part.type === 'tool-invocation',
+ )
+ .map(
+ (
+ part: ReasoningUIPart | ToolInvocationUIPart,
+ partIndex: number,
+ ) =>
+ part.type === 'reasoning' ? (
+
+ {part.reasoning}
+
+ ) : part.type === 'tool-invocation' &&
+ isCurrentlyStreaming &&
+ isLastAssistantMessageInConversation ? (
+
+ ) : null,
+ )}
+
+ {message.role === 'assistant' &&
+ !(
+ isLastAssistantMessageInConversation && status === 'streaming'
+ ) && (
+
+
+ copyToClipboard(message.content)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ copyToClipboard(message.content);
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ >
+
+ {!isMobile && (
+
+ {t('Copy')}
+
+ )}
+
+ {message.parts?.some((part) => part.type === 'source') &&
+ (() => {
+ const sourceCount =
+ message.parts?.filter((part) => part.type === 'source')
+ .length || 0;
+ return (
+ onOpenSources(message.id)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onOpenSources(message.id);
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ >
+
+
+ {t('Show')} {sourceCount}{' '}
+ {sourceCount !== 1 ? t('sources') : t('source')}
+
+
+ );
+ })()}
+
+
+ {conversationId &&
+ message.id &&
+ message.id.startsWith('trace-') && (
+
+ )}
+
+
+ )}
+ {message.parts &&
+ isSourceOpen === message.id &&
+ (() => {
+ const sourceParts = message.parts.filter(
+ (part): part is SourceUIPart => part.type === 'source',
+ );
+ return (
+
+
+
+ );
+ })()}
+
+
+
+ );
+});
diff --git a/src/frontend/apps/conversations/src/features/chat/hooks/index.ts b/src/frontend/apps/conversations/src/features/chat/hooks/index.ts
index ec6a610..45845b9 100644
--- a/src/frontend/apps/conversations/src/features/chat/hooks/index.ts
+++ b/src/frontend/apps/conversations/src/features/chat/hooks/index.ts
@@ -1,2 +1,3 @@
export { useChatScroll } from './useChatScroll';
export { useSourceMetadataCache } from './useSourceMetadata';
+export { useModelSelection } from './useModelSelection';
diff --git a/src/frontend/apps/conversations/src/features/chat/hooks/useModelSelection.ts b/src/frontend/apps/conversations/src/features/chat/hooks/useModelSelection.ts
new file mode 100644
index 0000000..0d21cba
--- /dev/null
+++ b/src/frontend/apps/conversations/src/features/chat/hooks/useModelSelection.ts
@@ -0,0 +1,44 @@
+import { useEffect, useRef, useState } from 'react';
+
+import { LLMModel, useLLMConfiguration } from '../api/useLLMConfiguration';
+import { useChatPreferencesStore } from '../stores/useChatPreferencesStore';
+
+export const useModelSelection = () => {
+ const { data: llmConfig } = useLLMConfiguration();
+ const { selectedModelHrid, setSelectedModelHrid } = useChatPreferencesStore();
+ const [selectedModel, setSelectedModel] = useState
(null);
+ const hasInitializedRef = useRef(false);
+
+ useEffect(() => {
+ // Ne s'exécuter qu'une seule fois quand llmConfig est chargé
+ if (llmConfig?.models && !hasInitializedRef.current) {
+ let modelToSelect: LLMModel | undefined;
+
+ if (selectedModelHrid) {
+ // Try to find the previously selected model
+ modelToSelect = llmConfig.models.find(
+ (model) =>
+ model.hrid === selectedModelHrid && model.is_active !== false,
+ );
+ }
+
+ // If no saved model or saved model not found/inactive, use default
+ if (!modelToSelect) {
+ modelToSelect = llmConfig.models.find((model) => model.is_default);
+ }
+
+ if (modelToSelect) {
+ setSelectedModel(modelToSelect);
+ setSelectedModelHrid(modelToSelect.hrid);
+ hasInitializedRef.current = true;
+ }
+ }
+ }, [llmConfig?.models, selectedModelHrid, setSelectedModelHrid]);
+
+ const handleModelSelect = (model: LLMModel) => {
+ setSelectedModel(model);
+ setSelectedModelHrid(model.hrid);
+ };
+
+ return { selectedModel, handleModelSelect };
+};