import React from 'react'; import type { Message, Part } from '@opencode-ai/sdk/v2'; import ChatMessage from './ChatMessage'; import { PermissionCard } from './PermissionCard'; import { QuestionCard } from './QuestionCard'; import type { PermissionRequest } from '@/types/permission'; import type { QuestionRequest } from '@/types/question'; import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager'; import { filterSyntheticParts } from '@/lib/messages/synthetic'; import { detectTurns, type Turn } from './hooks/useTurnGrouping'; import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext'; interface ChatMessageEntry { info: Message; parts: Part[]; } interface MessageListProps { messages: ChatMessageEntry[]; permissions: PermissionRequest[]; questions: QuestionRequest[]; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; hasMoreAbove: boolean; isLoadingOlder: boolean; onLoadOlder: () => void; hasRenderEarlier?: boolean; onRenderEarlier?: () => void; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; scrollRef?: React.RefObject; } interface MessageRowProps { message: ChatMessageEntry; onContentChange: (reason?: ContentChangeReason) => void; animationHandlers: AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; } // Static MessageRow - does NOT subscribe to dynamic context // Used for messages NOT in the last turn - no re-renders during streaming const StaticMessageRow = React.memo(({ message, onContentChange, animationHandlers, scrollToBottom, }) => { const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id); const turnGroupingContext = useTurnGroupingContextStatic(message.info.id); return ( ); }); StaticMessageRow.displayName = 'StaticMessageRow'; // Dynamic MessageRow - subscribes to dynamic context for streaming state // Used for messages in the LAST turn only const DynamicMessageRow = React.memo(({ message, onContentChange, animationHandlers, scrollToBottom, }) => { const { previousMessage, nextMessage } = useMessageNeighbors(message.info.id); const turnGroupingContext = useTurnGroupingContextForMessage(message.info.id); return ( ); }); DynamicMessageRow.displayName = 'DynamicMessageRow'; interface TurnBlockProps { turn: Turn; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; } const TurnBlock: React.FC = ({ turn, onMessageContentChange, getAnimationHandlers, scrollToBottom, }) => { const lastTurnMessageIds = useLastTurnMessageIds(); const renderMessage = React.useCallback( (message: ChatMessageEntry) => { const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role; const isInLastTurn = role !== 'user' && lastTurnMessageIds.has(message.info.id); const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow; return ( ); }, [getAnimationHandlers, lastTurnMessageIds, onMessageContentChange, scrollToBottom] ); return (
{renderMessage(turn.userMessage)}
{turn.assistantMessages.map((message) => renderMessage(message))}
); }; TurnBlock.displayName = 'TurnBlock'; // Inner component that renders messages with access to context hooks const MessageListContent: React.FC<{ turns: Turn[]; ungroupedMessages: ChatMessageEntry[]; onMessageContentChange: (reason?: ContentChangeReason) => void; getAnimationHandlers: (messageId: string) => AnimationHandlers; scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void; }> = ({ turns, ungroupedMessages, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => { const lastTurnMessageIds = useLastTurnMessageIds(); const renderUngroupedMessage = React.useCallback( (message: ChatMessageEntry) => { const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role; const isInLastTurn = role !== 'user' && lastTurnMessageIds.has(message.info.id); const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow; return ( ); }, [getAnimationHandlers, lastTurnMessageIds, onMessageContentChange, scrollToBottom] ); return ( <> {ungroupedMessages.map((message) => renderUngroupedMessage(message))} {turns.map((turn) => ( ))} ); }; const MessageList: React.FC = ({ messages, permissions, questions, onMessageContentChange, getAnimationHandlers, hasMoreAbove, isLoadingOlder, onLoadOlder, hasRenderEarlier, onRenderEarlier, scrollToBottom, }) => { React.useEffect(() => { if (permissions.length === 0 && questions.length === 0) { return; } onMessageContentChange('permission'); }, [permissions, questions, onMessageContentChange]); const displayMessages = React.useMemo(() => { const seenIds = new Set(); return messages .filter((message) => { const messageId = message.info?.id; if (typeof messageId === 'string') { if (seenIds.has(messageId)) { return false; } seenIds.add(messageId); } return true; }) .map((message) => { const filteredParts = filterSyntheticParts(message.parts); // Optimization: If parts haven't changed, return the original message object. // This preserves referential equality and prevents unnecessary re-renders of ChatMessage (which is memoized). if (filteredParts === message.parts) { return message; } return { ...message, parts: filteredParts, }; }); }, [messages]); const { turns, ungroupedMessages } = React.useMemo(() => { const groupedTurns = detectTurns(displayMessages); const groupedMessageIds = new Set(); groupedTurns.forEach((turn) => { groupedMessageIds.add(turn.userMessage.info.id); turn.assistantMessages.forEach((message) => { groupedMessageIds.add(message.info.id); }); }); const ungrouped = displayMessages.filter((message) => !groupedMessageIds.has(message.info.id)); return { turns: groupedTurns, ungroupedMessages: ungrouped, }; }, [displayMessages]); return (
{hasRenderEarlier && (
)} {hasMoreAbove && (
{isLoadingOlder ? ( Loading… ) : ( )}
)} {(questions.length > 0 || permissions.length > 0) && (
{questions.map((question) => ( ))} {permissions.map((permission) => ( ))}
)} {/* Bottom spacer - always 10% of viewport height */} ); }; export default React.memo(MessageList);