A React Native app for the ultimate thinking partner.

feat: add useMessageGroups hook for unified message display (Phase 1)

Implemented data transformation layer to group raw Letta messages by ID into
unified MessageGroup objects for rendering.

Grouping Logic:
- Groups messages with same ID (e.g., reasoning + assistant share ID)
- Pairs tool_call_message with tool_return_message
- Extracts compaction alerts from user messages
- Parses multipart user messages (text + images)
- Handles orphaned tool returns defensively
- Appends streaming group as temporary FlatList item

Message Types:
- user: Regular user messages with optional images
- assistant: Assistant messages with optional reasoning
- tool_call: Tool call + return pair with optional reasoning
- tool_return_orphaned: Tool return without matching call (defensive)
- compaction: Memory compaction alerts

Streaming Integration:
- Accepts isStreaming flag and streamingState
- Appends temporary group with id='streaming' and groupKey='streaming-*'
- Server refresh replaces streaming item with real messages

Architecture:
- Pure transformation hook (no side effects)
- Type-safe with comprehensive interfaces
- ES5-compatible (no downlevelIteration or s regex flag)
- Defensive parsing throughout

This is Phase 1 of message display unification. Next phases:
- Phase 2: Create MessageGroupBubble component
- Phase 3: Integrate into ChatScreen (non-breaking)
- Phase 4: Update streaming to use groups
- Phase 5: Remove old MessageBubbleEnhanced

+451
+451
src/hooks/useMessageGroups.ts
··· 1 + /** 2 + * useMessageGroups Hook 3 + * 4 + * Groups raw Letta messages by ID to create unified message groups. 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 12 + * 13 + * This hook is the data transformation layer - rendering components 14 + * consume MessageGroup instead of raw LettaMessage. 15 + */ 16 + 17 + import { useMemo } from 'react'; 18 + import type { LettaMessage } from '../types/letta'; 19 + 20 + /** 21 + * Unified message group for rendering 22 + */ 23 + export interface MessageGroup { 24 + // Identification 25 + id: string; // Original message ID (or 'streaming') 26 + groupKey: string; // Unique key for FlatList (id + type) 27 + 28 + // Type determines rendering component 29 + type: 'user' | 'assistant' | 'tool_call' | 'tool_return_orphaned' | 'compaction'; 30 + 31 + // Universal content 32 + content: string; 33 + reasoning?: string; 34 + 35 + // Tool-specific 36 + toolCall?: { 37 + name: string; 38 + args: string; // Python-formatted: "search(query=\"foo\")" 39 + }; 40 + toolReturn?: string; 41 + 42 + // User-specific (multipart messages) 43 + images?: Array<{ 44 + type: string; 45 + source: { 46 + type: string; 47 + data: string; 48 + mediaType?: string; 49 + media_type?: string; 50 + url?: string; 51 + }; 52 + }>; 53 + 54 + // Compaction-specific 55 + compactionMessage?: string; 56 + 57 + // Metadata 58 + created_at: string; 59 + role: 'user' | 'assistant' | 'system' | 'tool'; 60 + 61 + // Streaming indicator 62 + isStreaming?: boolean; 63 + } 64 + 65 + /** 66 + * Streaming state interface 67 + */ 68 + export interface StreamingState { 69 + reasoning: string; 70 + assistantMessage: string; 71 + toolCalls: Array<{ 72 + id: string; 73 + name: string; 74 + args: string; 75 + }>; 76 + } 77 + 78 + interface UseMessageGroupsParams { 79 + messages: LettaMessage[]; 80 + isStreaming: boolean; 81 + streamingState?: StreamingState; 82 + } 83 + 84 + /** 85 + * Group messages by ID into unified MessageGroup objects 86 + */ 87 + export function useMessageGroups({ 88 + messages, 89 + isStreaming, 90 + streamingState, 91 + }: UseMessageGroupsParams): MessageGroup[] { 92 + return useMemo(() => { 93 + // Step 1: Filter out system messages and login/heartbeat 94 + const filteredMessages = messages.filter((msg) => { 95 + if (msg.message_type === 'system_message') return false; 96 + 97 + // Filter login/heartbeat user messages 98 + if (msg.message_type === 'user_message' && msg.content) { 99 + try { 100 + const contentStr = typeof msg.content === 'string' 101 + ? msg.content 102 + : JSON.stringify(msg.content); 103 + const parsed = JSON.parse(contentStr); 104 + if (parsed?.type === 'login' || parsed?.type === 'heartbeat') { 105 + return false; 106 + } 107 + } catch { 108 + // Not JSON, keep it 109 + } 110 + } 111 + 112 + return true; 113 + }); 114 + 115 + // Step 2: Sort chronologically 116 + const sorted = [...filteredMessages].sort((a, b) => { 117 + const timeA = new Date(a.created_at || 0).getTime(); 118 + const timeB = new Date(b.created_at || 0).getTime(); 119 + return timeA - timeB; 120 + }); 121 + 122 + // Step 3: Group by ID 123 + const groupedById = new Map<string, LettaMessage[]>(); 124 + for (const msg of sorted) { 125 + if (!groupedById.has(msg.id)) { 126 + groupedById.set(msg.id, []); 127 + } 128 + groupedById.get(msg.id)!.push(msg); 129 + } 130 + 131 + // Step 4: Convert each ID group to MessageGroup 132 + const groups: MessageGroup[] = []; 133 + 134 + for (const [id, messagesInGroup] of Array.from(groupedById.entries())) { 135 + const group = createMessageGroup(id, messagesInGroup); 136 + if (group) { 137 + groups.push(group); 138 + } 139 + } 140 + 141 + // Step 5: Sort groups by created_at 142 + groups.sort((a, b) => { 143 + const timeA = new Date(a.created_at || 0).getTime(); 144 + const timeB = new Date(b.created_at || 0).getTime(); 145 + return timeA - timeB; 146 + }); 147 + 148 + // Step 6: Append streaming group if active 149 + if (isStreaming && streamingState) { 150 + const streamingGroup = createStreamingGroup(streamingState); 151 + if (streamingGroup) { 152 + groups.push(streamingGroup); 153 + } 154 + } 155 + 156 + return groups; 157 + }, [messages, isStreaming, streamingState]); 158 + } 159 + 160 + /** 161 + * Create a MessageGroup from messages with the same ID 162 + */ 163 + function createMessageGroup( 164 + id: string, 165 + messagesInGroup: LettaMessage[] 166 + ): MessageGroup | null { 167 + if (messagesInGroup.length === 0) return null; 168 + 169 + // Find message types in this group 170 + const userMsg = messagesInGroup.find((m) => m.message_type === 'user_message'); 171 + const assistantMsg = messagesInGroup.find((m) => m.message_type === 'assistant_message'); 172 + const reasoningMsg = messagesInGroup.find((m) => m.message_type === 'reasoning_message'); 173 + const toolCallMsg = messagesInGroup.find((m) => m.message_type === 'tool_call_message'); 174 + const toolReturnMsg = messagesInGroup.find((m) => m.message_type === 'tool_return_message'); 175 + 176 + // Use first message for metadata 177 + const firstMsg = messagesInGroup[0]; 178 + 179 + // ======================================== 180 + // USER MESSAGE 181 + // ======================================== 182 + if (userMsg) { 183 + // Check for compaction alert 184 + const compactionInfo = extractCompactionInfo(userMsg.content); 185 + if (compactionInfo.isCompaction) { 186 + return { 187 + id, 188 + groupKey: `${id}-compaction`, 189 + type: 'compaction', 190 + content: compactionInfo.message, 191 + compactionMessage: compactionInfo.message, 192 + created_at: userMsg.created_at, 193 + role: userMsg.role, 194 + }; 195 + } 196 + 197 + // Regular user message 198 + const { textContent, images } = parseUserContent(userMsg.content); 199 + 200 + // Skip if no content 201 + if (!textContent.trim() && images.length === 0) { 202 + return null; 203 + } 204 + 205 + return { 206 + id, 207 + groupKey: `${id}-user`, 208 + type: 'user', 209 + content: textContent, 210 + images: images.length > 0 ? images : undefined, 211 + created_at: userMsg.created_at, 212 + role: userMsg.role, 213 + }; 214 + } 215 + 216 + // ======================================== 217 + // TOOL CALL MESSAGE 218 + // ======================================== 219 + if (toolCallMsg) { 220 + const toolCall = parseToolCall(toolCallMsg); 221 + 222 + return { 223 + id, 224 + groupKey: `${id}-tool_call`, 225 + type: 'tool_call', 226 + content: toolCall.args, // The formatted args string 227 + reasoning: reasoningMsg?.reasoning, 228 + toolCall: { 229 + name: toolCall.name, 230 + args: toolCall.args, 231 + }, 232 + toolReturn: toolReturnMsg?.content || undefined, 233 + created_at: toolCallMsg.created_at, 234 + role: toolCallMsg.role, 235 + }; 236 + } 237 + 238 + // ======================================== 239 + // ORPHANED TOOL RETURN 240 + // ======================================== 241 + if (toolReturnMsg && !toolCallMsg) { 242 + return { 243 + id, 244 + groupKey: `${id}-tool_return_orphaned`, 245 + type: 'tool_return_orphaned', 246 + content: toolReturnMsg.content, 247 + created_at: toolReturnMsg.created_at, 248 + role: toolReturnMsg.role, 249 + }; 250 + } 251 + 252 + // ======================================== 253 + // ASSISTANT MESSAGE 254 + // ======================================== 255 + if (assistantMsg) { 256 + return { 257 + id, 258 + groupKey: `${id}-assistant`, 259 + type: 'assistant', 260 + content: assistantMsg.content, 261 + reasoning: reasoningMsg?.reasoning, 262 + created_at: assistantMsg.created_at, 263 + role: assistantMsg.role, 264 + }; 265 + } 266 + 267 + // ======================================== 268 + // STANDALONE REASONING (edge case) 269 + // ======================================== 270 + if (reasoningMsg) { 271 + // Reasoning without assistant message - treat as assistant with empty content 272 + return { 273 + id, 274 + groupKey: `${id}-assistant`, 275 + type: 'assistant', 276 + content: '', 277 + reasoning: reasoningMsg.reasoning, 278 + created_at: reasoningMsg.created_at, 279 + role: 'assistant', 280 + }; 281 + } 282 + 283 + // Unknown message type - skip 284 + return null; 285 + } 286 + 287 + /** 288 + * Create streaming group from current stream state 289 + */ 290 + function createStreamingGroup(state: StreamingState): MessageGroup | null { 291 + const now = new Date().toISOString(); 292 + 293 + // Determine type: tool_call if we have tool calls, otherwise assistant 294 + if (state.toolCalls.length > 0) { 295 + // For streaming, we'll show all tool calls as one group 296 + const primaryCall = state.toolCalls[0]; 297 + return { 298 + id: 'streaming', 299 + groupKey: 'streaming-tool_call', 300 + type: 'tool_call', 301 + content: primaryCall.args, 302 + reasoning: state.reasoning || undefined, 303 + toolCall: { 304 + name: primaryCall.name, 305 + args: primaryCall.args, 306 + }, 307 + toolReturn: undefined, // No return yet during streaming 308 + created_at: now, 309 + role: 'assistant', 310 + isStreaming: true, 311 + }; 312 + } 313 + 314 + // Assistant message streaming 315 + if (state.assistantMessage || state.reasoning) { 316 + return { 317 + id: 'streaming', 318 + groupKey: 'streaming-assistant', 319 + type: 'assistant', 320 + content: state.assistantMessage, 321 + reasoning: state.reasoning || undefined, 322 + created_at: now, 323 + role: 'assistant', 324 + isStreaming: true, 325 + }; 326 + } 327 + 328 + // No content yet - don't show anything 329 + return null; 330 + } 331 + 332 + /** 333 + * Extract compaction info from user message content 334 + */ 335 + function extractCompactionInfo(content: any): { 336 + isCompaction: boolean; 337 + message: string; 338 + } { 339 + try { 340 + const contentStr = typeof content === 'string' ? content : JSON.stringify(content); 341 + const parsed = JSON.parse(contentStr); 342 + 343 + if (parsed?.type === 'system_alert') { 344 + let messageText = parsed.message || ''; 345 + 346 + // Try to extract JSON from code block 347 + const jsonMatch = messageText.match(/```json\s*(\{[\s\S]*?\})\s*```/); 348 + if (jsonMatch) { 349 + try { 350 + const innerJson = JSON.parse(jsonMatch[1]); 351 + messageText = innerJson.message || messageText; 352 + } catch { 353 + // Use outer message 354 + } 355 + } 356 + 357 + // Strip preamble (use [\s\S] instead of . with s flag for ES5 compatibility) 358 + messageText = messageText.replace( 359 + /^Note: prior messages have been hidden from view[\s\S]*?The following is a summary of the previous messages:\s*/i, 360 + '' 361 + ); 362 + 363 + return { 364 + isCompaction: true, 365 + message: messageText, 366 + }; 367 + } 368 + } catch { 369 + // Not JSON 370 + } 371 + 372 + return { isCompaction: false, message: '' }; 373 + } 374 + 375 + /** 376 + * Parse user message content (text + images) 377 + */ 378 + function parseUserContent(content: any): { 379 + textContent: string; 380 + images: Array<{ 381 + type: string; 382 + source: { 383 + type: string; 384 + data: string; 385 + mediaType?: string; 386 + media_type?: string; 387 + url?: string; 388 + }; 389 + }>; 390 + } { 391 + let textContent = ''; 392 + let images: any[] = []; 393 + 394 + if (typeof content === 'object' && Array.isArray(content)) { 395 + // Multipart message 396 + images = content.filter((item: any) => item.type === 'image'); 397 + const textParts = content.filter((item: any) => item.type === 'text'); 398 + textContent = textParts 399 + .map((item: any) => item.text || '') 400 + .filter((t: string) => t) 401 + .join('\n'); 402 + } else if (typeof content === 'string') { 403 + textContent = content; 404 + } else { 405 + textContent = String(content || ''); 406 + } 407 + 408 + return { textContent, images }; 409 + } 410 + 411 + /** 412 + * Parse tool call message to extract name and args 413 + */ 414 + function parseToolCall(msg: LettaMessage): { 415 + name: string; 416 + args: string; 417 + } { 418 + // Try to parse from content (already formatted string like "search(query=\"foo\")") 419 + if (typeof msg.content === 'string' && msg.content.includes('(')) { 420 + return { 421 + name: msg.content.split('(')[0], 422 + args: msg.content, 423 + }; 424 + } 425 + 426 + // Fallback: extract from tool_call object 427 + if (msg.tool_call || msg.tool_calls?.[0]) { 428 + const toolCall = msg.tool_call || msg.tool_calls![0]; 429 + const callObj: any = toolCall.function || toolCall; 430 + const name = callObj?.name || 'unknown_tool'; 431 + const args = callObj?.arguments || callObj?.args || {}; 432 + 433 + // Format as Python call 434 + const formatArgsPython = (obj: any): string => { 435 + if (!obj || typeof obj !== 'object') return ''; 436 + return Object.entries(obj) 437 + .map(([k, v]) => `${k}=${typeof v === 'string' ? `"${v}"` : JSON.stringify(v)}`) 438 + .join(', '); 439 + }; 440 + 441 + const argsStr = `${name}(${formatArgsPython(args)})`; 442 + 443 + return { name, args: argsStr }; 444 + } 445 + 446 + // Fallback to content as-is 447 + return { 448 + name: 'unknown_tool', 449 + args: String(msg.content || ''), 450 + }; 451 + }