A React Native app for the ultimate thinking partner.

Fix message grouping and scroll behavior

Three critical fixes for message display and scrolling:

1. Message Grouping - Use step_id for tool call groupKeys
- Multiple tool calls can share the same message ID
- Each tool call has a unique step_id
- Changed groupKey from ${id}-tool_call to ${stepId}-tool_call
- Prevents tool calls from collapsing into single group in FlatList

2. Streaming Tool Calls - Show all tool calls during streaming
- Previously only showed first tool call (state.toolCalls[0])
- Now creates separate MessageGroup for each streaming tool call
- Each gets unique groupKey: streaming-tool_call-${toolCall.id}
- Tool calls accumulate in list instead of replacing each other

3. Reasoning Accumulation - Clear reasoning on phase transitions
- Fixed stored messages: Use LAST reasoning for tool calls when multiple exist
- Fixed streaming: Clear accumulated reasoning when first tool call arrives
- Prevents reasoning from assistant phase bleeding into tool call phase

4. Smart Scroll Behavior - Only auto-scroll when user is at bottom
- Tracks scroll position to determine if user is near bottom (100px threshold)
- Only auto-scrolls on content changes if user was already at bottom
- Prevents unwanted scroll jumps when streaming completes
- Allows manual scroll up to read history without interference

Technical details:
- createMessageGroup now selects reasoning intelligently based on message type
- createStreamingGroups returns array instead of single group
- useScrollToBottom tracks isNearBottomRef via onScroll handler
- ChatScreen passes onScroll and scrollEventThrottle={16} to FlatList

+118 -70
+50 -31
src/hooks/useMessageGroups.ts
··· 222 222 return timeA - timeB; 223 223 }); 224 224 225 - // Step 6: Append streaming group if active 225 + // Step 6: Append streaming groups if active 226 226 if (isStreaming && streamingState) { 227 - const streamingGroup = createStreamingGroup(streamingState); 228 - if (streamingGroup) { 229 - filteredGroups.push(streamingGroup); 230 - } 227 + const streamingGroups = createStreamingGroups(streamingState); 228 + filteredGroups.push(...streamingGroups); 231 229 } 232 230 233 231 return filteredGroups; ··· 246 244 // Find message types in this group 247 245 const userMsg = messagesInGroup.find((m) => m.message_type === 'user_message'); 248 246 const assistantMsg = messagesInGroup.find((m) => m.message_type === 'assistant_message'); 249 - const reasoningMsg = messagesInGroup.find((m) => m.message_type === 'reasoning_message'); 250 247 const toolCallMsg = messagesInGroup.find((m) => m.message_type === 'tool_call_message'); 251 248 const toolReturnMsg = messagesInGroup.find((m) => m.message_type === 'tool_return_message'); 252 249 250 + // CRITICAL FIX: When a group has BOTH assistant AND tool_call (with 2 reasoning messages), 251 + // the tool call should get the LAST reasoning (the one right before the tool call) 252 + const allReasoningMsgs = messagesInGroup.filter((m) => m.message_type === 'reasoning_message'); 253 + let reasoningMsg: LettaMessage | undefined; 254 + 255 + if (allReasoningMsgs.length === 0) { 256 + reasoningMsg = undefined; 257 + } else if (allReasoningMsgs.length === 1 || !toolCallMsg) { 258 + // Single reasoning OR no tool call → use first reasoning 259 + reasoningMsg = allReasoningMsgs[0]; 260 + } else { 261 + // Multiple reasoning messages AND we have a tool call → use LAST reasoning 262 + reasoningMsg = allReasoningMsgs[allReasoningMsgs.length - 1]; 263 + } 264 + 253 265 // Use first message for metadata 254 266 const firstMsg = messagesInGroup[0]; 255 267 ··· 295 307 // ======================================== 296 308 if (toolCallMsg) { 297 309 const toolCall = parseToolCall(toolCallMsg); 310 + const stepId = extractStepId(toolCallMsg); 311 + 312 + // CRITICAL: Use step_id for groupKey, not message ID 313 + // Multiple tool calls can share the same message ID but have different step_ids 314 + const groupKey = stepId ? `${stepId}-tool_call` : `${id}-tool_call`; 298 315 299 316 return { 300 317 id, 301 - groupKey: `${id}-tool_call`, 318 + groupKey, 302 319 type: 'tool_call', 303 320 content: toolCall.args, // The formatted args string 304 321 reasoning: reasoningMsg?.reasoning, ··· 362 379 } 363 380 364 381 /** 365 - * Create streaming group from current stream state 382 + * Create streaming groups from current stream state 383 + * Returns an array because multiple tool calls can be streaming simultaneously 366 384 */ 367 - function createStreamingGroup(state: StreamingState): MessageGroup | null { 385 + function createStreamingGroups(state: StreamingState): MessageGroup[] { 368 386 const now = new Date().toISOString(); 387 + const groups: MessageGroup[] = []; 369 388 370 - // Determine type: tool_call if we have tool calls, otherwise assistant 389 + // If we have tool calls, create a group for EACH one 371 390 if (state.toolCalls.length > 0) { 372 - // For streaming, we'll show all tool calls as one group 373 - const primaryCall = state.toolCalls[0]; 374 - return { 375 - id: 'streaming', 376 - groupKey: 'streaming-tool_call', 377 - type: 'tool_call', 378 - content: primaryCall.args, 379 - reasoning: state.reasoning || undefined, 380 - toolCall: { 381 - name: primaryCall.name, 382 - args: primaryCall.args, 383 - }, 384 - toolReturn: undefined, // No return yet during streaming 385 - created_at: now, 386 - role: 'assistant', 387 - isStreaming: true, 388 - }; 391 + state.toolCalls.forEach((toolCall, index) => { 392 + groups.push({ 393 + id: 'streaming', 394 + groupKey: `streaming-tool_call-${toolCall.id || index}`, 395 + type: 'tool_call', 396 + content: toolCall.args, 397 + reasoning: index === 0 ? state.reasoning || undefined : undefined, // Only first gets reasoning 398 + toolCall: { 399 + name: toolCall.name, 400 + args: toolCall.args, 401 + }, 402 + toolReturn: undefined, // No return yet during streaming 403 + created_at: now, 404 + role: 'assistant', 405 + isStreaming: true, 406 + }); 407 + }); 408 + return groups; 389 409 } 390 410 391 411 // Assistant message streaming 392 412 if (state.assistantMessage || state.reasoning) { 393 - return { 413 + groups.push({ 394 414 id: 'streaming', 395 415 groupKey: 'streaming-assistant', 396 416 type: 'assistant', ··· 399 419 created_at: now, 400 420 role: 'assistant', 401 421 isStreaming: true, 402 - }; 422 + }); 403 423 } 404 424 405 - // No content yet - don't show anything 406 - return null; 425 + return groups; 407 426 } 408 427 409 428 /**
+8
src/hooks/useMessageStream.ts
··· 37 37 38 38 // Process tool call messages 39 39 else if ((chunk.message_type === 'tool_call_message' || chunk.message_type === 'tool_call') && chunk.tool_call) { 40 + // CRITICAL FIX: When we get the first tool call, clear reasoning from the previous assistant message 41 + // The tool call will have its own reasoning chunks coming 42 + const currentToolCallCount = chatStore.currentStream.toolCalls.length; 43 + if (currentToolCallCount === 0) { 44 + // This is the first tool call - clear accumulated reasoning from assistant phase 45 + chatStore.clearStream(); 46 + } 47 + 40 48 const callObj = chunk.tool_call.function || chunk.tool_call; 41 49 const toolName = callObj?.name || callObj?.tool_name || 'tool'; 42 50 const args = callObj?.arguments || callObj?.args || {};
+55 -31
src/hooks/useScrollToBottom.ts
··· 1 - import { useRef, useEffect, useCallback } from 'react'; 2 - import { FlatList } from 'react-native'; 1 + import { useRef, useEffect, useCallback, useState } from 'react'; 2 + import { FlatList, NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; 3 3 4 4 interface UseScrollToBottomOptions { 5 5 /** ··· 15 15 delay?: number; 16 16 17 17 /** 18 - * Whether to animate the scroll 19 - * @default true 18 + * Distance from bottom (px) to consider "at bottom" 19 + * @default 100 20 20 */ 21 - animated?: boolean; 21 + threshold?: number; 22 22 } 23 23 24 24 /** 25 25 * Hook for managing scroll-to-bottom behavior in chat interfaces 26 26 * 27 + * ULTRATHINK SIMPLE SOLUTION: 28 + * - Tracks if user is at bottom 29 + * - Only auto-scrolls if user is at bottom 30 + * - Prevents scroll jumps on content replace 31 + * - Allows manual scroll up without interference 32 + * 27 33 * @example 28 - * const { scrollViewRef, scrollToBottom, onContentSizeChange } = useScrollToBottom({ 29 - * scrollOnMount: true, 30 - * delay: 100 31 - * }); 34 + * const { scrollViewRef, scrollToBottom, onContentSizeChange, onScroll } = useScrollToBottom(); 32 35 * 33 36 * <FlatList 34 37 * ref={scrollViewRef} 35 38 * onContentSizeChange={onContentSizeChange} 39 + * onScroll={onScroll} 36 40 * /> 37 41 */ 38 42 export function useScrollToBottom(options: UseScrollToBottomOptions = {}) { 39 43 const { 40 44 scrollOnMount = true, 41 45 delay = 100, 42 - animated = true, 46 + threshold = 100, 43 47 } = options; 44 48 45 49 const scrollViewRef = useRef<FlatList<any>>(null); 46 50 const hasMountedRef = useRef(false); 51 + const isNearBottomRef = useRef(true); // Assume at bottom initially 47 52 const contentSizeRef = useRef({ width: 0, height: 0 }); 53 + const layoutHeightRef = useRef(0); 54 + 55 + // Track scroll position to determine if user is at bottom 56 + const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { 57 + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; 58 + 59 + // Calculate distance from bottom 60 + const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height); 48 61 49 - const scrollToBottom = useCallback((customAnimated?: boolean) => { 50 - setTimeout(() => { 51 - scrollViewRef.current?.scrollToEnd({ 52 - animated: customAnimated !== undefined ? customAnimated : animated 53 - }); 54 - }, delay); 55 - }, [delay, animated]); 62 + // Update near-bottom state 63 + isNearBottomRef.current = distanceFromBottom <= threshold; 56 64 57 - // Handle content size change - scroll to bottom if content grows 65 + // Store layout height for later use 66 + layoutHeightRef.current = layoutMeasurement.height; 67 + }, [threshold]); 68 + 69 + // Scroll to bottom - but only if user is already near bottom 70 + const scrollToBottom = useCallback((force: boolean = false) => { 71 + if (force || isNearBottomRef.current) { 72 + setTimeout(() => { 73 + scrollViewRef.current?.scrollToEnd({ animated: false }); 74 + }, delay); 75 + } 76 + }, [delay]); 77 + 78 + // Handle content size change - scroll if user is at bottom 58 79 const onContentSizeChange = useCallback((width: number, height: number) => { 59 - const hasContentGrown = height > contentSizeRef.current.height; 80 + const prevHeight = contentSizeRef.current.height; 60 81 contentSizeRef.current = { width, height }; 61 82 62 - // Scroll if this is the first render with content, or if content has grown 63 - if (!hasMountedRef.current && height > 0 && scrollOnMount) { 83 + // Initial mount - always scroll to bottom 84 + if (!hasMountedRef.current && height > 0) { 64 85 hasMountedRef.current = true; 65 - scrollToBottom(false); // Don't animate initial scroll 66 - } else if (hasContentGrown && hasMountedRef.current) { 67 - // Content grew after mount - could be new message 68 - scrollToBottom(true); 86 + if (scrollOnMount) { 87 + setTimeout(() => { 88 + scrollViewRef.current?.scrollToEnd({ animated: false }); 89 + }, delay); 90 + } 91 + return; 69 92 } 70 - }, [scrollOnMount, scrollToBottom]); 71 93 72 - // Scroll on mount if requested 73 - useEffect(() => { 74 - if (scrollOnMount) { 75 - scrollToBottom(false); 94 + // Content grew (new messages) - only scroll if user was at bottom 95 + if (height > prevHeight && isNearBottomRef.current) { 96 + setTimeout(() => { 97 + scrollViewRef.current?.scrollToEnd({ animated: false }); 98 + }, delay); 76 99 } 77 - }, [scrollOnMount, scrollToBottom]); 100 + }, [scrollOnMount, delay]); 78 101 79 102 return { 80 103 scrollViewRef, 81 104 scrollToBottom, 82 105 onContentSizeChange, 106 + onScroll, 83 107 }; 84 108 }
+5 -8
src/screens/ChatScreen.tsx
··· 43 43 } = useMessageInteractions(); 44 44 45 45 // Scroll management 46 - const { scrollViewRef, scrollToBottom, onContentSizeChange } = useScrollToBottom({ 46 + const { scrollViewRef, scrollToBottom, onContentSizeChange, onScroll } = useScrollToBottom({ 47 47 scrollOnMount: true, 48 48 delay: 150, 49 - animated: false, 50 49 }); 51 50 52 51 // Chat store for images and streaming state ··· 75 74 const spacerHeightAnim = useRef(new Animated.Value(0)).current; 76 75 const [containerHeight, setContainerHeight] = React.useState(0); 77 76 78 - // Handle send message 77 + // Handle send message - no auto-scroll 79 78 const handleSend = async (text: string) => { 80 79 await sendMessage(text, selectedImages); 81 - scrollToBottom(true); // Animate scroll when sending 82 80 }; 83 81 84 82 // Render message group ··· 124 122 { paddingBottom: insets.bottom + 80 }, 125 123 ]} 126 124 onContentSizeChange={onContentSizeChange} 125 + onScroll={onScroll} 126 + scrollEventThrottle={16} 127 127 onEndReached={loadMoreMessages} 128 128 onEndReachedThreshold={0.5} 129 129 initialNumToRender={100} 130 130 maxToRenderPerBatch={20} 131 131 windowSize={21} 132 132 removeClippedSubviews={Platform.OS === 'android'} 133 - maintainVisibleContentPosition={{ 134 - minIndexForVisible: 0, 135 - autoscrollToTopThreshold: 10, 136 - }} 137 133 /> 138 134 139 135 {/* Spacer for animation */} ··· 146 142 theme={theme} 147 143 colorScheme={colorScheme} 148 144 hasMessages={messageGroups.length > 0} 145 + isLoadingMessages={isLoadingMessages} 149 146 isStreaming={isStreaming} 150 147 hasExpandedReasoning={expandedReasoning.size > 0} 151 148 selectedImages={selectedImages}