A React Native app for the ultimate thinking partner.

feat: add MessageGroupBubble component (Phase 2)

Created unified renderer for MessageGroup objects from useMessageGroups hook.
Single component that handles all message types with clean type-based rendering.

Component Architecture:
- Consumes MessageGroup interface from Phase 1
- Single switch on group.type for rendering logic
- Reuses existing sub-components (no duplication):
* ReasoningToggle for reasoning blocks
* ToolCallItem for tool calls
* ExpandableMessageContent for text
* CompactionBar for compaction alerts
* OrphanedToolReturn for orphaned returns
- Same interaction props as MessageBubbleEnhanced (backward compatible)

Rendering Logic by Type:
- compaction: CompactionBar with hideability based on settings
- tool_return_orphaned: OrphanedToolReturn (defensive case)
- tool_call: ReasoningToggle (if present) + ToolCallItem with call + return
- user: Image gallery + ExpandableMessageContent in bubble
- assistant: ReasoningToggle (if present) + "(co said)" + content + copy button

Key Features:
- Reasoning always co-located with content (no separate FlatList items)
- Streaming indicator styling (opacity: 0.95) via group.isStreaming flag
- Proper spacing for last message (lastMessageNeedsSpace prop)
- Copy button with checkmark feedback
- Type-safe throughout

This component exists alongside MessageBubbleEnhanced (non-breaking).
Next phases will integrate it into ChatScreen and eventually deprecate the old component.

Phase 2 complete. Next: Phase 3 (ChatScreen integration).

+322
+322
src/components/MessageGroupBubble.tsx
··· 1 + /** 2 + * MessageGroupBubble Component 3 + * 4 + * Unified renderer for MessageGroup objects from useMessageGroups hook. 5 + * Replaces the fragmented rendering logic from MessageBubbleEnhanced. 6 + * 7 + * Single component that handles all message types: 8 + * - user: User messages with optional images 9 + * - assistant: Assistant messages with optional reasoning 10 + * - tool_call: Tool call + return with optional reasoning 11 + * - tool_return_orphaned: Orphaned tool return (defensive case) 12 + * - compaction: Memory compaction alerts 13 + * 14 + * This component reuses existing sub-components (ReasoningToggle, ToolCallItem, 15 + * MessageContent, etc.) but provides a clean, unified rendering path. 16 + */ 17 + 18 + import React from 'react'; 19 + import { View, Text, TouchableOpacity, StyleSheet, Image, Platform } from 'react-native'; 20 + import { Ionicons } from '@expo/vector-icons'; 21 + import type { MessageGroup } from '../hooks/useMessageGroups'; 22 + import type { Theme } from '../theme'; 23 + 24 + // Import sub-components (reused from existing architecture) 25 + import ReasoningToggle from './ReasoningToggle'; 26 + import ToolCallItem from './ToolCallItem'; 27 + import ExpandableMessageContent from './ExpandableMessageContent'; 28 + import CompactionBar from './CompactionBar'; 29 + import OrphanedToolReturn from './OrphanedToolReturn'; 30 + 31 + interface MessageGroupBubbleProps { 32 + group: MessageGroup; 33 + theme: Theme; 34 + colorScheme: 'light' | 'dark'; 35 + 36 + // Interaction state (keyed by group.id) 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: (groupId: string) => void; 45 + toggleCompaction: (groupId: string) => void; 46 + toggleToolReturn: (groupId: string) => void; 47 + copyToClipboard: (content: string, groupId: string) => void; 48 + 49 + // Layout props 50 + lastMessageNeedsSpace?: boolean; 51 + containerHeight?: number; 52 + } 53 + 54 + export const MessageGroupBubble: React.FC<MessageGroupBubbleProps> = ({ 55 + group, 56 + theme, 57 + colorScheme, 58 + expandedReasoning, 59 + expandedCompaction, 60 + expandedToolReturns, 61 + copiedMessageId, 62 + showCompaction, 63 + toggleReasoning, 64 + toggleCompaction, 65 + toggleToolReturn, 66 + copyToClipboard, 67 + lastMessageNeedsSpace = false, 68 + containerHeight = 0, 69 + }) => { 70 + const isDark = colorScheme === 'dark'; 71 + 72 + // ======================================== 73 + // COMPACTION MESSAGE 74 + // ======================================== 75 + if (group.type === 'compaction') { 76 + // Hide if user disabled compaction in settings 77 + if (!showCompaction) { 78 + return null; 79 + } 80 + 81 + const isExpanded = expandedCompaction.has(group.id); 82 + return ( 83 + <CompactionBar 84 + message={group.compactionMessage || group.content} 85 + messageId={group.id} 86 + isExpanded={isExpanded} 87 + onToggle={() => toggleCompaction(group.id)} 88 + theme={theme} 89 + /> 90 + ); 91 + } 92 + 93 + // ======================================== 94 + // ORPHANED TOOL RETURN 95 + // ======================================== 96 + if (group.type === 'tool_return_orphaned') { 97 + const isExpanded = expandedToolReturns.has(group.id); 98 + return ( 99 + <View style={styles.messageContainer}> 100 + <OrphanedToolReturn 101 + content={group.content} 102 + messageId={group.id} 103 + isExpanded={isExpanded} 104 + onToggle={() => toggleToolReturn(group.id)} 105 + theme={theme} 106 + isDark={isDark} 107 + /> 108 + </View> 109 + ); 110 + } 111 + 112 + // ======================================== 113 + // TOOL CALL MESSAGE 114 + // ======================================== 115 + if (group.type === 'tool_call') { 116 + const isReasoningExpanded = expandedReasoning.has(group.id); 117 + 118 + return ( 119 + <View style={styles.messageContainer}> 120 + {/* Optional reasoning toggle */} 121 + {group.reasoning && ( 122 + <ReasoningToggle 123 + reasoning={group.reasoning} 124 + messageId={group.id} 125 + isExpanded={isReasoningExpanded} 126 + onToggle={() => toggleReasoning(group.id)} 127 + isDark={isDark} 128 + /> 129 + )} 130 + 131 + {/* Tool call + return */} 132 + <ToolCallItem 133 + callText={group.toolCall?.args || group.content} 134 + resultText={group.toolReturn} 135 + reasoning={undefined} // Already shown above if present 136 + hasResult={!!group.toolReturn} 137 + isDark={isDark} 138 + /> 139 + </View> 140 + ); 141 + } 142 + 143 + // ======================================== 144 + // USER MESSAGE 145 + // ======================================== 146 + if (group.type === 'user') { 147 + // Skip if no content 148 + if (!group.content.trim() && (!group.images || group.images.length === 0)) { 149 + return null; 150 + } 151 + 152 + return ( 153 + <View style={[styles.messageContainer, styles.userMessageContainer]}> 154 + <View 155 + style={[ 156 + styles.messageBubble, 157 + styles.userBubble, 158 + { 159 + backgroundColor: isDark ? '#FFFFFF' : '#000000', 160 + }, 161 + ]} 162 + > 163 + {/* Display images */} 164 + {group.images && group.images.length > 0 && ( 165 + <View style={styles.messageImagesContainer}> 166 + {group.images.map((img: any, idx: number) => { 167 + const uri = 168 + img.source.type === 'url' 169 + ? img.source.url 170 + : `data:${img.source.media_type || img.source.mediaType};base64,${img.source.data}`; 171 + 172 + return ( 173 + <Image key={idx} source={{ uri }} style={styles.messageImage} /> 174 + ); 175 + })} 176 + </View> 177 + )} 178 + 179 + {/* Display text content */} 180 + {group.content.trim().length > 0 && ( 181 + <ExpandableMessageContent 182 + content={group.content} 183 + isUser={true} 184 + isDark={isDark} 185 + lineLimit={3} 186 + /> 187 + )} 188 + </View> 189 + </View> 190 + ); 191 + } 192 + 193 + // ======================================== 194 + // ASSISTANT MESSAGE 195 + // ======================================== 196 + if (group.type === 'assistant') { 197 + const isReasoningExpanded = expandedReasoning.has(group.id); 198 + 199 + // Determine if this is the last message (for spacing) 200 + const shouldHaveMinHeight = lastMessageNeedsSpace; 201 + 202 + return ( 203 + <View 204 + style={[ 205 + styles.assistantFullWidthContainer, 206 + shouldHaveMinHeight && { minHeight: Math.max(containerHeight * 0.9, 450) }, 207 + group.isStreaming && styles.streamingPulse, 208 + ]} 209 + > 210 + {/* Reasoning toggle */} 211 + {group.reasoning && ( 212 + <ReasoningToggle 213 + reasoning={group.reasoning} 214 + messageId={group.id} 215 + isExpanded={isReasoningExpanded} 216 + onToggle={() => toggleReasoning(group.id)} 217 + isDark={isDark} 218 + /> 219 + )} 220 + 221 + {/* "(co said)" label */} 222 + <Text style={[styles.assistantLabel, { color: theme.colors.text.primary }]}> 223 + (co said) 224 + </Text> 225 + 226 + {/* Message content with copy button */} 227 + <View style={{ position: 'relative' }}> 228 + <ExpandableMessageContent 229 + content={group.content} 230 + isUser={false} 231 + isDark={isDark} 232 + lineLimit={20} 233 + /> 234 + 235 + {/* Copy button (absolute positioned overlay) */} 236 + <View style={styles.copyButtonContainer}> 237 + <TouchableOpacity 238 + onPress={() => copyToClipboard(group.content, group.id)} 239 + style={styles.copyButton} 240 + activeOpacity={0.7} 241 + testID="copy-button" 242 + > 243 + <Ionicons 244 + name={copiedMessageId === group.id ? 'checkmark-outline' : 'copy-outline'} 245 + size={16} 246 + color={ 247 + copiedMessageId === group.id 248 + ? theme.colors.interactive.primary 249 + : theme.colors.text.tertiary 250 + } 251 + /> 252 + </TouchableOpacity> 253 + </View> 254 + </View> 255 + </View> 256 + ); 257 + } 258 + 259 + // Unknown type - should never happen 260 + return null; 261 + }; 262 + 263 + const styles = StyleSheet.create({ 264 + messageContainer: { 265 + marginVertical: 8, 266 + paddingHorizontal: 18, 267 + }, 268 + userMessageContainer: { 269 + alignItems: 'flex-end', 270 + }, 271 + messageBubble: { 272 + maxWidth: '80%', 273 + borderRadius: 20, 274 + paddingHorizontal: 16, 275 + paddingVertical: 12, 276 + }, 277 + userBubble: { 278 + borderBottomRightRadius: 6, 279 + ...Platform.select({ 280 + web: { 281 + // @ts-ignore - web-only 282 + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', 283 + }, 284 + }), 285 + }, 286 + messageImagesContainer: { 287 + marginBottom: 8, 288 + }, 289 + messageImage: { 290 + width: 240, 291 + height: 240, 292 + borderRadius: 12, 293 + marginBottom: 8, 294 + }, 295 + assistantFullWidthContainer: { 296 + paddingHorizontal: 18, 297 + paddingVertical: 16, 298 + width: '100%', 299 + }, 300 + assistantLabel: { 301 + fontSize: 16, 302 + fontFamily: 'Lexend_500Medium', 303 + marginBottom: 8, 304 + }, 305 + copyButtonContainer: { 306 + position: 'absolute', 307 + top: 0, 308 + right: 0, 309 + zIndex: 10, 310 + }, 311 + copyButton: { 312 + padding: 8, 313 + opacity: 0.3, 314 + borderRadius: 4, 315 + }, 316 + streamingPulse: { 317 + // Optional: Add subtle pulse animation for streaming messages 318 + opacity: 0.95, 319 + }, 320 + }); 321 + 322 + export default MessageGroupBubble;