A React Native app for the ultimate thinking partner.

feat(ui): tidy tool call display and improve chat scrolling

- Pretty-print tool calls with expandable multiline JSON args and chevron
- Jump instantly to bottom on initial load; retry until layout stabilizes
- Load only the most recent 20 messages on first load (older via paging)
- Remove global LayoutAnimation + forced scroll reset to fix 'See more' flicker
- Make initial ChatScreen scroll non-animated for parity

Files: App.tsx, src/components/ToolCallItem.tsx, src/components/ExpandableMessageContent.tsx, src/screens/ChatScreen.tsx

+211 -16
+47 -11
App.tsx
··· 1 - import React, { useState, useEffect, useRef, useMemo } from 'react'; 1 + import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; 2 2 import { 3 3 View, 4 4 Text, ··· 30 30 import ProjectSelectorModal from './ProjectSelectorModal'; 31 31 import Sidebar from './src/components/Sidebar'; 32 32 import MessageContent from './src/components/MessageContent'; 33 + import ExpandableMessageContent from './src/components/ExpandableMessageContent'; 33 34 import ToolCallItem from './src/components/ToolCallItem'; 34 35 import { darkTheme } from './src/theme'; 35 36 import type { LettaAgent, LettaMessage, StreamingChunk, Project, MemoryBlock } from './src/types/letta'; ··· 58 59 // Message state 59 60 const [messages, setMessages] = useState<LettaMessage[]>([]); 60 61 const PAGE_SIZE = 50; 62 + const INITIAL_LOAD_LIMIT = 20; // load only the last N messages initially 61 63 const [earliestCursor, setEarliestCursor] = useState<string | null>(null); 62 64 const [hasMoreBefore, setHasMoreBefore] = useState<boolean>(false); 63 65 const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false); ··· 110 112 const [containerHeight, setContainerHeight] = useState(0); 111 113 const [showScrollToBottom, setShowScrollToBottom] = useState(false); 112 114 const [inputContainerHeight, setInputContainerHeight] = useState(0); 115 + // Track when we need to jump to the bottom after first load/render 116 + const pendingJumpToBottomRef = useRef<boolean>(false); 117 + const pendingJumpRetriesRef = useRef<number>(0); 113 118 114 119 // Smoothly shrink the bottom spacer and counter-scroll to avoid visual jumps 115 120 const smoothRemoveSpacer = (durationMs: number = 220) => { ··· 153 158 const handleContentSizeChange = (_w: number, h: number) => { 154 159 setContentHeight(h); 155 160 updateScrollButtonVisibility(scrollY); 161 + // If a bottom jump is pending and we now know content height, jump precisely 162 + if (pendingJumpToBottomRef.current && containerHeight > 0 && pendingJumpRetriesRef.current > 0) { 163 + const offset = Math.max(0, h - containerHeight); 164 + scrollViewRef.current?.scrollToOffset({ offset, animated: false }); 165 + setShowScrollToBottom(false); 166 + pendingJumpRetriesRef.current -= 1; 167 + if (pendingJumpRetriesRef.current <= 0) pendingJumpToBottomRef.current = false; 168 + } 156 169 }; 157 170 158 171 const handleMessagesLayout = (e: any) => { 159 - setContainerHeight(e.nativeEvent.layout.height); 172 + const h = e.nativeEvent.layout.height; 173 + setContainerHeight(h); 160 174 updateScrollButtonVisibility(scrollY); 175 + // If a bottom jump is pending and we now know container height, jump precisely 176 + if (pendingJumpToBottomRef.current && contentHeight > 0 && pendingJumpRetriesRef.current > 0) { 177 + const offset = Math.max(0, contentHeight - h); 178 + scrollViewRef.current?.scrollToOffset({ offset, animated: false }); 179 + setShowScrollToBottom(false); 180 + pendingJumpRetriesRef.current -= 1; 181 + if (pendingJumpRetriesRef.current <= 0) pendingJumpToBottomRef.current = false; 182 + } 161 183 }; 162 184 163 185 const scrollToBottom = () => { ··· 230 252 return groups; 231 253 }, [messages]); 232 254 255 + // Handle message expansion toggle (no forced scroll to avoid flicker) 256 + const handleMessageToggle = useCallback((_expanding: boolean) => { 257 + // Intentionally no-op; FlatList will handle re-layout. 258 + // Keeping this hook to allow future tweaks without re-plumbing props. 259 + }, []); 260 + 233 261 const renderGroupItem = ({ item }: { item: MessageGroup }) => { 234 262 if (item.type === 'toolPair') { 235 263 return ( ··· 259 287 {m.role === 'tool' ? ( 260 288 <Text style={styles.messageText}>{m.content}</Text> 261 289 ) : ( 262 - <MessageContent content={m.content} isUser={m.role === 'user'} /> 290 + <ExpandableMessageContent 291 + content={m.content} 292 + isUser={m.role === 'user'} 293 + onToggle={handleMessageToggle} 294 + /> 263 295 )} 264 296 </View> 265 297 </View> ··· 556 588 try { 557 589 const parsed = JSON.parse(msg.content); 558 590 if (parsed?.type === 'heartbeat') return false; 591 + if (parsed?.type === 'system_alert') return false; 559 592 } catch {} 560 593 } 561 594 return true; ··· 596 629 const loadMessagesForAgent = async (agentId: string) => { 597 630 setIsLoadingMessages(true); 598 631 try { 599 - const messageHistory = await lettaApi.listMessages(agentId, { limit: PAGE_SIZE }); 632 + const messageHistory = await lettaApi.listMessages(agentId, { limit: INITIAL_LOAD_LIMIT }); 600 633 console.log('Loaded messages for agent:', messageHistory); 601 634 602 - const displayMessages = buildDisplayMessages(messageHistory); 635 + let displayMessages = buildDisplayMessages(messageHistory); 636 + // Safety: if SDK returns more than requested, keep only the newest N 637 + if (displayMessages.length > INITIAL_LOAD_LIMIT) { 638 + displayMessages = displayMessages.slice(-INITIAL_LOAD_LIMIT); 639 + } 603 640 604 641 setMessages(displayMessages); 605 642 setEarliestCursor(displayMessages.length ? displayMessages[0].created_at : null); 606 - setHasMoreBefore(displayMessages.length >= PAGE_SIZE); 643 + setHasMoreBefore(displayMessages.length >= INITIAL_LOAD_LIMIT); 607 644 608 645 // If the latest message is an approval request, prompt for approval 609 646 const lastRaw: any = messageHistory[messageHistory.length - 1]; ··· 619 656 setApprovalData(null); 620 657 } 621 658 622 - // Scroll to bottom after messages are loaded 623 - setTimeout(() => { 624 - scrollViewRef.current?.scrollToEnd({ animated: true }); 625 - }, 100); 659 + // Defer precise jump until sizes are known via onLayout/onContentSizeChange 660 + pendingJumpToBottomRef.current = true; 661 + pendingJumpRetriesRef.current = 8; // retry across a few layout/size passes 626 662 } catch (error: any) { 627 663 console.error('Failed to load messages:', error); 628 664 Alert.alert('Error', 'Failed to load messages: ' + error.message); ··· 1266 1302 onContentSizeChange={handleContentSizeChange} 1267 1303 onLayout={handleMessagesLayout} 1268 1304 scrollEventThrottle={16} 1269 - maintainVisibleContentPosition={{ minIndexForVisible: 1 }} 1305 + maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 10 }} 1270 1306 ListHeaderComponent={hasMoreBefore ? ( 1271 1307 <TouchableOpacity style={styles.loadMoreBtn} onPress={loadOlderMessages} disabled={isLoadingMore}> 1272 1308 <Text style={styles.loadMoreText}>{isLoadingMore ? 'Loading…' : 'Load earlier messages'}</Text>
+91
src/components/ExpandableMessageContent.tsx
··· 1 + import React, { useState, useCallback, useRef } from 'react'; 2 + import { View, Text, TouchableOpacity, StyleSheet, Platform, UIManager } from 'react-native'; 3 + import MessageContent from './MessageContent'; 4 + import { darkTheme } from '../theme'; 5 + 6 + // Enable LayoutAnimation on Android 7 + if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { 8 + UIManager.setLayoutAnimationEnabledExperimental(true); 9 + } 10 + 11 + interface ExpandableMessageContentProps { 12 + content: string; 13 + isUser: boolean; 14 + lineLimit?: number; 15 + onToggle?: (expanding: boolean) => void; 16 + } 17 + 18 + const ExpandableMessageContent: React.FC<ExpandableMessageContentProps> = ({ 19 + content, 20 + isUser, 21 + lineLimit = 3, 22 + onToggle 23 + }) => { 24 + const [isExpanded, setIsExpanded] = useState(false); 25 + const [shouldShowToggle, setShouldShowToggle] = useState(false); 26 + 27 + // Only apply expandable behavior to user messages 28 + if (!isUser) { 29 + return <MessageContent content={content} isUser={isUser} />; 30 + } 31 + 32 + // Simple heuristic: estimate if content would exceed line limit 33 + // Assuming average ~60 chars per line in the chat bubble 34 + const estimatedLines = content.length / 60; 35 + const hasLineBreaks = (content.match(/\n/g) || []).length; 36 + const totalEstimatedLines = Math.max(estimatedLines, hasLineBreaks + 1); 37 + 38 + // Show toggle if content is likely to exceed limit 39 + const showToggle = totalEstimatedLines > lineLimit; 40 + 41 + if (!showToggle) { 42 + return <MessageContent content={content} isUser={isUser} />; 43 + } 44 + 45 + const handleToggle = useCallback(() => { 46 + const nextExpanded = !isExpanded; 47 + // Notify parent before state change 48 + onToggle?.(nextExpanded); 49 + // Avoid global LayoutAnimation to prevent list flicker; just toggle state 50 + setIsExpanded(nextExpanded); 51 + }, [isExpanded, onToggle]); 52 + 53 + return ( 54 + <View> 55 + <View style={isExpanded ? undefined : styles.collapsedContainer}> 56 + <MessageContent 57 + content={isExpanded ? content : content.slice(0, 180) + '...'} 58 + isUser={isUser} 59 + /> 60 + </View> 61 + <TouchableOpacity 62 + onPress={handleToggle} 63 + style={styles.toggleButton} 64 + activeOpacity={0.7} 65 + > 66 + <Text style={styles.toggleText}> 67 + {isExpanded ? 'See less' : 'See more'} 68 + </Text> 69 + </TouchableOpacity> 70 + </View> 71 + ); 72 + }; 73 + 74 + const styles = StyleSheet.create({ 75 + collapsedContainer: { 76 + maxHeight: 72, // Approximately 3 lines with standard font size 77 + overflow: 'hidden', 78 + }, 79 + toggleButton: { 80 + marginTop: 4, 81 + paddingVertical: 2, 82 + }, 83 + toggleText: { 84 + color: darkTheme.colors.text.inverse, 85 + fontSize: 13, 86 + fontWeight: '500', 87 + opacity: 0.8, 88 + }, 89 + }); 90 + 91 + export default ExpandableMessageContent;
+71 -3
src/components/ToolCallItem.tsx
··· 1 - import React, { useState } from 'react'; 1 + import React, { useMemo, useState } from 'react'; 2 2 import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; 3 + import { Ionicons } from '@expo/vector-icons'; 3 4 import { darkTheme } from '../theme'; 4 5 5 6 interface ToolCallItemProps { ··· 10 11 const ToolCallItem: React.FC<ToolCallItemProps> = ({ callText, resultText }) => { 11 12 const [expanded, setExpanded] = useState(false); 12 13 14 + // Try to parse a "name({json})" or "name(k=v, ...)" shape into 15 + // a nicer multiline representation for readability. 16 + const prettyCallText = useMemo(() => { 17 + const raw = callText?.trim() || ''; 18 + // Extract name and args inside parens if present 19 + const m = raw.match(/^\s*([\w.]+)\s*\(([\s\S]*)\)\s*$/); 20 + const fn = m ? m[1] : 'tool'; 21 + const inside = m ? m[2].trim() : raw; 22 + 23 + const looksJsonLike = (s: string) => s.startsWith('{') || s.startsWith('['); 24 + 25 + const toPrettyJson = (s: string): string | null => { 26 + try { return JSON.stringify(JSON.parse(s), null, 2); } catch { return null; } 27 + }; 28 + 29 + const fromKvToPrettyJson = (s: string): string | null => { 30 + // Best-effort conversion of "k=v, x=1" into JSON 31 + try { 32 + // Wrap in braces and quote keys. Avoid touching quoted strings by a light heuristic: 33 + // this will work for our formatted args that already JSON.stringify values. 34 + const replaced = s.replace(/([A-Za-z_][A-Za-z0-9_]*)\s*=/g, '"$1": '); 35 + const wrapped = `{ ${replaced} }`; 36 + return toPrettyJson(wrapped); 37 + } catch { 38 + return null; 39 + } 40 + }; 41 + 42 + const argsPretty = 43 + looksJsonLike(inside) 44 + ? (toPrettyJson(inside) ?? inside) 45 + : (fromKvToPrettyJson(inside) ?? inside); 46 + 47 + // If we couldn't improve it, just return the raw text 48 + if (argsPretty === raw) return raw; 49 + 50 + // Compose a friendly multiline signature 51 + const indented = argsPretty 52 + .split('\n') 53 + .map((line) => ` ${line}`) 54 + .join('\n'); 55 + return `${fn}(\n${indented}\n)`; 56 + }, [callText]); 57 + 13 58 return ( 14 59 <View style={styles.container}> 15 60 <TouchableOpacity ··· 17 62 onPress={() => setExpanded((e) => !e)} 18 63 activeOpacity={0.7} 19 64 > 65 + <Ionicons 66 + name={expanded ? 'chevron-down' : 'chevron-forward'} 67 + size={14} 68 + color={darkTheme.colors.text.secondary} 69 + style={styles.chevron} 70 + /> 20 71 <Text style={styles.callText} numberOfLines={expanded ? 0 : 2}> 21 - {callText} 72 + {expanded ? prettyCallText : callText} 22 73 </Text> 23 74 </TouchableOpacity> 24 75 {expanded && !!resultText && ( 25 76 <View style={styles.resultBox}> 26 77 <Text style={styles.resultLabel}>Result</Text> 27 - <Text style={styles.resultText}>{resultText}</Text> 78 + <Text style={styles.resultText}> 79 + {(() => { 80 + const s = (resultText || '').trim(); 81 + try { 82 + if (s.startsWith('{') || s.startsWith('[')) { 83 + return JSON.stringify(JSON.parse(s), null, 2); 84 + } 85 + } catch {} 86 + return resultText; 87 + })()} 88 + </Text> 28 89 </View> 29 90 )} 30 91 </View> ··· 45 106 borderColor: darkTheme.colors.border.primary, 46 107 paddingVertical: 8, 47 108 paddingHorizontal: 10, 109 + }, 110 + chevron: { 111 + marginTop: 2, 48 112 }, 49 113 headerExpanded: { 50 114 borderBottomLeftRadius: 0, ··· 56 120 fontSize: 13, 57 121 lineHeight: 18, 58 122 flexShrink: 1, 123 + // Preserve whitespace and newlines for pretty-printed JSON 124 + whiteSpace: 'pre-wrap' as any, 59 125 }, 60 126 resultBox: { 61 127 backgroundColor: darkTheme.colors.background.tertiary, ··· 77 143 color: darkTheme.colors.text.primary, 78 144 fontSize: 14, 79 145 lineHeight: 20, 146 + whiteSpace: 'pre-wrap' as any, 147 + fontFamily: 'Menlo', 80 148 }, 81 149 }); 82 150
+2 -2
src/screens/ChatScreen.tsx
··· 40 40 ); 41 41 42 42 useEffect(() => { 43 - // Scroll to bottom when new messages arrive 43 + // Jump to bottom when new messages arrive 44 44 if (currentMessages.length > 0) { 45 45 setTimeout(() => { 46 - flatListRef.current?.scrollToEnd({ animated: true }); 46 + flatListRef.current?.scrollToEnd({ animated: false }); 47 47 }, 100); 48 48 } 49 49 }, [currentMessages.length]);