A React Native app for the ultimate thinking partner.

feat: integrate unified message grouping into ChatScreen (Phase 3)

Replaced fragmented message rendering with unified MessageGroup system.
Messages with same ID (reasoning + assistant, tool_call + tool_return) now
render as single cohesive components instead of separate FlatList items.

Integration Changes:
- Imported useMessageGroups hook and MessageGroupBubble component
- Replaced displayMessages useMemo with useMessageGroups() call
- Passing currentStream from chatStore as streamingState
- Updated FlatList data source from messages to messageGroups
- Changed keyExtractor to use group.groupKey (unique per group type)
- Updated renderItem to renderMessageGroup with isLastGroup detection
- Changed hasMessages check to use messageGroups.length

Benefits:
- Reasoning and content always render together (no fragmentation)
- Tool calls and returns paired automatically
- Streaming appears as temporary group (id='streaming'), replaced on server refresh
- Fewer FlatList items (one per logical message turn vs one per message type)
- Cleaner scroll behavior (no reasoning/assistant separation)

Architecture:
- Non-breaking: MessageBubbleEnhanced still exists but unused
- Type-safe: Full MessageGroup typing throughout
- Backward compatible: Same interaction props (expandedReasoning, etc.)

Testing:
- Hot reload will pick up changes
- Existing messages should group correctly
- Streaming should append temporary group at end
- Server refresh replaces streaming group with real messages

Phase 3 complete. Next: Phase 4 (verify with real data, remove old code).

+39 -55
+39 -55
src/screens/ChatScreen.tsx
··· 14 14 import { useChatStore } from '../stores/chatStore'; 15 15 import { useMessageInteractions } from '../hooks/useMessageInteractions'; 16 16 import { useScrollToBottom } from '../hooks/useScrollToBottom'; 17 + import { useMessageGroups } from '../hooks/useMessageGroups'; 17 18 18 - import MessageBubbleEnhanced from '../components/MessageBubble.enhanced'; 19 + import MessageGroupBubble from '../components/MessageGroupBubble'; 19 20 import MessageInputEnhanced from '../components/MessageInputEnhanced'; 20 21 21 22 interface ChatScreenProps { ··· 48 49 animated: false, 49 50 }); 50 51 51 - // Filter and sort messages for display 52 - const displayMessages = React.useMemo(() => { 53 - // Sort messages chronologically 54 - const sorted = [...messages].sort((a, b) => { 55 - const timeA = new Date(a.created_at || 0).getTime(); 56 - const timeB = new Date(b.created_at || 0).getTime(); 57 - return timeA - timeB; 58 - }); 59 - 60 - // Filter out system messages and login/heartbeat messages 61 - return sorted.filter(msg => { 62 - if (msg.message_type === 'system_message') return false; 63 - 64 - if (msg.message_type === 'user_message' && msg.content) { 65 - try { 66 - const contentStr = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content); 67 - const parsed = JSON.parse(contentStr); 68 - if (parsed?.type === 'login' || parsed?.type === 'heartbeat') { 69 - return false; 70 - } 71 - } catch { 72 - // Not JSON, keep the message 73 - } 74 - } 75 - 76 - return true; 77 - }); 78 - }, [messages]); 79 - 80 - // Chat store for images 52 + // Chat store for images and streaming state 81 53 const selectedImages = useChatStore((state) => state.selectedImages); 82 54 const addImage = useChatStore((state) => state.addImage); 83 55 const removeImage = useChatStore((state) => state.removeImage); 84 56 const lastMessageNeedsSpace = useChatStore((state) => state.lastMessageNeedsSpace); 57 + const currentStream = useChatStore((state) => state.currentStream); 58 + 59 + // Group messages by ID (reasoning + assistant, tool_call + tool_return, etc.) 60 + const messageGroups = useMessageGroups({ 61 + messages, 62 + isStreaming, 63 + streamingState: currentStream, 64 + }); 85 65 86 66 // Animation refs and layout 87 67 const spacerHeightAnim = useRef(new Animated.Value(0)).current; ··· 93 73 scrollToBottom(true); // Animate scroll when sending 94 74 }; 95 75 96 - // Render message item 97 - const renderMessage = ({ item }: { item: any }) => ( 98 - <MessageBubbleEnhanced 99 - message={item} 100 - displayMessages={displayMessages} 101 - theme={theme} 102 - colorScheme={colorScheme} 103 - expandedReasoning={expandedReasoning} 104 - expandedCompaction={expandedCompaction} 105 - expandedToolReturns={expandedToolReturns} 106 - copiedMessageId={copiedMessageId} 107 - showCompaction={showCompaction} 108 - toggleReasoning={toggleReasoning} 109 - toggleCompaction={toggleCompaction} 110 - toggleToolReturn={toggleToolReturn} 111 - copyToClipboard={copyToClipboard} 112 - lastMessageNeedsSpace={lastMessageNeedsSpace} 113 - containerHeight={containerHeight} 114 - /> 115 - ); 76 + // Render message group 77 + const renderMessageGroup = ({ item, index }: { item: any; index: number }) => { 78 + // Determine if this is the last group (for spacing) 79 + const isLastGroup = index === messageGroups.length - 1; 80 + 81 + return ( 82 + <MessageGroupBubble 83 + group={item} 84 + theme={theme} 85 + colorScheme={colorScheme} 86 + expandedReasoning={expandedReasoning} 87 + expandedCompaction={expandedCompaction} 88 + expandedToolReturns={expandedToolReturns} 89 + copiedMessageId={copiedMessageId} 90 + showCompaction={showCompaction} 91 + toggleReasoning={toggleReasoning} 92 + toggleCompaction={toggleCompaction} 93 + toggleToolReturn={toggleToolReturn} 94 + copyToClipboard={copyToClipboard} 95 + lastMessageNeedsSpace={isLastGroup && lastMessageNeedsSpace} 96 + containerHeight={containerHeight} 97 + /> 98 + ); 99 + }; 116 100 117 101 return ( 118 102 <KeyboardAvoidingView ··· 124 108 {/* Messages List */} 125 109 <FlatList 126 110 ref={scrollViewRef} 127 - data={displayMessages} 128 - renderItem={renderMessage} 129 - keyExtractor={(item) => `${item.id}-${item.message_type}`} 111 + data={messageGroups} 112 + renderItem={renderMessageGroup} 113 + keyExtractor={(group) => group.groupKey} 130 114 contentContainerStyle={[ 131 115 styles.messagesList, 132 116 { paddingBottom: insets.bottom + 80 }, ··· 153 137 isSendingMessage={isSendingMessage || isLoadingMessages} 154 138 theme={theme} 155 139 colorScheme={colorScheme} 156 - hasMessages={displayMessages.length > 0} 140 + hasMessages={messageGroups.length > 0} 157 141 isStreaming={isStreaming} 158 142 hasExpandedReasoning={expandedReasoning.size > 0} 159 143 selectedImages={selectedImages}