A React Native app for the ultimate thinking partner.

refactor: Phase 4 cleanup - remove old message rendering code and simplify streaming

Phase 4 completes the message grouping refactor by removing obsolete code
and adding comprehensive documentation.

Changes:
- Delete MessageBubble.enhanced.tsx (400+ lines) - replaced by MessageGroupBubble
- Remove completedStreamBlocks from chatStore (50+ lines of complex state logic)
- Simplify streaming accumulation - just append chunks, useMessageGroups handles pairing
- Add comprehensive documentation to useMessageGroups hook
- Add clarifying comments to ChatScreen message grouping integration

Benefits:
- Simpler streaming state management (no manual block completion tracking)
- Reasoning/assistant pairing happens in pure transformation hook
- Clearer separation of concerns (state accumulation vs rendering logic)
- Better documentation for future maintainers

Related: Phase 1-3 created useMessageGroups hook and MessageGroupBubble component

+46 -489
-402
src/components/MessageBubble.enhanced.tsx
··· 1 - /** 2 - * Enhanced MessageBubble Component 3 - * 4 - * Complete message rendering with all features from the original app: 5 - * - Reasoning messages (expandable) 6 - * - Tool calls with paired tool returns 7 - * - Orphaned tool returns 8 - * - Compaction bars 9 - * - User messages with images 10 - * - Assistant messages with copy button 11 - * - Expandable content 12 - * 13 - * This replaces MessageBubble.v2 with full feature parity. 14 - */ 15 - 16 - import React from 'react'; 17 - import { View, Text, TouchableOpacity, StyleSheet, Image, Platform } from 'react-native'; 18 - import { Ionicons } from '@expo/vector-icons'; 19 - import type { LettaMessage } from '../types/letta'; 20 - import type { Theme } from '../theme'; 21 - 22 - // Import sub-components 23 - import ReasoningToggle from './ReasoningToggle'; 24 - import ToolCallItem from './ToolCallItem'; 25 - import MessageContent from './MessageContent'; 26 - import ExpandableMessageContent from './ExpandableMessageContent'; 27 - import CompactionBar from './CompactionBar'; 28 - import OrphanedToolReturn from './OrphanedToolReturn'; 29 - 30 - interface MessageBubbleEnhancedProps { 31 - message: LettaMessage; 32 - displayMessages: LettaMessage[]; // Needed for tool call/return pairing 33 - theme: Theme; 34 - colorScheme: 'light' | 'dark'; 35 - 36 - // Interaction state 37 - expandedReasoning: Set<string>; 38 - expandedCompaction: Set<string>; 39 - expandedToolReturns: Set<string>; 40 - copiedMessageId: string | null; 41 - showCompaction: boolean; 42 - 43 - // Interaction handlers 44 - toggleReasoning: (messageId: string) => void; 45 - toggleCompaction: (messageId: string) => void; 46 - toggleToolReturn: (messageId: string) => void; 47 - copyToClipboard: (content: string, messageId: string) => void; 48 - 49 - // Layout props 50 - lastMessageNeedsSpace?: boolean; 51 - containerHeight?: number; 52 - } 53 - 54 - export const MessageBubbleEnhanced: React.FC<MessageBubbleEnhancedProps> = ({ 55 - message, 56 - displayMessages, 57 - theme, 58 - colorScheme, 59 - expandedReasoning, 60 - expandedCompaction, 61 - expandedToolReturns, 62 - copiedMessageId, 63 - showCompaction, 64 - toggleReasoning, 65 - toggleCompaction, 66 - toggleToolReturn, 67 - copyToClipboard, 68 - lastMessageNeedsSpace = false, 69 - containerHeight = 0, 70 - }) => { 71 - const isDark = colorScheme === 'dark'; 72 - const msg = message; 73 - 74 - // Determine message type 75 - const isUser = msg.message_type === 'user_message'; 76 - const isSystem = msg.message_type === 'system_message'; 77 - const isToolCall = msg.message_type === 'tool_call_message'; 78 - const isToolReturn = msg.message_type === 'tool_return_message'; 79 - const isAssistant = msg.message_type === 'assistant_message'; 80 - const isReasoning = msg.message_type === 'reasoning_message'; 81 - 82 - // Filter out system messages 83 - if (isSystem) return null; 84 - 85 - // ==================== 86 - // REASONING MESSAGE 87 - // ==================== 88 - if (isReasoning) { 89 - const isExpanded = expandedReasoning.has(msg.id); 90 - return ( 91 - <View style={styles.messageContainer}> 92 - <ReasoningToggle 93 - reasoning={msg.reasoning || ''} 94 - messageId={msg.id} 95 - isExpanded={isExpanded} 96 - onToggle={() => toggleReasoning(msg.id)} 97 - isDark={isDark} 98 - /> 99 - </View> 100 - ); 101 - } 102 - 103 - // ==================== 104 - // TOOL CALL MESSAGE 105 - // ==================== 106 - if (isToolCall) { 107 - // Find the corresponding tool return (next message in the list) 108 - const msgIndex = displayMessages.findIndex((m) => m.id === msg.id); 109 - const nextMsg = 110 - msgIndex >= 0 && msgIndex < displayMessages.length - 1 111 - ? displayMessages[msgIndex + 1] 112 - : null; 113 - const toolReturn = 114 - nextMsg && nextMsg.message_type === 'tool_return_message' ? nextMsg : null; 115 - 116 - return ( 117 - <View style={styles.messageContainer}> 118 - <ToolCallItem 119 - callText={msg.content} 120 - resultText={toolReturn?.content} 121 - reasoning={msg.reasoning} 122 - hasResult={!!toolReturn} 123 - isDark={isDark} 124 - /> 125 - </View> 126 - ); 127 - } 128 - 129 - // ==================== 130 - // TOOL RETURN MESSAGE (Orphaned) 131 - // ==================== 132 - if (isToolReturn) { 133 - // Check if previous message is a tool call 134 - const msgIndex = displayMessages.findIndex((m) => m.id === msg.id); 135 - const prevMsg = msgIndex > 0 ? displayMessages[msgIndex - 1] : null; 136 - if (prevMsg && prevMsg.message_type === 'tool_call_message') { 137 - return null; // Already rendered with the tool call 138 - } 139 - 140 - // Orphaned tool return - render it standalone 141 - const isExpanded = expandedToolReturns.has(msg.id); 142 - return ( 143 - <View style={styles.messageContainer}> 144 - <OrphanedToolReturn 145 - content={msg.content} 146 - messageId={msg.id} 147 - isExpanded={isExpanded} 148 - onToggle={() => toggleToolReturn(msg.id)} 149 - theme={theme} 150 - isDark={isDark} 151 - /> 152 - </View> 153 - ); 154 - } 155 - 156 - // ==================== 157 - // USER MESSAGE 158 - // ==================== 159 - if (isUser) { 160 - // Check if this is a compaction alert 161 - let isCompactionAlert = false; 162 - let compactionMessage = ''; 163 - 164 - try { 165 - const parsed = JSON.parse(msg.content); 166 - if (parsed?.type === 'system_alert') { 167 - isCompactionAlert = true; 168 - 169 - // Extract the message field from the embedded JSON 170 - const messageText = parsed.message || ''; 171 - 172 - // Try to extract JSON from the message (usually in a code block) 173 - const jsonMatch = messageText.match(/```json\s*(\{[\s\S]*?\})\s*```/); 174 - if (jsonMatch) { 175 - try { 176 - const innerJson = JSON.parse(jsonMatch[1]); 177 - compactionMessage = innerJson.message || messageText; 178 - } catch { 179 - compactionMessage = messageText; 180 - } 181 - } else { 182 - compactionMessage = messageText; 183 - } 184 - 185 - // Strip out the "Note: prior messages..." preamble 186 - compactionMessage = compactionMessage.replace( 187 - /^Note: prior messages have been hidden from view.*?The following is a summary of the previous messages:\s*/is, 188 - '' 189 - ); 190 - } 191 - } catch { 192 - // Not JSON, treat as normal user message 193 - } 194 - 195 - // Render compaction bar 196 - if (isCompactionAlert) { 197 - // Hide compaction if user has disabled it in settings 198 - if (!showCompaction) { 199 - return null; 200 - } 201 - 202 - const isExpanded = expandedCompaction.has(msg.id); 203 - return ( 204 - <CompactionBar 205 - message={compactionMessage} 206 - messageId={msg.id} 207 - isExpanded={isExpanded} 208 - onToggle={() => toggleCompaction(msg.id)} 209 - theme={theme} 210 - /> 211 - ); 212 - } 213 - 214 - // Parse message content to check for multipart (images) 215 - let textContent: string = ''; 216 - let imageContent: Array<{ 217 - type: string; 218 - source: { type: string; data: string; mediaType?: string; media_type?: string; url?: string }; 219 - }> = []; 220 - 221 - if (typeof msg.content === 'object' && Array.isArray(msg.content)) { 222 - // Multipart message with images 223 - const contentArray = msg.content as any[]; 224 - imageContent = contentArray.filter((item: any) => item.type === 'image'); 225 - const textParts = contentArray.filter((item: any) => item.type === 'text'); 226 - textContent = textParts 227 - .map((item: any) => item.text || '') 228 - .filter((t: string) => t) 229 - .join('\n'); 230 - } else if (typeof msg.content === 'string') { 231 - textContent = msg.content; 232 - } else { 233 - // Fallback: convert to string 234 - textContent = String(msg.content || ''); 235 - } 236 - 237 - // Skip rendering if no content at all 238 - if (!textContent.trim() && imageContent.length === 0) { 239 - return null; 240 - } 241 - 242 - // Render user message bubble 243 - return ( 244 - <View style={[styles.messageContainer, styles.userMessageContainer]}> 245 - <View 246 - style={[ 247 - styles.messageBubble, 248 - styles.userBubble, 249 - { 250 - backgroundColor: isDark ? '#FFFFFF' : '#000000', 251 - }, 252 - ]} 253 - > 254 - {/* Display images */} 255 - {imageContent.length > 0 && ( 256 - <View style={styles.messageImagesContainer}> 257 - {imageContent.map((img: any, idx: number) => { 258 - const uri = 259 - img.source.type === 'url' 260 - ? img.source.url 261 - : `data:${img.source.media_type || img.source.mediaType};base64,${img.source.data}`; 262 - 263 - return ( 264 - <Image key={idx} source={{ uri }} style={styles.messageImage} /> 265 - ); 266 - })} 267 - </View> 268 - )} 269 - 270 - {/* Display text content */} 271 - {textContent.trim().length > 0 && ( 272 - <ExpandableMessageContent 273 - content={textContent} 274 - isUser={isUser} 275 - isDark={isDark} 276 - lineLimit={3} 277 - /> 278 - )} 279 - </View> 280 - </View> 281 - ); 282 - } 283 - 284 - // ==================== 285 - // ASSISTANT MESSAGE 286 - // ==================== 287 - const isReasoningExpanded = expandedReasoning.has(msg.id); 288 - const isLastMessage = displayMessages[displayMessages.length - 1]?.id === msg.id; 289 - const shouldHaveMinHeight = isLastMessage && lastMessageNeedsSpace; 290 - 291 - return ( 292 - <View 293 - style={[ 294 - styles.assistantFullWidthContainer, 295 - shouldHaveMinHeight && { minHeight: Math.max(containerHeight * 0.9, 450) }, 296 - ]} 297 - > 298 - {/* Reasoning toggle */} 299 - {msg.reasoning && ( 300 - <ReasoningToggle 301 - reasoning={msg.reasoning} 302 - messageId={msg.id} 303 - isExpanded={isReasoningExpanded} 304 - onToggle={() => toggleReasoning(msg.id)} 305 - isDark={isDark} 306 - /> 307 - )} 308 - 309 - {/* "(co said)" label */} 310 - <Text style={[styles.assistantLabel, { color: theme.colors.text.primary }]}> 311 - (co said) 312 - </Text> 313 - 314 - {/* Message content with copy button */} 315 - <View style={{ position: 'relative' }}> 316 - <ExpandableMessageContent 317 - content={msg.content} 318 - isUser={false} 319 - isDark={isDark} 320 - lineLimit={20} 321 - /> 322 - 323 - {/* Copy button (absolute positioned overlay) */} 324 - <View style={styles.copyButtonContainer}> 325 - <TouchableOpacity 326 - onPress={() => copyToClipboard(msg.content, msg.id)} 327 - style={styles.copyButton} 328 - activeOpacity={0.7} 329 - testID="copy-button" 330 - > 331 - <Ionicons 332 - name={copiedMessageId === msg.id ? 'checkmark-outline' : 'copy-outline'} 333 - size={16} 334 - color={ 335 - copiedMessageId === msg.id 336 - ? theme.colors.interactive.primary 337 - : theme.colors.text.tertiary 338 - } 339 - /> 340 - </TouchableOpacity> 341 - </View> 342 - </View> 343 - </View> 344 - ); 345 - }; 346 - 347 - const styles = StyleSheet.create({ 348 - messageContainer: { 349 - marginVertical: 8, 350 - paddingHorizontal: 18, 351 - }, 352 - userMessageContainer: { 353 - alignItems: 'flex-end', 354 - }, 355 - messageBubble: { 356 - maxWidth: '80%', 357 - borderRadius: 20, 358 - paddingHorizontal: 16, 359 - paddingVertical: 12, 360 - }, 361 - userBubble: { 362 - borderBottomRightRadius: 6, 363 - ...Platform.select({ 364 - web: { 365 - // @ts-ignore - web-only 366 - boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', 367 - }, 368 - }), 369 - }, 370 - messageImagesContainer: { 371 - marginBottom: 8, 372 - }, 373 - messageImage: { 374 - width: 240, 375 - height: 240, 376 - borderRadius: 12, 377 - marginBottom: 8, 378 - }, 379 - assistantFullWidthContainer: { 380 - paddingHorizontal: 18, 381 - paddingVertical: 16, 382 - width: '100%', 383 - }, 384 - assistantLabel: { 385 - fontSize: 16, 386 - fontFamily: 'Lexend_500Medium', 387 - marginBottom: 8, 388 - }, 389 - copyButtonContainer: { 390 - position: 'absolute', 391 - top: 0, 392 - right: 0, 393 - zIndex: 10, 394 - }, 395 - copyButton: { 396 - padding: 8, 397 - opacity: 0.3, 398 - borderRadius: 4, 399 - }, 400 - }); 401 - 402 - export default MessageBubbleEnhanced;
+17 -9
src/hooks/useMessageGroups.ts
··· 1 1 /** 2 2 * useMessageGroups Hook 3 3 * 4 - * Groups raw Letta messages by ID to create unified message groups. 4 + * Transforms raw Letta messages into unified MessageGroup objects for rendering. 5 5 * 6 - * Grouping rules: 7 - * - Messages with same ID are grouped (e.g., reasoning + assistant) 8 - * - Tool calls paired with tool returns 9 - * - User messages with images 10 - * - Compaction alerts extracted 11 - * - Streaming messages appended as temporary group 6 + * WHAT IT DOES: 7 + * - Groups messages by ID (reasoning + assistant share ID → single group) 8 + * - Pairs tool calls with tool returns automatically 9 + * - Extracts compaction alerts from user messages 10 + * - Parses multipart user messages (text + images) 11 + * - Appends streaming group as temporary FlatList item 12 12 * 13 - * This hook is the data transformation layer - rendering components 14 - * consume MessageGroup instead of raw LettaMessage. 13 + * WHY IT EXISTS: 14 + * Before: Reasoning and assistant messages were separate FlatList items, 15 + * requiring complex pairing logic in the render component. 16 + * After: One MessageGroup per logical message turn, with reasoning co-located. 17 + * 18 + * STREAMING BEHAVIOR: 19 + * - While streaming: Appends temporary group (id='streaming', groupKey='streaming-assistant') 20 + * - Server refresh: Replaces with real messages (different groupKeys prevent flashing) 21 + * 22 + * This hook is pure - no side effects, just data transformation. 15 23 */ 16 24 17 25 import { useMemo } from 'react';
-2
src/hooks/useMessageStream.ts
··· 221 221 isStreaming: chatStore.isStreaming, 222 222 isSendingMessage: chatStore.isSendingMessage, 223 223 currentStream: chatStore.currentStream, 224 - completedStreamBlocks: chatStore.completedStreamBlocks, 225 - 226 224 sendMessage, 227 225 }; 228 226 }
+9 -1
src/screens/ChatScreen.tsx
··· 56 56 const lastMessageNeedsSpace = useChatStore((state) => state.lastMessageNeedsSpace); 57 57 const currentStream = useChatStore((state) => state.currentStream); 58 58 59 - // Group messages by ID (reasoning + assistant, tool_call + tool_return, etc.) 59 + /** 60 + * Transform raw Letta messages into unified MessageGroup objects. 61 + * 62 + * This groups messages by ID (reasoning + assistant → single group), 63 + * pairs tool calls with returns, and appends a temporary streaming group 64 + * while the agent is responding. 65 + * 66 + * Each MessageGroup has a unique groupKey for FlatList rendering. 67 + */ 60 68 const messageGroups = useMessageGroups({ 61 69 messages, 62 70 isStreaming,
+20 -75
src/stores/chatStore.ts
··· 1 1 import { create } from 'zustand'; 2 2 import type { LettaMessage, StreamingChunk } from '../types/letta'; 3 3 4 + /** 5 + * Streaming state for accumulating message chunks 6 + * 7 + * Used by useMessageGroups to create a temporary streaming MessageGroup 8 + * that displays while the agent is responding. 9 + */ 4 10 interface StreamState { 5 11 reasoning: string; 6 12 toolCalls: Array<{ id: string; name: string; args: string }>; 7 13 assistantMessage: string; 8 - } 9 - 10 - interface CompletedBlock { 11 - type: 'reasoning' | 'assistant_message'; 12 - content: string; 13 14 } 14 15 15 16 interface ChatState { ··· 24 25 isStreaming: boolean; 25 26 isSendingMessage: boolean; 26 27 currentStream: StreamState; 27 - completedStreamBlocks: CompletedBlock[]; 28 28 29 29 // UI state 30 30 hasInputText: boolean; ··· 46 46 updateStreamReasoning: (reasoning: string) => void; 47 47 updateStreamAssistant: (content: string) => void; 48 48 addStreamToolCall: (toolCall: { id: string; name: string; args: string }) => void; 49 - completeReasoningBlock: (content: string) => void; 50 - completeAssistantBlock: (content: string) => void; 51 49 clearStream: () => void; 52 50 53 51 // Image actions ··· 82 80 toolCalls: [], 83 81 assistantMessage: '', 84 82 }, 85 - completedStreamBlocks: [], 86 83 87 84 hasInputText: false, 88 85 lastMessageNeedsSpace: false, ··· 125 122 set({ 126 123 isStreaming: true, 127 124 currentStream: { reasoning: '', toolCalls: [], assistantMessage: '' }, 128 - completedStreamBlocks: [], 129 125 lastMessageNeedsSpace: true, 130 126 }); 131 127 }, ··· 134 130 set({ isStreaming: false, lastMessageNeedsSpace: false }); 135 131 }, 136 132 133 + // Accumulate reasoning chunks (useMessageGroups will pair with assistant message) 137 134 updateStreamReasoning: (reasoning) => { 138 - set((state) => { 139 - // If we have assistant message, save it first and start new reasoning block 140 - if (state.currentStream.assistantMessage) { 141 - return { 142 - completedStreamBlocks: [ 143 - ...state.completedStreamBlocks, 144 - { type: 'assistant_message' as const, content: state.currentStream.assistantMessage }, 145 - ], 146 - currentStream: { 147 - reasoning, 148 - toolCalls: [], 149 - assistantMessage: '', 150 - }, 151 - }; 152 - } 153 - // Otherwise accumulate reasoning 154 - return { 155 - currentStream: { 156 - ...state.currentStream, 157 - reasoning: state.currentStream.reasoning + reasoning, 158 - }, 159 - }; 160 - }); 135 + set((state) => ({ 136 + currentStream: { 137 + ...state.currentStream, 138 + reasoning: state.currentStream.reasoning + reasoning, 139 + }, 140 + })); 161 141 }, 162 142 143 + // Accumulate assistant message chunks (useMessageGroups will pair with reasoning) 163 144 updateStreamAssistant: (content) => { 164 - set((state) => { 165 - // If we have reasoning and no assistant message yet, save reasoning first 166 - if (state.currentStream.reasoning && !state.currentStream.assistantMessage) { 167 - return { 168 - completedStreamBlocks: [ 169 - ...state.completedStreamBlocks, 170 - { type: 'reasoning' as const, content: state.currentStream.reasoning }, 171 - ], 172 - currentStream: { 173 - reasoning: '', 174 - toolCalls: [], 175 - assistantMessage: content, 176 - }, 177 - }; 178 - } 179 - // Otherwise accumulate assistant message 180 - return { 181 - currentStream: { 182 - ...state.currentStream, 183 - assistantMessage: state.currentStream.assistantMessage + content, 184 - }, 185 - }; 186 - }); 145 + set((state) => ({ 146 + currentStream: { 147 + ...state.currentStream, 148 + assistantMessage: state.currentStream.assistantMessage + content, 149 + }, 150 + })); 187 151 }, 188 152 189 153 addStreamToolCall: (toolCall) => { ··· 201 165 }); 202 166 }, 203 167 204 - completeReasoningBlock: (content) => { 205 - set((state) => ({ 206 - completedStreamBlocks: [ 207 - ...state.completedStreamBlocks, 208 - { type: 'reasoning' as const, content }, 209 - ], 210 - })); 211 - }, 212 - 213 - completeAssistantBlock: (content) => { 214 - set((state) => ({ 215 - completedStreamBlocks: [ 216 - ...state.completedStreamBlocks, 217 - { type: 'assistant_message' as const, content }, 218 - ], 219 - })); 220 - }, 221 - 222 168 clearStream: () => { 223 169 set({ 224 170 currentStream: { reasoning: '', toolCalls: [], assistantMessage: '' }, 225 - completedStreamBlocks: [], 226 171 }); 227 172 }, 228 173