import { useTranslation } from 'react-i18next'; import { memo, useCallback, useMemo } from 'react'; import type { Dispatch, RefObject, SetStateAction } from 'react'; import type { ChatMessage } from '../../types/types'; import type { Project, ProjectSession, LLMProvider, ProviderModelsDefinition, } from '../../../../types/app'; import { getIntrinsicMessageKey } from '../../utils/messageKeys'; import { groupConsecutiveTools, isToolGroupItem } from '../../utils/toolGrouping'; import MessageComponent from './MessageComponent'; import ProviderSelectionEmptyState from './ProviderSelectionEmptyState'; import ToolGroupContainer from './ToolGroupContainer'; import LoadAllMessagesOverlay from './LoadAllMessagesOverlay'; interface ChatMessagesPaneProps { scrollContainerRef: RefObject; onWheel: () => void; onTouchMove: () => void; isLoadingSessionMessages: boolean; /** True while the viewed session has an active provider run in flight. */ isProcessing?: boolean; chatMessages: ChatMessage[]; selectedSession: ProjectSession | null; currentSessionId: string | null; provider: LLMProvider; setProvider: (provider: LLMProvider) => void; textareaRef: RefObject; claudeModel: string; setClaudeModel: (model: string) => void; cursorModel: string; setCursorModel: (model: string) => void; codexModel: string; setCodexModel: (model: string) => void; geminiModel: string; setGeminiModel: (model: string) => void; opencodeModel: string; setOpenCodeModel: (model: string) => void; providerModelCatalog: Partial>; providerModelsLoading: boolean; tasksEnabled: boolean; isTaskMasterInstalled: boolean | null; onShowAllTasks?: (() => void) | null; setInput: Dispatch>; isLoadingMoreMessages: boolean; hasMoreMessages: boolean; totalMessages: number; sessionMessagesCount: number; visibleMessageCount: number; visibleMessages: ChatMessage[]; loadEarlierMessages: () => void; loadAllMessages: () => void; allMessagesLoaded: boolean; isLoadingAllMessages: boolean; loadAllJustFinished: boolean; showLoadAllOverlay: boolean; createDiff: any; onFileOpen?: (filePath: string, diffInfo?: unknown) => void; onShowSettings?: () => void; onGrantToolPermission: (suggestion: { entry: string; toolName: string }) => { success: boolean }; showRawParameters?: boolean; showThinking?: boolean; selectedProject: Project; } function ChatMessagesPane({ scrollContainerRef, onWheel, onTouchMove, isLoadingSessionMessages, isProcessing = false, chatMessages, selectedSession, currentSessionId, provider, setProvider, textareaRef, claudeModel, setClaudeModel, cursorModel, setCursorModel, codexModel, setCodexModel, geminiModel, setGeminiModel, opencodeModel, setOpenCodeModel, providerModelCatalog, providerModelsLoading, tasksEnabled, isTaskMasterInstalled, onShowAllTasks, setInput, isLoadingMoreMessages, hasMoreMessages, totalMessages, sessionMessagesCount, visibleMessageCount, visibleMessages, loadEarlierMessages, loadAllMessages, allMessagesLoaded, isLoadingAllMessages, loadAllJustFinished, showLoadAllOverlay, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, showRawParameters, showThinking, selectedProject, }: ChatMessagesPaneProps) { const { t } = useTranslation('chat'); const groupedVisibleMessages = useMemo( () => groupConsecutiveTools(visibleMessages, Boolean(showThinking)), [visibleMessages, showThinking], ); // Stable, deterministic keys for the messages rendered this pass. // // `normalizedToChatMessages` rebuilds fresh ChatMessage objects on every store // update, so caching keys by object identity (or via a cross-render allocation // Set) minted a brand-new key for the *same* logical message on each prepend — // remounting the whole list, which disconnects the scroll-restore anchor and // reflows heights, jumping the viewport to the bottom. Deriving keys purely // from this render's ordered messages (intrinsic key, disambiguated by // occurrence index on collision) yields the same key for the same message // order, so React preserves existing DOM nodes and component state on prepend. const messageKeyMap = useMemo(() => { const keys = new WeakMap(); const occurrences = new Map(); const assign = (message: ChatMessage) => { const intrinsicKey = getIntrinsicMessageKey(message) ?? 'message-generated'; const seen = occurrences.get(intrinsicKey) ?? 0; occurrences.set(intrinsicKey, seen + 1); keys.set(message, seen === 0 ? intrinsicKey : `${intrinsicKey}__${seen}`); }; for (const item of groupedVisibleMessages) { if (isToolGroupItem(item)) { item.messages.forEach(assign); } else { assign(item); } } return keys; }, [groupedVisibleMessages]); const getMessageKey = useCallback( (message: ChatMessage) => messageKeyMap.get(message) ?? getIntrinsicMessageKey(message) ?? 'message-generated', [messageKeyMap], ); return (
{(isLoadingSessionMessages || isProcessing) && chatMessages.length === 0 ? (

{t('session.loading.sessionMessages')}

) : chatMessages.length === 0 ? ( ) : ( <> {/* Loading indicator for older messages (hide when load-all is active) */} {isLoadingMoreMessages && !isLoadingAllMessages && !allMessagesLoaded && (

{t('session.loading.olderMessages')}

)} {/* Indicator showing there are more messages to load (hide when all loaded) */} {hasMoreMessages && !isLoadingMoreMessages && !allMessagesLoaded && (
{totalMessages > 0 && ( {t('session.messages.showingOf', { shown: sessionMessagesCount, total: totalMessages })}{' '} {t('session.messages.scrollToLoad')} )}
)} {/* Legacy message count indicator (for non-paginated view) */} {!hasMoreMessages && chatMessages.length > visibleMessageCount && (
{t('session.messages.showingLast', { count: visibleMessageCount, total: chatMessages.length })} | {' | '}
)} {(() => { let prevMessage: ChatMessage | null = null; return groupedVisibleMessages.map((item) => { if (isToolGroupItem(item)) { const groupPrevMessage = prevMessage; prevMessage = item.messages[item.messages.length - 1] || prevMessage; return ( ); } const messagePrevMessage = prevMessage; prevMessage = item; return ( ); }); })()} )}
); } export default memo(ChatMessagesPane);