A React Native app for the ultimate thinking partner.

feat(filesystem): add file upload with progress tracking and folder caching

- Fix broken SDK pagination by using direct API calls for folder listing
- Cache folder ID in local storage to avoid expensive searches on reload
- Implement file upload using direct API instead of broken SDK method
- Add upload progress UI with status messages in Files sidebar
- Handle job polling with 404 fallback for instant completions
- Add MemoryBlockViewer component for desktop/mobile viewing
- Add developer mode with agent refresh capability
- Improve error logging throughout streaming and upload flows

+746 -120
+304 -107
App.tsx
··· 31 31 import ExpandableMessageContent from './src/components/ExpandableMessageContent'; 32 32 import AnimatedStreamingText from './src/components/AnimatedStreamingText'; 33 33 import ToolCallItem from './src/components/ToolCallItem'; 34 + import MemoryBlockViewer from './src/components/MemoryBlockViewer'; 34 35 import { darkTheme, lightTheme, CoColors } from './src/theme'; 35 36 import type { LettaAgent, LettaMessage, StreamingChunk, MemoryBlock } from './src/types/letta'; 36 37 ··· 122 123 const [blocksError, setBlocksError] = useState<string | null>(null); 123 124 const [selectedBlock, setSelectedBlock] = useState<MemoryBlock | null>(null); 124 125 const sidebarAnimRef = useRef(new Animated.Value(0)).current; 126 + const [developerMode, setDeveloperMode] = useState(false); 127 + const [headerClickCount, setHeaderClickCount] = useState(0); 128 + const headerClickTimeoutRef = useRef<NodeJS.Timeout | null>(null); 125 129 126 130 // File management state 127 131 const [coFolder, setCoFolder] = useState<any | null>(null); 128 132 const [folderFiles, setFolderFiles] = useState<any[]>([]); 129 133 const [isLoadingFiles, setIsLoadingFiles] = useState(false); 130 134 const [isUploadingFile, setIsUploadingFile] = useState(false); 135 + const [uploadProgress, setUploadProgress] = useState<string>(''); 131 136 const [filesError, setFilesError] = useState<string | null>(null); 132 137 133 138 const isDesktop = screenData.width >= 768; ··· 244 249 console.log('Initializing Co agent...'); 245 250 const agent = await findOrCreateCo('User'); 246 251 setCoAgent(agent); 247 - console.log('Co agent ready:', agent.id); 252 + console.log('=== CO AGENT INITIALIZED ==='); 253 + console.log('Co agent ID:', agent.id); 254 + console.log('Co agent name:', agent.name); 255 + console.log('Co agent LLM config:', JSON.stringify(agent.llmConfig, null, 2)); 256 + console.log('LLM model:', agent.llmConfig?.model); 257 + console.log('LLM context window:', agent.llmConfig?.contextWindow); 248 258 } catch (error: any) { 249 259 console.error('Failed to initialize Co:', error); 250 260 Alert.alert('Error', 'Failed to initialize Co: ' + (error.message || 'Unknown error')); ··· 382 392 setSelectedImages([]); 383 393 setIsSendingMessage(true); 384 394 385 - // Remove space from previous message before adding new user message 386 - setLastMessageNeedsSpace(false); 387 - spacerHeightAnim.setValue(0); 388 - 389 395 // Immediately add user message to UI (with images if any) 390 396 let tempMessageContent: any; 391 397 if (imagesToSend.length > 0) { ··· 584 590 streamCompleteRef.current = true; 585 591 }, 586 592 (error) => { 593 + console.error('=== APP STREAMING ERROR CALLBACK ==='); 587 594 console.error('Streaming error:', error); 595 + console.error('Error type:', typeof error); 596 + console.error('Error keys:', Object.keys(error || {})); 597 + console.error('Error details:', { 598 + message: error?.message, 599 + status: error?.status, 600 + code: error?.code, 601 + response: error?.response, 602 + responseData: error?.responseData 603 + }); 604 + 605 + // Try to log full error structure 606 + try { 607 + console.error('Full error JSON:', JSON.stringify(error, null, 2)); 608 + } catch (e) { 609 + console.error('Could not stringify error:', e); 610 + } 588 611 589 612 // Clear intervals on error 590 613 if (bufferIntervalRef.current) { ··· 608 631 setStreamingReasoning(''); 609 632 tokenBufferRef.current = ''; 610 633 streamingReasoningRef.current = ''; 611 - Alert.alert('Error', 'Failed to send message: ' + (error.message || 'Unknown error')); 634 + 635 + // Create detailed error message 636 + let errorMsg = 'Failed to send message'; 637 + if (error?.message) { 638 + errorMsg += ': ' + error.message; 639 + } 640 + if (error?.status) { 641 + errorMsg += ' (Status: ' + error.status + ')'; 642 + } 643 + if (error?.responseData) { 644 + try { 645 + const responseStr = typeof error.responseData === 'string' 646 + ? error.responseData 647 + : JSON.stringify(error.responseData); 648 + errorMsg += '\nDetails: ' + responseStr; 649 + } catch (e) { 650 + // ignore 651 + } 652 + } 653 + 654 + Alert.alert('Error', errorMsg); 612 655 } 613 656 ); 614 657 } catch (error: any) { 658 + console.error('=== APP SEND MESSAGE OUTER CATCH ==='); 615 659 console.error('Failed to send message:', error); 660 + console.error('Error type:', typeof error); 661 + console.error('Error keys:', Object.keys(error || {})); 662 + console.error('Error details:', { 663 + message: error?.message, 664 + status: error?.status, 665 + code: error?.code, 666 + response: error?.response, 667 + responseData: error?.responseData 668 + }); 669 + 670 + try { 671 + console.error('Full error JSON:', JSON.stringify(error, null, 2)); 672 + } catch (e) { 673 + console.error('Could not stringify error:', e); 674 + } 675 + 616 676 Alert.alert('Error', 'Failed to send message: ' + (error.message || 'Unknown error')); 617 677 setIsStreaming(false); 618 678 spacerHeightAnim.setValue(0); ··· 724 784 try { 725 785 console.log('Initializing co folder...'); 726 786 727 - // Check if "co" folder already exists 728 - const folders = await lettaApi.listFolders(); 729 - let folder = folders.find((f: any) => f.name === 'co'); 787 + let folder: any = null; 788 + 789 + // First, try to get cached folder ID 790 + const cachedFolderId = await Storage.getItem(STORAGE_KEYS.CO_FOLDER_ID); 791 + if (cachedFolderId) { 792 + console.log('Found cached folder ID:', cachedFolderId); 793 + try { 794 + // Try to get the folder by ID directly (we'll need to add this method) 795 + const folders = await lettaApi.listFolders({ name: 'co-app' }); 796 + folder = folders.find(f => f.id === cachedFolderId); 797 + if (folder) { 798 + console.log('Using cached folder:', folder.id, folder.name); 799 + } else { 800 + console.log('Cached folder ID not found, will search...'); 801 + await Storage.removeItem(STORAGE_KEYS.CO_FOLDER_ID); 802 + } 803 + } catch (error) { 804 + console.log('Failed to get cached folder, will search:', error); 805 + await Storage.removeItem(STORAGE_KEYS.CO_FOLDER_ID); 806 + } 807 + } 730 808 809 + // If we don't have a cached folder, search for it 731 810 if (!folder) { 732 - // Create the folder 733 - console.log('Creating co folder...'); 734 - folder = await lettaApi.createFolder('co', 'Files shared with co'); 811 + console.log('Searching for co-app folder...'); 812 + const folders = await lettaApi.listFolders({ name: 'co-app' }); 813 + console.log('Folder query result:', folders.length, 'folders'); 814 + folder = folders.length > 0 ? folders[0] : null; 815 + console.log('Selected folder:', folder ? { id: folder.id, name: folder.name } : null); 735 816 } 817 + 818 + // If still no folder, create it 819 + if (!folder) { 820 + console.log('Creating co-app folder...'); 821 + try { 822 + folder = await lettaApi.createFolder('co-app', 'Files shared with co'); 823 + console.log('Folder created:', folder.id, 'name:', folder.name); 824 + } catch (createError: any) { 825 + // If 409 conflict, folder was created by another process - try to find it again 826 + if (createError.status === 409) { 827 + console.log('Folder already exists (409), retrying fetch...'); 828 + const foldersRetry = await lettaApi.listFolders({ name: 'co-app' }); 829 + console.log('Retry folder query result:', foldersRetry.length, 'folders'); 830 + folder = foldersRetry.length > 0 ? foldersRetry[0] : null; 831 + if (!folder) { 832 + console.error('Folder "co-app" not found after 409 conflict'); 833 + setFilesError('Folder "co-app" exists but could not be retrieved. Try refreshing.'); 834 + return; 835 + } 836 + } else { 837 + throw createError; 838 + } 839 + } 840 + } 841 + 842 + // Cache the folder ID for next time 843 + await Storage.setItem(STORAGE_KEYS.CO_FOLDER_ID, folder.id); 844 + console.log('Cached folder ID:', folder.id); 736 845 737 846 setCoFolder(folder); 738 847 console.log('Co folder ready:', folder.id); ··· 797 906 } 798 907 799 908 setIsUploadingFile(true); 909 + setUploadProgress(`Uploading ${file.name}...`); 800 910 try { 801 - // Upload file 802 - const job = await lettaApi.uploadFileToFolder(coFolder.id, file); 803 - console.log('Upload job:', job.id); 911 + // Upload file - this returns the job info 912 + const result = await lettaApi.uploadFileToFolder(coFolder.id, file); 913 + console.log('Upload result:', result); 914 + 915 + // The upload might complete immediately or return a job 916 + if (result.id && result.id.startsWith('file-')) { 917 + // It's a job ID - poll for completion 918 + setUploadProgress('Processing file...'); 919 + let attempts = 0; 920 + const maxAttempts = 30; // 30 seconds max 921 + 922 + while (attempts < maxAttempts) { 923 + await new Promise(resolve => setTimeout(resolve, 1000)); 924 + 925 + try { 926 + const status = await lettaApi.getJobStatus(result.id); 927 + console.log('Job status:', status.status); 804 928 805 - // Poll for job completion 806 - let attempts = 0; 807 - const maxAttempts = 60; // 60 seconds max 808 - while (attempts < maxAttempts) { 809 - await new Promise(resolve => setTimeout(resolve, 1000)); 810 - const status = await lettaApi.getJobStatus(job.id); 811 - console.log('Job status:', status.status); 929 + if (status.status === 'completed') { 930 + console.log('File uploaded successfully'); 931 + await loadFolderFiles(); 932 + setUploadProgress(''); 933 + Alert.alert('Success', `${file.name} uploaded successfully`); 934 + break; 935 + } else if (status.status === 'failed') { 936 + throw new Error('Upload failed: ' + (status.metadata || 'Unknown error')); 937 + } 938 + } catch (jobError: any) { 939 + // If job not found (404), it might have completed already 940 + if (jobError.status === 404) { 941 + console.log('Job not found - assuming completed'); 942 + await loadFolderFiles(); 943 + setUploadProgress(''); 944 + Alert.alert('Success', `${file.name} uploaded successfully`); 945 + break; 946 + } 947 + throw jobError; 948 + } 812 949 813 - if (status.status === 'completed') { 814 - console.log('File uploaded successfully'); 815 - await loadFolderFiles(); 816 - Alert.alert('Success', `${file.name} uploaded successfully`); 817 - break; 818 - } else if (status.status === 'failed') { 819 - throw new Error('Upload failed: ' + (status.metadata || 'Unknown error')); 950 + attempts++; 820 951 } 821 952 822 - attempts++; 823 - } 824 - 825 - if (attempts >= maxAttempts) { 826 - throw new Error('Upload timed out'); 953 + if (attempts >= maxAttempts) { 954 + throw new Error('Upload processing timed out'); 955 + } 956 + } else { 957 + // Upload completed immediately 958 + console.log('File uploaded immediately'); 959 + await loadFolderFiles(); 960 + setUploadProgress(''); 961 + Alert.alert('Success', `${file.name} uploaded successfully`); 827 962 } 828 963 } catch (error: any) { 829 964 console.error('Upload error:', error); 965 + setUploadProgress(''); 830 966 Alert.alert('Upload Failed', error.message || 'Failed to upload file'); 831 967 } finally { 832 968 setIsUploadingFile(false); ··· 1249 1385 1250 1386 const inputStyles = { 1251 1387 width: '100%', 1252 - minHeight: 40, 1253 - maxHeight: 120, 1388 + height: 76, 1254 1389 paddingLeft: 18, 1255 1390 paddingRight: 130, 1256 - paddingTop: 10, 1257 - paddingBottom: 10, 1391 + paddingTop: 12, 1392 + paddingBottom: 12, 1258 1393 borderRadius: 24, 1259 1394 color: colorScheme === 'dark' ? '#000000' : '#FFFFFF', // Inverted: black text in dark mode 1260 1395 fontFamily: 'Lexend_400Regular', ··· 1329 1464 </TouchableOpacity> 1330 1465 </View> 1331 1466 1332 - {/* Menu Items */} 1333 - <View style={styles.menuItems}> 1467 + <FlatList 1468 + style={{ flex: 1 }} 1469 + contentContainerStyle={{ flexGrow: 1 }} 1470 + ListHeaderComponent={ 1471 + <View style={styles.menuItems}> 1334 1472 <TouchableOpacity 1335 1473 style={[styles.menuItem, { borderBottomColor: theme.colors.border.primary }]} 1336 1474 onPress={() => { ··· 1381 1519 <Text style={[styles.menuItemText, { color: theme.colors.text.primary }]}>Open in Browser</Text> 1382 1520 </TouchableOpacity> 1383 1521 1522 + {developerMode && ( 1523 + <TouchableOpacity 1524 + style={[styles.menuItem, { borderBottomColor: theme.colors.border.primary }]} 1525 + onPress={async () => { 1526 + console.log('Refresh Co button pressed'); 1527 + const confirmed = Platform.OS === 'web' 1528 + ? window.confirm('This will delete the current co agent and create a new one. All conversation history will be lost. Are you sure?') 1529 + : await new Promise<boolean>((resolve) => { 1530 + Alert.alert( 1531 + 'Refresh Co Agent', 1532 + 'This will delete the current co agent and create a new one. All conversation history will be lost. Are you sure?', 1533 + [ 1534 + { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) }, 1535 + { text: 'Refresh', style: 'destructive', onPress: () => resolve(true) }, 1536 + ] 1537 + ); 1538 + }); 1539 + 1540 + if (!confirmed) return; 1541 + 1542 + console.log('Refresh confirmed, starting process...'); 1543 + setSidebarVisible(false); 1544 + try { 1545 + if (coAgent) { 1546 + console.log('Deleting agent:', coAgent.id); 1547 + await lettaApi.deleteAgent(coAgent.id); 1548 + console.log('Agent deleted, clearing state...'); 1549 + setCoAgent(null); 1550 + setMessages([]); 1551 + console.log('Initializing new co agent...'); 1552 + await initializeCo(); 1553 + console.log('Co agent refreshed successfully'); 1554 + } 1555 + } catch (error: any) { 1556 + console.error('Error refreshing co:', error); 1557 + if (Platform.OS === 'web') { 1558 + window.alert('Failed to refresh co: ' + (error.message || 'Unknown error')); 1559 + } else { 1560 + Alert.alert('Error', 'Failed to refresh co: ' + (error.message || 'Unknown error')); 1561 + } 1562 + } 1563 + }} 1564 + > 1565 + <Ionicons name="refresh-outline" size={24} color={theme.colors.status.error} /> 1566 + <Text style={[styles.menuItemText, { color: theme.colors.status.error }]}>Refresh Co</Text> 1567 + </TouchableOpacity> 1568 + )} 1569 + 1384 1570 <TouchableOpacity 1385 1571 style={[styles.menuItem, { borderBottomColor: theme.colors.border.primary }]} 1386 1572 onPress={() => { ··· 1391 1577 <Ionicons name="log-out-outline" size={24} color={theme.colors.text.primary} /> 1392 1578 <Text style={[styles.menuItemText, { color: theme.colors.text.primary }]}>Logout</Text> 1393 1579 </TouchableOpacity> 1394 - </View> 1395 - 1396 - {/* Memory blocks section - only show when memory tab is active */} 1397 - {activeSidebarTab === 'memory' && ( 1398 - <View style={styles.memorySection}> 1580 + </View> 1581 + } 1582 + ListFooterComponent={ 1583 + <> 1584 + {/* Memory blocks section - only show when memory tab is active */} 1585 + {activeSidebarTab === 'memory' && ( 1586 + <View style={styles.memorySection}> 1399 1587 <Text style={[styles.memorySectionTitle, { color: theme.colors.text.secondary }]}>Memory Blocks</Text> 1400 1588 {isLoadingBlocks ? ( 1401 1589 <ActivityIndicator size="large" color={theme.colors.text.secondary} /> ··· 1441 1629 )} 1442 1630 </TouchableOpacity> 1443 1631 </View> 1632 + {uploadProgress && ( 1633 + <View style={{ marginBottom: 12, paddingVertical: 8, paddingHorizontal: 12, backgroundColor: theme.colors.background.tertiary, borderRadius: 8 }}> 1634 + <Text style={{ color: theme.colors.text.secondary, fontSize: 14 }}>{uploadProgress}</Text> 1635 + </View> 1636 + )} 1444 1637 {isLoadingFiles ? ( 1445 1638 <ActivityIndicator size="large" color={theme.colors.text.secondary} /> 1446 1639 ) : filesError ? ( ··· 1482 1675 /> 1483 1676 )} 1484 1677 </View> 1485 - )} 1678 + )} 1679 + </> 1680 + } 1681 + data={[]} 1682 + renderItem={() => null} 1683 + /> 1486 1684 </Animated.View> 1487 1685 1488 1686 {/* Main content area */} ··· 1494 1692 </TouchableOpacity> 1495 1693 1496 1694 <View style={styles.headerCenter}> 1497 - <Text style={[styles.headerTitle, { color: theme.colors.text.primary }]}>co</Text> 1695 + <TouchableOpacity 1696 + onPress={() => { 1697 + setHeaderClickCount(prev => prev + 1); 1698 + 1699 + if (headerClickTimeoutRef.current) { 1700 + clearTimeout(headerClickTimeoutRef.current); 1701 + } 1702 + 1703 + headerClickTimeoutRef.current = setTimeout(() => { 1704 + if (headerClickCount >= 6) { 1705 + setDeveloperMode(!developerMode); 1706 + if (Platform.OS === 'web') { 1707 + window.alert(developerMode ? 'Developer mode disabled' : 'Developer mode enabled'); 1708 + } else { 1709 + Alert.alert('Developer Mode', developerMode ? 'Disabled' : 'Enabled'); 1710 + } 1711 + } 1712 + setHeaderClickCount(0); 1713 + }, 2000); 1714 + }} 1715 + > 1716 + <Text style={[styles.headerTitle, { color: theme.colors.text.primary }]}>co</Text> 1717 + </TouchableOpacity> 1498 1718 </View> 1499 1719 1500 1720 <View style={styles.headerSpacer} /> 1501 1721 </View> 1502 1722 1503 - {/* Messages */} 1504 - <View style={styles.messagesContainer} onLayout={handleMessagesLayout}> 1723 + {/* Chat and Memory Row */} 1724 + <View style={styles.chatRow}> 1725 + {/* Messages */} 1726 + <View style={styles.messagesContainer} onLayout={handleMessagesLayout}> 1505 1727 <FlatList 1506 1728 ref={scrollViewRef} 1507 1729 data={groupedMessages} ··· 1675 1897 </TouchableOpacity> 1676 1898 </View> 1677 1899 </View> 1678 - </View> 1679 - </View> 1680 - 1681 - {/* Memory block detail modal */} 1682 - <Modal 1683 - visible={selectedBlock !== null} 1684 - animationType="slide" 1685 - transparent={true} 1686 - onRequestClose={() => setSelectedBlock(null)} 1687 - > 1688 - <View style={styles.modalOverlay}> 1689 - <View style={[styles.detailContainer, { paddingTop: insets.top }]}> 1690 - <View style={styles.detailHeader}> 1691 - <Text style={styles.detailTitle}>{selectedBlock?.label}</Text> 1692 - <TouchableOpacity onPress={() => setSelectedBlock(null)}> 1693 - <Ionicons name="close" size={24} color={theme.colors.text.primary} /> 1694 - </TouchableOpacity> 1695 - </View> 1696 - <Text style={styles.detailContent}>{selectedBlock?.value}</Text> 1697 1900 </View> 1901 + 1902 + {/* Memory block viewer - right pane on desktop */} 1903 + {isDesktop && selectedBlock && ( 1904 + <MemoryBlockViewer 1905 + block={selectedBlock} 1906 + onClose={() => setSelectedBlock(null)} 1907 + isDark={colorScheme === 'dark'} 1908 + isDesktop={isDesktop} 1909 + /> 1910 + )} 1698 1911 </View> 1699 - </Modal> 1912 + </View> 1913 + 1914 + {/* Memory block viewer - overlay on mobile */} 1915 + {!isDesktop && selectedBlock && ( 1916 + <MemoryBlockViewer 1917 + block={selectedBlock} 1918 + onClose={() => setSelectedBlock(null)} 1919 + isDark={colorScheme === 'dark'} 1920 + isDesktop={isDesktop} 1921 + /> 1922 + )} 1700 1923 1701 1924 {/* Approval modal */} 1702 1925 <Modal ··· 1777 2000 flex: 1, 1778 2001 flexDirection: 'column', 1779 2002 }, 2003 + chatRow: { 2004 + flex: 1, 2005 + flexDirection: 'row', 2006 + }, 1780 2007 loadingContainer: { 1781 2008 flex: 1, 1782 2009 justifyContent: 'center', ··· 1833 2060 paddingBottom: 100, // Space for input at bottom 1834 2061 }, 1835 2062 messageContainer: { 1836 - paddingHorizontal: 40, 2063 + paddingHorizontal: 18, 1837 2064 paddingVertical: 8, 1838 2065 }, 1839 2066 userMessageContainer: { ··· 1843 2070 alignItems: 'flex-start', 1844 2071 }, 1845 2072 assistantFullWidthContainer: { 1846 - paddingHorizontal: 40, 2073 + paddingHorizontal: 18, 1847 2074 paddingVertical: 12, 1848 2075 width: '100%', 1849 2076 }, ··· 1948 2175 flex: 1, 1949 2176 justifyContent: 'center', 1950 2177 alignItems: 'center', 1951 - padding: 40, 2178 + paddingHorizontal: 60, 2179 + paddingVertical: 80, 1952 2180 }, 1953 2181 emptyText: { 1954 - fontSize: 16, 2182 + fontSize: 24, 1955 2183 fontFamily: 'Lexend_400Regular', 1956 - color: darkTheme.colors.text.secondary, 2184 + color: darkTheme.colors.text.primary, 1957 2185 textAlign: 'center', 2186 + lineHeight: 36, 1958 2187 }, 1959 2188 scrollToBottomButton: { 1960 2189 position: 'absolute', ··· 2066 2295 sendButtonDisabled: { 2067 2296 opacity: 0.5, 2068 2297 }, 2069 - modalOverlay: { 2070 - flex: 1, 2071 - backgroundColor: 'rgba(0, 0, 0, 0.5)', 2072 - justifyContent: 'flex-end', 2073 - }, 2074 2298 sidebarContainer: { 2075 2299 height: '100%', 2076 2300 backgroundColor: darkTheme.colors.background.secondary, ··· 2144 2368 fontFamily: 'Lexend_400Regular', 2145 2369 color: darkTheme.colors.text.secondary, 2146 2370 }, 2147 - detailContainer: { 2148 - width: '90%', 2149 - maxHeight: '80%', 2150 - backgroundColor: darkTheme.colors.background.primary, 2151 - borderRadius: 16, 2152 - padding: 20, 2153 - alignSelf: 'center', 2154 - marginTop: 'auto', 2155 - marginBottom: 'auto', 2156 - }, 2157 - detailHeader: { 2158 - flexDirection: 'row', 2159 - justifyContent: 'space-between', 2160 - alignItems: 'center', 2161 - marginBottom: 16, 2162 - }, 2163 - detailTitle: { 2164 - fontSize: 20, 2165 - fontFamily: 'Lexend_700Bold', 2166 - color: darkTheme.colors.text.primary, 2167 - }, 2168 - detailContent: { 2169 - fontSize: 16, 2170 - fontFamily: 'Lexend_400Regular', 2171 - color: darkTheme.colors.text.primary, 2172 - lineHeight: 24, 2173 - }, 2174 2371 errorText: { 2175 2372 color: darkTheme.colors.status.error, 2176 2373 fontSize: 14, ··· 2264 2461 }, 2265 2462 compactionContainer: { 2266 2463 marginVertical: 16, 2267 - marginHorizontal: 20, 2464 + marginHorizontal: 18, 2268 2465 }, 2269 2466 compactionLine: { 2270 2467 flexDirection: 'row',
+161 -13
src/api/lettaApi.ts
··· 317 317 // Token streaming provides partial chunks for real-time UX 318 318 streamTokens: messageData.stream_tokens !== false, 319 319 }; 320 + 321 + // Only add optional params if they're defined 322 + if (messageData.use_assistant_message !== undefined) { 323 + lettaStreamingRequest.useAssistantMessage = messageData.use_assistant_message; 324 + } 325 + if (messageData.max_steps !== undefined) { 326 + lettaStreamingRequest.maxSteps = messageData.max_steps; 327 + } 320 328 // Optional ping events if requested by caller 321 329 if ((messageData as any).include_pings === true) { 322 330 lettaStreamingRequest.includePings = true; ··· 344 352 }); 345 353 } 346 354 }); 355 + 356 + console.log('=== CALLING SDK createStream ==='); 357 + console.log('Agent ID:', agentId); 358 + console.log('Client base URL:', (this.client as any)?.baseURL || 'unknown'); 359 + console.log('About to call: POST /agents/{agentId}/messages/stream'); 347 360 348 361 const stream = await this.client.agents.messages.createStream(agentId, lettaStreamingRequest); 349 362 363 + console.log('=== STREAM OBJECT CREATED ==='); 364 + console.log('Stream object type:', typeof stream); 365 + 350 366 // Handle the stream response using async iteration 367 + console.log('=== STARTING STREAM ITERATION ==='); 351 368 try { 352 369 for await (const chunk of stream) { 353 370 console.log('=== RAW CHUNK RECEIVED ==='); ··· 382 399 usage: undefined 383 400 }); 384 401 } catch (streamError) { 402 + console.error('=== STREAM ITERATION ERROR ==='); 385 403 console.error('Stream iteration error:', streamError); 386 404 console.error('Stream error details:', { 387 405 message: streamError.message, 388 406 statusCode: streamError.statusCode, 407 + status: streamError.status, 389 408 body: streamError.body, 409 + data: streamError.data, 410 + response: streamError.response, 390 411 rawResponse: streamError.rawResponse, 412 + error: streamError.error, 391 413 stack: streamError.stack 392 414 }); 415 + 416 + // Try to extract any additional error info 417 + if (streamError.response) { 418 + console.error('Response object:', JSON.stringify(streamError.response, null, 2)); 419 + } 420 + if (streamError.body) { 421 + console.error('Body object:', JSON.stringify(streamError.body, null, 2)); 422 + } 423 + if (streamError.data) { 424 + console.error('Data object:', JSON.stringify(streamError.data, null, 2)); 425 + } 426 + 393 427 onError(this.handleError(streamError)); 394 428 } 395 429 } catch (error) { 430 + console.error('=== STREAM SETUP ERROR ==='); 396 431 console.error('sendMessageStream setup error:', error); 397 432 console.error('Setup error details:', { 398 433 message: error.message, 399 434 statusCode: error.statusCode, 435 + status: error.status, 400 436 body: error.body, 437 + data: error.data, 438 + response: error.response, 401 439 rawResponse: error.rawResponse, 440 + error: error.error, 402 441 stack: error.stack, 403 442 name: error.name, 404 443 constructor: error.constructor.name 405 444 }); 445 + 446 + // Try to extract any additional error info 447 + if (error.response) { 448 + console.error('Response object:', JSON.stringify(error.response, null, 2)); 449 + } 450 + if (error.body) { 451 + console.error('Body object:', JSON.stringify(error.body, null, 2)); 452 + } 453 + if (error.data) { 454 + console.error('Data object:', JSON.stringify(error.data, null, 2)); 455 + } 456 + 406 457 onError(this.handleError(error)); 407 458 } 408 459 } ··· 825 876 } 826 877 827 878 // Folder management 828 - async listFolders(): Promise<any[]> { 879 + async listFolders(params?: { name?: string }): Promise<any[]> { 829 880 try { 830 - if (!this.client) { 881 + if (!this.client || !this.token) { 831 882 throw new Error('Client not initialized. Please set auth token first.'); 832 883 } 833 - const folders = await this.client.folders.list(); 884 + console.log('listFolders - params:', params); 885 + 886 + // If searching by name, use direct API call (SDK pagination is broken) 887 + if (params?.name) { 888 + console.log('listFolders - searching for folder with name via direct API:', params.name); 889 + let allFolders: any[] = []; 890 + let after: string | undefined = undefined; 891 + let pageCount = 0; 892 + const maxPages = 20; // Safety limit 893 + 894 + do { 895 + console.log(`listFolders - requesting page ${pageCount + 1} with after cursor:`, after); 896 + 897 + // Build query params 898 + const queryParams = new URLSearchParams({ 899 + limit: '50', 900 + ...(after && { after }) 901 + }); 902 + 903 + const response = await fetch(`https://api.letta.com/v1/folders?${queryParams}`, { 904 + headers: { 905 + 'Authorization': `Bearer ${this.token}`, 906 + 'Content-Type': 'application/json' 907 + } 908 + }); 909 + 910 + if (!response.ok) { 911 + throw new Error(`API request failed: ${response.status} ${response.statusText}`); 912 + } 913 + 914 + const page = await response.json(); 915 + console.log(`listFolders - page ${pageCount + 1}: ${page.length} folders`); 916 + console.log(`listFolders - page ${pageCount + 1} first 3 names:`, page.slice(0, 3).map(f => f.name)); 917 + 918 + allFolders = allFolders.concat(page); 919 + pageCount++; 920 + 921 + // Stop if we found the folder we're looking for 922 + const found = page.find(f => f.name === params.name); 923 + if (found) { 924 + console.log('listFolders - found folder:', found); 925 + return [found]; 926 + } 927 + 928 + // Check if there are more pages 929 + if (page.length < 50) { 930 + after = undefined; 931 + } else { 932 + after = page[page.length - 1]?.id; 933 + } 934 + 935 + } while (after && pageCount < maxPages); 936 + 937 + console.log('listFolders - searched through', pageCount, 'pages,', allFolders.length, 'total folders'); 938 + console.log('listFolders - folder not found with name:', params.name); 939 + return []; 940 + } 941 + 942 + // No name filter, just return first page using SDK 943 + const folders = await this.client.folders.list(params); 944 + console.log('listFolders - returned count:', folders.length); 834 945 return folders; 835 946 } catch (error) { 836 947 throw this.handleError(error); ··· 855 966 } 856 967 } 857 968 858 - async uploadFileToFolder(folderId: string, file: File): Promise<any> { 969 + async uploadFileToFolder(folderId: string, file: File, duplicateHandling: 'skip' | 'error' | 'suffix' | 'replace' = 'replace'): Promise<any> { 859 970 try { 860 971 if (!this.client) { 861 972 throw new Error('Client not initialized. Please set auth token first.'); ··· 863 974 864 975 console.log('uploadFileToFolder - folderId:', folderId, 'fileName:', file.name); 865 976 866 - // Upload the file and get the job 867 - const job = await this.client.folders.files.upload(file, folderId); 868 - console.log('Upload job created:', job.id); 977 + // The SDK upload method signature might vary - try direct API call 978 + const formData = new FormData(); 979 + formData.append('file', file); 980 + 981 + const response = await fetch( 982 + `https://api.letta.com/v1/folders/${folderId}/files?duplicate_handling=${duplicateHandling}`, 983 + { 984 + method: 'POST', 985 + headers: { 986 + 'Authorization': `Bearer ${this.token}` 987 + }, 988 + body: formData 989 + } 990 + ); 991 + 992 + if (!response.ok) { 993 + const errorText = await response.text(); 994 + throw new Error(`Upload failed: ${response.status} ${errorText}`); 995 + } 996 + 997 + const result = await response.json(); 998 + console.log('Upload response:', result); 869 999 870 - return job; 1000 + return result; 871 1001 } catch (error) { 872 1002 throw this.handleError(error); 873 1003 } ··· 936 1066 } 937 1067 938 1068 private handleError(error: any): ApiError { 1069 + console.error('=== HANDLE ERROR ==='); 939 1070 console.error('handleError - Full error object:', error); 1071 + console.error('handleError - Error type:', typeof error); 940 1072 console.error('handleError - Error keys:', Object.keys(error)); 941 - 1073 + console.error('handleError - Error constructor:', error?.constructor?.name); 1074 + 942 1075 let message = 'An error occurred'; 943 1076 let status = 0; 944 1077 let code: string | undefined; ··· 958 1091 code = error.code; 959 1092 } 960 1093 1094 + // Try to extract detailed error information 1095 + const responseData = error?.responseData || error?.data || error?.body; 1096 + const response = error?.response || error?.rawResponse; 1097 + 961 1098 const apiError = { 962 1099 message, 963 1100 status, 964 1101 code, 965 - response: error?.response || error?.rawResponse, 966 - responseData: error?.responseData || error?.data || error?.body 1102 + response, 1103 + responseData 967 1104 }; 968 - 969 - console.error('handleError - Returning API error:', apiError); 1105 + 1106 + console.error('handleError - Returning API error:', JSON.stringify(apiError, null, 2)); 1107 + console.error('handleError - Response data type:', typeof responseData); 1108 + console.error('handleError - Response type:', typeof response); 1109 + 1110 + // Log any nested error details 1111 + if (responseData) { 1112 + console.error('handleError - Response data content:', JSON.stringify(responseData, null, 2)); 1113 + } 1114 + if (response) { 1115 + console.error('handleError - Response content:', JSON.stringify(response, null, 2)); 1116 + } 1117 + 970 1118 return apiError; 971 1119 } 972 1120 }
+280
src/components/MemoryBlockViewer.tsx
··· 1 + import React, { useRef, useEffect } from 'react'; 2 + import { 3 + View, 4 + Text, 5 + StyleSheet, 6 + TouchableOpacity, 7 + Animated, 8 + ScrollView, 9 + Dimensions, 10 + } from 'react-native'; 11 + import { Ionicons } from '@expo/vector-icons'; 12 + import MessageContent from './MessageContent'; 13 + import { darkTheme, lightTheme } from '../theme'; 14 + import type { MemoryBlock } from '../types/letta'; 15 + 16 + interface MemoryBlockViewerProps { 17 + block: MemoryBlock | null; 18 + onClose: () => void; 19 + isDark?: boolean; 20 + isDesktop: boolean; 21 + } 22 + 23 + const MemoryBlockViewer: React.FC<MemoryBlockViewerProps> = ({ 24 + block, 25 + onClose, 26 + isDark = true, 27 + isDesktop, 28 + }) => { 29 + const theme = isDark ? darkTheme : lightTheme; 30 + const slideAnim = useRef(new Animated.Value(0)).current; 31 + const fadeAnim = useRef(new Animated.Value(0)).current; 32 + 33 + useEffect(() => { 34 + if (block) { 35 + Animated.parallel([ 36 + Animated.timing(slideAnim, { 37 + toValue: 1, 38 + duration: 300, 39 + useNativeDriver: false, 40 + }), 41 + Animated.timing(fadeAnim, { 42 + toValue: 1, 43 + duration: 250, 44 + useNativeDriver: true, 45 + }), 46 + ]).start(); 47 + } else { 48 + Animated.parallel([ 49 + Animated.timing(slideAnim, { 50 + toValue: 0, 51 + duration: 250, 52 + useNativeDriver: false, 53 + }), 54 + Animated.timing(fadeAnim, { 55 + toValue: 0, 56 + duration: 200, 57 + useNativeDriver: true, 58 + }), 59 + ]).start(); 60 + } 61 + }, [block]); 62 + 63 + if (!block) return null; 64 + 65 + if (isDesktop) { 66 + // Desktop: Right pane 67 + const panelWidth = slideAnim.interpolate({ 68 + inputRange: [0, 1], 69 + outputRange: [0, 440], 70 + }); 71 + 72 + return ( 73 + <Animated.View 74 + style={[ 75 + styles.desktopPane, 76 + { 77 + width: panelWidth, 78 + backgroundColor: theme.colors.background.primary, 79 + borderLeftColor: theme.colors.border.primary, 80 + }, 81 + ]} 82 + > 83 + <View style={[styles.desktopHeader, { borderBottomColor: theme.colors.border.primary }]}> 84 + <View style={styles.headerLeft}> 85 + <Ionicons name="cube-outline" size={20} color={theme.colors.text.tertiary} /> 86 + <Text style={[styles.headerLabel, { color: theme.colors.text.tertiary }]}> 87 + MEMORY 88 + </Text> 89 + </View> 90 + <TouchableOpacity onPress={onClose} style={styles.closeButton}> 91 + <Ionicons name="close" size={24} color={theme.colors.text.primary} /> 92 + </TouchableOpacity> 93 + </View> 94 + 95 + <ScrollView 96 + style={styles.scrollContent} 97 + contentContainerStyle={styles.scrollContentContainer} 98 + showsVerticalScrollIndicator={true} 99 + > 100 + <View style={styles.blockContent}> 101 + <Text style={[styles.blockTitle, { color: theme.colors.text.primary }]}> 102 + {block.label} 103 + </Text> 104 + {block.description && ( 105 + <Text style={[styles.blockDescription, { color: theme.colors.text.secondary }]}> 106 + {block.description} 107 + </Text> 108 + )} 109 + <View style={styles.divider} /> 110 + <MessageContent content={block.value} isUser={false} isDark={isDark} /> 111 + </View> 112 + </ScrollView> 113 + </Animated.View> 114 + ); 115 + } else { 116 + // Mobile: Full screen overlay 117 + return ( 118 + <Animated.View 119 + style={[ 120 + styles.mobileOverlay, 121 + { 122 + opacity: fadeAnim, 123 + }, 124 + ]} 125 + > 126 + <TouchableOpacity 127 + style={styles.backdrop} 128 + activeOpacity={1} 129 + onPress={onClose} 130 + /> 131 + <Animated.View 132 + style={[ 133 + styles.mobilePanel, 134 + { 135 + backgroundColor: theme.colors.background.primary, 136 + transform: [ 137 + { 138 + translateY: slideAnim.interpolate({ 139 + inputRange: [0, 1], 140 + outputRange: [Dimensions.get('window').height, 0], 141 + }), 142 + }, 143 + ], 144 + }, 145 + ]} 146 + > 147 + <View style={[styles.mobileHeader, { borderBottomColor: theme.colors.border.primary }]}> 148 + <View style={styles.headerLeft}> 149 + <Ionicons name="cube-outline" size={20} color={theme.colors.text.tertiary} /> 150 + <Text style={[styles.headerLabel, { color: theme.colors.text.tertiary }]}> 151 + MEMORY 152 + </Text> 153 + </View> 154 + <TouchableOpacity onPress={onClose} style={styles.closeButton}> 155 + <Ionicons name="close" size={24} color={theme.colors.text.primary} /> 156 + </TouchableOpacity> 157 + </View> 158 + 159 + <ScrollView 160 + style={styles.scrollContent} 161 + contentContainerStyle={styles.scrollContentContainer} 162 + showsVerticalScrollIndicator={true} 163 + > 164 + <View style={styles.blockContent}> 165 + <Text style={[styles.blockTitle, { color: theme.colors.text.primary }]}> 166 + {block.label} 167 + </Text> 168 + {block.description && ( 169 + <Text style={[styles.blockDescription, { color: theme.colors.text.secondary }]}> 170 + {block.description} 171 + </Text> 172 + )} 173 + <View style={styles.divider} /> 174 + <MessageContent content={block.value} isUser={false} isDark={isDark} /> 175 + </View> 176 + </ScrollView> 177 + </Animated.View> 178 + </Animated.View> 179 + ); 180 + } 181 + }; 182 + 183 + const styles = StyleSheet.create({ 184 + // Desktop styles 185 + desktopPane: { 186 + borderLeftWidth: 1, 187 + overflow: 'hidden', 188 + }, 189 + desktopHeader: { 190 + flexDirection: 'row', 191 + justifyContent: 'space-between', 192 + alignItems: 'center', 193 + paddingHorizontal: 20, 194 + paddingVertical: 16, 195 + borderBottomWidth: 1, 196 + }, 197 + 198 + // Mobile styles 199 + mobileOverlay: { 200 + position: 'absolute', 201 + top: 0, 202 + left: 0, 203 + right: 0, 204 + bottom: 0, 205 + zIndex: 1000, 206 + }, 207 + backdrop: { 208 + position: 'absolute', 209 + top: 0, 210 + left: 0, 211 + right: 0, 212 + bottom: 0, 213 + backgroundColor: 'rgba(0, 0, 0, 0.6)', 214 + }, 215 + mobilePanel: { 216 + position: 'absolute', 217 + top: 60, 218 + left: 0, 219 + right: 0, 220 + bottom: 0, 221 + borderTopLeftRadius: 20, 222 + borderTopRightRadius: 20, 223 + shadowColor: '#000', 224 + shadowOffset: { width: 0, height: -2 }, 225 + shadowOpacity: 0.25, 226 + shadowRadius: 10, 227 + elevation: 10, 228 + }, 229 + mobileHeader: { 230 + flexDirection: 'row', 231 + justifyContent: 'space-between', 232 + alignItems: 'center', 233 + paddingHorizontal: 20, 234 + paddingTop: 20, 235 + paddingBottom: 16, 236 + borderBottomWidth: 1, 237 + }, 238 + 239 + // Shared styles 240 + headerLeft: { 241 + flexDirection: 'row', 242 + alignItems: 'center', 243 + gap: 8, 244 + }, 245 + headerLabel: { 246 + fontSize: 12, 247 + fontFamily: 'Lexend_600SemiBold', 248 + letterSpacing: 1.2, 249 + }, 250 + closeButton: { 251 + padding: 4, 252 + }, 253 + scrollContent: { 254 + flex: 1, 255 + }, 256 + scrollContentContainer: { 257 + padding: 24, 258 + }, 259 + blockContent: { 260 + flex: 1, 261 + }, 262 + blockTitle: { 263 + fontSize: 24, 264 + fontFamily: 'Lexend_700Bold', 265 + marginBottom: 8, 266 + }, 267 + blockDescription: { 268 + fontSize: 14, 269 + fontFamily: 'Lexend_400Regular', 270 + marginBottom: 16, 271 + lineHeight: 20, 272 + }, 273 + divider: { 274 + height: 1, 275 + backgroundColor: 'rgba(255, 255, 255, 0.1)', 276 + marginVertical: 20, 277 + }, 278 + }); 279 + 280 + export default MemoryBlockViewer;
+1
src/utils/storage.ts
··· 85 85 export const STORAGE_KEYS = { 86 86 API_TOKEN: 'ion_api_token', 87 87 AGENT_ID: 'ion_agent_id', 88 + CO_FOLDER_ID: 'co_folder_id', 88 89 } as const; 89 90 90 91 export default Storage;